diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4d55965..649de04 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,13 +6,18 @@ on: branches: [main] jobs: ci: - runs-on: ubuntu-latest + strategy: + matrix: + os: [macos-latest, ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: '22' - - run: npm install + - run: npm ci - run: npm run typecheck - run: npm run lint - run: npm run format:check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b46b22a..d86054a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,7 +1,6 @@ name: Release on: - push: - branches: [main] + workflow_dispatch: permissions: contents: write jobs: @@ -29,7 +28,7 @@ jobs: - uses: actions/setup-node@v4 with: node-version: '22' - - run: npm install + - run: npm ci - name: Sync package.json version run: npm --no-git-tag-version version ${{ needs.create-tag.outputs.new_version }} @@ -52,6 +51,8 @@ jobs: release/*.exe release/*.AppImage release/*.deb + release/*.yml + release/*.blockmap update-homebrew: needs: [create-tag, build-and-release] diff --git a/.gitignore b/.gitignore index 9ef3318..667cd7b 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ dist-ssr dist-electron release *.env +coverage/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..b744425 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,29 @@ +# Contributing to PaperCache + +First of all, thank you for considering contributing to PaperCache! + +## Development Setup + +1. **Clone the repository:** + ```bash + git clone https://github.com/VariableThe/PaperCache.git + cd PaperCache + ``` + +2. **Install dependencies:** + We strictly use `npm ci` to ensure reproducible builds. + ```bash + npm ci + ``` + +3. **Start the development server:** + ```bash + npm run dev + ``` + +## Development Guidelines +- **Pull Requests Required**: Never push new features directly to the `main` branch. Always create a new branch and push your changes as a Pull Request (PR) for review. +- **Pre-PR Checks**: Run `npm run lint`, `npm run typecheck`, `npm run format:check`, and `npm run test` before opening any PR — don't open a PR with failing checks. +- **Performance Reporting**: Performance changes require a before/after bundle size comparison in the PR description (just paste the Vite build output). + +Thank you for your contributions! diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..67dce15 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,18 @@ +# Security Policy + +## Supported Versions + +Currently, only the latest version of PaperCache receives security updates. Please ensure you are running the most recent version available. + +## Reporting a Vulnerability + +We take the security of PaperCache seriously. If you discover a security vulnerability, we would appreciate it if you could report it privately so it can be addressed before being disclosed publicly. + +Please report any security issues to: **security@papercache.app** + +When reporting, please include: +- A description of the vulnerability. +- Steps to reproduce the issue. +- Any potential impact you have identified. + +You should receive an acknowledgment of your report within 48 hours, along with an estimated timeline for a fix. Thank you for helping keep PaperCache secure! diff --git a/electron/main.ts b/electron/main.ts index 816cb5b..687d868 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -12,7 +12,10 @@ import { dialog, safeStorage, powerMonitor, + session, } from 'electron' +import electronUpdater from 'electron-updater' +const { autoUpdater } = electronUpdater import path from 'node:path' import { fileURLToPath } from 'node:url' import fs from 'node:fs' @@ -35,8 +38,15 @@ if (!fs.existsSync(COMMANDS_DIR)) { fs.mkdirSync(COMMANDS_DIR) } -fs.writeFileSync( - path.join(COMMANDS_DIR, 'basics.md'), +function writeCommandFile(name: string, content: string) { + const filePath = path.join(COMMANDS_DIR, name) + if (!fs.existsSync(filePath)) { + fs.writeFileSync(filePath, content) + } +} + +writeCommandFile( + 'basics.md', `# Basics - **Zoom**: \`Cmd + +\` to zoom in, \`Cmd + -\` to zoom out, \`Cmd + 0\` to reset. @@ -56,11 +66,11 @@ fs.writeFileSync( *Example use:* Press \`Cmd+K\` right now, select "Settings", and set your global hotkey! Next: [Folders](/file commands/folders.md) -`, +` ) -fs.writeFileSync( - path.join(COMMANDS_DIR, 'folders.md'), +writeCommandFile( + 'folders.md', `# Folders Organize your notes by using a \`/\` in the note title. @@ -70,11 +80,11 @@ Folders automatically receive a unique color identifier in the Graph View and Se If you rename this note (click the title at the top left) to \`projects/PaperCache.md\`, it will automatically be placed inside a \`projects\` folder! Next: [Variables](/file commands/variables.md) -`, +` ) -fs.writeFileSync( - path.join(COMMANDS_DIR, 'variables.md'), +writeCommandFile( + 'variables.md', `# Variables & Math PaperCache is a smart scratchpad. You can define variables and write math equations that auto-calculate. @@ -92,11 +102,11 @@ x * 3 = \u200B30 API_KEY Next: [Markdown & Code](/file commands/markdown.md) -`, +` ) -fs.writeFileSync( - path.join(COMMANDS_DIR, 'markdown.md'), +writeCommandFile( + 'markdown.md', `# Markdown & Code PaperCache supports full markdown with seamless inline editing. @@ -127,11 +137,11 @@ Type \`/ai \` and press enter to summon an AI assistant directly into yo \`/ai Write a python function to reverse a string\` Next: [Formats & Colors](/file commands/formats.md) -`, +` ) -fs.writeFileSync( - path.join(COMMANDS_DIR, 'formats.md'), +writeCommandFile( + 'formats.md', `# Formats & Colors PaperCache automatically recognizes and highlights common formats so you can easily spot them in your notes. @@ -146,11 +156,11 @@ Dates and times are also highlighted to help you keep track of your schedule. Meeting on 31-05-2024 at 14:30. Next: [Tags](/file commands/tags.md) -`, +` ) -fs.writeFileSync( - path.join(COMMANDS_DIR, 'tags.md'), +writeCommandFile( + 'tags.md', `# Tags You can tag your notes anywhere by typing an exclamation mark followed by a word (e.g., !important or !work). @@ -163,11 +173,11 @@ When you open the search menu (\`Cmd+P\`), you'll see all your unique tags at th Next: [Tasks](/file commands/tasks.md) [Back to Welcome](/file Welcome.md) -`, +` ) -fs.writeFileSync( - path.join(COMMANDS_DIR, 'tasks.md'), +writeCommandFile( + 'tasks.md', `# Tasks & Reminders Stay on top of your work by using tasks! @@ -184,17 +194,17 @@ Overdue tasks will automatically highlight in red. Next: [Ready](/file commands/ready.md) [Back to Welcome](/file Welcome.md) -`, +` ) -fs.writeFileSync( - path.join(COMMANDS_DIR, 'ready.md'), +writeCommandFile( + 'ready.md', `# Ready to get started? You're all set to use PaperCache! Start jotting down your thoughts, creating folders, and exploring the capabilities. [Back to Welcome](/file Welcome.md) -`, +` ) const welcomePath = path.join(NOTES_DIR, 'Welcome.md') @@ -346,6 +356,39 @@ app.on('web-contents-created', (event, contents) => { }) app.whenReady().then(() => { + // Content Security Policy + const isDev = !!process.env.VITE_DEV_SERVER_URL; + session.defaultSession.webRequest.onHeadersReceived((details, callback) => { + const isDevCSP = + "default-src 'none'; " + + "script-src 'self' 'unsafe-eval' 'unsafe-inline'; " + + "style-src 'self' 'unsafe-inline'; " + + "img-src 'self' data: https:; " + + "connect-src 'self' https: wss:; " + + "font-src 'self' data: https:; " + + "object-src 'none'; " + + "base-uri 'none';" + const isProdCSP = + "default-src 'none'; " + + "script-src 'self' 'unsafe-eval'; " + + "style-src 'self' 'unsafe-inline'; " + + "img-src 'self' data: https:; " + + "connect-src 'self' https:; " + + "font-src 'self' data: https:; " + + "object-src 'none'; " + + "base-uri 'none';" + + callback({ + responseHeaders: { + ...details.responseHeaders, + // unsafe-eval is required for mathjs dynamic compilation + 'Content-Security-Policy': [isDev ? isDevCSP : isProdCSP], + }, + }) + }) + + autoUpdater.checkForUpdatesAndNotify() + createWindow() powerMonitor.on('suspend', () => { @@ -379,6 +422,7 @@ app.whenReady().then(() => { const contextMenu = Menu.buildFromTemplate([ { label: 'Show/Hide PaperCache', click: toggleWindow }, + { label: 'Check for Updates', click: () => autoUpdater.checkForUpdatesAndNotify() }, { type: 'separator' }, { label: 'Quit', @@ -615,11 +659,56 @@ ipcMain.on('open-settings', () => { }) -ipcMain.handle('openai-chat', async (_, { model, messages, apiKey, baseURL }) => { +let memoryApiKey = '' +try { + const file = fs.readFileSync(path.join(NOTES_DIR, 'config.enc'), 'utf-8') + if (safeStorage.isEncryptionAvailable()) { + memoryApiKey = safeStorage.decryptString(Buffer.from(file, 'base64')) + } else { + memoryApiKey = file + } +} catch { + // Empty +} + +ipcMain.handle('set-api-key', (_, key: string) => { + memoryApiKey = key; + try { + const dataToSave = safeStorage.isEncryptionAvailable() + ? safeStorage.encryptString(key).toString('base64') + : key + fs.writeFileSync(path.join(NOTES_DIR, 'config.enc'), dataToSave) + return true + } catch (err) { + console.error('Failed to set API key:', err) + return false + } +}) + +ipcMain.handle('get-api-key-status', () => { + return !!memoryApiKey && memoryApiKey.length > 0 +}) + +ipcMain.on('check-for-updates', () => { + autoUpdater.checkForUpdatesAndNotify() +}) + +ipcMain.handle('openai-chat', async (_, { model, messages, baseURL }) => { + // Input Validation + if (typeof model !== 'string' || model.trim() === '') { + throw new Error('Invalid model provided') + } + if (!Array.isArray(messages)) { + throw new Error('Messages must be an array') + } + if (baseURL && typeof baseURL !== 'string') { + throw new Error('Invalid baseURL provided') + } + try { const OpenAI = (await import('openai')).default const openai = new OpenAI({ - apiKey: apiKey || 'dummy', + apiKey: memoryApiKey || 'dummy', baseURL: baseURL || undefined, }) const completion = await openai.chat.completions.create({ diff --git a/electron/preload.ts b/electron/preload.ts index 0f11d7f..6d24a9f 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -6,7 +6,10 @@ contextBridge.exposeInMainWorld('electronAPI', { saveNote: (id: string, content: string) => ipcRenderer.invoke('save-note', { id, content }), deleteNote: (id: string) => ipcRenderer.invoke('delete-note', id), renameNote: (oldId: string, newId: string) => ipcRenderer.invoke('rename-note', { oldId, newId }), - openAIChat: (args: { model: string, messages: { role: string; content: string }[], apiKey: string, baseURL: string }) => ipcRenderer.invoke('openai-chat', args), + openAIChat: (args: { model: string, messages: { role: string; content: string }[], baseURL: string }) => ipcRenderer.invoke('openai-chat', args), + setApiKey: (key: string) => ipcRenderer.invoke('set-api-key', key), + getApiKeyStatus: () => ipcRenderer.invoke('get-api-key-status'), + checkForUpdates: () => ipcRenderer.send('check-for-updates'), readNote: (id: string) => ipcRenderer.invoke('read-note', id), exportNote: (filename: string, content: string) => ipcRenderer.invoke('export-note', filename, content), diff --git a/package-lock.json b/package-lock.json index 99f411c..08300f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "papercache", - "version": "0.1.17", + "version": "0.1.18", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "papercache", - "version": "0.1.17", + "version": "0.1.18", "dependencies": { "@codemirror/lang-markdown": "^6.5.0", "@codemirror/language": "^6.12.3", @@ -15,7 +15,7 @@ "@codemirror/view": "^6.43.0", "@lezer/highlight": "^1.2.3", "@uiw/react-codemirror": "^4.25.10", - "marked": "^18.0.4", + "electron-updater": "^6.8.9", "mathjs": "^15.2.0", "openai": "^6.39.1", "react": "^19.2.6", @@ -33,6 +33,7 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", + "@vitest/coverage-v8": "^4.1.9", "electron": "^42.3.0", "electron-builder": "^26.8.1", "esbuild": "^0.28.0", @@ -52,6 +53,9 @@ "vite-plugin-electron": "^0.29.1", "vite-plugin-electron-renderer": "^0.14.7", "vitest": "^4.1.7" + }, + "engines": { + "node": ">=22" } }, "node_modules/@adobe/css-tools": { @@ -381,6 +385,16 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", @@ -3048,17 +3062,48 @@ } } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz", + "integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.9", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.9", + "vitest": "4.1.9" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", - "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -3067,13 +3112,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", - "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.8", + "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -3094,9 +3139,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", - "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", "dev": true, "license": "MIT", "dependencies": { @@ -3107,13 +3152,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", - "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.8", + "@vitest/utils": "4.1.9", "pathe": "^2.0.3" }, "funding": { @@ -3121,14 +3166,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", - "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -3137,9 +3182,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", - "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", "dev": true, "license": "MIT", "funding": { @@ -3147,13 +3192,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", - "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.8", + "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -3449,7 +3494,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/aria-query": { @@ -3487,6 +3531,25 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz", + "integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -3692,7 +3755,6 @@ "version": "9.7.0", "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz", "integrity": "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==", - "dev": true, "license": "MIT", "dependencies": { "debug": "^4.3.4", @@ -4372,7 +4434,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -4730,6 +4791,34 @@ "dev": true, "license": "ISC" }, + "node_modules/electron-updater": { + "version": "6.8.9", + "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.8.9.tgz", + "integrity": "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==", + "license": "MIT", + "dependencies": { + "builder-util-runtime": "9.7.0", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0", + "lazy-val": "^1.0.5", + "lodash.escaperegexp": "^4.1.2", + "lodash.isequal": "^4.5.0", + "semver": "~7.7.3", + "tiny-typed-emitter": "^2.1.0" + } + }, + "node_modules/electron-updater/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/electron-winstaller": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz", @@ -5524,7 +5613,6 @@ "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", @@ -5814,7 +5902,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/has-flag": { @@ -5946,6 +6033,13 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", @@ -6148,6 +6242,45 @@ "node": ">=18" } }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jake": { "version": "10.9.4", "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", @@ -6385,7 +6518,6 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", - "dev": true, "funding": [ { "type": "github", @@ -6514,7 +6646,6 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", - "dev": true, "license": "MIT", "dependencies": { "universalify": "^2.0.0" @@ -6549,7 +6680,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", - "dev": true, "license": "MIT" }, "node_modules/levn": { @@ -6975,6 +7105,19 @@ "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", "license": "MIT" }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, "node_modules/log-update": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", @@ -7166,16 +7309,32 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/marked": { - "version": "18.0.4", - "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.4.tgz", - "integrity": "sha512-c/BTaKzg0G6ezQx97DAkYU7k0HM6ys0FqYeKBL6hlBByZwy+ycA1+f0vDdjMHKKeEjdgkx0GOv9Il6D+85cOqA==", + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, "license": "MIT", - "bin": { - "marked": "bin/marked.js" + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" }, "engines": { - "node": ">= 20" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/matcher": { @@ -7368,7 +7527,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -8385,7 +8543,6 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=11.0.0" @@ -8841,6 +8998,12 @@ "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==", "license": "MIT" }, + "node_modules/tiny-typed-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz", + "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -9082,7 +9245,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 10.0.0" @@ -9273,19 +9435,19 @@ "license": "MIT" }, "node_modules/vitest": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", - "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.8", - "@vitest/mocker": "4.1.8", - "@vitest/pretty-format": "4.1.8", - "@vitest/runner": "4.1.8", - "@vitest/snapshot": "4.1.8", - "@vitest/spy": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -9313,12 +9475,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.8", - "@vitest/browser-preview": "4.1.8", - "@vitest/browser-webdriverio": "4.1.8", - "@vitest/coverage-istanbul": "4.1.8", - "@vitest/coverage-v8": "4.1.8", - "@vitest/ui": "4.1.8", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" diff --git a/package.json b/package.json index 4cf99bd..dbea129 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,12 @@ { "name": "papercache", "private": true, - "version": "0.1.17", + "version": "0.2.10", "type": "module", "main": "dist-electron/main.js", + "engines": { + "node": ">=22" + }, "author": { "name": "Aditya Sharma", "email": "adityasharma.variable@gmail.com" @@ -44,6 +47,10 @@ "dist-electron/**/*", "package.json" ], + "publish": { + "provider": "github", + "releaseType": "release" + }, "mac": { "icon": "public/icon.png", "category": "public.app-category.productivity", @@ -77,7 +84,7 @@ "@codemirror/view": "^6.43.0", "@lezer/highlight": "^1.2.3", "@uiw/react-codemirror": "^4.25.10", - "marked": "^18.0.4", + "electron-updater": "^6.8.9", "mathjs": "^15.2.0", "openai": "^6.39.1", "react": "^19.2.6", @@ -95,6 +102,7 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", + "@vitest/coverage-v8": "^4.1.9", "electron": "^42.3.0", "electron-builder": "^26.8.1", "esbuild": "^0.28.0", diff --git a/src/App.css b/src/App.css index 2abeef8..4bab2d4 100644 --- a/src/App.css +++ b/src/App.css @@ -209,7 +209,7 @@ body { } .cm-code-lang { - font-family: 'JetBrains Mono', 'Courier New', monospace; + font-family: inherit; font-size: 0.65em; margin-right: 6px; opacity: 0.7; @@ -219,7 +219,7 @@ body { .cm-code-block-line { background-color: rgba(128, 128, 128, 0.08); - font-family: 'JetBrains Mono', 'Courier New', monospace; + font-family: inherit; padding-left: 12px !important; padding-right: 12px !important; display: block !important; @@ -308,7 +308,7 @@ body { border-bottom: 1px solid rgba(128, 128, 128, 0.2); outline: none; box-sizing: border-box; - font-family: 'JetBrains Mono', monospace; + font-family: inherit; } .note-search-list { @@ -712,7 +712,7 @@ body { } .cm-rem-line-text { - font-family: 'JetBrains Mono', 'Courier New', monospace; + font-family: inherit; } .cm-rem-widget.cm-rem-overdue { diff --git a/src/App.tsx b/src/App.tsx index 49b21b1..7b49d7a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,11 +1,4 @@ -import { useCallback, useMemo, useRef, useEffect } from 'react' -import CodeMirror from '@uiw/react-codemirror' -import { markdown } from '@codemirror/lang-markdown' -import { EditorView, ViewUpdate, keymap } from '@codemirror/view' -import { Prec } from '@codemirror/state' -import { syntaxHighlighting } from '@codemirror/language' -import { search } from '@codemirror/search' -import { insertTab, indentLess } from '@codemirror/commands' +import { useRef, useEffect } from 'react' import './App.css' import GraphView from './GraphView' @@ -20,27 +13,16 @@ import { useVariables } from './hooks/useVariables' import { useReminders } from './hooks/useReminders' import { useGlobalHotkey } from './hooks/useGlobalHotkey' -import { mdHighlighting } from './lib/editor/matchers' -import { - numberPlugin, - symbolPlugin, - aiPlugin, - mathPlugin, - decomposedPlugins, -} from './lib/editor/plugins' import { NoteSearch } from './components/NoteSearch' import { MainActionMenu } from './components/MainActionMenu' import { NoteTitleBar } from './components/NoteTitleBar' -import { getSecure } from './lib/safeStorage' - -import { MathEvaluator } from './lib/editor/MathEvaluator' +import { Editor, type EditorRef } from './components/Editor' function App() { const notes = useAppStore((state) => state.notes) const setNotes = useAppStore((state) => state.setNotes) const currentNoteIndex = useAppStore((state) => state.currentNoteIndex) const setCurrentNoteIndex = useAppStore((state) => state.setCurrentNoteIndex) - const zoomLevel = useAppStore((state) => state.zoomLevel) const showGraphView = useAppStore((state) => state.showGraphView) const setShowGraphView = useAppStore((state) => state.setShowGraphView) const showRemindersView = useAppStore((state) => state.showRemindersView) @@ -48,39 +30,13 @@ function App() { const showNoteSearch = useAppStore((state) => state.showNoteSearch) const setShowMainActionMenu = useAppStore((state) => state.setShowMainActionMenu) - const { - themePreset, - fontFamily, - showRulings, - bgType, - bgColor, - bgImage, - textColor, - numColor, - symColor, - aiColor, - mathColor, - } = useSettingsStore() - - const { apiBaseUrl, apiModel, aiSystemPrompt, setApiKey, apiKey } = useAIStore() - - // Load Secure API Key asynchronously on mount - useEffect(() => { - async function fetchApiKey() { - const key = await getSecure('papercache-apikey') - if (key) { - setApiKey(key) - } - } - fetchApiKey() - }, [setApiKey]) + const { themePreset, fontFamily, showRulings, bgType, bgColor, bgImage, textColor, numColor } = + useSettingsStore() - const editorRef = useRef<{ view?: EditorView } | null>(null) + const editorRef = useRef(null) const searchInputRef = useRef(null) - const activeNote = notes[currentNoteIndex] || { id: '', content: '', mtime: 0 } - // Custom Hooks useNoteStorage() useVariables() @@ -112,7 +68,7 @@ function App() { aiColor: localStorage.getItem('papercache-ai-color') || '#8b5cf6', mathColor: localStorage.getItem('papercache-math-color') || '#10b981', }) - // Refresh AI Store (API Key handled securely, we don't listen to localStorage for it directly) + // Refresh AI Store useAIStore.setState({ apiBaseUrl: localStorage.getItem('papercache-api-base-url') || 'https://openrouter.ai/api/v1', @@ -122,47 +78,20 @@ function App() { localStorage.getItem('papercache-ai-system-prompt') || 'You are a helpful assistant directly inside a markdown note. You can format your responses with markdown.', }) - // Fetch Secure API Key again - getSecure('papercache-apikey').then((key) => { - if (key) setApiKey(key) - }) } window.addEventListener('storage', handleStorageChange) return () => { window.removeEventListener('storage', handleStorageChange) } - }, [setApiKey]) - - const handleEditorChange = useCallback( - (val: string, viewUpdate?: ViewUpdate) => { - const updatedNotes = [...notes] - if (updatedNotes[currentNoteIndex]) { - updatedNotes[currentNoteIndex].content = val - setNotes(updatedNotes) - window.electronAPI.saveNote(activeNote.id, val) - } - - if (viewUpdate?.transactions?.some((tr) => tr.docChanged)) { - if (editorRef.current?.view) { - MathEvaluator.triggerMathEvaluation(editorRef.current.view) - } - } - }, - [notes, currentNoteIndex, activeNote.id, setNotes] - ) + }, []) - const containerStyle: React.CSSProperties & Record = { + const containerStyle: React.CSSProperties = { + fontFamily: fontFamily, '--font-family': fontFamily, - '--text-color': textColor, - '--custom-color-num': numColor, - '--custom-color-sym': symColor, - '--custom-color-ai': aiColor, - '--custom-color-math': mathColor, - zoom: zoomLevel, - } + } as React.CSSProperties if (bgType === 'color') { - containerStyle['--bg-color'] = bgColor + containerStyle['--bg-color' as string] = bgColor containerStyle.backgroundImage = 'none' } else if (bgType === 'image' && bgImage) { containerStyle.backgroundImage = `url(${bgImage})` @@ -171,263 +100,9 @@ function App() { containerStyle.backgroundRepeat = 'no-repeat' } - const editorExtensions = useMemo( - () => [ - EditorView.lineWrapping, - Prec.highest( - // eslint-disable-next-line react-hooks/refs - keymap.of([ - { key: 'Tab', preventDefault: true, run: insertTab }, - { key: 'Shift-Tab', preventDefault: true, run: indentLess }, - { - key: 'Mod-h', - run: (view) => { - const selection = view.state.selection.main - if (!selection.empty) { - const selectedText = view.state.doc.sliceString(selection.from, selection.to) - view.dispatch({ - changes: { - from: selection.from, - to: selection.to, - insert: `==${selectedText}==`, - }, - selection: { anchor: selection.from + 2, head: selection.to + 2 }, - }) - return true - } - return false - }, - }, - { - key: 'Mod-e', - run: () => { - const note = useAppStore.getState().notes[useAppStore.getState().currentNoteIndex] - if (note) { - const filename = note.id.split('/').pop() || 'note.md' - window.electronAPI.exportNote(filename, note.content) - } - return true - }, - }, - { - key: 'Mod-Backspace', - run: () => { - const note = useAppStore.getState().notes[useAppStore.getState().currentNoteIndex] - if (note) { - if (note.id.startsWith('commands/')) { - alert('Files in the commands folder cannot be deleted.') - return true - } - if (confirm('Delete this note?')) { - window.electronAPI.deleteNote(note.id) - setNotes((prev) => prev.filter((n) => n.id !== note.id)) - if ( - useAppStore.getState().currentNoteIndex >= - useAppStore.getState().notes.length - 1 - ) - setCurrentNoteIndex(Math.max(0, useAppStore.getState().notes.length - 2)) - } - } - return true - }, - }, - { - key: 'Mod-Delete', - run: () => { - const note = useAppStore.getState().notes[useAppStore.getState().currentNoteIndex] - if (note) { - if (note.id.startsWith('commands/')) { - alert('Files in the commands folder cannot be deleted.') - return true - } - if (confirm('Delete this note?')) { - window.electronAPI.deleteNote(note.id) - setNotes((prev) => prev.filter((n) => n.id !== note.id)) - if ( - useAppStore.getState().currentNoteIndex >= - useAppStore.getState().notes.length - 1 - ) - setCurrentNoteIndex(Math.max(0, useAppStore.getState().notes.length - 2)) - } - } - return true - }, - }, - { - key: 'Enter', - run: (view) => { - const pos = view.state.selection.main.head - const line = view.state.doc.lineAt(pos) - const lineText = line.text.trim() - const lowerLine = lineText.toLowerCase() - if ( - lowerLine.startsWith('/ai') || - lowerLine.startsWith('/ctx') || - lowerLine.startsWith('/context') - ) { - const isCtx = lowerLine.startsWith('/ctx') || lowerLine.startsWith('/context') - const prefixLength = lowerLine.startsWith('/context') - ? 8 - : lowerLine.startsWith('/ctx') - ? 4 - : 3 - const prompt = lineText.substring(prefixLength).trim() - if (!apiKey) { - const errorText = '\n\u200BError - Set your OpenAI API key in settings\u200C\n' - view.dispatch({ changes: { from: line.to, insert: errorText } }) - return true - } - - const thinkingText = '\n\u200B...\u200C\n' - view.dispatch({ changes: { from: line.to, insert: thinkingText } }) - ;(async () => { - try { - let finalBaseUrl = apiBaseUrl.trim() - if (finalBaseUrl.endsWith('/chat/completions')) { - finalBaseUrl = finalBaseUrl.replace('/chat/completions', '') - } - if (finalBaseUrl.endsWith('/')) { - finalBaseUrl = finalBaseUrl.slice(0, -1) - } - - const systemContent = aiSystemPrompt.trim() - const messages: { role: 'user' | 'system'; content: string }[] = [] - if (systemContent) { - messages.push({ role: 'system', content: systemContent }) - } - - let finalPrompt = prompt - if (isCtx) { - const fullNoteText = view.state.doc.toString() - const MAX_CONTEXT_LENGTH = 50000 - let contextText = fullNoteText - if (contextText.length > MAX_CONTEXT_LENGTH) { - contextText = - contextText.substring(0, MAX_CONTEXT_LENGTH) + - '\n...[Context truncated due to length]' - } - finalPrompt = `Context:\n${contextText}\n\nPrompt:\n${prompt}` - } - - messages.push({ role: 'user', content: finalPrompt }) - - window.electronAPI - .openAIChat({ - model: apiModel.trim() || 'nvidia/nemotron-3-super-120b-a12b:free', - messages: messages, - apiKey: apiKey.trim() || '', - baseURL: finalBaseUrl || '', - }) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .then((completion: any) => { - let response: string - if (completion.choices && completion.choices.length > 0) { - response = completion.choices[0].message?.content || '' - } else if (completion.error) { - throw new Error(completion.error.message || 'Unknown API Error') - } else { - throw new Error( - 'Unexpected response format: ' + JSON.stringify(completion) - ) - } - - const docStr = view.state.doc.toString() - const finalVal = docStr.replace( - '\n\u200B...\u200C\n', - '\n\u200B' + response + '\u200C\n' - ) - handleEditorChange(finalVal) - }) - .catch((error) => { - const docStr = view.state.doc.toString() - const errorVal = docStr.replace( - '\n\u200B...\u200C\n', - '\n\u200BError - ' + error.message + '\u200C\n' - ) - handleEditorChange(errorVal) - }) - } catch (err: unknown) { - const docStr = view.state.doc.toString() - const errorVal = docStr.replace( - '\n\u200B...\u200C\n', - '\n\u200BSetup Error - ' + - ((err as Error).message || String(err)) + - '\u200C\n' - ) - handleEditorChange(errorVal) - } - })() - - return true - } - return false - }, - }, - ]) - ), - search({ top: true }), - markdown(), - syntaxHighlighting(mdHighlighting), - numberPlugin, - symbolPlugin, - aiPlugin, - mathPlugin, - ...decomposedPlugins, - EditorView.domEventHandlers({ - mousedown: (event) => { - const target = event.target as HTMLElement - const webLink = target?.closest('.cm-custom-clickable-link') - const fileLink = target?.closest('.cm-custom-file-link') - - if ((webLink || fileLink) && (event.metaKey || event.ctrlKey)) { - event.preventDefault() - if (webLink) { - const url = webLink.getAttribute('data-url') - if (url) { - let finalUrl = url - if (!/^https?:\/\//i.test(finalUrl)) { - finalUrl = 'https://' + finalUrl - } - window.electronAPI.openExternal(finalUrl) - } - } else if (fileLink) { - const path = fileLink.getAttribute('data-path') - if (path) { - window.dispatchEvent(new CustomEvent('open-papercache-note', { detail: { path } })) - } - } - return true - } - return false - }, - }), - ], - [ - apiKey, - apiBaseUrl, - apiModel, - aiSystemPrompt, - handleEditorChange, - setCurrentNoteIndex, - setNotes, - ] - ) - - useEffect(() => { - const handleWindowFocus = () => { - if (editorRef.current?.view && !editorRef.current.view.hasFocus) { - editorRef.current.view.focus() - } - } - window.addEventListener('focus', handleWindowFocus) - return () => window.removeEventListener('focus', handleWindowFocus) - }, []) - const handleAppClick = () => { setShowMainActionMenu(false) - if (editorRef.current?.view && !editorRef.current.view.hasFocus) { - editorRef.current.view.focus() - } + editorRef.current?.focus() } return ( @@ -461,10 +136,7 @@ function App() { window.electronAPI.saveNote(note.id, newContent) if (idx === currentNoteIndex) { - const view = editorRef.current?.view - if (view) { - view.dispatch({ changes: { from, to, insert } }) - } + editorRef.current?.dispatch({ changes: { from, to, insert } }) } } return newNotes @@ -494,22 +166,7 @@ function App() { /> )} -
- -
+ ) } diff --git a/src/GraphView.tsx b/src/GraphView.tsx index 9472ec3..7b68381 100644 --- a/src/GraphView.tsx +++ b/src/GraphView.tsx @@ -1,4 +1,4 @@ -import { useMemo, useCallback } from 'react' +import { useMemo, useCallback, useEffect } from 'react' import ForceGraph2D from 'react-force-graph-2d' import { getFolderColor } from './utils' @@ -68,6 +68,16 @@ export default function GraphView({ [onNodeClick] ) + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose() + } + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [onClose]) + return (
-

+

Graph View

diff --git a/src/Settings.css b/src/Settings.css index f6f90e1..230420a 100644 --- a/src/Settings.css +++ b/src/Settings.css @@ -1,5 +1,4 @@ .settings-container { - font-family: 'JetBrains Mono', monospace; background: #1e1e1e; color: #fff; height: 100vh; @@ -64,7 +63,7 @@ section h3 { background: #2a2a2a; color: #fff; border-radius: 6px; - font-family: 'JetBrains Mono', monospace; + font-family: inherit; font-size: 14px; box-sizing: border-box; outline: none; @@ -78,7 +77,7 @@ section h3 { background: #2a2a2a; color: #fff; border-radius: 6px; - font-family: 'JetBrains Mono', monospace; + font-family: inherit; font-size: 14px; box-sizing: border-box; outline: none; diff --git a/src/Settings.test.tsx b/src/Settings.test.tsx index 1d757a9..8d85536 100644 --- a/src/Settings.test.tsx +++ b/src/Settings.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react' import Settings from './Settings' describe('Settings Component', () => { @@ -12,11 +12,16 @@ describe('Settings Component', () => { updateGlobalShortcut: vi.fn(), closeWindow: vi.fn(), quitApp: vi.fn(), + getApiKeyStatus: vi.fn().mockResolvedValue(false), + setApiKey: vi.fn().mockResolvedValue(true), } as any // eslint-disable-line @typescript-eslint/no-explicit-any }) - it('renders settings headers correctly', () => { - render() + it('renders settings headers correctly', async () => { + await act(async () => { + render() + }) + expect(screen.getByText('Settings')).toBeInTheDocument() expect(screen.getByText('AI Configuration')).toBeInTheDocument() expect(screen.getByText('Global Shortcuts')).toBeInTheDocument() @@ -24,16 +29,20 @@ describe('Settings Component', () => { expect(screen.getByText('Appearance')).toBeInTheDocument() }) - it('loads initial state from localStorage', async () => { - localStorage.setItem('papercache-apikey-secure', 'sk-test-key') - render() + it('loads API key status from IPC', async () => { + ;(window.electronAPI.getApiKeyStatus as any).mockResolvedValue(true) + await act(async () => { + render() + }) - const apiKeyInput = screen.getByPlaceholderText('sk-...') as HTMLInputElement - await waitFor(() => expect(apiKeyInput.value).toBe('sk-test-key')) + expect(screen.getByText('API Key ✅ (Set)')).toBeInTheDocument() + expect(screen.getByPlaceholderText('Enter new key to replace existing')).toBeInTheDocument() }) - it('updates state when inputs change', () => { - render() + it('updates state when inputs change', async () => { + await act(async () => { + render() + }) const apiKeyInput = screen.getByPlaceholderText('sk-...') fireEvent.change(apiKeyInput, { target: { value: 'sk-new-key' } }) @@ -41,8 +50,10 @@ describe('Settings Component', () => { expect((apiKeyInput as HTMLInputElement).value).toBe('sk-new-key') }) - it('saves settings to localStorage on Save Settings button click', async () => { - render() + it('saves settings to IPC on Save Settings button click', async () => { + await act(async () => { + render() + }) const apiKeyInput = screen.getByPlaceholderText('sk-...') fireEvent.change(apiKeyInput, { target: { value: 'sk-new-key' } }) @@ -51,13 +62,15 @@ describe('Settings Component', () => { fireEvent.click(saveButton) await waitFor(() => { - expect(localStorage.getItem('papercache-apikey-secure')).toBe('sk-new-key') + expect(window.electronAPI.setApiKey).toHaveBeenCalledWith('sk-new-key') expect(window.electronAPI.closeWindow).toHaveBeenCalled() }) }) - it('calls quitApp when Quit PaperCache is clicked', () => { - render() + it('calls quitApp when Quit PaperCache is clicked', async () => { + await act(async () => { + render() + }) const quitButton = screen.getByText('Quit') fireEvent.click(quitButton) diff --git a/src/Settings.tsx b/src/Settings.tsx index 97cd863..127cc5a 100644 --- a/src/Settings.tsx +++ b/src/Settings.tsx @@ -1,14 +1,14 @@ import { useState, useEffect } from 'react' -import { getSecure, setSecure } from './lib/safeStorage' import { SETTINGS_KEYS } from './lib/settingsKeys' import './Settings.css' export default function Settings() { const [apiKey, setApiKey] = useState('') + const [isApiKeySet, setIsApiKeySet] = useState(false) useEffect(() => { - getSecure('papercache-apikey').then((key) => { - if (key) setApiKey(key) + window.electronAPI.getApiKeyStatus().then((status) => { + setIsApiKeySet(status) }) const handleKeyDown = (e: KeyboardEvent) => { @@ -72,12 +72,19 @@ export default function Settings() { ) const saveSettings = async () => { - await setSecure('papercache-apikey', apiKey) - localStorage.removeItem('papercache-apikey') localStorage.setItem(SETTINGS_KEYS.API_BASE_URL, apiBaseUrl) localStorage.setItem(SETTINGS_KEYS.API_MODEL, apiModel) localStorage.setItem(SETTINGS_KEYS.AI_SYSTEM_PROMPT, aiSystemPrompt) + if (apiKey) { + const success = await window.electronAPI.setApiKey(apiKey) + if (!success) { + alert('Failed to save API key securely. Check console.') + } + } else { + await window.electronAPI.setApiKey('') // clear key + } + localStorage.setItem(SETTINGS_KEYS.FONT_FAMILY, fontFamily) localStorage.setItem(SETTINGS_KEYS.SHOW_RULINGS, showRulings.toString()) localStorage.setItem(SETTINGS_KEYS.THEME_PRESET, themePreset) @@ -127,7 +134,7 @@ export default function Settings() { } return ( -
+

Settings

@@ -136,12 +143,12 @@ export default function Settings() {

AI Configuration

- + setApiKey(e.target.value)} - placeholder="sk-..." + placeholder={isApiKeySet ? 'Enter new key to replace existing' : 'sk-...'} />
@@ -179,7 +186,7 @@ export default function Settings() { border: '1px solid rgba(128,128,128,0.2)', color: 'inherit', borderRadius: '4px', - fontFamily: "'JetBrains Mono', monospace", + fontFamily: 'inherit', resize: 'vertical', textAlign: 'center', }} diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx new file mode 100644 index 0000000..1f34657 --- /dev/null +++ b/src/components/Editor.tsx @@ -0,0 +1,94 @@ +import { useCallback, useRef, useEffect, forwardRef, useImperativeHandle } from 'react' +import CodeMirror from '@uiw/react-codemirror' +import { ViewUpdate } from '@codemirror/view' +import { useAppStore } from '../store/useAppStore' +import { useSettingsStore } from '../store/useSettingsStore' +import { MathEvaluator } from '../lib/editor/MathEvaluator' +import { useEditorExtensions } from '../lib/editor/extensions' + +import { type TransactionSpec } from '@codemirror/state' +import { EditorView } from '@codemirror/view' + +export interface EditorRef { + dispatch: (tx: TransactionSpec) => void + focus: () => void + view?: EditorView +} + +export const Editor = forwardRef((_props, ref) => { + const notes = useAppStore((state) => state.notes) + const setNotes = useAppStore((state) => state.setNotes) + const currentNoteIndex = useAppStore((state) => state.currentNoteIndex) + const activeNote = notes[currentNoteIndex] || { id: '', content: '', mtime: 0 } + + const themePreset = useSettingsStore((state) => state.themePreset) + + const editorRef = useRef(null) + + useImperativeHandle(ref, () => ({ + dispatch: (tx: TransactionSpec) => { + if (editorRef.current?.view) { + editorRef.current.view.dispatch(tx) + } + }, + focus: () => { + if (editorRef.current?.view) { + editorRef.current.view.focus() + } + }, + })) + + const handleEditorChange = useCallback( + (val: string, viewUpdate?: ViewUpdate) => { + setNotes((prevNotes) => { + const updatedNotes = [...prevNotes] + if (updatedNotes[currentNoteIndex]) { + updatedNotes[currentNoteIndex] = { + ...updatedNotes[currentNoteIndex], + content: val, + } + window.electronAPI.saveNote(updatedNotes[currentNoteIndex].id, val) + } + return updatedNotes + }) + + if (viewUpdate?.transactions?.some((tr) => tr.docChanged)) { + if (editorRef.current?.view) { + MathEvaluator.triggerMathEvaluation(editorRef.current.view) + } + } + }, + [currentNoteIndex, setNotes] + ) + + const extensions = useEditorExtensions(handleEditorChange) + + useEffect(() => { + const handleWindowFocus = () => { + if (editorRef.current?.view && !editorRef.current.view.hasFocus) { + editorRef.current.view.focus() + } + } + window.addEventListener('focus', handleWindowFocus) + return () => window.removeEventListener('focus', handleWindowFocus) + }, []) + + return ( +
+ +
+ ) +}) diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..fa00360 --- /dev/null +++ b/src/components/ErrorBoundary.tsx @@ -0,0 +1,64 @@ +import { Component, type ErrorInfo, type ReactNode } from 'react' + +interface Props { + children?: ReactNode +} + +interface State { + hasError: boolean + error?: Error +} + +export class ErrorBoundary extends Component { + public state: State = { + hasError: false, + } + + public static getDerivedStateFromError(error: Error): State { + return { hasError: true, error } + } + + public componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error('Uncaught error:', error, errorInfo) + } + + public render() { + if (this.state.hasError) { + return ( +
+

Something went wrong.

+
+            {this.state.error?.message}
+          
+ +
+ ) + } + + return this.props.children + } +} diff --git a/src/components/RemindersPage.tsx b/src/components/RemindersPage.tsx index 8b7e806..a1c197d 100644 --- a/src/components/RemindersPage.tsx +++ b/src/components/RemindersPage.tsx @@ -84,6 +84,16 @@ export const RemindersPage: React.FC = ({ } }, []) + React.useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose() + } + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [onClose]) + return (
= ({ zIndex: 1000, display: 'flex', flexDirection: 'column', - fontFamily: 'system-ui, -apple-system, sans-serif', + fontFamily: 'inherit', }} onClick={(e) => e.stopPropagation()} > @@ -110,7 +120,7 @@ export const RemindersPage: React.FC = ({ alignItems: 'center', }} > -

Tasks

+

Tasks