diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..99f14ff --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PNPM + uses: pnpm/action-setup@v3 + with: + version: 10 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - name: Install deps + run: pnpm install --frozen-lockfile + + - name: Typecheck + run: pnpm typecheck + + - name: Test + run: pnpm test + + - name: Build + run: pnpm build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1e796e2..bad7e28 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,7 +17,7 @@ jobs: - name: Setup PNPM uses: pnpm/action-setup@v3 with: - version: 9 + version: 10 - name: Setup Node uses: actions/setup-node@v4 @@ -28,6 +28,12 @@ jobs: - name: Install deps run: pnpm install --frozen-lockfile + - name: Typecheck + run: pnpm typecheck + + - name: Test + run: pnpm test + - name: Build run: pnpm run build diff --git a/.gitignore b/.gitignore index 5aa3625..a65c10b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,4 @@ node_modules/ dist/ .DS_Store -ui/ diff --git a/README.md b/README.md index 3fa7ee8..82bb516 100644 --- a/README.md +++ b/README.md @@ -13,19 +13,23 @@ - **Inspire new ideas.** Beyond summaries, the assistant can remix your saved content: propose blog outlines, connect disparate articles, prompt you with “What if…” questions—anything that nudges you to create rather than collect. ### Feature List -- Capture the current tab and extract readable content with the built-in readability fallback. +- Capture the current tab and extract readable content with bundled Mozilla Readability, falling back to `article`/`main` text. - Auto-tag new items by calling an OpenAI-compatible `chat/completions` endpoint that you configure at runtime—no hard-coded hosts, no forced vendors. - Optionally save new captures to Pinboard and/or Readwise Reader. Import Pinboard tags, and export selected items to GoodLinks or Readwise. +- Readwise Reader creates or updates the existing document, including tags. +- After save, a page toast can retry a failed destination and edit tags. - Store everything locally; no backend service required. - Navigate a full options dashboard for configuring LLM models, privacy modes, tag behaviour, and integrations. - Inspect the Options-page Data Preview before saving: it shows local storage, Chrome sync storage, configured outbound API destinations, privacy-mode payload fields, and screenshot-safe secret states. ### Getting Started -- Install Node 18+. -- `npm install` to pull dependencies. -- `npm run build` for a one-off bundle (or `npm run watch` while hacking). +- Install Node 18+ and [pnpm](https://pnpm.io/). +- `pnpm install` to pull dependencies. +- `pnpm test` and `pnpm typecheck` for the current automated checks. +- `pnpm build` for a one-off bundle (or `pnpm watch` while hacking). - Load the unpacked extension from the freshly created `dist/` directory in Chrome. - Hop into the Options page to plug in your LLM base URL, model name, and optional API key. Paste Pinboard and Readwise tokens only if you want those destinations, then use the capture checkboxes to enable or skip them on save. +- Page extraction uses the bundled Mozilla Readability library, then `article`/`main` text, then `document.body.innerText`. ### Packaging & Release Ritual - Bump version in `package.json` (the build script mirrors it into `dist/manifest.json` automatically). @@ -33,6 +37,8 @@ - Upload to the Chrome Web Store with updated screenshots and data-collection notes. Use the Options-page Data Preview as the product-visible source for privacy fields: it distinguishes local data, synced settings, user-configured remote API calls, and the absence of remotely hosted executable code. - Tag the release (`git tag v && git push origin --tags`), draft GitHub notes, and attach the zip for sideloaders. +Page extraction bundles [Mozilla Readability](https://github.com/mozilla/readability) (Apache-2.0). + ### Contributing & Feedback - I’m dogfooding the tool to tame my own reading queue, but the north star is a community-sourced thinking partner. Open issues for features you crave, integrations you rely on, or moments where the flow breaks. - Want to explore the “report” or “idea lab” concepts early? Reach out—I’d love to collaborate on prototypes. diff --git a/docs/release/chrome-web-store-publishing-checklist.md b/docs/release/chrome-web-store-publishing-checklist.md index 2824835..94c5297 100644 --- a/docs/release/chrome-web-store-publishing-checklist.md +++ b/docs/release/chrome-web-store-publishing-checklist.md @@ -85,8 +85,8 @@ This checklist records the current Chrome Web Store publishing flow for `tldr`. - [ ] `activeTab`: accesses the page only when the user previews or saves the active tab. - [ ] `scripting`: injects the extraction/readability script into the active tab after user action. - [ ] `contextMenus`: opens settings from the extension action menu. -- [ ] `notifications`: verify whether still needed; remove if unused. -- [ ] `alarms`: verify whether still needed; remove if unused. +- [x] `notifications`: removed; unused. +- [x] `alarms`: removed; unused. - [ ] Host permissions: - Pinboard API for tag import and bookmark sync. - Readwise API for Reader export. diff --git a/package.json b/package.json index f6f39a2..3a40827 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "build": "node scripts/build.mjs", "watch": "node scripts/build.mjs --watch", "test": "vitest run", + "typecheck": "tsc --noEmit", "clean": "rm -rf dist", "package": "node scripts/package.mjs" }, @@ -15,5 +16,8 @@ "esbuild": "^0.21.5", "typescript": "^5.5.4", "vitest": "^4.1.8" + }, + "dependencies": { + "@mozilla/readability": "^0.6.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c42b96f..7d70622 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,10 @@ settings: importers: .: + dependencies: + '@mozilla/readability': + specifier: ^0.6.0 + version: 0.6.0 devDependencies: '@types/chrome': specifier: ^0.1.43 @@ -173,6 +177,10 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@mozilla/readability@0.6.0': + resolution: {integrity: sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ==} + engines: {node: '>=14.0.0'} + '@napi-rs/wasm-runtime@1.1.5': resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==} peerDependencies: @@ -699,6 +707,8 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} + '@mozilla/readability@0.6.0': {} + '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5ed0b5a..45b1285 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,5 @@ +packages: + - '.' allowBuilds: esbuild: true + diff --git a/scripts/build.mjs b/scripts/build.mjs index 69fd61e..c9e0a29 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -4,38 +4,42 @@ import { existsSync } from 'node:fs'; const watch = process.argv.includes('--watch'); -async function main() { - if (existsSync('dist')) await rm('dist', { recursive: true, force: true }); - await mkdir('dist', { recursive: true }); +const shared = { + bundle: true, + sourcemap: true, + target: ['chrome114'], + platform: 'browser', + logLevel: 'info' +}; - const opts = { - entryPoints: [ - 'src/background/index.ts', - 'src/ui/options.ts', - 'src/content/extract.ts' - ], - outdir: 'dist', - outbase: 'src', - bundle: true, - sourcemap: true, - format: 'esm', - target: ['chrome114'], - platform: 'browser', - logLevel: 'info' - }; +const appBuild = { + ...shared, + entryPoints: [ + 'src/background/index.ts', + 'src/ui/options.ts', + 'src/content/extract.ts' + ], + outdir: 'dist', + outbase: 'src', + format: 'esm' +}; - if (watch) { - const ctx = await context(opts); - await ctx.watch(); - console.log('Watching for changes…'); - } else { - await build(opts); +const extraBuilds = [ + { + ...shared, + entryPoints: ['src/content/readability-global.ts'], + outfile: 'dist/readability.js', + format: 'iife' + }, + { + ...shared, + entryPoints: ['src/content/toast.ts'], + outfile: 'dist/content/toast.js', + format: 'iife' } +]; - // Copy static files - await cp('static', 'dist', { recursive: true }); - - // Sync manifest version with package.json +async function syncManifestVersion() { const pkg = JSON.parse(await readFile('package.json', 'utf-8')); const manifestPath = 'dist/manifest.json'; try { @@ -47,4 +51,27 @@ async function main() { } } +async function copyStatic() { + await cp('static', 'dist', { recursive: true }); + await syncManifestVersion(); +} + +async function main() { + if (existsSync('dist')) await rm('dist', { recursive: true, force: true }); + await mkdir('dist', { recursive: true }); + await copyStatic(); + + if (watch) { + for (const opts of [appBuild, ...extraBuilds]) { + const ctx = await context(opts); + await ctx.watch(); + } + console.log('Watching for changes…'); + return; + } + + await build(appBuild); + for (const opts of extraBuilds) await build(opts); +} + main().catch((e) => { console.error(e); process.exit(1); }); diff --git a/src/background/exporters.test.ts b/src/background/exporters.test.ts new file mode 100644 index 0000000..0241dfc --- /dev/null +++ b/src/background/exporters.test.ts @@ -0,0 +1,97 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Settings } from '@common/types'; + +vi.mock('@common/storage', () => ({ + getSettings: vi.fn(), + getSecret: vi.fn() +})); + +vi.mock('./readwise', () => ({ + syncReadwiseDocument: vi.fn() +})); + +import { getSecret, getSettings } from '@common/storage'; +import { syncReadwiseDocument } from './readwise'; +import { exportToGoodlinks, exportToReadwise } from './exporters'; + +const settings: Settings = { + llm: { baseUrl: 'https://llm.example.test/v1', model: 'test', jsonMode: true, maxChars: 1000 }, + pinboard: { shared: true, toread: false }, + readwise: { apiTokenRef: 'readwise_token' }, + tagging: { knownTagLimit: 200, dedupeThreshold: 82, aliases: {} }, + privacy: { mode: 'title_excerpt' } +}; + +describe('exportToGoodlinks', () => { + let create: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + create = vi.fn().mockResolvedValue(undefined); + (globalThis as unknown as { chrome: unknown }).chrome = { + tabs: { create } + }; + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('opens a GoodLinks URL for each item', async () => { + const pending = exportToGoodlinks([ + { url: 'https://example.test/a', title: 'A', tags: ['ai'] }, + { url: 'https://example.test/b', title: '', tags: [] } + ]); + await vi.runAllTimersAsync(); + await expect(pending).resolves.toBe(2); + + expect(create).toHaveBeenCalledTimes(2); + expect(String(create.mock.calls[0]?.[0]?.url)).toContain('goodlinks://add?url=https%3A%2F%2Fexample.test%2Fa'); + expect(String(create.mock.calls[0]?.[0]?.url)).toContain('tags=ai'); + }); + + it('skips items that fail to open and still counts successes', async () => { + create + .mockRejectedValueOnce(new Error('blocked')) + .mockResolvedValueOnce(undefined); + const pending = exportToGoodlinks([ + { url: 'https://example.test/a', title: 'A', tags: [] }, + { url: 'https://example.test/b', title: 'B', tags: [] } + ]); + await vi.runAllTimersAsync(); + await expect(pending).resolves.toBe(1); + }); +}); + +describe('exportToReadwise', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.mocked(getSettings).mockResolvedValue(settings); + vi.mocked(getSecret).mockResolvedValue('rw_token'); + vi.mocked(syncReadwiseDocument).mockResolvedValue({ id: 'doc-1' }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('throws when no token is configured', async () => { + vi.mocked(getSecret).mockResolvedValue(undefined); + await expect(exportToReadwise([{ url: 'https://example.test/a', title: 'A', tags: [] }])).rejects.toThrow( + 'Readwise token not set' + ); + }); + + it('exports items and continues after an individual failure', async () => { + vi.mocked(syncReadwiseDocument) + .mockRejectedValueOnce(new Error('nope')) + .mockResolvedValueOnce({ id: 'doc-2' }); + + const pending = exportToReadwise([ + { url: 'https://example.test/a', title: 'A', tags: ['ai'] }, + { url: 'https://example.test/b', title: 'B', tags: [] } + ]); + await vi.runAllTimersAsync(); + await expect(pending).resolves.toBe(1); + }); +}); diff --git a/src/background/exporters.ts b/src/background/exporters.ts index cbdcd7b..00f8fd9 100644 --- a/src/background/exporters.ts +++ b/src/background/exporters.ts @@ -1,5 +1,5 @@ import { getSecret, getSettings } from '@common/storage'; -import { postReadwiseReaderDocument } from './readwise'; +import { syncReadwiseDocument } from './readwise'; export type SimpleItem = { url: string; title: string; tags: string[] }; @@ -29,7 +29,7 @@ export async function exportToReadwise(items: SimpleItem[]): Promise { let count = 0; for (const it of items) { try { - await postReadwiseReaderDocument(token, { + await syncReadwiseDocument(token, { url: it.url, title: it.title || undefined, tags: it.tags || [], diff --git a/src/background/index.ts b/src/background/index.ts index 560b790..4d5669f 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -1,46 +1,13 @@ import { getSettings, getTags } from '@common/storage'; import { hasCaptureError, type CaptureResult } from '@common/capture'; -import { buildDataPreview, type DestinationState, type ExportTargets } from '@common/preview'; -import type { PrivacyMode, Settings } from '@common/types'; +import { buildDataPreview, type DestinationState } from '@common/preview'; +import { RETRY_DESTINATION, SHOW_CAPTURE, UPDATE_TAGS } from '@common/messages'; import { extractFromActiveTab } from './tabs'; -import { captureAndSync } from './pipeline'; +import { captureAndSync, retryDestination, updateCapturedTags } from './pipeline'; import { importTagsFromPinboard, listRecentFromPinboard } from './pinboard'; import { exportToGoodlinks, exportToReadwise } from './exporters'; - -type PreviewDraft = { - llmBaseUrl?: string; - llmModel?: string; - llmMaxChars?: number; - privacyMode?: PrivacyMode; - pinboardConfigured?: boolean; - pinboardCaptureEnabled?: boolean; - readwiseConfigured?: boolean; - readwiseCaptureEnabled?: boolean; - exportTargets?: ExportTargets; -}; - -function settingsWithPreviewDraft(settings: Settings, draft?: PreviewDraft): Settings { - if (!draft) return settings; - return { - ...settings, - llm: { - ...settings.llm, - baseUrl: draft.llmBaseUrl ?? settings.llm.baseUrl, - model: draft.llmModel ?? settings.llm.model, - maxChars: Number.isFinite(draft.llmMaxChars) ? Number(draft.llmMaxChars) : settings.llm.maxChars - }, - privacy: { - ...settings.privacy, - mode: draft.privacyMode ?? settings.privacy.mode - } - }; -} - -function knownTagNames(map: Awaited>, limit: number): string[] { - return Object.keys(map) - .sort((a, b) => (map[b]?.count || 0) - (map[a]?.count || 0)) - .slice(0, limit); -} +import { ensureOriginPermission } from './permissions'; +import { knownTagNames, settingsWithPreviewDraft, type PreviewDraft } from './preview-draft'; async function previewCurrentTab(draft?: PreviewDraft) { const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); @@ -73,105 +40,13 @@ async function previewCurrentTab(draft?: PreviewDraft) { ); } -function renderCaptureStatus(payload: { - title: string; - url: string; - tags: string[]; - destinations: { label: string; status: string; message: string; url?: string; error?: string }[]; -}) { - const existing = document.getElementById('tldr-capture-status'); - if (existing) existing.remove(); - - const root = document.createElement('div'); - root.id = 'tldr-capture-status'; - const hasError = payload.destinations.some((destination) => destination.status === 'error'); - const statusText = hasError ? 'Saved with issues' : 'Saved'; - const tagText = payload.tags.length ? payload.tags.slice(0, 5).join(', ') : 'No tags'; - - root.style.cssText = [ - 'position:fixed', - 'top:10px', - 'left:50%', - 'transform:translateX(-50%)', - 'z-index:2147483647', - 'max-width:min(760px,calc(100vw - 24px))', - 'padding:8px 10px', - 'border:1px solid rgba(15,23,42,.12)', - 'border-radius:10px', - 'background:rgba(255,255,255,.94)', - 'box-shadow:0 8px 24px rgba(15,23,42,.12)', - 'color:#0f172a', - 'font:12px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif', - 'display:flex', - 'align-items:center', - 'gap:10px', - 'backdrop-filter:blur(16px)' - ].join(';'); - - const summary = document.createElement('div'); - summary.style.cssText = 'display:flex;align-items:center;gap:8px;min-width:0;flex:1'; - summary.innerHTML = `${statusText}${tagText}`; - root.append(summary); - - const chips = document.createElement('div'); - chips.style.cssText = 'display:flex;align-items:center;gap:5px;flex-wrap:wrap'; - for (const destination of payload.destinations.filter((item) => item.status !== 'skipped' || item.message.startsWith('Already'))) { - const chip = document.createElement('span'); - const color = destination.status === 'success' ? '#0f766e' : destination.status === 'skipped' ? '#64748b' : '#b45309'; - chip.title = destination.error || destination.message; - chip.textContent = destination.label; - chip.style.cssText = `border:1px solid rgba(15,23,42,.1);border-radius:999px;padding:3px 7px;color:${color};background:rgba(248,250,252,.92);font-weight:600`; - chips.append(chip); - } - root.append(chips); - - const actions = document.createElement('div'); - actions.style.cssText = 'display:flex;align-items:center;gap:4px'; - - const reader = payload.destinations.find((destination) => destination.label === 'Readwise Reader' && destination.status === 'success'); - if (reader?.url) { - const openReader = document.createElement('button'); - openReader.type = 'button'; - openReader.textContent = 'Reader'; - openReader.style.cssText = 'border:0;background:transparent;color:#4f46e5;font:inherit;font-weight:700;cursor:pointer;padding:4px 6px'; - openReader.addEventListener('click', () => window.open(reader.url, '_blank', 'noopener,noreferrer')); - actions.append(openReader); - } - - const pinboard = payload.destinations.find((destination) => destination.label === 'Pinboard' && destination.status === 'success'); - if (pinboard?.url) { - const openPinboard = document.createElement('button'); - openPinboard.type = 'button'; - openPinboard.textContent = 'Pinboard'; - openPinboard.style.cssText = 'border:0;background:transparent;color:#4f46e5;font:inherit;font-weight:700;cursor:pointer;padding:4px 6px'; - openPinboard.addEventListener('click', () => window.open(pinboard.url, '_blank', 'noopener,noreferrer')); - actions.append(openPinboard); - } - - const close = document.createElement('button'); - close.type = 'button'; - close.textContent = 'Dismiss'; - close.style.cssText = 'border:0;background:transparent;color:#64748b;font:inherit;cursor:pointer;padding:4px 6px'; - close.addEventListener('click', () => root.remove()); - actions.append(close); - root.append(actions); - - document.documentElement.append(root); - if (!hasError) setTimeout(() => root.remove(), 8000); -} - async function showCaptureStatus(tabId: number, result: CaptureResult): Promise { try { await chrome.scripting.executeScript({ target: { tabId }, - func: renderCaptureStatus, - args: [{ - title: result.item.title, - url: result.item.url, - tags: result.tags, - destinations: result.destinations - }] + files: ['content/toast.js'] }); + await chrome.tabs.sendMessage(tabId, { type: SHOW_CAPTURE, capture: result }); } catch (err) { console.warn('Could not render capture status UI', err); } @@ -205,6 +80,8 @@ chrome.runtime.onInstalled.addListener(async () => { chrome.action.onClicked.addListener(async (tab) => { try { if (!tab?.id) throw new Error('No active tab'); + const settings = await getSettings(); + await ensureOriginPermission(settings.llm.baseUrl); await chrome.action.setBadgeBackgroundColor({ color: '#2b7' }); await chrome.action.setBadgeText({ tabId: tab.id, text: '…' }); const data = await extractFromActiveTab(tab.id); @@ -256,6 +133,13 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { if (targets.goodlinks) goodlinksCount = await exportToGoodlinks(items); if (targets.readwise) readwiseCount = await exportToReadwise(items); sendResponse({ ok: true, goodlinksCount, readwiseCount }); + } else if (msg?.type === RETRY_DESTINATION) { + const capture = await retryDestination(String(msg.itemId || ''), msg.destinationId); + sendResponse({ ok: true, capture }); + } else if (msg?.type === UPDATE_TAGS) { + const tags = Array.isArray(msg.tags) ? msg.tags.map(String) : []; + const capture = await updateCapturedTags(String(msg.itemId || ''), tags); + sendResponse({ ok: true, capture }); } })().catch((err) => sendResponse({ ok: false, error: String(err) })); return true; diff --git a/src/background/llm.test.ts b/src/background/llm.test.ts new file mode 100644 index 0000000..2d0a6f5 --- /dev/null +++ b/src/background/llm.test.ts @@ -0,0 +1,184 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Settings } from '@common/types'; + +vi.mock('@common/storage', () => ({ + getSettings: vi.fn(), + getSecret: vi.fn() +})); + +import { getSecret, getSettings } from '@common/storage'; +import { extractErrorDetail, generateTags } from './llm'; + +const settings: Settings = { + llm: { + baseUrl: 'https://llm.example.test/v1', + model: 'test-model', + apiKeyRef: 'llm_api_key', + jsonMode: true, + maxChars: 20 + }, + pinboard: { shared: true, toread: false }, + tagging: { knownTagLimit: 200, dedupeThreshold: 82, aliases: {} }, + privacy: { mode: 'title_excerpt' } +}; + +function jsonResponse(body: unknown, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + text: async () => JSON.stringify(body) + } as unknown as Response; +} + +describe('extractErrorDetail', () => { + it('reads nested API error fields and falls back to raw text', () => { + expect(extractErrorDetail('')).toBeUndefined(); + expect(extractErrorDetail('{"error":{"message":"bad key"}}')).toBe('bad key'); + expect(extractErrorDetail('{"detail":"nope"}')).toBe('nope'); + expect(extractErrorDetail('{"message":"oops"}')).toBe('oops'); + expect(extractErrorDetail(' not-json ')).toBe('not-json'); + expect(extractErrorDetail('{"ok":true}')).toBeUndefined(); + }); +}); + +describe('generateTags', () => { + beforeEach(() => { + vi.mocked(getSettings).mockResolvedValue(settings); + vi.mocked(getSecret).mockResolvedValue('sk-test'); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('parses JSON tags, lowercases them, and dedupes', async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ + choices: [{ + message: { + content: JSON.stringify({ tags: [{ name: 'AI' }, { name: 'ai' }, { name: 'reading' }, { name: '' }] }) + } + }] + })); + vi.stubGlobal('fetch', fetchMock); + + await expect(generateTags({ + title: 'Post', + url: 'https://example.test/post', + domain: 'example.test', + excerpt: 'x'.repeat(50), + knownTags: ['ai'] + })).resolves.toEqual(['ai', 'reading']); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(init.headers).toMatchObject({ Authorization: 'Bearer sk-test' }); + const body = JSON.parse(String(init.body)); + expect(body.response_format).toEqual({ type: 'json_object' }); + expect(body.messages[1].content).toContain('Excerpt: xxxxxxxxxxxxxxxxxxxx'); + }); + + it('extracts JSON embedded in model prose', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({ + choices: [{ message: { content: 'Sure.\n{"tags":[{"name":"climate"}]}\n' } }] + }))); + + await expect(generateTags({ + title: 'Post', + url: 'https://example.test/post', + domain: 'example.test', + knownTags: [] + })).resolves.toEqual(['climate']); + }); + + it('joins array content parts', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({ + choices: [{ message: { content: [{ text: '{"tags":[{"name":"notes"}]}' }] } }] + }))); + + await expect(generateTags({ + title: 'Post', + url: 'https://example.test/post', + domain: 'example.test', + knownTags: [] + })).resolves.toEqual(['notes']); + }); + + it('returns an empty list when the model omits tags', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({ + choices: [{ message: { content: '{"ok":true}' } }] + }))); + + await expect(generateTags({ + title: 'Post', + url: 'https://example.test/post', + domain: 'example.test', + knownTags: [] + })).resolves.toEqual([]); + }); + + it('omits the API key header and JSON mode when they are not configured', async () => { + vi.mocked(getSettings).mockResolvedValue({ + ...settings, + llm: { ...settings.llm, apiKeyRef: undefined, jsonMode: false } + }); + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ + choices: [{ message: { content: '{"tags":[{"name":"notes"}]}' } }] + })); + vi.stubGlobal('fetch', fetchMock); + + await expect(generateTags({ + title: 'Post', + url: 'https://example.test/post', + domain: 'example.test', + knownTags: [] + })).resolves.toEqual(['notes']); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(init.headers).not.toHaveProperty('Authorization'); + expect(JSON.parse(String(init.body)).response_format).toBeUndefined(); + }); + + it('throws a host-permission hint on network failure', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Failed to fetch'))); + await expect(generateTags({ + title: 'Post', + url: 'https://example.test/post', + domain: 'example.test', + knownTags: [] + })).rejects.toThrow('Verify the LLM host permission'); + }); + + it('includes API error details from a non-OK response', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: false, + status: 401, + text: async () => '{"error":{"message":"unauthorized"}}', + json: async () => ({}) + } as unknown as Response)); + + await expect(generateTags({ + title: 'Post', + url: 'https://example.test/post', + domain: 'example.test', + knownTags: [] + })).rejects.toThrow('LLM error 401: unauthorized'); + }); + + it('throws when the response is not JSON', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => { + throw new Error('nope'); + }, + text: async () => 'html' + } as unknown as Response)); + + await expect(generateTags({ + title: 'Post', + url: 'https://example.test/post', + domain: 'example.test', + knownTags: [] + })).rejects.toThrow('LLM returned a non-JSON response'); + }); +}); diff --git a/src/background/llm.ts b/src/background/llm.ts index 5fc87ea..5571663 100644 --- a/src/background/llm.ts +++ b/src/background/llm.ts @@ -3,7 +3,7 @@ import { chatCompletionsUrl } from '@common/preview'; export interface LlmTagResponse { tags: { name: string; confidence?: number }[] } -function extractErrorDetail(raw: string): string | undefined { +export function extractErrorDetail(raw: string): string | undefined { if (!raw) return undefined; try { const parsed = JSON.parse(raw) as { diff --git a/src/background/permissions.test.ts b/src/background/permissions.test.ts new file mode 100644 index 0000000..df32a43 --- /dev/null +++ b/src/background/permissions.test.ts @@ -0,0 +1,50 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ensureOriginPermission } from './permissions'; + +describe('ensureOriginPermission', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('returns without prompting for non-http URLs', async () => { + const contains = vi.fn(); + (globalThis as unknown as { chrome: unknown }).chrome = { + permissions: { contains, request: vi.fn() } + }; + await ensureOriginPermission('chrome://extensions'); + expect(contains).not.toHaveBeenCalled(); + }); + + it('returns when the origin is already granted', async () => { + const contains = vi.fn().mockResolvedValue(true); + const request = vi.fn(); + (globalThis as unknown as { chrome: unknown }).chrome = { + permissions: { contains, request } + }; + await ensureOriginPermission('https://api.moonshot.cn/v1'); + expect(contains).toHaveBeenCalledWith({ origins: ['https://api.moonshot.cn/*'] }); + expect(request).not.toHaveBeenCalled(); + }); + + it('requests permission when missing', async () => { + const contains = vi.fn().mockResolvedValue(false); + const request = vi.fn().mockResolvedValue(true); + (globalThis as unknown as { chrome: unknown }).chrome = { + permissions: { contains, request } + }; + await ensureOriginPermission('http://localhost:11434/v1'); + expect(request).toHaveBeenCalledWith({ origins: ['http://localhost:11434/*'] }); + }); + + it('throws when the user denies the origin', async () => { + (globalThis as unknown as { chrome: unknown }).chrome = { + permissions: { + contains: vi.fn().mockResolvedValue(false), + request: vi.fn().mockResolvedValue(false) + } + }; + await expect(ensureOriginPermission('https://llm.example.test/v1')).rejects.toThrow( + 'Permission was not granted for https://llm.example.test' + ); + }); +}); diff --git a/src/background/permissions.ts b/src/background/permissions.ts new file mode 100644 index 0000000..f4dc9df --- /dev/null +++ b/src/background/permissions.ts @@ -0,0 +1,12 @@ +import { originPattern } from '@common/origins'; + +export async function ensureOriginPermission(url: string): Promise { + const pattern = originPattern(url); + if (!pattern) return; + const alreadyGranted = await chrome.permissions.contains({ origins: [pattern] }); + if (alreadyGranted) return; + const granted = await chrome.permissions.request({ origins: [pattern] }); + if (!granted) { + throw new Error(`Permission was not granted for ${new URL(url).origin}`); + } +} diff --git a/src/background/pinboard-tags.test.ts b/src/background/pinboard-tags.test.ts new file mode 100644 index 0000000..7a79470 --- /dev/null +++ b/src/background/pinboard-tags.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { mergePinboardTagCounts } from './pinboard'; + +describe('mergePinboardTagCounts', () => { + it('uses Pinboard counts as the source of truth instead of accumulating', () => { + const merged = mergePinboardTagCounts( + { ai: { slug: 'ai', count: 4 }, local: { slug: 'local', count: 1 } }, + { AI: 2, reading: 5 } + ); + + expect(merged).toEqual({ + ai: { slug: 'ai', count: 2 }, + local: { slug: 'local', count: 1 }, + reading: { slug: 'reading', count: 5 } + }); + }); + + it('treats non-numeric Pinboard counts as zero', () => { + expect(mergePinboardTagCounts({}, { broken: Number.NaN })).toEqual({ + broken: { slug: 'broken', count: 0 } + }); + }); +}); diff --git a/src/background/pinboard.test.ts b/src/background/pinboard.test.ts new file mode 100644 index 0000000..67ccb10 --- /dev/null +++ b/src/background/pinboard.test.ts @@ -0,0 +1,207 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Item, Settings } from '@common/types'; + +vi.mock('@common/storage', () => ({ + getSettings: vi.fn(), + getSecret: vi.fn(), + getTags: vi.fn(), + updateTags: vi.fn() +})); + +import { getSecret, getSettings, getTags, updateTags } from '@common/storage'; +import { addToPinboard, importTagsFromPinboard, listRecentFromPinboard } from './pinboard'; + +const settings: Settings = { + llm: { baseUrl: 'https://llm.example.test/v1', model: 'test', jsonMode: true, maxChars: 1000 }, + pinboard: { authTokenRef: 'pin_token', shared: true, toread: false }, + tagging: { knownTagLimit: 200, dedupeThreshold: 82, aliases: {} }, + privacy: { mode: 'title_excerpt' } +}; + +const item: Item = { + id: '1', + url: 'https://example.test/post', + domain: 'example.test', + title: 'Example post', + excerpt: 'x'.repeat(300), + createdAt: 1, + tags: ['ai', 'reading'], + status: 'tagged' +}; + +describe('addToPinboard', () => { + beforeEach(() => { + vi.mocked(getSettings).mockResolvedValue(settings); + vi.mocked(getSecret).mockResolvedValue('user:token'); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('throws when no token is configured', async () => { + vi.mocked(getSettings).mockResolvedValue({ + ...settings, + pinboard: { shared: true, toread: false } + }); + await expect(addToPinboard(item)).rejects.toThrow('Pinboard token not set'); + }); + + it('posts the bookmark and truncates the excerpt', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ result_code: 'done' }) + }); + vi.stubGlobal('fetch', fetchMock); + + await addToPinboard(item); + + const url = String(fetchMock.mock.calls[0]?.[0]); + expect(url).toContain('https://api.pinboard.in/v1/posts/add?'); + expect(url).toContain('auth_token=user%3Atoken'); + expect(new URL(url).searchParams.get('extended')).toHaveLength(250); + expect(new URL(url).searchParams.get('tags')).toBe('ai reading'); + expect(new URL(url).searchParams.get('shared')).toBe('yes'); + expect(new URL(url).searchParams.get('toread')).toBe('no'); + }); + + it('sends private and toread flags from settings', async () => { + vi.mocked(getSettings).mockResolvedValue({ + ...settings, + pinboard: { ...settings.pinboard, shared: false, toread: true } + }); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ result_code: 'done' }) + }); + vi.stubGlobal('fetch', fetchMock); + + await addToPinboard({ ...item, title: '', excerpt: undefined, tags: [] }); + const params = new URL(String(fetchMock.mock.calls[0]?.[0])).searchParams; + expect(params.get('description')).toBe(item.url); + expect(params.get('shared')).toBe('no'); + expect(params.get('toread')).toBe('yes'); + }); + + it('retries after 429 and then reports rate limiting', async () => { + vi.useFakeTimers(); + const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 429, json: async () => ({}) }); + vi.stubGlobal('fetch', fetchMock); + + const pending = addToPinboard(item); + const expectation = expect(pending).rejects.toThrow('Pinboard rate limited'); + await vi.runAllTimersAsync(); + await expectation; + expect(fetchMock).toHaveBeenCalledTimes(3); + vi.useRealTimers(); + }); + + it('throws on HTTP errors and non-done result codes', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: false, + status: 500, + json: async () => ({}) + })); + await expect(addToPinboard(item)).rejects.toThrow('Pinboard error 500'); + + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ result_code: 'something went wrong' }) + })); + await expect(addToPinboard(item)).rejects.toThrow('Pinboard: something went wrong'); + }); +}); + +describe('importTagsFromPinboard', () => { + beforeEach(() => { + vi.mocked(getSettings).mockResolvedValue(settings); + vi.mocked(getSecret).mockResolvedValue('user:token'); + vi.mocked(getTags).mockResolvedValue({ local: { slug: 'local', count: 1 } }); + vi.mocked(updateTags).mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('throws when no token is configured', async () => { + vi.mocked(getSecret).mockResolvedValue(undefined); + await expect(importTagsFromPinboard()).rejects.toThrow('Pinboard token not set'); + }); + + it('merges remote counts and returns the imported tag count', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ AI: 4, reading: 2 }) + })); + + await expect(importTagsFromPinboard()).resolves.toBe(2); + expect(updateTags).toHaveBeenCalledWith({ + local: { slug: 'local', count: 1 }, + ai: { slug: 'ai', count: 4 }, + reading: { slug: 'reading', count: 2 } + }); + }); + + it('throws on HTTP errors', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 403, json: async () => ({}) })); + await expect(importTagsFromPinboard()).rejects.toThrow('Pinboard error 403'); + }); +}); + +describe('listRecentFromPinboard', () => { + beforeEach(() => { + vi.mocked(getSettings).mockResolvedValue(settings); + vi.mocked(getSecret).mockResolvedValue('user:token'); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('throws when no token is configured', async () => { + vi.mocked(getSecret).mockResolvedValue(undefined); + await expect(listRecentFromPinboard()).rejects.toThrow('Pinboard token not set'); + }); + + it('maps posts, splits tags, drops empty URLs, and clamps the count', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + posts: [ + { href: 'https://example.test/a', description: 'A', tags: 'AI Reading' }, + { href: '', description: 'Missing' }, + { href: 'https://example.test/b', tags: '' } + ] + }) + }); + vi.stubGlobal('fetch', fetchMock); + + const posts = await listRecentFromPinboard(500); + expect(String(fetchMock.mock.calls[0]?.[0])).toContain('count=100'); + expect(posts).toEqual([ + { url: 'https://example.test/a', title: 'A', tags: ['ai', 'reading'] }, + { url: 'https://example.test/b', title: 'https://example.test/b', tags: [] } + ]); + }); + + it('returns an empty list when the response has no posts array', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({}) + })); + await expect(listRecentFromPinboard(0)).resolves.toEqual([]); + }); + + it('throws on HTTP errors', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 401, json: async () => ({}) })); + await expect(listRecentFromPinboard(10)).rejects.toThrow('Pinboard error 401'); + }); +}); diff --git a/src/background/pinboard.ts b/src/background/pinboard.ts index 990cb2c..61a8229 100644 --- a/src/background/pinboard.ts +++ b/src/background/pinboard.ts @@ -4,6 +4,19 @@ import type { Item, TagInfo } from '@common/types'; function sleep(ms: number) { return new Promise((r) => setTimeout(r, ms)); } +export function mergePinboardTagCounts( + existing: Record, + incoming: Record +): Record { + const next = { ...existing }; + for (const [tag, count] of Object.entries(incoming)) { + const slug = tag.toLowerCase(); + const prev: TagInfo = next[slug] || { slug, count: 0 }; + next[slug] = { ...prev, count: Number(count) || 0 }; + } + return next; +} + export async function addToPinboard(item: Item): Promise { const settings = await getSettings(); const token = settings.pinboard.authTokenRef ? await getSecret(settings.pinboard.authTokenRef) : undefined; @@ -41,16 +54,13 @@ export async function importTagsFromPinboard(): Promise { if (!res.ok) throw new Error(`Pinboard error ${res.status}`); const data = await res.json(); // data is an object: { tag: count, ... } + const incoming = Object.fromEntries( + Object.entries(data).map(([tag, count]) => [tag, Number(count) || 0]) + ); const existing = await getTags(); - let count = 0; - for (const [tag, c] of Object.entries(data)) { - const slug = tag.toLowerCase(); - const prev: TagInfo = existing[slug] || { slug, count: 0 }; - existing[slug] = { ...prev, count: (prev.count || 0) + (c || 0) }; - count += 1; - } - await updateTags(existing); - return count; + const merged = mergePinboardTagCounts(existing, incoming); + await updateTags(merged); + return Object.keys(incoming).length; } export interface PinboardPost { url: string; title: string; tags: string[] } diff --git a/src/background/pipeline.test.ts b/src/background/pipeline.test.ts new file mode 100644 index 0000000..c9408fc --- /dev/null +++ b/src/background/pipeline.test.ts @@ -0,0 +1,368 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Item, Settings } from '@common/types'; + +vi.mock('@common/storage', () => ({ + getSettings: vi.fn(), + getTags: vi.fn(), + getItem: vi.fn(), + findItemByUrl: vi.fn(), + getSyncRecord: vi.fn(), + setSyncRecord: vi.fn(), + upsertItem: vi.fn(), + updateTags: vi.fn() +})); + +vi.mock('./llm', () => ({ generateTags: vi.fn() })); +vi.mock('./pinboard', () => ({ addToPinboard: vi.fn() })); +vi.mock('./readwise', () => ({ + readwiseInputFromItem: vi.fn((item: Item) => ({ + url: item.url, + title: item.title, + summary: item.excerpt, + tags: item.tags, + documentId: item.readwiseDocumentId + })), + saveToReadwiseReader: vi.fn() +})); + +import { captureSyncFingerprint } from '@common/capture'; +import { selectExcerpt } from '@common/preview'; +import { findItemByUrl, getItem, getSettings, getSyncRecord, getTags, setSyncRecord, updateTags, upsertItem } from '@common/storage'; +import { generateTags } from './llm'; +import { addToPinboard } from './pinboard'; +import { saveToReadwiseReader } from './readwise'; +import { captureAndSync, retryDestination, tagAndMaybeSync, updateCapturedTags } from './pipeline'; + +const settings: Settings = { + llm: { baseUrl: 'https://llm.example.test/v1', model: 'test', jsonMode: true, maxChars: 1000 }, + pinboard: { shared: true, toread: false }, + readwise: { saveOnCapture: false }, + tagging: { knownTagLimit: 200, dedupeThreshold: 82, aliases: {} }, + privacy: { mode: 'title_excerpt' } +}; + +const input = { + url: 'https://example.test/post', + title: 'Example post', + domain: 'example.test', + text: 'A useful excerpt about tagging.' +}; + +function savedItem(overrides: Partial = {}): Item { + return { + id: 'item-1', + url: input.url, + domain: input.domain, + title: input.title, + excerpt: 'A useful excerpt about tagging.', + createdAt: 1, + tags: ['ai'], + status: 'tagged', + ...overrides + }; +} + +describe('captureAndSync', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getSettings).mockResolvedValue(settings); + vi.mocked(getTags).mockResolvedValue({ ai: { slug: 'ai', count: 3 } }); + vi.mocked(getItem).mockResolvedValue(undefined); + vi.mocked(findItemByUrl).mockResolvedValue(undefined); + vi.mocked(getSyncRecord).mockResolvedValue(undefined); + vi.mocked(setSyncRecord).mockResolvedValue(undefined); + vi.mocked(upsertItem).mockResolvedValue(undefined); + vi.mocked(updateTags).mockResolvedValue(undefined); + vi.mocked(generateTags).mockResolvedValue(['ai', 'reading']); + vi.mocked(addToPinboard).mockResolvedValue(undefined); + vi.mocked(saveToReadwiseReader).mockResolvedValue({}); + }); + + it('saves locally and reports skipped destinations when integrations are off', async () => { + const result = await captureAndSync(input); + + expect(result.tags).toEqual(['ai', 'reading']); + expect(result.destinations.map((destination) => [destination.id, destination.status])).toEqual([ + ['local', 'success'], + ['llm', 'success'], + ['pinboard', 'skipped'], + ['readwise', 'skipped'] + ]); + expect(upsertItem).toHaveBeenCalled(); + expect(addToPinboard).not.toHaveBeenCalled(); + }); + + it('still saves locally when tagging fails', async () => { + vi.mocked(generateTags).mockRejectedValue(new Error('LLM error 401: unauthorized')); + + const result = await captureAndSync(input); + + expect(result.item.status).toBe('tagged'); + expect(result.tags).toEqual([]); + expect(result.destinations.find((destination) => destination.id === 'llm')).toMatchObject({ + status: 'error', + error: 'LLM error 401: unauthorized' + }); + expect(result.destinations.find((destination) => destination.id === 'local')?.status).toBe('success'); + expect(upsertItem).toHaveBeenCalled(); + expect(updateTags).not.toHaveBeenCalled(); + }); + + it('syncs to Pinboard when a token is configured', async () => { + vi.mocked(getSettings).mockResolvedValue({ + ...settings, + pinboard: { ...settings.pinboard, authTokenRef: 'pin_token' } + }); + + const result = await captureAndSync(input); + + expect(addToPinboard).toHaveBeenCalled(); + expect(result.destinations.find((destination) => destination.id === 'pinboard')).toMatchObject({ + status: 'success' + }); + expect(setSyncRecord).toHaveBeenCalledWith(expect.objectContaining({ service: 'pinboard', status: 'ok' })); + }); + + it('stores the Readwise document id and Reader URL', async () => { + vi.mocked(getSettings).mockResolvedValue({ + ...settings, + readwise: { saveOnCapture: true, apiTokenRef: 'readwise_token' } + }); + vi.mocked(saveToReadwiseReader).mockResolvedValue({ + id: 'doc-1', + url: 'https://read.readwise.io/read/doc-1', + alreadyExists: true + }); + + const result = await captureAndSync(input); + + expect(result.item.readwiseDocumentId).toBe('doc-1'); + expect(result.destinations.find((destination) => destination.id === 'readwise')).toMatchObject({ + status: 'success', + message: 'Updated Readwise Reader', + url: 'https://read.readwise.io/read/doc-1' + }); + expect(upsertItem).toHaveBeenCalledWith(expect.objectContaining({ readwiseDocumentId: 'doc-1' })); + }); + + it('reuses existing tags when the page payload is unchanged', async () => { + vi.mocked(findItemByUrl).mockResolvedValue(savedItem({ + tags: ['ai', 'reading'], + excerpt: 'A useful excerpt about tagging.' + })); + + const result = await captureAndSync(input); + expect(generateTags).not.toHaveBeenCalled(); + expect(result.destinations.find((destination) => destination.id === 'llm')).toMatchObject({ + status: 'skipped', + message: 'Reused existing tags' + }); + expect(result.tags).toEqual(['ai', 'reading']); + }); + + it('skips Pinboard when the fingerprint is already synced', async () => { + const excerpt = selectExcerpt(input.text, settings.privacy.mode, settings.llm.maxChars); + const fingerprint = captureSyncFingerprint({ + url: input.url, + title: input.title, + excerpt, + tags: ['ai', 'reading'] + }); + vi.mocked(getSettings).mockResolvedValue({ + ...settings, + pinboard: { ...settings.pinboard, authTokenRef: 'pin_token' } + }); + vi.mocked(getSyncRecord).mockImplementation(async (_itemId, service) => ( + service === 'pinboard' + ? { itemId: 'item-1', service: 'pinboard', status: 'ok', lastHash: fingerprint, updatedAt: 1 } + : undefined + )); + + const result = await captureAndSync(input); + expect(result.destinations.find((destination) => destination.id === 'pinboard')).toMatchObject({ + status: 'skipped', + message: 'Already synced to Pinboard' + }); + expect(addToPinboard).not.toHaveBeenCalled(); + }); + + it('reports a Pinboard error without failing the local save', async () => { + vi.mocked(getSettings).mockResolvedValue({ + ...settings, + pinboard: { ...settings.pinboard, authTokenRef: 'pin_token' } + }); + vi.mocked(addToPinboard).mockRejectedValue(new Error('Pinboard error 500')); + + const result = await captureAndSync(input); + expect(result.destinations.find((destination) => destination.id === 'pinboard')).toMatchObject({ + status: 'error', + error: 'Pinboard error 500' + }); + expect(setSyncRecord).toHaveBeenCalledWith(expect.objectContaining({ service: 'pinboard', status: 'error' })); + expect(result.destinations.find((destination) => destination.id === 'local')?.status).toBe('success'); + }); + + it('marks an unchanged existing item as already saved locally', async () => { + vi.mocked(findItemByUrl).mockResolvedValue(savedItem({ + excerpt: 'A useful excerpt about tagging.', + status: 'synced' + })); + + const result = await captureAndSync(input); + expect(generateTags).not.toHaveBeenCalled(); + expect(result.destinations.find((destination) => destination.id === 'local')).toMatchObject({ + status: 'success', + message: 'Already saved locally' + }); + expect(result.item.status).toBe('synced'); + }); + + it('skips Pinboard when capture is disabled even if a token is set', async () => { + vi.mocked(getSettings).mockResolvedValue({ + ...settings, + pinboard: { ...settings.pinboard, authTokenRef: 'pin_token', saveOnCapture: false } + }); + + const result = await captureAndSync(input); + expect(addToPinboard).not.toHaveBeenCalled(); + expect(result.destinations.find((destination) => destination.id === 'pinboard')).toMatchObject({ + status: 'skipped', + message: 'Pinboard capture disabled' + }); + }); + + it('errors when Readwise capture is enabled without a token', async () => { + vi.mocked(getSettings).mockResolvedValue({ + ...settings, + readwise: { saveOnCapture: true } + }); + + const result = await captureAndSync(input); + expect(result.destinations.find((destination) => destination.id === 'readwise')).toMatchObject({ + status: 'error', + message: 'Readwise capture enabled but token is missing' + }); + }); +}); + +describe('retryDestination', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getSettings).mockResolvedValue({ + ...settings, + pinboard: { ...settings.pinboard, authTokenRef: 'pin_token' } + }); + vi.mocked(getTags).mockResolvedValue({ ai: { slug: 'ai', count: 3 } }); + vi.mocked(getItem).mockResolvedValue(savedItem()); + vi.mocked(getSyncRecord).mockResolvedValue(undefined); + vi.mocked(setSyncRecord).mockResolvedValue(undefined); + vi.mocked(upsertItem).mockResolvedValue(undefined); + vi.mocked(updateTags).mockResolvedValue(undefined); + vi.mocked(generateTags).mockResolvedValue(['ai', 'reading']); + vi.mocked(addToPinboard).mockResolvedValue(undefined); + vi.mocked(saveToReadwiseReader).mockResolvedValue({}); + }); + + it('retries a failed Pinboard destination', async () => { + const result = await retryDestination('item-1', 'pinboard'); + expect(addToPinboard).toHaveBeenCalled(); + expect(result.destinations.find((destination) => destination.id === 'pinboard')?.status).toBe('success'); + }); + + it('retries tagging and then resyncs destinations', async () => { + const result = await retryDestination('item-1', 'llm'); + expect(generateTags).toHaveBeenCalled(); + expect(result.tags).toEqual(['ai', 'reading']); + expect(addToPinboard).toHaveBeenCalled(); + }); + + it('returns a tagging error without losing the saved item', async () => { + vi.mocked(generateTags).mockRejectedValue(new Error('down')); + const result = await retryDestination('item-1', 'llm'); + expect(result.destinations.find((destination) => destination.id === 'llm')).toMatchObject({ + status: 'error', + error: 'down' + }); + }); + + it('retries Readwise', async () => { + vi.mocked(getSettings).mockResolvedValue({ + ...settings, + readwise: { saveOnCapture: true, apiTokenRef: 'readwise_token' } + }); + vi.mocked(saveToReadwiseReader).mockResolvedValue({ + id: 'doc-1', + url: 'https://read.readwise.io/read/doc-1' + }); + const result = await retryDestination('item-1', 'readwise'); + expect(saveToReadwiseReader).toHaveBeenCalled(); + expect(result.destinations.find((destination) => destination.id === 'readwise')?.status).toBe('success'); + }); + + it('throws when the item is missing or the destination cannot be retried', async () => { + vi.mocked(getItem).mockResolvedValue(undefined); + await expect(retryDestination('missing', 'pinboard')).rejects.toThrow('Saved item not found'); + vi.mocked(getItem).mockResolvedValue(savedItem()); + await expect(retryDestination('item-1', 'local')).rejects.toThrow('Cannot retry local'); + }); +}); + +describe('updateCapturedTags', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getSettings).mockResolvedValue({ + ...settings, + readwise: { saveOnCapture: true, apiTokenRef: 'readwise_token' } + }); + vi.mocked(getTags).mockResolvedValue({ ai: { slug: 'ai', count: 3 } }); + vi.mocked(getItem).mockResolvedValue(savedItem({ readwiseDocumentId: 'doc-1' })); + vi.mocked(getSyncRecord).mockResolvedValue(undefined); + vi.mocked(setSyncRecord).mockResolvedValue(undefined); + vi.mocked(upsertItem).mockResolvedValue(undefined); + vi.mocked(updateTags).mockResolvedValue(undefined); + vi.mocked(saveToReadwiseReader).mockResolvedValue({ + id: 'doc-1', + url: 'https://read.readwise.io/read/doc-1', + alreadyExists: true + }); + }); + + it('saves edited tags and updates Readwise', async () => { + const result = await updateCapturedTags('item-1', ['reading', 'climate-tech']); + expect(result.tags).toEqual(['reading', 'climate-tech']); + expect(saveToReadwiseReader).toHaveBeenCalledWith(expect.objectContaining({ + tags: ['reading', 'climate-tech'], + documentId: 'doc-1' + })); + expect(result.destinations.find((destination) => destination.id === 'llm')).toMatchObject({ + status: 'skipped', + message: 'Tags updated' + }); + }); + + it('throws when the item is missing', async () => { + vi.mocked(getItem).mockResolvedValue(undefined); + await expect(updateCapturedTags('missing', ['ai'])).rejects.toThrow('Saved item not found'); + }); +}); + +describe('tagAndMaybeSync', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getSettings).mockResolvedValue(settings); + vi.mocked(getTags).mockResolvedValue({}); + vi.mocked(getItem).mockResolvedValue(undefined); + vi.mocked(findItemByUrl).mockResolvedValue(undefined); + vi.mocked(getSyncRecord).mockResolvedValue(undefined); + vi.mocked(setSyncRecord).mockResolvedValue(undefined); + vi.mocked(upsertItem).mockResolvedValue(undefined); + vi.mocked(updateTags).mockResolvedValue(undefined); + vi.mocked(generateTags).mockResolvedValue(['ai']); + }); + + it('returns the captured item', async () => { + const item = await tagAndMaybeSync(input); + expect(item.url).toBe(input.url); + expect(item.tags).toEqual(['ai']); + }); +}); diff --git a/src/background/pipeline.ts b/src/background/pipeline.ts index a0d1fec..c00875f 100644 --- a/src/background/pipeline.ts +++ b/src/background/pipeline.ts @@ -1,15 +1,16 @@ -import { findItemByUrl, getSettings, getSyncRecord, getTags, setSyncRecord, upsertItem, updateTags } from '@common/storage'; +import { findItemByUrl, getItem, getSettings, getSyncRecord, getTags, setSyncRecord, upsertItem, updateTags } from '@common/storage'; import { captureSyncFingerprint } from '@common/capture'; import { selectExcerpt } from '@common/preview'; -import type { CaptureDestinationResult, CaptureResult } from '@common/capture'; -import type { Item, SyncRecord } from '@common/types'; +import { parseTagInput } from '@common/tags-input'; +import type { CaptureDestinationId, CaptureDestinationResult, CaptureResult } from '@common/capture'; +import type { Item, Settings, SyncRecord } from '@common/types'; import { generateTags } from './llm'; import { canonicalizeTags, slugify } from './tags'; import { addToPinboard } from './pinboard'; import { readwiseInputFromItem, saveToReadwiseReader } from './readwise'; function uuid(): string { - return crypto.getRandomValues(new Uint8Array(16)).reduce((p, c, i) => p + (i === 6 ? (c & 0x0f | 0x40) : i === 8 ? (c & 0x3f | 0x80) : c).toString(16).padStart(2, '0'), ''); + return crypto.randomUUID(); } function destination( @@ -57,34 +58,141 @@ async function alreadySynced(item: Item, service: SyncRecord['service'], fingerp return Boolean(record?.status === 'ok' && record.lastHash === fingerprint); } -export async function captureAndSync(input: { url: string; title: string; domain: string; text?: string }): Promise { - const settings = await getSettings(); +function localDestination(item: Item, existingItem?: Item, unchangedExistingItem = false): CaptureDestinationResult { + return destination( + 'local', + 'Local library', + 'success', + unchangedExistingItem ? 'Already saved locally' : existingItem ? 'Updated local item' : 'Saved locally' + ); +} + +async function incrementKnownTags(knownMap: Awaited>, tags: string[], previous: string[] = []): Promise { + let changed = false; + for (const t of tags) { + if (previous.includes(t)) continue; + knownMap[t] = knownMap[t] || { slug: t, count: 0 }; + knownMap[t].count += 1; + changed = true; + } + if (changed) await updateTags(knownMap); +} + +async function knownTagNames(settings: Settings): Promise<{ known: string[]; knownMap: Awaited> }> { const knownMap = await getTags(); - const known = Object.keys(knownMap).sort((a, b) => (knownMap[b]?.count || 0) - (knownMap[a]?.count || 0)).slice(0, settings.tagging.knownTagLimit); + const known = Object.keys(knownMap) + .sort((a, b) => (knownMap[b]?.count || 0) - (knownMap[a]?.count || 0)) + .slice(0, settings.tagging.knownTagLimit); + return { known, knownMap }; +} - const excerpt = selectExcerpt(input.text, settings.privacy.mode, settings.llm.maxChars); +function normalizeTags(candidates: string[], known: string[], settings: Settings): string[] { + return canonicalizeTags(candidates, known, settings).map(slugify).filter(Boolean); +} + +async function syncPinboard(item: Item, fingerprint: string, force: boolean): Promise { + const settings = await getSettings(); + if (settings.pinboard.saveOnCapture === false) { + return destination('pinboard', 'Pinboard', 'skipped', 'Pinboard capture disabled'); + } + if (!settings.pinboard.authTokenRef) { + return destination('pinboard', 'Pinboard', 'skipped', 'Pinboard token not configured'); + } + try { + if (!force && await alreadySynced(item, 'pinboard', fingerprint)) { + return destination('pinboard', 'Pinboard', 'skipped', 'Already synced to Pinboard', { url: 'https://pinboard.in/' }); + } + await addToPinboard(item); + item.status = 'synced'; + await upsertItem(item); + await markSync(item, 'pinboard', 'ok', fingerprint); + return destination('pinboard', 'Pinboard', 'success', 'Saved to Pinboard', { url: 'https://pinboard.in/' }); + } catch (err) { + await markSync(item, 'pinboard', 'error', fingerprint, errorMessage(err)); + return destination('pinboard', 'Pinboard', 'error', 'Pinboard save failed', { error: errorMessage(err) }); + } +} + +async function syncReadwise(item: Item, fingerprint: string, force: boolean): Promise { + const settings = await getSettings(); + if (!settings.readwise?.saveOnCapture) { + return destination('readwise', 'Readwise Reader', 'skipped', 'Readwise capture disabled'); + } + if (!settings.readwise.apiTokenRef) { + return destination('readwise', 'Readwise Reader', 'error', 'Readwise capture enabled but token is missing'); + } + + try { + if (!force && await alreadySynced(item, 'readwise', fingerprint)) { + return destination('readwise', 'Readwise Reader', 'skipped', 'Already synced to Readwise Reader', { + url: item.readwiseDocumentId ? `https://read.readwise.io/read/${item.readwiseDocumentId}` : 'https://readwise.io/read' + }); + } + const saved = await saveToReadwiseReader(readwiseInputFromItem(item)); + if (saved.id && saved.id !== item.readwiseDocumentId) { + item.readwiseDocumentId = saved.id; + await upsertItem(item); + } + await markSync(item, 'readwise', 'ok', fingerprint); + return destination( + 'readwise', + 'Readwise Reader', + saved.alreadyExists ? 'success' : 'success', + saved.alreadyExists ? 'Updated Readwise Reader' : 'Saved to Readwise Reader', + { url: saved.url || (saved.id ? `https://read.readwise.io/read/${saved.id}` : 'https://readwise.io/read') } + ); + } catch (err) { + await markSync(item, 'readwise', 'error', fingerprint, errorMessage(err)); + return destination('readwise', 'Readwise Reader', 'error', 'Readwise save failed', { error: errorMessage(err) }); + } +} + +async function syncConfiguredDestinations(item: Item, force: boolean): Promise { + const fingerprint = captureSyncFingerprint(item); + return [ + await syncPinboard(item, fingerprint, force), + await syncReadwise(item, fingerprint, force) + ]; +} + +function captureResult(item: Item, llm: CaptureDestinationResult, local: CaptureDestinationResult, synced: CaptureDestinationResult[]): CaptureResult { + return { item, tags: item.tags, destinations: [local, llm, ...synced] }; +} + +export async function captureAndSync(input: { url: string; title: string; domain: string; text?: string }): Promise { + const settings = await getSettings(); + const { known, knownMap } = await knownTagNames(settings); + const excerpt = selectExcerpt(input.text, settings.privacy.mode, settings.llm.maxChars); const existingItem = await findItemByUrl(input.url); const canReuseExistingTags = Boolean(existingItem?.tags.length && existingItem.title === input.title && existingItem.domain === input.domain && (existingItem.excerpt || '') === (excerpt || '')); - const tags = canReuseExistingTags - ? existingItem!.tags - : canonicalizeTags( - await generateTags({ title: input.title, url: input.url, domain: input.domain, excerpt, knownTags: known }), - known, - settings - ).map(slugify); - - // update known tags counts - if (!canReuseExistingTags) { - for (const t of tags) { - if (existingItem?.tags.includes(t)) continue; - knownMap[t] = knownMap[t] || { slug: t, count: 0 } as any; - knownMap[t].count += 1; + + let tags: string[]; + let llmDest: CaptureDestinationResult; + if (canReuseExistingTags) { + tags = existingItem!.tags; + llmDest = destination('llm', 'LLM tags', 'skipped', 'Reused existing tags'); + } else { + try { + tags = normalizeTags( + await generateTags({ title: input.title, url: input.url, domain: input.domain, excerpt, knownTags: known }), + known, + settings + ); + llmDest = destination( + 'llm', + 'LLM tags', + 'success', + tags.length ? `Tagged: ${tags.slice(0, 5).join(', ')}` : 'No tags generated' + ); + await incrementKnownTags(knownMap, tags, existingItem?.tags); + } catch (err) { + tags = existingItem?.tags || []; + llmDest = destination('llm', 'LLM tags', 'error', 'Tagging failed', { error: errorMessage(err) }); } - await updateTags(knownMap); } const unchangedExistingItem = existingItem @@ -105,57 +213,103 @@ export async function captureAndSync(input: { url: string; title: string; domain delete item.lastError; await upsertItem(item); - const destinations: CaptureDestinationResult[] = [ - destination('local', 'Local library', 'success', unchangedExistingItem ? 'Already saved locally' : existingItem ? 'Updated local item' : 'Saved locally') - ]; + return captureResult( + item, + llmDest, + localDestination(item, existingItem, unchangedExistingItem), + await syncConfiguredDestinations(item, false) + ); +} + +export async function retryDestination(itemId: string, destinationId: CaptureDestinationId): Promise { + const item = await getItem(itemId); + if (!item) throw new Error('Saved item not found'); + const settings = await getSettings(); const fingerprint = captureSyncFingerprint(item); - if (settings.pinboard.saveOnCapture === false) { - destinations.push(destination('pinboard', 'Pinboard', 'skipped', 'Pinboard capture disabled')); - } else if (!settings.pinboard.authTokenRef) { - destinations.push(destination('pinboard', 'Pinboard', 'skipped', 'Pinboard token not configured')); - } else { + if (destinationId === 'llm') { + const { known, knownMap } = await knownTagNames(settings); try { - if (await alreadySynced(item, 'pinboard', fingerprint)) { - destinations.push(destination('pinboard', 'Pinboard', 'skipped', 'Already synced to Pinboard', { url: 'https://pinboard.in/' })); - } else if (unchangedExistingItem && existingItem?.status === 'synced') { - await markSync(item, 'pinboard', 'ok', fingerprint); - destinations.push(destination('pinboard', 'Pinboard', 'skipped', 'Already synced to Pinboard', { url: 'https://pinboard.in/' })); - } else { - await addToPinboard(item); - item.status = 'synced'; - await upsertItem(item); - await markSync(item, 'pinboard', 'ok', fingerprint); - destinations.push(destination('pinboard', 'Pinboard', 'success', 'Saved to Pinboard', { url: 'https://pinboard.in/' })); - } + const tags = normalizeTags( + await generateTags({ + title: item.title, + url: item.url, + domain: item.domain, + excerpt: item.excerpt, + knownTags: known + }), + known, + settings + ); + const previous = item.tags; + item.tags = tags; + item.contentHash = captureSyncFingerprint(item); + item.status = 'tagged'; + delete item.lastError; + await incrementKnownTags(knownMap, tags, previous); + await upsertItem(item); + const synced = await syncConfiguredDestinations(item, true); + return captureResult( + item, + destination('llm', 'LLM tags', 'success', tags.length ? `Tagged: ${tags.slice(0, 5).join(', ')}` : 'No tags generated'), + localDestination(item, item, false), + synced + ); } catch (err) { - await markSync(item, 'pinboard', 'error', fingerprint, errorMessage(err)); - destinations.push(destination('pinboard', 'Pinboard', 'error', 'Pinboard save failed', { error: errorMessage(err) })); + return captureResult( + item, + destination('llm', 'LLM tags', 'error', 'Tagging failed', { error: errorMessage(err) }), + localDestination(item, item, true), + await syncConfiguredDestinations(item, false) + ); } } - if (settings.readwise?.saveOnCapture) { - if (!settings.readwise.apiTokenRef) { - destinations.push(destination('readwise', 'Readwise Reader', 'error', 'Readwise capture enabled but token is missing')); - } else { - try { - if (await alreadySynced(item, 'readwise', fingerprint)) { - destinations.push(destination('readwise', 'Readwise Reader', 'skipped', 'Already synced to Readwise Reader', { url: 'https://readwise.io/read' })); - } else { - await saveToReadwiseReader(readwiseInputFromItem(item)); - await markSync(item, 'readwise', 'ok', fingerprint); - destinations.push(destination('readwise', 'Readwise Reader', 'success', 'Saved to Readwise Reader', { url: 'https://readwise.io/read' })); - } - } catch (err) { - await markSync(item, 'readwise', 'error', fingerprint, errorMessage(err)); - destinations.push(destination('readwise', 'Readwise Reader', 'error', 'Readwise save failed', { error: errorMessage(err) })); - } - } - } else { - destinations.push(destination('readwise', 'Readwise Reader', 'skipped', 'Readwise capture disabled')); + if (destinationId === 'pinboard') { + const pinboard = await syncPinboard(item, fingerprint, true); + const readwise = await syncReadwise(item, fingerprint, false); + return captureResult( + item, + destination('llm', 'LLM tags', item.tags.length ? 'success' : 'skipped', item.tags.length ? `Tagged: ${item.tags.slice(0, 5).join(', ')}` : 'No tags'), + localDestination(item, item, true), + [pinboard, readwise] + ); + } + + if (destinationId === 'readwise') { + const pinboard = await syncPinboard(item, fingerprint, false); + const readwise = await syncReadwise(item, fingerprint, true); + return captureResult( + item, + destination('llm', 'LLM tags', item.tags.length ? 'success' : 'skipped', item.tags.length ? `Tagged: ${item.tags.slice(0, 5).join(', ')}` : 'No tags'), + localDestination(item, item, true), + [pinboard, readwise] + ); } - return { item, tags, destinations }; + throw new Error(`Cannot retry ${destinationId}`); +} + +export async function updateCapturedTags(itemId: string, incoming: string[]): Promise { + const item = await getItem(itemId); + if (!item) throw new Error('Saved item not found'); + const settings = await getSettings(); + const { known, knownMap } = await knownTagNames(settings); + const tags = normalizeTags(parseTagInput(incoming.join(',')), known, settings); + const previous = item.tags; + item.tags = tags; + item.contentHash = captureSyncFingerprint(item); + item.status = 'tagged'; + delete item.lastError; + await incrementKnownTags(knownMap, tags, previous); + await upsertItem(item); + const synced = await syncConfiguredDestinations(item, true); + return captureResult( + item, + destination('llm', 'LLM tags', 'skipped', 'Tags updated'), + localDestination(item, item, false), + synced + ); } export async function tagAndMaybeSync(input: { url: string; title: string; domain: string; text?: string }): Promise { diff --git a/src/background/preview-draft.test.ts b/src/background/preview-draft.test.ts new file mode 100644 index 0000000..377f869 --- /dev/null +++ b/src/background/preview-draft.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import type { Settings } from '@common/types'; +import { knownTagNames, settingsWithPreviewDraft } from './preview-draft'; + +const settings: Settings = { + llm: { baseUrl: 'https://llm.example.test/v1', model: 'test', jsonMode: true, maxChars: 1000 }, + pinboard: { shared: true, toread: false }, + tagging: { knownTagLimit: 200, dedupeThreshold: 82, aliases: {} }, + privacy: { mode: 'title_excerpt' } +}; + +describe('settingsWithPreviewDraft', () => { + it('returns the original settings when no draft is provided', () => { + expect(settingsWithPreviewDraft(settings)).toBe(settings); + }); + + it('overrides LLM and privacy fields from the draft', () => { + const next = settingsWithPreviewDraft(settings, { + llmBaseUrl: 'http://localhost:11434/v1', + llmModel: 'llama3', + llmMaxChars: 250, + privacyMode: 'title_only' + }); + + expect(next.llm.baseUrl).toBe('http://localhost:11434/v1'); + expect(next.llm.model).toBe('llama3'); + expect(next.llm.maxChars).toBe(250); + expect(next.privacy.mode).toBe('title_only'); + expect(next.llm.jsonMode).toBe(true); + }); + + it('keeps the stored maxChars when the draft value is not finite', () => { + const next = settingsWithPreviewDraft(settings, { llmMaxChars: Number.NaN }); + expect(next.llm.maxChars).toBe(1000); + }); +}); + +describe('knownTagNames', () => { + it('orders tags by count and applies the limit', () => { + expect(knownTagNames({ + rare: { slug: 'rare', count: 1 }, + ai: { slug: 'ai', count: 9 }, + reading: { slug: 'reading', count: 4 } + }, 2)).toEqual(['ai', 'reading']); + }); +}); diff --git a/src/background/preview-draft.ts b/src/background/preview-draft.ts new file mode 100644 index 0000000..3e4a42e --- /dev/null +++ b/src/background/preview-draft.ts @@ -0,0 +1,37 @@ +import type { PrivacyMode, Settings, TagInfo } from '@common/types'; +import type { ExportTargets } from '@common/preview'; + +export type PreviewDraft = { + llmBaseUrl?: string; + llmModel?: string; + llmMaxChars?: number; + privacyMode?: PrivacyMode; + pinboardConfigured?: boolean; + pinboardCaptureEnabled?: boolean; + readwiseConfigured?: boolean; + readwiseCaptureEnabled?: boolean; + exportTargets?: ExportTargets; +}; + +export function settingsWithPreviewDraft(settings: Settings, draft?: PreviewDraft): Settings { + if (!draft) return settings; + return { + ...settings, + llm: { + ...settings.llm, + baseUrl: draft.llmBaseUrl ?? settings.llm.baseUrl, + model: draft.llmModel ?? settings.llm.model, + maxChars: Number.isFinite(draft.llmMaxChars) ? Number(draft.llmMaxChars) : settings.llm.maxChars + }, + privacy: { + ...settings.privacy, + mode: draft.privacyMode ?? settings.privacy.mode + } + }; +} + +export function knownTagNames(map: Record, limit: number): string[] { + return Object.keys(map) + .sort((a, b) => (map[b]?.count || 0) - (map[a]?.count || 0)) + .slice(0, limit); +} diff --git a/src/background/readwise.test.ts b/src/background/readwise.test.ts index 01bbc89..8d421db 100644 --- a/src/background/readwise.test.ts +++ b/src/background/readwise.test.ts @@ -1,6 +1,31 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import type { Item } from '@common/types'; -import { buildReadwiseSaveBody, readwiseInputFromItem } from './readwise'; + +vi.mock('@common/storage', () => ({ + getSettings: vi.fn(), + getSecret: vi.fn() +})); + +import { getSecret, getSettings } from '@common/storage'; +import { + buildReadwiseSaveBody, + buildReadwiseUpdateBody, + parseReadwiseDocumentResponse, + patchReadwiseReaderDocument, + postReadwiseReaderDocument, + readwiseInputFromItem, + readwiseUpdateUrl, + saveToReadwiseReader, + syncReadwiseDocument +} from './readwise'; + +function jsonResponse(status: number, body: unknown): Response { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body + } as Response; +} describe('buildReadwiseSaveBody', () => { it('builds a Reader save payload with tags and tldr source metadata', () => { @@ -29,6 +54,57 @@ describe('buildReadwiseSaveBody', () => { }); }); +describe('buildReadwiseUpdateBody', () => { + it('includes only provided update fields', () => { + expect(buildReadwiseUpdateBody({ tags: ['ai'] })).toEqual({ tags: ['ai'] }); + expect(buildReadwiseUpdateBody({ + title: 'Title', + summary: 'Summary', + tags: ['ai'] + })).toEqual({ + title: 'Title', + summary: 'Summary', + tags: ['ai'] + }); + expect(buildReadwiseUpdateBody({})).toEqual({}); + }); +}); + +describe('readwiseUpdateUrl', () => { + it('encodes the document id in the PATCH URL', () => { + expect(readwiseUpdateUrl('doc/1')).toBe('https://readwise.io/api/v3/update/doc%2F1/'); + }); +}); + +describe('parseReadwiseDocumentResponse', () => { + it('treats HTTP 200 as an existing document', () => { + expect(parseReadwiseDocumentResponse(200, { + id: 'doc-1', + url: 'https://read.readwise.io/read/doc-1' + })).toEqual({ + id: 'doc-1', + url: 'https://read.readwise.io/read/doc-1', + alreadyExists: true + }); + }); + + it('treats HTTP 201 as a newly created document', () => { + expect(parseReadwiseDocumentResponse(201, { id: 'doc-2' })).toEqual({ + id: 'doc-2', + url: undefined, + alreadyExists: false + }); + }); + + it('ignores non-object payloads', () => { + expect(parseReadwiseDocumentResponse(201, 'nope')).toEqual({ + id: undefined, + url: undefined, + alreadyExists: false + }); + }); +}); + describe('readwiseInputFromItem', () => { it('maps a saved item into a Reader save input', () => { const item: Item = { @@ -39,7 +115,8 @@ describe('readwiseInputFromItem', () => { excerpt: 'Readable excerpt', createdAt: 1, tags: ['ai', 'reading'], - status: 'tagged' + status: 'tagged', + readwiseDocumentId: 'doc-1' }; expect(readwiseInputFromItem(item)).toEqual({ @@ -47,7 +124,190 @@ describe('readwiseInputFromItem', () => { title: 'Example post', summary: 'Readable excerpt', tags: ['ai', 'reading'], - savedUsing: 'tldr' + savedUsing: 'tldr', + documentId: 'doc-1' + }); + }); + + it('falls back to the URL when the title is empty', () => { + expect(readwiseInputFromItem({ + id: '1', + url: 'https://example.test/post', + domain: 'example.test', + title: '', + createdAt: 1, + tags: [], + status: 'tagged' + }).title).toBe('https://example.test/post'); + }); +}); + +describe('postReadwiseReaderDocument', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('posts a save payload and parses the response', async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse(201, { + id: 'doc-9', + url: 'https://read.readwise.io/read/doc-9' + })); + vi.stubGlobal('fetch', fetchMock); + + await expect(postReadwiseReaderDocument('tok', { + url: 'https://example.test/post', + title: 'Post' + })).resolves.toEqual({ + id: 'doc-9', + url: 'https://read.readwise.io/read/doc-9', + alreadyExists: false + }); + expect(fetchMock.mock.calls[0]?.[0]).toBe('https://readwise.io/api/v3/save/'); + expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ method: 'POST' }); + }); + + it('throws on a non-OK save response', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse(400, {}))); + await expect(postReadwiseReaderDocument('tok', { url: 'https://example.test/post' })).rejects.toThrow('Readwise error 400'); + }); +}); + +describe('patchReadwiseReaderDocument', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('patches tags and keeps the document id when the body omits it', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse(200, {}))); + await expect(patchReadwiseReaderDocument('tok', 'doc-1', { tags: ['ai'] })).resolves.toEqual({ + id: 'doc-1', + url: undefined, + alreadyExists: true + }); + }); + + it('throws on a non-OK update response', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse(404, {}))); + await expect(patchReadwiseReaderDocument('tok', 'doc-1', { tags: ['ai'] })).rejects.toThrow('Readwise error 404'); + }); +}); + +describe('saveToReadwiseReader', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('throws when no token is configured', async () => { + vi.mocked(getSettings).mockResolvedValue({ + llm: { baseUrl: 'https://llm.example.test/v1', model: 'test', jsonMode: true, maxChars: 1000 }, + pinboard: { shared: true, toread: false }, + readwise: {}, + tagging: { knownTagLimit: 200, dedupeThreshold: 82, aliases: {} }, + privacy: { mode: 'title_excerpt' } + }); + await expect(saveToReadwiseReader({ url: 'https://example.test/post' })).rejects.toThrow('Readwise token not set'); + }); + + it('throws when the token reference has no stored secret', async () => { + vi.mocked(getSettings).mockResolvedValue({ + llm: { baseUrl: 'https://llm.example.test/v1', model: 'test', jsonMode: true, maxChars: 1000 }, + pinboard: { shared: true, toread: false }, + readwise: { apiTokenRef: 'readwise_token' }, + tagging: { knownTagLimit: 200, dedupeThreshold: 82, aliases: {} }, + privacy: { mode: 'title_excerpt' } + }); + vi.mocked(getSecret).mockResolvedValue(undefined); + await expect(saveToReadwiseReader({ url: 'https://example.test/post' })).rejects.toThrow('Readwise token not set'); + }); + + it('loads the stored token and syncs the document', async () => { + vi.mocked(getSettings).mockResolvedValue({ + llm: { baseUrl: 'https://llm.example.test/v1', model: 'test', jsonMode: true, maxChars: 1000 }, + pinboard: { shared: true, toread: false }, + readwise: { apiTokenRef: 'readwise_token' }, + tagging: { knownTagLimit: 200, dedupeThreshold: 82, aliases: {} }, + privacy: { mode: 'title_excerpt' } + }); + vi.mocked(getSecret).mockResolvedValue('rw_token'); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse(201, { id: 'doc-3' }))); + + await expect(saveToReadwiseReader({ url: 'https://example.test/post', title: 'Post' })).resolves.toMatchObject({ + id: 'doc-3', + alreadyExists: false + }); + }); +}); + +describe('syncReadwiseDocument', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('patches when a document id is already known', async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse(200, { + id: 'doc-1', + url: 'https://read.readwise.io/read/doc-1' + })); + vi.stubGlobal('fetch', fetchMock); + + const result = await syncReadwiseDocument('tok', { + url: 'https://example.test/post', + title: 'Example post', + tags: ['ai'], + documentId: 'doc-1' + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0]?.[0]).toBe(readwiseUpdateUrl('doc-1')); + expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ method: 'PATCH' }); + expect(result).toMatchObject({ id: 'doc-1', alreadyExists: true }); + }); + + it('patches tags when save reports the document already exists', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(jsonResponse(200, { + id: 'doc-1', + url: 'https://read.readwise.io/read/doc-1' + })) + .mockResolvedValueOnce(jsonResponse(200, { + id: 'doc-1', + url: 'https://read.readwise.io/read/doc-1' + })); + vi.stubGlobal('fetch', fetchMock); + + const result = await syncReadwiseDocument('tok', { + url: 'https://example.test/post', + title: 'Example post', + tags: ['ai', 'reading'] + }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(String(fetchMock.mock.calls[0]?.[0])).toContain('/api/v3/save/'); + expect(fetchMock.mock.calls[1]?.[0]).toBe(readwiseUpdateUrl('doc-1')); + expect(JSON.parse(String((fetchMock.mock.calls[1]?.[1] as RequestInit).body))).toEqual({ + title: 'Example post', + tags: ['ai', 'reading'] + }); + expect(result.alreadyExists).toBe(true); + }); + + it('does not patch a newly created document', async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse(201, { + id: 'doc-9', + url: 'https://read.readwise.io/read/doc-9' + })); + vi.stubGlobal('fetch', fetchMock); + + const result = await syncReadwiseDocument('tok', { + url: 'https://example.test/post', + tags: ['ai'] + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + id: 'doc-9', + url: 'https://read.readwise.io/read/doc-9', + alreadyExists: false }); }); }); diff --git a/src/background/readwise.ts b/src/background/readwise.ts index 157c4e5..852455b 100644 --- a/src/background/readwise.ts +++ b/src/background/readwise.ts @@ -7,6 +7,7 @@ export interface ReadwiseSaveInput { summary?: string; tags?: string[]; savedUsing?: string; + documentId?: string; } export interface ReadwiseSaveResponse { @@ -15,6 +16,12 @@ export interface ReadwiseSaveResponse { alreadyExists?: boolean; } +export interface ReadwiseUpdateInput { + title?: string; + summary?: string; + tags?: string[]; +} + export function buildReadwiseSaveBody(input: ReadwiseSaveInput): Record { return { url: input.url, @@ -25,16 +32,42 @@ export function buildReadwiseSaveBody(input: ReadwiseSaveInput): Record { + const body: Record = {}; + if (input.title) body.title = input.title; + if (input.summary) body.summary = input.summary; + if (input.tags) body.tags = input.tags; + return body; +} + +export function parseReadwiseDocumentResponse(status: number, data: unknown): ReadwiseSaveResponse { + const record = data && typeof data === 'object' ? data as Record : {}; + return { + id: typeof record.id === 'string' ? record.id : undefined, + url: typeof record.url === 'string' ? record.url : undefined, + alreadyExists: status === 200 + }; +} + +export function readwiseUpdateUrl(documentId: string): string { + return `https://readwise.io/api/v3/update/${encodeURIComponent(documentId)}/`; +} + export function readwiseInputFromItem(item: Item): ReadwiseSaveInput { return { url: item.url, title: item.title || item.url, summary: item.excerpt, tags: item.tags, - savedUsing: 'tldr' + savedUsing: 'tldr', + documentId: item.readwiseDocumentId }; } +async function readwiseJson(res: Response): Promise { + return res.json().catch(() => ({})); +} + export async function postReadwiseReaderDocument(token: string, input: ReadwiseSaveInput): Promise { const res = await fetch('https://readwise.io/api/v3/save/', { method: 'POST', @@ -46,18 +79,54 @@ export async function postReadwiseReaderDocument(token: string, input: ReadwiseS }); if (!res.ok) throw new Error(`Readwise error ${res.status}`); + return parseReadwiseDocumentResponse(res.status, await readwiseJson(res)); +} - const data = await res.json().catch(() => ({})); - return { - id: typeof data?.id === 'string' ? data.id : undefined, - url: typeof data?.url === 'string' ? data.url : undefined, - alreadyExists: res.status === 200 +export async function patchReadwiseReaderDocument( + token: string, + documentId: string, + input: ReadwiseUpdateInput +): Promise { + const res = await fetch(readwiseUpdateUrl(documentId), { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Token ${token}` + }, + body: JSON.stringify(buildReadwiseUpdateBody(input)) + }); + + if (!res.ok) throw new Error(`Readwise error ${res.status}`); + const parsed = parseReadwiseDocumentResponse(res.status, await readwiseJson(res)); + return { ...parsed, id: parsed.id || documentId, alreadyExists: true }; +} + +export async function syncReadwiseDocument(token: string, input: ReadwiseSaveInput): Promise { + const update = { + title: input.title, + summary: input.summary, + tags: input.tags || [] }; + + if (input.documentId) { + return patchReadwiseReaderDocument(token, input.documentId, update); + } + + const created = await postReadwiseReaderDocument(token, input); + if (created.alreadyExists && created.id) { + const patched = await patchReadwiseReaderDocument(token, created.id, update); + return { + id: patched.id || created.id, + url: patched.url || created.url, + alreadyExists: true + }; + } + return created; } export async function saveToReadwiseReader(input: ReadwiseSaveInput): Promise { const settings = await getSettings(); const token = settings.readwise?.apiTokenRef ? await getSecret(settings.readwise.apiTokenRef) : undefined; if (!token) throw new Error('Readwise token not set'); - return postReadwiseReaderDocument(token, input); + return syncReadwiseDocument(token, input); } diff --git a/src/background/tabs.test.ts b/src/background/tabs.test.ts new file mode 100644 index 0000000..125b9de --- /dev/null +++ b/src/background/tabs.test.ts @@ -0,0 +1,46 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { extractFromActiveTab } from './tabs'; + +describe('extractFromActiveTab', () => { + let executeScript: ReturnType; + + beforeEach(() => { + executeScript = vi.fn(); + (globalThis as unknown as { chrome: unknown }).chrome = { + scripting: { executeScript } + }; + }); + + it('injects Readability then returns the extracted page payload', async () => { + executeScript + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ result: { url: 'https://example.test', title: 'Post', domain: 'example.test', text: 'Hello' } }]); + + await expect(extractFromActiveTab(12)).resolves.toEqual({ + url: 'https://example.test', + title: 'Post', + domain: 'example.test', + text: 'Hello' + }); + expect(executeScript).toHaveBeenNthCalledWith(1, { + target: { tabId: 12 }, + files: ['readability.js'] + }); + }); + + it('still extracts when Readability injection fails', async () => { + executeScript + .mockRejectedValueOnce(new Error('missing file')) + .mockResolvedValueOnce([{ result: { url: 'https://example.test', title: 'Post', domain: 'example.test', text: 'Fallback' } }]); + + await expect(extractFromActiveTab(3)).resolves.toMatchObject({ text: 'Fallback' }); + }); + + it('throws when the tab does not return extractable content', async () => { + executeScript + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{}]); + + await expect(extractFromActiveTab(9)).rejects.toThrow('Could not extract the current tab'); + }); +}); diff --git a/src/background/tabs.ts b/src/background/tabs.ts index a5b2b59..709cb06 100644 --- a/src/background/tabs.ts +++ b/src/background/tabs.ts @@ -6,29 +6,47 @@ export interface ExtractedContent { } export async function extractFromActiveTab(tabId: number): Promise { - // Attempt to inject readability.js if present in the extension - try { await chrome.scripting.executeScript({ target: { tabId }, files: ['readability.js'] }); } catch {} - const [{ result }] = await chrome.scripting.executeScript({ + // Inject the bundled Mozilla Readability build into the isolated world. + try { + await chrome.scripting.executeScript({ target: { tabId }, files: ['readability.js'] }); + } catch { + // Optional enhancement; extraction still has a DOM fallback. + } + + const [injection] = await chrome.scripting.executeScript({ target: { tabId }, - world: 'MAIN', func: () => { const url = location.href; const title = document.title || ''; const domain = location.hostname; - // If Readability is present, prefer it. Otherwise fallback to body text. - // @ts-ignore - const hasReadability = typeof window.Readability !== 'undefined'; + const readability = (window as unknown as { + Readability?: new (doc: Document) => { parse: () => { textContent?: string } | null }; + }).Readability; + let text = ''; try { - if (hasReadability) { - // @ts-ignore - const article = new window.Readability(document.cloneNode(true)).parse(); + if (readability) { + const article = new readability(document.cloneNode(true) as Document).parse(); text = article?.textContent || ''; } - } catch {} + } catch { + // Fall through to semantic DOM extraction. + } + + if (!text) { + const root = document.querySelector('article, main, [role="main"]') || document.body; + if (root) { + const clone = root.cloneNode(true) as HTMLElement; + clone.querySelectorAll('script, style, noscript, nav, footer, aside, iframe, [role="navigation"]').forEach((el) => el.remove()); + text = (clone.innerText || '').replace(/\n{3,}/g, '\n\n').trim(); + } + } + if (!text) text = document.body?.innerText?.trim() || ''; return { url, title, domain, text }; } }); - return result as ExtractedContent; + + if (!injection?.result) throw new Error('Could not extract the current tab'); + return injection.result as ExtractedContent; } diff --git a/src/background/tags.test.ts b/src/background/tags.test.ts new file mode 100644 index 0000000..8a62eed --- /dev/null +++ b/src/background/tags.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import type { Settings } from '@common/types'; +import { canonicalizeTags, similarity, slugify } from './tags'; + +const settings: Settings = { + llm: { baseUrl: 'https://llm.example.test/v1', model: 'test', jsonMode: true, maxChars: 1000 }, + pinboard: { shared: true, toread: false }, + tagging: { knownTagLimit: 200, dedupeThreshold: 82, aliases: { colour: 'color' } }, + privacy: { mode: 'title_excerpt' } +}; + +describe('slugify', () => { + it('normalizes latin tags', () => { + expect(slugify('Hello World')).toBe('hello-world'); + expect(slugify(' AI__ML ')).toBe('ai-ml'); + }); + + it('keeps CJK and other letters so non-English tags are not dropped', () => { + expect(slugify('机器学习')).toBe('机器学习'); + expect(slugify('Résumé notes')).toBe('resume-notes'); + }); + + it('returns empty for punctuation-only input', () => { + expect(slugify('!!!')).toBe(''); + }); +}); + +describe('similarity', () => { + it('scores exact slugs at 100 and different slugs lower', () => { + expect(similarity('machine-learning', 'machine-learning')).toBe(100); + expect(similarity('ai', 'climate')).toBeLessThan(50); + }); +}); + +describe('canonicalizeTags', () => { + it('maps aliases and exact known tags', () => { + expect(canonicalizeTags(['Colour', 'AI'], ['ai', 'color'], settings)).toEqual(['color', 'ai']); + }); + + it('merges near-duplicates into known tags', () => { + expect(canonicalizeTags(['machinelearning'], ['machine-learning'], settings)).toEqual(['machine-learning']); + }); + + it('keeps genuinely new tags and drops empty slugs', () => { + expect(canonicalizeTags(['climate-tech', '!!!'], ['ai'], settings)).toEqual(['climate-tech']); + }); + + it('dedupes repeated candidates', () => { + expect(canonicalizeTags(['AI', 'ai', 'AI'], ['ai'], settings)).toEqual(['ai']); + }); +}); diff --git a/src/background/tags.ts b/src/background/tags.ts index 1d0df13..3e69bbb 100644 --- a/src/background/tags.ts +++ b/src/background/tags.ts @@ -4,10 +4,11 @@ export function slugify(s: string): string { return s .toLowerCase() .normalize('NFKD') - .replace(/[^a-z0-9\s-_]/g, '') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^\p{L}\p{N}\s_-]/gu, '') .replace(/[\s_]+/g, '-') .replace(/-+/g, '-') - .replace(/^-|-$|\s+/g, ''); + .replace(/^-|-$/g, ''); } function levenshtein(a: string, b: string): number { @@ -36,6 +37,7 @@ export function canonicalizeTags(candidates: string[], known: string[], settings const out: string[] = []; const knownSet = new Set(known); for (const t of candidates.map(slugify)) { + if (!t) continue; const aliasTo = settings.tagging.aliases[t]; if (aliasTo) { if (!out.includes(aliasTo)) out.push(aliasTo); diff --git a/src/common/capture.test.ts b/src/common/capture.test.ts index 4b3722e..c644ef6 100644 --- a/src/common/capture.test.ts +++ b/src/common/capture.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { captureSyncFingerprint } from './capture'; +import { captureSyncFingerprint, hasCaptureError } from './capture'; +import type { CaptureResult } from './capture'; describe('captureSyncFingerprint', () => { it('is stable when tag order changes', () => { @@ -36,3 +37,45 @@ describe('captureSyncFingerprint', () => { expect(before).not.toBe(after); }); }); + +describe('hasCaptureError', () => { + it('is true when any destination failed', () => { + const result: CaptureResult = { + item: { + id: '1', + url: 'https://example.test/post', + domain: 'example.test', + title: 'Post', + createdAt: 1, + tags: [], + status: 'error' + }, + tags: [], + destinations: [ + { id: 'local', label: 'Local library', status: 'success', message: 'Saved locally' }, + { id: 'llm', label: 'LLM tags', status: 'error', message: 'Tagging failed', error: 'LLM error 401' } + ] + }; + + expect(hasCaptureError(result)).toBe(true); + }); + + it('is false when every destination succeeded or was skipped', () => { + expect(hasCaptureError({ + item: { + id: '1', + url: 'https://example.test/post', + domain: 'example.test', + title: 'Post', + createdAt: 1, + tags: ['ai'], + status: 'tagged' + }, + tags: ['ai'], + destinations: [ + { id: 'local', label: 'Local library', status: 'success', message: 'Saved locally' }, + { id: 'pinboard', label: 'Pinboard', status: 'skipped', message: 'Pinboard token not configured' } + ] + })).toBe(false); + }); +}); diff --git a/src/common/capture.ts b/src/common/capture.ts index c3b60b0..21fda97 100644 --- a/src/common/capture.ts +++ b/src/common/capture.ts @@ -1,6 +1,6 @@ import type { Item } from './types'; -export type CaptureDestinationId = 'local' | 'pinboard' | 'readwise'; +export type CaptureDestinationId = 'local' | 'pinboard' | 'readwise' | 'llm'; export type CaptureDestinationStatus = 'success' | 'skipped' | 'error'; export interface CaptureDestinationResult { diff --git a/src/common/html.test.ts b/src/common/html.test.ts new file mode 100644 index 0000000..5f9725d --- /dev/null +++ b/src/common/html.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { escapeHtml, safeHttpUrl } from './html'; + +describe('escapeHtml', () => { + it('escapes markup and quotes', () => { + expect(escapeHtml(``)).toBe( + '<img src=x onerror="alert('xss')">' + ); + }); + + it('leaves plain text unchanged', () => { + expect(escapeHtml('hello')).toBe('hello'); + }); +}); + +describe('safeHttpUrl', () => { + it('keeps http(s) URLs', () => { + expect(safeHttpUrl('https://example.test/a')).toBe('https://example.test/a'); + }); + + it('rejects javascript and other schemes', () => { + expect(safeHttpUrl('http://localhost:11434/v1')).toBe('http://localhost:11434/v1'); + expect(safeHttpUrl('javascript:alert(1)')).toBe('#'); + expect(safeHttpUrl('not a url')).toBe('#'); + }); +}); diff --git a/src/common/html.ts b/src/common/html.ts new file mode 100644 index 0000000..cfa6bc9 --- /dev/null +++ b/src/common/html.ts @@ -0,0 +1,18 @@ +export function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +export function safeHttpUrl(url: string): string { + try { + const parsed = new URL(url); + if (parsed.protocol === 'http:' || parsed.protocol === 'https:') return parsed.toString(); + } catch { + // Ignore invalid URLs. + } + return '#'; +} diff --git a/src/common/item-index.test.ts b/src/common/item-index.test.ts new file mode 100644 index 0000000..a55724e --- /dev/null +++ b/src/common/item-index.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; +import type { Item } from './types'; +import { applyItemToIndex, buildItemIndex, emptyItemIndex, isItemIndex, isStoredItem, itemKey } from './item-index'; + +function item(partial: Partial & Pick): Item { + return { + domain: 'example.test', + title: partial.title || partial.url, + tags: [], + status: 'tagged', + ...partial + }; +} + +describe('itemKey', () => { + it('prefixes item ids', () => { + expect(itemKey('abc')).toBe('item:abc'); + }); +}); + +describe('isStoredItem', () => { + it('accepts saved items and rejects secrets and tag maps', () => { + expect(isStoredItem(item({ id: '1', url: 'https://example.test', createdAt: 1 }))).toBe(true); + expect(isStoredItem('sk-secret')).toBe(false); + expect(isStoredItem({ ai: { slug: 'ai', count: 1 } })).toBe(false); + expect(isStoredItem(null)).toBe(false); + }); +}); + +describe('isItemIndex', () => { + it('accepts a valid index and rejects other shapes', () => { + expect(isItemIndex({ ids: ['a'], byUrl: { 'https://example.test': 'a' } })).toBe(true); + expect(isItemIndex({ ids: ['a'], byUrl: ['a'] })).toBe(false); + expect(isItemIndex({ ids: 'a', byUrl: {} })).toBe(false); + expect(isItemIndex(null)).toBe(false); + }); +}); + +describe('emptyItemIndex', () => { + it('returns an empty index', () => { + expect(emptyItemIndex()).toEqual({ ids: [], byUrl: {} }); + }); +}); + +describe('buildItemIndex', () => { + it('indexes only item: records and keeps the newest id per URL', () => { + const index = buildItemIndex({ + llm_api_key: 'sk-secret', + tags: { ai: { slug: 'ai', count: 4 } }, + 'item:old': item({ id: 'old', url: 'https://example.test/post', createdAt: 1, title: 'Old' }), + 'item:new': item({ id: 'new', url: 'https://example.test/post', createdAt: 2, title: 'New' }), + 'item:other': item({ id: 'other', url: 'https://example.test/other', createdAt: 3, title: 'Other' }) + }); + + expect(index.ids).toEqual(['other', 'new', 'old']); + expect(index.byUrl).toEqual({ + 'https://example.test/post': 'new', + 'https://example.test/other': 'other' + }); + }); +}); + +describe('applyItemToIndex', () => { + it('moves an updated item to the front and rewrites its URL mapping', () => { + const next = applyItemToIndex( + { + ids: ['a', 'b'], + byUrl: { + 'https://example.test/a': 'a', + 'https://example.test/b': 'b' + } + }, + item({ id: 'a', url: 'https://example.test/moved', createdAt: 10 }), + 'https://example.test/a' + ); + + expect(next.ids).toEqual(['a', 'b']); + expect(next.byUrl).toEqual({ + 'https://example.test/moved': 'a', + 'https://example.test/b': 'b' + }); + }); + + it('starts from an empty index', () => { + const next = applyItemToIndex( + emptyItemIndex(), + item({ id: 'a', url: 'https://example.test/a', createdAt: 1 }) + ); + expect(next).toEqual({ + ids: ['a'], + byUrl: { 'https://example.test/a': 'a' } + }); + }); +}); diff --git a/src/common/item-index.ts b/src/common/item-index.ts new file mode 100644 index 0000000..2ce39b3 --- /dev/null +++ b/src/common/item-index.ts @@ -0,0 +1,58 @@ +import type { Item } from './types'; + +export const ITEM_KEY_PREFIX = 'item:'; +export const ITEM_INDEX_KEY = 'itemIndex'; + +export interface ItemIndex { + ids: string[]; + byUrl: Record; +} + +export function itemKey(id: string): string { + return `${ITEM_KEY_PREFIX}${id}`; +} + +export function isStoredItem(value: unknown): value is Item { + if (!value || typeof value !== 'object') return false; + const item = value as Partial; + return typeof item.id === 'string' + && typeof item.url === 'string' + && typeof item.createdAt === 'number'; +} + +export function isItemIndex(value: unknown): value is ItemIndex { + if (!value || typeof value !== 'object') return false; + const index = value as Partial; + return Array.isArray(index.ids) + && Boolean(index.byUrl) + && typeof index.byUrl === 'object' + && !Array.isArray(index.byUrl); +} + +export function emptyItemIndex(): ItemIndex { + return { ids: [], byUrl: {} }; +} + +export function buildItemIndex(records: Record): ItemIndex { + const items = Object.entries(records) + .filter(([key, value]) => key.startsWith(ITEM_KEY_PREFIX) && isStoredItem(value)) + .map(([, value]) => value as Item) + .sort((a, b) => b.createdAt - a.createdAt || a.id.localeCompare(b.id)); + + const byUrl: Record = {}; + for (const item of [...items].reverse()) { + byUrl[item.url] = item.id; + } + + return { ids: items.map((item) => item.id), byUrl }; +} + +export function applyItemToIndex(index: ItemIndex, item: Item, previousUrl?: string): ItemIndex { + const ids = [item.id, ...index.ids.filter((id) => id !== item.id)]; + const byUrl = { ...index.byUrl }; + if (previousUrl && previousUrl !== item.url && byUrl[previousUrl] === item.id) { + delete byUrl[previousUrl]; + } + byUrl[item.url] = item.id; + return { ids, byUrl }; +} diff --git a/src/common/messages.test.ts b/src/common/messages.test.ts new file mode 100644 index 0000000..ba43681 --- /dev/null +++ b/src/common/messages.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest'; +import { RETRY_DESTINATION, SHOW_CAPTURE, UPDATE_TAGS } from './messages'; + +describe('message types', () => { + it('keeps capture toast message names stable', () => { + expect(SHOW_CAPTURE).toBe('tldr:show-capture'); + expect(RETRY_DESTINATION).toBe('tldr:retry-destination'); + expect(UPDATE_TAGS).toBe('tldr:update-tags'); + }); +}); diff --git a/src/common/messages.ts b/src/common/messages.ts new file mode 100644 index 0000000..7dd0638 --- /dev/null +++ b/src/common/messages.ts @@ -0,0 +1,22 @@ +import type { CaptureDestinationId, CaptureResult } from './capture'; + +export const SHOW_CAPTURE = 'tldr:show-capture'; +export const RETRY_DESTINATION = 'tldr:retry-destination'; +export const UPDATE_TAGS = 'tldr:update-tags'; + +export type ShowCaptureMessage = { + type: typeof SHOW_CAPTURE; + capture: CaptureResult; +}; + +export type RetryDestinationMessage = { + type: typeof RETRY_DESTINATION; + itemId: string; + destinationId: CaptureDestinationId; +}; + +export type UpdateTagsMessage = { + type: typeof UPDATE_TAGS; + itemId: string; + tags: string[]; +}; diff --git a/src/common/origins.test.ts b/src/common/origins.test.ts new file mode 100644 index 0000000..3bf42da --- /dev/null +++ b/src/common/origins.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; +import { originPattern } from './origins'; + +describe('originPattern', () => { + it('builds an optional-host-permission pattern', () => { + expect(originPattern('https://api.moonshot.cn/v1')).toBe('https://api.moonshot.cn/*'); + expect(originPattern('http://localhost:11434/v1/')).toBe('http://localhost:11434/*'); + }); + + it('rejects empty, invalid, and non-http URLs', () => { + expect(originPattern('')).toBeNull(); + expect(originPattern('not a url')).toBeNull(); + expect(originPattern('chrome://extensions')).toBeNull(); + }); +}); diff --git a/src/common/origins.ts b/src/common/origins.ts new file mode 100644 index 0000000..c2143ed --- /dev/null +++ b/src/common/origins.ts @@ -0,0 +1,9 @@ +export function originPattern(url: string): string | null { + try { + const parsed = new URL(url.trim()); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null; + return `${parsed.origin}/*`; + } catch { + return null; + } +} diff --git a/src/common/preview.test.ts b/src/common/preview.test.ts index c58f7cf..7c0b6f0 100644 --- a/src/common/preview.test.ts +++ b/src/common/preview.test.ts @@ -5,6 +5,8 @@ import { buildDestinationPreviews, buildLlmPayloadPreview, chatCompletionsUrl, + privacyModeDescription, + privacyModeLabel, selectExcerpt } from './preview'; @@ -50,6 +52,22 @@ describe('chatCompletionsUrl', () => { }); }); +describe('privacyModeLabel', () => { + it('labels each privacy mode', () => { + expect(privacyModeLabel('title_only')).toBe('Title only'); + expect(privacyModeLabel('title_excerpt')).toBe('Title + excerpt'); + expect(privacyModeLabel('full_truncated')).toBe('Readable text, truncated'); + }); +}); + +describe('privacyModeDescription', () => { + it('describes what each mode sends', () => { + expect(privacyModeDescription('title_only')).toContain('No page text'); + expect(privacyModeDescription('title_excerpt')).toContain('short excerpt'); + expect(privacyModeDescription('full_truncated')).toContain('character limit'); + }); +}); + describe('selectExcerpt', () => { it('omits page text in title-only mode', () => { expect(selectExcerpt('page text', 'title_only', 1000)).toBeUndefined(); @@ -62,6 +80,11 @@ describe('selectExcerpt', () => { it('uses the configured max character limit in full-truncated mode', () => { expect(selectExcerpt('x'.repeat(1200), 'full_truncated', 450)).toHaveLength(450); }); + + it('returns undefined for empty text', () => { + expect(selectExcerpt('', 'title_excerpt', 1000)).toBeUndefined(); + expect(selectExcerpt(undefined, 'full_truncated', 1000)).toBeUndefined(); + }); }); describe('buildLlmPayloadPreview', () => { @@ -83,6 +106,25 @@ describe('buildLlmPayloadPreview', () => { expect(preview.fields.some((field) => field.key === 'excerpt')).toBe(false); }); + + it('omits the endpoint when the base URL is empty', () => { + const preview = buildLlmPayloadPreview(previewInput, { + ...baseSettings, + llm: { ...baseSettings.llm, baseUrl: '' } + }); + expect(preview.endpoint).toBeUndefined(); + }); + + it('labels readable text in full-truncated mode', () => { + const preview = buildLlmPayloadPreview(previewInput, { + ...baseSettings, + privacy: { mode: 'full_truncated' } + }); + expect(preview.fields.find((field) => field.key === 'excerpt')).toMatchObject({ + label: 'Readable text', + charCount: 1000 + }); + }); }); describe('buildDestinationPreviews', () => { @@ -137,6 +179,19 @@ describe('buildDestinationPreviews', () => { }); }); + it('marks Readwise as requested but unconfigured', () => { + const destinations = buildDestinationPreviews(baseSettings, { + pinboardConfigured: false, + readwiseConfigured: false, + readwiseCaptureEnabled: true + }); + expect(destinations.find((destination) => destination.id === 'readwise')).toMatchObject({ + status: 'not_configured', + statusText: 'Requested but token is not configured.', + receives: [] + }); + }); + it('shows Pinboard as disabled when capture is turned off', () => { const destinations = buildDestinationPreviews(baseSettings, { pinboardConfigured: true, diff --git a/src/common/storage.test.ts b/src/common/storage.test.ts new file mode 100644 index 0000000..130d854 --- /dev/null +++ b/src/common/storage.test.ts @@ -0,0 +1,237 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import type { Item } from './types'; +import { ITEM_INDEX_KEY } from './item-index'; +import { + findItemByUrl, + getItem, + getSecret, + getSettings, + getSyncRecord, + getTags, + listItems, + setSecret, + setSettings, + setSyncRecord, + updateTags, + upsertItem +} from './storage'; + +function createMemoryArea() { + let data: Record = {}; + return { + get(keys: string | string[] | Record | null, cb: (items: Record) => void) { + if (keys === null) { + cb({ ...data }); + return; + } + if (typeof keys === 'string') { + cb(keys in data ? { [keys]: data[keys] } : {}); + return; + } + if (Array.isArray(keys)) { + const out: Record = {}; + for (const key of keys) if (key in data) out[key] = data[key]; + cb(out); + return; + } + cb({ ...keys, ...data }); + }, + set(items: Record, cb: () => void) { + data = { ...data, ...items }; + cb(); + }, + remove(keys: string | string[], cb: () => void) { + const list = Array.isArray(keys) ? keys : [keys]; + data = Object.fromEntries(Object.entries(data).filter(([key]) => !list.includes(key))); + cb(); + }, + _dump() { + return data; + } + }; +} + +describe('item storage index', () => { + beforeEach(() => { + const local = createMemoryArea(); + (globalThis as unknown as { chrome: unknown }).chrome = { + runtime: { lastError: undefined }, + storage: { + local, + sync: createMemoryArea() + } + }; + }); + + function sample(id: string, url: string, createdAt: number): Item { + return { + id, + url, + domain: 'example.test', + title: id, + createdAt, + tags: ['ai'], + status: 'tagged' + }; + } + + it('looks up and lists items without reading unrelated local keys', async () => { + const local = chrome.storage.local as unknown as { set: Function }; + await new Promise((resolve) => local.set({ llm_api_key: 'sk-secret', tags: { ai: { slug: 'ai', count: 1 } } }, resolve)); + + await upsertItem(sample('a', 'https://example.test/a', 1)); + await upsertItem(sample('b', 'https://example.test/b', 2)); + + const dump = (chrome.storage.local as unknown as { _dump: () => Record })._dump(); + expect(dump.llm_api_key).toBe('sk-secret'); + expect(dump[ITEM_INDEX_KEY]).toEqual({ + ids: ['b', 'a'], + byUrl: { + 'https://example.test/a': 'a', + 'https://example.test/b': 'b' + } + }); + + expect((await findItemByUrl('https://example.test/a'))?.id).toBe('a'); + expect((await listItems(1)).map((item) => item.id)).toEqual(['b']); + expect((await getItem('b'))?.title).toBe('b'); + }); + + it('rebuilds the index once from existing item: records', async () => { + const local = chrome.storage.local as unknown as { set: Function }; + await new Promise((resolve) => local.set({ + llm_api_key: 'sk-secret', + 'item:legacy': sample('legacy', 'https://example.test/legacy', 5) + }, resolve)); + + expect((await findItemByUrl('https://example.test/legacy'))?.id).toBe('legacy'); + const dump = (chrome.storage.local as unknown as { _dump: () => Record })._dump(); + expect(dump[ITEM_INDEX_KEY]).toMatchObject({ + ids: ['legacy'], + byUrl: { 'https://example.test/legacy': 'legacy' } + }); + }); + + it('returns undefined for a URL that is not indexed', async () => { + expect(await findItemByUrl('https://missing.test')).toBeUndefined(); + expect(await listItems()).toEqual([]); + }); + + it('rewrites the index when an item URL changes', async () => { + await upsertItem(sample('a', 'https://example.test/a', 1)); + await upsertItem({ ...sample('a', 'https://example.test/moved', 2), title: 'moved' }); + + expect(await findItemByUrl('https://example.test/a')).toBeUndefined(); + expect((await findItemByUrl('https://example.test/moved'))?.title).toBe('moved'); + }); +}); + +describe('getSettings and setSettings', () => { + beforeEach(() => { + (globalThis as unknown as { chrome: unknown }).chrome = { + runtime: { lastError: undefined }, + storage: { + local: createMemoryArea(), + sync: createMemoryArea() + } + }; + }); + + it('returns merged defaults when nothing is stored', async () => { + const settings = await getSettings(); + expect(settings.privacy.mode).toBe('title_excerpt'); + expect(settings.llm.model).toBe('kimi-k2-0905-preview'); + expect(settings.pinboard.saveOnCapture).toBe(true); + expect(settings.readwise?.saveOnCapture).toBe(false); + }); + + it('rejects when chrome.storage reports lastError', async () => { + (chrome.runtime as { lastError?: { message: string } }).lastError = { message: 'quota exceeded' }; + await expect(getSettings()).rejects.toThrow('quota exceeded'); + }); + + it('persists a merged settings object', async () => { + await setSettings({ + llm: { baseUrl: 'http://localhost:11434/v1', model: 'llama3', jsonMode: true, maxChars: 100 }, + pinboard: { shared: false, toread: true }, + tagging: { knownTagLimit: 10, dedupeThreshold: 90, aliases: {} }, + privacy: { mode: 'title_only' } + }); + const settings = await getSettings(); + expect(settings.llm.model).toBe('llama3'); + expect(settings.pinboard.shared).toBe(false); + expect(settings.privacy.mode).toBe('title_only'); + expect(settings.readwise?.saveOnCapture).toBe(false); + }); +}); + +describe('getSecret and setSecret', () => { + beforeEach(() => { + (globalThis as unknown as { chrome: unknown }).chrome = { + runtime: { lastError: undefined }, + storage: { + local: createMemoryArea(), + sync: createMemoryArea() + } + }; + }); + + it('stores, reads, and clears secrets', async () => { + expect(await getSecret('')).toBeUndefined(); + await setSecret('', 'ignored'); + await setSecret('llm_api_key', 'sk-test'); + expect(await getSecret('llm_api_key')).toBe('sk-test'); + await setSecret('llm_api_key', ''); + expect(await getSecret('llm_api_key')).toBeUndefined(); + }); +}); + +describe('getTags and updateTags', () => { + beforeEach(() => { + (globalThis as unknown as { chrome: unknown }).chrome = { + runtime: { lastError: undefined }, + storage: { + local: createMemoryArea(), + sync: createMemoryArea() + } + }; + }); + + it('returns an empty map by default and persists updates', async () => { + expect(await getTags()).toEqual({}); + await updateTags({ ai: { slug: 'ai', count: 2 } }); + expect(await getTags()).toEqual({ ai: { slug: 'ai', count: 2 } }); + }); +}); + +describe('getSyncRecord and setSyncRecord', () => { + beforeEach(() => { + (globalThis as unknown as { chrome: unknown }).chrome = { + runtime: { lastError: undefined }, + storage: { + local: createMemoryArea(), + sync: createMemoryArea() + } + }; + }); + + it('stores and reads a sync record, defaulting the service to pinboard', async () => { + expect(await getSyncRecord('item-1')).toBeUndefined(); + await setSyncRecord({ + itemId: 'item-1', + service: 'pinboard', + status: 'ok', + lastHash: 'hash', + updatedAt: 10 + }); + expect(await getSyncRecord('item-1')).toMatchObject({ status: 'ok', lastHash: 'hash' }); + await setSyncRecord({ + itemId: 'item-1', + service: 'readwise', + status: 'error', + lastError: 'nope', + updatedAt: 11 + }); + expect(await getSyncRecord('item-1', 'readwise')).toMatchObject({ status: 'error', lastError: 'nope' }); + }); +}); diff --git a/src/common/storage.ts b/src/common/storage.ts index 7528934..7698b42 100644 --- a/src/common/storage.ts +++ b/src/common/storage.ts @@ -1,4 +1,11 @@ import type { Settings, Item, TagInfo, SyncRecord } from './types'; +import { + ITEM_INDEX_KEY, + applyItemToIndex, + buildItemIndex, + isItemIndex, + itemKey +} from './item-index'; const DEFAULT_SETTINGS: Settings = { llm: { baseUrl: 'https://api.moonshot.cn/v1', model: 'kimi-k2-0905-preview', jsonMode: false, maxChars: 4000 }, @@ -66,7 +73,7 @@ export async function getSettings(): Promise { } export async function setSettings(settings: Settings): Promise { - await storageSet(chrome.storage.sync, { settings }); + await storageSet(chrome.storage.sync, { settings: mergeSettings(settings) }); } export async function getSecret(key: string): Promise { @@ -84,27 +91,45 @@ export async function setSecret(key: string, value: string): Promise { await storageSet(chrome.storage.local, { [key]: value }); } +async function getOrCreateItemIndex() { + const stored = await storageGet>(chrome.storage.local, ITEM_INDEX_KEY); + if (isItemIndex(stored[ITEM_INDEX_KEY])) return stored[ITEM_INDEX_KEY]; + + const all = await storageGet>(chrome.storage.local, null); + const built = buildItemIndex(all); + await storageSet(chrome.storage.local, { [ITEM_INDEX_KEY]: built }); + return built; +} + export async function upsertItem(item: Item): Promise { - const key = `item:${item.id}`; - await storageSet(chrome.storage.local, { [key]: item }); + const key = itemKey(item.id); + const existing = await getItem(item.id); + const index = applyItemToIndex(await getOrCreateItemIndex(), item, existing?.url); + await storageSet(chrome.storage.local, { [key]: item, [ITEM_INDEX_KEY]: index }); } export async function getItem(id: string): Promise { - const key = `item:${id}`; + const key = itemKey(id); const res = await storageGet>(chrome.storage.local, key); return res[key]; } export async function findItemByUrl(url: string): Promise { - const all = await storageGet>(chrome.storage.local, null); - const items: Item[] = Object.values(all).filter((v): v is Item => Boolean(v && (v as Item).id && (v as Item).url === url)); - return items.sort((a, b) => b.createdAt - a.createdAt)[0]; + const index = await getOrCreateItemIndex(); + const id = index.byUrl[url]; + if (!id) return undefined; + return getItem(id); } export async function listItems(limit = 50): Promise { - const all = await storageGet>(chrome.storage.local, null); - const items: Item[] = Object.values(all).filter((v): v is Item => Boolean(v && (v as Item).id && (v as Item).url)); - return items.sort((a, b) => b.createdAt - a.createdAt).slice(0, limit); + const index = await getOrCreateItemIndex(); + const ids = index.ids.slice(0, Math.max(0, limit)); + if (!ids.length) return []; + const res = await storageGet>(chrome.storage.local, ids.map(itemKey)); + return ids + .map((id) => res[itemKey(id)]) + .filter((item): item is Item => Boolean(item)) + .sort((a, b) => b.createdAt - a.createdAt); } export async function updateTags(map: Record): Promise { diff --git a/src/common/tags-input.test.ts b/src/common/tags-input.test.ts new file mode 100644 index 0000000..0d3ba01 --- /dev/null +++ b/src/common/tags-input.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; +import { parseTagInput } from './tags-input'; + +describe('parseTagInput', () => { + it('splits comma-separated tags and drops empties', () => { + expect(parseTagInput(' ai, reading, ,climate-tech ')).toEqual(['ai', 'reading', 'climate-tech']); + }); + + it('splits newline-separated tags and ignores blank input', () => { + expect(parseTagInput('ai\nreading\n')).toEqual(['ai', 'reading']); + expect(parseTagInput(' ')).toEqual([]); + }); +}); diff --git a/src/common/tags-input.ts b/src/common/tags-input.ts new file mode 100644 index 0000000..b97f1e4 --- /dev/null +++ b/src/common/tags-input.ts @@ -0,0 +1,6 @@ +export function parseTagInput(raw: string): string[] { + return raw + .split(/[,\n]+/) + .map((tag) => tag.trim()) + .filter(Boolean); +} diff --git a/src/common/types.ts b/src/common/types.ts index 047d2dc..859f64d 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -40,6 +40,7 @@ export interface Item { tags: string[]; // slugs status: 'new' | 'tagged' | 'synced' | 'error'; lastError?: string; + readwiseDocumentId?: string; } export interface TagInfo { diff --git a/src/content/readability-global.ts b/src/content/readability-global.ts new file mode 100644 index 0000000..f085f4d --- /dev/null +++ b/src/content/readability-global.ts @@ -0,0 +1,4 @@ +import { Readability } from '@mozilla/readability'; + +const root = globalThis as typeof globalThis & { Readability?: typeof Readability }; +root.Readability = Readability; diff --git a/src/content/toast-helpers.test.ts b/src/content/toast-helpers.test.ts new file mode 100644 index 0000000..5e82835 --- /dev/null +++ b/src/content/toast-helpers.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { toastChipColor, visibleToastDestinations } from './toast-helpers'; + +describe('toastChipColor', () => { + it('maps destination status to chip colors', () => { + expect(toastChipColor('success')).toBe('#0f766e'); + expect(toastChipColor('skipped')).toBe('#64748b'); + expect(toastChipColor('error')).toBe('#b45309'); + expect(toastChipColor('unknown')).toBe('#b45309'); + }); +}); + +describe('visibleToastDestinations', () => { + it('hides ordinary skips and keeps already-synced or updated skips', () => { + const visible = visibleToastDestinations([ + { id: 'local', label: 'Local library', status: 'success', message: 'Saved locally' }, + { id: 'pinboard', label: 'Pinboard', status: 'skipped', message: 'Pinboard token not configured' }, + { id: 'readwise', label: 'Readwise Reader', status: 'skipped', message: 'Already synced to Readwise Reader' }, + { id: 'llm', label: 'LLM tags', status: 'skipped', message: 'Tags updated' }, + { id: 'pinboard', label: 'Pinboard', status: 'error', message: 'Pinboard save failed' } + ]); + + expect(visible.map((destination) => destination.message)).toEqual([ + 'Saved locally', + 'Already synced to Readwise Reader', + 'Tags updated', + 'Pinboard save failed' + ]); + }); +}); diff --git a/src/content/toast-helpers.ts b/src/content/toast-helpers.ts new file mode 100644 index 0000000..0a25d88 --- /dev/null +++ b/src/content/toast-helpers.ts @@ -0,0 +1,15 @@ +import type { CaptureDestinationResult } from '@common/capture'; + +export function toastChipColor(status: string): string { + if (status === 'success') return '#0f766e'; + if (status === 'skipped') return '#64748b'; + return '#b45309'; +} + +export function visibleToastDestinations(destinations: CaptureDestinationResult[]): CaptureDestinationResult[] { + return destinations.filter((destination) => ( + destination.status !== 'skipped' + || destination.message.startsWith('Already') + || destination.message === 'Tags updated' + )); +} diff --git a/src/content/toast.ts b/src/content/toast.ts new file mode 100644 index 0000000..2004820 --- /dev/null +++ b/src/content/toast.ts @@ -0,0 +1,214 @@ +import { hasCaptureError, type CaptureResult } from '@common/capture'; +import { RETRY_DESTINATION, SHOW_CAPTURE, UPDATE_TAGS } from '@common/messages'; +import { parseTagInput } from '@common/tags-input'; +import { toastChipColor, visibleToastDestinations } from './toast-helpers'; + +declare global { + interface Window { + __tldrToastInstalled?: boolean; + } +} + +const ROOT_ID = 'tldr-capture-status'; +const ACTION_STYLE = 'border:0;background:transparent;color:#4f46e5;font:inherit;font-weight:700;cursor:pointer;padding:4px 6px'; +const MUTED_ACTION_STYLE = 'border:0;background:transparent;color:#64748b;font:inherit;cursor:pointer;padding:4px 6px'; + +let dismissTimer: number | undefined; + +function sendMessage(message: unknown): Promise { + return new Promise((resolve, reject) => { + chrome.runtime.sendMessage(message, (res) => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message)); + return; + } + if (!res?.ok) { + reject(new Error(res?.error || 'Request failed')); + return; + } + resolve(res as T); + }); + }); +} + +function clearTimer(): void { + if (dismissTimer) { + window.clearTimeout(dismissTimer); + dismissTimer = undefined; + } +} + +function scheduleDismiss(root: HTMLElement, capture: CaptureResult): void { + clearTimer(); + if (hasCaptureError(capture)) return; + dismissTimer = window.setTimeout(() => root.remove(), 8000); +} + +function renderToast(capture: CaptureResult): void { + document.getElementById(ROOT_ID)?.remove(); + + const root = document.createElement('div'); + root.id = ROOT_ID; + root.setAttribute('data-tldr-toast', '1'); + const failed = hasCaptureError(capture); + const canEdit = Boolean(capture.item.id && capture.item.id !== 'error'); + + root.style.cssText = [ + 'position:fixed', + 'top:10px', + 'left:50%', + 'transform:translateX(-50%)', + 'z-index:2147483647', + 'max-width:min(760px,calc(100vw - 24px))', + 'padding:8px 10px', + 'border:1px solid rgba(15,23,42,.12)', + 'border-radius:10px', + 'background:rgba(255,255,255,.94)', + 'box-shadow:0 8px 24px rgba(15,23,42,.12)', + 'color:#0f172a', + 'font:12px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif', + 'display:flex', + 'align-items:center', + 'gap:10px', + 'flex-wrap:wrap', + 'backdrop-filter:blur(16px)' + ].join(';'); + + const summary = document.createElement('div'); + summary.style.cssText = 'display:flex;align-items:center;gap:8px;min-width:0;flex:1'; + const statusEl = document.createElement('strong'); + statusEl.style.fontSize = '12px'; + statusEl.textContent = failed ? 'Saved with issues' : 'Saved'; + const tagsEl = document.createElement('span'); + tagsEl.style.cssText = 'color:#64748b;overflow:hidden;text-overflow:ellipsis;white-space:nowrap'; + tagsEl.textContent = capture.tags.length ? capture.tags.slice(0, 8).join(', ') : 'No tags'; + summary.append(statusEl, tagsEl); + root.append(summary); + + const chips = document.createElement('div'); + chips.style.cssText = 'display:flex;align-items:center;gap:5px;flex-wrap:wrap'; + for (const dest of visibleToastDestinations(capture.destinations)) { + const chip = dest.status === 'error' && canEdit + ? document.createElement('button') + : document.createElement('span'); + chip.title = dest.error || dest.message; + chip.textContent = dest.status === 'error' && canEdit ? `Retry ${dest.label}` : dest.label; + chip.style.cssText = `border:1px solid rgba(15,23,42,.1);border-radius:999px;padding:3px 7px;color:${toastChipColor(dest.status)};background:rgba(248,250,252,.92);font-weight:600;font:inherit;cursor:${chip.tagName === 'BUTTON' ? 'pointer' : 'default'}`; + if (chip instanceof HTMLButtonElement) { + chip.type = 'button'; + chip.addEventListener('click', async () => { + chip.textContent = `Retrying ${dest.label}…`; + chip.disabled = true; + try { + const res = await sendMessage<{ capture: CaptureResult }>({ + type: RETRY_DESTINATION, + itemId: capture.item.id, + destinationId: dest.id + }); + renderToast(res.capture); + } catch (err) { + chip.disabled = false; + chip.textContent = `Retry ${dest.label}`; + chip.title = err instanceof Error ? err.message : String(err); + } + }); + } + chips.append(chip); + } + root.append(chips); + + const actions = document.createElement('div'); + actions.style.cssText = 'display:flex;align-items:center;gap:4px'; + + const reader = capture.destinations.find((destination) => destination.id === 'readwise' && destination.url && destination.status !== 'error'); + if (reader?.url) { + const openReader = document.createElement('button'); + openReader.type = 'button'; + openReader.textContent = 'Reader'; + openReader.style.cssText = ACTION_STYLE; + openReader.addEventListener('click', () => window.open(reader.url, '_blank', 'noopener,noreferrer')); + actions.append(openReader); + } + + const pinboard = capture.destinations.find((destination) => destination.id === 'pinboard' && destination.url && destination.status !== 'error'); + if (pinboard?.url) { + const openPinboard = document.createElement('button'); + openPinboard.type = 'button'; + openPinboard.textContent = 'Pinboard'; + openPinboard.style.cssText = ACTION_STYLE; + openPinboard.addEventListener('click', () => window.open(pinboard.url, '_blank', 'noopener,noreferrer')); + actions.append(openPinboard); + } + + if (canEdit) { + const edit = document.createElement('button'); + edit.type = 'button'; + edit.textContent = 'Edit tags'; + edit.style.cssText = ACTION_STYLE; + edit.addEventListener('click', () => { + clearTimer(); + const form = document.createElement('form'); + form.style.cssText = 'display:flex;align-items:center;gap:6px;width:100%'; + const input = document.createElement('input'); + input.type = 'text'; + input.value = capture.tags.join(', '); + input.setAttribute('aria-label', 'Tags'); + input.style.cssText = 'flex:1;min-width:160px;border:1px solid rgba(15,23,42,.14);border-radius:8px;padding:4px 8px;font:inherit'; + const save = document.createElement('button'); + save.type = 'submit'; + save.textContent = 'Save tags'; + save.style.cssText = ACTION_STYLE; + form.append(input, save); + form.addEventListener('submit', async (event) => { + event.preventDefault(); + save.disabled = true; + try { + const res = await sendMessage<{ capture: CaptureResult }>({ + type: UPDATE_TAGS, + itemId: capture.item.id, + tags: parseTagInput(input.value) + }); + renderToast(res.capture); + } catch (err) { + save.disabled = false; + save.title = err instanceof Error ? err.message : String(err); + } + }); + root.append(form); + input.focus(); + input.select(); + }); + actions.append(edit); + } + + const close = document.createElement('button'); + close.type = 'button'; + close.textContent = 'Dismiss'; + close.style.cssText = MUTED_ACTION_STYLE; + close.addEventListener('click', () => { + clearTimer(); + root.remove(); + }); + actions.append(close); + root.append(actions); + + document.documentElement.append(root); + scheduleDismiss(root, capture); +} + +function install(): void { + if (window.__tldrToastInstalled) return; + window.__tldrToastInstalled = true; + chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { + if (message?.type !== SHOW_CAPTURE || !message.capture) return; + try { + renderToast(message.capture as CaptureResult); + sendResponse({ ok: true }); + } catch (err) { + sendResponse({ ok: false, error: err instanceof Error ? err.message : String(err) }); + } + return false; + }); +} + +install(); diff --git a/src/ui/options-helpers.test.ts b/src/ui/options-helpers.test.ts new file mode 100644 index 0000000..e43993f --- /dev/null +++ b/src/ui/options-helpers.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { destinationStatusLabel, renderDestination } from './options-helpers'; + +describe('destinationStatusLabel', () => { + it('labels each destination status', () => { + expect(destinationStatusLabel('active')).toBe('Active'); + expect(destinationStatusLabel('not_configured')).toBe('Not configured'); + expect(destinationStatusLabel('disabled')).toBe('Disabled'); + }); +}); + +describe('renderDestination', () => { + it('escapes destination copy and lists received fields', () => { + const html = renderDestination({ + id: 'pinboard', + label: '', + status: 'active', + statusText: 'Sends ', + receives: ['page URL', '