├── .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 | 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 | 11 | -------------------------------------------------------------------------------- /src/lib/components/AddCollection.svelte: -------------------------------------------------------------------------------- 1 | 2 | 39 | 40 |
41 | 49 | 55 |
-------------------------------------------------------------------------------- /src/lib/components/AddStudyMaterialModal.svelte: -------------------------------------------------------------------------------- 1 | 128 | 129 | 130 |
131 | {#if errorMessage} 132 | 136 | {/if} 137 | 138 |
139 | 140 | 147 |
148 | 149 |
156 | 164 | 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 | 187 |
  • 188 | {/each} 189 |
190 |
191 | {/if} 192 | 193 | 199 |
200 |
-------------------------------------------------------------------------------- /src/lib/components/AdvancedLLMOptions.svelte: -------------------------------------------------------------------------------- 1 | 39 | 40 |
41 | 52 | {#if showAdvancedOptions} 53 |
54 | 64 | 74 | 83 | 89 |
90 | {/if} 91 |
92 | 93 | -------------------------------------------------------------------------------- /src/lib/components/Calendar.svelte: -------------------------------------------------------------------------------- 1 | 98 | 99 |
100 |
101 | 106 |

107 | {currentDate.toLocaleString('default', { month: 'long', year: 'numeric' })} 108 |

109 | 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 | 67 |
68 | 74 | 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 | 32 | 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 | 49 |
50 |
-------------------------------------------------------------------------------- /src/lib/components/EditTestModal.svelte: -------------------------------------------------------------------------------- 1 | 104 | 105 | 106 |
107 | 113 | 114 | {#if errorMessage} 115 | 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 | 136 |
137 | {/each} 138 | 139 |
140 | 141 | 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 | 163 |
164 | 167 | 175 |
176 | 183 |
184 |
185 |
186 | 187 |

Add New Question

188 | 194 | {#each newQuestion.options as option, index} 195 |
196 | 202 | 209 |
210 | {/each} 211 | 217 |
218 | 224 | 230 |
231 |
232 |
-------------------------------------------------------------------------------- /src/lib/components/File.svelte: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 |
16 | 29 | 38 |
-------------------------------------------------------------------------------- /src/lib/components/Flashcard.svelte: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 |
14 |

Flashcard

15 |

{question}

16 | 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 |
49 | 55 | 61 |
62 | 68 | 74 |
75 |
76 | {:else} 77 | 83 | {/if} 84 | 85 |
86 | {#each deck.flashcards as card} 87 |
88 | {card.question} 89 | 95 |
96 | {/each} 97 |
98 |
-------------------------------------------------------------------------------- /src/lib/components/GeneralOptions.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 |
10 |
11 | 20 |
21 |
-------------------------------------------------------------------------------- /src/lib/components/LLMConfiguration.svelte: -------------------------------------------------------------------------------- 1 | 36 | 37 |
38 |
39 |
40 | 46 | 52 | 58 | 64 |
65 | {#if localAIConfig.provider && localAIConfig.provider !== 'none'} 66 | 73 | {#if localAIConfig.provider === 'ollama'} 74 |
75 | 84 | 93 |
94 | {:else if localAIConfig.provider === 'runpod'} 95 |
96 | 105 | 114 |
115 | {:else if localAIConfig.provider === 'openai'} 116 |
117 | 126 | 135 |
136 | {/if} 137 | {/if} 138 |
139 |
140 | 141 | -------------------------------------------------------------------------------- /src/lib/components/LLMExplanation.svelte: -------------------------------------------------------------------------------- 1 | 32 | 33 |
34 |
35 | 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 | 46 |
47 | 50 |
51 |
52 | {/if} -------------------------------------------------------------------------------- /src/lib/components/OllamaConfig.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 |
10 | 19 | 28 |
-------------------------------------------------------------------------------- /src/lib/components/OpenAIConfig.svelte: -------------------------------------------------------------------------------- 1 | 21 | 22 |
23 | 32 | 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 | 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 |
67 |
68 | 69 |
70 |
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 | Flashcard Deck 41 | {material.name} 42 | 43 | {material.flashcards.length} card{material.flashcards.length !== 1 ? 's' : ''} 44 | 45 | {:else if 'questions' in material} 46 | Test 47 | {material.name} 48 | 49 | {material.questions.length} question{material.questions.length !== 1 ? 's' : ''} 50 | 51 | {/if} 52 |
53 |
54 | 61 | 67 | 73 |
74 |
75 | {/each} 76 |
-------------------------------------------------------------------------------- /src/lib/components/RunpodConfig.svelte: -------------------------------------------------------------------------------- 1 | 21 | 22 |
23 | 32 | 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 | 135 | 139 | 145 |
146 | {:else if showTestResults} 147 |
148 |

Test Results

149 |

150 | Correct: {testResults.correct}
151 | Kinda: {testResults.kinda}
152 | Wrong: {testResults.wrong} 153 |

154 | 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 | 177 | {/each} 178 | {#if selectedAnswer !== null} 179 | 185 | {/if} 186 | {:else if showAnswer} 187 |

Answer:

188 |

189 | {getCorrectAnswer(shuffledCards[currentIndex])} 190 |

191 | 195 | {#if isTestMode} 196 |
197 | 203 | 209 | 215 |
216 | {/if} 217 | {/if} 218 |
219 | {#if !isTestMode || (isTestMode && !isTestFlashcard(shuffledCards[currentIndex]))} 220 | 226 | {/if} 227 |
228 | {#if !isTestMode} 229 | 236 | {:else} 237 |
238 | {/if} 239 | 240 | {currentIndex + 1} / {shuffledCards.length} 241 | 242 | {#if !isTestMode} 243 | 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 | 111 |
112 | {:else} 113 |
114 |
handleViewFile(material)} 117 | > 118 | 119 |

{getMaterialName(material)}

120 |
121 | 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 | 150 | 156 |
157 |
158 |
159 | {/if} -------------------------------------------------------------------------------- /src/lib/components/StudyTimer.svelte: -------------------------------------------------------------------------------- 1 | 86 | 87 |
88 | {displayTime} 89 |
90 | 106 | 116 | 117 | 128 |
129 | 139 |
140 | 143 | 144 |
145 |
146 | 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(/)<[^<]*)*<\/script>/gi, '') 10 | .replace(/)<[^<]*)*<\/style>/gi, '') 11 | .replace(/)<[^<]*)*<\/nav>/gi, '') 12 | .replace(/)<[^<]*)*<\/header>/gi, '') 13 | .replace(/)<[^<]*)*<\/footer>/gi, '') 14 | .replace(/)<[^<]*)*<\/aside>/gi, ''); 15 | 16 | // Extract main content 17 | let mainContent = extractContent(cleanHtml, 'main') || 18 | extractContent(cleanHtml, 'article') || 19 | extractContent(cleanHtml, 'div[id="mw-content-text"]') || // Wikipedia-specific 20 | extractContent(cleanHtml, 'div[class="mw-parser-output"]') || // Wikipedia-specific 21 | cleanHtml; 22 | 23 | const turndownService = new TurndownService({ 24 | headingStyle: 'atx', 25 | codeBlockStyle: 'fenced' 26 | }); 27 | 28 | let markdown = turndownService.turndown(mainContent); 29 | 30 | // Clean up the markdown 31 | markdown = markdown 32 | // Remove "From Wikipedia, the free encyclopedia" 33 | .replace(/^From Wikipedia,?\s?the free encyclopedia\s*/i, '') 34 | // Remove image descriptions 35 | .replace(/!\[.*?\]\(.*?\)/g, '') 36 | // Remove citations 37 | .replace(/\[citation needed\]/gi, '') 38 | .replace(/\[\d+\]/g, '') 39 | // Remove edit links 40 | .replace(/\[edit\]/gi, '') 41 | // Remove "Contents" or "Table of contents" headers 42 | .replace(/^(?:Contents|Table of contents)$\n[-=]+\n\n/gim, '') 43 | // Remove pronunciation guides 44 | .replace(/\(\/.*?\/\s+.*?\)/g, '') 45 | // Remove multiple consecutive newlines 46 | .replace(/\n{3,}/g, '\n\n') 47 | // Remove lines that are just punctuation (often artifacts from removing other elements) 48 | .replace(/^\s*[^\w\s]+\s*$/gm, '') 49 | // Trim each line 50 | .split('\n').map(line => line.trim()).join('\n') 51 | // Final trim of the entire text 52 | .trim(); 53 | 54 | return markdown; 55 | } catch (error) { 56 | console.error('Error scraping website:', error); 57 | throw new Error(`Failed to scrape website: ${error.message}`); 58 | } 59 | } 60 | 61 | function extractContent(html: string, selector: string): string { 62 | const regex = new RegExp(`<${selector}\\b[^>]*>(.*?)<\/${selector.split('[')[0]}>`, 'is'); 63 | const match = html.match(regex); 64 | return match ? match[1] : ''; 65 | } 66 | 67 | export async function getWebsiteTitle(url: string): Promise { 68 | try { 69 | const response = await window.electronAPI.fetchWebContent(url); 70 | const titleMatch = response.match(/(.*?)<\/title>/i); 71 | return titleMatch ? titleMatch[1].trim() : url; 72 | } catch (error) { 73 | console.error('Error fetching website title:', error); 74 | return url; 75 | } 76 | } -------------------------------------------------------------------------------- /src/lib/stores/collections.ts: -------------------------------------------------------------------------------- 1 | import { writable } from 'svelte/store'; 2 | import type { Writable } from 'svelte/store'; 3 | 4 | export interface StudyMaterial { 5 | type: 'pdf' | 'markdown' | 'webpage'; 6 | filePath?: string; 7 | fileName?: string; 8 | url?: string; 9 | name: string; 10 | } 11 | 12 | export interface Flashcard { 13 | id: string; 14 | question: string; 15 | answer: string; 16 | } 17 | 18 | export interface FlashcardDeck { 19 | id: string; 20 | name: string; 21 | flashcards: Flashcard[]; 22 | } 23 | 24 | export interface TestData { 25 | id: string; 26 | question: string; 27 | options: string[]; 28 | correctOptionIndex: number; 29 | } 30 | 31 | export interface Test { 32 | id: string; 33 | name: string; 34 | questions: TestData[]; 35 | } 36 | 37 | export type ReviewMaterial = FlashcardDeck | Test; 38 | 39 | export interface Collection { 40 | id: string; 41 | name: string; 42 | studyMaterials: StudyMaterial[]; 43 | reviewMaterials: ReviewMaterial[]; 44 | } 45 | 46 | async function loadCollections(): Promise<Collection[]> { 47 | try { 48 | const content = await window.electronAPI.readFile('collections.json'); 49 | return JSON.parse(content); 50 | } catch (error) { 51 | console.log('No collections found, returning empty array'); 52 | return []; 53 | } 54 | } 55 | 56 | async function saveCollections(collections: Collection[]): Promise<void> { 57 | try { 58 | await window.electronAPI.saveFile(JSON.stringify(collections), 'collections.json', ''); 59 | } catch (error) { 60 | console.error('Error saving collections:', error); 61 | } 62 | } 63 | 64 | function createCollectionsStore() { 65 | const { subscribe, set, update } = writable<Collection[]>([]); 66 | 67 | function getUniqueName(name: string, existingNames: Set<string>): string { 68 | let newName = name; 69 | let counter = 1; 70 | while (existingNames.has(newName)) { 71 | newName = `${name} copy${counter > 1 ? ' ' + counter : ''}`; 72 | counter++; 73 | } 74 | return newName; 75 | } 76 | 77 | return { 78 | subscribe, 79 | initialize: async () => { 80 | const loadedCollections = await loadCollections(); 81 | set(loadedCollections); 82 | }, 83 | addCollection: async (name: string) => { 84 | let uniqueName = ''; 85 | await update(cols => { 86 | const existingNames = new Set(cols.map(c => c.name)); 87 | uniqueName = getUniqueName(name, existingNames); 88 | const newCollection = { 89 | id: Date.now().toString(), 90 | name: uniqueName, 91 | studyMaterials: [], 92 | reviewMaterials: [] 93 | }; 94 | const newCols = [...cols, newCollection]; 95 | saveCollections(newCols); // Save immediately after adding a new collection 96 | return newCols; 97 | }); 98 | try { 99 | await window.electronAPI.createCollectionFolder(uniqueName); 100 | } catch (error) { 101 | console.error('Failed to create collection folder:', error); 102 | throw new Error('Failed to create collection'); 103 | } 104 | }, 105 | deleteCollection: async (id: string) => { 106 | update(cols => { 107 | const newCols = cols.filter(col => col.id !== id); 108 | saveCollections(newCols); 109 | return newCols; 110 | }); 111 | }, 112 | addStudyMaterials: async (collectionId: string, newMaterials: StudyMaterial[]) => { 113 | update(cols => { 114 | const newCols = cols.map(col => 115 | col.id === collectionId 116 | ? { ...col, studyMaterials: [...col.studyMaterials, ...newMaterials.map(m => ({...m}))] } 117 | : col 118 | ); 119 | saveCollections(newCols); 120 | return newCols; 121 | }); 122 | }, 123 | removeStudyMaterial: async (collectionId: string, materialName: string) => { 124 | update(cols => { 125 | const newCols = cols.map(col => 126 | col.id === collectionId 127 | ? { 128 | ...col, 129 | studyMaterials: col.studyMaterials.filter(material => material.name !== materialName) 130 | } 131 | : col 132 | ); 133 | saveCollections(newCols); 134 | return newCols; 135 | }); 136 | }, 137 | addFlashcardDeck: async (collectionId: string, deck: FlashcardDeck) => { 138 | update(cols => { 139 | const newCols = cols.map(col => { 140 | if (col.id === collectionId) { 141 | const existingDeckIndex = col.reviewMaterials.findIndex( 142 | (material): material is FlashcardDeck => 143 | 'flashcards' in material && material.id === deck.id 144 | ); 145 | 146 | if (existingDeckIndex !== -1) { 147 | // Update existing deck 148 | return { 149 | ...col, 150 | reviewMaterials: col.reviewMaterials.map((material, index) => 151 | index === existingDeckIndex 152 | ? {...deck, flashcards: deck.flashcards.map(card => ({...card}))} 153 | : material 154 | ) 155 | }; 156 | } else { 157 | // Add new deck 158 | return { 159 | ...col, 160 | reviewMaterials: [...col.reviewMaterials, {...deck, flashcards: deck.flashcards.map(card => ({...card}))}] 161 | }; 162 | } 163 | } 164 | return col; 165 | }); 166 | console.log('Saving collections:', JSON.stringify(newCols, null, 2)); 167 | saveCollections(newCols); 168 | return newCols; 169 | }); 170 | }, 171 | updateFlashcardDeck: async (collectionId: string, updatedDeck: FlashcardDeck) => { 172 | update(cols => { 173 | try { 174 | const newCols = cols.map(col => { 175 | if (col.id === collectionId) { 176 | const newReviewMaterials = col.reviewMaterials.map(material => { 177 | if ('flashcards' in material && material.id === updatedDeck.id) { 178 | return { 179 | ...updatedDeck, 180 | flashcards: updatedDeck.flashcards.map(card => ({...card})) 181 | }; 182 | } 183 | return material; 184 | }); 185 | return { 186 | ...col, 187 | reviewMaterials: newReviewMaterials 188 | }; 189 | } 190 | return col; 191 | }); 192 | saveCollections(newCols); 193 | return newCols; 194 | } catch (error) { 195 | return cols; // Return original state if there's an error 196 | } 197 | }); 198 | }, 199 | removeFlashcardDeck: async (collectionId: string, deckId: string) => { 200 | update(cols => { 201 | const newCols = cols.map(col => 202 | col.id === collectionId 203 | ? { 204 | ...col, 205 | reviewMaterials: col.reviewMaterials.filter(material => 206 | !('flashcards' in material) || material.id !== deckId 207 | ) 208 | } 209 | : col 210 | ); 211 | saveCollections(newCols); 212 | return newCols; 213 | }); 214 | }, 215 | addTest: async (collectionId: string, test: Test) => { 216 | update(cols => { 217 | const newCols = cols.map(col => 218 | col.id === collectionId 219 | ? { 220 | ...col, 221 | reviewMaterials: [ 222 | ...col.reviewMaterials, 223 | {...test, questions: test.questions.map(q => ({...q}))} 224 | ] 225 | } 226 | : col 227 | ); 228 | saveCollections(newCols); 229 | return newCols; 230 | }); 231 | }, 232 | removeTest: async (collectionId: string, testId: string) => { 233 | update(cols => { 234 | const newCols = cols.map(col => 235 | col.id === collectionId 236 | ? { 237 | ...col, 238 | reviewMaterials: col.reviewMaterials.filter(material => 239 | !('questions' in material) || material.id !== testId 240 | ) 241 | } 242 | : col 243 | ); 244 | saveCollections(newCols); 245 | return newCols; 246 | }); 247 | }, 248 | updateTest: async (collectionId: string, updatedTest: Test) => { 249 | update(cols => { 250 | const newCols = cols.map(col => 251 | col.id === collectionId 252 | ? { 253 | ...col, 254 | reviewMaterials: col.reviewMaterials.map(material => 255 | 'questions' in material && material.id === updatedTest.id 256 | ? {...updatedTest, questions: updatedTest.questions.map(q => ({...q}))} 257 | : material 258 | ) 259 | } 260 | : col 261 | ); 262 | saveCollections(newCols); 263 | return newCols; 264 | }); 265 | }, 266 | renameCollection: async (id: string, newName: string) => { 267 | let oldName = ''; 268 | let collection: Collection | undefined; 269 | 270 | update(collections => { 271 | collection = collections.find(c => c.id === id); 272 | oldName = collection ? collection.name : ''; 273 | return collections; 274 | }); 275 | 276 | if (oldName && collection) { 277 | try { 278 | await window.electronAPI.renameCollectionFolder(oldName, newName); 279 | 280 | // Update file paths for study materials 281 | const updatedStudyMaterials = collection.studyMaterials.map(material => { 282 | if (material.filePath) { 283 | const newFilePath = material.filePath.replace(oldName, newName); 284 | return { ...material, filePath: newFilePath }; 285 | } 286 | return material; 287 | }); 288 | 289 | update(collections => { 290 | const newCollections = collections.map(col => 291 | col.id === id ? { ...col, name: newName, studyMaterials: updatedStudyMaterials } : col 292 | ); 293 | saveCollections(newCollections); // Save after updating 294 | return newCollections; 295 | }); 296 | } catch (error) { 297 | console.error('Failed to rename collection folder:', error); 298 | throw new Error('Failed to rename collection'); 299 | } 300 | } 301 | }, 302 | importCollection: async (importedCollection: Collection) => { 303 | let uniqueName = ''; 304 | await update(cols => { 305 | const existingNames = new Set(cols.map(c => c.name)); 306 | uniqueName = getUniqueName(importedCollection.name, existingNames); 307 | const newCollection = { ...importedCollection, name: uniqueName }; 308 | return [...cols, newCollection]; 309 | }); 310 | try { 311 | await window.electronAPI.createCollectionFolder(uniqueName); 312 | // You might want to add logic here to copy imported study materials 313 | } catch (error) { 314 | console.error('Failed to create folder for imported collection:', error); 315 | throw new Error('Failed to import collection'); 316 | } 317 | }, 318 | }; 319 | } 320 | 321 | export const collections = createCollectionsStore(); 322 | 323 | export async function loadStudyMaterialContent(filePath: string): Promise<string> { 324 | try { 325 | return await window.electronAPI.readFile(filePath); 326 | } catch (error) { 327 | console.error('Error loading study material content:', error); 328 | throw error; 329 | } 330 | } 331 | 332 | // Initialize the store when the app starts 333 | collections.initialize(); 334 | 335 | // You can keep your existing function declarations, but they should now call the corresponding methods on the collections object 336 | export const addCollection = collections.addCollection; 337 | export const deleteCollection = collections.deleteCollection; 338 | export const addStudyMaterials = collections.addStudyMaterials; 339 | export const removeStudyMaterial = collections.removeStudyMaterial; 340 | export const addFlashcardDeck = collections.addFlashcardDeck; 341 | export const removeFlashcardDeck = collections.removeFlashcardDeck; 342 | export const addTest = collections.addTest; 343 | export const removeTest = collections.removeTest; 344 | export const updateTest = collections.updateTest; 345 | export const updateFlashcardDeck = collections.updateFlashcardDeck; -------------------------------------------------------------------------------- /src/lib/stores/options.ts: -------------------------------------------------------------------------------- 1 | import { writable } from 'svelte/store'; 2 | 3 | export interface OllamaConfig { 4 | model: string; 5 | port: number; 6 | } 7 | 8 | export interface RunpodConfig { 9 | apiKey: string; 10 | serverlessApiId: string; 11 | } 12 | 13 | export interface OpenAIConfig { 14 | apiKey: string; 15 | model: string; 16 | } 17 | 18 | export interface AIConfig { 19 | provider: 'none' | 'ollama' | 'runpod' | 'openai'; 20 | ollama: OllamaConfig; 21 | runpod: RunpodConfig; 22 | openai: OpenAIConfig; 23 | } 24 | 25 | export interface VectorDBConfig { 26 | provider: 'none' | 'chroma' | 'pinecone'; 27 | pineconeApiKey: string; 28 | pineconeEnvironment: string; 29 | pineconeIndex: string; 30 | } 31 | 32 | export interface CustomPrompts { 33 | flashcardQuestionPrompt: string; 34 | flashcardAnswerPrompt: string; 35 | explanationPrompt: string; 36 | testFlashcardIncorrectAnswerPrompt: string; 37 | testFlashcardAnswerPrompt: string; 38 | } 39 | 40 | export interface Options { 41 | openMaterialsInDefaultApp: boolean; 42 | simplifiedMaterialView: boolean; 43 | aiConfig: AIConfig; 44 | customPrompts: CustomPrompts; 45 | } 46 | 47 | const defaultPrompts: CustomPrompts = { 48 | flashcardQuestionPrompt: `Generate {numberOfCards} unique and diverse flashcard questions based on the following content. Follow these guidelines: 49 | 50 | - Each question should test a different key concept or fact from the content. 51 | - Ensure questions cover a wide range of topics within the content. 52 | - Use a variety of question types (e.g., "what", "how", "why", "explain", "compare", "analyze"). 53 | - Questions should be clear, concise, and specific. 54 | - Avoid yes/no questions; prefer open-ended or fill-in-the-blank questions. 55 | - Output only the questions, one per line, without numbering, bullets, or additional text. 56 | 57 | Content: 58 | {content} 59 | 60 | Questions:`, 61 | flashcardAnswerPrompt: `Provide a concise and accurate answer to the following question based on the given content. Follow these guidelines: 62 | 63 | - Answer should be clear, informative, and directly address the question. 64 | - Keep the answer concise, ideally 1-3 sentences. 65 | - Include key details or examples if necessary for clarity. 66 | - If the question asks to fill in a blank, provide the missing term or phrase. 67 | - Output only the answer, without any additional text, explanation, or numbering. 68 | 69 | Question: "{question}" 70 | 71 | Content: 72 | {content} 73 | 74 | Answer:`, 75 | explanationPrompt: `Please explain the following answer to the question "{question}": {answer}. Provide a rich but brief explanation that is easy to understand. Answer with only the explanation and nothing else. Explanation:`, 76 | testFlashcardIncorrectAnswerPrompt: `Generate three incorrect answers for the following question. The correct answer is provided. 77 | Ensure that all options are wrong and clearly incorrect to someone who knows the material well. Make them diverse and challenging. 78 | 79 | Each answer should be concise (10 words max) and distinctly different from the others and from the correct answer. 80 | Provide ONLY the incorrect answers, without any explanations or reasoning. 81 | 82 | Question: {question} 83 | Correct Answer: {correctAnswer} 84 | 85 | Incorrect Answers:`, 86 | 87 | testFlashcardAnswerPrompt: `Provide an extremely concise answer to the following question based on the given content. Follow these strict guidelines: 88 | 89 | - Answer must be clear, informative, and directly address the question. 90 | - Answer MUST NOT exceed 10 words. Aim for 5-7 words if possible. 91 | - If the question asks to fill in a blank, provide only the missing term or phrase. 92 | - Output only the answer, without any additional text, explanation, or numbering. 93 | - Do not use complete sentences if a phrase suffices. 94 | 95 | Question: "{question}" 96 | 97 | Content: 98 | {content} 99 | 100 | Answer (remember, 10 words maximum):`, 101 | }; 102 | 103 | 104 | const defaultOptions: Options = { 105 | openMaterialsInDefaultApp: false, 106 | simplifiedMaterialView: false, 107 | aiConfig: { 108 | provider: 'none', 109 | ollama: { model: 'llama2', port: 11434 }, 110 | runpod: { apiKey: '', serverlessApiId: '' }, 111 | openai: { apiKey: '', model: 'text-davinci-003' }, 112 | }, 113 | customPrompts: defaultPrompts, 114 | }; 115 | 116 | function createOptionsStore() { 117 | const { subscribe, set, update } = writable<Options>(defaultOptions); 118 | 119 | return { 120 | subscribe, 121 | setOption: <K extends keyof Options>(key: K, value: Options[K]) => 122 | update(opts => ({ ...opts, [key]: value })), 123 | setAIOption: <K extends keyof AIConfig>(key: K, value: AIConfig[K]) => 124 | update(opts => ({ ...opts, aiConfig: { ...opts.aiConfig, [key]: value } })), 125 | setAIProviderOption: ( 126 | provider: AIConfig['provider'], 127 | key: string, 128 | value: any 129 | ) => 130 | update(opts => ({ 131 | ...opts, 132 | aiConfig: { 133 | ...opts.aiConfig, 134 | [provider]: { ...opts.aiConfig[provider], [key]: value } 135 | } 136 | })), 137 | resetToDefaults: () => set(defaultOptions), 138 | loadOptions: () => { 139 | const savedOptions = localStorage.getItem('studycraft_options'); 140 | if (savedOptions) { 141 | const parsedOptions = JSON.parse(savedOptions); 142 | // Merge saved options with default options 143 | const mergedOptions = { 144 | ...defaultOptions, 145 | ...parsedOptions, 146 | aiConfig: { 147 | ...defaultOptions.aiConfig, 148 | ...parsedOptions.aiConfig, 149 | }, 150 | customPrompts: { 151 | ...defaultOptions.customPrompts, 152 | ...parsedOptions.customPrompts, 153 | }, 154 | }; 155 | set(mergedOptions); 156 | } 157 | }, 158 | saveOptions: (options: Options) => { 159 | localStorage.setItem('studycraft_options', JSON.stringify(options)); 160 | }, 161 | setCustomPrompt: (key: keyof CustomPrompts, value: string) => 162 | update(opts => ({ 163 | ...opts, 164 | customPrompts: { ...opts.customPrompts, [key]: value } 165 | })), 166 | resetCustomPrompts: () => 167 | update(opts => ({ ...opts, customPrompts: defaultPrompts })), 168 | }; 169 | } 170 | 171 | export const options = createOptionsStore(); 172 | 173 | // Initialize options when the module is imported 174 | options.loadOptions(); -------------------------------------------------------------------------------- /src/lib/stores/timerStore.ts: -------------------------------------------------------------------------------- 1 | import { writable } from 'svelte/store'; 2 | 3 | interface TimerState { 4 | isRunning: boolean; 5 | startTime: number | null; 6 | elapsedTime: number; 7 | isPomodoroMode: boolean; 8 | pomodoroLength: number; 9 | restLength: number; 10 | isRestPhase: boolean; 11 | } 12 | 13 | export interface StudySession { 14 | date: string; 15 | startTime: number; 16 | endTime: number; 17 | duration: number; 18 | isPomodoro: boolean; 19 | } 20 | 21 | function createTimerStore() { 22 | const { subscribe, set, update } = writable<TimerState>({ 23 | isRunning: false, 24 | startTime: null, 25 | elapsedTime: 0, 26 | isPomodoroMode: false, 27 | pomodoroLength: 25 * 60 * 1000, 28 | restLength: 5 * 60 * 1000, 29 | isRestPhase: false, 30 | }); 31 | 32 | let studySessions: StudySession[] = []; 33 | 34 | async function loadStudySessions() { 35 | studySessions = await window.electronAPI.loadStudySessions(); 36 | } 37 | 38 | async function saveStudySession(session: StudySession) { 39 | console.log('Saving study session:', session); 40 | await window.electronAPI.saveStudySession(session); 41 | studySessions.push(session); 42 | } 43 | 44 | return { 45 | subscribe, 46 | start: () => update(state => ({ ...state, isRunning: true, startTime: Date.now() })), 47 | pause: () => update(state => { 48 | if (state.isRunning && state.startTime) { 49 | const now = Date.now(); 50 | return { ...state, isRunning: false, elapsedTime: state.elapsedTime + (now - state.startTime) }; 51 | } 52 | return state; 53 | }), 54 | resume: () => update(state => ({ ...state, isRunning: true, startTime: Date.now() })), 55 | stop: () => update(state => { 56 | if (state.isRunning && state.startTime) { 57 | const now = Date.now(); 58 | const duration = state.elapsedTime + (now - state.startTime); 59 | if (duration > 0 && !state.isRestPhase) { 60 | const session: StudySession = { 61 | date: new Date(state.startTime).toISOString().split('T')[0], 62 | startTime: state.startTime, 63 | endTime: now, 64 | duration: duration, 65 | isPomodoro: state.isPomodoroMode, 66 | }; 67 | saveStudySession(session); 68 | } 69 | } 70 | loadStudySessions(); 71 | return { 72 | ...state, 73 | isRunning: false, 74 | startTime: null, 75 | elapsedTime: 0, 76 | isPomodoroMode: false, 77 | isRestPhase: false 78 | }; 79 | }), 80 | reset: () => set({ 81 | isRunning: false, 82 | startTime: null, 83 | elapsedTime: 0, 84 | isPomodoroMode: false, 85 | pomodoroLength: 25 * 60 * 1000, 86 | restLength: 5 * 60 * 1000, 87 | isRestPhase: false, 88 | }), 89 | togglePomodoroMode: () => update(state => ({ ...state, isPomodoroMode: !state.isPomodoroMode })), 90 | setPomodoroLength: (length: number) => update(state => ({ ...state, pomodoroLength: length })), 91 | setRestLength: (length: number) => update(state => ({ ...state, restLength: length })), 92 | switchPhase: (callback?: () => void) => update(state => { 93 | if (state.isRunning && state.startTime) { 94 | const now = Date.now(); 95 | const duration = state.elapsedTime + (now - state.startTime); 96 | if (!state.isRestPhase && duration > 0) { 97 | const session: StudySession = { 98 | date: new Date(state.startTime).toISOString().split('T')[0], 99 | startTime: state.startTime, 100 | endTime: now, 101 | duration: duration, 102 | isPomodoro: true, 103 | }; 104 | saveStudySession(session); 105 | } 106 | } 107 | if (callback) { 108 | callback(); 109 | } 110 | return { 111 | ...state, 112 | isRestPhase: !state.isRestPhase, 113 | elapsedTime: 0, 114 | startTime: null, 115 | isRunning: false 116 | }; 117 | }), 118 | getStudySessions: () => studySessions, 119 | loadStudySessions, 120 | }; 121 | } 122 | 123 | export const timerStore = createTimerStore(); -------------------------------------------------------------------------------- /src/lib/styles/markdown.css: -------------------------------------------------------------------------------- 1 | /* src/lib/styles/markdown.css */ 2 | .markdown-body { 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; 4 | font-size: 16px; 5 | line-height: 1.5; 6 | word-wrap: break-word; 7 | } 8 | 9 | .markdown-body h1, 10 | .markdown-body h2, 11 | .markdown-body h3, 12 | .markdown-body h4, 13 | .markdown-body h5, 14 | .markdown-body h6 { 15 | margin-top: 24px; 16 | margin-bottom: 16px; 17 | font-weight: 600; 18 | line-height: 1.25; 19 | } 20 | 21 | .markdown-body h1 { font-size: 2em; } 22 | .markdown-body h2 { font-size: 1.5em; } 23 | .markdown-body h3 { font-size: 1.25em; } 24 | .markdown-body h4 { font-size: 1em; } 25 | .markdown-body h5 { font-size: 0.875em; } 26 | .markdown-body h6 { font-size: 0.85em; } 27 | 28 | .markdown-body p, 29 | .markdown-body blockquote, 30 | .markdown-body ul, 31 | .markdown-body ol, 32 | .markdown-body dl, 33 | .markdown-body table, 34 | .markdown-body pre { 35 | margin-top: 0; 36 | margin-bottom: 16px; 37 | } 38 | 39 | .markdown-body code { 40 | padding: 0.2em 0.4em; 41 | margin: 0; 42 | font-size: 85%; 43 | background-color: rgba(27,31,35,0.05); 44 | border-radius: 3px; 45 | } 46 | 47 | .markdown-body pre { 48 | padding: 16px; 49 | overflow: auto; 50 | font-size: 85%; 51 | line-height: 1.45; 52 | background-color: #f6f8fa; 53 | border-radius: 3px; 54 | } 55 | 56 | .markdown-body pre code { 57 | display: inline; 58 | max-width: auto; 59 | padding: 0; 60 | margin: 0; 61 | overflow: visible; 62 | line-height: inherit; 63 | word-wrap: normal; 64 | background-color: transparent; 65 | border: 0; 66 | } 67 | 68 | .markdown-body blockquote { 69 | padding: 0 1em; 70 | color: #6a737d; 71 | border-left: 0.25em solid #dfe2e5; 72 | } 73 | 74 | .markdown-body ul, 75 | .markdown-body ol { 76 | padding-left: 2em; 77 | } 78 | 79 | .markdown-body table { 80 | display: block; 81 | width: 100%; 82 | overflow: auto; 83 | border-spacing: 0; 84 | border-collapse: collapse; 85 | } 86 | 87 | .markdown-body table th, 88 | .markdown-body table td { 89 | padding: 6px 13px; 90 | border: 1px solid #dfe2e5; 91 | } 92 | 93 | .markdown-body table tr { 94 | background-color: #fff; 95 | border-top: 1px solid #c6cbd1; 96 | } 97 | 98 | .markdown-body table tr:nth-child(2n) { 99 | background-color: #f6f8fa; 100 | } 101 | 102 | /* Dark mode styles */ 103 | @media (prefers-color-scheme: dark) { 104 | .markdown-body { 105 | color: #c9d1d9; 106 | background-color: #0d1117; 107 | } 108 | 109 | .markdown-body code { 110 | background-color: rgba(240,246,252,0.15); 111 | } 112 | 113 | .markdown-body pre { 114 | background-color: #161b22; 115 | } 116 | 117 | .markdown-body blockquote { 118 | color: #8b949e; 119 | border-left-color: #30363d; 120 | } 121 | 122 | .markdown-body table tr { 123 | background-color: #0d1117; 124 | border-top-color: #21262d; 125 | } 126 | 127 | .markdown-body table tr:nth-child(2n) { 128 | background-color: #161b22; 129 | } 130 | 131 | .markdown-body table th, 132 | .markdown-body table td { 133 | border-color: #30363d; 134 | } 135 | } -------------------------------------------------------------------------------- /src/lib/testData.ts: -------------------------------------------------------------------------------- 1 | // src/lib/testData.ts 2 | 3 | export const testCollections = [ 4 | { 5 | name: "Web Development Basics", 6 | studyMaterials: [ 7 | { type: "markdown" as const, content: "# HTML Basics\n\nHTML (HyperText Markup Language) is the standard markup language for creating web pages.\n\n## Key Concepts\n\n- Tags\n- Elements\n- Attributes" } 8 | ], 9 | reviewMaterials: [ 10 | { question: "What does HTML stand for?", answer: "HyperText Markup Language" }, 11 | { question: "What is the purpose of CSS?", answer: "CSS (Cascading Style Sheets) is used to style and layout web pages" } 12 | ] 13 | }, 14 | { 15 | name: "JavaScript Fundamentals", 16 | studyMaterials: [ 17 | { type: "markdown" as const, content: "# JavaScript Variables\n\nIn JavaScript, you can declare variables using `var`, `let`, or `const`.\n\n```javascript\nlet x = 5;\nconst y = 10;\n```" } 18 | ], 19 | reviewMaterials: [ 20 | { question: "What is the difference between let and const?", answer: "let allows reassignment, while const creates a read-only reference" }, 21 | { question: "What is a closure in JavaScript?", answer: "A closure is a function that has access to variables in its outer (enclosing) lexical scope" } 22 | ] 23 | } 24 | ]; -------------------------------------------------------------------------------- /src/lib/utils/fileUtils.ts: -------------------------------------------------------------------------------- 1 | export async function saveFile(content: string, fileName: string, collectionName: string): Promise<string> { 2 | console.log('Saving file:', fileName, 'to collection:', collectionName); 3 | const filePath = await window.electronAPI.saveFile(content, fileName, collectionName); 4 | console.log('File saved at:', filePath); 5 | return filePath; 6 | } 7 | 8 | export async function readFile(filePath: string): Promise<string> { 9 | console.log('Reading file from:', filePath); 10 | const content = await window.electronAPI.readFile(filePath); 11 | console.log('File content read, length:', content?.length); 12 | return content; 13 | } 14 | 15 | export async function deleteFile(filePath: string): Promise<boolean> { 16 | console.log('Deleting file:', filePath); 17 | const result = await window.electronAPI.deleteFile(filePath); 18 | console.log('File deleted:', result); 19 | return result; 20 | } 21 | 22 | export async function cleanPDFContent(filePath: string): Promise<string> { 23 | try { 24 | console.log('Extracting and cleaning PDF content from:', filePath); 25 | // Ensure we're passing the file path, not the content 26 | const content = await window.electronAPI.parsePDF(filePath); 27 | 28 | // Simple cleaning of the extracted text 29 | const cleanedContent = content 30 | .replace(/(\r\n|\n|\r)/gm, " ") // Replace line breaks with spaces 31 | .replace(/\s+/g, " ") // Replace multiple spaces with single space 32 | .trim(); 33 | console.log('PDF content extracted and cleaned successfully'); 34 | return cleanedContent; 35 | } catch (error) { 36 | console.error('Error extracting and cleaning PDF content:', error); 37 | throw new Error(`Failed to extract and clean PDF content: ${error.message}`); 38 | } 39 | } -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import './app.css' 2 | import App from './App.svelte' 3 | 4 | const app = new App({ 5 | target: document.body, 6 | }) 7 | 8 | export default app 9 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// <reference types="svelte" /> 2 | /// <reference types="vite/client" /> 3 | -------------------------------------------------------------------------------- /svelte.config.js: -------------------------------------------------------------------------------- 1 | import { vitePreprocess } from '@sveltejs/vite-plugin-svelte' 2 | 3 | export default { 4 | // Consult https://svelte.dev/docs#compile-time-svelte-preprocess 5 | // for more information about preprocessors 6 | preprocess: vitePreprocess(), 7 | } 8 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: [ 4 | "./index.html", 5 | "./src/**/*.{html,svelte,js,ts,}", 6 | "./electron/**/*.{html,svelte,js,ts,}" 7 | ], 8 | theme: { 9 | extend: {}, 10 | }, 11 | plugins: [], 12 | } 13 | // tailwind.config.js 14 | module.exports = { 15 | content: ['./src/**/*.{html,js,svelte,ts}'], 16 | darkMode: 'class', 17 | theme: { 18 | extend: { 19 | colors: { 20 | primary: { 21 | light: '#ffffff', 22 | dark: '#000000', 23 | }, 24 | background: { 25 | light: '#f8f8f8', 26 | dark: '#1a1a1a', 27 | }, 28 | text: { 29 | light: '#1f2937', 30 | dark: '#f3f4f6', 31 | }, 32 | }, 33 | fontFamily: { 34 | 'header': ['Playfair Display', 'serif'], 35 | 'body': ['Inter', 'sans-serif'], 36 | }, 37 | }, 38 | }, 39 | plugins: [], 40 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2018", 4 | "module": "ESNext", 5 | "moduleResolution": "node", 6 | "esModuleInterop": true, 7 | "allowSyntheticDefaultImports": true, 8 | "strict": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "skipLibCheck": true, 11 | "outDir": "./dist", 12 | "rootDir": "./src", 13 | "types": ["node", "electron"], 14 | "typeRoots": ["./node_modules/@types", "./src"] 15 | }, 16 | "include": [ 17 | "src/**/*" 18 | ], 19 | "exclude": [ 20 | "node_modules" 21 | ] 22 | } -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import path from 'path'; 3 | 4 | export default defineConfig(async () => { 5 | const { svelte } = await import('@sveltejs/vite-plugin-svelte'); 6 | 7 | return { 8 | plugins: [svelte()], 9 | base: './', 10 | build: { 11 | outDir: 'dist', 12 | emptyOutDir: true, 13 | rollupOptions: { 14 | input: { 15 | main: path.resolve(__dirname, 'index.html'), 16 | 'electron-main': path.resolve(__dirname, 'electron/main.cjs'), 17 | }, 18 | output: { 19 | entryFileNames: (chunkInfo) => { 20 | return chunkInfo.name === 'electron-main' ? '[name].cjs' : '[name]-[hash].js'; 21 | }, 22 | }, 23 | external: ['electron'] 24 | } 25 | } 26 | }; 27 | }); --------------------------------------------------------------------------------