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
4 changes: 2 additions & 2 deletions apps/extension/store/listing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
4 changes: 2 additions & 2 deletions apps/web/app/(site)/open-source/page.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -28,7 +28,7 @@ export default async function OpenSource() {
</div>
<div className={`${styles.terminal} ${styles.mono}`}>
<span className={styles.comment}># {REPO_URL}</span>
<span>git clone {CLONE_URL}</span>
<span>git clone {REPO_URL}</span>
<span>cd pluck</span>
<span className={styles.mint}>docker compose up -d</span>
<span className={`${styles.comment} ${styles.gap}`}>{t("terminalComment")}</span>
Expand Down
10 changes: 5 additions & 5 deletions apps/web/components/TopBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> | null = null;

const post = (body: Record<string, unknown>) =>
Expand Down Expand Up @@ -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" };
Expand All @@ -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;
}
Expand Down
6 changes: 3 additions & 3 deletions apps/web/lib/library/items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,11 @@ const LINKS_PER_HOUR = 2000;
export async function pluckAny(userId: string, body: CreateItemBody, { defer }: { defer?: Defer } = {}): Promise<PluckResponse> {
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);
Expand Down
2 changes: 1 addition & 1 deletion apps/web/lib/library/sharing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
}
Expand Down
3 changes: 1 addition & 2 deletions apps/web/lib/site.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions e2e/extension.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
2 changes: 1 addition & 1 deletion packages/client/src/queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/db/src/account.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion packages/db/src/backfill.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down
2 changes: 2 additions & 0 deletions packages/db/src/collections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -204,6 +205,7 @@ export function db(): Promise<Db> {
{ 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 } },
Expand Down
10 changes: 5 additions & 5 deletions packages/db/src/digest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
Expand Down
8 changes: 4 additions & 4 deletions packages/db/src/expiry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" } });
Expand Down Expand Up @@ -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);
Expand Down
18 changes: 15 additions & 3 deletions packages/db/src/items.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
Expand All @@ -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 () => {
Expand Down
20 changes: 16 additions & 4 deletions packages/db/src/items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Item> {
// 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();
Expand All @@ -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 = {
Expand Down
Loading
Loading