From f16ee9834a8d6e46d5f5bded86246e405d5e942e Mon Sep 17 00:00:00 2001 From: NiallJoeMaher Date: Mon, 26 Oct 2026 22:49:41 +0000 Subject: [PATCH] A Note queued offline is made once, even if the page closes mid-send; no flaky e2e; dead helpers and launch-placeholder notes gone Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Per4f8yL5A7nL9frZVDJj7 --- apps/extension/store/listing.md | 4 ++-- apps/web/app/(site)/open-source/page.tsx | 4 ++-- apps/web/components/TopBar.tsx | 10 +++++----- apps/web/lib/library/items.ts | 6 +++--- apps/web/lib/library/sharing.ts | 2 +- apps/web/lib/site.ts | 3 +-- docs/architecture.md | 2 +- e2e/extension.spec.ts | 1 + packages/client/src/queue.ts | 2 +- packages/db/src/account.test.ts | 2 +- packages/db/src/backfill.test.ts | 2 +- packages/db/src/collections.ts | 2 ++ packages/db/src/digest.test.ts | 10 +++++----- packages/db/src/expiry.test.ts | 8 ++++---- packages/db/src/items.test.ts | 18 +++++++++++++++--- packages/db/src/items.ts | 20 ++++++++++++++++---- packages/db/src/lists.test.ts | 6 +++--- packages/db/src/search.test.ts | 8 ++++---- packages/db/src/triage.test.ts | 6 +++--- packages/shared-schemas/src/format.ts | 17 +---------------- packages/shared-schemas/src/items.ts | 2 ++ 21 files changed, 74 insertions(+), 61 deletions(-) diff --git a/apps/extension/store/listing.md b/apps/extension/store/listing.md index 7c64e1b..db1214c 100644 --- a/apps/extension/store/listing.md +++ b/apps/extension/store/listing.md @@ -47,7 +47,7 @@ Needs a free Pluck account at https://getpluck.io. **Homepage URL:** https://getpluck.io -**Support URL:** https://github.com/codu-code/pluck/issues (people open an issue and we pick it up). Needs the repo public; leave it empty until then +**Support URL:** https://getpluck.io/support ## Privacy practices @@ -86,6 +86,6 @@ Tick all three certifications: not sold to third parties, not used for purposes ## Distribution -- **Visibility:** Unlisted for the first upload (only people with the link can install it), Public once it is checked. +- **Visibility:** Public. - **Regions:** all. - **Test instructions** (for the reviewer, under Account or Test instructions): "Sign in with the email link at https://getpluck.io using the test account below, then click the Pluck toolbar button on any article." Give a test account whose inbox the reviewer can read, or an API key: Options → "Use an API key instead" with a `pk_live_…` key made in Settings, API keys. diff --git a/apps/web/app/(site)/open-source/page.tsx b/apps/web/app/(site)/open-source/page.tsx index ec3854c..4fa6a9b 100644 --- a/apps/web/app/(site)/open-source/page.tsx +++ b/apps/web/app/(site)/open-source/page.tsx @@ -1,7 +1,7 @@ import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; import Link from "@/components/SiteLink"; -import { CLONE_URL, REPO_URL, pageMeta } from "@/lib/site"; +import { REPO_URL, pageMeta } from "@/lib/site"; import { SitePage } from "../SiteChrome"; import styles from "../site.module.css"; @@ -28,7 +28,7 @@ export default async function OpenSource() {
# {REPO_URL} - git clone {CLONE_URL} + git clone {REPO_URL} cd pluck docker compose up -d {t("terminalComment")} diff --git a/apps/web/components/TopBar.tsx b/apps/web/components/TopBar.tsx index d957334..54aef57 100644 --- a/apps/web/components/TopBar.tsx +++ b/apps/web/components/TopBar.tsx @@ -39,9 +39,9 @@ function writeQueue(q: Queued[]) { } } -// One flush at a time, across every open tab: mount, the online event and a re-render can all start one, and two -// walking the same queue would send a queued Note twice (a link is safe either way: plucking it again never makes a -// second Item). A Web Lock where the browser has one, else one per page. +// One flush at a time, across every open tab: mount, the online event and a re-render can all start one. A page that +// closes mid-send leaves its pluck queued though it may have landed, so each carries a clientKey and a resend of a +// Note never makes a second one (a link is safe either way). A Web Lock where the browser has one, else one per page. let flushing: Promise | null = null; const post = (body: Record) => @@ -93,7 +93,7 @@ export function TopBar({ sample, emphasis = false, onPluck, onPlucked, onFailed if (!text) return; setError(null); setNotice(null); - const draft: PluckDraft = { key: `${Date.now()}`, urls, text: isText ? text : null, kind: isText ? kind : null }; + const draft: PluckDraft = { key: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`, urls, text: isText ? text : null, kind: isText ? kind : null }; const body = isText ? { text, kind, source: "web", ...(due && kind !== "note" ? { dueAt: new Date(`${due}T09:00:00`).toISOString() } : {}) } : { text, source: "web" }; @@ -105,7 +105,7 @@ export function TopBar({ sample, emphasis = false, onPluck, onPlucked, onFailed if (!navigator.onLine) throw new TypeError("offline"); res = await post(body); } catch { - writeQueue([...readQueue(), { draft, body }]); + writeQueue([...readQueue(), { draft, body: { ...body, clientKey: draft.key } }]); // the key makes a resend safe setNotice(t("queued")); return; } diff --git a/apps/web/lib/library/items.ts b/apps/web/lib/library/items.ts index f38759c..0274ca9 100644 --- a/apps/web/lib/library/items.ts +++ b/apps/web/lib/library/items.ts @@ -21,11 +21,11 @@ const LINKS_PER_HOUR = 2000; export async function pluckAny(userId: string, body: CreateItemBody, { defer }: { defer?: Defer } = {}): Promise { const source = body.source ?? "web"; if (body.text !== undefined && (isTextKind(body.kind) || extractUrls(body.text).length === 0)) { - const item = await createTextItem(userId, { + const result = await createTextItem(userId, { kind: isTextKind(body.kind) ? body.kind : "note", text: body.text, title: body.title, source, - dueAt: body.dueAt ? new Date(body.dueAt) : null, + dueAt: body.dueAt ? new Date(body.dueAt) : null, clientKey: body.clientKey, }); - return { results: [{ item, created: true }], rejected: [] }; + return { results: [result], rejected: [] }; } const inputs = body.url !== undefined ? [body.url] : (body.urls ?? extractUrls(body.text!)); const budget = await hitRateLimit(`links:${userId}`, LINKS_PER_HOUR, 3600, inputs.length); diff --git a/apps/web/lib/library/sharing.ts b/apps/web/lib/library/sharing.ts index 3f44ab4..c7c8aad 100644 --- a/apps/web/lib/library/sharing.ts +++ b/apps/web/lib/library/sharing.ts @@ -91,7 +91,7 @@ export async function copyList(userId: string, listId: string, { defer }: { defe if (needsExtraction) toRead.push(item); entries.push({ itemId: item.id, note: e.note }); } else { - const note = await createTextItem(userId, { kind: "note", text: [e.title, e.excerpt].filter(Boolean).join("\n"), source: "copied" }); + const { item: note } = await createTextItem(userId, { kind: "note", text: [e.title, e.excerpt].filter(Boolean).join("\n"), source: "copied" }); entries.push({ itemId: note.id, note: e.note }); } } diff --git a/apps/web/lib/site.ts b/apps/web/lib/site.ts index 11e455a..99db757 100644 --- a/apps/web/lib/site.ts +++ b/apps/web/lib/site.ts @@ -4,9 +4,8 @@ import { MCP_RESOURCE } from "./auth"; import { siteAlternates } from "./siteUrls"; import { ORIGIN } from "./origin"; -// Links the marketing site needs that only exist at launch. Placeholders stay [LIKE THIS] until then. +// Where the marketing site sends people. export const REPO_URL = "https://github.com/codu-code/pluck"; -export const CLONE_URL = "https://github.com/codu-code/pluck"; // shortcut: the TestFlight beta page while the app is in public beta; swap in the App Store URL once it is live. export const APP_STORE_URL = "/iphone"; // The public beta: anyone with the link can join (up to the limit set in App Store Connect, TestFlight, Public beta). diff --git a/docs/architecture.md b/docs/architecture.md index dd782a1..3510183 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -119,7 +119,7 @@ These are the places where the code is shallower than it needs to be: a rule wri ### 7. Row words: one set of rules, translated at the edge (S to M) -- **Files:** `packages/shared-schemas/src/format.ts` (`when`, `ago`, `dueLabel`, `rowSite`, `rowMeta`, `sourceLabel`, `MOVED`: English, used only by tests now), `apps/web/components/useFormat.ts`, `apps/ios/lib/format.ts` (`rowText`), and an inline `ago` in `apps/web/app/p/[handle]/[slug]/page.tsx`. +- **Files:** `packages/shared-schemas/src/format.ts` (`when`, `ago`, `dueLabel`, `rowSite`, `rowMeta`), `apps/web/components/useFormat.ts`, `apps/ios/lib/format.ts` (`rowText`), and an inline `ago` in `apps/web/app/p/[handle]/[slug]/page.tsx`. - **Problem:** "5 min ago", "Due Friday" and the row's site and meta line are worked out in four places. The English versions in `shared-schemas` survive as a test oracle for the iPhone app, which makes them look used. - **Shape:** `shared-schemas` returns a message key and its values instead of words (for example `span(iso, now)` gives `["minutes", 5]`), plus `dueSpan`, `metaOf(item)` and `siteOf(item)`. Each app turns those into words with its own translator. One table test in `shared-schemas` covers the branching; delete the English copies. diff --git a/e2e/extension.spec.ts b/e2e/extension.spec.ts index 6569f28..4f529a3 100644 --- a/e2e/extension.spec.ts +++ b/e2e/extension.spec.ts @@ -191,6 +191,7 @@ test("signing in with OAuth: consent once, then the popup plucks with the app's // Signing out revokes the refresh token and the popup asks to sign in again. await options.getByRole("button", { name: "Sign out" }).click(); + await expect(options.getByText("Signed out.")).toBeVisible(); // the revoke and the cleared tokens are done const after = await popupFor(context, sw, extId, `${FIX}/news-2.html`); await expect(after.getByRole("button", { name: "Sign in" })).toBeVisible(); }); diff --git a/packages/client/src/queue.ts b/packages/client/src/queue.ts index 1944ec1..fe11fbc 100644 --- a/packages/client/src/queue.ts +++ b/packages/client/src/queue.ts @@ -24,7 +24,7 @@ export function createQueue(store: QueueStore) { try { for (const q of await store.read()) { try { - await send(q.body); + await send(q.id ? { ...q.body, clientKey: q.id } : q.body); // the key makes a resend safe } catch (e) { if (!(e instanceof Refused)) break; } diff --git a/packages/db/src/account.test.ts b/packages/db/src/account.test.ts index fc7e040..3449554 100644 --- a/packages/db/src/account.test.ts +++ b/packages/db/src/account.test.ts @@ -54,7 +54,7 @@ test("export gathers everything; delete removes one person entirely and leaves t await createHighlight(a, { itemId: link.id, quote: "q", selector: { textQuote: { exact: "q", prefix: "", suffix: "" }, textPosition: { start: 0, end: 1 } } }); await createApiKey(a, "k"); await aliasOf(a); - const theirs = await createTextItem(b, { kind: "note", text: "Theirs", source: "web" }); + const { item: theirs } = await createTextItem(b, { kind: "note", text: "Theirs", source: "web" }); const data = await exportData(a); expect(data.items).toHaveLength(2); diff --git a/packages/db/src/backfill.test.ts b/packages/db/src/backfill.test.ts index 2b982f4..073e4e3 100644 --- a/packages/db/src/backfill.test.ts +++ b/packages/db/src/backfill.test.ts @@ -12,7 +12,7 @@ test("the Preview backfill walks every link read before Previews once, then stop await (await items()).updateOne({ _id: oid(old.id) }, { $unset: { previewGuess: "", facts: "" } }); // as it was before const sorted = (await pluckLink(b, { url: "https://example.com/after-previews", source: "web" })).item; await applyExtraction(b, sorted.id, { ...read, kind: "link", preview: "site", facts: {} }); - const note = await createTextItem(a, { kind: "note", text: "no link", source: "web" }); + const { item: note } = await createTextItem(a, { kind: "note", text: "no link", source: "web" }); await (await meta()).deleteMany({ _id: { $in: ["backfill:previews", "backfill:posts-2026-10", "backfill:app-links-2026-10"] } }); const seen: string[] = []; diff --git a/packages/db/src/collections.ts b/packages/db/src/collections.ts index 158e89e..12f6e61 100644 --- a/packages/db/src/collections.ts +++ b/packages/db/src/collections.ts @@ -10,6 +10,7 @@ export type ItemDoc = { kind: Kind; url: string | null; urlHash?: string; // absent for notes, todos and things + clientKey?: string; // a note, todo or thing sent from a queue: its id there, so a resend finds this one canonicalUrl: string | null; title: string; titleIsDerived: boolean; @@ -204,6 +205,7 @@ export function db(): Promise { { key: { userId: 1, status: 1, createdAt: -1, _id: -1 } }, { key: { userId: 1, status: 1, lastTouchedAt: 1, _id: 1 } }, { key: { userId: 1, urlHash: 1 }, unique: true, partialFilterExpression: { urlHash: { $type: "string" } } }, + { key: { userId: 1, clientKey: 1 }, unique: true, partialFilterExpression: { clientKey: { $type: "string" } } }, { key: { userId: 1, tagIds: 1, createdAt: -1, _id: -1 } }, { key: { userId: 1, kind: 1, dueAt: 1 } }, { key: { "extraction.state": 1, updatedAt: 1 } }, diff --git a/packages/db/src/digest.test.ts b/packages/db/src/digest.test.ts index e8a7ac8..7653aab 100644 --- a/packages/db/src/digest.test.ts +++ b/packages/db/src/digest.test.ts @@ -8,15 +8,15 @@ const touch = async (id: string, daysAgo: number) => (await items()).updateOne({ test("the digest: expiring within a week (not in a list), due this week, oldest inbox, quiet lists", async () => { const { a, b } = await twoUsers(); - const expiring = await createTextItem(a, { kind: "note", text: "Expiring", source: "web" }); - const listed = await createTextItem(a, { kind: "note", text: "Listed", source: "web" }); - const tooOld = await createTextItem(a, { kind: "note", text: "Already past", source: "web" }); + const { item: expiring } = await createTextItem(a, { kind: "note", text: "Expiring", source: "web" }); + const { item: listed } = await createTextItem(a, { kind: "note", text: "Listed", source: "web" }); + const { item: tooOld } = await createTextItem(a, { kind: "note", text: "Already past", source: "web" }); await touch(expiring.id, 25); await touch(listed.id, 25); await touch(tooOld.id, 31); - const due = await createTextItem(a, { kind: "todo", text: "Due Friday", source: "web", dueAt: new Date(Date.now() + 3 * DAY) }); + const { item: due } = await createTextItem(a, { kind: "todo", text: "Due Friday", source: "web", dueAt: new Date(Date.now() + 3 * DAY) }); await createTextItem(a, { kind: "todo", text: "Due next month", source: "web", dueAt: new Date(Date.now() + 30 * DAY) }); - const doneTodo = await createTextItem(a, { kind: "todo", text: "Done already", source: "web", dueAt: new Date(Date.now() + DAY) }); + const { item: doneTodo } = await createTextItem(a, { kind: "todo", text: "Done already", source: "web", dueAt: new Date(Date.now() + DAY) }); await editItem(a, doneTodo.id, { status: "done" }); const list = await createList(a, { name: "Chapter 3", createdBy: "user" }); await addToList(a, list.id, [listed.id], { by: "user" }); diff --git a/packages/db/src/expiry.test.ts b/packages/db/src/expiry.test.ts index e38e7f6..0c299eb 100644 --- a/packages/db/src/expiry.test.ts +++ b/packages/db/src/expiry.test.ts @@ -14,9 +14,9 @@ describe("expiry", () => { test("moves untouched items to the Archive, never items in a list, and respects scope", async () => { const { a } = await twoUsers(); const old = (await pluckLink(a, { url: "https://example.com/old", source: "web" })).item; - const later = await createTextItem(a, { kind: "note", text: "kept for later", source: "web" }); - const listed = await createTextItem(a, { kind: "note", text: "in a list", source: "web" }); - const fresh = await createTextItem(a, { kind: "note", text: "fresh", source: "web" }); + const { item: later } = await createTextItem(a, { kind: "note", text: "kept for later", source: "web" }); + const { item: listed } = await createTextItem(a, { kind: "note", text: "in a list", source: "web" }); + const { item: fresh } = await createTextItem(a, { kind: "note", text: "fresh", source: "web" }); const list = await createList(a, { name: "Plan", createdBy: "user" }); await addToList(a, list.id, [listed.id], { by: "user" }); await (await items()).updateOne({ _id: oid(later.id) }, { $set: { status: "later" } }); @@ -58,7 +58,7 @@ describe("expiry", () => { test("expiring-soon counts the week before, and local dates follow the timezone", async () => { const { a } = await twoUsers(); - const it = await createTextItem(a, { kind: "note", text: "soon", source: "web" }); + const { item: it } = await createTextItem(a, { kind: "note", text: "soon", source: "web" }); await age(it.id, 25); expect(await countExpiringSoon(a, { ...DEFAULT_SETTINGS }, ["inbox"])).toBe(1); expect(await countExpiringSoon(a, { ...DEFAULT_SETTINGS, expiryDays: null }, ["inbox"])).toBe(0); diff --git a/packages/db/src/items.test.ts b/packages/db/src/items.test.ts index 6652c97..d8b1f6c 100644 --- a/packages/db/src/items.test.ts +++ b/packages/db/src/items.test.ts @@ -106,7 +106,7 @@ test("a malformed id finds nothing", async () => { describe("notes, todos and things", () => { test("a note takes its title from the first line until a title is typed, and back again when cleared", async () => { const { a, b } = await twoUsers(); - const note = await createTextItem(a, { kind: "note", text: "Kitchen plan\nHob under the window.", source: "web" }); + const { item: note } = await createTextItem(a, { kind: "note", text: "Kitchen plan\nHob under the window.", source: "web" }); expect(note).toMatchObject({ kind: "note", url: null, title: "Kitchen plan", titleIsDerived: true, excerpt: "Hob under the window." }); expect(note.extraction.state).toBe("none"); expect((await getContent(a, note.id))?.textMarkdown).toBe("Kitchen plan\nHob under the window."); @@ -124,14 +124,26 @@ describe("notes, todos and things", () => { test("kind and due date change, and notes have no URL hash so many can exist", async () => { const { a } = await twoUsers(); - const one = await createTextItem(a, { kind: "note", text: "same", source: "web" }); - const two = await createTextItem(a, { kind: "note", text: "same", source: "web" }); + const { item: one } = await createTextItem(a, { kind: "note", text: "same", source: "web" }); + const { item: two } = await createTextItem(a, { kind: "note", text: "same", source: "web" }); expect(one.id).not.toBe(two.id); const due = new Date("2026-11-03T09:00:00.000Z"); const todo = await editItem(a, one.id, { kind: "todo", dueAt: due }); expect(todo).toMatchObject({ kind: "todo", dueAt: due.toISOString() }); expect((await editItem(a, one.id, { dueAt: null }))?.dueAt).toBeNull(); }); + + test("a resent note with the same key is made once, even sent twice at once; another person's key is theirs", async () => { + const { a, b } = await twoUsers(); + const send = (u: string) => createTextItem(u, { kind: "note", text: "Offline", source: "web", clientKey: "k-1" }); + const [first, again] = await Promise.all([send(a), send(a)]); + expect([first.created, again.created].sort()).toEqual([false, true]); + expect(again.item.id).toBe(first.item.id); + expect((await send(a)).created).toBe(false); + const theirs = await send(b); + expect(theirs.created).toBe(true); + expect(theirs.item.id).not.toBe(first.item.id); + }); }); test("findByUrl finds only the person's own live item", async () => { diff --git a/packages/db/src/items.ts b/packages/db/src/items.ts index b751f18..e322a9b 100644 --- a/packages/db/src/items.ts +++ b/packages/db/src/items.ts @@ -124,10 +124,11 @@ export async function createFileItem(userId: string, input: { kind: "image" | "p return toItem((await col.findOne({ _id }))!); } -export type TextItemInput = { kind: TextKind; text: string; title?: string | null; dueAt?: Date | null; source: Source }; +export type TextItemInput = { kind: TextKind; text: string; title?: string | null; dueAt?: Date | null; source: Source; clientKey?: string }; // A Note, Todo or Thing: no URL, no extraction, the body in contents with extractor "user". -export async function createTextItem(userId: string, input: TextItemInput): Promise { +// With a clientKey it is made once: a resend of the same key (its response was lost) gets the first one back. +export async function createTextItem(userId: string, input: TextItemInput): Promise<{ item: Item; created: boolean }> { const now = new Date(); const uid = oid(userId); const typed = input.title?.trim(); @@ -138,12 +139,23 @@ export async function createTextItem(userId: string, input: TextItemInput): Prom dueAt: input.dueAt ?? null, tagIds: [], note: null, source: input.source, readingProgress: 0, wordCount: null, lang: null, extraction: { state: "none", attempts: 0, lastError: null }, createdAt: now, updatedAt: now, lastOpenedAt: null, lastTouchedAt: now, statusChangedAt: now, deletedAt: null, searchBody: bodyForSearch(input.text), + ...(input.clientKey ? { clientKey: input.clientKey } : {}), }; - await (await items()).insertOne(doc); + const col = await items(); + const sent = async () => (input.clientKey ? await col.findOne({ userId: uid, clientKey: input.clientKey }) : null); + const first = await sent(); + if (first) return { item: toItem(first), created: false }; + try { + await col.insertOne(doc); + } catch (e) { + const raced = (e as { code?: number }).code === 11000 ? await sent() : null; // the same key, sent twice at once + if (raced) return { item: toItem(raced), created: false }; + throw e; + } await (await contents()).insertOne({ _id: new ObjectId(), itemId: doc._id, userId: uid, textMarkdown: input.text, extractor: "user", bytes: Buffer.byteLength(input.text), }); - return toItem(doc); + return { item: toItem(doc), created: true }; } export type ItemEdit = { diff --git a/packages/db/src/lists.test.ts b/packages/db/src/lists.test.ts index b3a82d6..9f5721f 100644 --- a/packages/db/src/lists.test.ts +++ b/packages/db/src/lists.test.ts @@ -5,7 +5,7 @@ import { } from "./index.ts"; import { twoUsers } from "./testing.ts"; -const note = async (u: string, text: string) => createTextItem(u, { kind: "note", text, source: "web" }); +const note = async (u: string, text: string) => (await createTextItem(u, { kind: "note", text, source: "web" })).item; describe("lists", () => { test("are per person, ordered, with a note and a tick that belong to the list", async () => { @@ -74,7 +74,7 @@ describe("lists", () => { test("a Todo's mark and its Done stay in step, in every List, for its owner only", async () => { const { a, b } = await twoUsers(); - const todo = await createTextItem(a, { kind: "todo", text: "Book the venue", source: "web" }); + const { item: todo } = await createTextItem(a, { kind: "todo", text: "Book the venue", source: "web" }); const read = await note(a, "Just a note"); const talk = await createList(a, { name: "Talk", createdBy: "user", items: [{ itemId: todo.id }, { itemId: read.id }] }); const trip = await createList(a, { name: "Trip", createdBy: "user", items: [{ itemId: todo.id }] }); @@ -111,7 +111,7 @@ describe("lists", () => { expect(await ticks(later.id)).toEqual([["Book the venue", true]]); // Someone else can neither mark it nor move it, and their own same-named Todo is untouched. - const theirs = await createTextItem(b, { kind: "todo", text: "Book the venue", source: "web" }); + const { item: theirs } = await createTextItem(b, { kind: "todo", text: "Book the venue", source: "web" }); const theirList = await createList(b, { name: "Talk", createdBy: "user", items: [{ itemId: theirs.id }] }); expect(await markEntries(b, talk.id, [todo.id], false)).toBeNull(); expect(await updateEntry(b, talk.id, todo.id, { ticked: false }, "user")).toBe(false); diff --git a/packages/db/src/search.test.ts b/packages/db/src/search.test.ts index eff34a0..dff2966 100644 --- a/packages/db/src/search.test.ts +++ b/packages/db/src/search.test.ts @@ -24,7 +24,7 @@ describe("search", () => { await applyExtraction(a, inBody.id, extracted("Long text where window functions appear deep inside the article body.")); const inTitle = (await pluckLink(a, { url: "https://example.com/title", source: "web" })).item; await applyExtraction(a, inTitle.id, extracted("Nothing relevant here.", "Window functions, finally")); - const note = await createTextItem(a, { kind: "note", text: "Kitchen\nmeasure the worktop", source: "web" }); + const { item: note } = await createTextItem(a, { kind: "note", text: "Kitchen\nmeasure the worktop", source: "web" }); await editItem(a, note.id, { note: null }); const tag = (await createTag(a, "postgres", "user"))!.tag; await setItemTags(a, note.id, [tag.id], "user"); @@ -41,7 +41,7 @@ describe("search", () => { test("tag renames and deletes reach search", async () => { const { a } = await twoUsers(); - const note = await createTextItem(a, { kind: "note", text: "Plain", source: "web" }); + const { item: note } = await createTextItem(a, { kind: "note", text: "Plain", source: "web" }); const t = (await createTag(a, "pg", "user"))!.tag; await setItemTags(a, note.id, [t.id], "user"); await renameTag(a, t.id, "databases"); @@ -55,9 +55,9 @@ describe("search", () => { const { a } = await twoUsers(); const link = (await pluckLink(a, { url: "https://example.com/pinned?id=1", source: "web" })).item; await applyExtraction(a, link.id, extracted("sourdough starter notes")); - const archived = await createTextItem(a, { kind: "todo", text: "Feed the sourdough", source: "web" }); + const { item: archived } = await createTextItem(a, { kind: "todo", text: "Feed the sourdough", source: "web" }); await editItem(a, archived.id, { status: "archived" }); - const binned = await createTextItem(a, { kind: "note", text: "sourdough in the bin", source: "web" }); + const { item: binned } = await createTextItem(a, { kind: "note", text: "sourdough in the bin", source: "web" }); await (await items()).updateOne({ _id: new ObjectId(binned.id) }, { $set: { deletedAt: new Date() } }); expect((await searchItems(a, q("sourdough"))).total).toBe(2); diff --git a/packages/db/src/triage.test.ts b/packages/db/src/triage.test.ts index b41200d..93b5fee 100644 --- a/packages/db/src/triage.test.ts +++ b/packages/db/src/triage.test.ts @@ -15,7 +15,7 @@ describe("status changes", () => { test("archive, later, done and back, with the reason and the time of the move", async () => { const { a } = await twoUsers(); const link = (await pluckLink(a, { url: "https://example.com/s", source: "web" })).item; - const todo = await createTextItem(a, { kind: "todo", text: "Book the room", source: "web" }); + const { item: todo } = await createTextItem(a, { kind: "todo", text: "Book the room", source: "web" }); const archived = await editItem(a, link.id, { status: "archived" }); expect(archived).toMatchObject({ status: "archived", archivedReason: "user" }); @@ -48,7 +48,7 @@ describe("batch", () => { const { a, b } = await twoUsers(); const l1 = (await pluckLink(a, { url: "https://example.com/b1", source: "web" })).item; const l2 = (await pluckLink(a, { url: "https://example.com/b2", source: "web" })).item; - const todo = await createTextItem(a, { kind: "todo", text: "t", source: "web" }); + const { item: todo } = await createTextItem(a, { kind: "todo", text: "t", source: "web" }); const theirs = (await pluckLink(b, { url: "https://example.com/b3", source: "web" })).item; expect(await batchUpdate(a, [l1.id, l2.id, theirs.id], { status: "later" })).toEqual({ matched: 2, updated: 2 }); @@ -118,7 +118,7 @@ describe("highlights", () => { test("only on the owner's links, listed in order, noted, removed", async () => { const { a, b } = await twoUsers(); const link = (await pluckLink(a, { url: "https://example.com/h", source: "web" })).item; - const note = await createTextItem(a, { kind: "note", text: "n", source: "web" }); + const { item: note } = await createTextItem(a, { kind: "note", text: "n", source: "web" }); expect(await createHighlight(a, { itemId: note.id, quote: "n", selector })).toBeNull(); expect(await createHighlight(b, { itemId: link.id, quote: "scope", selector })).toBeNull(); diff --git a/packages/shared-schemas/src/format.ts b/packages/shared-schemas/src/format.ts index 62cd511..a96b3ae 100644 --- a/packages/shared-schemas/src/format.ts +++ b/packages/shared-schemas/src/format.ts @@ -1,6 +1,6 @@ import { isTextKind } from "./notes.ts"; import type { Item } from "./items.ts"; -import type { Kind, Source, Status } from "./enums.ts"; +import type { Kind } from "./enums.ts"; // How an item reads in a row, shared by the web app and the iOS app. @@ -95,18 +95,3 @@ export function tileIndex(key: string): number { for (const c of key) h = (h * 31 + c.charCodeAt(0)) >>> 0; return h % 6; } - -const SOURCE: Record = { - web: "Plucked on the web", extension: "Plucked from the extension", ios: "Plucked from iOS", email: "Plucked by email", - mcp: "Plucked by your AI", import: "Imported", api: "Plucked with an API key", copied: "Plucked from a public list", -}; -export const sourceLabel = (s: Source) => SOURCE[s]; - - -// What a move says once it is done, with Undo beside it. -export const MOVED: Record = { - archived: "Gone. Well, archived.", - later: "Kept for later.", - done: "Done.", - inbox: "Back in the Inbox.", -}; diff --git a/packages/shared-schemas/src/items.ts b/packages/shared-schemas/src/items.ts index 42d2f21..1a3e1e2 100644 --- a/packages/shared-schemas/src/items.ts +++ b/packages/shared-schemas/src/items.ts @@ -60,6 +60,8 @@ export const CreateItemBody = z text: z.string().min(1).max(100_000).optional(), kind: Kind.optional(), dueAt: DueAt.optional(), + // A queued pluck's own id: sent again after a lost response, a Note, Todo or Thing with a key it has seen is not made twice. + clientKey: z.string().min(1).max(64).optional(), ...Optional, }) .refine((b) => [b.url, b.urls, b.text].filter((v) => v !== undefined).length === 1, {