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 pluckdocker 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, {