From a6bba58279875dceb87d1eda5345d422fd9b83b9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 30 Aug 2026 14:54:41 +0000 Subject: [PATCH 1/4] fix: harden capture, tagging, and extraction after code review Keep saving locally when the LLM fails, stop dropping CJK tags, and avoid treating page-injected tags as HTML. Also align extraction with the isolated world, stop double-counting Pinboard imports, drop unused permissions, and add CI for typecheck/tests. Co-authored-by: i.an --- .github/workflows/ci.yml | 36 ++++++ .github/workflows/release.yml | 6 + .gitignore | 1 - README.md | 10 +- .../chrome-web-store-publishing-checklist.md | 4 +- package.json | 1 + src/background/index.ts | 11 +- src/background/permissions.ts | 12 ++ src/background/pinboard-tags.test.ts | 17 +++ src/background/pinboard.ts | 28 +++-- src/background/pipeline.test.ts | 106 ++++++++++++++++++ src/background/pipeline.ts | 44 +++++--- src/background/tabs.ts | 43 +++++-- src/background/tags.test.ts | 46 ++++++++ src/background/tags.ts | 6 +- src/common/capture.test.ts | 26 ++++- src/common/capture.ts | 2 +- src/common/html.test.ts | 21 ++++ src/common/html.ts | 18 +++ src/common/origins.test.ts | 15 +++ src/common/origins.ts | 9 ++ src/common/storage.ts | 2 +- src/ui/options.ts | 32 ++---- static/manifest.json | 4 +- static/readability.js | 5 +- 25 files changed, 429 insertions(+), 76 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 src/background/permissions.ts create mode 100644 src/background/pinboard-tags.test.ts create mode 100644 src/background/pipeline.test.ts create mode 100644 src/background/tags.test.ts create mode 100644 src/common/html.test.ts create mode 100644 src/common/html.ts create mode 100644 src/common/origins.test.ts create mode 100644 src/common/origins.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..67780f9 --- /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: 9 + + - 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..01d43dc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 ff3d33b..fa37c99 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ - **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 (`article`/`main`, then body text). Drop a Mozilla Readability build into `static/readability.js` for higher-quality extraction. - Auto-tag new items by calling an OpenAI-compatible `chat/completions` endpoint that you configure at runtime—no hard-coded hosts, no forced vendors. - Sync saved items to Pinboard (including tag import/export), with GoodLinks and Readwise exports on deck. - Store everything locally; no backend service required. @@ -21,11 +21,13 @@ - 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 your Pinboard token; choose how much content is shared with the LLM. +- Page extraction prefers Mozilla Readability if you replace `static/readability.js` with the real library. Otherwise it uses `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). 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 7d49f04..81d2a10 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" }, diff --git a/src/background/index.ts b/src/background/index.ts index 8abc053..02b3b3e 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -6,6 +6,7 @@ import { extractFromActiveTab } from './tabs'; import { captureAndSync } from './pipeline'; import { importTagsFromPinboard, listRecentFromPinboard } from './pinboard'; import { exportToGoodlinks, exportToReadwise } from './exporters'; +import { ensureOriginPermission } from './permissions'; type PreviewDraft = { llmBaseUrl?: string; @@ -108,7 +109,13 @@ function renderCaptureStatus(payload: { const summary = document.createElement('div'); summary.style.cssText = 'display:flex;align-items:center;gap:8px;min-width:0;flex:1'; - summary.innerHTML = `${statusText}${tagText}`; + const statusEl = document.createElement('strong'); + statusEl.style.fontSize = '12px'; + statusEl.textContent = statusText; + const tagsEl = document.createElement('span'); + tagsEl.style.cssText = 'color:#64748b;overflow:hidden;text-overflow:ellipsis;white-space:nowrap'; + tagsEl.textContent = tagText; + summary.append(statusEl, tagsEl); root.append(summary); const chips = document.createElement('div'); @@ -203,6 +210,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); 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..a644d5b --- /dev/null +++ b/src/background/pinboard-tags.test.ts @@ -0,0 +1,17 @@ +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 } + }); + }); +}); 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..a326b74 --- /dev/null +++ b/src/background/pipeline.test.ts @@ -0,0 +1,106 @@ +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(), + 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 + })), + saveToReadwiseReader: vi.fn() +})); + +import { findItemByUrl, getSettings, getSyncRecord, getTags, setSyncRecord, updateTags, upsertItem } from '@common/storage'; +import { generateTags } from './llm'; +import { addToPinboard } from './pinboard'; +import { saveToReadwiseReader } from './readwise'; +import { captureAndSync } 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.' +}; + +describe('captureAndSync', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getSettings).mockResolvedValue(settings); + vi.mocked(getTags).mockResolvedValue({ ai: { slug: 'ai', count: 3 } }); + 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' })); + }); +}); diff --git a/src/background/pipeline.ts b/src/background/pipeline.ts index de3b273..edc27ab 100644 --- a/src/background/pipeline.ts +++ b/src/background/pipeline.ts @@ -9,7 +9,7 @@ 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( @@ -69,19 +69,35 @@ export async function captureAndSync(input: { url: string; title: string; domain && 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) { + + const destinations: CaptureDestinationResult[] = []; + let tags: string[]; + if (canReuseExistingTags) { + tags = existingItem!.tags; + destinations.push(destination('llm', 'LLM tags', 'skipped', 'Reused existing tags')); + } else { + try { + tags = canonicalizeTags( + await generateTags({ title: input.title, url: input.url, domain: input.domain, excerpt, knownTags: known }), + known, + settings + ).map(slugify).filter(Boolean); + destinations.push(destination( + 'llm', + 'LLM tags', + 'success', + tags.length ? `Tagged: ${tags.slice(0, 5).join(', ')}` : 'No tags generated' + )); + } catch (err) { + tags = existingItem?.tags || []; + destinations.push(destination('llm', 'LLM tags', 'error', 'Tagging failed', { error: errorMessage(err) })); + } + } + + if (!canReuseExistingTags && destinations.at(-1)?.status === 'success') { for (const t of tags) { if (existingItem?.tags.includes(t)) continue; - knownMap[t] = knownMap[t] || { slug: t, count: 0 } as any; + knownMap[t] = knownMap[t] || { slug: t, count: 0 }; knownMap[t].count += 1; } await updateTags(knownMap); @@ -105,9 +121,9 @@ export async function captureAndSync(input: { url: string; title: string; domain delete item.lastError; await upsertItem(item); - const destinations: CaptureDestinationResult[] = [ + destinations.unshift( destination('local', 'Local library', 'success', unchangedExistingItem ? 'Already saved locally' : existingItem ? 'Updated local item' : 'Saved locally') - ]; + ); const fingerprint = captureSyncFingerprint(item); if (settings.pinboard.authTokenRef) { diff --git a/src/background/tabs.ts b/src/background/tabs.ts index a5b2b59..a11e7b8 100644 --- a/src/background/tabs.ts +++ b/src/background/tabs.ts @@ -6,29 +6,48 @@ 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 into the isolated world so a real Readability build can attach to this world. + // static/readability.js ships as a stub until replaced with Mozilla Readability. + 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..8466c3a --- /dev/null +++ b/src/background/tags.test.ts @@ -0,0 +1,46 @@ +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', () => { + expect(similarity('machine-learning', 'machine-learning')).toBe(100); + }); +}); + +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']); + }); +}); 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..b348cd5 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,26 @@ 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); + }); +}); 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..1a88a19 --- /dev/null +++ b/src/common/html.test.ts @@ -0,0 +1,21 @@ +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')">' + ); + }); +}); + +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('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/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/storage.ts b/src/common/storage.ts index 3d166f8..974e089 100644 --- a/src/common/storage.ts +++ b/src/common/storage.ts @@ -66,7 +66,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 { diff --git a/src/ui/options.ts b/src/ui/options.ts index 4ea8dde..f22ba47 100644 --- a/src/ui/options.ts +++ b/src/ui/options.ts @@ -7,6 +7,8 @@ import { type DestinationPreview, type ExportTargets } from '@common/preview'; +import { escapeHtml, safeHttpUrl } from '@common/html'; +import { originPattern } from '@common/origins'; import type { PrivacyMode, Settings } from '@common/types'; function byId(id: string) { return document.getElementById(id) as T; } @@ -14,15 +16,6 @@ function byId(id: string) { return document.getElementByI const SECRET_PLACEHOLDER = '••••••••'; let loadedSettings: Settings | undefined; -function originPattern(url: string): string | null { - try { - const parsed = new URL(url); - return `${parsed.origin}/*`; - } catch { - return null; - } -} - function containsOriginPermission(pattern: string): Promise { return new Promise((resolve) => { chrome.permissions.contains({ origins: [pattern] }, (granted) => { @@ -75,14 +68,6 @@ function getActiveTab(): Promise { }); } -function escapeHtml(value: string): string { - return value - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); -} - function inputNumber(id: string, fallback: number): number { const value = parseInt(byId(id).value, 10); return Number.isFinite(value) ? value : fallback; @@ -301,7 +286,10 @@ async function save() { newSettings.privacy.mode = byId('privacy').value as any; try { newSettings.advanced = JSON.parse(byId('adv').value || '{}'); - } catch {} + } catch { + statusEl.textContent = 'Advanced config must be valid JSON.'; + return; + } await persistSecretField({ inputId: 'llm_key', @@ -551,11 +539,9 @@ function renderPinList() { return; } const rows = pinItems.map((it, i) => { - const tags = it.tags.join(', '); - const safeTitle = (it.title || it.url) - .replace(/&/g, '&') - .replace(/
diff --git a/static/manifest.json b/static/manifest.json index b41521c..91ddad4 100644 --- a/static/manifest.json +++ b/static/manifest.json @@ -4,7 +4,7 @@ "version": "0.1.1", "description": "Save links, extract content, tag with LLM, sync to Pinboard.", "action": { - "default_title": "tldr settings", + "default_title": "Save and tag this page", "default_icon": { "16": "icons/icon16.png", "32": "icons/icon32.png" @@ -18,8 +18,6 @@ "storage", "activeTab", "scripting", - "notifications", - "alarms", "contextMenus" ], "host_permissions": [ diff --git a/static/readability.js b/static/readability.js index 07052e8..98103cf 100644 --- a/static/readability.js +++ b/static/readability.js @@ -1,3 +1,4 @@ -// Placeholder for Mozilla Readability. Replace this file with the library to enable high-quality extraction. -// If Readability is not available, the extension falls back to body.innerText. +// Placeholder for Mozilla Readability. Replace this file with the browser build to +// attach window.Readability in the isolated world. Extraction falls back to +// article/main text, then document.body.innerText. From 3669370e4445783434a122478191ddb2375ee860 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 30 Aug 2026 15:33:33 +0000 Subject: [PATCH 2/4] feat: readability, item index, Reader updates, and toast retry Bundle Mozilla Readability for extraction, look up saved items through an index instead of scanning local storage, create-or-update Readwise documents when they already exist, and move the capture toast into a content script that can retry destinations and edit tags. Co-authored-by: i.an --- README.md | 9 +- package.json | 3 + pnpm-lock.yaml | 10 ++ scripts/build.mjs | 83 ++++++---- src/background/exporters.ts | 4 +- src/background/index.ts | 112 ++----------- src/background/pipeline.test.ts | 111 ++++++++++++- src/background/pipeline.ts | 261 +++++++++++++++++++++++------- src/background/readwise.test.ts | 126 ++++++++++++++- src/background/readwise.ts | 83 +++++++++- src/background/tabs.ts | 3 +- src/common/item-index.test.ts | 71 ++++++++ src/common/item-index.ts | 58 +++++++ src/common/messages.ts | 22 +++ src/common/storage.test.ts | 101 ++++++++++++ src/common/storage.ts | 43 +++-- src/common/tags-input.test.ts | 8 + src/common/tags-input.ts | 6 + src/common/types.ts | 1 + src/content/readability-global.ts | 4 + src/content/toast.ts | 223 +++++++++++++++++++++++++ static/readability.js | 4 - 22 files changed, 1121 insertions(+), 225 deletions(-) create mode 100644 src/common/item-index.test.ts create mode 100644 src/common/item-index.ts create mode 100644 src/common/messages.ts create mode 100644 src/common/storage.test.ts create mode 100644 src/common/tags-input.test.ts create mode 100644 src/common/tags-input.ts create mode 100644 src/content/readability-global.ts create mode 100644 src/content/toast.ts delete mode 100644 static/readability.js diff --git a/README.md b/README.md index fa37c99..7f7ec30 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,10 @@ - **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 (`article`/`main`, then body text). Drop a Mozilla Readability build into `static/readability.js` for higher-quality extraction. +- 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. -- Sync saved items to Pinboard (including tag import/export), with GoodLinks and Readwise exports on deck. +- Sync saved items to Pinboard (including tag import/export). 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. @@ -27,7 +28,7 @@ - `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 your Pinboard token; choose how much content is shared with the LLM. -- Page extraction prefers Mozilla Readability if you replace `static/readability.js` with the real library. Otherwise it uses `article`/`main` text, then `document.body.innerText`. +- 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). @@ -35,6 +36,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/package.json b/package.json index 81d2a10..9c53029 100644 --- a/package.json +++ b/package.json @@ -16,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/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.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 02b3b3e..6f60d65 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -2,8 +2,9 @@ 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 { 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'; import { ensureOriginPermission } from './permissions'; @@ -72,111 +73,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'; - const statusEl = document.createElement('strong'); - statusEl.style.fontSize = '12px'; - statusEl.textContent = statusText; - const tagsEl = document.createElement('span'); - tagsEl.style.cssText = 'color:#64748b;overflow:hidden;text-overflow:ellipsis;white-space:nowrap'; - tagsEl.textContent = tagText; - 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 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); } @@ -263,6 +166,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/pipeline.test.ts b/src/background/pipeline.test.ts index a326b74..a661dd2 100644 --- a/src/background/pipeline.test.ts +++ b/src/background/pipeline.test.ts @@ -4,6 +4,7 @@ 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(), @@ -18,16 +19,17 @@ vi.mock('./readwise', () => ({ url: item.url, title: item.title, summary: item.excerpt, - tags: item.tags + tags: item.tags, + documentId: item.readwiseDocumentId })), saveToReadwiseReader: vi.fn() })); -import { findItemByUrl, getSettings, getSyncRecord, getTags, setSyncRecord, updateTags, upsertItem } from '@common/storage'; +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 } from './pipeline'; +import { captureAndSync, retryDestination, updateCapturedTags } from './pipeline'; const settings: Settings = { llm: { baseUrl: 'https://llm.example.test/v1', model: 'test', jsonMode: true, maxChars: 1000 }, @@ -44,11 +46,26 @@ const input = { 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); @@ -103,4 +120,92 @@ describe('captureAndSync', () => { }); 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' })); + }); +}); + +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(); + }); +}); + +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' + }); + }); }); diff --git a/src/background/pipeline.ts b/src/background/pipeline.ts index edc27ab..b096a50 100644 --- a/src/background/pipeline.ts +++ b/src/background/pipeline.ts @@ -1,8 +1,9 @@ -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'; @@ -57,52 +58,140 @@ 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.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 destinations: CaptureDestinationResult[] = []; let tags: string[]; + let llmDest: CaptureDestinationResult; if (canReuseExistingTags) { tags = existingItem!.tags; - destinations.push(destination('llm', 'LLM tags', 'skipped', 'Reused existing tags')); + llmDest = destination('llm', 'LLM tags', 'skipped', 'Reused existing tags'); } else { try { - tags = canonicalizeTags( + tags = normalizeTags( await generateTags({ title: input.title, url: input.url, domain: input.domain, excerpt, knownTags: known }), known, settings - ).map(slugify).filter(Boolean); - destinations.push(destination( + ); + 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 || []; - destinations.push(destination('llm', 'LLM tags', 'error', 'Tagging failed', { error: errorMessage(err) })); + llmDest = destination('llm', 'LLM tags', 'error', 'Tagging failed', { error: errorMessage(err) }); } } - if (!canReuseExistingTags && destinations.at(-1)?.status === 'success') { - for (const t of tags) { - if (existingItem?.tags.includes(t)) continue; - knownMap[t] = knownMap[t] || { slug: t, count: 0 }; - knownMap[t].count += 1; - } - await updateTags(knownMap); - } - const unchangedExistingItem = existingItem ? sameItemPayload(existingItem, { url: input.url, title: input.title, domain: input.domain, excerpt, tags }) : false; @@ -121,55 +210,103 @@ export async function captureAndSync(input: { url: string; title: string; domain delete item.lastError; await upsertItem(item); - destinations.unshift( - 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.authTokenRef) { + 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) + ); } - } else { - destinations.push(destination('pinboard', 'Pinboard', 'skipped', 'Pinboard token not configured')); } - 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] + ); } - return { item, tags, destinations }; + 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] + ); + } + + 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/readwise.test.ts b/src/background/readwise.test.ts index 01bbc89..019e659 100644 --- a/src/background/readwise.test.ts +++ b/src/background/readwise.test.ts @@ -1,6 +1,21 @@ -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'; +import { + buildReadwiseSaveBody, + buildReadwiseUpdateBody, + parseReadwiseDocumentResponse, + readwiseInputFromItem, + readwiseUpdateUrl, + 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 +44,33 @@ describe('buildReadwiseSaveBody', () => { }); }); +describe('buildReadwiseUpdateBody', () => { + it('includes only provided update fields', () => { + expect(buildReadwiseUpdateBody({ tags: ['ai'] })).toEqual({ tags: ['ai'] }); + }); +}); + +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 + }); + }); +}); + describe('readwiseInputFromItem', () => { it('maps a saved item into a Reader save input', () => { const item: Item = { @@ -39,7 +81,8 @@ describe('readwiseInputFromItem', () => { excerpt: 'Readable excerpt', createdAt: 1, tags: ['ai', 'reading'], - status: 'tagged' + status: 'tagged', + readwiseDocumentId: 'doc-1' }; expect(readwiseInputFromItem(item)).toEqual({ @@ -47,7 +90,82 @@ describe('readwiseInputFromItem', () => { title: 'Example post', summary: 'Readable excerpt', tags: ['ai', 'reading'], - savedUsing: 'tldr' + savedUsing: 'tldr', + documentId: 'doc-1' + }); + }); +}); + +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.ts b/src/background/tabs.ts index a11e7b8..709cb06 100644 --- a/src/background/tabs.ts +++ b/src/background/tabs.ts @@ -6,8 +6,7 @@ export interface ExtractedContent { } export async function extractFromActiveTab(tabId: number): Promise { - // Inject into the isolated world so a real Readability build can attach to this world. - // static/readability.js ships as a stub until replaced with Mozilla Readability. + // Inject the bundled Mozilla Readability build into the isolated world. try { await chrome.scripting.executeScript({ target: { tabId }, files: ['readability.js'] }); } catch { diff --git a/src/common/item-index.test.ts b/src/common/item-index.test.ts new file mode 100644 index 0000000..c7da6a9 --- /dev/null +++ b/src/common/item-index.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import type { Item } from './types'; +import { applyItemToIndex, buildItemIndex, emptyItemIndex, isStoredItem } from './item-index'; + +function item(partial: Partial & Pick): Item { + return { + domain: 'example.test', + title: partial.title || partial.url, + tags: [], + status: 'tagged', + ...partial + }; +} + +describe('isStoredItem', () => { + it('rejects secrets and tag maps', () => { + expect(isStoredItem('sk-secret')).toBe(false); + expect(isStoredItem({ ai: { slug: 'ai', count: 1 } })).toBe(false); + }); +}); + +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.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/storage.test.ts b/src/common/storage.test.ts new file mode 100644 index 0000000..d8d407b --- /dev/null +++ b/src/common/storage.test.ts @@ -0,0 +1,101 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import type { Item } from './types'; +import { ITEM_INDEX_KEY } from './item-index'; +import { findItemByUrl, getItem, listItems, 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' } + }); + }); +}); diff --git a/src/common/storage.ts b/src/common/storage.ts index 974e089..94752ad 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 }, @@ -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..8ec8d40 --- /dev/null +++ b/src/common/tags-input.test.ts @@ -0,0 +1,8 @@ +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']); + }); +}); 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 830a75b..c514082 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -39,6 +39,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.ts b/src/content/toast.ts new file mode 100644 index 0000000..ac106fa --- /dev/null +++ b/src/content/toast.ts @@ -0,0 +1,223 @@ +import { hasCaptureError, type CaptureDestinationResult, type CaptureResult } from '@common/capture'; +import { RETRY_DESTINATION, SHOW_CAPTURE, UPDATE_TAGS } from '@common/messages'; +import { parseTagInput } from '@common/tags-input'; + +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 chipColor(status: string): string { + if (status === 'success') return '#0f766e'; + if (status === 'skipped') return '#64748b'; + return '#b45309'; +} + +function visibleDestinations(destinations: CaptureDestinationResult[]): CaptureDestinationResult[] { + return destinations.filter((destination) => destination.status !== 'skipped' || destination.message.startsWith('Already') || destination.message === 'Tags updated'); +} + +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 visibleDestinations(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:${chipColor(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/static/readability.js b/static/readability.js deleted file mode 100644 index 98103cf..0000000 --- a/static/readability.js +++ /dev/null @@ -1,4 +0,0 @@ -// Placeholder for Mozilla Readability. Replace this file with the browser build to -// attach window.Readability in the isolated world. Extraction falls back to -// article/main text, then document.body.innerText. - From 55eb5080d5e74e008f44350bc8d4bd90f7d46071 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 30 Aug 2026 15:46:21 +0000 Subject: [PATCH 3/4] test: add unit tests for exported capture and sync functions Cover each exported helper in background, common, toast, and settings preview code, including LLM, Pinboard, Readwise, exporters, and storage. Co-authored-by: i.an --- src/background/exporters.test.ts | 97 +++++++++++++ src/background/index.ts | 38 +---- src/background/llm.test.ts | 184 ++++++++++++++++++++++++ src/background/llm.ts | 2 +- src/background/permissions.test.ts | 50 +++++++ src/background/pinboard-tags.test.ts | 6 + src/background/pinboard.test.ts | 207 +++++++++++++++++++++++++++ src/background/pipeline.test.ts | 145 ++++++++++++++++++- src/background/preview-draft.test.ts | 46 ++++++ src/background/preview-draft.ts | 36 +++++ src/background/readwise.test.ts | 142 ++++++++++++++++++ src/background/tabs.test.ts | 46 ++++++ src/background/tags.test.ts | 7 +- src/common/capture.test.ts | 19 +++ src/common/html.test.ts | 5 + src/common/item-index.test.ts | 27 +++- src/common/messages.test.ts | 10 ++ src/common/preview.test.ts | 55 +++++++ src/common/storage.test.ts | 137 +++++++++++++++++- src/common/tags-input.test.ts | 5 + src/content/toast-helpers.test.ts | 30 ++++ src/content/toast-helpers.ts | 15 ++ src/content/toast.ts | 17 +-- src/ui/options-helpers.test.ts | 42 ++++++ src/ui/options-helpers.ts | 28 ++++ src/ui/options.ts | 28 +--- 26 files changed, 1342 insertions(+), 82 deletions(-) create mode 100644 src/background/exporters.test.ts create mode 100644 src/background/llm.test.ts create mode 100644 src/background/permissions.test.ts create mode 100644 src/background/pinboard.test.ts create mode 100644 src/background/preview-draft.test.ts create mode 100644 src/background/preview-draft.ts create mode 100644 src/background/tabs.test.ts create mode 100644 src/common/messages.test.ts create mode 100644 src/content/toast-helpers.test.ts create mode 100644 src/content/toast-helpers.ts create mode 100644 src/ui/options-helpers.test.ts create mode 100644 src/ui/options-helpers.ts 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/index.ts b/src/background/index.ts index 6f60d65..a5779e6 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -1,47 +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, retryDestination, updateCapturedTags } from './pipeline'; import { importTagsFromPinboard, listRecentFromPinboard } from './pinboard'; import { exportToGoodlinks, exportToReadwise } from './exporters'; import { ensureOriginPermission } from './permissions'; - -type PreviewDraft = { - llmBaseUrl?: string; - llmModel?: string; - llmMaxChars?: number; - privacyMode?: PrivacyMode; - pinboardConfigured?: 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 { knownTagNames, settingsWithPreviewDraft, type PreviewDraft } from './preview-draft'; async function previewCurrentTab(draft?: PreviewDraft) { const [tab] = await chrome.tabs.query({ active: true, currentWindow: 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/pinboard-tags.test.ts b/src/background/pinboard-tags.test.ts index a644d5b..7a79470 100644 --- a/src/background/pinboard-tags.test.ts +++ b/src/background/pinboard-tags.test.ts @@ -14,4 +14,10 @@ describe('mergePinboardTagCounts', () => { 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/pipeline.test.ts b/src/background/pipeline.test.ts index a661dd2..a822ff2 100644 --- a/src/background/pipeline.test.ts +++ b/src/background/pipeline.test.ts @@ -25,11 +25,13 @@ vi.mock('./readwise', () => ({ 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, updateCapturedTags } from './pipeline'; +import { captureAndSync, retryDestination, tagAndMaybeSync, updateCapturedTags } from './pipeline'; const settings: Settings = { llm: { baseUrl: 'https://llm.example.test/v1', model: 'test', jsonMode: true, maxChars: 1000 }, @@ -142,6 +144,91 @@ describe('captureAndSync', () => { }); 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('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', () => { @@ -174,6 +261,36 @@ describe('retryDestination', () => { 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', () => { @@ -208,4 +325,30 @@ describe('updateCapturedTags', () => { 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/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..dd66ee1 --- /dev/null +++ b/src/background/preview-draft.ts @@ -0,0 +1,36 @@ +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; + 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 019e659..8d421db 100644 --- a/src/background/readwise.test.ts +++ b/src/background/readwise.test.ts @@ -1,11 +1,21 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import type { Item } from '@common/types'; + +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'; @@ -47,6 +57,22 @@ 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/'); }); }); @@ -69,6 +95,14 @@ describe('parseReadwiseDocumentResponse', () => { alreadyExists: false }); }); + + it('ignores non-object payloads', () => { + expect(parseReadwiseDocumentResponse(201, 'nope')).toEqual({ + id: undefined, + url: undefined, + alreadyExists: false + }); + }); }); describe('readwiseInputFromItem', () => { @@ -94,6 +128,114 @@ describe('readwiseInputFromItem', () => { 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', () => { 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/tags.test.ts b/src/background/tags.test.ts index 8466c3a..8a62eed 100644 --- a/src/background/tags.test.ts +++ b/src/background/tags.test.ts @@ -26,8 +26,9 @@ describe('slugify', () => { }); describe('similarity', () => { - it('scores exact slugs at 100', () => { + it('scores exact slugs at 100 and different slugs lower', () => { expect(similarity('machine-learning', 'machine-learning')).toBe(100); + expect(similarity('ai', 'climate')).toBeLessThan(50); }); }); @@ -43,4 +44,8 @@ describe('canonicalizeTags', () => { 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/common/capture.test.ts b/src/common/capture.test.ts index b348cd5..c644ef6 100644 --- a/src/common/capture.test.ts +++ b/src/common/capture.test.ts @@ -59,4 +59,23 @@ describe('hasCaptureError', () => { 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/html.test.ts b/src/common/html.test.ts index 1a88a19..5f9725d 100644 --- a/src/common/html.test.ts +++ b/src/common/html.test.ts @@ -7,6 +7,10 @@ describe('escapeHtml', () => { '<img src=x onerror="alert('xss')">' ); }); + + it('leaves plain text unchanged', () => { + expect(escapeHtml('hello')).toBe('hello'); + }); }); describe('safeHttpUrl', () => { @@ -15,6 +19,7 @@ describe('safeHttpUrl', () => { }); 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/item-index.test.ts b/src/common/item-index.test.ts index c7da6a9..a55724e 100644 --- a/src/common/item-index.test.ts +++ b/src/common/item-index.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import type { Item } from './types'; -import { applyItemToIndex, buildItemIndex, emptyItemIndex, isStoredItem } from './item-index'; +import { applyItemToIndex, buildItemIndex, emptyItemIndex, isItemIndex, isStoredItem, itemKey } from './item-index'; function item(partial: Partial & Pick): Item { return { @@ -12,10 +12,33 @@ function item(partial: Partial & Pick): }; } +describe('itemKey', () => { + it('prefixes item ids', () => { + expect(itemKey('abc')).toBe('item:abc'); + }); +}); + describe('isStoredItem', () => { - it('rejects secrets and tag maps', () => { + 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: {} }); }); }); 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/preview.test.ts b/src/common/preview.test.ts index 49c6833..d50e6c9 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', () => { @@ -136,6 +178,19 @@ describe('buildDestinationPreviews', () => { receives: [] }); }); + + 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: [] + }); + }); }); describe('buildDataPreview', () => { diff --git a/src/common/storage.test.ts b/src/common/storage.test.ts index d8d407b..40bf9c4 100644 --- a/src/common/storage.test.ts +++ b/src/common/storage.test.ts @@ -1,7 +1,20 @@ import { beforeEach, describe, expect, it } from 'vitest'; import type { Item } from './types'; import { ITEM_INDEX_KEY } from './item-index'; -import { findItemByUrl, getItem, listItems, upsertItem } from './storage'; +import { + findItemByUrl, + getItem, + getSecret, + getSettings, + getSyncRecord, + getTags, + listItems, + setSecret, + setSettings, + setSyncRecord, + updateTags, + upsertItem +} from './storage'; function createMemoryArea() { let data: Record = {}; @@ -98,4 +111,126 @@ describe('item storage index', () => { 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.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/tags-input.test.ts b/src/common/tags-input.test.ts index 8ec8d40..0d3ba01 100644 --- a/src/common/tags-input.test.ts +++ b/src/common/tags-input.test.ts @@ -5,4 +5,9 @@ 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/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 index ac106fa..2004820 100644 --- a/src/content/toast.ts +++ b/src/content/toast.ts @@ -1,6 +1,7 @@ -import { hasCaptureError, type CaptureDestinationResult, type CaptureResult } from '@common/capture'; +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 { @@ -30,16 +31,6 @@ function sendMessage(message: unknown): Promise { }); } -function chipColor(status: string): string { - if (status === 'success') return '#0f766e'; - if (status === 'skipped') return '#64748b'; - return '#b45309'; -} - -function visibleDestinations(destinations: CaptureDestinationResult[]): CaptureDestinationResult[] { - return destinations.filter((destination) => destination.status !== 'skipped' || destination.message.startsWith('Already') || destination.message === 'Tags updated'); -} - function clearTimer(): void { if (dismissTimer) { window.clearTimeout(dismissTimer); @@ -96,13 +87,13 @@ function renderToast(capture: CaptureResult): void { const chips = document.createElement('div'); chips.style.cssText = 'display:flex;align-items:center;gap:5px;flex-wrap:wrap'; - for (const dest of visibleDestinations(capture.destinations)) { + 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:${chipColor(dest.status)};background:rgba(248,250,252,.92);font-weight:600;font:inherit;cursor:${chip.tagName === 'BUTTON' ? 'pointer' : 'default'}`; + 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 () => { 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', '