From 2617d07d9d9302c2c86fe6aa3f91f699113818be Mon Sep 17 00:00:00 2001 From: Aditya Date: Sat, 20 Jun 2026 01:05:05 +0530 Subject: [PATCH] Refactor App.tsx, move to zustand, and update code quality --- .editorconfig | 9 + .github/workflows/ci.yml | 45 +- .github/workflows/release.yml | 34 + .husky/pre-commit | 1 + .prettierrc | 6 +- electron/main.ts | 14 + electron/preload.ts | 2 + eslint.config.js | 4 +- package-lock.json | 601 +++++++++++++- package.json | 33 +- src/App.css | 12 +- src/App.tsx | 1303 ++++-------------------------- src/GraphView.tsx | 2 +- src/Settings.tsx | 25 +- src/components/RemindersPage.tsx | 154 ++-- src/hooks/useGlobalHotkey.ts | 158 ++++ src/hooks/useNoteStorage.ts | 53 ++ src/hooks/useReminders.ts | 50 ++ src/hooks/useVariables.ts | 25 + src/lib/editor/matchers.ts | 41 + src/lib/editor/plugins.ts | 611 ++++++++++++++ src/lib/editor/widgets.ts | 160 ++++ src/lib/safeStorage.ts | 18 + src/main.tsx | 12 + src/store/useAIStore.ts | 36 + src/store/useAppStore.ts | 106 +++ src/store/useSettingsStore.ts | 86 ++ src/types.d.ts | 26 + src/utils.ts | 2 +- tsconfig.app.json | 4 + 30 files changed, 2355 insertions(+), 1278 deletions(-) create mode 100644 .editorconfig create mode 100644 .github/workflows/release.yml create mode 100644 .husky/pre-commit create mode 100644 src/hooks/useGlobalHotkey.ts create mode 100644 src/hooks/useNoteStorage.ts create mode 100644 src/hooks/useReminders.ts create mode 100644 src/hooks/useVariables.ts create mode 100644 src/lib/editor/matchers.ts create mode 100644 src/lib/editor/plugins.ts create mode 100644 src/lib/editor/widgets.ts create mode 100644 src/lib/safeStorage.ts create mode 100644 src/store/useAIStore.ts create mode 100644 src/store/useAppStore.ts create mode 100644 src/store/useSettingsStore.ts create mode 100644 src/types.d.ts diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..c6c8b36 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 10ac6ad..f010df3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,40 +1,19 @@ name: CI - on: push: - branches: ['main'] + branches: [main] pull_request: - branches: ['main'] - + branches: [main] jobs: - build-and-test: - strategy: - matrix: - os: [ubuntu-latest, windows-latest] - - runs-on: ${{ matrix.os }} - + ci: + runs-on: ubuntu-latest steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up Node.js - uses: actions/setup-node@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: - node-version: 22 - cache: 'npm' - - - name: Install dependencies - run: npm install - - - name: Run linter - run: npm run lint - - - name: Check formatting - run: npm run format:check - - - name: Run tests - run: npm run test - - - name: Build project - run: npm run build + node-version: '20' + - run: npm ci + - run: npm run typecheck + - run: npm run lint + - run: npm run format:check + - run: npm run test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..dbbe5f5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,34 @@ +name: Release +on: + push: + branches: [main] +permissions: + contents: write +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-node@v4 + with: + node-version: '20' + - run: npm ci + - name: Bump version and push tag + id: tag_version + uses: mathieudutour/github-tag-action@v6.2 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + - run: npm run build + - run: npm run package + - name: Create GitHub Release + uses: softprops/action-gh-release@v3 + with: + tag_name: ${{ steps.tag_version.outputs.new_tag }} + files: | + release/*.zip + release/*.dmg + release/*.exe + release/*.AppImage + release/*.deb diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..2312dc5 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +npx lint-staged diff --git a/.prettierrc b/.prettierrc index dce8efd..af88520 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,7 +1,7 @@ { "semi": false, "singleQuote": true, - "tabWidth": 2, - "trailingComma": "all", - "printWidth": 100 + "trailingComma": "es5", + "printWidth": 100, + "tabWidth": 2 } diff --git a/electron/main.ts b/electron/main.ts index 75a0726..8d855eb 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -10,6 +10,7 @@ import { nativeTheme, shell, dialog, + safeStorage, } from 'electron' import path from 'node:path' import { fileURLToPath } from 'node:url' @@ -637,3 +638,16 @@ ipcMain.on('open-file', (_, filePath) => { const absolutePath = path.isAbsolute(filePath) ? filePath : path.join(NOTES_DIR, filePath) shell.openPath(absolutePath) }) + +ipcMain.handle('safe-storage-encrypt', (_, val: string) => { + return safeStorage.isEncryptionAvailable() ? safeStorage.encryptString(val).toString('base64') : val +}) + +ipcMain.handle('safe-storage-decrypt', (_, val: string) => { + if (!safeStorage.isEncryptionAvailable()) return val + try { + return safeStorage.decryptString(Buffer.from(val, 'base64')) + } catch (e) { + return val + } +}) diff --git a/electron/preload.ts b/electron/preload.ts index 2900375..3f162ab 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -25,4 +25,6 @@ contextBridge.exposeInMainWorld('electronAPI', { onTriggerTasks: (callback: () => void) => { ipcRenderer.on('trigger-tasks', () => callback()) }, + safeStorageEncrypt: (val: string) => ipcRenderer.invoke('safe-storage-encrypt', val), + safeStorageDecrypt: (val: string) => ipcRenderer.invoke('safe-storage-decrypt', val), }) diff --git a/eslint.config.js b/eslint.config.js index 159062b..cf91ae2 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -21,8 +21,10 @@ export default defineConfig([ globals: globals.browser, }, rules: { + 'no-console': 'warn', + 'prefer-const': 'error', '@typescript-eslint/no-explicit-any': 'warn', - '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], + '@typescript-eslint/no-unused-vars': 'error', '@typescript-eslint/no-require-imports': 'warn', 'no-empty': 'warn', 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], diff --git a/package-lock.json b/package-lock.json index 5195e25..b9af618 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,8 @@ "openai": "^6.39.1", "react": "^19.2.6", "react-dom": "^19.2.6", - "react-force-graph-2d": "^1.29.1" + "react-force-graph-2d": "^1.29.1", + "zustand": "^5.0.14" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -41,7 +42,9 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.6.0", + "husky": "^9.1.7", "jsdom": "^29.1.1", + "lint-staged": "^17.0.7", "prettier": "^3.8.3", "typescript": "~6.0.2", "typescript-eslint": "^8.59.2", @@ -991,9 +994,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "dev": true, "license": "MIT", "optional": true, @@ -2255,6 +2258,40 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@rolldown/binding-win32-arm64-msvc": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", @@ -2627,7 +2664,7 @@ "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true, "dependencies": { @@ -3182,6 +3219,22 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -3775,6 +3828,85 @@ "node": ">=8" } }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", + "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^8.0.0", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/cli-truncate/node_modules/string-width": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -3982,7 +4114,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/d3-array": { @@ -4677,6 +4809,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/err-code": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", @@ -5088,6 +5233,13 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "dev": true, + "license": "MIT" + }, "node_modules/expect": { "version": "30.4.1", "resolved": "https://registry.npmjs.org/expect/-/expect-30.4.1.tgz", @@ -5414,6 +5566,19 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -5809,6 +5974,22 @@ "node": ">= 14" } }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -6625,6 +6806,125 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lint-staged": { + "version": "17.0.7", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.0.7.tgz", + "integrity": "sha512-JrSobt+tW3rH8IOMi8tDZd3foorM5yPEkLD/V2NxobgHrFfHWGee4MOLVuZeScgxftEwbHrPHIFA/ZL+nUJeuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "listr2": "^10.2.1", + "picomatch": "^4.0.4", + "string-argv": "^0.3.2", + "tinyexec": "^1.2.4" + }, + "bin": { + "lint-staged": "bin/lint-staged.js" + }, + "engines": { + "node": ">=22.22.1" + }, + "funding": { + "url": "https://opencollective.com/lint-staged" + }, + "optionalDependencies": { + "yaml": "^2.9.0" + } + }, + "node_modules/listr2": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.1.tgz", + "integrity": "sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^5.2.0", + "eventemitter3": "^5.0.4", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^10.0.0" + }, + "engines": { + "node": ">=22.13.0" + } + }, + "node_modules/listr2/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/listr2/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/listr2/node_modules/string-width": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/listr2/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/listr2/node_modules/wrap-ansi": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-10.0.0.tgz", + "integrity": "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "string-width": "^8.2.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -6654,6 +6954,144 @@ "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", "license": "MIT" }, + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/log-update/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/log-update/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-update/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -6808,6 +7246,19 @@ "node": ">= 0.6" } }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mimic-response": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", @@ -7107,6 +7558,22 @@ "wrappy": "1" } }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/openai": { "version": "6.39.1", "resolved": "https://registry.npmjs.org/openai/-/openai-6.39.1.tgz", @@ -7757,6 +8224,36 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", @@ -7767,6 +8264,13 @@ "node": ">= 4" } }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, "node_modules/rimraf": { "version": "2.6.3", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", @@ -7984,6 +8488,52 @@ "node": ">=8" } }, + "node_modules/slice-ansi": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", + "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -8080,6 +8630,16 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.19" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -9023,6 +9583,35 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "node_modules/zustand": { + "version": "5.0.14", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", + "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index d86c809..b374022 100644 --- a/package.json +++ b/package.json @@ -11,13 +11,26 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", - "lint": "eslint .", - "format": "prettier --write .", - "format:check": "prettier --check .", + "lint": "eslint src --ext .ts,.tsx", + "lint:fix": "eslint src --ext .ts,.tsx --fix", + "format": "prettier --write src", + "format:check": "prettier --check src", + "typecheck": "tsc --noEmit", "test": "vitest run", - "test:ui": "vitest", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", "preview": "vite preview", - "package": "npm run build && electron-builder --mac --win --linux" + "package": "npm run build && electron-builder --mac --win --linux", + "prepare": "husky" + }, + "lint-staged": { + "src/**/*.{ts,tsx}": [ + "eslint --fix", + "prettier --write" + ], + "src/**/*.{json,md}": [ + "prettier --write" + ] }, "build": { "appId": "com.variablethe.papercache", @@ -36,7 +49,8 @@ "target": [ "dmg", "zip" - ] + ], + "identity": null }, "win": { "icon": "public/icon.png", @@ -67,7 +81,8 @@ "openai": "^6.39.1", "react": "^19.2.6", "react-dom": "^19.2.6", - "react-force-graph-2d": "^1.29.1" + "react-force-graph-2d": "^1.29.1", + "zustand": "^5.0.14" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -88,7 +103,9 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.6.0", + "husky": "^9.1.7", "jsdom": "^29.1.1", + "lint-staged": "^17.0.7", "prettier": "^3.8.3", "typescript": "~6.0.2", "typescript-eslint": "^8.59.2", @@ -97,4 +114,4 @@ "vite-plugin-electron-renderer": "^0.14.7", "vitest": "^4.1.7" } -} +} \ No newline at end of file diff --git a/src/App.css b/src/App.css index 4578fc3..40df502 100644 --- a/src/App.css +++ b/src/App.css @@ -664,7 +664,7 @@ body { justify-content: center; width: 14px; height: 14px; - border: 2px solid #7EB8D4; /* pale steel blue */ + border: 2px solid #7eb8d4; /* pale steel blue */ border-radius: 50%; margin-right: 6px; cursor: pointer; @@ -677,7 +677,7 @@ body { } .cm-rem-widget.cm-rem-checked { - background-color: #7EB8D4; /* fill color */ + background-color: #7eb8d4; /* fill color */ color: white; /* tick color */ } @@ -688,8 +688,8 @@ body { .cm-rem-checked-line-text { text-decoration: line-through; - text-decoration-color: #7EB8D4; - color: #7EB8D4; + text-decoration-color: #7eb8d4; + color: #7eb8d4; opacity: 0.6; } @@ -698,9 +698,9 @@ body { } .cm-rem-widget.cm-rem-overdue { - border-color: #FF3B30; /* Red */ + border-color: #ff3b30; /* Red */ } .cm-overdue-line-text { - color: #FF3B30 !important; + color: #ff3b30 !important; } diff --git a/src/App.tsx b/src/App.tsx index 8acdb35..46355db 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,906 +1,107 @@ -import { useState, useEffect, useRef, useCallback, useMemo } from 'react' +import { useCallback, useMemo, useRef, useEffect } from 'react' import CodeMirror from '@uiw/react-codemirror' import { markdown } from '@codemirror/lang-markdown' -import { - ViewPlugin, - Decoration, - MatchDecorator, - EditorView, - keymap, - WidgetType, - ViewUpdate, -} from '@codemirror/view' +import { EditorView, keymap } from '@codemirror/view' import { Prec } from '@codemirror/state' -import { HighlightStyle, syntaxHighlighting, syntaxTree } from '@codemirror/language' -import { tags as t } from '@lezer/highlight' +import { syntaxHighlighting } from '@codemirror/language' import { search } from '@codemirror/search' import { insertTab, indentLess } from '@codemirror/commands' import * as mathjs from 'mathjs' import OpenAI from 'openai' -import GraphView from './GraphView' -import { RemindersPage } from './components/RemindersPage' -import './App.css' +import './App.css' import { getFolderColor } from './utils' +import GraphView from './GraphView' +import { RemindersPage } from './components/RemindersPage' -const mdHighlighting = HighlightStyle.define([ - { tag: t.heading1, fontSize: '1.4em', fontWeight: 'bold' }, - { tag: t.heading2, fontSize: '1.2em', fontWeight: 'bold' }, - { tag: t.heading3, fontSize: '1.1em', fontWeight: 'bold' }, - { tag: t.heading4, fontSize: '1em', fontWeight: 'bold' }, - { tag: t.heading5, fontSize: '1em', fontWeight: 'bold' }, - { tag: t.heading6, fontSize: '1em', fontWeight: 'bold' }, - { tag: t.strong, fontWeight: 'bold' }, - { tag: t.emphasis, fontStyle: 'italic' }, - { tag: t.strikethrough, textDecoration: 'line-through' }, - { tag: t.link, color: '#3b82f6', textDecoration: 'underline' }, - { tag: t.url, color: '#3b82f6' }, - { tag: t.processingInstruction, color: 'rgba(128,128,128,0.5)' }, - { tag: t.meta, color: 'rgba(128,128,128,0.5)' }, - { tag: t.punctuation, color: 'rgba(128,128,128,0.5)' }, -]) - -// Custom Decorators for syntax highlighting -const numberMatcher = new MatchDecorator({ - regexp: /\b\d+(\.\d+)?\b/g, - decoration: Decoration.mark({ class: 'cm-custom-number' }), -}) -const symbolMatcher = new MatchDecorator({ - regexp: /[+\-*/=^()]/g, - decoration: Decoration.mark({ class: 'cm-custom-symbol' }), -}) -const aiMatcher = new MatchDecorator({ - regexp: /\u200B[\s\S]*?\u200C/g, - decoration: Decoration.mark({ class: 'cm-custom-ai' }), -}) -const mathMatcher = new MatchDecorator({ - regexp: /\u200B.*/g, // matches zero-width space and everything after it - decoration: Decoration.mark({ class: 'cm-custom-math' }), -}) - -const numberPlugin = ViewPlugin.fromClass( - class { - decorations - constructor(view: any) { - this.decorations = numberMatcher.createDeco(view) - } - update(update: any) { - this.decorations = numberMatcher.updateDeco(update, this.decorations) - } - }, - { decorations: (v) => v.decorations }, -) - -const symbolPlugin = ViewPlugin.fromClass( - class { - decorations - constructor(view: any) { - this.decorations = symbolMatcher.createDeco(view) - } - update(update: any) { - this.decorations = symbolMatcher.updateDeco(update, this.decorations) - } - }, - { decorations: (v) => v.decorations }, -) - -const aiPlugin = ViewPlugin.fromClass( - class { - decorations - constructor(view: any) { - this.decorations = aiMatcher.createDeco(view) - } - update(update: any) { - this.decorations = aiMatcher.updateDeco(update, this.decorations) - } - }, - { decorations: (v) => v.decorations }, -) - -const mathPlugin = ViewPlugin.fromClass( - class { - decorations - constructor(view: any) { - this.decorations = mathMatcher.createDeco(view) - } - update(update: any) { - this.decorations = mathMatcher.updateDeco(update, this.decorations) - } - }, - { decorations: (v) => v.decorations }, -) -class CopyWidget extends WidgetType { - code: string - language: string - constructor(code: string, language: string) { - super() - this.code = code - this.language = language - } - - eq(other: CopyWidget) { - return other.code === this.code && other.language === this.language - } - - toDOM() { - const wrap = document.createElement('span') - wrap.setAttribute('aria-hidden', 'true') - wrap.className = 'cm-copy-button' - wrap.title = 'Copy code' - - if (this.language) { - const langSpan = document.createElement('sup') - langSpan.textContent = this.language - langSpan.className = 'cm-code-lang' - wrap.appendChild(langSpan) - } - - const iconSpan = document.createElement('span') - // Standard copy icon (two offset rounded rectangles) - iconSpan.innerHTML = `` - wrap.appendChild(iconSpan) - - wrap.onclick = (e) => { - e.preventDefault() - e.stopPropagation() - navigator.clipboard.writeText(this.code) - const originalHtml = iconSpan.innerHTML - // Checkmark icon - iconSpan.innerHTML = `` - setTimeout(() => { - iconSpan.innerHTML = originalHtml - }, 2000) - } - return wrap - } -} - -class CheckboxWidget extends WidgetType { - checked: boolean - pos: number - view: EditorView - - constructor(checked: boolean, pos: number, view: EditorView) { - super() - this.checked = checked - this.pos = pos - this.view = view - } - - eq(other: CheckboxWidget) { - return other.checked === this.checked && other.pos === this.pos - } - - toDOM() { - const wrap = document.createElement('span') - wrap.className = 'cm-checkbox-widget' + (this.checked ? ' cm-checkbox-checked' : '') - - if (this.checked) { - wrap.innerHTML = `` - } else { - wrap.innerHTML = `` // empty for unchecked, border provides the box - } - - wrap.onclick = (e) => { - e.preventDefault() - e.stopPropagation() - const from = this.pos - const to = this.pos + (this.checked ? 8 : 6) // length of "/checked" or "/check" - const insert = this.checked ? '/check' : '/checked' - this.view.dispatch({ - changes: { from, to, insert }, - }) - } - - return wrap - } -} - -class VariableWidget extends WidgetType { - value: string - constructor(value: string) { - super() - this.value = value - } - eq(other: VariableWidget) { - return other.value === this.value - } - toDOM() { - const span = document.createElement('span') - span.textContent = String(this.value) - span.className = 'cm-variable-pill' - return span - } -} - -class ReminderWidget extends WidgetType { - checked: boolean - overdue: boolean - pos: number - view: EditorView - - constructor(checked: boolean, overdue: boolean, pos: number, view: EditorView) { - super() - this.checked = checked - this.overdue = overdue - this.pos = pos - this.view = view - } - - eq(other: ReminderWidget) { - return other.checked === this.checked && other.pos === this.pos && other.overdue === this.overdue - } - - toDOM() { - const wrap = document.createElement('span') - wrap.className = 'cm-rem-widget' + (this.checked ? ' cm-rem-checked' : '') + (this.overdue && !this.checked ? ' cm-rem-overdue' : '') - - if (this.checked) { - wrap.innerHTML = `` - } else { - wrap.innerHTML = `` // empty for unchecked, border provides the box - } - - // Use onmousedown to prevent CodeMirror from interfering with selection - wrap.onmousedown = (e) => { - e.preventDefault() - e.stopPropagation() - - const from = this.pos - const to = this.pos + (this.checked ? 10 : 5) // length of "/task-done" or "/task" - const insert = this.checked ? '/task' : '/task-done' - - this.view.dispatch({ - changes: { from, to, insert }, - }) - } - - return wrap - } - - ignoreEvent() { - return true - } -} - -const hideMarkdownPlugin = ViewPlugin.fromClass( - class { - decorations - constructor(view: EditorView) { - this.decorations = this.buildDeco(view) - } - update(update: any) { - if (update.docChanged || update.viewportChanged || update.selectionSet) { - this.decorations = this.buildDeco(update.view) - } - } - - buildDeco(view: EditorView) { - const decos: { from: number; to: number; deco: Decoration }[] = [] - - const selectionRanges = view.state.selection.ranges - const isCursorInMatch = (start: number, end: number) => { - return selectionRanges.some((r: any) => r.from <= end && r.to >= start) - } - - const linkRanges: { from: number; to: number }[] = [] - const fullDoc = view.state.doc.toString() - - // Build variable scope (incorporate global variables) - const scope: any = Object.assign({}, (window as any).__globalVariables || {}) - const reVar = /^\/var\s+([a-zA-Z0-9_]+)\s*=\s*(.*)$/gm - let varMatch - while ((varMatch = reVar.exec(fullDoc)) !== null) { - const name = varMatch[1] - try { - scope[name] = mathjs.evaluate(varMatch[2], scope) - } catch (e) { - scope[name] = varMatch[2].trim() - } - } - const scopeKeys = Object.keys(scope).sort((a, b) => b.length - a.length) - - for (const { from, to } of view.visibleRanges) { - const text = view.state.doc.sliceString(from, to) - - const reHighlight = /==(.*?)==/g - let match - while ((match = reHighlight.exec(text)) !== null) { - const start = from + match.index - const end = start + match[0].length - if (start + 2 <= end - 2) { - if (!isCursorInMatch(start, end)) { - decos.push({ from: start, to: start + 2, deco: Decoration.replace({}) }) - decos.push({ from: end - 2, to: end, deco: Decoration.replace({}) }) - } - decos.push({ - from: start + 2, - to: end - 2, - deco: Decoration.mark({ class: 'cm-custom-highlight' }), - }) - } - } - - const reList = /^(\s*)\*\s+/gm - while ((match = reList.exec(text)) !== null) { - const start = from + match.index + match[1].length - const end = start + 1 // only the asterisk - if (!isCursorInMatch(start, end + 1)) { - decos.push({ from: start, to: end, deco: Decoration.replace({}) }) - } - } - - // Handled by syntaxTree below - - const reHeading = /^#{1,6}\s+/gm - while ((match = reHeading.exec(text)) !== null) { - const start = from + match.index - const end = start + match[0].length - if (!isCursorInMatch(start, end)) { - decos.push({ from: start, to: end, deco: Decoration.replace({}) }) - } - } - - const reLink = /\[(.*?)\]\((.*?)\)/g - while ((match = reLink.exec(text)) !== null) { - const start = from + match.index - const end = start + match[0].length - linkRanges.push({ from: start, to: end }) - - const textStart = start + 1 - const textEnd = start + 1 + match[1].length - const urlStart = textEnd - const urlEnd = end - - let isFile = false - let linkPath = match[2].trim() - - if (linkPath.startsWith('/file')) { - isFile = true - linkPath = linkPath.substring(5).trim() - } else if (linkPath.startsWith('/url')) { - linkPath = linkPath.substring(4).trim() - } - - if (!isCursorInMatch(start, end)) { - decos.push({ from: start, to: textStart, deco: Decoration.replace({}) }) - decos.push({ from: urlStart, to: urlEnd, deco: Decoration.replace({}) }) - } - - if (isFile) { - decos.push({ - from: textStart, - to: textEnd, - deco: Decoration.mark({ - class: 'cm-custom-file-link', - attributes: { 'data-path': linkPath, title: 'Open file: ' + linkPath }, - }), - }) - } else { - decos.push({ - from: textStart, - to: textEnd, - deco: Decoration.mark({ - class: 'cm-custom-clickable-link', - attributes: { 'data-url': linkPath, title: linkPath }, - }), - }) - } - } - - const reFile = /\/file\s+([^\s)\]]+)/g - while ((match = reFile.exec(text)) !== null) { - const start = from + match.index - const end = start + match[0].length - - if (linkRanges.some((r) => r.from <= start && r.to >= end)) continue - - const pathStart = start + match[0].indexOf(match[1]) - - if (!isCursorInMatch(start, end)) { - decos.push({ from: start, to: pathStart, deco: Decoration.replace({}) }) - } - - decos.push({ - from: pathStart, - to: end, - deco: Decoration.mark({ - class: 'cm-custom-file-link', - attributes: { 'data-path': match[1], title: 'Open file: ' + match[1] }, - }), - }) - } - - // Variable rendering - if (scopeKeys.length > 0) { - const reKeys = new RegExp(`\\b(${scopeKeys.join('|')})\\b`, 'g') - while ((match = reKeys.exec(text)) !== null) { - const start = from + match.index - const end = start + match[0].length - const line = view.state.doc.lineAt(start) - if (line.text.trim().startsWith('/var')) continue // don't replace inside variable definitions! - - if (!isCursorInMatch(start, end)) { - decos.push({ - from: start, - to: end, - deco: Decoration.replace({ widget: new VariableWidget(scope[match[1]]) }), - }) - } else { - decos.push({ - from: start, - to: end, - deco: Decoration.mark({ class: 'cm-variable-highlight' }), - }) - } - } - } - - // Color Formats - const reColor = /#[0-9a-fA-F]{6}\b|#[0-9a-fA-F]{3}\b/g - while ((match = reColor.exec(text)) !== null) { - const start = from + match.index - const end = start + match[0].length - if (!isCursorInMatch(start, end)) { - decos.push({ - from: start, - to: end, - deco: Decoration.mark({ - class: 'cm-color-pill', - attributes: { style: `--pill-color: ${match[0]}` }, - }), - }) - } else { - decos.push({ - from: start, - to: end, - deco: Decoration.mark({ - class: 'cm-color-highlight', - attributes: { style: `--pill-color: ${match[0]}` }, - }), - }) - } - } - - // Date Formats (YYYY-MM-DD) - const reDate = /\b\d{4}-\d{2}-\d{2}\b/g - while ((match = reDate.exec(text)) !== null) { - const start = from + match.index - const end = start + match[0].length - if (!isCursorInMatch(start, end)) { - decos.push({ - from: start, - to: end, - deco: Decoration.mark({ class: 'cm-date-pill' }), - }) - } else { - decos.push({ - from: start, - to: end, - deco: Decoration.mark({ class: 'cm-date-highlight' }), - }) - } - } - - // Time Formats (HH:MM or HH:MM:SS) - const reTime = /\b\d{2}:\d{2}(?::\d{2})?\b/g - while ((match = reTime.exec(text)) !== null) { - const start = from + match.index - const end = start + match[0].length - if (!isCursorInMatch(start, end)) { - decos.push({ - from: start, - to: end, - deco: Decoration.mark({ class: 'cm-time-pill' }), - }) - } else { - decos.push({ - from: start, - to: end, - deco: Decoration.mark({ class: 'cm-time-highlight' }), - }) - } - } - - // Currency Formats - const reCurrency = /[$€£¥₹]\s*\d+(?:,\d{3})*(?:\.\d{1,2})?/g - while ((match = reCurrency.exec(text)) !== null) { - const start = from + match.index - const end = start + match[0].length - if (!isCursorInMatch(start, end)) { - decos.push({ - from: start, - to: end, - deco: Decoration.mark({ class: 'cm-currency-pill' }), - }) - } else { - decos.push({ - from: start, - to: end, - deco: Decoration.mark({ class: 'cm-currency-highlight' }), - }) - } - } - - // Tags (!tag) - const reTag = /![a-zA-Z0-9_-]+/g - while ((match = reTag.exec(text)) !== null) { - const start = from + match.index - const end = start + match[0].length - if (!isCursorInMatch(start, end)) { - decos.push({ - from: start, - to: end, - deco: Decoration.mark({ class: 'cm-tag-pill' }), - }) - } else { - decos.push({ - from: start, - to: end, - deco: Decoration.mark({ class: 'cm-tag-highlight' }), - }) - } - } - - // Checkboxes (/check, /checked) - const reCheck = /\/(check(?:ed)?)\b/g - while ((match = reCheck.exec(text)) !== null) { - const start = from + match.index - const end = start + match[0].length - const isChecked = match[1] === 'checked' - - if (!isCursorInMatch(start, end)) { - decos.push({ - from: start, - to: end, - deco: Decoration.replace({ widget: new CheckboxWidget(isChecked, start, view) }), - }) - } else { - decos.push({ - from: start, - to: end, - deco: Decoration.mark({ class: 'cm-check-highlight' }), - }) - } - - if (isChecked) { - const line = view.state.doc.lineAt(start) - if (line.to > end) { - decos.push({ - from: end, - to: line.to, - deco: Decoration.mark({ class: 'cm-checked-line-text' }), - }) - } - } - } - - // Tasks (/task, /task-done) - const reRem = /\/(task(?:-done)?)(?:\s+\((\d{4}-\d{2}-\d{2} \d{2}:\d{2})\))?\s+/g - while ((match = reRem.exec(text)) !== null) { - const start = from + match.index - const end = start + match[0].length - const isChecked = match[1] === 'task-done' - - const line = view.state.doc.lineAt(start) - let isOverdue = false - const fullRe = /\/(task(?:-done)?)(?:\s+\((\d{4}-\d{2}-\d{2} \d{2}:\d{2})\))?\s+(.*?)(?:\s+@\s+(\d{4}-\d{2}-\d{2}(?:\s+\d{2}:\d{2}(?::\d{2})?)?))?[ \t]*$/ - const fullMatch = fullRe.exec(line.text) - if (fullMatch && fullMatch[4]) { - if (new Date(fullMatch[4]).getTime() < Date.now()) isOverdue = true - } - - if (!isCursorInMatch(start, end)) { - decos.push({ - from: start, - to: end, - deco: Decoration.replace({ widget: new ReminderWidget(isChecked, isOverdue, start, view) }), - }) - } else { - decos.push({ - from: start, - to: end, - deco: Decoration.mark({ class: 'cm-rem-highlight' }), - }) - } - - if (line.to > end) { - let classStr = 'cm-rem-line-text' - if (isChecked) classStr += ' cm-checked-line-text' - else if (isOverdue) classStr += ' cm-overdue-line-text' - - decos.push({ - from: end, - to: line.to, - deco: Decoration.mark({ class: classStr }), - }) - } - } - } // end of visibleRanges iteration - - // Traverse AST for Code Blocks - syntaxTree(view.state).iterate({ - enter: (node) => { - if (node.type.name === 'FencedCode') { - let lang = '' - let code = '' - let startCodeMark = null - let endCodeMark = null - let codeInfo = null - - let child = node.node.firstChild - while (child) { - if (child.type.name === 'CodeInfo') { - lang = view.state.doc.sliceString(child.from, child.to) - codeInfo = child - } - if (child.type.name === 'CodeText') - code = view.state.doc.sliceString(child.from, child.to) - if (child.type.name === 'CodeMark') { - if (!startCodeMark) startCodeMark = child - else endCodeMark = child - } - child = child.nextSibling - } - - const start = node.from - const end = node.to - - if (!isCursorInMatch(start, end)) { - if (startCodeMark) { - const replaceTo = codeInfo ? codeInfo.to : startCodeMark.to - decos.push({ - from: startCodeMark.from, - to: replaceTo, - deco: Decoration.replace({}), - }) - } - if (endCodeMark) { - decos.push({ - from: endCodeMark.from, - to: endCodeMark.to, - deco: Decoration.replace({}), - }) - } - } else { - if (codeInfo && !isCursorInMatch(codeInfo.from, codeInfo.to)) { - decos.push({ from: codeInfo.from, to: codeInfo.to, deco: Decoration.replace({}) }) - } - } - - if (startCodeMark) { - decos.push({ - from: startCodeMark.from, - to: startCodeMark.from, - deco: Decoration.widget({ widget: new CopyWidget(code, lang), side: 1 }), - }) - } - - const startLine = view.state.doc.lineAt(start).number - const endLine = view.state.doc.lineAt(end).number - for (let i = startLine; i <= endLine; i++) { - const line = view.state.doc.line(i) - let className = 'cm-code-block-line' - if (i === startLine) className += ' cm-code-block-first' - if (i === endLine) className += ' cm-code-block-last' - decos.push({ - from: line.from, - to: line.from, - deco: Decoration.line({ class: className }), - }) - } - } - - if (node.type.name === 'EmphasisMark' || node.type.name === 'StrongMark') { - const parent = node.node.parent - if (parent) { - const start = parent.from - const end = parent.to - if (!isCursorInMatch(start, end)) { - decos.push({ from: node.from, to: node.to, deco: Decoration.replace({}) }) - } - } - } - - if (node.type.name === 'HorizontalRule') { - const start = node.from - const end = node.to - if (!isCursorInMatch(start, end)) { - decos.push({ - from: start, - to: end, - deco: Decoration.replace({ - widget: new (class extends WidgetType { - eq() { - return true - } - toDOM() { - const hr = document.createElement('hr') - hr.className = 'cm-hr' - return hr - } - })(), - }), - }) - } - } - }, - }) +import { useAppStore } from './store/useAppStore' +import { useSettingsStore } from './store/useSettingsStore' +import { useAIStore } from './store/useAIStore' - try { - const ranges = decos.map((d) => d.deco.range(d.from, d.to)) - return Decoration.set(ranges, true) - } catch (e) { - console.error('Decoration builder error:', e) - return Decoration.none - } - } - }, - { decorations: (v) => v.decorations }, -) +import { useNoteStorage } from './hooks/useNoteStorage' +import { useVariables } from './hooks/useVariables' +import { useReminders } from './hooks/useReminders' +import { useGlobalHotkey } from './hooks/useGlobalHotkey' -const remConverterPlugin = ViewPlugin.fromClass( - class { - update(update: ViewUpdate) { - if (!update.docChanged) return - - const docStr = update.state.doc.toString() - const changes: {from: number, to: number, insert: string}[] = [] - - // Match /task or /task-done followed by a space, but ONLY if not already followed by a date bracket ( - const re = /^\/(task|task-done) (?!\()/gm - let match - while ((match = re.exec(docStr)) !== null) { - const now = new Date() - const yyyy = now.getFullYear() - const mm = String(now.getMonth() + 1).padStart(2, '0') - const dd = String(now.getDate()).padStart(2, '0') - const hh = String(now.getHours()).padStart(2, '0') - const mins = String(now.getMinutes()).padStart(2, '0') - - const timestamp = `(${yyyy}-${mm}-${dd} ${hh}:${mins})` - - changes.push({ - from: match.index, - to: match.index + match[0].length, - insert: `/${match[1]} ${timestamp} ` - }) - } +import { mdHighlighting } from './lib/editor/matchers' +import { + numberPlugin, + symbolPlugin, + aiPlugin, + mathPlugin, + hideMarkdownPlugin, + remConverterPlugin, +} from './lib/editor/plugins' +import { getSecure } from './lib/safeStorage' - // Match shorthand timers at the end of a task, ONLY after a space or Enter is typed - const reShort = /^(\/(?:task|task-done)[^\n]*?@\s*)((?:[0-9]+[smhd])+|tmrw)([ \t]+|\n|(?:\r\n))/gm - while ((match = reShort.exec(docStr)) !== null) { - const now = new Date() - const short = match[2] - if (short === 'tmrw') { - now.setDate(now.getDate() + 1) - now.setHours(9, 0, 0, 0) - } else { - const partRe = /([0-9]+)([smhd])/g - let partMatch - while ((partMatch = partRe.exec(short)) !== null) { - const val = parseInt(partMatch[1]) - const unit = partMatch[2] - if (unit === 's') now.setSeconds(now.getSeconds() + val) - else if (unit === 'm') now.setMinutes(now.getMinutes() + val) - else if (unit === 'h') now.setHours(now.getHours() + val) - else if (unit === 'd') now.setDate(now.getDate() + val) - } - } - - const yyyy = now.getFullYear() - const mm = String(now.getMonth() + 1).padStart(2, '0') - const dd = String(now.getDate()).padStart(2, '0') - const hh = String(now.getHours()).padStart(2, '0') - const mins = String(now.getMinutes()).padStart(2, '0') - - const absoluteDate = `${yyyy}-${mm}-${dd} ${hh}:${mins}` - - // Push the change to replace ONLY the shorthand part - changes.push({ - from: match.index + match[1].length, - to: match.index + match[1].length + match[2].length, - insert: absoluteDate - }) - } - - if (changes.length > 0) { - setTimeout(() => { - update.view.dispatch({ changes }) - }, 10) +function App() { + const { + notes, + setNotes, + currentNoteIndex, + setCurrentNoteIndex, + zoomLevel, + showGraphView, + setShowGraphView, + showRemindersView, + setShowRemindersView, + isRenaming, + setIsRenaming, + renameValue, + setRenameValue, + showNoteSearch, + setShowNoteSearch, + noteSearchQuery, + setNoteSearchQuery, + searchSelectedIndex, + setSearchSelectedIndex, + showNoteActionMenu, + setShowNoteActionMenu, + showMainActionMenu, + setShowMainActionMenu, + actionMenuIndex, + setActionMenuIndex, + } = useAppStore() + + 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) } } - } -) - -interface Note { - id: string - content: string - mtime: number -} - -// Ensure electronAPI is typed -declare global { - interface Window { - electronAPI: any - } -} - -function App() { - const [notes, setNotes] = useState([]) - const [currentNoteIndex, setCurrentNoteIndex] = useState(0) - const [zoomLevel, setZoomLevel] = useState( - Number(localStorage.getItem('papercache-zoom')) || 1, - ) - const [themePreset, setThemePreset] = useState( - localStorage.getItem('papercache-theme') || 'paper-light', - ) - const [fontFamily, setFontFamily] = useState( - localStorage.getItem('papercache-font') || "'JetBrains Mono', monospace", - ) - const [showRulings, setShowRulings] = useState( - localStorage.getItem('papercache-show-rulings') === 'true', - ) - const [bgType, setBgType] = useState(localStorage.getItem('papercache-bg-type') || 'preset') - const [bgColor, setBgColor] = useState(localStorage.getItem('papercache-bg-color') || '#ffffff') - const [bgImage, setBgImage] = useState(localStorage.getItem('papercache-bg-image') || '') - - const [textColor, setTextColor] = useState( - localStorage.getItem('papercache-color-text') || '#333333', - ) - const [numColor, setNumColor] = useState( - localStorage.getItem('papercache-color-num') || '#007acc', - ) - const [symColor, setSymColor] = useState( - localStorage.getItem('papercache-color-sym') || '#c586c0', - ) - const [aiColor, setAiColor] = useState(localStorage.getItem('papercache-color-ai') || '#10b981') - const [mathColor, setMathColor] = useState( - localStorage.getItem('papercache-color-math') || '#f59e0b', - ) - - // AI Config State - const [apiKey, setApiKey] = useState(localStorage.getItem('papercache-apikey') || '') - const [apiBaseUrl, setApiBaseUrl] = useState( - localStorage.getItem('papercache-baseurl') || 'https://api.openai.com/v1', - ) - const [apiModel, setApiModel] = useState(localStorage.getItem('papercache-model') || 'gpt-4o') - const [aiSystemPrompt, setAiSystemPrompt] = useState( - localStorage.getItem('papercache-system-prompt') || - 'Please provide a short and concise answer.', - ) - - const [showGraphView, setShowGraphView] = useState(false) - const [showRemindersView, setShowRemindersView] = useState(false) - const [isRenaming, setIsRenaming] = useState(false) - const [renameValue, setRenameValue] = useState('') - - const [showNoteSearch, setShowNoteSearch] = useState(false) - const [noteSearchQuery, setNoteSearchQuery] = useState('') - const [searchSelectedIndex, setSearchSelectedIndex] = useState(0) + fetchApiKey() + }, [setApiKey]) const editorRef = useRef(null) - - const [showNoteActionMenu, setShowNoteActionMenu] = useState(false) - const [showMainActionMenu, setShowMainActionMenu] = useState(false) - const [actionMenuIndex, setActionMenuIndex] = useState(0) const searchInputRef = useRef(null) - const notesRef = useRef(notes) - useEffect(() => { - notesRef.current = notes - }, [notes]) + useEffect(() => {}, [notes]) - const currentNoteIndexRef = useRef(currentNoteIndex) - useEffect(() => { - currentNoteIndexRef.current = currentNoteIndex - }, [currentNoteIndex]) + useEffect(() => {}, [currentNoteIndex]) - useEffect(() => { - if (notes.length > 0 && currentNoteIndex >= 0 && currentNoteIndex < notes.length) { - localStorage.setItem('papercache-last-open-note', notes[currentNoteIndex].id) - } - }, [currentNoteIndex, notes]) + // Custom Hooks + useNoteStorage() + useVariables() + useReminders() + useGlobalHotkey() useEffect(() => { if (showNoteSearch && searchInputRef.current) { @@ -910,73 +111,39 @@ function App() { } }, [showNoteSearch]) - // We don't save theme state to localStorage here anymore, Settings window does it and we listen via storage event. - - // Load notes initially + // Listen to storage events to update settings if changed from Settings window useEffect(() => { - async function loadNotes() { - const loaded = await window.electronAPI.getNotes() - if (loaded.length > 0) { - setNotes(loaded) - const lastOpenNoteId = localStorage.getItem('papercache-last-open-note') - if (lastOpenNoteId) { - const idx = loaded.findIndex((n: Note) => n.id === lastOpenNoteId) - if (idx !== -1) { - setCurrentNoteIndex(idx) - } - } - } - } - loadNotes() - - // Listen to storage events to update config if changed from Settings window const handleStorageChange = () => { - setApiKey(localStorage.getItem('papercache-apikey') || '') - setApiBaseUrl(localStorage.getItem('papercache-baseurl') || 'https://api.openai.com/v1') - setApiModel(localStorage.getItem('papercache-model') || 'gpt-4o') - setAiSystemPrompt( - localStorage.getItem('papercache-system-prompt') || - 'Please provide a short and concise answer.', - ) - - setShowRulings(localStorage.getItem('papercache-show-rulings') === 'true') - setThemePreset(localStorage.getItem('papercache-theme') || 'paper-light') - setFontFamily(localStorage.getItem('papercache-font') || "'JetBrains Mono', monospace") - setBgType(localStorage.getItem('papercache-bg-type') || 'preset') - setBgColor(localStorage.getItem('papercache-bg-color') || '#ffffff') - setBgImage(localStorage.getItem('papercache-bg-image') || '') - - setTextColor(localStorage.getItem('papercache-color-text') || '#333333') - setNumColor(localStorage.getItem('papercache-color-num') || '#007acc') - setSymColor(localStorage.getItem('papercache-color-sym') || '#c586c0') - setAiColor(localStorage.getItem('papercache-color-ai') || '#10b981') - setMathColor(localStorage.getItem('papercache-color-math') || '#f59e0b') + // Refresh Settings Store + useSettingsStore.setState({ + themePreset: localStorage.getItem('papercache-theme-preset') || 'grid-light', + fontFamily: localStorage.getItem('papercache-font') || 'monospace', + showRulings: localStorage.getItem('papercache-rulings') !== 'false', + bgType: (localStorage.getItem('papercache-bg-type') as 'color' | 'image') || 'color', + bgColor: localStorage.getItem('papercache-bg-color') || '#ffffff', + bgImage: localStorage.getItem('papercache-bg-image') || '', + textColor: localStorage.getItem('papercache-text-color') || '#000000', + numColor: localStorage.getItem('papercache-num-color') || '#0000ff', + symColor: localStorage.getItem('papercache-sym-color') || '#ff0000', + 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) + useAIStore.setState({ + apiBaseUrl: localStorage.getItem('papercache-api-base-url') || 'https://api.openai.com/v1', + apiModel: localStorage.getItem('papercache-api-model') || 'gpt-4o', + aiSystemPrompt: + 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) - }, []) - - useEffect(() => { - const handleOpenNote = (e: any) => { - let path = e.detail.path - if (!path.endsWith('.md')) path += '.md' - - const index = notesRef.current.findIndex((n) => n.id === path) - if (index !== -1) { - setCurrentNoteIndex(index) - } else { - const newNote = { id: path, content: '', mtime: Date.now() } - window.electronAPI.saveNote(path, '') - setNotes((prev) => { - const updated = [newNote, ...prev] - setCurrentNoteIndex(0) - return updated - }) - } - } - window.addEventListener('open-papercache-note', handleOpenNote) - return () => window.removeEventListener('open-papercache-note', handleOpenNote) - }, []) + }, [setApiKey]) const activeNote = notes[currentNoteIndex] || { id: '', content: '' } const isAuto = /^\d+\.md$/.test(activeNote.id) @@ -1030,7 +197,7 @@ function App() { try { const val = mathjs.evaluate(match[2], scope) scope[name] = val - } catch (e) { + } catch { scope[name] = match[2].trim() } } @@ -1047,7 +214,7 @@ function App() { docStr = before + newLineText + after modified = true } - } catch (e) {} + } catch {} } // Re-evaluate ALL existing calculations in the document @@ -1071,7 +238,7 @@ function App() { calcModified = true continue } - } catch (e) {} + } catch {} } } @@ -1088,152 +255,9 @@ function App() { } } }, - [notes, currentNoteIndex, activeNote.id], + [notes, currentNoteIndex, activeNote.id, setNotes] ) - // Sync global variables whenever notes change - useEffect(() => { - const globals: any = {} - const reVar = /^\/globvar\s+([a-zA-Z0-9_]+)\s*=\s*(.*)$/gm - notes.forEach((note) => { - let varMatch - while ((varMatch = reVar.exec(note.content)) !== null) { - const name = varMatch[1] - try { - globals[name] = mathjs.evaluate(varMatch[2], globals) - } catch (e) { - globals[name] = varMatch[2].trim() - } - } - }) - ;(window as any).__globalVariables = globals - }, [notes]) - - useEffect(() => { - const handleGlobalKeyDown = async (e: KeyboardEvent) => { - if (e.key === 'Escape') { - if (showMainActionMenu) { - e.preventDefault() - e.stopPropagation() - setShowMainActionMenu(false) - return - } - if (showNoteSearch) { - e.preventDefault() - e.stopPropagation() - setShowNoteSearch(false) - return - } - if (showGraphView) { - e.preventDefault() - e.stopPropagation() - setShowGraphView(false) - return - } - } - - // Settings Shortcut - if (e.key.toLowerCase() === 's' && e.shiftKey && (e.metaKey || e.ctrlKey)) { - e.preventDefault() - window.electronAPI.openSettings() - } - - // Graph View Shortcut - if (e.key.toLowerCase() === 'g' && (e.metaKey || e.ctrlKey)) { - e.preventDefault() - e.stopPropagation() - setShowGraphView((prev) => !prev) - } - - if (e.key === 'n' && (e.metaKey || e.ctrlKey)) { - e.preventDefault() - const id = Date.now() + '.md' - const newNote = { id, content: '', mtime: Date.now() } - setNotes((prev) => [newNote, ...prev]) - setCurrentNoteIndex(0) - window.electronAPI.saveNote(id, '') - } - - if (e.key === 'e' && (e.metaKey || e.ctrlKey)) { - e.preventDefault() - e.stopPropagation() - const note = notesRef.current[currentNoteIndexRef.current] - if (note) { - const filename = note.id.replace(/\.md$/, '') - window.electronAPI.exportNote(filename, note.content) - } - } - - if (e.key.toLowerCase() === 'p' && (e.metaKey || e.ctrlKey)) { - e.preventDefault() - e.stopPropagation() - setShowNoteSearch(true) - setNoteSearchQuery('') - setSearchSelectedIndex(0) - } - - if (e.key.toLowerCase() === 't' && (e.metaKey || e.ctrlKey)) { - e.preventDefault() - e.stopPropagation() - setShowRemindersView(true) - } - - if (e.key.toLowerCase() === 'k' && (e.metaKey || e.ctrlKey)) { - e.preventDefault() - e.stopPropagation() - setShowMainActionMenu((prev) => !prev) - } - - // Zoom Shortcuts - if ((e.metaKey || e.ctrlKey) && (e.key === '=' || e.key === '+' || e.key === '-')) { - e.preventDefault() - setZoomLevel((prev) => { - const newZoom = e.key === '-' ? Math.max(0.5, prev - 0.1) : Math.min(3, prev + 0.1) - localStorage.setItem('papercache-zoom', newZoom.toString()) - return newZoom - }) - } - - if ((e.metaKey || e.ctrlKey) && e.key === '0') { - e.preventDefault() - setZoomLevel(1) - localStorage.setItem('papercache-zoom', '1') - } - } - - // Sync global shortcut on load - const shortcut = - localStorage.getItem('papercache-shortcut-newnote') || 'CommandOrControl+Shift+N' - if (window.electronAPI.updateGlobalShortcut) { - window.electronAPI.updateGlobalShortcut('new-note', '', shortcut) - } - const toggleShortcut = - localStorage.getItem('papercache-shortcut-toggle') || 'CommandOrControl+Shift+C' - if (window.electronAPI.updateGlobalShortcut) { - window.electronAPI.updateGlobalShortcut('toggle', '', toggleShortcut) - } - - // Listen for global new note shortcut - if (window.electronAPI.onTriggerNewNote) { - window.electronAPI.onTriggerNewNote(() => { - const id = Date.now() + '.md' - const initialNote = { id, content: '', mtime: Date.now() } - setNotes((prev) => [initialNote, ...prev]) - window.electronAPI.saveNote(id, '') - setCurrentNoteIndex(0) - }) - } - - if (window.electronAPI.onTriggerTasks) { - window.electronAPI.onTriggerTasks(() => { - setShowRemindersView((prev) => !prev) - }) - } - - window.addEventListener('keydown', handleGlobalKeyDown, { capture: true }) - return () => window.removeEventListener('keydown', handleGlobalKeyDown, { capture: true }) - }, [showMainActionMenu, showNoteSearch, showGraphView]) - const containerStyle: any = { '--font-family': fontFamily, '--text-color': textColor, @@ -1258,7 +282,6 @@ function App() { () => [ 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 }, @@ -1284,7 +307,7 @@ function App() { { key: 'Mod-e', run: () => { - const note = notesRef.current[currentNoteIndexRef.current] + const note = useAppStore.getState().notes[useAppStore.getState().currentNoteIndex] if (note) { const filename = note.id.split('/').pop() || 'note.md' window.electronAPI.exportNote(filename, note.content) @@ -1295,7 +318,7 @@ function App() { { key: 'Mod-Backspace', run: () => { - const note = notesRef.current[currentNoteIndexRef.current] + const note = useAppStore.getState().notes[useAppStore.getState().currentNoteIndex] if (note) { if (note.id.startsWith('commands/')) { alert('Files in the commands folder cannot be deleted.') @@ -1304,8 +327,11 @@ function App() { if (confirm('Delete this note?')) { window.electronAPI.deleteNote(note.id) setNotes((prev) => prev.filter((n) => n.id !== note.id)) - if (currentNoteIndexRef.current >= notesRef.current.length - 1) - setCurrentNoteIndex(Math.max(0, notesRef.current.length - 2)) + if ( + useAppStore.getState().currentNoteIndex >= + useAppStore.getState().notes.length - 1 + ) + setCurrentNoteIndex(Math.max(0, useAppStore.getState().notes.length - 2)) } } return true @@ -1314,7 +340,7 @@ function App() { { key: 'Mod-Delete', run: () => { - const note = notesRef.current[currentNoteIndexRef.current] + const note = useAppStore.getState().notes[useAppStore.getState().currentNoteIndex] if (note) { if (note.id.startsWith('commands/')) { alert('Files in the commands folder cannot be deleted.') @@ -1323,8 +349,11 @@ function App() { if (confirm('Delete this note?')) { window.electronAPI.deleteNote(note.id) setNotes((prev) => prev.filter((n) => n.id !== note.id)) - if (currentNoteIndexRef.current >= notesRef.current.length - 1) - setCurrentNoteIndex(Math.max(0, notesRef.current.length - 2)) + if ( + useAppStore.getState().currentNoteIndex >= + useAppStore.getState().notes.length - 1 + ) + setCurrentNoteIndex(Math.max(0, useAppStore.getState().notes.length - 2)) } } return true @@ -1380,7 +409,7 @@ function App() { const docStr = view.state.doc.toString() const finalVal = docStr.replace( '\n\u200B...\u200C\n', - '\n\u200B' + response + '\u200C\n', + '\n\u200B' + response + '\u200C\n' ) handleEditorChange(finalVal, {}) }) @@ -1388,7 +417,7 @@ function App() { const docStr = view.state.doc.toString() const errorVal = docStr.replace( '\n\u200B...\u200C\n', - '\n\u200BError - ' + error.message + '\u200C\n', + '\n\u200BError - ' + error.message + '\u200C\n' ) handleEditorChange(errorVal, {}) }) @@ -1396,7 +425,7 @@ function App() { const docStr = view.state.doc.toString() const errorVal = docStr.replace( '\n\u200B...\u200C\n', - '\n\u200BSetup Error - ' + err.message + '\u200C\n', + '\n\u200BSetup Error - ' + err.message + '\u200C\n' ) handleEditorChange(errorVal, {}) } @@ -1406,7 +435,7 @@ function App() { return false }, }, - ]), + ]) ), search({ top: true }), markdown(), @@ -1418,7 +447,7 @@ function App() { hideMarkdownPlugin, remConverterPlugin, EditorView.domEventHandlers({ - mousedown: (event, _view) => { + mousedown: (event) => { const target = event.target as HTMLElement const webLink = target?.closest('.cm-custom-clickable-link') const fileLink = target?.closest('.cm-custom-file-link') @@ -1446,7 +475,15 @@ function App() { }, }), ], - [apiKey, apiBaseUrl, apiModel, aiSystemPrompt, handleEditorChange], + [ + apiKey, + apiBaseUrl, + apiModel, + aiSystemPrompt, + handleEditorChange, + setCurrentNoteIndex, + setNotes, + ] ) useEffect(() => { @@ -1458,48 +495,6 @@ function App() { window.addEventListener('focus', handleWindowFocus) return () => window.removeEventListener('focus', handleWindowFocus) }, []) - useEffect(() => { - if (Notification.permission !== 'granted' && Notification.permission !== 'denied') { - Notification.requestPermission() - } - - const interval = setInterval(() => { - const notifiedStr = localStorage.getItem('papercache_notified') || '[]' - const notified = new Set(JSON.parse(notifiedStr)) - let hasNewNotifs = false - - notes.forEach((note) => { - const reRem = /\/(task(?:-done)?)(?:\s+\((\d{4}-\d{2}-\d{2} \d{2}:\d{2})\))?\s+(.*?)(?:\s+@\s+(\d{4}-\d{2}-\d{2}(?:\s+\d{2}:\d{2}(?::\d{2})?)?))?[ \t]*$/gm - let match - while ((match = reRem.exec(note.content)) !== null) { - const isDone = match[1] === 'task-done' - const label = match[3] - const targetStr = match[4] - if (!isDone && targetStr) { - const targetMs = new Date(targetStr).getTime() - if (Date.now() >= targetMs) { - const notifKey = `${note.id}-${targetMs}-${label}` - if (!notified.has(notifKey)) { - console.log('Triggering OS notification for:', label) - new Notification('PaperCache Reminder', { - body: label, - silent: false - }) - notified.add(notifKey) - hasNewNotifs = true - } - } - } - } - }) - - if (hasNewNotifs) { - localStorage.setItem('papercache_notified', JSON.stringify(Array.from(notified))) - } - }, 10000) - - return () => clearInterval(interval) - }, [notes]) const handleAppClick = () => { setShowMainActionMenu(false) @@ -1545,26 +540,22 @@ function App() { theme={themePreset.includes('dark') ? 'dark' : 'light'} onClose={() => setShowRemindersView(false)} onNavigateToNote={(noteId) => { - const idx = notes.findIndex(n => n.id === noteId) + const idx = notes.findIndex((n) => n.id === noteId) if (idx !== -1) { setCurrentNoteIndex(idx) setShowRemindersView(false) } }} onToggleReminder={(noteId, from, to, insert) => { - setNotes(prevNotes => { + setNotes((prevNotes) => { const newNotes = [...prevNotes] - const idx = newNotes.findIndex(n => n.id === noteId) + const idx = newNotes.findIndex((n) => n.id === noteId) if (idx !== -1) { const note = newNotes[idx] const newContent = note.content.slice(0, from) + insert + note.content.slice(to) newNotes[idx] = { ...note, content: newContent } window.electronAPI.saveNote(note.id, newContent) - - // If the note being modified is the currently open note, we need to update the CodeMirror view - // We'll dispatch a custom event that a useEffect in App can listen to, or we can just let - // the `notes` state update handle it. However, the Editor is uncontrolled by `notes` once loaded! - // To safely update the open editor without re-mounting, we dispatch a DOM event. + if (idx === currentNoteIndex) { const view = editorRef.current?.view if (view) { @@ -1583,7 +574,7 @@ function App() { const filteredNotes = notes.filter( (n) => n.content.toLowerCase().includes(noteSearchQuery.toLowerCase()) || - n.id.toLowerCase().includes(noteSearchQuery.toLowerCase()), + n.id.toLowerCase().includes(noteSearchQuery.toLowerCase()) ) const allTags = new Set() diff --git a/src/GraphView.tsx b/src/GraphView.tsx index fa1d400..84237b6 100644 --- a/src/GraphView.tsx +++ b/src/GraphView.tsx @@ -65,7 +65,7 @@ export default function GraphView({ (node: any) => { onNodeClick(node.id) }, - [onNodeClick], + [onNodeClick] ) return ( diff --git a/src/Settings.tsx b/src/Settings.tsx index f9c1d0f..16d94d6 100644 --- a/src/Settings.tsx +++ b/src/Settings.tsx @@ -4,53 +4,52 @@ import './Settings.css' export default function Settings() { const [apiKey, setApiKey] = useState(localStorage.getItem('papercache-apikey') || '') const [apiBaseUrl, setApiBaseUrl] = useState( - localStorage.getItem('papercache-baseurl') || 'https://api.openai.com/v1', + localStorage.getItem('papercache-baseurl') || 'https://api.openai.com/v1' ) const [apiModel, setApiModel] = useState(localStorage.getItem('papercache-model') || 'gpt-4o') const [aiSystemPrompt, setAiSystemPrompt] = useState( - localStorage.getItem('papercache-system-prompt') || - 'Please provide a short and concise answer.', + localStorage.getItem('papercache-system-prompt') || 'Please provide a short and concise answer.' ) // Shortcuts const [globalShortcutNewNote, setGlobalShortcutNewNote] = useState( - localStorage.getItem('papercache-shortcut-newnote') || 'CommandOrControl+Shift+N', + localStorage.getItem('papercache-shortcut-newnote') || 'CommandOrControl+Shift+N' ) const [globalShortcutToggle, setGlobalShortcutToggle] = useState( - localStorage.getItem('papercache-shortcut-toggle') || 'CommandOrControl+Shift+C', + localStorage.getItem('papercache-shortcut-toggle') || 'CommandOrControl+Shift+C' ) // Startup const [launchAtStartup, setLaunchAtStartup] = useState( - localStorage.getItem('papercache-launch-startup') === 'true', + localStorage.getItem('papercache-launch-startup') === 'true' ) // Appearance State const [fontFamily, setFontFamily] = useState( - localStorage.getItem('papercache-font') || "'JetBrains Mono', monospace", + localStorage.getItem('papercache-font') || "'JetBrains Mono', monospace" ) const [showRulings, setShowRulings] = useState( - localStorage.getItem('papercache-show-rulings') === 'true', + localStorage.getItem('papercache-show-rulings') === 'true' ) const [themePreset, setThemePreset] = useState( - localStorage.getItem('papercache-theme') || 'paper-light', + localStorage.getItem('papercache-theme') || 'paper-light' ) const [bgType, setBgType] = useState(localStorage.getItem('papercache-bg-type') || 'preset') // preset, color, image const [bgColor, setBgColor] = useState(localStorage.getItem('papercache-bg-color') || '#ffffff') const [bgImage, setBgImage] = useState(localStorage.getItem('papercache-bg-image') || '') const [textColor, setTextColor] = useState( - localStorage.getItem('papercache-color-text') || '#333333', + localStorage.getItem('papercache-color-text') || '#333333' ) const [numColor, setNumColor] = useState( - localStorage.getItem('papercache-color-num') || '#007acc', + localStorage.getItem('papercache-color-num') || '#007acc' ) const [symColor, setSymColor] = useState( - localStorage.getItem('papercache-color-sym') || '#c586c0', + localStorage.getItem('papercache-color-sym') || '#c586c0' ) const [aiColor, setAiColor] = useState(localStorage.getItem('papercache-color-ai') || '#10b981') const [mathColor, setMathColor] = useState( - localStorage.getItem('papercache-color-math') || '#f59e0b', + localStorage.getItem('papercache-color-math') || '#f59e0b' ) const saveSettings = () => { diff --git a/src/components/RemindersPage.tsx b/src/components/RemindersPage.tsx index 86c6bb7..c360b7f 100644 --- a/src/components/RemindersPage.tsx +++ b/src/components/RemindersPage.tsx @@ -23,7 +23,13 @@ interface ReminderItem { matchLength: number } -export const RemindersPage: React.FC = ({ notes, onClose, onNavigateToNote, onToggleReminder, theme }) => { +export const RemindersPage: React.FC = ({ + notes, + onClose, + onNavigateToNote, + onToggleReminder, + theme, +}) => { const isDark = theme === 'dark' const bgColor = isDark ? '#1e1e1e' : '#ffffff' const textColor = isDark ? '#d4d4d4' : '#333333' @@ -32,7 +38,8 @@ export const RemindersPage: React.FC = ({ notes, onClose, on // Parse reminders from all notes const reminders: ReminderItem[] = [] - const reRem = /\/(task(?:-done)?)(?:\s+\((\d{4}-\d{2}-\d{2} \d{2}:\d{2})\))?\s+(.*?)(?:\s+@\s+(\d{4}-\d{2}-\d{2}(?:\s+\d{2}:\d{2}(?::\d{2})?)?))?[ \t]*$/gm + const reRem = + /\/(task(?:-done)?)(?:\s+\((\d{4}-\d{2}-\d{2} \d{2}:\d{2})\))?\s+(.*?)(?:\s+@\s+(\d{4}-\d{2}-\d{2}(?:\s+\d{2}:\d{2}(?::\d{2})?)?))?[ \t]*$/gm notes.forEach((note) => { let match @@ -58,8 +65,8 @@ export const RemindersPage: React.FC = ({ notes, onClose, on return 0 }) - const [now, setNow] = React.useState(Date.now()) - + const [now, setNow] = React.useState(() => Date.now()) + React.useEffect(() => { const timer = setInterval(() => setNow(Date.now()), 10000) // update every 10s for better responsiveness return () => clearInterval(timer) @@ -103,48 +110,80 @@ export const RemindersPage: React.FC = ({ notes, onClose, on display: 'flex', alignItems: 'center', justifyContent: 'center', - borderRadius: '4px' + borderRadius: '4px', }} > - + - +
{reminders.length === 0 ? ( -
- +
+

No tasks found.

-

Type `/task ` in any note to create one.

+

+ Type `/task ` in any note to create one. +

) : ( -
+
{reminders.map((rem, idx) => { const isOverdue = !rem.done && rem.targetMs && rem.targetMs < now - const isImminent = !rem.done && rem.targetMs && rem.targetMs > now && (rem.targetMs - now) < 60 * 60 * 1000 - + const isImminent = + !rem.done && + rem.targetMs && + rem.targetMs > now && + rem.targetMs - now < 60 * 60 * 1000 + let baseColor = '#7EB8D4' // default if (!rem.done && rem.targetMs) { - if (isOverdue) baseColor = '#FF3B30' // red + if (isOverdue) + baseColor = '#FF3B30' // red else if (isImminent) baseColor = '#faad14' // orange } - - return (
= ({ notes, onClose, on color: rem.done ? 'white' : 'transparent', marginRight: '12px', flexShrink: 0, - cursor: 'pointer' + cursor: 'pointer', }} > - +
- + {rem.label} - + {rem.creationDate && ( - + Created {rem.creationDate} )}
- + {rem.targetMs && ( -
- {isOverdue ? 'Overdue: ' : 'Due: '} +
+ {isOverdue ? 'Overdue: ' : 'Due: '} {new Date(rem.targetMs).toLocaleString([], { - month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', })}
)} diff --git a/src/hooks/useGlobalHotkey.ts b/src/hooks/useGlobalHotkey.ts new file mode 100644 index 0000000..a973d08 --- /dev/null +++ b/src/hooks/useGlobalHotkey.ts @@ -0,0 +1,158 @@ +import { useEffect } from 'react' +import { useAppStore } from '../store/useAppStore' + +export function useGlobalHotkey() { + const { + showMainActionMenu, + showNoteSearch, + showGraphView, + setShowMainActionMenu, + setShowNoteSearch, + setShowGraphView, + setShowRemindersView, + setZoomLevel, + setNotes, + setCurrentNoteIndex, + setNoteSearchQuery, + setSearchSelectedIndex, + } = useAppStore() + + useEffect(() => { + const handleGlobalKeyDown = async (e: KeyboardEvent) => { + if (e.key === 'Escape') { + if (showMainActionMenu) { + e.preventDefault() + e.stopPropagation() + setShowMainActionMenu(false) + return + } + if (showNoteSearch) { + e.preventDefault() + e.stopPropagation() + setShowNoteSearch(false) + return + } + if (showGraphView) { + e.preventDefault() + e.stopPropagation() + setShowGraphView(false) + return + } + } + + // Settings Shortcut + if (e.key.toLowerCase() === 's' && e.shiftKey && (e.metaKey || e.ctrlKey)) { + e.preventDefault() + window.electronAPI.openSettings() + } + + // Graph View Shortcut + if (e.key.toLowerCase() === 'g' && (e.metaKey || e.ctrlKey)) { + e.preventDefault() + e.stopPropagation() + setShowGraphView((prev) => !prev) + } + + if (e.key === 'n' && (e.metaKey || e.ctrlKey)) { + e.preventDefault() + const id = Date.now() + '.md' + const newNote = { id, content: '', mtime: Date.now() } + setNotes((prev) => [newNote, ...prev]) + setCurrentNoteIndex(0) + window.electronAPI.saveNote(id, '') + } + + if (e.key === 'e' && (e.metaKey || e.ctrlKey)) { + e.preventDefault() + e.stopPropagation() + const { notes, currentNoteIndex } = useAppStore.getState() + const note = notes[currentNoteIndex] + if (note) { + const filename = note.id.replace(/\.md$/, '') + window.electronAPI.exportNote(filename, note.content) + } + } + + if (e.key.toLowerCase() === 'p' && (e.metaKey || e.ctrlKey)) { + e.preventDefault() + e.stopPropagation() + setShowNoteSearch(true) + setNoteSearchQuery('') + setSearchSelectedIndex(0) + } + + if (e.key.toLowerCase() === 't' && (e.metaKey || e.ctrlKey)) { + e.preventDefault() + e.stopPropagation() + setShowRemindersView(true) + } + + if (e.key.toLowerCase() === 'k' && (e.metaKey || e.ctrlKey)) { + e.preventDefault() + e.stopPropagation() + setShowMainActionMenu((prev) => !prev) + } + + // Zoom Shortcuts + if ((e.metaKey || e.ctrlKey) && (e.key === '=' || e.key === '+' || e.key === '-')) { + e.preventDefault() + setZoomLevel((prev) => { + const newZoom = e.key === '-' ? Math.max(0.5, prev - 0.1) : Math.min(3, prev + 0.1) + localStorage.setItem('papercache-zoom', newZoom.toString()) + return newZoom + }) + } + + if ((e.metaKey || e.ctrlKey) && e.key === '0') { + e.preventDefault() + setZoomLevel(1) + localStorage.setItem('papercache-zoom', '1') + } + } + + // Sync global shortcut on load + const shortcut = + localStorage.getItem('papercache-shortcut-newnote') || 'CommandOrControl+Shift+N' + if (window.electronAPI.updateGlobalShortcut) { + window.electronAPI.updateGlobalShortcut('new-note', '', shortcut) + } + const toggleShortcut = + localStorage.getItem('papercache-shortcut-toggle') || 'CommandOrControl+Shift+C' + if (window.electronAPI.updateGlobalShortcut) { + window.electronAPI.updateGlobalShortcut('toggle', '', toggleShortcut) + } + + // Listen for global new note shortcut + if (window.electronAPI.onTriggerNewNote) { + window.electronAPI.onTriggerNewNote(() => { + const id = Date.now() + '.md' + const initialNote = { id, content: '', mtime: Date.now() } + setNotes((prev) => [initialNote, ...prev]) + window.electronAPI.saveNote(id, '') + setCurrentNoteIndex(0) + }) + } + + if (window.electronAPI.onTriggerTasks) { + window.electronAPI.onTriggerTasks(() => { + setShowRemindersView((prev) => !prev) + }) + } + + window.addEventListener('keydown', handleGlobalKeyDown, { capture: true }) + return () => window.removeEventListener('keydown', handleGlobalKeyDown, { capture: true }) + }, [ + showMainActionMenu, + showNoteSearch, + showGraphView, + setShowGraphView, + setShowMainActionMenu, + setShowNoteSearch, + setNoteSearchQuery, + setSearchSelectedIndex, + setShowRemindersView, + setNotes, + setCurrentNoteIndex, + setZoomLevel, + ]) +} diff --git a/src/hooks/useNoteStorage.ts b/src/hooks/useNoteStorage.ts new file mode 100644 index 0000000..734a099 --- /dev/null +++ b/src/hooks/useNoteStorage.ts @@ -0,0 +1,53 @@ +import { useEffect } from 'react' +import { useAppStore, Note } from '../store/useAppStore' + +export function useNoteStorage() { + const { notes, setNotes, currentNoteIndex, setCurrentNoteIndex } = useAppStore() + + // Load notes initially + useEffect(() => { + async function loadNotes() { + const loaded = await window.electronAPI.getNotes() + if (loaded.length > 0) { + setNotes(loaded) + const lastOpenNoteId = localStorage.getItem('papercache-last-open-note') + if (lastOpenNoteId) { + const idx = loaded.findIndex((n: Note) => n.id === lastOpenNoteId) + if (idx !== -1) { + setCurrentNoteIndex(idx) + } + } + } + } + loadNotes() + }, [setNotes, setCurrentNoteIndex]) + + // Save current note index to localStorage + useEffect(() => { + if (notes.length > 0 && currentNoteIndex >= 0 && currentNoteIndex < notes.length) { + localStorage.setItem('papercache-last-open-note', notes[currentNoteIndex].id) + } + }, [currentNoteIndex, notes]) + + // Listen to external open note events + useEffect(() => { + const handleOpenNote = (e: any) => { + let path = e.detail.path + if (!path.endsWith('.md')) path += '.md' + + // We need the latest notes, so use useAppStore.getState() + const currentNotes = useAppStore.getState().notes + const index = currentNotes.findIndex((n) => n.id === path) + if (index !== -1) { + setCurrentNoteIndex(index) + } else { + const newNote = { id: path, content: '', mtime: Date.now() } + window.electronAPI.saveNote(path, '') + setNotes([newNote, ...currentNotes]) + setCurrentNoteIndex(0) + } + } + window.addEventListener('open-papercache-note', handleOpenNote) + return () => window.removeEventListener('open-papercache-note', handleOpenNote) + }, [setNotes, setCurrentNoteIndex]) +} diff --git a/src/hooks/useReminders.ts b/src/hooks/useReminders.ts new file mode 100644 index 0000000..85b74d1 --- /dev/null +++ b/src/hooks/useReminders.ts @@ -0,0 +1,50 @@ +import { useEffect } from 'react' +import { useAppStore } from '../store/useAppStore' + +export function useReminders() { + const notes = useAppStore((state) => state.notes) + + useEffect(() => { + if (Notification.permission !== 'granted' && Notification.permission !== 'denied') { + Notification.requestPermission() + } + + const interval = setInterval(() => { + const notifiedStr = localStorage.getItem('papercache_notified') || '[]' + const notified = new Set(JSON.parse(notifiedStr)) + let hasNewNotifs = false + + notes.forEach((note) => { + const reRem = + /\/(task(?:-done)?)(?:\s+\((\d{4}-\d{2}-\d{2} \d{2}:\d{2})\))?\s+(.*?)(?:\s+@\s+(\d{4}-\d{2}-\d{2}(?:\s+\d{2}:\d{2}(?::\d{2})?)?))?[ \t]*$/gm + let match + while ((match = reRem.exec(note.content)) !== null) { + const isDone = match[1] === 'task-done' + const label = match[3] + const targetStr = match[4] + if (!isDone && targetStr) { + const targetMs = new Date(targetStr).getTime() + if (Date.now() >= targetMs) { + const notifKey = `${note.id}-${targetMs}-${label}` + if (!notified.has(notifKey)) { + console.log('Triggering OS notification for:', label) + new Notification('PaperCache Reminder', { + body: label, + silent: false, + }) + notified.add(notifKey) + hasNewNotifs = true + } + } + } + } + }) + + if (hasNewNotifs) { + localStorage.setItem('papercache_notified', JSON.stringify(Array.from(notified))) + } + }, 10000) + + return () => clearInterval(interval) + }, [notes]) +} diff --git a/src/hooks/useVariables.ts b/src/hooks/useVariables.ts new file mode 100644 index 0000000..0651297 --- /dev/null +++ b/src/hooks/useVariables.ts @@ -0,0 +1,25 @@ +import { useEffect } from 'react' +import * as mathjs from 'mathjs' +import { useAppStore } from '../store/useAppStore' + +export function useVariables() { + const notes = useAppStore((state) => state.notes) + + // Sync global variables whenever notes change + useEffect(() => { + const globals: any = {} + const reVar = /^\/globvar\s+([a-zA-Z0-9_]+)\s*=\s*(.*)$/gm + notes.forEach((note) => { + let varMatch + while ((varMatch = reVar.exec(note.content)) !== null) { + const name = varMatch[1] + try { + globals[name] = mathjs.evaluate(varMatch[2], globals) + } catch { + globals[name] = varMatch[2].trim() + } + } + }) + ;(window as any).__globalVariables = globals + }, [notes]) +} diff --git a/src/lib/editor/matchers.ts b/src/lib/editor/matchers.ts new file mode 100644 index 0000000..a032a16 --- /dev/null +++ b/src/lib/editor/matchers.ts @@ -0,0 +1,41 @@ +import { HighlightStyle } from '@codemirror/language' +import { tags as t } from '@lezer/highlight' +import { MatchDecorator, Decoration } from '@codemirror/view' + +export const mdHighlighting = HighlightStyle.define([ + { tag: t.heading1, fontSize: '1.4em', fontWeight: 'bold' }, + { tag: t.heading2, fontSize: '1.2em', fontWeight: 'bold' }, + { tag: t.heading3, fontSize: '1.1em', fontWeight: 'bold' }, + { tag: t.heading4, fontSize: '1em', fontWeight: 'bold' }, + { tag: t.heading5, fontSize: '1em', fontWeight: 'bold' }, + { tag: t.heading6, fontSize: '1em', fontWeight: 'bold' }, + { tag: t.strong, fontWeight: 'bold' }, + { tag: t.emphasis, fontStyle: 'italic' }, + { tag: t.strikethrough, textDecoration: 'line-through' }, + { tag: t.link, color: '#3b82f6', textDecoration: 'underline' }, + { tag: t.url, color: '#3b82f6' }, + { tag: t.processingInstruction, color: 'rgba(128,128,128,0.5)' }, + { tag: t.meta, color: 'rgba(128,128,128,0.5)' }, + { tag: t.punctuation, color: 'rgba(128,128,128,0.5)' }, +]) + +// Custom Decorators for syntax highlighting +export const numberMatcher = new MatchDecorator({ + regexp: /\b\d+(\.\d+)?\b/g, + decoration: Decoration.mark({ class: 'cm-custom-number' }), +}) + +export const symbolMatcher = new MatchDecorator({ + regexp: /[+\-*/=^()]/g, + decoration: Decoration.mark({ class: 'cm-custom-symbol' }), +}) + +export const aiMatcher = new MatchDecorator({ + regexp: /\u200B[\s\S]*?\u200C/g, + decoration: Decoration.mark({ class: 'cm-custom-ai' }), +}) + +export const mathMatcher = new MatchDecorator({ + regexp: /\u200B.*/g, // matches zero-width space and everything after it + decoration: Decoration.mark({ class: 'cm-custom-math' }), +}) diff --git a/src/lib/editor/plugins.ts b/src/lib/editor/plugins.ts new file mode 100644 index 0000000..4e33c8a --- /dev/null +++ b/src/lib/editor/plugins.ts @@ -0,0 +1,611 @@ +import { ViewPlugin, Decoration, EditorView, ViewUpdate, WidgetType } from '@codemirror/view' +import { syntaxTree } from '@codemirror/language' +import * as mathjs from 'mathjs' +import { numberMatcher, symbolMatcher, aiMatcher, mathMatcher } from './matchers' +import { CopyWidget, CheckboxWidget, VariableWidget, ReminderWidget } from './widgets' + +export const numberPlugin = ViewPlugin.fromClass( + class { + decorations + constructor(view: EditorView) { + this.decorations = numberMatcher.createDeco(view) + } + update(update: ViewUpdate) { + this.decorations = numberMatcher.updateDeco(update, this.decorations) + } + }, + { decorations: (v) => v.decorations } +) + +export const symbolPlugin = ViewPlugin.fromClass( + class { + decorations + constructor(view: EditorView) { + this.decorations = symbolMatcher.createDeco(view) + } + update(update: ViewUpdate) { + this.decorations = symbolMatcher.updateDeco(update, this.decorations) + } + }, + { decorations: (v) => v.decorations } +) + +export const aiPlugin = ViewPlugin.fromClass( + class { + decorations + constructor(view: EditorView) { + this.decorations = aiMatcher.createDeco(view) + } + update(update: ViewUpdate) { + this.decorations = aiMatcher.updateDeco(update, this.decorations) + } + }, + { decorations: (v) => v.decorations } +) + +export const mathPlugin = ViewPlugin.fromClass( + class { + decorations + constructor(view: EditorView) { + this.decorations = mathMatcher.createDeco(view) + } + update(update: ViewUpdate) { + this.decorations = mathMatcher.updateDeco(update, this.decorations) + } + }, + { decorations: (v) => v.decorations } +) + +export const hideMarkdownPlugin = ViewPlugin.fromClass( + class { + decorations + constructor(view: EditorView) { + this.decorations = this.buildDeco(view) + } + update(update: ViewUpdate) { + if (update.docChanged || update.viewportChanged || update.selectionSet) { + this.decorations = this.buildDeco(update.view) + } + } + + buildDeco(view: EditorView) { + const decos: { from: number; to: number; deco: Decoration }[] = [] + + const selectionRanges = view.state.selection.ranges + const isCursorInMatch = (start: number, end: number) => { + return selectionRanges.some((r: any) => r.from <= end && r.to >= start) + } + + const linkRanges: { from: number; to: number }[] = [] + const fullDoc = view.state.doc.toString() + + // Build variable scope (incorporate global variables) + const scope: any = Object.assign({}, (window as any).__globalVariables || {}) + const reVar = /^\/var\s+([a-zA-Z0-9_]+)\s*=\s*(.*)$/gm + let varMatch + while ((varMatch = reVar.exec(fullDoc)) !== null) { + const name = varMatch[1] + try { + scope[name] = mathjs.evaluate(varMatch[2], scope) + } catch { + scope[name] = varMatch[2].trim() + } + } + const scopeKeys = Object.keys(scope).sort((a, b) => b.length - a.length) + + for (const { from, to } of view.visibleRanges) { + const text = view.state.doc.sliceString(from, to) + + const reHighlight = /==(.*?)==/g + let match + while ((match = reHighlight.exec(text)) !== null) { + const start = from + match.index + const end = start + match[0].length + if (start + 2 <= end - 2) { + if (!isCursorInMatch(start, end)) { + decos.push({ from: start, to: start + 2, deco: Decoration.replace({}) }) + decos.push({ from: end - 2, to: end, deco: Decoration.replace({}) }) + } + decos.push({ + from: start + 2, + to: end - 2, + deco: Decoration.mark({ class: 'cm-custom-highlight' }), + }) + } + } + + const reList = /^(\s*)\*\s+/gm + while ((match = reList.exec(text)) !== null) { + const start = from + match.index + match[1].length + const end = start + 1 // only the asterisk + if (!isCursorInMatch(start, end + 1)) { + decos.push({ from: start, to: end, deco: Decoration.replace({}) }) + } + } + + // Handled by syntaxTree below + + const reHeading = /^#{1,6}\s+/gm + while ((match = reHeading.exec(text)) !== null) { + const start = from + match.index + const end = start + match[0].length + if (!isCursorInMatch(start, end)) { + decos.push({ from: start, to: end, deco: Decoration.replace({}) }) + } + } + + const reLink = /\[(.*?)\]\((.*?)\)/g + while ((match = reLink.exec(text)) !== null) { + const start = from + match.index + const end = start + match[0].length + linkRanges.push({ from: start, to: end }) + + const textStart = start + 1 + const textEnd = start + 1 + match[1].length + const urlStart = textEnd + const urlEnd = end + + let isFile = false + let linkPath = match[2].trim() + + if (linkPath.startsWith('/file')) { + isFile = true + linkPath = linkPath.substring(5).trim() + } else if (linkPath.startsWith('/url')) { + linkPath = linkPath.substring(4).trim() + } + + if (!isCursorInMatch(start, end)) { + decos.push({ from: start, to: textStart, deco: Decoration.replace({}) }) + decos.push({ from: urlStart, to: urlEnd, deco: Decoration.replace({}) }) + } + + if (isFile) { + decos.push({ + from: textStart, + to: textEnd, + deco: Decoration.mark({ + class: 'cm-custom-file-link', + attributes: { 'data-path': linkPath, title: 'Open file: ' + linkPath }, + }), + }) + } else { + decos.push({ + from: textStart, + to: textEnd, + deco: Decoration.mark({ + class: 'cm-custom-clickable-link', + attributes: { 'data-url': linkPath, title: linkPath }, + }), + }) + } + } + + const reFile = /\/file\s+([^\s)\]]+)/g + while ((match = reFile.exec(text)) !== null) { + const start = from + match.index + const end = start + match[0].length + + if (linkRanges.some((r) => r.from <= start && r.to >= end)) continue + + const pathStart = start + match[0].indexOf(match[1]) + + if (!isCursorInMatch(start, end)) { + decos.push({ from: start, to: pathStart, deco: Decoration.replace({}) }) + } + + decos.push({ + from: pathStart, + to: end, + deco: Decoration.mark({ + class: 'cm-custom-file-link', + attributes: { 'data-path': match[1], title: 'Open file: ' + match[1] }, + }), + }) + } + + // Variable rendering + if (scopeKeys.length > 0) { + const reKeys = new RegExp(`\\b(${scopeKeys.join('|')})\\b`, 'g') + while ((match = reKeys.exec(text)) !== null) { + const start = from + match.index + const end = start + match[0].length + const line = view.state.doc.lineAt(start) + if (line.text.trim().startsWith('/var')) continue // don't replace inside variable definitions! + + if (!isCursorInMatch(start, end)) { + decos.push({ + from: start, + to: end, + deco: Decoration.replace({ widget: new VariableWidget(scope[match[1]]) }), + }) + } else { + decos.push({ + from: start, + to: end, + deco: Decoration.mark({ class: 'cm-variable-highlight' }), + }) + } + } + } + + // Color Formats + const reColor = /#[0-9a-fA-F]{6}\b|#[0-9a-fA-F]{3}\b/g + while ((match = reColor.exec(text)) !== null) { + const start = from + match.index + const end = start + match[0].length + if (!isCursorInMatch(start, end)) { + decos.push({ + from: start, + to: end, + deco: Decoration.mark({ + class: 'cm-color-pill', + attributes: { style: `--pill-color: ${match[0]}` }, + }), + }) + } else { + decos.push({ + from: start, + to: end, + deco: Decoration.mark({ + class: 'cm-color-highlight', + attributes: { style: `--pill-color: ${match[0]}` }, + }), + }) + } + } + + // Date Formats (YYYY-MM-DD) + const reDate = /\b\d{4}-\d{2}-\d{2}\b/g + while ((match = reDate.exec(text)) !== null) { + const start = from + match.index + const end = start + match[0].length + if (!isCursorInMatch(start, end)) { + decos.push({ + from: start, + to: end, + deco: Decoration.mark({ class: 'cm-date-pill' }), + }) + } else { + decos.push({ + from: start, + to: end, + deco: Decoration.mark({ class: 'cm-date-highlight' }), + }) + } + } + + // Time Formats (HH:MM or HH:MM:SS) + const reTime = /\b\d{2}:\d{2}(?::\d{2})?\b/g + while ((match = reTime.exec(text)) !== null) { + const start = from + match.index + const end = start + match[0].length + if (!isCursorInMatch(start, end)) { + decos.push({ + from: start, + to: end, + deco: Decoration.mark({ class: 'cm-time-pill' }), + }) + } else { + decos.push({ + from: start, + to: end, + deco: Decoration.mark({ class: 'cm-time-highlight' }), + }) + } + } + + // Currency Formats + const reCurrency = /[$€£¥₹]\s*\d+(?:,\d{3})*(?:\.\d{1,2})?/g + while ((match = reCurrency.exec(text)) !== null) { + const start = from + match.index + const end = start + match[0].length + if (!isCursorInMatch(start, end)) { + decos.push({ + from: start, + to: end, + deco: Decoration.mark({ class: 'cm-currency-pill' }), + }) + } else { + decos.push({ + from: start, + to: end, + deco: Decoration.mark({ class: 'cm-currency-highlight' }), + }) + } + } + + // Tags (!tag) + const reTag = /![a-zA-Z0-9_-]+/g + while ((match = reTag.exec(text)) !== null) { + const start = from + match.index + const end = start + match[0].length + if (!isCursorInMatch(start, end)) { + decos.push({ + from: start, + to: end, + deco: Decoration.mark({ class: 'cm-tag-pill' }), + }) + } else { + decos.push({ + from: start, + to: end, + deco: Decoration.mark({ class: 'cm-tag-highlight' }), + }) + } + } + + // Checkboxes (/check, /checked) + const reCheck = /\/(check(?:ed)?)\b/g + while ((match = reCheck.exec(text)) !== null) { + const start = from + match.index + const end = start + match[0].length + const isChecked = match[1] === 'checked' + + if (!isCursorInMatch(start, end)) { + decos.push({ + from: start, + to: end, + deco: Decoration.replace({ widget: new CheckboxWidget(isChecked, start, view) }), + }) + } else { + decos.push({ + from: start, + to: end, + deco: Decoration.mark({ class: 'cm-check-highlight' }), + }) + } + + if (isChecked) { + const line = view.state.doc.lineAt(start) + if (line.to > end) { + decos.push({ + from: end, + to: line.to, + deco: Decoration.mark({ class: 'cm-checked-line-text' }), + }) + } + } + } + + // Tasks (/task, /task-done) + const reRem = /\/(task(?:-done)?)(?:\s+\((\d{4}-\d{2}-\d{2} \d{2}:\d{2})\))?\s+/g + while ((match = reRem.exec(text)) !== null) { + const start = from + match.index + const end = start + match[0].length + const isChecked = match[1] === 'task-done' + + const line = view.state.doc.lineAt(start) + let isOverdue = false + const fullRe = + /\/(task(?:-done)?)(?:\s+\((\d{4}-\d{2}-\d{2} \d{2}:\d{2})\))?\s+(.*?)(?:\s+@\s+(\d{4}-\d{2}-\d{2}(?:\s+\d{2}:\d{2}(?::\d{2})?)?))?[ \t]*$/ + const fullMatch = fullRe.exec(line.text) + if (fullMatch && fullMatch[4]) { + if (new Date(fullMatch[4]).getTime() < Date.now()) isOverdue = true + } + + if (!isCursorInMatch(start, end)) { + decos.push({ + from: start, + to: end, + deco: Decoration.replace({ + widget: new ReminderWidget(isChecked, isOverdue, start, view), + }), + }) + } else { + decos.push({ + from: start, + to: end, + deco: Decoration.mark({ class: 'cm-rem-highlight' }), + }) + } + + if (line.to > end) { + let classStr = 'cm-rem-line-text' + if (isChecked) classStr += ' cm-checked-line-text' + else if (isOverdue) classStr += ' cm-overdue-line-text' + + decos.push({ + from: end, + to: line.to, + deco: Decoration.mark({ class: classStr }), + }) + } + } + } // end of visibleRanges iteration + + // Traverse AST for Code Blocks + syntaxTree(view.state).iterate({ + enter: (node) => { + if (node.type.name === 'FencedCode') { + let lang = '' + let code = '' + let startCodeMark: any = null + let endCodeMark: any = null + let codeInfo: any = null + + let child = node.node.firstChild + while (child) { + if (child.type.name === 'CodeInfo') { + lang = view.state.doc.sliceString(child.from, child.to) + codeInfo = child + } + if (child.type.name === 'CodeText') + code = view.state.doc.sliceString(child.from, child.to) + if (child.type.name === 'CodeMark') { + if (!startCodeMark) startCodeMark = child + else endCodeMark = child + } + child = child.nextSibling + } + + const start = node.from + const end = node.to + + if (!isCursorInMatch(start, end)) { + if (startCodeMark) { + const replaceTo = codeInfo ? codeInfo.to : startCodeMark.to + decos.push({ + from: startCodeMark.from, + to: replaceTo, + deco: Decoration.replace({}), + }) + } + if (endCodeMark) { + decos.push({ + from: endCodeMark.from, + to: endCodeMark.to, + deco: Decoration.replace({}), + }) + } + } else { + if (codeInfo && !isCursorInMatch(codeInfo.from, codeInfo.to)) { + decos.push({ from: codeInfo.from, to: codeInfo.to, deco: Decoration.replace({}) }) + } + } + + if (startCodeMark) { + decos.push({ + from: startCodeMark.from, + to: startCodeMark.from, + deco: Decoration.widget({ widget: new CopyWidget(code, lang), side: 1 }), + }) + } + + const startLine = view.state.doc.lineAt(start).number + const endLine = view.state.doc.lineAt(end).number + for (let i = startLine; i <= endLine; i++) { + const line = view.state.doc.line(i) + let className = 'cm-code-block-line' + if (i === startLine) className += ' cm-code-block-first' + if (i === endLine) className += ' cm-code-block-last' + decos.push({ + from: line.from, + to: line.from, + deco: Decoration.line({ class: className }), + }) + } + } + + if (node.type.name === 'EmphasisMark' || node.type.name === 'StrongMark') { + const parent = node.node.parent + if (parent) { + const start = parent.from + const end = parent.to + if (!isCursorInMatch(start, end)) { + decos.push({ from: node.from, to: node.to, deco: Decoration.replace({}) }) + } + } + } + + if (node.type.name === 'HorizontalRule') { + const start = node.from + const end = node.to + if (!isCursorInMatch(start, end)) { + decos.push({ + from: start, + to: end, + deco: Decoration.replace({ + widget: new (class extends WidgetType { + eq() { + return true + } + toDOM() { + const hr = document.createElement('hr') + hr.className = 'cm-hr' + return hr + } + })(), + }), + }) + } + } + }, + }) + + try { + const ranges = decos.map((d) => d.deco.range(d.from, d.to)) + return Decoration.set(ranges, true) + } catch { + console.error('Decoration builder error:', e) + return Decoration.none + } + } + }, + { decorations: (v) => v.decorations } +) + +export const remConverterPlugin = ViewPlugin.fromClass( + class { + update(update: ViewUpdate) { + if (!update.docChanged) return + + const docStr = update.state.doc.toString() + const changes: { from: number; to: number; insert: string }[] = [] + + // Match /task or /task-done followed by a space, but ONLY if not already followed by a date bracket ( + const re = /^\/(task|task-done) (?!\()/gm + let match + while ((match = re.exec(docStr)) !== null) { + const now = new Date() + const yyyy = now.getFullYear() + const mm = String(now.getMonth() + 1).padStart(2, '0') + const dd = String(now.getDate()).padStart(2, '0') + const hh = String(now.getHours()).padStart(2, '0') + const mins = String(now.getMinutes()).padStart(2, '0') + + const timestamp = `(${yyyy}-${mm}-${dd} ${hh}:${mins})` + + changes.push({ + from: match.index, + to: match.index + match[0].length, + insert: `/${match[1]} ${timestamp} `, + }) + } + + // Match shorthand timers at the end of a task, ONLY after a space or Enter is typed + const reShort = + /^(\/(?:task|task-done)[^\n]*?@\s*)((?:[0-9]+[smhd])+|tmrw)([ \t]+|\n|(?:\r\n))/gm + while ((match = reShort.exec(docStr)) !== null) { + const now = new Date() + const short = match[2] + if (short === 'tmrw') { + now.setDate(now.getDate() + 1) + now.setHours(9, 0, 0, 0) + } else { + const partRe = /([0-9]+)([smhd])/g + let partMatch + while ((partMatch = partRe.exec(short)) !== null) { + const val = parseInt(partMatch[1]) + const unit = partMatch[2] + if (unit === 's') now.setSeconds(now.getSeconds() + val) + else if (unit === 'm') now.setMinutes(now.getMinutes() + val) + else if (unit === 'h') now.setHours(now.getHours() + val) + else if (unit === 'd') now.setDate(now.getDate() + val) + } + } + + const yyyy = now.getFullYear() + const mm = String(now.getMonth() + 1).padStart(2, '0') + const dd = String(now.getDate()).padStart(2, '0') + const hh = String(now.getHours()).padStart(2, '0') + const mins = String(now.getMinutes()).padStart(2, '0') + + const absoluteDate = `${yyyy}-${mm}-${dd} ${hh}:${mins}` + + // Push the change to replace ONLY the shorthand part + changes.push({ + from: match.index + match[1].length, + to: match.index + match[1].length + match[2].length, + insert: absoluteDate, + }) + } + + if (changes.length > 0) { + setTimeout(() => { + update.view.dispatch({ changes }) + }, 10) + } + } + } +) diff --git a/src/lib/editor/widgets.ts b/src/lib/editor/widgets.ts new file mode 100644 index 0000000..d545a89 --- /dev/null +++ b/src/lib/editor/widgets.ts @@ -0,0 +1,160 @@ +import { WidgetType, EditorView } from '@codemirror/view' + +export class CopyWidget extends WidgetType { + code: string + language: string + constructor(code: string, language: string) { + super() + this.code = code + this.language = language + } + + eq(other: CopyWidget) { + return other.code === this.code && other.language === this.language + } + + toDOM() { + const wrap = document.createElement('span') + wrap.setAttribute('aria-hidden', 'true') + wrap.className = 'cm-copy-button' + wrap.title = 'Copy code' + + if (this.language) { + const langSpan = document.createElement('sup') + langSpan.textContent = this.language + langSpan.className = 'cm-code-lang' + wrap.appendChild(langSpan) + } + + const iconSpan = document.createElement('span') + // Standard copy icon (two offset rounded rectangles) + iconSpan.innerHTML = `` + wrap.appendChild(iconSpan) + + wrap.onclick = (e) => { + e.preventDefault() + e.stopPropagation() + navigator.clipboard.writeText(this.code) + const originalHtml = iconSpan.innerHTML + // Checkmark icon + iconSpan.innerHTML = `` + setTimeout(() => { + iconSpan.innerHTML = originalHtml + }, 2000) + } + return wrap + } +} + +export class CheckboxWidget extends WidgetType { + checked: boolean + pos: number + view: EditorView + + constructor(checked: boolean, pos: number, view: EditorView) { + super() + this.checked = checked + this.pos = pos + this.view = view + } + + eq(other: CheckboxWidget) { + return other.checked === this.checked && other.pos === this.pos + } + + toDOM() { + const wrap = document.createElement('span') + wrap.className = 'cm-checkbox-widget' + (this.checked ? ' cm-checkbox-checked' : '') + + if (this.checked) { + wrap.innerHTML = `` + } else { + wrap.innerHTML = `` // empty for unchecked, border provides the box + } + + wrap.onclick = (e) => { + e.preventDefault() + e.stopPropagation() + const from = this.pos + const to = this.pos + (this.checked ? 8 : 6) // length of "/checked" or "/check" + const insert = this.checked ? '/check' : '/checked' + this.view.dispatch({ + changes: { from, to, insert }, + }) + } + + return wrap + } +} + +export class VariableWidget extends WidgetType { + value: string + constructor(value: string) { + super() + this.value = value + } + eq(other: VariableWidget) { + return other.value === this.value + } + toDOM() { + const span = document.createElement('span') + span.textContent = String(this.value) + span.className = 'cm-variable-pill' + return span + } +} + +export class ReminderWidget extends WidgetType { + checked: boolean + overdue: boolean + pos: number + view: EditorView + + constructor(checked: boolean, overdue: boolean, pos: number, view: EditorView) { + super() + this.checked = checked + this.overdue = overdue + this.pos = pos + this.view = view + } + + eq(other: ReminderWidget) { + return ( + other.checked === this.checked && other.pos === this.pos && other.overdue === this.overdue + ) + } + + toDOM() { + const wrap = document.createElement('span') + wrap.className = + 'cm-rem-widget' + + (this.checked ? ' cm-rem-checked' : '') + + (this.overdue && !this.checked ? ' cm-rem-overdue' : '') + + if (this.checked) { + wrap.innerHTML = `` + } else { + wrap.innerHTML = `` // empty for unchecked, border provides the box + } + + // Use onmousedown to prevent CodeMirror from interfering with selection + wrap.onmousedown = (e) => { + e.preventDefault() + e.stopPropagation() + + const from = this.pos + const to = this.pos + (this.checked ? 10 : 5) // length of "/task-done" or "/task" + const insert = this.checked ? '/task' : '/task-done' + + this.view.dispatch({ + changes: { from, to, insert }, + }) + } + + return wrap + } + + ignoreEvent() { + return true + } +} diff --git a/src/lib/safeStorage.ts b/src/lib/safeStorage.ts new file mode 100644 index 0000000..3093ff1 --- /dev/null +++ b/src/lib/safeStorage.ts @@ -0,0 +1,18 @@ +export async function setSecure(key: string, value: string): Promise { + const encrypted = await window.electronAPI.safeStorageEncrypt(value) + localStorage.setItem(`${key}-secure`, encrypted) +} + +export async function getSecure(key: string): Promise { + const encrypted = localStorage.getItem(`${key}-secure`) + if (!encrypted) return null + return await window.electronAPI.safeStorageDecrypt(encrypted) +} + +export async function migrateApiKeyFromLocalStorage(key: string) { + const plain = localStorage.getItem(key) + if (plain) { + await setSecure(key, plain) + localStorage.removeItem(key) + } +} diff --git a/src/main.tsx b/src/main.tsx index 58f9d3a..463c37d 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,9 +3,11 @@ import { createRoot } from 'react-dom/client' import './index.css' import App from './App.tsx' import Settings from './Settings.tsx' +import { migrateApiKeyFromLocalStorage } from './lib/safeStorage' function Root() { const [hash, setHash] = useState(window.location.hash) + const [migrated, setMigrated] = useState(false) useEffect(() => { const handleHashChange = () => setHash(window.location.hash) @@ -13,6 +15,16 @@ function Root() { return () => window.removeEventListener('hashchange', handleHashChange) }, []) + useEffect(() => { + async function migrate() { + await migrateApiKeyFromLocalStorage('papercache-apikey') + setMigrated(true) + } + migrate() + }, []) + + if (!migrated) return null + return {hash === '#/settings' ? : } } diff --git a/src/store/useAIStore.ts b/src/store/useAIStore.ts new file mode 100644 index 0000000..f04d966 --- /dev/null +++ b/src/store/useAIStore.ts @@ -0,0 +1,36 @@ +import { create } from 'zustand' + +export interface AIState { + apiKey: string + apiBaseUrl: string + apiModel: string + aiSystemPrompt: string + + setApiKey: (key: string) => void + setApiBaseUrl: (url: string) => void + setApiModel: (model: string) => void + setAiSystemPrompt: (prompt: string) => void +} + +export const useAIStore = create((set) => ({ + apiKey: '', + apiBaseUrl: localStorage.getItem('papercache-api-base-url') || 'https://api.openai.com/v1', + apiModel: localStorage.getItem('papercache-api-model') || 'gpt-4o', + aiSystemPrompt: + localStorage.getItem('papercache-ai-system-prompt') || + 'You are a helpful assistant directly inside a markdown note. You can format your responses with markdown.', + + setApiKey: (apiKey) => set({ apiKey }), + setApiBaseUrl: (apiBaseUrl) => { + localStorage.setItem('papercache-api-base-url', apiBaseUrl) + set({ apiBaseUrl }) + }, + setApiModel: (apiModel) => { + localStorage.setItem('papercache-api-model', apiModel) + set({ apiModel }) + }, + setAiSystemPrompt: (aiSystemPrompt) => { + localStorage.setItem('papercache-ai-system-prompt', aiSystemPrompt) + set({ aiSystemPrompt }) + }, +})) diff --git a/src/store/useAppStore.ts b/src/store/useAppStore.ts new file mode 100644 index 0000000..8ae9077 --- /dev/null +++ b/src/store/useAppStore.ts @@ -0,0 +1,106 @@ +import { create } from 'zustand' + +export interface Note { + id: string + content: string + mtime: number +} + +interface AppState { + notes: Note[] + currentNoteIndex: number + zoomLevel: number + + // UI state + showGraphView: boolean + showRemindersView: boolean + isRenaming: boolean + renameValue: string + showNoteSearch: boolean + noteSearchQuery: string + searchSelectedIndex: number + showNoteActionMenu: boolean + showMainActionMenu: boolean + actionMenuIndex: number + + setNotes: (notes: Note[] | ((prev: Note[]) => Note[])) => void + setCurrentNoteIndex: (index: number) => void + setZoomLevel: (zoom: number | ((prev: number) => number)) => void + + setShowGraphView: (show: boolean | ((prev: boolean) => boolean)) => void + setShowRemindersView: (show: boolean | ((prev: boolean) => boolean)) => void + setIsRenaming: (isRenaming: boolean) => void + setRenameValue: (renameValue: string) => void + setShowNoteSearch: (show: boolean) => void + setNoteSearchQuery: (query: string) => void + setSearchSelectedIndex: (index: number | ((prev: number) => number)) => void + setShowNoteActionMenu: (show: boolean) => void + setShowMainActionMenu: (show: boolean | ((prev: boolean) => boolean)) => void + setActionMenuIndex: (index: number | ((prev: number) => number)) => void +} + +export const useAppStore = create((set) => ({ + notes: [], + currentNoteIndex: 0, + zoomLevel: Number(localStorage.getItem('papercache-zoom')) || 1, + + showGraphView: false, + showRemindersView: false, + isRenaming: false, + renameValue: '', + showNoteSearch: false, + noteSearchQuery: '', + searchSelectedIndex: 0, + showNoteActionMenu: false, + showMainActionMenu: false, + actionMenuIndex: 0, + + setNotes: (notes) => + set((state) => ({ + notes: typeof notes === 'function' ? notes(state.notes) : notes, + })), + setCurrentNoteIndex: (currentNoteIndex) => set({ currentNoteIndex }), + setZoomLevel: (zoomLevel) => + set((state) => ({ + zoomLevel: typeof zoomLevel === 'function' ? zoomLevel(state.zoomLevel) : zoomLevel, + })), + + setShowGraphView: (showGraphView) => + set((state) => ({ + showGraphView: + typeof showGraphView === 'function' ? showGraphView(state.showGraphView) : showGraphView, + })), + setShowRemindersView: (showRemindersView) => + set((state) => ({ + showRemindersView: + typeof showRemindersView === 'function' + ? showRemindersView(state.showRemindersView) + : showRemindersView, + })), + setIsRenaming: (isRenaming) => set({ isRenaming }), + setRenameValue: (renameValue) => set({ renameValue }), + setShowNoteSearch: (showNoteSearch) => set({ showNoteSearch }), + setNoteSearchQuery: (noteSearchQuery) => set({ noteSearchQuery }), + setSearchSelectedIndex: (searchSelectedIndex) => + set((state) => ({ + searchSelectedIndex: + typeof searchSelectedIndex === 'function' + ? searchSelectedIndex(state.searchSelectedIndex) + : searchSelectedIndex, + })), + setShowNoteActionMenu: (showNoteActionMenu) => set({ showNoteActionMenu }), + setShowMainActionMenu: (showMainActionMenu) => + set((state) => ({ + showMainActionMenu: + typeof showMainActionMenu === 'function' + ? showMainActionMenu(state.showMainActionMenu) + : showMainActionMenu, + })), + setActionMenuIndex: (actionMenuIndex) => + set((state) => ({ + actionMenuIndex: + typeof actionMenuIndex === 'function' + ? actionMenuIndex(state.actionMenuIndex) + : actionMenuIndex, + })), +})) diff --git a/src/store/useSettingsStore.ts b/src/store/useSettingsStore.ts new file mode 100644 index 0000000..9b32f82 --- /dev/null +++ b/src/store/useSettingsStore.ts @@ -0,0 +1,86 @@ +import { create } from 'zustand' + +export interface SettingsState { + themePreset: string + fontFamily: string + showRulings: boolean + bgType: 'color' | 'image' + bgColor: string + bgImage: string + textColor: string + numColor: string + symColor: string + aiColor: string + mathColor: string + + setThemePreset: (preset: string) => void + setFontFamily: (font: string) => void + setShowRulings: (show: boolean) => void + setBgType: (type: 'color' | 'image') => void + setBgColor: (color: string) => void + setBgImage: (image: string) => void + setTextColor: (color: string) => void + setNumColor: (color: string) => void + setSymColor: (color: string) => void + setAiColor: (color: string) => void + setMathColor: (color: string) => void +} + +export const useSettingsStore = create((set) => ({ + themePreset: localStorage.getItem('papercache-theme-preset') || 'grid-light', + fontFamily: localStorage.getItem('papercache-font') || 'monospace', + showRulings: localStorage.getItem('papercache-rulings') !== 'false', + bgType: (localStorage.getItem('papercache-bg-type') as 'color' | 'image') || 'color', + bgColor: localStorage.getItem('papercache-bg-color') || '#ffffff', + bgImage: localStorage.getItem('papercache-bg-image') || '', + textColor: localStorage.getItem('papercache-text-color') || '#000000', + numColor: localStorage.getItem('papercache-num-color') || '#0000ff', + symColor: localStorage.getItem('papercache-sym-color') || '#ff0000', + aiColor: localStorage.getItem('papercache-ai-color') || '#8b5cf6', + mathColor: localStorage.getItem('papercache-math-color') || '#10b981', + + setThemePreset: (themePreset) => { + localStorage.setItem('papercache-theme-preset', themePreset) + set({ themePreset }) + }, + setFontFamily: (fontFamily) => { + localStorage.setItem('papercache-font', fontFamily) + set({ fontFamily }) + }, + setShowRulings: (showRulings) => { + localStorage.setItem('papercache-rulings', String(showRulings)) + set({ showRulings }) + }, + setBgType: (bgType) => { + localStorage.setItem('papercache-bg-type', bgType) + set({ bgType }) + }, + setBgColor: (bgColor) => { + localStorage.setItem('papercache-bg-color', bgColor) + set({ bgColor }) + }, + setBgImage: (bgImage) => { + localStorage.setItem('papercache-bg-image', bgImage) + set({ bgImage }) + }, + setTextColor: (textColor) => { + localStorage.setItem('papercache-text-color', textColor) + set({ textColor }) + }, + setNumColor: (numColor) => { + localStorage.setItem('papercache-num-color', numColor) + set({ numColor }) + }, + setSymColor: (symColor) => { + localStorage.setItem('papercache-sym-color', symColor) + set({ symColor }) + }, + setAiColor: (aiColor) => { + localStorage.setItem('papercache-ai-color', aiColor) + set({ aiColor }) + }, + setMathColor: (mathColor) => { + localStorage.setItem('papercache-math-color', mathColor) + set({ mathColor }) + }, +})) diff --git a/src/types.d.ts b/src/types.d.ts new file mode 100644 index 0000000..f474006 --- /dev/null +++ b/src/types.d.ts @@ -0,0 +1,26 @@ +export interface ElectronAPI { + closeWindow: () => Promise + getNotes: () => Promise + saveNote: (id: string, content: string) => Promise + deleteNote: (id: string) => Promise + renameNote: (oldId: string, newId: string) => Promise + readNote: (id: string) => Promise + exportNote: (filename: string, content: string) => Promise + openSettings: () => void + quitApp: () => void + openExternal: (url: string) => void + openFile: (path: string) => void + onSwipeGesture: (callback: (direction: string) => void) => void + setLaunchAtStartup: (value: boolean) => void + updateGlobalShortcut: (action: string, oldShortcut: string, newShortcut: string) => void + onTriggerNewNote: (callback: () => void) => void + onTriggerTasks: (callback: () => void) => void + safeStorageEncrypt: (val: string) => Promise + safeStorageDecrypt: (val: string) => Promise +} + +declare global { + interface Window { + electronAPI: ElectronAPI + } +} diff --git a/src/utils.ts b/src/utils.ts index 50302e9..5758e31 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -3,7 +3,7 @@ export const getFolderColor = (str: string): string => { let colors: Record = {} try { colors = JSON.parse(localStorage.getItem('papercache-folder-colors') || '{}') - } catch (e) {} + } catch {} if (colors[str]) return colors[str] diff --git a/tsconfig.app.json b/tsconfig.app.json index 7f42e5f..c095c0a 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -6,6 +6,10 @@ "module": "esnext", "types": ["vite/client"], "skipLibCheck": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitReturns": true, + "exactOptionalPropertyTypes": true, /* Bundler mode */ "moduleResolution": "bundler",