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/ios/components/ItemsScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Item, Status } from "@pluck/shared-schemas";
import { isTask, type Item, type Status } from "@pluck/shared-schemas";
import { useQueryClient } from "@tanstack/react-query";
import * as Haptics from "expo-haptics";
import { router, useFocusEffect, usePathname } from "expo-router";
Expand Down Expand Up @@ -93,7 +93,7 @@ export function ItemsScreen({ status }: { status: "inbox" | "later" }) {
const more = () => {
const options = [
...(one && one.url ? ["note" as const] : []),
...(chosen.every((i) => i.kind === "todo" || i.kind === "thing") ? ["done" as const] : []),
...(chosen.every((i) => isTask(i.kind)) ? ["done" as const] : []),
"all" as const,
];
const names = { note: tr("items.addNote"), done: tr("common.done"), all: tr("items.selectAll") };
Expand Down
6 changes: 3 additions & 3 deletions apps/web/components/ItemBar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import type { Item, Status } from "@pluck/shared-schemas";
import { isTask, type Item, type Status } from "@pluck/shared-schemas";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useRouter } from "next/navigation";
Expand Down Expand Up @@ -43,7 +43,7 @@ export function ItemBar({ item, top, state, before }: { item: Item; top?: ReactN
return void (before ?? (async () => {}))().then(() => router.push(backHref(origin)));
}
if (isTyping(e.target) || window.getSelection()?.toString()) return;
const run = { l: () => move("later"), e: () => move("archived"), d: () => (item.kind === "todo" || item.kind === "thing") && move("done") }[e.key];
const run = { l: () => move("later"), e: () => move("archived"), d: () => isTask(item.kind) && move("done") }[e.key];
if (run) {
e.preventDefault();
void run();
Expand Down Expand Up @@ -71,7 +71,7 @@ export function ItemBar({ item, top, state, before }: { item: Item; top?: ReactN
<button type="button" className={styles.action} aria-pressed={status === "archived"} onClick={() => move("archived")}>
{t("itemBar.archive")} <kbd className={styles.hint}>e</kbd>
</button>
{(item.kind === "todo" || item.kind === "thing") && (
{isTask(item.kind) && (
<button type="button" className={styles.action} aria-pressed={status === "done"} onClick={() => move("done")}>
{t("itemBar.done")} <kbd className={styles.hint}>d</kbd>
</button>
Expand Down
8 changes: 4 additions & 4 deletions apps/web/components/ItemList.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import type { Item, ItemPage, List, PluckResponse, Status, Tag } from "@pluck/shared-schemas";
import { isTask, type Item, type ItemPage, type List, type PluckResponse, type Status, type Tag } from "@pluck/shared-schemas";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
Expand Down Expand Up @@ -137,7 +137,7 @@ export function ItemList({ view, initial, total: initialTotal, tags: initialTags
// Moves slide the rows out, then call the API; a failure puts them back.
const move = useCallback(
async (ids: string[], status: Status) => {
const moved = real.filter((r) => ids.includes(r.id) && (status !== "done" || r.kind === "todo" || r.kind === "thing"));
const moved = real.filter((r) => ids.includes(r.id) && (status !== "done" || isTask(r.kind)));
if (!moved.length) return;
const movedIds = moved.map((r) => r.id);
setLeaving(new Set(movedIds));
Expand Down Expand Up @@ -301,7 +301,7 @@ export function ItemList({ view, initial, total: initialTotal, tags: initialTags
const emptyNode = !everPlucked && rows.length === 0 ? empty() : null;
const selecting = selected.size > 0;
const selectedRows = real.filter((r) => selected.has(r.id));
const allTodo = selectedRows.length > 0 && selectedRows.every((r) => r.kind === "todo" || r.kind === "thing");
const allTodo = selectedRows.length > 0 && selectedRows.every((r) => isTask(r.kind));

return (
<>
Expand Down Expand Up @@ -381,7 +381,7 @@ export function ItemList({ view, initial, total: initialTotal, tags: initialTags
<RowAction label={t("actions.archive")} keyHint="e" onClick={() => move([r.id], "archived")} />
<RowAction label={t("actions.tag")} keyHint="t" onClick={() => setPicking([r.id])} />
<RowAction label={t("actions.list")} keyHint="a" onClick={() => openLists([r.id])} />
{(r.kind === "todo" || r.kind === "thing") && <RowAction label={t("actions.done")} keyHint="d" onClick={() => move([r.id], "done")} />}
{isTask(r.kind) && <RowAction label={t("actions.done")} keyHint="d" onClick={() => move([r.id], "done")} />}
<RowAction label={t("actions.open")} keyHint="o" href={`/items/${r.id}`} />
</>
)
Expand Down
9 changes: 7 additions & 2 deletions apps/web/lib/library/items.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,12 +109,17 @@ test("edits follow the rules for links and notes", async () => {
expect((await updateItem(a, link.id, { kind: "video" })).kind).toBe("video");
});

test("Done is only for todos and things; batch skips what does not fit", async () => {
const { updateItem: update, batch } = await import("./index");
test("Done is only for todos and things; batch refuses it as an edit does, before any tag moves", async () => {
const { updateItem: update, batch, addTag } = await import("./index");
const { a } = await twoUsers();
const link = (await pluckAny(a, { url: `${FIX}/blog-4.html` })).results[0]!.item;
const todo = (await pluckAny(a, { text: "Book the room", kind: "todo" })).results[0]!.item;
const tag = (await addTag(a, "errands")).tag;
await expect(update(a, link.id, { status: "done" })).rejects.toMatchObject({ status: 400, message: "Done is for todos and things." });
await expect(batch(a, { itemIds: [link.id, todo.id], patch: { status: "done", addTagIds: [tag.id] } })).rejects.toMatchObject({
status: 400, message: "Done is for todos and things.", details: { itemIds: [link.id] },
});
expect(await readItem(a, todo.id)).toMatchObject({ status: "inbox", tagIds: [] });
expect((await update(a, todo.id, { status: "done" })).status).toBe("done");
expect(await batch(a, { itemIds: [link.id, todo.id], patch: { status: "archived" } })).toEqual({ matched: 2, updated: 2 });
});
Expand Down
22 changes: 10 additions & 12 deletions apps/web/lib/library/items.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { deletePrefix, keys as blobKeys, putObject, signedUrl } from "@pluck/blob";
import {
batchTags, batchUpdate, countItems, createHighlight, createTextItem, deleteHighlight, editItem, findByUrl, getContent, getItem, getItemBlobKeys,
hitRateLimit, listHighlights, listItems, listsForItem, markOpened, moveToBin, pluckLink, purgeBin, purgeFromBin, resetExtraction, restoreFromBin, setBlobKeys, setItemTags,
hitRateLimit, listHighlights, listItems, listsForItem, markOpened, misfit, moveToBin, pluckLink, purgeBin, purgeFromBin, resetExtraction, restoreFromBin, setBlobKeys, setItemTags,
updateHighlight,
} from "@pluck/db";
import {
extractUrls, isTextKind, LINK_KINDS, normalizeUrl, type BatchBody, type CreateHighlightBody, type CreateItemBody, type CreateItemResult,
extractUrls, isTextKind, normalizeUrl, type BatchBody, type CreateHighlightBody, type CreateItemBody, type CreateItemResult,
type Highlight, type Item, type ItemPage, type ListItemsQuery, type ListMembership, type PluckResponse, type Status, type UpdateItemBody,
} from "@pluck/shared-schemas";
import { ApiError } from "../errors";
Expand Down Expand Up @@ -106,13 +106,9 @@ import { tagIdsFor, type Actor } from "./tags";
// Edits from the person. Text and the text kinds belong to items with no URL; link kinds to items with one.
export async function updateItem(userId: string, itemId: string, patch: UpdateItemBody, { by = "user" }: { by?: Actor } = {}): Promise<Item> {
const current = await readItem(userId, itemId);
const isLink = !isTextKind(current.kind);
if (patch.text !== undefined && isLink) throw new ApiError("bad_request", "Only notes, todos and things have text to edit.", 400);
if (patch.kind && isLink !== (LINK_KINDS as readonly string[]).includes(patch.kind))
throw new ApiError("bad_request", isLink ? "A link can be Read, Watch, PDF or Image." : "This can be a Note, Todo or Thing.", 400);
const refused = misfit(current.kind, patch);
if (refused) throw new ApiError("bad_request", refused, 400);
if (patch.preview !== undefined && current.preview === null) throw new ApiError("bad_request", "Only links open on a Preview.", 400);
if (patch.status === "done" && !["todo", "thing"].includes(patch.kind ?? current.kind))
throw new ApiError("bad_request", "Done is for todos and things.", 400);
const { tagIds, ...rest } = patch;
if (tagIds) await setItemTags(userId, itemId, await tagIdsFor(userId, tagIds), by);
if (!Object.keys(rest).length) return readItem(userId, itemId);
Expand All @@ -131,10 +127,12 @@ export async function batch(userId: string, body: BatchBody, { by = "user" }: {
const { addTagIds, removeTagIds, ...p } = body.patch;
const add = addTagIds ? await tagIdsFor(userId, addTagIds) : [];
const remove = removeTagIds ? await tagIdsFor(userId, removeTagIds) : [];
let tagged = 0;
if (add.length || remove.length) tagged = await batchTags(userId, body.itemIds, add, remove, by);
if (!Object.keys(p).length) return { matched: body.itemIds.length, updated: tagged };
const res = await batchUpdate(userId, body.itemIds, { ...p, dueAt: p.dueAt === undefined ? undefined : p.dueAt === null ? null : new Date(p.dueAt) });
// The change goes first: one that does not fit every Item is refused, like an edit of one, before any tag moves.
const res = Object.keys(p).length
? await batchUpdate(userId, body.itemIds, { ...p, dueAt: p.dueAt === undefined ? undefined : p.dueAt === null ? null : new Date(p.dueAt) })
: { matched: body.itemIds.length, updated: 0, refused: undefined };
if (res.refused) throw new ApiError("bad_request", res.refused.reason, 400, { itemIds: res.refused.itemIds });
const tagged = add.length || remove.length ? await batchTags(userId, body.itemIds, add, remove, by) : 0;
return { matched: res.matched, updated: Math.max(res.updated, tagged) };
}

Expand Down
1 change: 1 addition & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ These are the places where the code is shallower than it needs to be: a rule wri
- **Files:** `packages/db/src/items.ts` (pluckLink, createFileItem, createTextItem, the bin, extraction updates), `email-in.ts` (createEmailItem), `account.ts` (importLink), `lists.ts` (around `doneFollowsMark`), `expiry.ts` (`archive`).
- **Problem:** a whole new Item document (about 30 fields) is spelled out by hand in five places, and the Re-pluck update is copied between `items.ts` and `email-in.ts`. Status is written in about six places: `statusFields()` handles the archived reason, but ticking a Todo in a List and expiry write it inline. The Touch rule (`lastTouchedAt`) is applied in about twelve places, with an exception for reading progress. A new field or Status rule means editing five or six files. `items.ts` (453 lines) changes for four reasons: writes, extraction, the bin and listing.
- **Shape:** one small module (`item-doc.ts`) with `newItem(userId, fields, now)`, `replucked(now)`, `toStatus(status, reason, now)` and `touched(now)`, used by every writer. Then split `items.ts` along its four reasons. Tests cross the module's interface: one table test per Status transition (the changed time, the archived reason, the List tick), and Re-pluck for both a URL and an email.
- **Done so far:** `item-doc.ts` has `newItem`, `replucked`, `statusFields` and `misfit` (which changes fit an Item: text, Kind, Done); every writer above but expiry uses them, and an edit and a batch refuse on the same `misfit`. The UIs ask `isTask` in `shared-schemas`. Left: `touched`, expiry's Status write, and the split of `items.ts`.

### 2. Derived fields have no single owner (M)

Expand Down
2 changes: 1 addition & 1 deletion docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ A tool is only listed when the token carries its scope.
| `list_items` | Browse by status; `status, kinds[], sort (newest, untouched), limit (max 25), cursor` |
| `get_item` | One item with its facts, its kept text (paged at about 20k tokens, follow `textCursor`), note, highlights and lists |
| `save_item` | Pluck a URL, every URL in a block of text, or a note, todo or thing; `url or text, kind, title, note, tagIds[], listId, dueAt`. Saving a link twice brings it back to the Inbox. `tagIds` take existing tag ids or names; unknown tags (refused with the nearest existing ones) or an unknown `listId` refuse the call before anything is saved |
| `batch_update` | One change over up to 100 items: status, kind, due date, note, add or remove tags. Unknown tags are refused with the nearest existing ones; tags are never created here |
| `batch_update` | One change over up to 100 items: status, kind, due date, note, add or remove tags. Unknown tags are refused with the nearest existing ones; tags are never created here. A change that does not fit every item (Done is only for Todos and Things; a link Kind only for links) refuses the call, naming those items in `details.itemIds`, and nothing changes |
| `list_tags` | Tags with counts, and whether an AI made them; paged with `cursor` |
| `create_tag` | A new tag, only after the person agreed. Marked as made by AI |
| `list_lists` | Lists with purpose, counts and visibility |
Expand Down
12 changes: 5 additions & 7 deletions packages/db/src/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { ImportEntry } from "@pluck/extract";
import type { ImportStatus, Item } from "@pluck/shared-schemas";
import { ObjectId } from "mongodb";
import { apiKeys, contents, db, emailAliases, exportsCol, highlights, imports, items, listRows, lists, listShares, oid, reports, tags, users, type ImportDoc } from "./collections.ts";
import { newItem } from "./item-doc.ts";
import { toItem } from "./items.ts";
import { textForSearch } from "./search.ts";
import { createTag } from "./tags.ts";
Expand Down Expand Up @@ -43,13 +44,10 @@ export async function importLink(userId: string, url: string, e: Pick<ImportEntr
const res = await (await items()).findOneAndUpdate(
{ userId: uid, urlHash },
{
$setOnInsert: {
userId: uid, kind: "link", url, urlHash, canonicalUrl: null, title: e.title?.trim() || url, titleIsDerived: true,
siteName: new URL(url).hostname.replace(/^www\./, ""), author: null, excerpt: null, coverKey: null, htmlKey: null, status: "archived",
archivedReason: "import", dueAt: null, tagIds: [], note: e.note?.trim() || null, source: "import", readingProgress: 0, wordCount: null, lang: null,
extraction: { state: "pending", attempts: 0, lastError: null }, createdAt: e.createdAt ?? now, updatedAt: now, lastOpenedAt: null,
lastTouchedAt: now, statusChangedAt: now, deletedAt: null, searchText: textForSearch({ url }),
},
$setOnInsert: newItem(userId, {
kind: "link", url, urlHash, title: e.title?.trim() || url, siteName: new URL(url).hostname.replace(/^www\./, ""), status: "archived",
archivedReason: "import", note: e.note?.trim() || null, source: "import", createdAt: e.createdAt ?? now, searchText: textForSearch({ url }),
}, now),
},
{ upsert: true, returnDocument: "after", includeResultMetadata: true },
);
Expand Down
4 changes: 2 additions & 2 deletions packages/db/src/digest.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Digest } from "@pluck/shared-schemas";
import { TASK_KINDS, type Digest } from "@pluck/shared-schemas";
import { DEFAULT_SETTINGS, items, lists, oid, users, type UserSettings } from "./collections.ts";
import { localNow } from "./expiry.ts";

Expand Down Expand Up @@ -30,7 +30,7 @@ export async function digestFor(userId: string, settings: UserSettings, now = ne
.toArray()
: [],
col
.find({ ...base, kind: { $in: ["todo", "thing"] }, status: { $ne: "done" }, dueAt: { $gte: new Date(now.getTime() - DAY), $lt: new Date(now.getTime() + 7 * DAY) } }, lean)
.find({ ...base, kind: { $in: [...TASK_KINDS] }, status: { $ne: "done" }, dueAt: { $gte: new Date(now.getTime() - DAY), $lt: new Date(now.getTime() + 7 * DAY) } }, lean)
.sort({ dueAt: 1 })
.limit(limit)
.toArray(),
Expand Down
21 changes: 10 additions & 11 deletions packages/db/src/email-in.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createHash, randomBytes } from "node:crypto";
import type { Item } from "@pluck/shared-schemas";
import { ObjectId } from "mongodb";
import { contents, emailAliases, items, oid } from "./collections.ts";
import { newItem, replucked } from "./item-doc.ts";
import { recountAround, toItem } from "./items.ts";
import { bodyForSearch, textForSearch } from "./search.ts";

Expand Down Expand Up @@ -49,24 +50,22 @@ export async function createEmailItem(userId: string, m: EmailItemInput): Promis
if (hash) {
const existing = await col.findOneAndUpdate(
{ userId: uid, urlHash: hash },
{ $set: { status: "inbox", archivedReason: null, deletedAt: null, lastTouchedAt: now, updatedAt: now, statusChangedAt: now } },
{ $set: replucked(now) },
{ returnDocument: "after" },
);
if (existing) {
await recountAround(userId, existing); // it may have come back from the Bin
return { item: toItem(existing), created: false };
}
}
const _id = new ObjectId();
await col.insertOne({
_id, userId: uid, kind: "link", url: null, ...(hash ? { urlHash: hash } : {}), canonicalUrl: null, title: m.subject, titleIsDerived: true,
siteName, author: m.fromName, excerpt: m.excerpt, coverKey: null, htmlKey: null, status: "inbox", archivedReason: null, dueAt: null,
tagIds: [], note: null, source: "email", readingProgress: 0, wordCount: m.wordCount, lang: null,
extraction: { state: "done", attempts: 1, lastError: null }, createdAt: now, updatedAt: now, lastOpenedAt: null, lastTouchedAt: now,
statusChangedAt: now, deletedAt: null, searchText: textForSearch({ excerpt: m.excerpt, siteName, author: m.fromAddress }), searchBody: bodyForSearch(m.markdown),
});
await (await contents()).insertOne({ _id: new ObjectId(), itemId: _id, userId: uid, textMarkdown: m.markdown, extractor: "email", bytes: Buffer.byteLength(m.markdown) });
return { item: toItem((await col.findOne({ _id }))!), created: true };
const doc = newItem(userId, {
kind: "link", ...(hash ? { urlHash: hash } : {}), title: m.subject, siteName, author: m.fromName, excerpt: m.excerpt, source: "email",
wordCount: m.wordCount, extraction: { state: "done", attempts: 1, lastError: null },
searchText: textForSearch({ excerpt: m.excerpt, siteName, author: m.fromAddress }), searchBody: bodyForSearch(m.markdown),
}, now);
await col.insertOne(doc);
await (await contents()).insertOne({ _id: new ObjectId(), itemId: doc._id, userId: uid, textMarkdown: m.markdown, extractor: "email", bytes: Buffer.byteLength(m.markdown) });
return { item: toItem((await col.findOne({ _id: doc._id }))!), created: true };
}

export async function setRawEmailKey(userId: string, itemId: string, key: string): Promise<void> {
Expand Down
1 change: 1 addition & 0 deletions packages/db/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export {
countItems, BIN_DAYS,
type PluckLinkInput, type TextItemInput, type ItemEdit, type BatchEdit, type ExtractionResult,
} from "./items.ts";
export { misfit } from "./item-doc.ts";
export { createHighlight, listHighlights, updateHighlight, deleteHighlight, type HighlightInput } from "./highlights.ts";
export { enqueue, claimJob, completeJob, retryJob, failJob, pendingJobCount, type Job } from "./jobs.ts";
export { hitRateLimit } from "./rate-limit.ts";
Expand Down
Loading
Loading