From 8f92a2f061a7c73b072e6cb9327b3f42f3e6ec36 Mon Sep 17 00:00:00 2001 From: Anton Reshetov Date: Tue, 12 May 2026 05:05:04 +0300 Subject: [PATCH 01/92] feat(integrations): add massCode clipper (#781) * feat(integrations): add capture API token foundation * feat(integrations): add chrome capture extension * fix(integrations): sanitize capture names * feat(integrations): enrich capture metadata * feat(integrations): add capture name input * fix(integrations): clarify note capture formatting * feat(integrations): preserve note capture markdown * feat(integrations): refine chrome capture popup * fix(integrations): infer code capture language * fix(integrations): show detected preview language * fix(integrations): persist popup target selection * fix(integrations): improve chrome page capture * fix(integrations): validate capture content * chore(integrations): rename chrome extension to clipper * feat(integrations): add clipper browser targets * fix(integrations): change revoke button variant to destructive * chore(integrations): prepare clipper distribution --- integrations/README.md | 13 + integrations/clipper/.gitignore | 2 + integrations/clipper/PRIVACY.md | 35 + integrations/clipper/README.md | 98 +++ integrations/clipper/manifests/chrome.json | 32 + integrations/clipper/manifests/firefox.json | 40 + integrations/clipper/manifests/safari.json | 33 + integrations/clipper/package.json | 24 + integrations/clipper/pnpm-lock.yaml | 814 +++++++++++++++++++ integrations/clipper/popup.html | 192 +++++ integrations/clipper/public/logo128.png | Bin 0 -> 9128 bytes integrations/clipper/public/logo16.png | Bin 0 -> 608 bytes integrations/clipper/public/logo32.png | Bin 0 -> 1282 bytes integrations/clipper/public/logo48.png | Bin 0 -> 2188 bytes integrations/clipper/scripts/package.mjs | 49 ++ integrations/clipper/src/api.ts | 204 +++++ integrations/clipper/src/background.ts | 109 +++ integrations/clipper/src/chrome.d.ts | 68 ++ integrations/clipper/src/pageCapture.ts | 21 + integrations/clipper/src/pageExtractor.ts | 130 +++ integrations/clipper/src/popup.ts | 386 +++++++++ integrations/clipper/src/styles.css | 368 +++++++++ integrations/clipper/src/types.ts | 46 ++ integrations/clipper/tsconfig.json | 15 + integrations/clipper/vite.config.ts | 73 ++ package.json | 9 + scripts/api-generate.js | 2 +- src/main/api/dto/captures.ts | 51 ++ src/main/api/index.ts | 2 + src/main/api/integrations/auth.ts | 73 ++ src/main/api/routes/captures.ts | 501 ++++++++++++ src/main/i18n/locales/cs_CZ/preferences.json | 2 +- src/main/i18n/locales/de_DE/preferences.json | 2 +- src/main/i18n/locales/el_GR/preferences.json | 2 +- src/main/i18n/locales/en_US/preferences.json | 16 +- src/main/i18n/locales/es_ES/preferences.json | 2 +- src/main/i18n/locales/fa_IR/preferences.json | 2 +- src/main/i18n/locales/fr_FR/preferences.json | 2 +- src/main/i18n/locales/ja_JP/preferences.json | 2 +- src/main/i18n/locales/pl_PL/preferences.json | 2 +- src/main/i18n/locales/pt_BR/preferences.json | 2 +- src/main/i18n/locales/ro_RO/preferences.json | 2 +- src/main/i18n/locales/ru_RU/preferences.json | 16 +- src/main/i18n/locales/tr_TR/preferences.json | 2 +- src/main/i18n/locales/uk_UA/preferences.json | 2 +- src/main/i18n/locales/zh_CN/preferences.json | 2 +- src/main/i18n/locales/zh_HK/preferences.json | 2 +- src/main/i18n/locales/zh_TW/preferences.json | 2 +- src/main/ipc/handlers/system.ts | 13 + src/main/store/__tests__/preferences.test.ts | 25 + src/main/store/module/preferences.ts | 31 + src/main/store/types/index.ts | 5 + src/main/types/ipc.ts | 2 + src/renderer/components/preferences/API.vue | 100 ++- src/renderer/services/api/generated/index.ts | 49 ++ 55 files changed, 3658 insertions(+), 19 deletions(-) create mode 100644 integrations/README.md create mode 100644 integrations/clipper/.gitignore create mode 100644 integrations/clipper/PRIVACY.md create mode 100644 integrations/clipper/README.md create mode 100644 integrations/clipper/manifests/chrome.json create mode 100644 integrations/clipper/manifests/firefox.json create mode 100644 integrations/clipper/manifests/safari.json create mode 100644 integrations/clipper/package.json create mode 100644 integrations/clipper/pnpm-lock.yaml create mode 100644 integrations/clipper/popup.html create mode 100644 integrations/clipper/public/logo128.png create mode 100644 integrations/clipper/public/logo16.png create mode 100644 integrations/clipper/public/logo32.png create mode 100644 integrations/clipper/public/logo48.png create mode 100644 integrations/clipper/scripts/package.mjs create mode 100644 integrations/clipper/src/api.ts create mode 100644 integrations/clipper/src/background.ts create mode 100644 integrations/clipper/src/chrome.d.ts create mode 100644 integrations/clipper/src/pageCapture.ts create mode 100644 integrations/clipper/src/pageExtractor.ts create mode 100644 integrations/clipper/src/popup.ts create mode 100644 integrations/clipper/src/styles.css create mode 100644 integrations/clipper/src/types.ts create mode 100644 integrations/clipper/tsconfig.json create mode 100644 integrations/clipper/vite.config.ts create mode 100644 src/main/api/dto/captures.ts create mode 100644 src/main/api/integrations/auth.ts create mode 100644 src/main/api/routes/captures.ts diff --git a/integrations/README.md b/integrations/README.md new file mode 100644 index 000000000..11a82fa04 --- /dev/null +++ b/integrations/README.md @@ -0,0 +1,13 @@ +# Integrations + +External clients that connect massCode to other tools live here. + +Examples: + +- `clipper/` +- `raycast/` +- `cli/` +- `vscode/` + +Integration clients should talk to massCode through the local integration API. +They must not read or write the vault directly. diff --git a/integrations/clipper/.gitignore b/integrations/clipper/.gitignore new file mode 100644 index 000000000..5f1fff4c0 --- /dev/null +++ b/integrations/clipper/.gitignore @@ -0,0 +1,2 @@ +dist +builds diff --git a/integrations/clipper/PRIVACY.md b/integrations/clipper/PRIVACY.md new file mode 100644 index 000000000..c50c23562 --- /dev/null +++ b/integrations/clipper/PRIVACY.md @@ -0,0 +1,35 @@ +# massCode Clipper Privacy + +massCode Clipper sends captured browser content only to the local massCode API +configured in the extension popup. + +## Data processed + +Depending on the selected capture target, the extension may read: + +- selected page text or HTML; +- extracted page title, URL, favicon URL, and readable page content; +- selected link URL; +- API port and Integration API token stored in browser extension storage. + +## Data sharing + +The extension does not send captured content to massCode Cloud or third-party +services. Save requests are sent to `http://localhost:/captures/` or +`http://127.0.0.1:/captures/`. + +## Token storage + +The Integration API token is stored in browser extension local storage. massCode +stores only a hash of the token in app preferences. Revoke the token from +massCode Preferences -> API to stop integration access. + +## Browser permissions + +Permissions are used to read the active tab on user action, create context menu +entries, run the page extractor, and save extension settings. + +## Store publishing + +Browser stores may require a public URL version of this notice before +publishing. diff --git a/integrations/clipper/README.md b/integrations/clipper/README.md new file mode 100644 index 000000000..03575dbe3 --- /dev/null +++ b/integrations/clipper/README.md @@ -0,0 +1,98 @@ +# massCode Clipper + +Browser extension client for saving web content into massCode. + +## Setup + +1. Open massCode preferences. +2. Go to API. +3. Enable API integrations and generate an API token. +4. Build the extension: + +```bash +pnpm integrations:clipper:build +``` + +5. Open `chrome://extensions`. +6. Enable Developer mode. +7. Load `integrations/clipper/dist/chrome` as an unpacked extension. +8. Paste the API token into the extension popup. + +## Browser targets + +The Clipper uses shared source code with browser-specific manifests: + +```bash +pnpm integrations:clipper:build:chrome +pnpm integrations:clipper:build:firefox +pnpm integrations:clipper:build:safari +``` + +Production outputs are written to: + +- `dist/chrome` +- `dist/firefox` +- `dist/safari` + +Store packages are written to `builds/`: + +```bash +pnpm integrations:clipper:package +pnpm integrations:clipper:package:chrome +pnpm integrations:clipper:package:firefox +pnpm integrations:clipper:package:safari +``` + +`package.json` is the version source of truth. Browser manifests are copied +from `manifests/` during build, and the output `manifest.json` receives the +package version automatically. + +## Local Firefox loading + +Firefox does not load an unpacked extension by selecting the `dist` folder. + +1. Build the Firefox target: + +```bash +pnpm integrations:clipper:build:firefox +``` + +2. Open `about:debugging#/runtime/this-firefox`. +3. Click `Load Temporary Add-on`. +4. Select `integrations/clipper/dist/firefox/manifest.json`. + +The extension stays installed until Firefox restarts. Use `Reload` on +`about:debugging` after rebuilding. + +## Local Safari loading + +1. Build the Safari target: + +```bash +pnpm integrations:clipper:build:safari +``` + +2. Open Safari settings and enable developer features. +3. In Safari developer settings, enable unsigned extensions. +4. Use `Add Temporary Extension...`. +5. Select `integrations/clipper/dist/safari`. + +Safari temporary extensions are removed after Safari exits or after the browser +temporary extension timeout expires. Store distribution requires a Safari Web +Extension app package through Xcode and App Store Connect. + +## Privacy + +See `PRIVACY.md` for the Clipper privacy note used for store review. + +## Scope + +The extension talks to the local massCode integration API and does not read or +write the vault directly. + +Supported MVP captures: + +- selected text to Code; +- selected text to Notes; +- current page to Notes; +- current page or selected link to HTTP as `GET`. diff --git a/integrations/clipper/manifests/chrome.json b/integrations/clipper/manifests/chrome.json new file mode 100644 index 000000000..f582d063a --- /dev/null +++ b/integrations/clipper/manifests/chrome.json @@ -0,0 +1,32 @@ +{ + "manifest_version": 3, + "name": "massCode Clipper", + "description": "Save browser content to massCode.", + "version": "0.1.0", + "action": { + "default_popup": "popup.html", + "default_title": "Save to massCode", + "default_icon": { + "16": "logo16.png", + "32": "logo32.png" + } + }, + "background": { + "service_worker": "background.js", + "type": "module" + }, + "icons": { + "16": "logo16.png", + "32": "logo32.png", + "48": "logo48.png", + "128": "logo128.png" + }, + "permissions": ["activeTab", "contextMenus", "scripting", "storage"], + "host_permissions": ["http://localhost/*", "http://127.0.0.1/*"], + "web_accessible_resources": [ + { + "resources": ["pageExtractor.js", "assets/*.js"], + "matches": [""] + } + ] +} diff --git a/integrations/clipper/manifests/firefox.json b/integrations/clipper/manifests/firefox.json new file mode 100644 index 000000000..4779390f0 --- /dev/null +++ b/integrations/clipper/manifests/firefox.json @@ -0,0 +1,40 @@ +{ + "manifest_version": 3, + "name": "massCode Clipper", + "description": "Save browser content to massCode.", + "version": "0.1.0", + "action": { + "default_popup": "popup.html", + "default_title": "Save to massCode", + "default_icon": { + "16": "logo16.png", + "32": "logo32.png" + } + }, + "background": { + "scripts": ["background.js"], + "type": "module" + }, + "icons": { + "16": "logo16.png", + "32": "logo32.png", + "48": "logo48.png", + "128": "logo128.png" + }, + "permissions": ["activeTab", "contextMenus", "scripting", "storage"], + "host_permissions": ["http://localhost/*", "http://127.0.0.1/*"], + "optional_host_permissions": [""], + "web_accessible_resources": [ + { + "resources": ["pageExtractor.js", "assets/*.js"], + "matches": [""], + "extension_ids": ["*"] + } + ], + "browser_specific_settings": { + "gecko": { + "id": "clipper@masscode.io", + "strict_min_version": "113.0" + } + } +} diff --git a/integrations/clipper/manifests/safari.json b/integrations/clipper/manifests/safari.json new file mode 100644 index 000000000..11c96e8cc --- /dev/null +++ b/integrations/clipper/manifests/safari.json @@ -0,0 +1,33 @@ +{ + "manifest_version": 3, + "name": "massCode Clipper", + "description": "Save browser content to massCode.", + "version": "0.1.0", + "action": { + "default_popup": "popup.html", + "default_title": "Save to massCode", + "default_icon": { + "16": "logo16.png", + "32": "logo32.png" + } + }, + "background": { + "service_worker": "background.js", + "type": "module" + }, + "icons": { + "16": "logo16.png", + "32": "logo32.png", + "48": "logo48.png", + "128": "logo128.png" + }, + "permissions": ["activeTab", "contextMenus", "scripting", "storage"], + "host_permissions": ["http://localhost/*", "http://127.0.0.1/*"], + "web_accessible_resources": [ + { + "resources": ["pageExtractor.js", "assets/*.js"], + "matches": [""], + "extension_ids": ["*"] + } + ] +} diff --git a/integrations/clipper/package.json b/integrations/clipper/package.json new file mode 100644 index 000000000..8d87f1b5c --- /dev/null +++ b/integrations/clipper/package.json @@ -0,0 +1,24 @@ +{ + "name": "@masscode/clipper", + "version": "0.1.0", + "type": "module", + "private": true, + "scripts": { + "build": "pnpm build:chrome && pnpm build:firefox && pnpm build:safari", + "build:chrome": "vite build --config vite.config.ts --mode chrome", + "build:firefox": "vite build --config vite.config.ts --mode firefox", + "build:safari": "vite build --config vite.config.ts --mode safari", + "package": "pnpm build && node scripts/package.mjs", + "package:chrome": "pnpm build:chrome && node scripts/package.mjs chrome", + "package:firefox": "pnpm build:firefox && node scripts/package.mjs firefox", + "package:safari": "pnpm build:safari && node scripts/package.mjs safari", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "defuddle": "0.18.1" + }, + "devDependencies": { + "typescript": "5.7.3", + "vite": "6.1.1" + } +} diff --git a/integrations/clipper/pnpm-lock.yaml b/integrations/clipper/pnpm-lock.yaml new file mode 100644 index 000000000..f37cec655 --- /dev/null +++ b/integrations/clipper/pnpm-lock.yaml @@ -0,0 +1,814 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + defuddle: + specifier: 0.18.1 + version: 0.18.1 + devDependencies: + typescript: + specifier: 5.7.3 + version: 5.7.3 + vite: + specifier: 6.1.1 + version: 6.1.1 + +packages: + + '@esbuild/aix-ppc64@0.24.2': + resolution: {integrity: sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.24.2': + resolution: {integrity: sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.24.2': + resolution: {integrity: sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.24.2': + resolution: {integrity: sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.24.2': + resolution: {integrity: sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.24.2': + resolution: {integrity: sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.24.2': + resolution: {integrity: sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.24.2': + resolution: {integrity: sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.24.2': + resolution: {integrity: sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.24.2': + resolution: {integrity: sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.24.2': + resolution: {integrity: sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.24.2': + resolution: {integrity: sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.24.2': + resolution: {integrity: sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.24.2': + resolution: {integrity: sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.24.2': + resolution: {integrity: sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.24.2': + resolution: {integrity: sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.24.2': + resolution: {integrity: sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.24.2': + resolution: {integrity: sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.24.2': + resolution: {integrity: sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.24.2': + resolution: {integrity: sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.24.2': + resolution: {integrity: sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.24.2': + resolution: {integrity: sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.24.2': + resolution: {integrity: sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.24.2': + resolution: {integrity: sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.24.2': + resolution: {integrity: sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@mixmark-io/domino@2.2.0': + resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==} + + '@rollup/rollup-android-arm-eabi@4.60.3': + resolution: {integrity: sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.3': + resolution: {integrity: sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.3': + resolution: {integrity: sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.3': + resolution: {integrity: sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.3': + resolution: {integrity: sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.3': + resolution: {integrity: sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.3': + resolution: {integrity: sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.60.3': + resolution: {integrity: sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.60.3': + resolution: {integrity: sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.60.3': + resolution: {integrity: sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.60.3': + resolution: {integrity: sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.60.3': + resolution: {integrity: sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.60.3': + resolution: {integrity: sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.60.3': + resolution: {integrity: sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.60.3': + resolution: {integrity: sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.60.3': + resolution: {integrity: sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.60.3': + resolution: {integrity: sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.60.3': + resolution: {integrity: sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.60.3': + resolution: {integrity: sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.60.3': + resolution: {integrity: sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.3': + resolution: {integrity: sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.3': + resolution: {integrity: sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.3': + resolution: {integrity: sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.3': + resolution: {integrity: sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.3': + resolution: {integrity: sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==} + cpu: [x64] + os: [win32] + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@xmldom/xmldom@0.8.13': + resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} + engines: {node: '>=10.0.0'} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + commander@12.1.0: + resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} + engines: {node: '>=18'} + + css-select@5.2.2: + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + + css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} + engines: {node: '>= 6'} + + cssom@0.5.0: + resolution: {integrity: sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==} + + defuddle@0.18.1: + resolution: {integrity: sha512-AvFPFOsoDjt5xUOA1QxzafSSzJ5dqEIC63yO72tHYtSjj1DYY/XM0XTPUCsHkm5A2f1X9ulBvoSVFJrd4s2ckA==} + hasBin: true + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + esbuild@0.24.2: + resolution: {integrity: sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==} + engines: {node: '>=18'} + hasBin: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + html-escaper@3.0.3: + resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} + + htmlparser2@10.1.0: + resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + + linkedom@0.18.12: + resolution: {integrity: sha512-jalJsOwIKuQJSeTvsgzPe9iJzyfVaEJiEXl+25EkKevsULHvMJzpNqwvj1jOESWdmgKDiXObyjOYwlUqG7wo1Q==} + engines: {node: '>=16'} + peerDependencies: + canvas: '>= 2' + peerDependenciesMeta: + canvas: + optional: true + + mathml-to-latex@1.5.0: + resolution: {integrity: sha512-rrWn0eEvcEcdMM4xfHcSGIy+i01DX9byOdXTLWg+w1iJ6O6ohP5UXY1dVzNUZLhzfl3EGcRekWLhY7JT5Omaew==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + postcss@8.5.14: + resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} + engines: {node: ^10 || ^12 || >=14} + + rollup@4.60.3: + resolution: {integrity: sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + temml@0.13.2: + resolution: {integrity: sha512-n8fDRSsLscq9nh9j6z+FgkCvFMT0IJm6GCgwfzh+7AHT3Sfb4jFTQlsA6hVcF2dYYr3b66oDBVES95RfoukyrA==} + engines: {node: '>=18.13.0'} + + turndown@7.2.4: + resolution: {integrity: sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==} + engines: {node: '>=18', npm: '>=9'} + + typescript@5.7.3: + resolution: {integrity: sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==} + engines: {node: '>=14.17'} + hasBin: true + + uhyphen@0.2.0: + resolution: {integrity: sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==} + + vite@6.1.1: + resolution: {integrity: sha512-4GgM54XrwRfrOp297aIYspIti66k56v16ZnqHvrIM7mG+HjDlAwS7p+Srr7J6fGvEdOJ5JcQ/D9T7HhtdXDTzA==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + +snapshots: + + '@esbuild/aix-ppc64@0.24.2': + optional: true + + '@esbuild/android-arm64@0.24.2': + optional: true + + '@esbuild/android-arm@0.24.2': + optional: true + + '@esbuild/android-x64@0.24.2': + optional: true + + '@esbuild/darwin-arm64@0.24.2': + optional: true + + '@esbuild/darwin-x64@0.24.2': + optional: true + + '@esbuild/freebsd-arm64@0.24.2': + optional: true + + '@esbuild/freebsd-x64@0.24.2': + optional: true + + '@esbuild/linux-arm64@0.24.2': + optional: true + + '@esbuild/linux-arm@0.24.2': + optional: true + + '@esbuild/linux-ia32@0.24.2': + optional: true + + '@esbuild/linux-loong64@0.24.2': + optional: true + + '@esbuild/linux-mips64el@0.24.2': + optional: true + + '@esbuild/linux-ppc64@0.24.2': + optional: true + + '@esbuild/linux-riscv64@0.24.2': + optional: true + + '@esbuild/linux-s390x@0.24.2': + optional: true + + '@esbuild/linux-x64@0.24.2': + optional: true + + '@esbuild/netbsd-arm64@0.24.2': + optional: true + + '@esbuild/netbsd-x64@0.24.2': + optional: true + + '@esbuild/openbsd-arm64@0.24.2': + optional: true + + '@esbuild/openbsd-x64@0.24.2': + optional: true + + '@esbuild/sunos-x64@0.24.2': + optional: true + + '@esbuild/win32-arm64@0.24.2': + optional: true + + '@esbuild/win32-ia32@0.24.2': + optional: true + + '@esbuild/win32-x64@0.24.2': + optional: true + + '@mixmark-io/domino@2.2.0': + optional: true + + '@rollup/rollup-android-arm-eabi@4.60.3': + optional: true + + '@rollup/rollup-android-arm64@4.60.3': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.3': + optional: true + + '@rollup/rollup-darwin-x64@4.60.3': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.3': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.3': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.3': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.3': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.3': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.3': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.3': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.3': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.3': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.3': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.3': + optional: true + + '@types/estree@1.0.8': {} + + '@xmldom/xmldom@0.8.13': + optional: true + + boolbase@1.0.0: + optional: true + + commander@12.1.0: {} + + css-select@5.2.2: + dependencies: + boolbase: 1.0.0 + css-what: 6.2.2 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + optional: true + + css-what@6.2.2: + optional: true + + cssom@0.5.0: + optional: true + + defuddle@0.18.1: + dependencies: + commander: 12.1.0 + optionalDependencies: + linkedom: 0.18.12 + mathml-to-latex: 1.5.0 + temml: 0.13.2 + turndown: 7.2.4 + transitivePeerDependencies: + - canvas + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + optional: true + + domelementtype@2.3.0: + optional: true + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + optional: true + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + optional: true + + entities@4.5.0: + optional: true + + entities@7.0.1: + optional: true + + esbuild@0.24.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.24.2 + '@esbuild/android-arm': 0.24.2 + '@esbuild/android-arm64': 0.24.2 + '@esbuild/android-x64': 0.24.2 + '@esbuild/darwin-arm64': 0.24.2 + '@esbuild/darwin-x64': 0.24.2 + '@esbuild/freebsd-arm64': 0.24.2 + '@esbuild/freebsd-x64': 0.24.2 + '@esbuild/linux-arm': 0.24.2 + '@esbuild/linux-arm64': 0.24.2 + '@esbuild/linux-ia32': 0.24.2 + '@esbuild/linux-loong64': 0.24.2 + '@esbuild/linux-mips64el': 0.24.2 + '@esbuild/linux-ppc64': 0.24.2 + '@esbuild/linux-riscv64': 0.24.2 + '@esbuild/linux-s390x': 0.24.2 + '@esbuild/linux-x64': 0.24.2 + '@esbuild/netbsd-arm64': 0.24.2 + '@esbuild/netbsd-x64': 0.24.2 + '@esbuild/openbsd-arm64': 0.24.2 + '@esbuild/openbsd-x64': 0.24.2 + '@esbuild/sunos-x64': 0.24.2 + '@esbuild/win32-arm64': 0.24.2 + '@esbuild/win32-ia32': 0.24.2 + '@esbuild/win32-x64': 0.24.2 + + fsevents@2.3.3: + optional: true + + html-escaper@3.0.3: + optional: true + + htmlparser2@10.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 7.0.1 + optional: true + + linkedom@0.18.12: + dependencies: + css-select: 5.2.2 + cssom: 0.5.0 + html-escaper: 3.0.3 + htmlparser2: 10.1.0 + uhyphen: 0.2.0 + optional: true + + mathml-to-latex@1.5.0: + dependencies: + '@xmldom/xmldom': 0.8.13 + optional: true + + nanoid@3.3.12: {} + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + optional: true + + picocolors@1.1.1: {} + + postcss@8.5.14: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rollup@4.60.3: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.3 + '@rollup/rollup-android-arm64': 4.60.3 + '@rollup/rollup-darwin-arm64': 4.60.3 + '@rollup/rollup-darwin-x64': 4.60.3 + '@rollup/rollup-freebsd-arm64': 4.60.3 + '@rollup/rollup-freebsd-x64': 4.60.3 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.3 + '@rollup/rollup-linux-arm-musleabihf': 4.60.3 + '@rollup/rollup-linux-arm64-gnu': 4.60.3 + '@rollup/rollup-linux-arm64-musl': 4.60.3 + '@rollup/rollup-linux-loong64-gnu': 4.60.3 + '@rollup/rollup-linux-loong64-musl': 4.60.3 + '@rollup/rollup-linux-ppc64-gnu': 4.60.3 + '@rollup/rollup-linux-ppc64-musl': 4.60.3 + '@rollup/rollup-linux-riscv64-gnu': 4.60.3 + '@rollup/rollup-linux-riscv64-musl': 4.60.3 + '@rollup/rollup-linux-s390x-gnu': 4.60.3 + '@rollup/rollup-linux-x64-gnu': 4.60.3 + '@rollup/rollup-linux-x64-musl': 4.60.3 + '@rollup/rollup-openbsd-x64': 4.60.3 + '@rollup/rollup-openharmony-arm64': 4.60.3 + '@rollup/rollup-win32-arm64-msvc': 4.60.3 + '@rollup/rollup-win32-ia32-msvc': 4.60.3 + '@rollup/rollup-win32-x64-gnu': 4.60.3 + '@rollup/rollup-win32-x64-msvc': 4.60.3 + fsevents: 2.3.3 + + source-map-js@1.2.1: {} + + temml@0.13.2: + optional: true + + turndown@7.2.4: + dependencies: + '@mixmark-io/domino': 2.2.0 + optional: true + + typescript@5.7.3: {} + + uhyphen@0.2.0: + optional: true + + vite@6.1.1: + dependencies: + esbuild: 0.24.2 + postcss: 8.5.14 + rollup: 4.60.3 + optionalDependencies: + fsevents: 2.3.3 diff --git a/integrations/clipper/popup.html b/integrations/clipper/popup.html new file mode 100644 index 000000000..af183454b --- /dev/null +++ b/integrations/clipper/popup.html @@ -0,0 +1,192 @@ + + + + + + Save to massCode + + + +
+
+
+

Save to massCode

+

Save selected text or the current page.

+
+ +
+ + + +
+ +
+ Current page + Waiting for selection +
+
+ +
+
+ + + +
+ + + + +
+ +
+
+ + + Save + Esc + Close +
+ +
+ +

+
+ + diff --git a/integrations/clipper/public/logo128.png b/integrations/clipper/public/logo128.png new file mode 100644 index 0000000000000000000000000000000000000000..91d0e6b316d1ed06c1e37b7e7e82506ae2f97cdb GIT binary patch literal 9128 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 0 ? requestedTargets : targets +const buildsDir = resolve(root, 'builds') + +mkdirSync(buildsDir, { recursive: true }) + +for (const target of packageTargets) { + if (!targets.includes(target)) { + throw new Error(`Unsupported clipper package target: ${target}`) + } + + const targetDir = resolve(root, 'dist', target) + const manifestPath = resolve(targetDir, 'manifest.json') + + if (!existsSync(manifestPath)) { + throw new Error(`Build ${target} before packaging it.`) + } + + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) + const version = manifest.version + const archiveName = `masscode-clipper-${version}-${target}.zip` + const archivePath = resolve(buildsDir, archiveName) + + rmSync(archivePath, { force: true }) + + const result = spawnSync('zip', ['-r', '-X', archivePath, '.'], { + cwd: targetDir, + stdio: 'inherit', + }) + + if (result.status !== 0) { + throw new Error(`Failed to package ${target}.`) + } + + console.log(`Created ${archivePath}`) +} diff --git a/integrations/clipper/src/api.ts b/integrations/clipper/src/api.ts new file mode 100644 index 000000000..cfcf3f6f0 --- /dev/null +++ b/integrations/clipper/src/api.ts @@ -0,0 +1,204 @@ +import type { + CaptureRequest, + CaptureResponse, + CaptureTarget, + ExtensionSettings, + PageCapturePayload, +} from './types' + +const DEFAULT_SETTINGS: ExtensionSettings = { + apiPort: 4321, + apiToken: '', + defaultTarget: 'notes', +} + +export async function getSettings(): Promise { + const stored = await chrome.storage.local.get(DEFAULT_SETTINGS) + + return { + apiPort: Number(stored.apiPort) || DEFAULT_SETTINGS.apiPort, + apiToken: + typeof stored.apiToken === 'string' + ? stored.apiToken + : DEFAULT_SETTINGS.apiToken, + defaultTarget: isCaptureTarget(stored.defaultTarget) + ? stored.defaultTarget + : DEFAULT_SETTINGS.defaultTarget, + } +} + +export async function saveSettings(settings: ExtensionSettings): Promise { + await chrome.storage.local.set(settings) +} + +export function buildCaptureRequest( + target: CaptureTarget, + payload: PageCapturePayload, + explicitName?: string, +): CaptureRequest { + const selectedText = payload.selectedText.trim() + const noteMarkdown = payload.selectedMarkdown ?? payload.pageMarkdown + const noteText = selectedText || payload.pageText || payload.pageTitle + const name = trimToValue(explicitName) + + if (target === 'http') { + const url = findFirstUrl(selectedText) ?? payload.url + + return { + contextLabel: payload.contextLabel, + method: 'GET', + name, + pageTitle: payload.pageTitle, + sourceTitle: payload.sourceTitle, + sourceUrl: payload.sourceUrl, + source: { + capturedAt: Date.now(), + title: payload.sourceTitle, + url: payload.sourceUrl, + }, + suggestedName: getHttpSuggestedName(url), + target, + url, + } + } + + return { + contextLabel: payload.contextLabel, + language: target === 'code' ? getCodeLanguage(payload) : undefined, + markdown: target === 'notes' ? noteMarkdown : undefined, + name, + pageTitle: payload.pageTitle, + sourceTitle: payload.sourceTitle, + sourceUrl: payload.sourceUrl, + source: { + capturedAt: Date.now(), + title: payload.sourceTitle, + url: payload.sourceUrl, + }, + suggestedName: payload.suggestedName ?? payload.contextLabel, + target, + text: target === 'notes' ? noteText : selectedText, + url: payload.url, + } +} + +export function getCaptureNameSuggestion( + target: CaptureTarget, + payload: PageCapturePayload, +): string { + if (target === 'http') { + const url = findFirstUrl(payload.selectedText.trim()) ?? payload.url + + return getHttpSuggestedName(url) + } + + return ( + payload.suggestedName + ?? payload.contextLabel + ?? payload.sourceTitle + ?? payload.pageTitle + ) +} + +export async function postCapture( + settings: ExtensionSettings, + request: CaptureRequest, +): Promise { + const response = await fetch( + `http://localhost:${settings.apiPort}/captures/`, + { + body: JSON.stringify(request), + headers: { + 'Authorization': `Bearer ${settings.apiToken}`, + 'Content-Type': 'application/json', + }, + method: 'POST', + }, + ) + + if (!response.ok) { + throw new Error(await getErrorMessage(response)) + } + + return (await response.json()) as CaptureResponse +} + +export function isCaptureTarget(value: unknown): value is CaptureTarget { + return value === 'code' || value === 'notes' || value === 'http' +} + +function findFirstUrl(text: string): string | undefined { + return text.match(/https?:\/\/\S+/)?.[0] +} + +export function getCodeLanguage(payload: PageCapturePayload): string { + const sourceName + = trimToValue(payload.contextLabel) + ?? trimToValue(payload.sourceTitle) + ?? trimToValue(payload.url) + const extension = sourceName?.match(/\.([a-z0-9]+)(?:$|[\s?#])/i)?.[1] + + if (!extension) { + return 'plain_text' + } + + const languages: Record = { + c: 'c_cpp', + cc: 'c_cpp', + cpp: 'c_cpp', + cs: 'csharp', + cjs: 'javascript', + css: 'css', + go: 'golang', + h: 'c_cpp', + hpp: 'c_cpp', + html: 'html', + java: 'java', + js: 'javascript', + json: 'json', + jsx: 'jsx', + md: 'markdown', + mjs: 'javascript', + php: 'php', + py: 'python', + rs: 'rust', + scss: 'scss', + sh: 'sh', + ts: 'typescript', + tsx: 'tsx', + vue: 'vue', + yaml: 'yaml', + yml: 'yaml', + } + + return languages[extension.toLowerCase()] ?? 'plain_text' +} + +function trimToValue(value?: string): string | undefined { + const trimmed = value?.trim() + + return trimmed || undefined +} + +function getHttpSuggestedName(url: string): string { + try { + const parsedUrl = new URL(url) + const path + = parsedUrl.pathname === '/' ? parsedUrl.hostname : parsedUrl.pathname + + return `GET ${path}` + } + catch { + return 'GET request' + } +} + +async function getErrorMessage(response: Response): Promise { + try { + const data = (await response.json()) as { message?: string } + return data.message ?? `massCode API error: ${response.status}` + } + catch { + return `massCode API error: ${response.status}` + } +} diff --git a/integrations/clipper/src/background.ts b/integrations/clipper/src/background.ts new file mode 100644 index 000000000..4f1723b61 --- /dev/null +++ b/integrations/clipper/src/background.ts @@ -0,0 +1,109 @@ +import type { CaptureTarget, PageCapturePayload } from './types' +import { buildCaptureRequest, getSettings, postCapture } from './api' +import { getPageCaptureFromPage } from './pageCapture' + +const MENU_ROOT_ID = 'masscode-capture' +const MENU_TARGETS: Array<{ + contexts: chrome.contextMenus.ContextType[] + id: CaptureTarget + title: string +}> = [ + { contexts: ['selection'], id: 'code', title: 'Code' }, + { contexts: ['selection', 'page'], id: 'notes', title: 'Note' }, + { + contexts: ['selection', 'page', 'link'], + id: 'http', + title: 'HTTP Request', + }, +] + +chrome.runtime.onInstalled.addListener(() => { + chrome.contextMenus.removeAll(() => { + chrome.contextMenus.create({ + contexts: ['selection', 'page', 'link'], + id: MENU_ROOT_ID, + title: 'Save to massCode', + }) + + MENU_TARGETS.forEach((target) => { + chrome.contextMenus.create({ + contexts: target.contexts, + id: target.id, + parentId: MENU_ROOT_ID, + title: target.title, + }) + }) + }) +}) + +chrome.contextMenus.onClicked.addListener((info, tab) => { + if (!isCaptureTarget(info.menuItemId) || !tab?.id) { + return + } + + void captureFromTab(tab.id, info.menuItemId, { + linkUrl: info.linkUrl, + selectionText: info.selectionText, + }) +}) + +async function captureFromTab( + tabId: number, + target: CaptureTarget, + context: { linkUrl?: string, selectionText?: string }, +): Promise { + try { + const settings = await getSettings() + if (!settings.apiToken.trim()) { + throw new Error('Set the massCode API token in the extension popup.') + } + + const payload = await getPageCapture(tabId) + const request = buildCaptureRequest(target, { + ...payload, + selectedText: payload.selectedText || context.selectionText?.trim() || '', + url: context.linkUrl || payload.url, + }) + + const result = await postCapture(settings, request) + await notify( + 'Saved to massCode', + `Created ${result.target} item #${result.id}.`, + ) + } + catch (error) { + await notify('massCode capture failed', getErrorMessage(error)) + } +} + +async function getPageCapture(tabId: number): Promise { + const [response] = await chrome.scripting.executeScript({ + func: getPageCaptureFromPage, + target: { tabId }, + }) + + if (!response.result) { + throw new Error('Could not read the active page.') + } + + return response.result +} + +async function notify(title: string, message: string): Promise { + await chrome.action.setBadgeText({ + text: title.startsWith('Saved') ? 'OK' : 'ERR', + }) + await chrome.action.setTitle({ title: `${title}: ${message}` }) + + setTimeout(() => { + void chrome.action.setBadgeText({ text: '' }) + }, 3000) +} + +function isCaptureTarget(value: string | number): value is CaptureTarget { + return value === 'code' || value === 'notes' || value === 'http' +} + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : 'Unknown error' +} diff --git a/integrations/clipper/src/chrome.d.ts b/integrations/clipper/src/chrome.d.ts new file mode 100644 index 000000000..9768b3059 --- /dev/null +++ b/integrations/clipper/src/chrome.d.ts @@ -0,0 +1,68 @@ +declare namespace chrome { + namespace action { + const setBadgeText: (details: { text: string }) => Promise + const setTitle: (details: { title: string }) => Promise + } + + namespace contextMenus { + type ContextType = 'selection' | 'page' | 'link' + + interface OnClickData { + linkUrl?: string + menuItemId: number | string + selectionText?: string + } + + const create: (properties: { + contexts?: ContextType[] + id: string + parentId?: string + title: string + }) => void + const removeAll: (callback?: () => void) => void + + const onClicked: { + addListener: ( + callback: (info: OnClickData, tab?: tabs.Tab) => void, + ) => void + } + } + + namespace runtime { + const getURL: (path: string) => string + + const onInstalled: { + addListener: (callback: () => void) => void + } + } + + namespace scripting { + interface InjectionResult { + result?: T + } + + const executeScript: (options: { + func: () => Promise | T + target: { tabId: number } + }) => Promise>> + } + + namespace storage { + const local: { + get: (defaults: T) => Promise + set: (values: T) => Promise + } + } + + namespace tabs { + interface Tab { + favIconUrl?: string + id?: number + } + + const query: (queryInfo: { + active: boolean + currentWindow: boolean + }) => Promise + } +} diff --git a/integrations/clipper/src/pageCapture.ts b/integrations/clipper/src/pageCapture.ts new file mode 100644 index 000000000..c5a4ef369 --- /dev/null +++ b/integrations/clipper/src/pageCapture.ts @@ -0,0 +1,21 @@ +import type { PageCapturePayload } from './types' + +interface PageExtractorModule { + getPageCaptureFromPage: () => PageCapturePayload +} + +declare global { + // eslint-disable-next-line vars-on-top, no-var + var massCodePageExtractor: PageExtractorModule | undefined +} + +export async function getPageCaptureFromPage(): Promise { + await import(chrome.runtime.getURL('pageExtractor.js')) + const extractor = globalThis.massCodePageExtractor + + if (!extractor) { + throw new Error('Could not initialize the page extractor.') + } + + return extractor.getPageCaptureFromPage() +} diff --git a/integrations/clipper/src/pageExtractor.ts b/integrations/clipper/src/pageExtractor.ts new file mode 100644 index 000000000..a486674d5 --- /dev/null +++ b/integrations/clipper/src/pageExtractor.ts @@ -0,0 +1,130 @@ +import type { PageCapturePayload } from './types' +import Defuddle, { createMarkdownContent } from 'defuddle/full' + +declare global { + // eslint-disable-next-line vars-on-top, no-var + var massCodePageExtractor: PageExtractorModule | undefined +} + +interface PageExtractorModule { + getPageCaptureFromPage: () => PageCapturePayload +} + +export function getPageCaptureFromPage(): PageCapturePayload { + const contextLabel = getContextLabel() + const selectedText = getSelectedText() + const selectedHtml = contextLabel ? '' : getSelectedHtml() + const extracted = getExtractedPage() + const pageTitle = extracted.title || document.title + const selectedMarkdown = selectedHtml + ? normalizeMarkdown( + createMarkdownContent(selectedHtml, window.location.href), + ) + : undefined + + return { + contextLabel, + pageMarkdown: contextLabel ? undefined : extracted.markdown, + pageText: extracted.text, + pageTitle, + selectedMarkdown, + selectedText, + sourceTitle: pageTitle, + sourceUrl: window.location.href, + suggestedName: contextLabel, + url: window.location.href, + } +} + +function getSelectedText(): string { + return window.getSelection()?.toString().trim() ?? '' +} + +function getSelectedHtml(): string { + const selection = window.getSelection() + + if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { + return '' + } + + const container = document.createElement('div') + for (let index = 0; index < selection.rangeCount; index += 1) { + container.append(selection.getRangeAt(index).cloneContents()) + } + + return container.innerHTML.trim() +} + +function getElementText( + selector: string, + root: ParentNode, +): string | undefined { + return root.querySelector(selector)?.textContent?.trim() || undefined +} + +function getContextLabel(): string | undefined { + const selection = window.getSelection() + const node = selection?.anchorNode + const element = node instanceof Element ? node : node?.parentElement + const fileRoot = element?.closest('.file, .gist-file, [data-path]') + + if (!fileRoot) { + return undefined + } + + const dataPath = fileRoot.getAttribute('data-path')?.trim() + if (dataPath) { + return dataPath + } + + return ( + getElementText('.file-info a', fileRoot) + ?? getElementText('.file-header a', fileRoot) + ?? getElementText('[data-testid="file-name"]', fileRoot) + ?? undefined + ) +} + +function getExtractedPage(): { + markdown?: string + text?: string + title?: string +} { + try { + const result = new Defuddle(document, { + includeReplies: false, + url: window.location.href, + }).parse() + const markdown = normalizeMarkdown( + createMarkdownContent(result.content, window.location.href), + ) + + return { + markdown: markdown || undefined, + text: getHtmlText(result.content), + title: result.title || undefined, + } + } + catch { + return { + title: document.title, + } + } +} + +function getHtmlText(html: string): string | undefined { + const parsed = new DOMParser().parseFromString(html, 'text/html') + + return parsed.body.textContent?.replace(/\s+/g, ' ').trim() || undefined +} + +function normalizeMarkdown(value: string): string { + return value + .replace(/[ \t]+\n/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim() +} + +globalThis.massCodePageExtractor = { + getPageCaptureFromPage, +} diff --git a/integrations/clipper/src/popup.ts b/integrations/clipper/src/popup.ts new file mode 100644 index 000000000..9d52c7c70 --- /dev/null +++ b/integrations/clipper/src/popup.ts @@ -0,0 +1,386 @@ +import type { + CaptureTarget, + ExtensionSettings, + PageCapturePayload, +} from './types' +import { + buildCaptureRequest, + getCaptureNameSuggestion, + getCodeLanguage, + getSettings, + isCaptureTarget, + postCapture, + saveSettings, +} from './api' +import { getPageCaptureFromPage } from './pageCapture' +import './styles.css' + +const apiPortInput = document.querySelector('#apiPort') +const apiTokenInput = document.querySelector('#apiToken') +const captureNameInput + = document.querySelector('#captureName') +const previewInput = document.querySelector('#preview') +const previewLabel = document.querySelector('#previewLabel') +const saveSettingsButton + = document.querySelector('#saveSettings') +const captureButton = document.querySelector('#capture') +const captureLabel = document.querySelector('#captureLabel') +const settingsPanel = document.querySelector('#settingsPanel') +const sourceMark = document.querySelector('.source-mark') +const sourceTitle = document.querySelector('#sourceTitle') +const sourcePath = document.querySelector('#sourcePath') +const statusText = document.querySelector('#status') +const toggleSettingsButton + = document.querySelector('#toggleSettings') +const targetButtons = Array.from( + document.querySelectorAll('[data-target]'), +) + +let settings: ExtensionSettings +let activeTarget: CaptureTarget = 'notes' +let currentPayload: PageCapturePayload | null = null +let isCaptureNameEdited = false + +void init() + +async function init(): Promise { + settings = await getSettings() + activeTarget = settings.defaultTarget + + if (apiPortInput) { + apiPortInput.value = String(settings.apiPort) + } + + if (apiTokenInput) { + apiTokenInput.value = settings.apiToken + } + + bindEvents() + updateTargetButtons() + updateSettingsPanel(!settings.apiToken.trim()) + + try { + currentPayload = await getActiveTabCapture() + updateTargetButtons() + updateCaptureName(true) + updateSourceRow() + updatePreview() + } + catch (error) { + setStatus(getErrorMessage(error), true) + } +} + +function bindEvents(): void { + targetButtons.forEach((button) => { + button.addEventListener('click', () => { + const target = button.dataset.target + if (!isCaptureTarget(target)) { + return + } + + activeTarget = target + settings.defaultTarget = activeTarget + updateTargetButtons() + updateCaptureName() + updatePreview() + void persistDefaultTarget() + }) + }) + + captureNameInput?.addEventListener('input', () => { + isCaptureNameEdited = true + }) + + toggleSettingsButton?.addEventListener('click', () => { + updateSettingsPanel(settingsPanel?.hidden ?? true) + }) + + saveSettingsButton?.addEventListener('click', () => { + void persistSettings() + }) + + captureButton?.addEventListener('click', () => { + void captureCurrentPayload() + }) + + document.addEventListener('keydown', (event) => { + if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') { + event.preventDefault() + void captureCurrentPayload() + return + } + + if (event.key === 'Escape') { + window.close() + } + }) +} + +async function persistSettings(): Promise { + settings = readSettingsFromForm() + await saveSettings(settings) + setStatus('Settings saved.') +} + +async function persistDefaultTarget(): Promise { + try { + await saveSettings(settings) + } + catch (error) { + setStatus(getErrorMessage(error), true) + } +} + +async function captureCurrentPayload(): Promise { + if (!currentPayload) { + setStatus('No active page to capture.', true) + return + } + + settings = readSettingsFromForm() + await saveSettings(settings) + + if (!settings.apiToken.trim()) { + setStatus('Set the massCode API token first.', true) + return + } + + if (!canCaptureCurrentTarget()) { + setStatus('Select text on the page to save a snippet.', true) + return + } + + try { + const request = buildCaptureRequest( + activeTarget, + currentPayload, + captureNameInput?.value, + ) + const result = await postCapture(settings, request) + setStatus(`Saved ${result.target} item #${result.id}.`) + } + catch (error) { + setStatus(getErrorMessage(error), true) + } +} + +async function getActiveTabCapture(): Promise { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }) + + if (!tab.id) { + throw new Error('No active tab found.') + } + + const [response] = await chrome.scripting.executeScript({ + func: getPageCaptureFromPage, + target: { tabId: tab.id }, + }) + + if (!response.result) { + throw new Error('Could not read the active page.') + } + + return { + ...response.result, + faviconUrl: tab.favIconUrl, + } +} + +function readSettingsFromForm(): ExtensionSettings { + return { + apiPort: Number(apiPortInput?.value) || settings.apiPort, + apiToken: apiTokenInput?.value ?? settings.apiToken, + defaultTarget: activeTarget, + } +} + +function updateTargetButtons(): void { + targetButtons.forEach((button) => { + button.dataset.active + = button.dataset.target === activeTarget ? 'true' : 'false' + }) + + if (captureLabel) { + captureLabel.textContent = getCaptureButtonLabel(activeTarget) + } + + if (captureButton) { + captureButton.disabled = !canCaptureCurrentTarget() + } +} + +function updateCaptureName(force = false): void { + if (!captureNameInput || !currentPayload) { + return + } + + if (isCaptureNameEdited && !force) { + return + } + + captureNameInput.value = getCaptureNameSuggestion( + activeTarget, + currentPayload, + ) + isCaptureNameEdited = false +} + +function updatePreview(): void { + if (!previewInput || !currentPayload) { + return + } + + if (activeTarget === 'http') { + previewInput.value = currentPayload.url + updatePreviewLabel() + return + } + + if (activeTarget === 'notes') { + previewInput.value + = currentPayload.selectedMarkdown + || currentPayload.selectedText + || currentPayload.pageMarkdown + || currentPayload.pageText + || currentPayload.pageTitle + updatePreviewLabel() + return + } + + previewInput.value + = currentPayload.selectedText || 'Select text on the page to save a snippet.' + updatePreviewLabel() +} + +function canCaptureCurrentTarget(): boolean { + if (!currentPayload) { + return false + } + + if (activeTarget === 'code') { + return Boolean(currentPayload.selectedText.trim()) + } + + return true +} + +function updateSettingsPanel(isOpen: boolean): void { + if (!settingsPanel || !toggleSettingsButton) { + return + } + + settingsPanel.hidden = !isOpen + toggleSettingsButton.dataset.active = isOpen ? 'true' : 'false' +} + +function updateSourceRow(): void { + if (!currentPayload) { + return + } + + const source = getSourceParts(currentPayload) + + if (sourceTitle) { + sourceTitle.textContent = source.title + } + + if (sourcePath) { + sourcePath.textContent = source.path + } + + updateSourceIcon(currentPayload.faviconUrl) +} + +function updatePreviewLabel(): void { + if (!previewLabel || !previewInput) { + return + } + + const lines = previewInput.value ? previewInput.value.split('\n').length : 0 + const format = getPreviewFormat() + + previewLabel.textContent = `Preview · ${format} · ${lines} ${lines === 1 ? 'line' : 'lines'}` +} + +function getPreviewFormat(): string { + if (activeTarget === 'http') { + return 'url' + } + + if (activeTarget === 'code' && currentPayload) { + if (!currentPayload.selectedText.trim()) { + return 'text' + } + + const language = getCodeLanguage(currentPayload) + + return language === 'plain_text' ? 'text' : language + } + + if ( + activeTarget === 'notes' + && (currentPayload?.selectedMarkdown || currentPayload?.pageMarkdown) + ) { + return 'markdown' + } + + return 'text' +} + +function getSourceParts(payload: PageCapturePayload): { + path: string + title: string +} { + try { + const url = new URL(payload.sourceUrl) + + return { + path: payload.contextLabel ?? (url.pathname || '/'), + title: url.hostname.replace(/^www\./, ''), + } + } + catch { + return { + path: payload.contextLabel ?? payload.sourceUrl, + title: payload.sourceTitle || 'Current page', + } + } +} + +function updateSourceIcon(faviconUrl?: string): void { + if (!sourceMark) { + return + } + + sourceMark.textContent = '' + + if (!faviconUrl) { + sourceMark.textContent = 'M' + sourceMark.style.backgroundImage = '' + return + } + + sourceMark.style.backgroundImage = `url("${faviconUrl}")` +} + +function getCaptureButtonLabel(target: CaptureTarget): string { + return target === 'code' + ? 'Save snippet' + : target === 'notes' + ? 'Save note' + : 'Save request' +} + +function setStatus(message: string, isError = false): void { + if (!statusText) { + return + } + + statusText.textContent = message + statusText.dataset.error = isError ? 'true' : 'false' +} + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : 'Unknown error' +} diff --git a/integrations/clipper/src/styles.css b/integrations/clipper/src/styles.css new file mode 100644 index 000000000..f994ecc3a --- /dev/null +++ b/integrations/clipper/src/styles.css @@ -0,0 +1,368 @@ +:root { + --radius: 0.625rem; + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --background: oklch(24.78% 0 0); + --foreground: oklch(75% 0 0); + --card: color-mix(in oklch, var(--foreground) 4%, var(--background)); + --primary: oklch(50% 0.19 260); + --primary-foreground: oklch(100% 0 0); + --secondary: oklch(27% 0 0); + --secondary-foreground: oklch(95% 0 0); + --muted: oklch(27% 0 0); + --muted-foreground: oklch(60% 0 0); + --accent: oklch(32% 0 0); + --accent-hover: color-mix(in oklch, var(--accent) 50%, var(--background)); + --accent-foreground: oklch(95% 0 0); + --border: oklch(30% 0 0); + --input: oklch(30% 0 0); + --ring: oklch(50% 0.19 260); + --destructive: oklch(0.704 0.191 22.216); + color: var(--foreground); + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", + sans-serif; + font-size: 14px; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + width: 400px; + background: var(--background); +} + +button, +input, +textarea { + font: inherit; +} + +button { + cursor: pointer; +} + +svg { + display: block; +} + +.icon { + width: 16px; + height: 16px; + fill: none; + stroke: currentcolor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 2; +} + +.shell { + overflow: hidden; + border: 1px solid var(--border); + background: var(--background); +} + +.header, +.source-row, +.content, +.footer, +.settings-panel, +.status { + padding-right: 14px; + padding-left: 14px; +} + +.header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding-top: 14px; + padding-bottom: 12px; +} + +h1, +p { + margin: 0; +} + +h1 { + color: var(--secondary-foreground); + font-size: 15px; + font-weight: 600; + letter-spacing: 0; + line-height: 20px; +} + +.header p { + margin-top: 3px; + color: var(--muted-foreground); + font-size: 12px; + line-height: 16px; +} + +.icon-button { + display: grid; + width: 28px; + height: 28px; + place-items: center; + border-radius: var(--radius-sm); + border: 0; + background: transparent; + color: var(--muted-foreground); +} + +.icon-button:hover, +.icon-button[data-active="true"] { + background: var(--accent); + color: var(--accent-foreground); +} + +.settings-panel { + display: grid; + grid-template-columns: 86px 1fr auto; + align-items: end; + gap: 8px; + padding-top: 10px; + padding-bottom: 10px; + border-top: 1px solid var(--border); + background: var(--card); +} + +.settings-panel[hidden] { + display: none; +} + +.source-row { + display: grid; + grid-template-columns: 24px minmax(0, 1fr); + align-items: center; + gap: 8px; + min-height: 42px; + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); + background: var(--card); +} + +.source-mark { + display: grid; + width: 20px; + height: 20px; + place-items: center; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background-position: center; + background-repeat: no-repeat; + background-size: 16px 16px; + color: var(--foreground); + font-size: 11px; + font-weight: 600; + line-height: 1; +} + +.source-text { + display: flex; + min-width: 0; + align-items: baseline; + gap: 6px; +} + +#sourceTitle, +#sourcePath { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +#sourceTitle { + color: var(--secondary-foreground); + font-weight: 500; +} + +#sourcePath { + color: var(--muted-foreground); + font-family: + "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; +} + +.content { + display: grid; + gap: 12px; + padding-top: 12px; + padding-bottom: 12px; +} + +.segments { + display: grid; + grid-template-columns: repeat(3, 1fr); + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--card); +} + +.segments button { + display: flex; + height: 34px; + align-items: center; + justify-content: center; + gap: 6px; + border: 0; + border-right: 1px solid var(--border); + background: transparent; + color: var(--muted-foreground); + font-size: 13px; + font-weight: 500; +} + +.segments button:last-child { + border-right: 0; +} + +.segments button:hover { + background: var(--accent-hover); + color: var(--accent-foreground); +} + +.segments button[data-active="true"] { + background: var(--accent); + color: var(--accent-foreground); +} + +label { + display: grid; + gap: 6px; + color: var(--muted-foreground); + font-size: 12px; + font-weight: 500; + line-height: 16px; +} + +input, +textarea { + width: 100%; + border: 1px solid var(--input); + border-radius: var(--radius-md); + background: var(--background); + color: var(--foreground); + outline: none; +} + +input { + height: 32px; + padding: 0 9px; +} + +textarea { + min-height: 116px; + max-height: 180px; + padding: 9px; + resize: vertical; + font-family: + "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + font-size: 12px; + line-height: 18px; +} + +input:focus, +textarea:focus { + border-color: var(--ring); + box-shadow: 0 0 0 2px rgb(113 113 122 / 22%); +} + +.footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 52px; + border-top: 1px solid var(--border); + background: var(--card); +} + +.shortcuts { + display: flex; + min-width: 0; + align-items: center; + gap: 6px; + color: var(--muted-foreground); + font-size: 12px; + white-space: nowrap; +} + +.shortcuts span:nth-child(1), +.shortcuts span:nth-child(2), +.shortcuts span:nth-child(4) { + display: grid; + min-width: 22px; + height: 22px; + place-items: center; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--background); + color: var(--muted-foreground); + font-size: 11px; +} + +.primary-button, +.secondary-button { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-md); + border: 0; + font-weight: 500; +} + +.primary-button { + min-width: 118px; + height: 34px; + padding: 0 12px; + background: var(--primary); + color: var(--primary-foreground); + font-size: 13px; +} + +.primary-button:hover { + background: color-mix(in oklch, var(--primary) 92%, black); +} + +.primary-button:disabled { + cursor: default; + opacity: 0.55; +} + +.primary-button:disabled:hover { + background: var(--primary); +} + +.secondary-button { + height: 32px; + padding: 0 10px; + border: 1px solid var(--border); + background: var(--secondary); + color: var(--secondary-foreground); +} + +.secondary-button:hover { + background: var(--accent); +} + +.status { + min-height: 16px; + padding-top: 0; + padding-bottom: 10px; + color: var(--muted-foreground); + font-size: 12px; + line-height: 16px; +} + +.status:empty { + display: none; +} + +.status[data-error="true"] { + color: var(--destructive); +} diff --git a/integrations/clipper/src/types.ts b/integrations/clipper/src/types.ts new file mode 100644 index 000000000..de2b5140e --- /dev/null +++ b/integrations/clipper/src/types.ts @@ -0,0 +1,46 @@ +export type CaptureTarget = 'code' | 'notes' | 'http' + +export interface ExtensionSettings { + apiPort: number + apiToken: string + defaultTarget: CaptureTarget +} + +export interface PageCapturePayload { + contextLabel?: string + faviconUrl?: string + pageMarkdown?: string + pageTitle: string + pageText?: string + selectedMarkdown?: string + selectedText: string + sourceTitle: string + sourceUrl: string + suggestedName?: string + url: string +} + +export interface CaptureRequest { + target: CaptureTarget + contextLabel?: string + markdown?: string + name?: string + text?: string + url?: string + pageTitle?: string + suggestedName?: string + sourceTitle?: string + sourceUrl?: string + language?: string + method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' + source?: { + title?: string + url?: string + capturedAt?: number + } +} + +export interface CaptureResponse { + target: CaptureTarget + id: number +} diff --git a/integrations/clipper/tsconfig.json b/integrations/clipper/tsconfig.json new file mode 100644 index 000000000..1dcb17b81 --- /dev/null +++ b/integrations/clipper/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "composite": false, + "target": "ES2022", + "lib": ["ES2022", "DOM"], + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noImplicitAny": true, + "noEmit": true, + "isolatedModules": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts", "vite.config.ts"] +} diff --git a/integrations/clipper/vite.config.ts b/integrations/clipper/vite.config.ts new file mode 100644 index 000000000..27b72abfe --- /dev/null +++ b/integrations/clipper/vite.config.ts @@ -0,0 +1,73 @@ +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { defineConfig } from 'vite' + +const root = __dirname +const browsers = ['chrome', 'firefox', 'safari'] as const +type BrowserTarget = (typeof browsers)[number] + +interface ClipperPackage { + version: string +} + +interface ExtensionManifest { + version: string + [key: string]: unknown +} + +function getBrowserTarget(mode: string): BrowserTarget { + return browsers.includes(mode as BrowserTarget) + ? (mode as BrowserTarget) + : 'chrome' +} + +function readJson(filePath: string): T { + return JSON.parse(readFileSync(filePath, 'utf8')) as T +} + +export default defineConfig(({ mode }) => { + const browser = getBrowserTarget(mode) + const outDir = resolve(root, 'dist', browser) + + return { + build: { + emptyOutDir: true, + outDir, + rollupOptions: { + input: { + background: resolve(root, 'src/background.ts'), + pageExtractor: resolve(root, 'src/pageExtractor.ts'), + popup: resolve(root, 'popup.html'), + }, + output: { + assetFileNames: 'assets/[name][extname]', + chunkFileNames: 'assets/[name].js', + entryFileNames: '[name].js', + }, + }, + }, + plugins: [ + { + name: 'clipper-browser-manifest', + writeBundle() { + mkdirSync(outDir, { recursive: true }) + const packageJson = readJson( + resolve(root, 'package.json'), + ) + const manifest = readJson( + resolve(root, 'manifests', `${browser}.json`), + ) + + manifest.version = packageJson.version + + writeFileSync( + resolve(outDir, 'manifest.json'), + `${JSON.stringify(manifest, null, 2)}\n`, + ) + }, + }, + ], + publicDir: resolve(root, 'public'), + root, + } +}) diff --git a/package.json b/package.json index 03512a6dc..b142c03e5 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,15 @@ "docs:generate-assets": "node docs/website/scripts/generate-assets.mjs", "docs:build": "pnpm docs:generate-assets && pnpm --dir docs/website build", "docs:preview": "pnpm --dir docs/website preview", + "integrations:clipper:build": "pnpm --dir integrations/clipper build", + "integrations:clipper:build:chrome": "pnpm --dir integrations/clipper build:chrome", + "integrations:clipper:build:firefox": "pnpm --dir integrations/clipper build:firefox", + "integrations:clipper:build:safari": "pnpm --dir integrations/clipper build:safari", + "integrations:clipper:package": "pnpm --dir integrations/clipper package", + "integrations:clipper:package:chrome": "pnpm --dir integrations/clipper package:chrome", + "integrations:clipper:package:firefox": "pnpm --dir integrations/clipper package:firefox", + "integrations:clipper:package:safari": "pnpm --dir integrations/clipper package:safari", + "integrations:clipper:typecheck": "pnpm --dir integrations/clipper typecheck", "lint": "eslint .", "lint:fix": "eslint --fix .", "release": "bumpp -c 'build: release v' -t", diff --git a/scripts/api-generate.js b/scripts/api-generate.js index e5c987b00..ad704d83c 100644 --- a/scripts/api-generate.js +++ b/scripts/api-generate.js @@ -15,7 +15,7 @@ const appDataDir = path.join( 'v2', ) const store = new Store({ name: 'preferences', cwd: appDataDir }) -const apiPort = store.get('apiPort', 4321) +const apiPort = store.get('api.port', store.get('apiPort', 4321)) const url = `http://localhost:${apiPort}/swagger/json` async function generateApi() { diff --git a/src/main/api/dto/captures.ts b/src/main/api/dto/captures.ts new file mode 100644 index 000000000..fb9b944be --- /dev/null +++ b/src/main/api/dto/captures.ts @@ -0,0 +1,51 @@ +import Elysia, { t } from 'elysia' + +const captureTarget = t.Union([ + t.Literal('code'), + t.Literal('notes'), + t.Literal('http'), +]) + +const captureHttpMethod = t.Union([ + t.Literal('GET'), + t.Literal('POST'), + t.Literal('PUT'), + t.Literal('PATCH'), + t.Literal('DELETE'), +]) + +const captureSource = t.Object({ + title: t.Optional(t.String()), + url: t.Optional(t.String()), + capturedAt: t.Optional(t.Number()), +}) + +const captureRequest = t.Object({ + target: captureTarget, + name: t.Optional(t.String()), + folderId: t.Optional(t.Union([t.Number(), t.Null()])), + text: t.Optional(t.String()), + markdown: t.Optional(t.String()), + url: t.Optional(t.String()), + pageTitle: t.Optional(t.String()), + suggestedName: t.Optional(t.String()), + sourceTitle: t.Optional(t.String()), + sourceUrl: t.Optional(t.String()), + contextLabel: t.Optional(t.String()), + language: t.Optional(t.String()), + method: t.Optional(captureHttpMethod), + source: t.Optional(captureSource), +}) + +const captureResponse = t.Object({ + target: captureTarget, + id: t.Number(), +}) + +export const capturesDTO = new Elysia().model({ + captureRequest, + captureResponse, +}) + +export type CaptureRequest = typeof captureRequest.static +export type CaptureResponse = typeof captureResponse.static diff --git a/src/main/api/index.ts b/src/main/api/index.ts index 15f64fa26..c7264a63c 100644 --- a/src/main/api/index.ts +++ b/src/main/api/index.ts @@ -4,6 +4,7 @@ import { app as electronApp } from 'electron' import { Elysia } from 'elysia' import { store } from '../store' import { importEsm } from '../utils' +import captures from './routes/captures' import folders from './routes/folders' import httpEnvironments from './routes/http-environments' import httpFolders from './routes/http-folders' @@ -40,6 +41,7 @@ export async function initApi() { }, }), ) + .use(captures) .use(snippets) .use(folders) .use(system) diff --git a/src/main/api/integrations/auth.ts b/src/main/api/integrations/auth.ts new file mode 100644 index 000000000..462393634 --- /dev/null +++ b/src/main/api/integrations/auth.ts @@ -0,0 +1,73 @@ +import { Buffer } from 'node:buffer' +import { createHash, randomBytes, timingSafeEqual } from 'node:crypto' +import { store } from '../../store' + +const TOKEN_PREFIX = 'mc_' +const TOKEN_BYTES = 32 + +export function hashIntegrationToken(token: string): string { + return createHash('sha256').update(token).digest('hex') +} + +function getTokenPreview(token: string): string { + return `${token.slice(0, 5)}...${token.slice(-4)}` +} + +function isSameHash(a: string, b: string): boolean { + const aBuffer = Buffer.from(a, 'hex') + const bBuffer = Buffer.from(b, 'hex') + + if (aBuffer.length !== bBuffer.length) { + return false + } + + return timingSafeEqual(aBuffer, bBuffer) +} + +export function generateIntegrationToken(): { + token: string + tokenPreview: string +} { + const token = `${TOKEN_PREFIX}${randomBytes(TOKEN_BYTES).toString('base64url')}` + const tokenPreview = getTokenPreview(token) + + store.preferences.set('api.integrations.enabled', true) + store.preferences.set( + 'api.integrations.tokenHash', + hashIntegrationToken(token), + ) + store.preferences.set('api.integrations.tokenPreview', tokenPreview) + + return { token, tokenPreview } +} + +export function revokeIntegrationToken(): void { + store.preferences.set('api.integrations.enabled', false) + store.preferences.set('api.integrations.tokenHash', null) + store.preferences.set('api.integrations.tokenPreview', null) +} + +export function isIntegrationTokenAuthorized( + authorizationHeader?: string, +): boolean { + const enabled = store.preferences.get('api.integrations.enabled') as boolean + const tokenHash = store.preferences.get('api.integrations.tokenHash') as + | string + | null + + if (!enabled || !tokenHash || !authorizationHeader) { + return false + } + + const bearerPrefix = 'bearer ' + if (!authorizationHeader.toLowerCase().startsWith(bearerPrefix)) { + return false + } + + const token = authorizationHeader.slice(bearerPrefix.length).trim() + if (!token) { + return false + } + + return isSameHash(hashIntegrationToken(token), tokenHash) +} diff --git a/src/main/api/routes/captures.ts b/src/main/api/routes/captures.ts new file mode 100644 index 000000000..053ff8a7a --- /dev/null +++ b/src/main/api/routes/captures.ts @@ -0,0 +1,501 @@ +import type { CaptureRequest, CaptureResponse } from '../dto/captures' +import { BrowserWindow } from 'electron' +import { Elysia } from 'elysia' +import { getEntryNameValidationIssue } from '../../../shared/entryNameValidation' +import { useHttpStorage, useNotesStorage, useStorage } from '../../storage' +import { capturesDTO } from '../dto/captures' +import { commonMessageResponse } from '../dto/common/response' +import { isIntegrationTokenAuthorized } from '../integrations/auth' + +const app = new Elysia({ prefix: '/captures' }) +const INVALID_CAPTURE_NAME_CHARS = new Set([ + '#', + '[', + ']', + '<', + '>', + ':', + '"', + '/', + '\\', + '^', + '|', + '?', + '*', +]) + +function parseStorageError( + error: unknown, +): { code: string, message: string } | null { + if (!(error instanceof Error)) { + return null + } + + const separatorIndex = error.message.indexOf(':') + if (separatorIndex <= 0) { + return null + } + + return { + code: error.message.slice(0, separatorIndex), + message: error.message.slice(separatorIndex + 1).trim(), + } +} + +function mapStorageError(status: unknown, error: unknown): never { + const setStatus = status as ( + code: number, + payload: { message: string }, + ) => never + const parsedError = parseStorageError(error) + + if (!parsedError) { + return setStatus(500, { message: 'Internal storage error' }) + } + + if (parsedError.code === 'NAME_CONFLICT') { + return setStatus(409, { message: parsedError.message }) + } + + if (parsedError.code === 'FOLDER_NOT_FOUND') { + return setStatus(404, { message: parsedError.message }) + } + + if ( + parsedError.code === 'INVALID_NAME' + || parsedError.code === 'RESERVED_NAME' + ) { + return setStatus(400, { message: parsedError.message }) + } + + return setStatus(500, { + message: parsedError.message || 'Internal storage error', + }) +} + +function trimToValue(value?: string): string | undefined { + const trimmed = value?.trim() + return trimmed || undefined +} + +function getUrlHost(url?: string): string | undefined { + if (!url) { + return undefined + } + + try { + return new URL(url).hostname + } + catch { + return undefined + } +} + +function getHttpNameFromUrl(url?: string, method = 'GET'): string | undefined { + if (!url) { + return undefined + } + + try { + const parsedUrl = new URL(url) + const path + = parsedUrl.pathname === '/' ? parsedUrl.hostname : parsedUrl.pathname + + return `${method} ${path}` + } + catch { + return undefined + } +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function getNextIndexedName(baseName: string, existingNames: string[]): string { + const normalizedBase = baseName.trim() + const indexedNameRe = new RegExp( + `^${escapeRegExp(normalizedBase)}(?:\\s+(\\d+))?$`, + 'i', + ) + + let maxIndex = 0 + existingNames.forEach((name) => { + const match = name.trim().match(indexedNameRe) + if (!match) { + return + } + + const index = match[1] ? Number(match[1]) : 0 + if (Number.isFinite(index)) { + maxIndex = Math.max(maxIndex, index) + } + }) + + return `${normalizedBase} ${maxIndex + 1}` +} + +function getUniqueName(baseName: string, existingNames: string[]): string { + const normalizedBase = sanitizeCaptureName(baseName, 'Captured item') + const hasConflict = existingNames.some( + name => name.trim().toLowerCase() === normalizedBase.toLowerCase(), + ) + + return hasConflict + ? getNextIndexedName(normalizedBase, existingNames) + : normalizedBase +} + +function sanitizeCaptureName(name: string, fallback: string): string { + let sanitized = Array.from(name) + .map(char => + INVALID_CAPTURE_NAME_CHARS.has(char) || char.charCodeAt(0) <= 0x1F + ? ' ' + : char, + ) + .join('') + .replace(/\s+/g, ' ') + .trim() + + sanitized = sanitized.replace(/^\.+/, '').replace(/\.+$/, '').trim() + + if (!sanitized) { + sanitized = fallback + } + + const issue = getEntryNameValidationIssue(sanitized) + + if (issue?.code === 'windowsReserved') { + return `${sanitized} capture` + } + + return issue ? fallback : sanitized +} + +function resolveCaptureName(body: CaptureRequest, fallback: string): string { + const sourceTitle + = trimToValue(body.sourceTitle) + ?? trimToValue(body.source?.title) + ?? trimToValue(body.pageTitle) + const sourceUrl + = trimToValue(body.sourceUrl) + ?? trimToValue(body.source?.url) + ?? trimToValue(body.url) + const explicitName = trimToValue(body.name) + + if (explicitName) { + return explicitName + } + + if (body.target === 'http') { + const requestUrl = trimToValue(body.url) ?? sourceUrl + + return ( + trimToValue(body.suggestedName) + ?? getHttpNameFromUrl(requestUrl, body.method ?? 'GET') + ?? trimToValue(body.contextLabel) + ?? getUrlHost(requestUrl) + ?? fallback + ) + } + + if (body.target === 'code') { + return ( + trimToValue(body.suggestedName) + ?? trimToValue(body.contextLabel) + ?? sourceTitle + ?? getUrlHost(sourceUrl) + ?? fallback + ) + } + + return ( + trimToValue(body.suggestedName) + ?? sourceTitle + ?? trimToValue(body.contextLabel) + ?? getUrlHost(sourceUrl) + ?? fallback + ) +} + +function resolveCapturedAt(body: CaptureRequest): string { + const capturedAt = body.source?.capturedAt + const date + = typeof capturedAt === 'number' && Number.isFinite(capturedAt) + ? new Date(capturedAt) + : new Date() + + return new Intl.DateTimeFormat('en-GB', { + day: '2-digit', + hour: '2-digit', + hour12: false, + minute: '2-digit', + month: 'short', + second: '2-digit', + timeZoneName: 'short', + year: 'numeric', + }).format(date) +} + +function createSourceDescription(body: CaptureRequest): string { + const title + = trimToValue(body.sourceTitle) + ?? trimToValue(body.source?.title) + ?? trimToValue(body.pageTitle) + const url + = trimToValue(body.sourceUrl) + ?? trimToValue(body.source?.url) + ?? trimToValue(body.url) + const lines: string[] = [] + + if (url) { + lines.push(`Captured from: ${title ? `${title} (${url})` : url}`) + } + + const contextLabel = trimToValue(body.contextLabel) + if (contextLabel) { + lines.push(`Context: ${contextLabel}`) + } + + lines.push(`Captured at: ${resolveCapturedAt(body)}`) + + return lines.join('\n') +} + +function getCodeFence(text: string): string { + const matches = text.match(/`{3,}/g) + if (!matches) { + return '```' + } + + const length = Math.max(...matches.map(match => match.length)) + 1 + + return '`'.repeat(length) +} + +function getMarkdownFenceLanguage(body: CaptureRequest): string | undefined { + const sourceName + = trimToValue(body.contextLabel) + ?? trimToValue(body.sourceTitle) + ?? trimToValue(body.source?.title) + ?? trimToValue(body.pageTitle) + + if (!sourceName) { + return undefined + } + + const extension = sourceName.match(/\.([a-z0-9]+)(?:$|[\s?#])/i)?.[1] + if (!extension) { + return undefined + } + + const languages: Record = { + cjs: 'js', + css: 'css', + html: 'html', + js: 'js', + json: 'json', + jsx: 'jsx', + md: 'md', + mjs: 'js', + sh: 'sh', + ts: 'ts', + tsx: 'tsx', + vue: 'vue', + yaml: 'yaml', + yml: 'yaml', + } + + return languages[extension.toLowerCase()] +} + +function createNoteContent(body: CaptureRequest): string { + const text = trimToValue(body.text) + const markdown = trimToValue(body.markdown) + const title + = trimToValue(body.sourceTitle) + ?? trimToValue(body.source?.title) + ?? trimToValue(body.pageTitle) + const url + = trimToValue(body.sourceUrl) + ?? trimToValue(body.source?.url) + ?? trimToValue(body.url) + const lines: string[] = [] + const sourceLines: string[] = [] + + if (markdown) { + lines.push(markdown, '') + } + else if (text) { + const language = getMarkdownFenceLanguage(body) + + if (language) { + const fence = getCodeFence(text) + + lines.push(`${fence}${language}`, text, fence, '') + } + else { + lines.push(text, '') + } + } + + if (url) { + if (title) { + sourceLines.push(`Source: ${title}`) + sourceLines.push(`URL: ${url}`) + } + else { + sourceLines.push(`Source: ${url}`) + } + } + + const contextLabel = trimToValue(body.contextLabel) + if (contextLabel) { + sourceLines.push(`Context: ${contextLabel}`) + } + + sourceLines.push(`Captured: ${resolveCapturedAt(body)}`) + lines.push(...sourceLines.map(line => `> ${line}`)) + + return lines.join('\n') +} + +function getCaptureValidationMessage(body: CaptureRequest): string | null { + if (body.target === 'code' && !trimToValue(body.text)) { + return 'Code capture requires selected text' + } + + if ( + body.target === 'notes' + && !trimToValue(body.text) + && !trimToValue(body.markdown) + ) { + return 'Notes capture requires text or markdown content' + } + + return null +} + +function notifyStorageSynced(): void { + BrowserWindow.getAllWindows().forEach((window) => { + window.webContents.send('system:storage-synced') + }) +} + +function createCodeCapture(body: CaptureRequest): CaptureResponse { + const storage = useStorage() + const folderId = body.folderId ?? null + const siblings = storage.snippets.getSnippets({ + isDeleted: 0, + ...(folderId === null ? { isInbox: 1 } : { folderId }), + }) + const name = getUniqueName( + resolveCaptureName(body, 'Captured snippet'), + siblings.map(item => item.name), + ) + const { id } = storage.snippets.createSnippet({ folderId, name }) + + storage.snippets.createSnippetContent(id, { + label: name, + language: trimToValue(body.language) ?? 'plain_text', + value: body.text ?? '', + }) + + const description = createSourceDescription(body) + if (description) { + storage.snippets.updateSnippet(id, { description }) + } + + return { id, target: 'code' } +} + +function createNotesCapture(body: CaptureRequest): CaptureResponse { + const storage = useNotesStorage() + const folderId = body.folderId ?? null + const siblings = storage.notes.getNotes({ + isDeleted: 0, + ...(folderId === null ? { isInbox: 1 } : { folderId }), + }) + const name = getUniqueName( + resolveCaptureName(body, 'Captured note'), + siblings.map(item => item.name), + ) + const { id } = storage.notes.createNote({ folderId, name }) + + storage.notes.updateNoteContent(id, createNoteContent(body)) + + return { id, target: 'notes' } +} + +function createHttpCapture(body: CaptureRequest): CaptureResponse { + const storage = useHttpStorage() + const folderId = body.folderId ?? null + const siblings = storage.requests.getRequests({ + isDeleted: 0, + ...(folderId === null ? { isInbox: 1 } : { folderId }), + }) + const url = trimToValue(body.url) ?? trimToValue(body.source?.url) ?? '' + const name = getUniqueName( + resolveCaptureName(body, 'Captured request'), + siblings.map(item => item.name), + ) + const { id } = storage.requests.createRequest({ + folderId, + method: body.method ?? 'GET', + name, + url, + }) + + const description = createSourceDescription(body) + if (description) { + storage.requests.updateRequest(id, { description }) + } + + return { id, target: 'http' } +} + +app.use(capturesDTO).post( + '/', + ({ body, headers, status }) => { + if (!isIntegrationTokenAuthorized(headers.authorization)) { + return status(401, { message: 'Unauthorized integration request' }) + } + + const validationMessage = getCaptureValidationMessage(body) + if (validationMessage) { + return status(400, { message: validationMessage }) + } + + try { + const result + = body.target === 'code' + ? createCodeCapture(body) + : body.target === 'notes' + ? createNotesCapture(body) + : createHttpCapture(body) + + notifyStorageSynced() + + return result + } + catch (error) { + return mapStorageError(status, error) + } + }, + { + body: 'captureRequest', + response: { + 200: 'captureResponse', + 400: commonMessageResponse, + 401: commonMessageResponse, + 404: commonMessageResponse, + 409: commonMessageResponse, + 500: commonMessageResponse, + }, + detail: { + tags: ['Captures'], + }, + }, +) + +export default app diff --git a/src/main/i18n/locales/cs_CZ/preferences.json b/src/main/i18n/locales/cs_CZ/preferences.json index c39de89b7..d355a1ed4 100644 --- a/src/main/i18n/locales/cs_CZ/preferences.json +++ b/src/main/i18n/locales/cs_CZ/preferences.json @@ -116,7 +116,7 @@ "codeRenderer": "Renderer bloků kódu" }, "api": { - "label": "API Port", + "label": "API", "port": { "label": "API Port", "description": "Číslo portu pro API server (vyžaduje restart aplikace). Platný rozsah: 1024-65535." diff --git a/src/main/i18n/locales/de_DE/preferences.json b/src/main/i18n/locales/de_DE/preferences.json index f35481272..dca5e46c3 100644 --- a/src/main/i18n/locales/de_DE/preferences.json +++ b/src/main/i18n/locales/de_DE/preferences.json @@ -116,7 +116,7 @@ "codeRenderer": "Code-Block Renderer" }, "api": { - "label": "API-Port", + "label": "API", "port": { "label": "API-Port", "description": "Portnummer für den API-Server (erfordert Neustart der App). Gültiger Bereich: 1024-65535." diff --git a/src/main/i18n/locales/el_GR/preferences.json b/src/main/i18n/locales/el_GR/preferences.json index 35034f287..a097c509c 100644 --- a/src/main/i18n/locales/el_GR/preferences.json +++ b/src/main/i18n/locales/el_GR/preferences.json @@ -116,7 +116,7 @@ "codeRenderer": "Code block Renderer" }, "api": { - "label": "Θύρα API", + "label": "API", "port": { "label": "Θύρα API", "description": "Αριθμός θύρας για τον διακομιστή API (απαιτείται επανεκκίνηση της εφαρμογής). Έγκυρο εύρος: 1024-65535." diff --git a/src/main/i18n/locales/en_US/preferences.json b/src/main/i18n/locales/en_US/preferences.json index 5818b1849..fa9c6e465 100644 --- a/src/main/i18n/locales/en_US/preferences.json +++ b/src/main/i18n/locales/en_US/preferences.json @@ -166,10 +166,24 @@ } }, "api": { - "label": "API Port", + "label": "API", "port": { "label": "API Port", "description": "Port number for the API server (requires app restart to take effect). Valid range: 1024-65535." + }, + "integrations": { + "label": "Integrations", + "enabled": { + "label": "Enable API integrations", + "description": "Allow external local clients to use protected integration endpoints." + }, + "token": { + "label": "API token", + "description": "Use this token in external clients. The full token is shown only after generation.", + "empty": "No token generated", + "generate": "Generate token", + "revoke": "Revoke token" + } } } } diff --git a/src/main/i18n/locales/es_ES/preferences.json b/src/main/i18n/locales/es_ES/preferences.json index c34620405..f2d7d6baf 100644 --- a/src/main/i18n/locales/es_ES/preferences.json +++ b/src/main/i18n/locales/es_ES/preferences.json @@ -116,7 +116,7 @@ "codeRenderer": "Renderizador de Bloques de Código" }, "api": { - "label": "Puerto API", + "label": "API", "port": { "label": "Puerto API", "description": "Número de puerto para el servidor API (requiere reiniciar la aplicación). Rango válido: 1024-65535." diff --git a/src/main/i18n/locales/fa_IR/preferences.json b/src/main/i18n/locales/fa_IR/preferences.json index b42ea449b..aa1db52a5 100644 --- a/src/main/i18n/locales/fa_IR/preferences.json +++ b/src/main/i18n/locales/fa_IR/preferences.json @@ -116,7 +116,7 @@ "codeRenderer": "رندرکننده بلوک کد" }, "api": { - "label": "پورت API", + "label": "API", "port": { "label": "پورت API", "description": "شماره پورت برای سرور API (نیاز به راه‌اندازی مجدد برنامه دارد). محدوده معتبر: 1024-65535." diff --git a/src/main/i18n/locales/fr_FR/preferences.json b/src/main/i18n/locales/fr_FR/preferences.json index 0b8b58dd2..8e24904e3 100644 --- a/src/main/i18n/locales/fr_FR/preferences.json +++ b/src/main/i18n/locales/fr_FR/preferences.json @@ -116,7 +116,7 @@ "codeRenderer": "Rendu des blocs de code" }, "api": { - "label": "Port API", + "label": "API", "port": { "label": "Port API", "description": "Numéro de port pour le serveur API (nécessite un redémarrage de l'application). Plage valide: 1024-65535." diff --git a/src/main/i18n/locales/ja_JP/preferences.json b/src/main/i18n/locales/ja_JP/preferences.json index 6a68f11df..646a16904 100644 --- a/src/main/i18n/locales/ja_JP/preferences.json +++ b/src/main/i18n/locales/ja_JP/preferences.json @@ -116,7 +116,7 @@ "codeRenderer": "コードブロックレンダラー" }, "api": { - "label": "APIポート", + "label": "API", "port": { "label": "APIポート", "description": "APIサーバーのポート番号(アプリの再起動が必要です)。有効範囲:1024-65535。" diff --git a/src/main/i18n/locales/pl_PL/preferences.json b/src/main/i18n/locales/pl_PL/preferences.json index 81f479bf8..fb2776c29 100644 --- a/src/main/i18n/locales/pl_PL/preferences.json +++ b/src/main/i18n/locales/pl_PL/preferences.json @@ -116,7 +116,7 @@ "codeRenderer": "Renderer bloków kodu" }, "api": { - "label": "Port API", + "label": "API", "port": { "label": "Port API", "description": "Numer portu dla serwera API (wymaga ponownego uruchomienia aplikacji). Prawidłowy zakres: 1024-65535." diff --git a/src/main/i18n/locales/pt_BR/preferences.json b/src/main/i18n/locales/pt_BR/preferences.json index 440693229..e2cf6ee4a 100644 --- a/src/main/i18n/locales/pt_BR/preferences.json +++ b/src/main/i18n/locales/pt_BR/preferences.json @@ -116,7 +116,7 @@ "codeRenderer": "Renderizador de Bloco de Código" }, "api": { - "label": "Porta API", + "label": "API", "port": { "label": "Porta API", "description": "Número da porta para o servidor API (requer reinicialização do aplicativo). Faixa válida: 1024-65535." diff --git a/src/main/i18n/locales/ro_RO/preferences.json b/src/main/i18n/locales/ro_RO/preferences.json index f33e4fb77..d1a028d6c 100644 --- a/src/main/i18n/locales/ro_RO/preferences.json +++ b/src/main/i18n/locales/ro_RO/preferences.json @@ -116,7 +116,7 @@ "codeRenderer": "Renderer Bloc de Cod" }, "api": { - "label": "Port API", + "label": "API", "port": { "label": "Port API", "description": "Numărul portului pentru serverul API (necesită repornirea aplicației). Interval valid: 1024-65535." diff --git a/src/main/i18n/locales/ru_RU/preferences.json b/src/main/i18n/locales/ru_RU/preferences.json index b3a743575..b2b0480cf 100644 --- a/src/main/i18n/locales/ru_RU/preferences.json +++ b/src/main/i18n/locales/ru_RU/preferences.json @@ -133,10 +133,24 @@ } }, "api": { - "label": "Порт API", + "label": "API", "port": { "label": "Порт API", "description": "Номер порта для API-сервера (требуется перезапуск приложения). Допустимый диапазон: 1024-65535." + }, + "integrations": { + "label": "Интеграции", + "enabled": { + "label": "Включить API-интеграции", + "description": "Разрешить внешним локальным клиентам использовать защищённые endpoints интеграций." + }, + "token": { + "label": "API token", + "description": "Используйте этот token во внешних клиентах. Полный token показывается только после генерации.", + "empty": "Token не создан", + "generate": "Создать token", + "revoke": "Отозвать token" + } } } } diff --git a/src/main/i18n/locales/tr_TR/preferences.json b/src/main/i18n/locales/tr_TR/preferences.json index 5a9c225d1..fb63667ac 100644 --- a/src/main/i18n/locales/tr_TR/preferences.json +++ b/src/main/i18n/locales/tr_TR/preferences.json @@ -116,7 +116,7 @@ "codeRenderer": "Kod Bloğu Görüntüleyici" }, "api": { - "label": "API Portu", + "label": "API", "port": { "label": "API Portu", "description": "API sunucusu için port numarası (uygulamayı yeniden başlatmak gerekir). Geçerli aralık: 1024-65535." diff --git a/src/main/i18n/locales/uk_UA/preferences.json b/src/main/i18n/locales/uk_UA/preferences.json index d7e61e9a5..be9a82d04 100644 --- a/src/main/i18n/locales/uk_UA/preferences.json +++ b/src/main/i18n/locales/uk_UA/preferences.json @@ -116,7 +116,7 @@ "codeRenderer": "Відображення блоків коду" }, "api": { - "label": "Порт API", + "label": "API", "port": { "label": "Порт API", "description": "Номер порту для API-сервера (потребує перезапуску додатку). Допустимий діапазон: 1024-65535." diff --git a/src/main/i18n/locales/zh_CN/preferences.json b/src/main/i18n/locales/zh_CN/preferences.json index 33df8ad7a..6f7e0bc59 100644 --- a/src/main/i18n/locales/zh_CN/preferences.json +++ b/src/main/i18n/locales/zh_CN/preferences.json @@ -116,7 +116,7 @@ "codeRenderer": "代码块渲染器" }, "api": { - "label": "API 端口", + "label": "API", "port": { "label": "API 端口", "description": "API 服务器的端口号(需要重启应用程序才能生效)。有效范围:1024-65535。" diff --git a/src/main/i18n/locales/zh_HK/preferences.json b/src/main/i18n/locales/zh_HK/preferences.json index c4b84914d..495c87a77 100644 --- a/src/main/i18n/locales/zh_HK/preferences.json +++ b/src/main/i18n/locales/zh_HK/preferences.json @@ -116,7 +116,7 @@ "codeRenderer": "程式碼區塊渲染器" }, "api": { - "label": "API 端口", + "label": "API", "port": { "label": "API 端口", "description": "API 伺服器的端口號(需要重新啟動應用程式)。有效範圍:1024-65535。" diff --git a/src/main/i18n/locales/zh_TW/preferences.json b/src/main/i18n/locales/zh_TW/preferences.json index ea37b4d09..25aa41e38 100644 --- a/src/main/i18n/locales/zh_TW/preferences.json +++ b/src/main/i18n/locales/zh_TW/preferences.json @@ -116,7 +116,7 @@ "codeRenderer": "代碼塊渲染器" }, "api": { - "label": "API 連接埠", + "label": "API", "port": { "label": "API 連接埠", "description": "API 伺服器的連接埠號碼(需要重新啟動應用程式)。有效範圍:1024-65535。" diff --git a/src/main/ipc/handlers/system.ts b/src/main/ipc/handlers/system.ts index 1c9b5796b..9adca2d96 100644 --- a/src/main/ipc/handlers/system.ts +++ b/src/main/ipc/handlers/system.ts @@ -1,5 +1,9 @@ import path from 'node:path' import { app, ipcMain, shell } from 'electron' +import { + generateIntegrationToken, + revokeIntegrationToken, +} from '../../api/integrations/auth' import { getCurrencyRates, refreshCryptoRatesForced, @@ -28,6 +32,15 @@ import { getVaultPath } from '../../storage/providers/markdown/runtime/paths' import { store } from '../../store' export function registerSystemHandlers() { + ipcMain.handle('system:api-token-generate', () => { + return generateIntegrationToken() + }) + + ipcMain.handle('system:api-token-revoke', () => { + revokeIntegrationToken() + return true + }) + ipcMain.handle('system:currency-rates', () => { return getCurrencyRates() }) diff --git a/src/main/store/__tests__/preferences.test.ts b/src/main/store/__tests__/preferences.test.ts index a1afd5814..e88e9d794 100644 --- a/src/main/store/__tests__/preferences.test.ts +++ b/src/main/store/__tests__/preferences.test.ts @@ -140,6 +140,9 @@ describe('preferences store sanitization', () => { expect(preferences.get('storage.rootPath' as any)).toBe('/custom-storage') expect(preferences.get('api.port' as any)).toBe(9876) + expect(preferences.get('api.integrations.enabled' as any)).toBe(false) + expect(preferences.get('api.integrations.tokenHash' as any)).toBeNull() + expect(preferences.get('api.integrations.tokenPreview' as any)).toBeNull() expect(preferences.get('localization.locale' as any)).toBe('ru_RU') expect(preferences.get('appearance.theme' as any)).toBe('dark') expect(preferences.get('editor.code.fontSize' as any)).toBe(18) @@ -200,6 +203,28 @@ describe('preferences store sanitization', () => { expect(preferences.get('http.defaultPreviewFormat' as any)).toBe('http') expect(preferences.get('http.autoSwitchToResponse' as any)).toBe(true) }) + + it('keeps sanitized API integration settings', async () => { + persistedStateByName.preferences = { + api: { + integrations: { + enabled: true, + tokenHash: 'hash', + tokenPreview: 'mc_...abcd', + stale: true, + }, + }, + } + + const { default: preferences } = await import('../module/preferences') + + expect(preferences.get('api.integrations.enabled' as any)).toBe(true) + expect(preferences.get('api.integrations.tokenHash' as any)).toBe('hash') + expect(preferences.get('api.integrations.tokenPreview' as any)).toBe( + 'mc_...abcd', + ) + expect(preferences.get('api.integrations.stale' as any)).toBeUndefined() + }) }) describe('app store sanitization', () => { diff --git a/src/main/store/module/preferences.ts b/src/main/store/module/preferences.ts index 041252636..d6f249e72 100644 --- a/src/main/store/module/preferences.ts +++ b/src/main/store/module/preferences.ts @@ -34,6 +34,12 @@ const HTTP_DEFAULTS: HttpSettings = { autoSwitchToResponse: true, } +const API_INTEGRATIONS_DEFAULTS: PreferencesStore['api']['integrations'] = { + enabled: false, + tokenHash: null, + tokenPreview: null, +} + const PREFERENCES_DEFAULTS: PreferencesStore = { appearance: { theme: 'auto', @@ -43,6 +49,7 @@ const PREFERENCES_DEFAULTS: PreferencesStore = { }, api: { port: 4321, + integrations: API_INTEGRATIONS_DEFAULTS, }, storage: { rootPath: storagePath, @@ -59,6 +66,29 @@ const PREFERENCES_DEFAULTS: PreferencesStore = { http: HTTP_DEFAULTS, } +function sanitizeApiIntegrationsSettings( + value: unknown, +): PreferencesStore['api']['integrations'] { + const source = asRecord(value) + + return { + enabled: + typeof source.enabled === 'boolean' + ? source.enabled + : API_INTEGRATIONS_DEFAULTS.enabled, + tokenHash: readNullableString( + source, + 'tokenHash', + API_INTEGRATIONS_DEFAULTS.tokenHash, + ), + tokenPreview: readNullableString( + source, + 'tokenPreview', + API_INTEGRATIONS_DEFAULTS.tokenPreview, + ), + } +} + function sanitizeCodeEditorSettings(value: unknown): EditorSettings { const source = asRecord(value) @@ -246,6 +276,7 @@ function sanitizePreferences(value: unknown): PreferencesStore { 'port', readNumber(source, 'apiPort', PREFERENCES_DEFAULTS.api.port), ), + integrations: sanitizeApiIntegrationsSettings(apiSource.integrations), }, storage: { rootPath: readString( diff --git a/src/main/store/types/index.ts b/src/main/store/types/index.ts index 47cae2f04..6cc7a44f1 100644 --- a/src/main/store/types/index.ts +++ b/src/main/store/types/index.ts @@ -203,6 +203,11 @@ export interface PreferencesStore { } api: { port: number + integrations: { + enabled: boolean + tokenHash: string | null + tokenPreview: string | null + } } storage: StorageSettings & { rootPath: string diff --git a/src/main/types/ipc.ts b/src/main/types/ipc.ts index ddeb2f45a..963c3b967 100644 --- a/src/main/types/ipc.ts +++ b/src/main/types/ipc.ts @@ -37,6 +37,8 @@ type MainMenuAction = type DBAction = 'migrate-to-markdown' type SystemAction = + | 'api-token-generate' + | 'api-token-revoke' | 'currency-rates' | 'currency-rates-refresh' | 'crypto-rates-refresh' diff --git a/src/renderer/components/preferences/API.vue b/src/renderer/components/preferences/API.vue index 7c0cb024c..473612451 100644 --- a/src/renderer/components/preferences/API.vue +++ b/src/renderer/components/preferences/API.vue @@ -1,8 +1,21 @@ + + + + + + + + +
+ + + + +
+ +
+
diff --git a/src/renderer/services/api/generated/index.ts b/src/renderer/services/api/generated/index.ts index e79a0ca5c..5ca2e2228 100644 --- a/src/renderer/services/api/generated/index.ts +++ b/src/renderer/services/api/generated/index.ts @@ -9,6 +9,32 @@ * --------------------------------------------------------------- */ +export interface CaptureRequest { + target: "code" | "notes" | "http"; + name?: string; + folderId?: number | null; + text?: string; + markdown?: string; + url?: string; + pageTitle?: string; + suggestedName?: string; + sourceTitle?: string; + sourceUrl?: string; + contextLabel?: string; + language?: string; + method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + source?: { + title?: string; + url?: string; + capturedAt?: number; + }; +} + +export interface CaptureResponse { + target: "code" | "notes" | "http"; + id: number; +} + export interface SnippetContentsAdd { label: string; value: string | null; @@ -1017,6 +1043,29 @@ export class HttpClient { export class Api< SecurityDataType extends unknown, > extends HttpClient { + captures = { + /** + * No description + * + * @tags Captures + * @name PostCaptures + * @request POST:/captures/ + */ + postCaptures: (data: CaptureRequest, params: RequestParams = {}) => + this.request< + CaptureResponse, + { + message: string; + } + >({ + path: `/captures/`, + method: "POST", + body: data, + type: ContentType.Json, + format: "json", + ...params, + }), + }; snippets = { /** * No description From bc3c1b1ab370e1f0a759c9b3f061daeb439f30e3 Mon Sep 17 00:00:00 2001 From: Anton Reshetov Date: Tue, 12 May 2026 05:28:53 +0300 Subject: [PATCH 02/92] docs: add clipper (#782) * docs: add clipper * docs: add clipper screenshot * docs: add clipper downloads --- .github/workflows/deploy-docs.yml | 16 ++- docs/website/.vitepress/config.mts | 1 + docs/website/documentation/clipper.md | 134 ++++++++++++++++++++++++++ docs/website/documentation/index.md | 4 + docs/website/public/clipper.png | Bin 0 -> 57044 bytes 5 files changed, 153 insertions(+), 2 deletions(-) create mode 100644 docs/website/documentation/clipper.md create mode 100644 docs/website/public/clipper.png diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 3119e327e..3e71a76dc 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -22,10 +22,22 @@ jobs: with: node-version: 24.14.1 cache: pnpm - cache-dependency-path: docs/website/pnpm-lock.yaml + cache-dependency-path: | + docs/website/pnpm-lock.yaml + integrations/clipper/pnpm-lock.yaml - name: Install dependencies - run: pnpm --dir docs/website install + run: | + pnpm --dir docs/website install + pnpm --dir integrations/clipper install + + - name: Build clipper downloads + run: | + pnpm --dir integrations/clipper package + mkdir -p docs/website/public/clipper/downloads + cp integrations/clipper/builds/*-chrome.zip docs/website/public/clipper/downloads/clipper-chrome.zip + cp integrations/clipper/builds/*-firefox.zip docs/website/public/clipper/downloads/clipper-firefox.zip + cp integrations/clipper/builds/*-safari.zip docs/website/public/clipper/downloads/clipper-safari.zip - name: Build docs run: pnpm docs:build diff --git a/docs/website/.vitepress/config.mts b/docs/website/.vitepress/config.mts index 46276a15e..87aee6a07 100644 --- a/docs/website/.vitepress/config.mts +++ b/docs/website/.vitepress/config.mts @@ -118,6 +118,7 @@ export default defineConfig({ { text: 'Storage', link: '/documentation/storage' }, { text: 'Sync', link: '/documentation/sync' }, { text: 'Imports', link: '/documentation/imports' }, + { text: 'Clipper', link: '/documentation/clipper' }, { text: 'Themes', link: '/documentation/themes' }, ], }, diff --git a/docs/website/documentation/clipper.md b/docs/website/documentation/clipper.md new file mode 100644 index 000000000..f585e30b5 --- /dev/null +++ b/docs/website/documentation/clipper.md @@ -0,0 +1,134 @@ +--- +title: Clipper +description: "Set up massCode Clipper locally to save selected text, pages, and links from Chrome, Firefox, or Safari into Code, Notes, and HTTP." +--- + +# Clipper + +massCode Clipper saves web content from your browser into the local massCode app. Use it to send selected code to Code, selected text or readable page content to Notes, and pages or links to HTTP as `GET` requests. + +

+massCode Clipper popup +

+ +The browser store version is not available yet. Until distribution is published, download a build from this page or install the Clipper from a local repository build. + +## Enable The Local API + +The Clipper talks to massCode through the local Integration API. + +1. Open massCode. +2. Open **Preferences**. +3. Go to **API**. +4. Keep the **API Port** value. The default is `4321`. +5. Enable **API integrations**. +6. Click **Generate token**. +7. Copy the generated token. The full token is shown only after generation. + +If you change the API port, reload the app before using the new port from the browser extension. + +## Download The Clipper + +Download the archive for your browser: + +- [Chrome build](/clipper/downloads/clipper-chrome.zip) +- [Firefox build](/clipper/downloads/clipper-firefox.zip) +- [Safari build](/clipper/downloads/clipper-safari.zip) + +Unzip the archive before loading it into the browser. Browsers do not load these local development builds directly from the ZIP file. + +## Build The Clipper Locally + +Run the build from the repository root: + +```bash +pnpm integrations:clipper:build +``` + +This creates browser-specific builds: + +- `integrations/clipper/dist/chrome` +- `integrations/clipper/dist/firefox` +- `integrations/clipper/dist/safari` + +After installing the extension in a browser, open the Clipper popup, set the API port, paste the API token, and click **Save settings**. + +## Chrome + +If you downloaded the archive, unzip `clipper-chrome.zip` first. + +If you are building from the repository, build the Chrome target: + +```bash +pnpm integrations:clipper:build:chrome +``` + +2. Open `chrome://extensions`. +3. Enable **Developer mode**. +4. Click **Load unpacked**. +5. Select the unzipped Chrome build folder, or `integrations/clipper/dist/chrome` when building locally. + +After rebuilding, return to `chrome://extensions` and reload the unpacked extension. + +## Firefox + +Firefox does not load an unpacked extension by selecting the `dist` folder. + +If you downloaded the archive, unzip `clipper-firefox.zip` first. + +If you are building from the repository, build the Firefox target: + +```bash +pnpm integrations:clipper:build:firefox +``` + +Then: + +1. Open `about:debugging#/runtime/this-firefox`. +2. Click **Load Temporary Add-on**. +3. Select `manifest.json` inside the unzipped Firefox build folder, or `integrations/clipper/dist/firefox/manifest.json` when building locally. + +The temporary extension stays installed until Firefox restarts. Use **Reload** on `about:debugging` after rebuilding. + +## Safari + +If you downloaded the archive, unzip `clipper-safari.zip` first. + +If you are building from the repository, build the Safari target: + +```bash +pnpm integrations:clipper:build:safari +``` + +Then: + +1. Open Safari. +2. Choose **Safari** > **Settings**. +3. Open **Advanced** and enable **Show features for web developers** or **Show Develop menu in menu bar**. +4. Open the **Developer** tab. +5. Enable **Allow unsigned extensions**. +6. Click **Add Temporary Extension...**. +7. Select the unzipped Safari build folder, or `integrations/clipper/dist/safari` when building locally. +8. Enable the extension in Safari's **Extensions** settings if Safari does not enable it automatically. + +Safari removes temporary extensions after 24 hours or when you quit Safari. Safari also resets **Allow unsigned extensions** when you quit the browser, so enable it again before the next local test session. + +## Capture Content + +Use the Clipper popup when you want to review or edit the item name before saving. + +- **Code** saves selected text as a snippet. Select text on the page first. +- **Notes** saves selected text when available, otherwise it saves the readable page content. +- **HTTP** saves the current page URL as a `GET` request. + +You can also right-click the page, selected text, or a link and choose **Save to massCode**. Context menu saves use the stored API port and token, then save to the target you choose in the menu. + +## Troubleshooting + +If the popup says it cannot read the active page, try a regular website tab. Browsers block extensions from reading internal pages such as browser settings, extension pages, and some store pages. + +If saving fails, check that massCode is running, **API integrations** are enabled, the extension API port matches Preferences, and the token in the popup is the latest generated token. + + diff --git a/docs/website/documentation/index.md b/docs/website/documentation/index.md index ac6b7f755..2d27880d4 100644 --- a/docs/website/documentation/index.md +++ b/docs/website/documentation/index.md @@ -31,6 +31,10 @@ Math is a calculator-style notebook for quick development math without leaving m Tools covers the small one-off tasks that usually send you to a browser tab: JSON comparison, encoders, decoders, generators, hash utilities, and text converters. Categories are listed on the left, and the active tool opens on the right. +## Clipper + +Use [Clipper](/documentation/clipper) to save selected text, readable page content, and links from your browser into Code, Notes, or HTTP. Until browser store distribution is available, install it from a local build. + ## General Settings ### Sidebar Toggle diff --git a/docs/website/public/clipper.png b/docs/website/public/clipper.png new file mode 100644 index 0000000000000000000000000000000000000000..629e17e1de05b271bfce6fa4ac5f9eb9013de265 GIT binary patch literal 57044 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 z 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 Date: Tue, 12 May 2026 05:54:32 +0300 Subject: [PATCH 03/92] feat(ui): add keyboard delete shortcuts (#783) --- src/renderer/components/http/HttpSidebar.vue | 13 +- .../http/HttpSidebarFolderContextMenu.vue | 40 +----- src/renderer/components/http/RequestsList.vue | 21 ++- .../components/http/RequestsListItem.vue | 76 +--------- .../components/math-notebook/SheetList.vue | 55 ++++++- src/renderer/components/notes/NotesList.vue | 19 ++- .../notes/NotesListItemContextMenu.vue | 56 +------- .../notes/NotesSidebarFolderContextMenu.vue | 56 +------- .../components/notes/NotesSidebarFolders.vue | 13 +- .../components/sidebar/folders/Tree.vue | 70 +++------ src/renderer/components/snippet/Item.vue | 58 +------- src/renderer/components/snippet/List.vue | 12 +- src/renderer/composables/index.ts | 1 + .../composables/spaces/http/useHttpFolders.ts | 58 +++++++- .../spaces/http/useHttpRequests.ts | 134 +++++++++++++++++- .../spaces/notes/useNoteFolders.ts | 62 ++++++++ .../composables/spaces/notes/useNotes.ts | 110 ++++++++++++++ src/renderer/composables/useDeleteShortcut.ts | 73 ++++++++++ src/renderer/composables/useFolders.ts | 64 ++++++++- src/renderer/composables/useSnippets.ts | 119 ++++++++++++++++ 20 files changed, 770 insertions(+), 340 deletions(-) create mode 100644 src/renderer/composables/useDeleteShortcut.ts diff --git a/src/renderer/components/http/HttpSidebar.vue b/src/renderer/components/http/HttpSidebar.vue index 0489c81ee..6cf04c25d 100644 --- a/src/renderer/components/http/HttpSidebar.vue +++ b/src/renderer/components/http/HttpSidebar.vue @@ -3,6 +3,7 @@ import type { TreeNode as TreeNodeType } from '@/components/ui/tree/types' import * as ContextMenu from '@/components/ui/shadcn/context-menu' import { Tree as UiTree } from '@/components/ui/tree' import { + useDeleteShortcut, useHttpApp, useHttpEnvironments, useHttpFolderDragDrop, @@ -31,6 +32,7 @@ const { } = useHttpApp() const { createHttpFolderAndSelect, + deleteSelectedHttpFolders, folders, getHttpFolders, updateHttpFolder, @@ -229,6 +231,12 @@ async function onImported() { await getHttpRequests() await getHttpEnvironments() } + +useDeleteShortcut({ + rootSelector: '[data-http-folders-tree]', + isEnabled: () => focusedFolderId.value !== undefined, + onDelete: () => deleteSelectedHttpFolders(focusedFolderId.value), +}) -
+
1 - ? i18n.t('messages:confirm.delete', { - name: i18n.t('common.folders'), - }) - : i18n.t('messages:confirm.delete', { name: folderName }), - }) - - if (!isConfirmed) - return - - await Promise.all(targetIds.map(id => deleteHttpFolder(id, false))) - await getHttpFolders(false) - - const fallbackId = selectedFolderIds.value[0] - if (fallbackId) { - await selectHttpFolder(fallbackId) - scrollToElement(`[id="${fallbackId}"]`) - } - else { - clearFolderSelection() - } + await deleteSelectedHttpFolders(props.contextNode.id) } function onRenameFolder() { diff --git a/src/renderer/components/http/RequestsList.vue b/src/renderer/components/http/RequestsList.vue index bd83496be..36d951cd7 100644 --- a/src/renderer/components/http/RequestsList.vue +++ b/src/renderer/components/http/RequestsList.vue @@ -1,8 +1,14 @@