├── .gitignore
├── LICENSE
├── README.md
├── electron
├── main.cjs
└── preload.js
├── forge.config.cjs
├── index.html
├── jsconfig.json
├── package.json
├── postcss.config.js
├── public
└── vite.svg
├── src
├── App.svelte
├── app.css
├── assets
│ ├── OllamaIconDark.png
│ ├── OllamaIconLight.png
│ ├── OpenAIIconDark.png
│ ├── OpenAIIconLight.png
│ ├── RunpodIconDark.png
│ ├── RunpodIconLight.png
│ ├── StudyCraftLogo.ico
│ ├── StudyCraftLogo.png
│ ├── StudyCraftLogo2Transparente.png
│ ├── StudyCraftLogo2TransparenteInv.png
│ ├── deck-icon.svg
│ ├── electron.svg
│ ├── notification-sound.wav
│ ├── svelte.svg
│ ├── tailwind.svg
│ └── test-icon.svg
├── electron.d.ts
├── lib
│ ├── Counter.svelte
│ ├── components
│ │ ├── AddCollection.svelte
│ │ ├── AddStudyMaterialModal.svelte
│ │ ├── AdvancedLLMOptions.svelte
│ │ ├── Calendar.svelte
│ │ ├── Collection.svelte
│ │ ├── CollectionHeader.svelte
│ │ ├── Collections.svelte
│ │ ├── ConfirmDialog.svelte
│ │ ├── CustomPrompts.svelte
│ │ ├── EditDeckModal.svelte
│ │ ├── EditTestModal.svelte
│ │ ├── File.svelte
│ │ ├── Flashcard.svelte
│ │ ├── FlashcardDeck.svelte
│ │ ├── GeneralOptions.svelte
│ │ ├── LLMConfiguration.svelte
│ │ ├── LLMExplanation.svelte
│ │ ├── LLMInstructionsModal.svelte
│ │ ├── MarkdownRenderer.svelte
│ │ ├── Modal.svelte
│ │ ├── OllamaConfig.svelte
│ │ ├── OpenAIConfig.svelte
│ │ ├── Options.svelte
│ │ ├── Popover.svelte
│ │ ├── ReviewMaterialsList.svelte
│ │ ├── RunpodConfig.svelte
│ │ ├── StudyDeckModal.svelte
│ │ ├── StudyMaterialsList.svelte
│ │ ├── StudyTimer.svelte
│ │ └── TimeInput.svelte
│ ├── index.ts
│ ├── services
│ │ ├── llmService.ts
│ │ └── webScraperService.ts
│ ├── stores
│ │ ├── collections.ts
│ │ ├── options.ts
│ │ └── timerStore.ts
│ ├── styles
│ │ └── markdown.css
│ ├── testData.ts
│ └── utils
│ │ └── fileUtils.ts
├── main.js
└── vite-env.d.ts
├── svelte.config.js
├── tailwind.config.js
├── tsconfig.json
└── vite.config.js
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 | package-lock.json
26 |
27 | out/
28 | electron/build
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "[]"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright [yyyy] [name of copyright owner]
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # StudyCraft
2 |
3 | StudyCraft is an open-source application developed to empower your learning journey, allowing you to group your study material into collections, automatically create flashcards, track your study sessions, and utilize AI integrations.
4 |
5 | Manage your study and review materials as you wish, grouping them into collections!
6 |
7 |
8 |
9 |
10 | Study using flashcards. You can ask LLMs for explanations!
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | After studying, review your knowledge in test mode!
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | Generate review material automatically from your study material! You can create either standard flashcards or test-type flashcards!
27 |
28 |
29 |
30 |
31 | Track your study sessions, a Pomodoro timer is available as well!
32 |
33 |
34 |
35 |
36 |
37 | We also got a dark theme!
38 |
39 |
40 |
41 |
42 | You can configure your preferred LLM, whether it’s a self-hosted model, Runpod, or OpenAI's API. You choose how to configure your study. StudyCraft is fully open-source, built with ❤️ with Svelte and Electron. In case you want to buy me a coffee, you can do it [here](https://ko-fi.com/rodmarkun).
43 |
--------------------------------------------------------------------------------
/electron/main.cjs:
--------------------------------------------------------------------------------
1 | // Modules to control application life and create native browser window
2 | const { log } = require('console');
3 | const { app, BrowserWindow, ipcMain, shell } = require('electron');
4 | const path = require('path');
5 | const fs = require('fs');
6 | const pdf = require('pdf-parse');
7 | const axios = require('axios')
8 |
9 | if (require('electron-squirrel-startup')) app.quit();
10 |
11 | const isDevEnvironment = process.env.DEV_ENV === 'true';
12 |
13 | // enable live reload for electron in dev mode
14 | if (isDevEnvironment) {
15 | require('electron-reload')(__dirname, {
16 | electron: path.join(__dirname, '..', 'node_modules', '.bin', 'electron'),
17 | hardResetMethod: 'exit'
18 | });
19 | }
20 |
21 | let mainWindow;
22 |
23 | const createWindow = () => {
24 |
25 | // Create the browser window.
26 | mainWindow = new BrowserWindow({
27 | width: 1300,
28 | height: 600,
29 | webPreferences: {
30 | preload: path.join(__dirname, 'preload.js'),
31 | contextIsolation: true,
32 | nodeIntegration: false
33 | },
34 | icon: path.join(__dirname, '..', 'src', 'assets', 'StudyCraftLogo.ico')
35 | })
36 |
37 | mainWindow.setMenu(null)
38 |
39 | // define how electron will load the app
40 | if (isDevEnvironment) {
41 |
42 | // if your vite app is running on a different port, change it here
43 | mainWindow.loadURL('http://localhost:5173/');
44 |
45 | // Open the DevTools.
46 | mainWindow.webContents.on("did-frame-finish-load", () => {
47 | mainWindow.webContents.openDevTools();
48 | });
49 |
50 | log('Electron running in dev mode: 🧪')
51 |
52 | } else {
53 |
54 | // when not in dev mode, load the build file instead
55 | mainWindow.loadFile(path.join(__dirname, '../dist/index.html'));
56 |
57 | log('Electron running in prod mode: 🚀')
58 | }
59 | }
60 |
61 | ipcMain.handle('saveFile', (event, content, fileName, collectionName) => {
62 | const userDataPath = app.getPath('userData');
63 | const collectionPath = path.join(userDataPath, 'collections', collectionName);
64 |
65 | if (!fs.existsSync(collectionPath)) {
66 | fs.mkdirSync(collectionPath, { recursive: true });
67 | }
68 |
69 | const filePath = path.join(collectionPath, fileName);
70 |
71 | if (content instanceof ArrayBuffer) {
72 | // Handle binary data (e.g., PDF)
73 | fs.writeFileSync(filePath, Buffer.from(content));
74 | } else {
75 | // Handle text data (e.g., Markdown)
76 | fs.writeFileSync(filePath, content, 'utf-8');
77 | }
78 |
79 | return filePath;
80 | });
81 |
82 | ipcMain.handle('readFile', (event, filePath) => {
83 | try {
84 | const userDataPath = app.getPath('userData');
85 | const collectionsPath = path.join(userDataPath, 'collections');
86 |
87 | let trueFilePath;
88 | if (filePath.startsWith(userDataPath)) {
89 | // If the filePath already includes the userData path, use it as is
90 | trueFilePath = filePath;
91 | } else {
92 | // If not, append it to the collections path
93 | trueFilePath = path.join(collectionsPath, filePath);
94 | }
95 |
96 | // Ensure the file is within the userData directory (security measure)
97 | if (!trueFilePath.startsWith(userDataPath)) {
98 | throw new Error('Access denied: Attempting to read file outside of userData directory');
99 | }
100 |
101 | const content = fs.readFileSync(trueFilePath, 'utf-8');
102 | return content;
103 | } catch (error) {
104 | console.error('Error reading file:', error);
105 | throw error;
106 | }
107 | });
108 |
109 | ipcMain.handle('deleteFile', (event, filePath) => {
110 | try {
111 | fs.unlinkSync(filePath);
112 | return true;
113 | } catch (error) {
114 | console.error('Error deleting file:', error);
115 | throw error;
116 | }
117 | });
118 |
119 | ipcMain.handle('openFile', (event, filePath) => {
120 | shell.openPath(filePath);
121 | });
122 |
123 | ipcMain.handle('parsePDF', async (event, filePath) => {
124 | try {
125 | console.log('Parsing PDF file:', filePath); // Add this log
126 | const dataBuffer = fs.readFileSync(filePath);
127 | const data = await pdf(dataBuffer);
128 | return data.text;
129 | } catch (error) {
130 | console.error('Error parsing PDF:', error);
131 | throw error;
132 | }
133 | });
134 |
135 | ipcMain.handle('saveStudySession', (event, session) => {
136 | const userDataPath = app.getPath('userData');
137 | const sessionsPath = path.join(userDataPath, 'studySessions.json');
138 |
139 | let sessions = [];
140 | if (fs.existsSync(sessionsPath)) {
141 | const content = fs.readFileSync(sessionsPath, 'utf-8');
142 | sessions = JSON.parse(content);
143 | }
144 |
145 | sessions.push(session);
146 | fs.writeFileSync(sessionsPath, JSON.stringify(sessions));
147 |
148 | return true;
149 | });
150 |
151 | ipcMain.handle('loadStudySessions', () => {
152 | const userDataPath = app.getPath('userData');
153 | const sessionsPath = path.join(userDataPath, 'studySessions.json');
154 |
155 | if (fs.existsSync(sessionsPath)) {
156 | const content = fs.readFileSync(sessionsPath, 'utf-8');
157 | return JSON.parse(content);
158 | }
159 |
160 | return [];
161 | });
162 |
163 | ipcMain.handle('deleteAllData', async () => {
164 | const userDataPath = app.getPath('userData');
165 | const collectionsPath = path.join(userDataPath, 'collections');
166 | const studySessionPath = path.join(userDataPath, 'studySessions.json');
167 |
168 | try {
169 | // Delete collections directory
170 | if (fs.existsSync(collectionsPath)) {
171 | fs.rmdirSync(collectionsPath, { recursive: true });
172 | }
173 |
174 | // Delete studySessions.json
175 | if (fs.existsSync(studySessionPath)) {
176 | fs.unlinkSync(studySessionPath);
177 | }
178 |
179 | return true;
180 | } catch (error) {
181 | console.error('Error deleting all data:', error);
182 | return false;
183 | }
184 | });
185 |
186 | ipcMain.handle('fetchWebContent', async (event, url) => {
187 | try {
188 | const response = await axios.get(url);
189 | return response.data;
190 | } catch (error) {
191 | console.error('Error fetching web content:', error);
192 | throw error;
193 | }
194 | });
195 |
196 | ipcMain.handle('renameCollectionFolder', async (event, oldName, newName) => {
197 | const userDataPath = app.getPath('userData');
198 | const oldPath = path.join(userDataPath, 'collections', oldName);
199 | const newPath = path.join(userDataPath, 'collections', newName);
200 |
201 | return new Promise((resolve, reject) => {
202 | fs.rename(oldPath, newPath, (err) => {
203 | if (err) {
204 | console.error('Error renaming collection folder:', err);
205 | reject(err);
206 | } else {
207 | // Update file paths within the renamed folder
208 | fs.readdir(newPath, (err, files) => {
209 | if (err) {
210 | console.error('Error reading directory:', err);
211 | reject(err);
212 | } else {
213 | files.forEach(file => {
214 | const oldFilePath = path.join(oldPath, file);
215 | const newFilePath = path.join(newPath, file);
216 | fs.rename(oldFilePath, newFilePath, err => {
217 | if (err) console.error('Error renaming file:', err);
218 | });
219 | });
220 | resolve(true);
221 | }
222 | });
223 | }
224 | });
225 | });
226 | });
227 |
228 | ipcMain.handle('createCollectionFolder', async (event, name) => {
229 | const userDataPath = app.getPath('userData');
230 | const collectionPath = path.join(userDataPath, 'collections', name);
231 |
232 | return new Promise((resolve, reject) => {
233 | fs.mkdir(collectionPath, { recursive: true }, (err) => {
234 | if (err) {
235 | console.error('Error creating collection folder:', err);
236 | reject(err);
237 | } else {
238 | resolve(true);
239 | }
240 | });
241 | });
242 | });
243 |
244 | ipcMain.on('exitApp', () => {
245 | app.exit(0);
246 | });
247 |
248 |
249 |
250 | // This method will be called when Electron has finished
251 | // initialization and is ready to create browser windows.
252 | // Some APIs can only be used after this event occurs.
253 | app.on('ready', createWindow);
254 |
255 | app.on('window-all-closed', () => {
256 | if (process.platform !== 'darwin') app.quit();
257 | });
258 |
259 | app.on('activate', () => {
260 | if (BrowserWindow.getAllWindows().length === 0) createWindow();
261 | });
262 |
263 | // Quit when all windows are closed, except on macOS. There, it's common
264 | // for applications and their menu bar to stay active until the user quits
265 | // explicitly with Cmd + Q.
266 | // app.on('window-all-closed', () => {
267 | // if (process.platform !== 'darwin') app.quit()
268 | // })
269 |
270 | // In this file you can include the rest of your app's specific main process
271 | // code. You can also put them in separate files and require them here.
--------------------------------------------------------------------------------
/electron/preload.js:
--------------------------------------------------------------------------------
1 | const { contextBridge, ipcRenderer } = require('electron')
2 |
3 | const api = {
4 | node: () => process.versions.node,
5 | chrome: () => process.versions.chrome,
6 | electron: () => process.versions.electron
7 | }
8 |
9 | contextBridge.exposeInMainWorld('api', api)
10 |
11 | contextBridge.exposeInMainWorld('electronAPI', {
12 | saveFile: (content, fileName, collectionName) =>
13 | ipcRenderer.invoke('saveFile', content, fileName, collectionName),
14 | readFile: (filePath) =>
15 | ipcRenderer.invoke('readFile', filePath),
16 | deleteFile: (filePath) =>
17 | ipcRenderer.invoke('deleteFile', filePath),
18 | saveStudySession: (session) =>
19 | ipcRenderer.invoke('saveStudySession', session),
20 | loadStudySessions: () =>
21 | ipcRenderer.invoke('loadStudySessions'),
22 | openFile: (filePath) => ipcRenderer.invoke('openFile', filePath),
23 | deleteAllData: () => ipcRenderer.invoke('deleteAllData'),
24 | exitApp: () => ipcRenderer.send('exitApp'),
25 | parsePDF: (filePath) => ipcRenderer.invoke('parsePDF', filePath),
26 | fetchWebContent: (url) => ipcRenderer.invoke('fetchWebContent', url),
27 | renameCollectionFolder: (oldName, newName) => ipcRenderer.invoke('renameCollectionFolder', oldName, newName),
28 | createCollectionFolder: (name) => ipcRenderer.invoke('createCollectionFolder', name),
29 | });
--------------------------------------------------------------------------------
/forge.config.cjs:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 |
3 | module.exports = {
4 | packagerConfig: {
5 | asar: true,
6 | icon: path.join(__dirname, 'src', 'assets', 'StudyCraftLogo') // no file extension
7 | },
8 | rebuildConfig: {},
9 | makers: [
10 | {
11 | name: '@electron-forge/maker-squirrel',
12 | config: {
13 | iconUrl: 'https://url/to/icon.ico', // Replace with a URL to your icon
14 | setupIcon: path.join(__dirname, 'src', 'assets', 'StudyCraftLogo.ico')
15 | },
16 | },
17 | {
18 | name: '@electron-forge/maker-zip',
19 | platforms: ['darwin'],
20 | },
21 | {
22 | name: '@electron-forge/maker-deb',
23 | config: {
24 | options: {
25 | icon: path.join(__dirname, 'src', 'assets', 'StudyCraftLogo.png')
26 | }
27 | },
28 | },
29 | {
30 | name: '@electron-forge/maker-rpm',
31 | config: {
32 | options: {
33 | icon: path.join(__dirname, 'src', 'assets', 'StudyCraftLogo.png')
34 | }
35 | },
36 | },
37 | ],
38 | plugins: [
39 | {
40 | name: '@electron-forge/plugin-vite',
41 | config: {
42 | build: [
43 | {
44 | entry: 'electron/main.cjs',
45 | config: 'vite.config.js',
46 | },
47 | ],
48 | renderer: [
49 | {
50 | name: 'main_window',
51 | config: 'vite.config.js',
52 | },
53 | ],
54 | },
55 | },
56 | ],
57 | };
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | StudyCraft
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "moduleResolution": "bundler",
4 | "target": "ESNext",
5 | "module": "ESNext",
6 | /**
7 | * svelte-preprocess cannot figure out whether you have
8 | * a value or a type, so tell TypeScript to enforce using
9 | * `import type` instead of `import` for Types.
10 | */
11 | "verbatimModuleSyntax": true,
12 | "isolatedModules": true,
13 | "resolveJsonModule": true,
14 | /**
15 | * To have warnings / errors of the Svelte compiler at the
16 | * correct position, enable source maps by default.
17 | */
18 | "sourceMap": true,
19 | "esModuleInterop": true,
20 | "skipLibCheck": true,
21 | /**
22 | * Typecheck JS in `.svelte` and `.js` files by default.
23 | * Disable this if you'd like to use dynamic types.
24 | */
25 | "checkJs": false
26 | },
27 | /**
28 | * Use global.d.ts instead of compilerOptions.types
29 | * to avoid limiting type declarations.
30 | */
31 | "include": ["src/**/*.d.ts", "src/**/*.js", "src/**/*.svelte"]
32 | }
33 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "studycraft",
3 | "productName": "StudyCraft",
4 | "description": "An efficient solution to studying.",
5 | "license": "MIT",
6 | "private": true,
7 | "version": "1.1.0",
8 | "type": "module",
9 | "main": "electron/main.cjs",
10 | "author": {
11 | "name": "Rodmar",
12 | "email": "rodmarprogrammer@gmail.com"
13 | },
14 | "scripts": {
15 | "dev": "concurrently --kill-others \"vite\" \"npm run dev:electron\"",
16 | "dev:vite": "vite",
17 | "dev:electron": "cross-env DEV_ENV=true electron .",
18 | "build": "vite build",
19 | "preview": "vite preview",
20 | "prepackage": "rimraf --max-tries=10 dist && rimraf --max-tries=10 out",
21 | "package": "npm run prepackage && npm run build && electron-forge package",
22 | "make": "npm run prepackage && npm run build && electron-forge make"
23 | },
24 | "build": {
25 | "appId": "com.Rodmar.StudyCraft"
26 | },
27 | "devDependencies": {
28 | "@electron-forge/cli": "^6.1.1",
29 | "@electron-forge/maker-deb": "^6.1.1",
30 | "@electron-forge/maker-rpm": "^6.1.1",
31 | "@electron-forge/maker-squirrel": "^6.1.1",
32 | "@electron-forge/maker-zip": "^6.1.1",
33 | "@electron-forge/plugin-vite": "^6.1.1",
34 | "@sveltejs/vite-plugin-svelte": "^2.0.3",
35 | "autoprefixer": "^10.4.14",
36 | "concurrently": "^8.0.1",
37 | "cross-env": "^7.0.3",
38 | "electron": "^24.3.1",
39 | "electron-reload": "^2.0.0-alpha.1",
40 | "jsdom": "^24.1.1",
41 | "pdfjs-dist": "^4.4.168",
42 | "postcss": "^8.4.23",
43 | "svelte": "^3.57.0",
44 | "tailwindcss": "^3.3.2",
45 | "vite": "^4.3.2"
46 | },
47 | "dependencies": {
48 | "@types/marked": "^6.0.0",
49 | "@types/turndown": "^5.0.4",
50 | "@xenova/transformers": "^2.17.2",
51 | "axios": "^1.7.2",
52 | "dompurify": "^3.1.5",
53 | "electron-squirrel-startup": "^1.0.0",
54 | "lucide-react": "^0.400.0",
55 | "lucide-svelte": "^0.399.0",
56 | "marked": "^13.0.1",
57 | "pdf-parse": "^1.1.1",
58 | "turndown": "^7.2.0"
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/App.svelte:
--------------------------------------------------------------------------------
1 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
78 |
79 |
80 |
81 |
82 |
86 | {#if darkMode}
87 | 🌙
88 | {:else}
89 | ☀️
90 | {/if}
91 |
92 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
--------------------------------------------------------------------------------
/src/app.css:
--------------------------------------------------------------------------------
1 | /* src/app.css */
2 | @import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;700&family=Inter:wght@400;600&display=swap');
3 |
4 | @tailwind base;
5 | @tailwind components;
6 | @tailwind utilities;
7 |
8 | @layer base {
9 | h1, h2, h3, h4, h5, h6 {
10 | @apply font-header;
11 | }
12 | }
13 |
14 | body {
15 | @apply font-body;
16 | }
17 |
18 | /* :root {
19 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
20 | line-height: 1.5;
21 | font-weight: 400;
22 |
23 | color-scheme: light dark;
24 | color: rgba(255, 255, 255, 0.87);
25 | background-color: #242424;
26 |
27 | font-synthesis: none;
28 | text-rendering: optimizeLegibility;
29 | -webkit-font-smoothing: antialiased;
30 | -moz-osx-font-smoothing: grayscale;
31 | -webkit-text-size-adjust: 100%;
32 | }
33 |
34 | a {
35 | font-weight: 500;
36 | color: #646cff;
37 | text-decoration: inherit;
38 | }
39 | a:hover {
40 | color: #535bf2;
41 | }
42 |
43 | body {
44 | margin: 0;
45 | display: flex;
46 | place-items: center;
47 | min-width: 320px;
48 | min-height: 100vh;
49 | }
50 |
51 | h1 {
52 | font-size: 3.2em;
53 | line-height: 1.1;
54 | }
55 |
56 | .card {
57 | padding: 2em;
58 | }
59 |
60 | #app {
61 | max-width: 1280px;
62 | margin: 0 auto;
63 | padding: 2rem;
64 | text-align: center;
65 | }
66 |
67 | button {
68 | border-radius: 8px;
69 | border: 1px solid transparent;
70 | padding: 0.6em 1.2em;
71 | font-size: 1em;
72 | font-weight: 500;
73 | font-family: inherit;
74 | background-color: #1a1a1a;
75 | cursor: pointer;
76 | transition: border-color 0.25s;
77 | }
78 | button:hover {
79 | border-color: #646cff;
80 | }
81 | button:focus,
82 | button:focus-visible {
83 | outline: 4px auto -webkit-focus-ring-color;
84 | }
85 |
86 | @media (prefers-color-scheme: light) {
87 | :root {
88 | color: #213547;
89 | background-color: #ffffff;
90 | }
91 | a:hover {
92 | color: #747bff;
93 | }
94 | button {
95 | background-color: #f9f9f9;
96 | }
97 | } */
98 |
--------------------------------------------------------------------------------
/src/assets/OllamaIconDark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rodmarkun/StudyCraft/c0ff4cd5f186fe3d5f3fae0f2d8a8690698a43f4/src/assets/OllamaIconDark.png
--------------------------------------------------------------------------------
/src/assets/OllamaIconLight.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rodmarkun/StudyCraft/c0ff4cd5f186fe3d5f3fae0f2d8a8690698a43f4/src/assets/OllamaIconLight.png
--------------------------------------------------------------------------------
/src/assets/OpenAIIconDark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rodmarkun/StudyCraft/c0ff4cd5f186fe3d5f3fae0f2d8a8690698a43f4/src/assets/OpenAIIconDark.png
--------------------------------------------------------------------------------
/src/assets/OpenAIIconLight.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rodmarkun/StudyCraft/c0ff4cd5f186fe3d5f3fae0f2d8a8690698a43f4/src/assets/OpenAIIconLight.png
--------------------------------------------------------------------------------
/src/assets/RunpodIconDark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rodmarkun/StudyCraft/c0ff4cd5f186fe3d5f3fae0f2d8a8690698a43f4/src/assets/RunpodIconDark.png
--------------------------------------------------------------------------------
/src/assets/RunpodIconLight.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rodmarkun/StudyCraft/c0ff4cd5f186fe3d5f3fae0f2d8a8690698a43f4/src/assets/RunpodIconLight.png
--------------------------------------------------------------------------------
/src/assets/StudyCraftLogo.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rodmarkun/StudyCraft/c0ff4cd5f186fe3d5f3fae0f2d8a8690698a43f4/src/assets/StudyCraftLogo.ico
--------------------------------------------------------------------------------
/src/assets/StudyCraftLogo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rodmarkun/StudyCraft/c0ff4cd5f186fe3d5f3fae0f2d8a8690698a43f4/src/assets/StudyCraftLogo.png
--------------------------------------------------------------------------------
/src/assets/StudyCraftLogo2Transparente.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rodmarkun/StudyCraft/c0ff4cd5f186fe3d5f3fae0f2d8a8690698a43f4/src/assets/StudyCraftLogo2Transparente.png
--------------------------------------------------------------------------------
/src/assets/StudyCraftLogo2TransparenteInv.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rodmarkun/StudyCraft/c0ff4cd5f186fe3d5f3fae0f2d8a8690698a43f4/src/assets/StudyCraftLogo2TransparenteInv.png
--------------------------------------------------------------------------------
/src/assets/deck-icon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/electron.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/notification-sound.wav:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rodmarkun/StudyCraft/c0ff4cd5f186fe3d5f3fae0f2d8a8690698a43f4/src/assets/notification-sound.wav
--------------------------------------------------------------------------------
/src/assets/svelte.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/tailwind.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/src/assets/test-icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
5 |
6 |
17 |
19 |
21 |
27 |
28 |
30 |
31 |
32 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/src/electron.d.ts:
--------------------------------------------------------------------------------
1 | // src/electron.d.ts
2 | interface ElectronAPI {
3 | saveFile: (content: string, fileName: string, collectionName: string) => Promise;
4 | readFile: (filePath: string) => Promise;
5 | deleteFile: (filePath: string) => Promise;
6 | }
7 |
8 | declare global {
9 | interface Window {
10 | electronAPI: ElectronAPI;
11 | }
12 | }
--------------------------------------------------------------------------------
/src/lib/Counter.svelte:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 | count is {count}
10 |
11 |
--------------------------------------------------------------------------------
/src/lib/components/AddCollection.svelte:
--------------------------------------------------------------------------------
1 |
2 |
39 |
40 |
--------------------------------------------------------------------------------
/src/lib/components/AddStudyMaterialModal.svelte:
--------------------------------------------------------------------------------
1 |
128 |
129 |
130 |
131 | {#if errorMessage}
132 |
133 | Error!
134 | {errorMessage}
135 |
136 | {/if}
137 |
138 |
139 | Add Webpage URL
140 |
147 |
148 |
149 |
156 |
164 |
168 | Click to upload
169 |
170 |
or drag and drop
171 |
PDF, MD files are allowed
172 |
173 |
174 | {#if uploadedFiles.length > 0}
175 |
176 |
Uploaded Files:
177 |
178 | {#each uploadedFiles as file}
179 |
180 | {file.name}
181 | removeFile(file.name)}
183 | class="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300"
184 | >
185 | Remove
186 |
187 |
188 | {/each}
189 |
190 |
191 | {/if}
192 |
193 |
197 | Add Materials
198 |
199 |
200 |
--------------------------------------------------------------------------------
/src/lib/components/AdvancedLLMOptions.svelte:
--------------------------------------------------------------------------------
1 |
39 |
40 |
92 |
93 |
--------------------------------------------------------------------------------
/src/lib/components/Calendar.svelte:
--------------------------------------------------------------------------------
1 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 | {currentDate.toLocaleString('default', { month: 'long', year: 'numeric' })}
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 | {#each ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as day}
117 |
{day}
118 | {/each}
119 | {#each calendarDays as day, i}
120 |
day && handleDayHover(e, day)}
125 | on:mouseleave={handleDayLeave}
126 | >
127 | {day ? day.getDate() : ''}
128 |
129 | {/each}
130 |
131 |
132 |
133 | {#if hoveredDay && hoveredSessions.length > 0}
134 |
138 |
Study sessions on {hoveredDay}:
139 |
140 | {#each hoveredSessions as session}
141 |
142 | {new Date(session.startTime).toLocaleTimeString()} - {new Date(session.endTime).toLocaleTimeString()}
143 | (Duration: {formatDuration(session.duration)})
144 |
145 | {/each}
146 |
147 |
148 | {/if}
--------------------------------------------------------------------------------
/src/lib/components/CollectionHeader.svelte:
--------------------------------------------------------------------------------
1 |
46 |
47 |
48 | {#if showRenameInput}
49 |
e.key === 'Enter' && confirmRename()}
52 | on:blur={confirmRename}
53 | class="text-2xl font-semibold truncate flex-grow bg-transparent border-b border-gray-300 dark:border-gray-600 focus:outline-none focus:border-blue-500"
54 | />
55 | {:else}
56 |
{name}
57 | {/if}
58 |
59 |
65 |
66 |
67 |
68 |
72 | Rename Collection
73 |
74 |
78 | Remove Collection
79 |
80 |
81 |
82 |
83 |
84 | {#if showConfirmDialog}
85 |
91 | {/if}
--------------------------------------------------------------------------------
/src/lib/components/Collections.svelte:
--------------------------------------------------------------------------------
1 |
37 |
38 |
56 |
57 | Collections
58 |
59 |
60 | {#each sortedCollections as collection (collection.id)}
61 |
62 | handleRenameCollection({detail: {id: collection.id, name: event.detail}})}
65 | on:exportCollection={() => handleExportCollection({detail: {id: collection.id}})}
66 | on:importCollection={handleImportCollection}
67 | />
68 |
69 | {/each}
70 |
71 | {#if sortedCollections.length === 0}
72 | No collections yet. Add one to get started!
73 | {/if}
--------------------------------------------------------------------------------
/src/lib/components/ConfirmDialog.svelte:
--------------------------------------------------------------------------------
1 |
2 |
20 |
21 |
22 |
23 |
{title}
24 |
{message}
25 |
26 |
30 | {cancelText}
31 |
32 |
36 | {confirmText}
37 |
38 |
39 |
40 |
--------------------------------------------------------------------------------
/src/lib/components/CustomPrompts.svelte:
--------------------------------------------------------------------------------
1 |
16 |
17 |
18 |
19 |
20 |
Flashcard Question Prompt
21 |
26 |
27 |
28 |
Flashcard Answer Prompt
29 |
34 |
35 |
36 |
Explanation Prompt
37 |
42 |
43 |
47 | Restore Default Prompts
48 |
49 |
50 |
--------------------------------------------------------------------------------
/src/lib/components/EditTestModal.svelte:
--------------------------------------------------------------------------------
1 |
104 |
105 |
106 |
107 |
113 |
114 | {#if errorMessage}
115 |
116 | Error!
117 | {errorMessage}
118 |
119 | {/if}
120 |
121 |
Questions
122 | {#each editedTest.questions as question, index}
123 |
124 |
{question.question}
125 |
126 | {#each question.options as option, optionIndex}
127 | {option}
128 | {/each}
129 |
130 |
removeQuestion(index)}
133 | >
134 | Remove
135 |
136 |
137 | {/each}
138 |
139 |
140 |
141 |
146 | Generate Questions
147 |
148 |
149 |
Generate Questions
150 |
151 | Select a study material and specify the number of questions you want to generate.
152 | The AI will create questions based on the content of the selected material.
153 |
154 |
158 | Select a study material
159 | {#each studyMaterials as material}
160 | {material.name}
161 | {/each}
162 |
163 |
164 |
165 | Number of questions to generate:
166 |
167 |
175 |
176 |
181 | {isGenerating ? 'Generating...' : 'Generate Questions'}
182 |
183 |
184 |
185 |
186 |
187 |
Add New Question
188 |
194 | {#each newQuestion.options as option, index}
195 |
196 |
202 | newQuestion.correctOptionIndex = index}
206 | >
207 | {newQuestion.correctOptionIndex === index ? 'Correct' : 'Set Correct'}
208 |
209 |
210 | {/each}
211 |
215 | Add Question
216 |
217 |
218 |
222 | Cancel
223 |
224 |
228 | Save Test
229 |
230 |
231 |
232 |
--------------------------------------------------------------------------------
/src/lib/components/File.svelte:
--------------------------------------------------------------------------------
1 |
2 |
14 |
15 |
16 |
20 |
21 | {#if fileType === 'pdf'}
22 | 📄
23 | {:else}
24 | 📝
25 | {/if}
26 |
27 | {fileName}
28 |
29 |
34 |
35 |
36 |
37 |
38 |
--------------------------------------------------------------------------------
/src/lib/components/Flashcard.svelte:
--------------------------------------------------------------------------------
1 |
2 |
12 |
13 |
14 |
Flashcard
15 |
{question}
16 |
20 | {showAnswer ? 'Hide Answer' : 'Show Answer'}
21 |
22 | {#if showAnswer}
23 |
{answer}
24 | {/if}
25 |
--------------------------------------------------------------------------------
/src/lib/components/FlashcardDeck.svelte:
--------------------------------------------------------------------------------
1 |
2 |
42 |
43 |
44 |
{deck.name}
45 |
Total cards: {deck.flashcards.length}
46 |
47 | {#if isAddingCard}
48 |
76 | {:else}
77 |
81 | Add New Card
82 |
83 | {/if}
84 |
85 |
86 | {#each deck.flashcards as card}
87 |
88 | {card.question}
89 | removeFlashcard(card.id)}
91 | class="px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600"
92 | >
93 | Remove
94 |
95 |
96 | {/each}
97 |
98 |
--------------------------------------------------------------------------------
/src/lib/components/GeneralOptions.svelte:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
11 |
12 | handleChange('simplifiedMaterialView', !$options.simplifiedMaterialView)}
16 | class="form-checkbox h-5 w-5 text-blue-600"
17 | />
18 | Simplified Study Material View
19 |
20 |
21 |
--------------------------------------------------------------------------------
/src/lib/components/LLMConfiguration.svelte:
--------------------------------------------------------------------------------
1 |
36 |
37 |
38 |
39 |
40 |
handleChange('provider', 'none')}
43 | >
44 |
45 |
46 |
handleChange('provider', 'ollama')}
49 | >
50 |
51 |
52 |
handleChange('provider', 'runpod')}
55 | >
56 |
57 |
58 |
handleChange('provider', 'openai')}
61 | >
62 |
63 |
64 |
65 | {#if localAIConfig.provider && localAIConfig.provider !== 'none'}
66 |
showInstructions(localAIConfig.provider)}
69 | >
70 |
71 | How to use {localAIConfig.provider}
72 |
73 | {#if localAIConfig.provider === 'ollama'}
74 |
75 |
76 | Model
77 | handleChange('ollama.model', e.target.value)}
81 | class="custom-input"
82 | />
83 |
84 |
85 | Port
86 | handleChange('ollama.port', parseInt(e.target.value))}
90 | class="custom-input"
91 | />
92 |
93 |
94 | {:else if localAIConfig.provider === 'runpod'}
95 |
96 |
97 | API Key
98 | handleChange('runpod.apiKey', e.target.value)}
102 | class="custom-input"
103 | />
104 |
105 |
106 | Serverless API ID
107 | handleChange('runpod.serverlessApiId', e.target.value)}
111 | class="custom-input"
112 | />
113 |
114 |
115 | {:else if localAIConfig.provider === 'openai'}
116 |
117 |
118 | API Key
119 | handleChange('openai.apiKey', e.target.value)}
123 | class="custom-input"
124 | />
125 |
126 |
127 | Model
128 | handleChange('openai.model', e.target.value)}
132 | class="custom-input"
133 | />
134 |
135 |
136 | {/if}
137 | {/if}
138 |
139 |
140 |
141 |
--------------------------------------------------------------------------------
/src/lib/components/LLMExplanation.svelte:
--------------------------------------------------------------------------------
1 |
32 |
33 |
34 |
35 |
40 |
41 | {#if !isLLMConfigured}
42 | LLM Not Configured
43 | {:else if loadingExplanation}
44 | Thinking...
45 | {:else}
46 | Ask for Explanation
47 | {/if}
48 |
49 |
50 | {#if explanation}
51 |
52 |
Explanation:
53 |
{explanation}
54 |
55 | {/if}
56 |
--------------------------------------------------------------------------------
/src/lib/components/LLMInstructionsModal.svelte:
--------------------------------------------------------------------------------
1 |
2 |
49 |
50 |
51 |
52 | {@html instructions[provider] || 'No instructions available for this provider.'}
53 |
54 |
--------------------------------------------------------------------------------
/src/lib/components/MarkdownRenderer.svelte:
--------------------------------------------------------------------------------
1 |
2 |
31 |
32 |
33 | {@html renderedContent}
34 |
35 |
36 |
--------------------------------------------------------------------------------
/src/lib/components/Modal.svelte:
--------------------------------------------------------------------------------
1 |
2 |
14 |
15 |
35 |
36 | {#if isOpen}
37 |
38 |
39 |
40 |
{title}
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 | {/if}
--------------------------------------------------------------------------------
/src/lib/components/OllamaConfig.svelte:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
11 | Ollama Model
12 | handleChange('model', $options.aiConfig.ollama.model)}
16 | class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50 text-base"
17 | />
18 |
19 |
20 | Ollama Port
21 | handleChange('port', $options.aiConfig.ollama.port)}
25 | class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50"
26 | />
27 |
28 |
--------------------------------------------------------------------------------
/src/lib/components/OpenAIConfig.svelte:
--------------------------------------------------------------------------------
1 |
21 |
22 |
23 |
24 | OpenAI API Key
25 | handleChange('apiKey', apiKey)}
29 | class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50 text-base"
30 | />
31 |
32 |
33 | Model
34 | handleChange('model', model)}
37 | class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50"
38 | >
39 | GPT-3.5 Turbo
40 | GPT-4 (if available)
41 |
42 |
43 |
--------------------------------------------------------------------------------
/src/lib/components/Options.svelte:
--------------------------------------------------------------------------------
1 |
43 |
44 |
73 |
74 |
75 |
76 |
87 | {#if generalOptionsOpen}
88 |
89 |
90 |
91 | {/if}
92 |
93 |
94 |
95 |
106 | {#if llmConfigOpen}
107 |
108 |
109 |
110 |
111 |
112 |
113 | {/if}
114 |
115 |
116 |
117 |
118 |
Danger Zone
119 |
124 | {isDeleting ? 'Deleting...' : 'Delete All Data'}
125 |
126 |
127 |
128 |
129 |
130 | {#if showConfirmDialog}
131 | showConfirmDialog = false}
137 | />
138 | {/if}
--------------------------------------------------------------------------------
/src/lib/components/Popover.svelte:
--------------------------------------------------------------------------------
1 |
55 |
56 |
57 |
58 |
59 |
60 |
61 | {#if open}
62 |
71 | {/if}
72 |
73 |
74 |
91 |
--------------------------------------------------------------------------------
/src/lib/components/ReviewMaterialsList.svelte:
--------------------------------------------------------------------------------
1 |
2 |
34 |
35 |
36 | {#each reviewMaterials as material (material.id)}
37 |
38 |
39 | {#if 'flashcards' in material}
40 |
41 |
{material.name}
42 |
43 | {material.flashcards.length} card{material.flashcards.length !== 1 ? 's' : ''}
44 |
45 | {:else if 'questions' in material}
46 |
47 |
{material.name}
48 |
49 | {material.questions.length} question{material.questions.length !== 1 ? 's' : ''}
50 |
51 | {/if}
52 |
53 |
54 | handleStudy(material)}
56 | class="px-3 py-1 bg-blue-500 text-white rounded mb-2 hover:bg-blue-600 w-3/4 disabled:opacity-50 disabled:cursor-not-allowed"
57 | disabled={!canStudyMaterial(material)}
58 | >
59 | Study
60 |
61 | handleEdit(material)}
63 | class="px-3 py-1 bg-green-500 text-white rounded hover:bg-green-600 w-3/4 mb-2"
64 | >
65 | Edit
66 |
67 | handleRemove(material)}
69 | class="px-3 py-1 bg-red-500 text-white rounded hover:bg-red-600 w-3/4"
70 | >
71 | Delete
72 |
73 |
74 |
75 | {/each}
76 |
--------------------------------------------------------------------------------
/src/lib/components/RunpodConfig.svelte:
--------------------------------------------------------------------------------
1 |
21 |
22 |
23 |
24 | Runpod API Key
25 | handleChange('apiKey', apiKey)}
29 | class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50 text-base"
30 | />
31 |
32 |
33 | Serverless API ID
34 | handleChange('serverlessApiId', serverlessApiId)}
38 | class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50"
39 | />
40 |
41 |
--------------------------------------------------------------------------------
/src/lib/components/StudyDeckModal.svelte:
--------------------------------------------------------------------------------
1 |
122 |
123 |
124 | {#if deck && shuffledCards.length > 0}
125 | {#if !isStudyStarted}
126 |
127 |
Ready to study?
128 |
129 | This deck contains {shuffledCards.length} flashcard{shuffledCards.length !== 1 ? 's' : ''}.
130 |
131 |
132 |
133 | Shuffle deck
134 |
135 |
136 |
137 | Test mode
138 |
139 |
143 | Start Reviewing
144 |
145 |
146 | {:else if showTestResults}
147 |
148 |
Test Results
149 |
150 | Correct: {testResults.correct}
151 | Kinda: {testResults.kinda}
152 | Wrong: {testResults.wrong}
153 |
154 |
158 | Start Over
159 |
160 |
161 | {:else}
162 |
163 |
164 |
Question:
165 |
166 | {shuffledCards[currentIndex].question}
167 |
168 | {#if isTestMode && isTestFlashcard(shuffledCards[currentIndex])}
169 | {#each JSON.parse(shuffledCards[currentIndex].answer).options as option, index}
170 |
handleTestChoice(index)}
172 | class="w-full p-2 mb-2 text-left {getButtonClass(index, shuffledCards[currentIndex])} rounded"
173 | disabled={selectedAnswer !== null}
174 | >
175 | {option}
176 |
177 | {/each}
178 | {#if selectedAnswer !== null}
179 |
183 | Next
184 |
185 | {/if}
186 | {:else if showAnswer}
187 |
Answer:
188 |
189 | {getCorrectAnswer(shuffledCards[currentIndex])}
190 |
191 |
195 | {#if isTestMode}
196 |
197 | handleTestAnswer('wrong')}
199 | class="px-6 py-2 bg-red-500 text-white rounded-full hover:bg-red-600 transition-colors duration-200"
200 | >
201 | Wrong
202 |
203 | handleTestAnswer('kinda')}
205 | class="px-6 py-2 bg-yellow-500 text-white rounded-full hover:bg-yellow-600 transition-colors duration-200"
206 | >
207 | Kinda
208 |
209 | handleTestAnswer('correct')}
211 | class="px-6 py-2 bg-green-500 text-white rounded-full hover:bg-green-600 transition-colors duration-200"
212 | >
213 | Got it!
214 |
215 |
216 | {/if}
217 | {/if}
218 |
219 | {#if !isTestMode || (isTestMode && !isTestFlashcard(shuffledCards[currentIndex]))}
220 |
224 | {showAnswer ? 'Hide Answer' : 'Show Answer'}
225 |
226 | {/if}
227 |
228 | {#if !isTestMode}
229 |
234 | Previous
235 |
236 | {:else}
237 |
238 | {/if}
239 |
240 | {currentIndex + 1} / {shuffledCards.length}
241 |
242 | {#if !isTestMode}
243 |
248 | Next
249 |
250 | {:else}
251 |
252 | {/if}
253 |
254 |
255 | {/if}
256 | {:else}
257 | No flashcards in this deck.
258 | {/if}
259 |
--------------------------------------------------------------------------------
/src/lib/components/StudyMaterialsList.svelte:
--------------------------------------------------------------------------------
1 |
82 |
83 |
90 |
91 |
92 | {#each studyMaterials as material (material.id)}
93 | {#if $options.simplifiedMaterialView}
94 |
handleViewFile(material)}
97 | >
98 |
99 |
100 |
101 | {getMaterialName(material)}
102 |
103 |
104 |
confirmDelete(material)}
106 | class="flex-shrink-0 p-1 ml-2 text-gray-500 hover:text-red-500 dark:text-gray-400 dark:hover:text-red-400 focus:outline-none transition-colors duration-150"
107 | title="Remove"
108 | >
109 |
110 |
111 |
112 | {:else}
113 |
114 |
handleViewFile(material)}
117 | >
118 |
119 |
{getMaterialName(material)}
120 |
121 |
confirmDelete(material)}
123 | class="absolute top-2 right-2 p-2 bg-white dark:bg-gray-600 text-gray-600 dark:text-gray-300 rounded-full opacity-0 group-hover:opacity-100 transition-all duration-300 hover:bg-gray-200 dark:hover:bg-gray-500 focus:outline-none focus:ring-2 focus:ring-gray-300 dark:focus:ring-gray-400"
124 | title="Remove"
125 | >
126 |
127 |
128 |
129 | {/if}
130 | {/each}
131 |
132 |
133 | {#if showDeleteConfirm}
134 |
135 |
136 |
137 |
138 |
Confirm Deletion
139 |
140 |
141 | Are you sure you want to delete "{materialToDelete?.name}"? This action cannot be undone.
142 |
143 |
144 | showDeleteConfirm = false}
146 | class="px-4 py-2 bg-gray-200 text-gray-800 dark:bg-gray-700 dark:text-gray-200 rounded hover:bg-gray-300 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-gray-300 dark:focus:ring-gray-500"
147 | >
148 | Cancel
149 |
150 |
154 | Delete
155 |
156 |
157 |
158 |
159 | {/if}
--------------------------------------------------------------------------------
/src/lib/components/StudyTimer.svelte:
--------------------------------------------------------------------------------
1 |
86 |
87 |
88 |
{displayTime}
89 |
90 |
95 | {#if $timerStore.isRunning}
96 |
97 |
98 |
99 | {:else}
100 |
101 |
102 |
103 |
104 | {/if}
105 |
106 |
111 |
112 |
113 |
114 |
115 |
116 |
117 | popoverOpen = !popoverOpen}
120 | class="p-1.5 rounded-full bg-white dark:bg-gray-800 text-gray-800 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 dark:focus:ring-gray-400 transition-colors duration-200"
121 | class:text-gray-900={$timerStore.isPomodoroMode}
122 | title="Pomodoro Settings"
123 | >
124 |
125 |
126 |
127 |
128 |
129 |
130 |
137 | Pomodoro Mode
138 |
139 |
140 |
141 | Pomodoro Length
142 |
143 |
144 |
145 |
146 |
147 | Rest Length
148 |
149 |
150 |
151 |
152 |
153 |
154 |
--------------------------------------------------------------------------------
/src/lib/components/TimeInput.svelte:
--------------------------------------------------------------------------------
1 |
14 |
15 |
16 |
24 | :
25 |
33 | :
34 |
42 |
--------------------------------------------------------------------------------
/src/lib/index.ts:
--------------------------------------------------------------------------------
1 | // place files you want to import through the `$lib` alias in this folder.
2 |
--------------------------------------------------------------------------------
/src/lib/services/llmService.ts:
--------------------------------------------------------------------------------
1 | // src/lib/services/llmService.ts
2 |
3 | import { get } from 'svelte/store';
4 | import { options } from '../stores/options';
5 | import { cleanPDFContent } from '../utils/fileUtils'
6 |
7 | export interface LLMResponse {
8 | response: string;
9 | }
10 |
11 | function getCurrentOptions() {
12 | return get(options);
13 | }
14 |
15 | export async function getLLMExplanation(question: string, answer: string): Promise {
16 | const currentOptions = getCurrentOptions();
17 | const { aiConfig, customPrompts } = currentOptions;
18 |
19 | if (aiConfig.provider === 'none') {
20 | return "LLM is not configured. Please set up an LLM provider in the options.";
21 | }
22 |
23 | const prompt = customPrompts.explanationPrompt
24 | .replace('{question}', question)
25 | .replace('{answer}', answer);
26 |
27 | try {
28 | let response;
29 | switch (aiConfig.provider) {
30 | case 'ollama':
31 | response = await getOllamaResponse(prompt, aiConfig.ollama);
32 | break;
33 | case 'runpod':
34 | response = await getRunpodResponse(prompt, aiConfig.runpod);
35 | break;
36 | case 'openai':
37 | response = await getOpenAIResponse(prompt, aiConfig.openai);
38 | break;
39 | default:
40 | throw new Error('Unsupported LLM provider');
41 | }
42 | return response;
43 | } catch (error) {
44 | console.error('Error fetching explanation:', error);
45 | return `There was an error with the LLM. Please check if your current configuration is correct: Provider - ${aiConfig.provider}`;
46 | }
47 | }
48 |
49 | async function getLLMResponse(prompt: string): Promise {
50 | const currentOptions = getCurrentOptions();
51 | const { aiConfig } = currentOptions;
52 |
53 | if (aiConfig.provider === 'none') {
54 | throw new Error("LLM is not configured. Please set up an LLM provider in the options.");
55 | }
56 |
57 | try {
58 | switch (aiConfig.provider) {
59 | case 'ollama':
60 | return await getOllamaResponse(prompt, aiConfig.ollama);
61 | case 'runpod':
62 | return await getRunpodResponse(prompt, aiConfig.runpod);
63 | case 'openai':
64 | return await getOpenAIResponse(prompt, aiConfig.openai);
65 | default:
66 | throw new Error('Unsupported LLM provider');
67 | }
68 | } catch (error) {
69 | console.error('Error getting LLM response:', error);
70 | throw error;
71 | }
72 | }
73 |
74 | async function getOllamaResponse(prompt: string, config: { model: string; port: number }): Promise {
75 | const API_URL = `http://localhost:${config.port}/api/generate`;
76 | const response = await fetch(API_URL, {
77 | method: 'POST',
78 | headers: { 'Content-Type': 'application/json' },
79 | body: JSON.stringify({ model: config.model, prompt, stream: false }),
80 | });
81 | if (!response.ok) throw new Error('Ollama API request failed');
82 | const data: LLMResponse = await response.json();
83 | return data.response;
84 | }
85 |
86 | async function getRunpodResponse(prompt: string, config: { apiKey: string; serverlessApiId: string }): Promise {
87 | const API_URL = `https://api.runpod.ai/v2/${config.serverlessApiId}/runsync`;
88 | const response = await fetch(API_URL, {
89 | method: 'POST',
90 | headers: {
91 | 'Content-Type': 'application/json',
92 | 'Authorization': `Bearer ${config.apiKey}`
93 | },
94 | body: JSON.stringify({
95 | input: {
96 | prompt,
97 | max_new_tokens: 300,
98 | temperature: 0.7,
99 | }
100 | }),
101 | });
102 | if (!response.ok) throw new Error('Runpod API request failed');
103 | const data = await response.json();
104 | return data.output;
105 | }
106 |
107 | async function getOpenAIResponse(prompt: string, config: { apiKey: string; model: string }): Promise {
108 | const API_URL = 'https://api.openai.com/v1/chat/completions';
109 | const response = await fetch(API_URL, {
110 | method: 'POST',
111 | headers: {
112 | 'Content-Type': 'application/json',
113 | 'Authorization': `Bearer ${config.apiKey}`
114 | },
115 | body: JSON.stringify({
116 | model: config.model,
117 | messages: [
118 | {
119 | role: "system",
120 | content: "You are a helpful assistant that provides brief explanations."
121 | },
122 | {
123 | role: "user",
124 | content: prompt
125 | }
126 | ],
127 | max_tokens: 300,
128 | temperature: 0.7,
129 | }),
130 | });
131 |
132 | if (!response.ok) {
133 | const errorData = await response.json();
134 | console.error('OpenAI API error:', errorData);
135 | throw new Error(`OpenAI API request failed: ${response.status} ${response.statusText}`);
136 | }
137 |
138 | const data = await response.json();
139 | return data.choices[0].message.content.trim();
140 | }
141 |
142 | function cleanResponse(response: string): string {
143 | // Remove numbering and bullet points
144 | return response.replace(/^\s*(\d+\.|-|\*)\s*/gm, '').trim();
145 | }
146 |
147 | export async function generateFlashcards(content: string, numberOfCards: number): Promise> {
148 | // Remove the PDF cleaning step here, as it's now done before calling this function
149 | const chunkSize = 3000;
150 | const chunks = splitContent(content, chunkSize);
151 | const flashcards: Array<{question: string, answer: string}> = [];
152 | const usedQuestions = new Set();
153 |
154 | const currentOptions = getCurrentOptions();
155 | const { customPrompts } = currentOptions;
156 |
157 | for (const chunk of chunks) {
158 | const remainingCards = numberOfCards - flashcards.length;
159 | if (remainingCards <= 0) break;
160 |
161 | const chunkPrompt = customPrompts.flashcardQuestionPrompt
162 | .replace('{numberOfCards}', Math.min(remainingCards, 5).toString())
163 | .replace('{content}', chunk);
164 |
165 | console.log("Content used for this flashcard:", chunk);
166 |
167 | const questionsResponse = await getLLMResponse(chunkPrompt);
168 | const questions = questionsResponse.split('\n')
169 | .map(q => cleanResponse(q))
170 | .filter(q => q.trim() !== '');
171 |
172 | for (const question of questions) {
173 | if (usedQuestions.has(question.toLowerCase())) continue;
174 |
175 | const answerPrompt = customPrompts.flashcardAnswerPrompt
176 | .replace('{question}', question)
177 | .replace('{content}', chunk);
178 |
179 | const answer = cleanResponse(await getLLMResponse(answerPrompt));
180 |
181 | if (answer !== '') {
182 | flashcards.push({ question, answer });
183 | usedQuestions.add(question.toLowerCase());
184 | }
185 |
186 | if (flashcards.length >= numberOfCards) break;
187 | }
188 | }
189 |
190 | return flashcards;
191 | }
192 |
193 | export async function generateTestFlashcards(content: string, numberOfCards: number): Promise> {
194 | const chunkSize = 2500;
195 | const chunks = splitContent(content, chunkSize);
196 | const flashcards: Array<{question: string, answer: {options: string[], correctIndex: number}}> = [];
197 | const usedQuestions = new Set();
198 |
199 | const currentOptions = getCurrentOptions();
200 | const { customPrompts } = currentOptions;
201 |
202 | for (const chunk of chunks) {
203 | const remainingCards = numberOfCards - flashcards.length;
204 | if (remainingCards <= 0) break;
205 |
206 | const chunkPrompt = customPrompts.flashcardQuestionPrompt
207 | .replace('{numberOfCards}', Math.min(remainingCards, 5).toString())
208 | .replace('{content}', chunk);
209 |
210 | const questionsResponse = await getLLMResponse(chunkPrompt);
211 | const questions = questionsResponse.split('\n')
212 | .map(q => cleanResponse(q))
213 | .filter(q => q.trim() !== '');
214 |
215 | for (const question of questions) {
216 | if (usedQuestions.has(question.toLowerCase())) continue;
217 |
218 | const answerPrompt = customPrompts.testFlashcardAnswerPrompt
219 | .replace('{question}', question)
220 | .replace('{content}', chunk);
221 |
222 | const answerResponse = await getLLMResponse(answerPrompt);
223 | const correctAnswer = cleanResponse(answerResponse).replace(/"/g, '');
224 |
225 | const answerIncorrectPrompt = customPrompts.testFlashcardIncorrectAnswerPrompt
226 | .replace('{question}', question)
227 | .replace('{correctAnswer}', correctAnswer);
228 |
229 | const answerIncorrectResponse = await getLLMResponse(answerIncorrectPrompt);
230 |
231 | const incorrectAnswers = answerIncorrectResponse.split('\n')
232 | .map(a => a.trim())
233 | .filter(a => a.match(/^\d+\.\s*/)) // Only keep lines starting with a number and a dot
234 | .map(a => a.replace(/^\d+\.\s*/, '').replace(/"/g, '')) // Remove the number and dot
235 | .filter(a => a !== '' && a !== correctAnswer);
236 |
237 | console.log("Correct answer: ", correctAnswer)
238 | console.log("Incorrect answer: ", incorrectAnswers)
239 |
240 | if (correctAnswer && incorrectAnswers.length >= 3) {
241 | const options = [...incorrectAnswers.slice(0, 3)];
242 | const correctIndex = Math.floor(Math.random() * (options.length + 1));
243 | options.splice(correctIndex, 0, correctAnswer);
244 |
245 | flashcards.push({
246 | question,
247 | answer: {
248 | options,
249 | correctIndex
250 | }
251 | });
252 | usedQuestions.add(question.toLowerCase());
253 | }
254 |
255 | if (flashcards.length >= numberOfCards) break;
256 | }
257 | }
258 |
259 | return flashcards;
260 | }
261 |
262 | function splitContent(content: string, chunkSize: number): string[] {
263 | const chunks: string[] = [];
264 | for (let i = 0; i < content.length; i += chunkSize) {
265 | chunks.push(content.slice(i, i + chunkSize));
266 | }
267 | return chunks;
268 | }
--------------------------------------------------------------------------------
/src/lib/services/webScraperService.ts:
--------------------------------------------------------------------------------
1 | import TurndownService from 'turndown';
2 |
3 |
4 |
5 | export async function scrapeWebsite(url: string): Promise {
6 | try {
7 | const html = await window.electronAPI.fetchWebContent(url);
8 | let cleanHtml = html
9 | .replace(/