Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
name: CI

on:
push:
branches: [main]
pull_request:

jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup PNPM
uses: pnpm/action-setup@v3
with:
version: 10

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm

- name: Install deps
run: pnpm install --frozen-lockfile

- name: Typecheck
run: pnpm typecheck

- name: Test
run: pnpm test

- name: Build
run: pnpm build
8 changes: 7 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
- name: Setup PNPM
uses: pnpm/action-setup@v3
with:
version: 9
version: 10

- name: Setup Node
uses: actions/setup-node@v4
Expand All @@ -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

Expand Down
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,4 @@ node_modules/
dist/
.DS_Store

ui/

14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,26 +13,32 @@
- **Inspire new ideas.** Beyond summaries, the assistant can remix your saved content: propose blog outlines, connect disparate articles, prompt you with “What if…” questions—anything that nudges you to create rather than collect.

### Feature List
- Capture the current tab and extract readable content with the built-in readability fallback.
- Capture the current tab and extract readable content with bundled Mozilla Readability, falling back to `article`/`main` text.
- Auto-tag new items by calling an OpenAI-compatible `chat/completions` endpoint that you configure at runtime—no hard-coded hosts, no forced vendors.
- Optionally save new captures to Pinboard and/or Readwise Reader. Import Pinboard tags, and export selected items to GoodLinks or Readwise.
- Readwise Reader creates or updates the existing document, including tags.
- After save, a page toast can retry a failed destination and edit tags.
- Store everything locally; no backend service required.
- Navigate a full options dashboard for configuring LLM models, privacy modes, tag behaviour, and integrations.
- Inspect the Options-page Data Preview before saving: it shows local storage, Chrome sync storage, configured outbound API destinations, privacy-mode payload fields, and screenshot-safe secret states.

### Getting Started
- Install Node 18+.
- `npm install` to pull dependencies.
- `npm run build` for a one-off bundle (or `npm run watch` while hacking).
- Install Node 18+ and [pnpm](https://pnpm.io/).
- `pnpm install` to pull dependencies.
- `pnpm test` and `pnpm typecheck` for the current automated checks.
- `pnpm build` for a one-off bundle (or `pnpm watch` while hacking).
- Load the unpacked extension from the freshly created `dist/` directory in Chrome.
- Hop into the Options page to plug in your LLM base URL, model name, and optional API key. Paste Pinboard and Readwise tokens only if you want those destinations, then use the capture checkboxes to enable or skip them on save.
- Page extraction uses the bundled Mozilla Readability library, then `article`/`main` text, then `document.body.innerText`.

### Packaging & Release Ritual
- Bump version in `package.json` (the build script mirrors it into `dist/manifest.json` automatically).
- `npm run build` then `npm run package` (see `scripts/package.mjs`) to produce `tldr-v<version>.zip`.
- 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<version> && 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.
Expand Down
4 changes: 2 additions & 2 deletions docs/release/chrome-web-store-publishing-checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand All @@ -15,5 +16,8 @@
"esbuild": "^0.21.5",
"typescript": "^5.5.4",
"vitest": "^4.1.8"
},
"dependencies": {
"@mozilla/readability": "^0.6.0"
}
}
10 changes: 10 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
packages:
- '.'
allowBuilds:
esbuild: true

83 changes: 55 additions & 28 deletions scripts/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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); });
97 changes: 97 additions & 0 deletions src/background/exporters.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>;

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);
});
});
4 changes: 2 additions & 2 deletions src/background/exporters.ts
Original file line number Diff line number Diff line change
@@ -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[] };

Expand Down Expand Up @@ -29,7 +29,7 @@ export async function exportToReadwise(items: SimpleItem[]): Promise<number> {
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 || [],
Expand Down
Loading
Loading