From 6b7e5cef98e377b0db7d7a61fb34cfb44b32b64d Mon Sep 17 00:00:00 2001
From: Sidney von Katzendame
Date: Mon, 25 May 2026 19:24:10 -0400
Subject: [PATCH 01/18] feat(core): add updateBookmarks (bulk) and
restoreBookmark pure mutations
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds two new exports to @gitmarks/core: `updateBookmarks` for applying
patches to multiple bookmarks in a single atomic operation, and
`restoreBookmark` which composes `updateBookmark` to clear `deleted_at`.
Both are pure functions suitable for use as GitHubClient.update() callbacks.
Exposes `BookmarkPatch` interface from the public index. Test count: 65 → 71.
Co-Authored-By: Claude Sonnet 4.6
---
packages/core/src/index.ts | 3 ++
packages/core/src/mutate.ts | 36 ++++++++++++++++++
packages/core/test/mutate.test.ts | 61 +++++++++++++++++++++++++++++++
3 files changed, 100 insertions(+)
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 45cc7f1..6d91104 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -20,7 +20,10 @@ export { normalizeUrl } from "./url.js";
export {
addBookmark,
updateBookmark,
+ updateBookmarks,
+ type BookmarkPatch,
softDeleteBookmark,
+ restoreBookmark,
gcTombstones,
} from "./mutate.js";
diff --git a/packages/core/src/mutate.ts b/packages/core/src/mutate.ts
index 305043e..7454e6c 100644
--- a/packages/core/src/mutate.ts
+++ b/packages/core/src/mutate.ts
@@ -51,3 +51,39 @@ export function gcTombstones(
}
return { ...file, updated_at: nowIso, bookmarks: kept };
}
+
+export interface BookmarkPatch {
+ id: string;
+ patch: Partial>;
+}
+
+export function updateBookmarks(
+ file: BookmarksFile,
+ patches: BookmarkPatch[],
+ nowIso: string,
+): BookmarksFile {
+ if (patches.length === 0) {
+ return { ...file, updated_at: nowIso };
+ }
+ const byId = new Map>>();
+ for (const p of patches) byId.set(p.id, p.patch);
+ const next = file.bookmarks.map((b) => {
+ const patch = byId.get(b.id);
+ if (patch === undefined) return b;
+ byId.delete(b.id);
+ return { ...b, ...patch, updated_at: nowIso };
+ });
+ if (byId.size > 0) {
+ const missing = [...byId.keys()].join(", ");
+ throw new Error(`bookmark not found: ${missing}`);
+ }
+ return { ...file, updated_at: nowIso, bookmarks: next };
+}
+
+export function restoreBookmark(
+ file: BookmarksFile,
+ id: string,
+ nowIso: string,
+): BookmarksFile {
+ return updateBookmark(file, id, { deleted_at: null }, nowIso);
+}
diff --git a/packages/core/test/mutate.test.ts b/packages/core/test/mutate.test.ts
index d4e9f3d..9a1d74e 100644
--- a/packages/core/test/mutate.test.ts
+++ b/packages/core/test/mutate.test.ts
@@ -5,6 +5,8 @@ import {
updateBookmark,
softDeleteBookmark,
gcTombstones,
+ restoreBookmark,
+ updateBookmarks,
} from "../src/mutate.js";
function mkBookmark(overrides: Partial = {}): Bookmark {
@@ -107,3 +109,62 @@ describe("gcTombstones", () => {
expect(out.bookmarks).toEqual([live]);
});
});
+
+describe("updateBookmarks (bulk)", () => {
+ it("applies a patch to every listed id and stamps updated_at", () => {
+ const file = mkFile([
+ mkBookmark({ id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CA" }),
+ mkBookmark({ id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CB" }),
+ mkBookmark({ id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CC" }),
+ ]);
+ const next = updateBookmarks(
+ file,
+ [
+ { id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CA", patch: { folder: "Archive" } },
+ { id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CC", patch: { tags: ["x"] } },
+ ],
+ "2026-05-25T00:00:00Z",
+ );
+ expect(next.bookmarks[0]!.folder).toBe("Archive");
+ expect(next.bookmarks[0]!.updated_at).toBe("2026-05-25T00:00:00Z");
+ expect(next.bookmarks[1]!.folder).toBe(file.bookmarks[1]!.folder);
+ expect(next.bookmarks[2]!.tags).toEqual(["x"]);
+ expect(next.updated_at).toBe("2026-05-25T00:00:00Z");
+ });
+
+ it("throws when any id is missing", () => {
+ const file = mkFile([mkBookmark({ id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CA" })]);
+ expect(() =>
+ updateBookmarks(file, [{ id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CZ", patch: {} }], "2026-05-25T00:00:00Z"),
+ ).toThrow(/not found/);
+ });
+
+ it("no-ops on empty patch list but stamps updated_at", () => {
+ const file = mkFile([mkBookmark({ id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CA" })]);
+ const next = updateBookmarks(file, [], "2026-05-25T00:00:00Z");
+ expect(next.updated_at).toBe("2026-05-25T00:00:00Z");
+ expect(next.bookmarks).toEqual(file.bookmarks);
+ });
+
+ it("does not mutate the input", () => {
+ const file = mkFile([mkBookmark({ id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CA", folder: "" })]);
+ updateBookmarks(file, [{ id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CA", patch: { folder: "X" } }], "2026-05-25T00:00:00Z");
+ expect(file.bookmarks[0]!.folder).toBe("");
+ });
+});
+
+describe("restoreBookmark", () => {
+ it("clears deleted_at and updates updated_at", () => {
+ const file = mkFile([
+ mkBookmark({ id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CA", deleted_at: "2026-04-01T00:00:00Z" }),
+ ]);
+ const next = restoreBookmark(file, "01HXYZ8K7M9P3RQ2V5W6Z8B0CA", "2026-05-25T00:00:00Z");
+ expect(next.bookmarks[0]!.deleted_at).toBeNull();
+ expect(next.bookmarks[0]!.updated_at).toBe("2026-05-25T00:00:00Z");
+ });
+
+ it("throws when the id is missing", () => {
+ const file = mkFile([]);
+ expect(() => restoreBookmark(file, "01HXYZ8K7M9P3RQ2V5W6Z8B0CZ", "2026-05-25T00:00:00Z")).toThrow(/not found/);
+ });
+});
From 51decc3544c4cf877e9f99463b70cb6eefdf38fd Mon Sep 17 00:00:00 2001
From: Sidney von Katzendame
Date: Mon, 25 May 2026 19:26:10 -0400
Subject: [PATCH 02/18] feat(web): add writeBookmarks to useGitmarksData hook
---
packages/web/src/hooks/useGitmarksData.ts | 14 +++++++++++++
.../web/test/hooks.useGitmarksData.test.ts | 20 +++++++++++++++++++
2 files changed, 34 insertions(+)
diff --git a/packages/web/src/hooks/useGitmarksData.ts b/packages/web/src/hooks/useGitmarksData.ts
index e008dea..af91962 100644
--- a/packages/web/src/hooks/useGitmarksData.ts
+++ b/packages/web/src/hooks/useGitmarksData.ts
@@ -30,6 +30,10 @@ export interface UseGitmarksData {
loading: boolean;
error: string | null;
refresh: () => Promise;
+ writeBookmarks: (
+ mutate: (f: BookmarksFile) => BookmarksFile,
+ message: string,
+ ) => Promise;
writeTags: (
mutate: (f: TagsFile) => TagsFile,
message: string,
@@ -84,6 +88,15 @@ export function useGitmarksData(client: GitHubClient): UseGitmarksData {
}
}, [bookmarks, tags, client, loadInitial]);
+ const writeBookmarks = useCallback(
+ async (mutate: (f: BookmarksFile) => BookmarksFile, message: string) => {
+ const result = await client.update("bookmarks.json", mutate, message);
+ if (!mounted.current) return;
+ setBookmarks({ data: result.data, etag: result.etag, sha: result.sha });
+ },
+ [client],
+ );
+
const writeTags = useCallback(
async (mutate: (f: TagsFile) => TagsFile, message: string) => {
const result = await client.update("tags.json", mutate, message);
@@ -107,6 +120,7 @@ export function useGitmarksData(client: GitHubClient): UseGitmarksData {
loading,
error,
refresh,
+ writeBookmarks,
writeTags,
};
}
diff --git a/packages/web/test/hooks.useGitmarksData.test.ts b/packages/web/test/hooks.useGitmarksData.test.ts
index ed69c1e..d79b5a9 100644
--- a/packages/web/test/hooks.useGitmarksData.test.ts
+++ b/packages/web/test/hooks.useGitmarksData.test.ts
@@ -80,6 +80,26 @@ describe("useGitmarksData", () => {
expect(update).toHaveBeenCalledWith("tags.json", mutator, "test commit");
});
+ it("writeBookmarks() calls client.update on bookmarks.json with the mutator", async () => {
+ const updatedFile: BookmarksFile = {
+ version: 1,
+ updated_at: "2026-05-25T00:00:00Z",
+ bookmarks: [],
+ };
+ const update = vi.fn().mockResolvedValue({ data: updatedFile, sha: "b2", etag: '"b2"' });
+ const client = fakeClient({ update } as any);
+ const { result } = renderHook(() => useGitmarksData(client));
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ const mutator = (f: BookmarksFile) => f;
+ await act(async () => {
+ await result.current.writeBookmarks(mutator, "bulk: move to trash");
+ });
+
+ expect(update).toHaveBeenCalledWith("bookmarks.json", mutator, "bulk: move to trash");
+ expect(result.current.bookmarksFile).toEqual(updatedFile);
+ });
+
it("sets error when initial read throws", async () => {
const client = fakeClient({
read: vi.fn().mockRejectedValue(new Error("boom")),
From c093e94ede7fb9b3522fde09607f994bbdc8b4b6 Mon Sep 17 00:00:00 2001
From: Sidney von Katzendame
Date: Mon, 25 May 2026 19:27:23 -0400
Subject: [PATCH 03/18] feat(web): add useSelection hook for multi-select state
Co-Authored-By: Claude Sonnet 4.6
---
packages/web/src/hooks/useSelection.ts | 34 +++++++++++++++++
packages/web/test/hooks.useSelection.test.ts | 40 ++++++++++++++++++++
2 files changed, 74 insertions(+)
create mode 100644 packages/web/src/hooks/useSelection.ts
create mode 100644 packages/web/test/hooks.useSelection.test.ts
diff --git a/packages/web/src/hooks/useSelection.ts b/packages/web/src/hooks/useSelection.ts
new file mode 100644
index 0000000..20c4fb7
--- /dev/null
+++ b/packages/web/src/hooks/useSelection.ts
@@ -0,0 +1,34 @@
+import { useCallback, useState } from "react";
+
+export interface UseSelection {
+ selected: ReadonlySet;
+ isSelected: (id: string) => boolean;
+ toggle: (id: string) => void;
+ setAll: (ids: readonly string[]) => void;
+ clear: () => void;
+}
+
+export function useSelection(): UseSelection {
+ const [selected, setSelected] = useState>(() => new Set());
+
+ const toggle = useCallback((id: string) => {
+ setSelected((prev) => {
+ const next = new Set(prev);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ return next;
+ });
+ }, []);
+
+ const setAll = useCallback((ids: readonly string[]) => {
+ setSelected(new Set(ids));
+ }, []);
+
+ const clear = useCallback(() => {
+ setSelected(new Set());
+ }, []);
+
+ const isSelected = useCallback((id: string) => selected.has(id), [selected]);
+
+ return { selected, isSelected, toggle, setAll, clear };
+}
diff --git a/packages/web/test/hooks.useSelection.test.ts b/packages/web/test/hooks.useSelection.test.ts
new file mode 100644
index 0000000..f7f27ec
--- /dev/null
+++ b/packages/web/test/hooks.useSelection.test.ts
@@ -0,0 +1,40 @@
+import { describe, it, expect } from "vitest";
+import { renderHook, act } from "@testing-library/react";
+import { useSelection } from "../src/hooks/useSelection.js";
+
+describe("useSelection", () => {
+ it("starts empty", () => {
+ const { result } = renderHook(() => useSelection());
+ expect(result.current.selected.size).toBe(0);
+ });
+
+ it("toggle adds then removes", () => {
+ const { result } = renderHook(() => useSelection());
+ act(() => result.current.toggle("a"));
+ expect(result.current.selected.has("a")).toBe(true);
+ act(() => result.current.toggle("a"));
+ expect(result.current.selected.has("a")).toBe(false);
+ });
+
+ it("setAll replaces selection", () => {
+ const { result } = renderHook(() => useSelection());
+ act(() => result.current.setAll(["a", "b", "c"]));
+ expect(result.current.selected.size).toBe(3);
+ act(() => result.current.setAll(["d"]));
+ expect([...result.current.selected]).toEqual(["d"]);
+ });
+
+ it("clear empties the selection", () => {
+ const { result } = renderHook(() => useSelection());
+ act(() => result.current.setAll(["a", "b"]));
+ act(() => result.current.clear());
+ expect(result.current.selected.size).toBe(0);
+ });
+
+ it("isSelected reflects state", () => {
+ const { result } = renderHook(() => useSelection());
+ act(() => result.current.toggle("x"));
+ expect(result.current.isSelected("x")).toBe(true);
+ expect(result.current.isSelected("y")).toBe(false);
+ });
+});
From 68082d91242662f7b8d32e6f10d0ed1e2f68e5e9 Mon Sep 17 00:00:00 2001
From: Sidney von Katzendame
Date: Mon, 25 May 2026 19:29:12 -0400
Subject: [PATCH 04/18] feat(web): pure bulk-mutation factories built on core's
updateBookmarks
Co-Authored-By: Claude Sonnet 4.6
---
packages/web/src/lib/bulk-mutations.ts | 45 ++++++++
packages/web/test/lib.bulk-mutations.test.ts | 112 +++++++++++++++++++
2 files changed, 157 insertions(+)
create mode 100644 packages/web/src/lib/bulk-mutations.ts
create mode 100644 packages/web/test/lib.bulk-mutations.test.ts
diff --git a/packages/web/src/lib/bulk-mutations.ts b/packages/web/src/lib/bulk-mutations.ts
new file mode 100644
index 0000000..5aee586
--- /dev/null
+++ b/packages/web/src/lib/bulk-mutations.ts
@@ -0,0 +1,45 @@
+import type { BookmarksFile } from "@gitmarks/core";
+import { updateBookmarks } from "@gitmarks/core";
+
+type Mutator = (file: BookmarksFile) => BookmarksFile;
+
+export function bulkAddTag(ids: string[], tag: string, nowIso: string): Mutator {
+ return (file) =>
+ updateBookmarks(
+ file,
+ ids.map((id) => {
+ const existing = file.bookmarks.find((b) => b.id === id);
+ const tags = existing?.tags ?? [];
+ const nextTags = tags.includes(tag) ? tags : [...tags, tag];
+ return { id, patch: { tags: nextTags } };
+ }),
+ nowIso,
+ );
+}
+
+export function bulkRemoveTag(ids: string[], tag: string, nowIso: string): Mutator {
+ return (file) =>
+ updateBookmarks(
+ file,
+ ids.map((id) => {
+ const existing = file.bookmarks.find((b) => b.id === id);
+ const tags = existing?.tags ?? [];
+ return { id, patch: { tags: tags.filter((t) => t !== tag) } };
+ }),
+ nowIso,
+ );
+}
+
+export function bulkSetFolder(ids: string[], folder: string, nowIso: string): Mutator {
+ return (file) => updateBookmarks(file, ids.map((id) => ({ id, patch: { folder } })), nowIso);
+}
+
+export function bulkSoftDelete(ids: string[], nowIso: string): Mutator {
+ return (file) =>
+ updateBookmarks(file, ids.map((id) => ({ id, patch: { deleted_at: nowIso } })), nowIso);
+}
+
+export function bulkRestore(ids: string[], nowIso: string): Mutator {
+ return (file) =>
+ updateBookmarks(file, ids.map((id) => ({ id, patch: { deleted_at: null } })), nowIso);
+}
diff --git a/packages/web/test/lib.bulk-mutations.test.ts b/packages/web/test/lib.bulk-mutations.test.ts
new file mode 100644
index 0000000..ffeb805
--- /dev/null
+++ b/packages/web/test/lib.bulk-mutations.test.ts
@@ -0,0 +1,112 @@
+import { describe, it, expect } from "vitest";
+import type { Bookmark, BookmarksFile } from "@gitmarks/core";
+import {
+ bulkAddTag,
+ bulkRemoveTag,
+ bulkSetFolder,
+ bulkSoftDelete,
+ bulkRestore,
+} from "../src/lib/bulk-mutations.js";
+
+function mk(over: Partial = {}): Bookmark {
+ return {
+ id: "01HXYZ8K7M9P3RQ2V5W6Z8B0C1",
+ url: "https://example.com/",
+ title: "Example",
+ folder: "",
+ tags: [],
+ added_at: "2026-05-01T00:00:00Z",
+ updated_at: "2026-05-01T00:00:00Z",
+ added_from: "chrome@minerva",
+ deleted_at: null,
+ notes: null,
+ ...over,
+ };
+}
+
+const file: BookmarksFile = {
+ version: 1,
+ updated_at: "2026-05-23T00:00:00Z",
+ bookmarks: [
+ mk({ id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CA", tags: ["daily"] }),
+ mk({ id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CB", tags: ["daily", "to-read"] }),
+ mk({ id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CC", tags: [] }),
+ mk({ id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CD", deleted_at: "2026-05-20T00:00:00Z" }),
+ ],
+};
+
+const now = "2026-05-25T00:00:00Z";
+
+describe("bulkAddTag", () => {
+ it("adds a tag to each selected bookmark without duplicating", () => {
+ const mutator = bulkAddTag(
+ ["01HXYZ8K7M9P3RQ2V5W6Z8B0CA", "01HXYZ8K7M9P3RQ2V5W6Z8B0CB", "01HXYZ8K7M9P3RQ2V5W6Z8B0CC"],
+ "daily",
+ now,
+ );
+ const next = mutator(file);
+ expect(next.bookmarks[0]!.tags).toEqual(["daily"]);
+ expect(next.bookmarks[1]!.tags).toEqual(["daily", "to-read"]);
+ expect(next.bookmarks[2]!.tags).toEqual(["daily"]);
+ });
+
+ it("returned mutator is pure (same input → same output)", () => {
+ const mutator = bulkAddTag(["01HXYZ8K7M9P3RQ2V5W6Z8B0CA"], "new", now);
+ expect(mutator(file)).toEqual(mutator(file));
+ });
+});
+
+describe("bulkRemoveTag", () => {
+ it("removes the tag from each selected bookmark; no-op when absent", () => {
+ const mutator = bulkRemoveTag(
+ ["01HXYZ8K7M9P3RQ2V5W6Z8B0CA", "01HXYZ8K7M9P3RQ2V5W6Z8B0CB", "01HXYZ8K7M9P3RQ2V5W6Z8B0CC"],
+ "daily",
+ now,
+ );
+ const next = mutator(file);
+ expect(next.bookmarks[0]!.tags).toEqual([]);
+ expect(next.bookmarks[1]!.tags).toEqual(["to-read"]);
+ expect(next.bookmarks[2]!.tags).toEqual([]);
+ });
+});
+
+describe("bulkSetFolder", () => {
+ it("sets folder on each selected bookmark", () => {
+ const mutator = bulkSetFolder(
+ ["01HXYZ8K7M9P3RQ2V5W6Z8B0CA", "01HXYZ8K7M9P3RQ2V5W6Z8B0CB"],
+ "Archive",
+ now,
+ );
+ const next = mutator(file);
+ expect(next.bookmarks[0]!.folder).toBe("Archive");
+ expect(next.bookmarks[1]!.folder).toBe("Archive");
+ expect(next.bookmarks[2]!.folder).toBe("");
+ });
+});
+
+describe("bulkSoftDelete", () => {
+ it("sets deleted_at on each selected bookmark", () => {
+ const mutator = bulkSoftDelete(
+ ["01HXYZ8K7M9P3RQ2V5W6Z8B0CA", "01HXYZ8K7M9P3RQ2V5W6Z8B0CB"],
+ now,
+ );
+ const next = mutator(file);
+ expect(next.bookmarks[0]!.deleted_at).toBe(now);
+ expect(next.bookmarks[1]!.deleted_at).toBe(now);
+ expect(next.bookmarks[2]!.deleted_at).toBeNull();
+ });
+});
+
+describe("bulkRestore", () => {
+ it("clears deleted_at on each selected bookmark", () => {
+ const mutator = bulkRestore(["01HXYZ8K7M9P3RQ2V5W6Z8B0CD"], now);
+ const next = mutator(file);
+ expect(next.bookmarks[3]!.deleted_at).toBeNull();
+ expect(next.bookmarks[3]!.updated_at).toBe(now);
+ });
+
+ it("throws via updateBookmarks when an id is missing", () => {
+ const mutator = bulkRestore(["01HXYZ8K7M9P3RQ2V5W6Z8B0CZ"], now);
+ expect(() => mutator(file)).toThrow(/not found/);
+ });
+});
From 992de4e14cc519561f4f88a71d9abec791dee213 Mon Sep 17 00:00:00 2001
From: Sidney von Katzendame
Date: Mon, 25 May 2026 19:32:33 -0400
Subject: [PATCH 05/18] feat(web): optional selection props on BookmarkRow +
select-all header
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Extend BookmarkRow with optional selected/onToggleSelect props and
BookmarkList with selected/onToggleSelect/onSetAll props; checkbox UI
is only rendered when onToggleSelect is provided, keeping the legacy
call-site (no selection props) fully unchanged. 3 new tests → 83 total.
Co-Authored-By: Claude Sonnet 4.6
---
packages/web/src/components/BookmarkList.tsx | 48 +++++++++++++--
packages/web/src/components/BookmarkRow.tsx | 60 ++++++++++++-------
.../web/test/components.BookmarkList.test.tsx | 39 +++++++++++-
3 files changed, 117 insertions(+), 30 deletions(-)
diff --git a/packages/web/src/components/BookmarkList.tsx b/packages/web/src/components/BookmarkList.tsx
index c59443d..f6c8697 100644
--- a/packages/web/src/components/BookmarkList.tsx
+++ b/packages/web/src/components/BookmarkList.tsx
@@ -5,9 +5,18 @@ import { visibleBookmarks } from "../lib/data.js";
interface Props {
bookmarksFile: BookmarksFile;
tagsFile: TagsFile;
+ selected?: ReadonlySet;
+ onToggleSelect?: (id: string) => void;
+ onSetAll?: (ids: string[]) => void;
}
-export function BookmarkList({ bookmarksFile, tagsFile }: Props) {
+export function BookmarkList({
+ bookmarksFile,
+ tagsFile,
+ selected,
+ onToggleSelect,
+ onSetAll,
+}: Props) {
const items = visibleBookmarks(bookmarksFile);
if (items.length === 0) {
return (
@@ -16,11 +25,38 @@ export function BookmarkList({ bookmarksFile, tagsFile }: Props) {
);
}
+ const showSelectAll = onToggleSelect !== undefined;
+ const allSelected =
+ showSelectAll && selected !== undefined && items.length > 0 && items.every((b) => selected.has(b.id));
return (
-
- {items.map((b) => (
-
- ))}
-
+
+ {showSelectAll && (
+
+ {
+ if (onSetAll !== undefined) {
+ onSetAll(allSelected ? [] : items.map((b) => b.id));
+ }
+ }}
+ />
+ {selected?.size ?? 0} selected
+
+ )}
+
+ {items.map((b) => (
+
+ ))}
+
+
);
}
diff --git a/packages/web/src/components/BookmarkRow.tsx b/packages/web/src/components/BookmarkRow.tsx
index 17b644d..d057dcc 100644
--- a/packages/web/src/components/BookmarkRow.tsx
+++ b/packages/web/src/components/BookmarkRow.tsx
@@ -4,34 +4,48 @@ import { TagChip } from "./TagChip.js";
interface Props {
bookmark: Bookmark;
tagsFile: TagsFile;
+ selected?: boolean;
+ onToggleSelect?: (id: string) => void;
}
-export function BookmarkRow({ bookmark, tagsFile }: Props) {
+export function BookmarkRow({ bookmark, tagsFile, selected, onToggleSelect }: Props) {
const folder = bookmark.folder.length > 0 ? bookmark.folder : "(root)";
+ const showCheckbox = onToggleSelect !== undefined;
return (
-
-
- {bookmark.url}
- {bookmark.tags.length > 0 && (
-
- {bookmark.tags.map((t) => (
-
- ))}
-
- )}
- {bookmark.notes != null && (
- {bookmark.notes}
+
+ {showCheckbox && (
+ onToggleSelect(bookmark.id)}
+ className="mt-1.5"
+ />
)}
+
+
+
{bookmark.url}
+ {bookmark.tags.length > 0 && (
+
+ {bookmark.tags.map((t) => (
+
+ ))}
+
+ )}
+ {bookmark.notes != null && (
+
{bookmark.notes}
+ )}
+
);
}
diff --git a/packages/web/test/components.BookmarkList.test.tsx b/packages/web/test/components.BookmarkList.test.tsx
index 7961d1b..94aff4d 100644
--- a/packages/web/test/components.BookmarkList.test.tsx
+++ b/packages/web/test/components.BookmarkList.test.tsx
@@ -1,5 +1,6 @@
-import { describe, it, expect } from "vitest";
+import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
import type { BookmarksFile, TagsFile } from "@gitmarks/core";
import { BookmarkList } from "../src/components/BookmarkList.js";
@@ -64,4 +65,40 @@ describe("BookmarkList", () => {
render( );
expect(screen.getByText(/no bookmarks yet/i)).toBeInTheDocument();
});
+
+ it("renders a checkbox per row when onToggleSelect is provided", () => {
+ const onToggleSelect = vi.fn();
+ render(
+ ,
+ );
+ const checkboxes = screen.getAllByRole("checkbox");
+ // 1 row checkbox + 1 select-all = 2
+ expect(checkboxes.length).toBeGreaterThanOrEqual(2);
+ });
+
+ it("calls onToggleSelect with the bookmark id when its checkbox is clicked", async () => {
+ const user = userEvent.setup();
+ const onToggleSelect = vi.fn();
+ render(
+ ,
+ );
+ const rowCheckbox = screen.getByLabelText(/select hacker news/i);
+ await user.click(rowCheckbox);
+ expect(onToggleSelect).toHaveBeenCalledWith("01HXYZ8K7M9P3RQ2V5W6Z8B0CA");
+ });
+
+ it("renders no checkboxes when onToggleSelect is not provided", () => {
+ render( );
+ expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
+ });
});
From faf244955a001511530198216f9b97bb833540b5 Mon Sep 17 00:00:00 2001
From: Sidney von Katzendame
Date: Mon, 25 May 2026 19:34:29 -0400
Subject: [PATCH 06/18] feat(web): bulk actions bar (add tag, remove tag, set
folder, delete, clear)
Co-Authored-By: Claude Sonnet 4.6
---
.../web/src/components/BulkActionsBar.tsx | 119 ++++++++++++++++++
.../test/components.BulkActionsBar.test.tsx | 73 +++++++++++
2 files changed, 192 insertions(+)
create mode 100644 packages/web/src/components/BulkActionsBar.tsx
create mode 100644 packages/web/test/components.BulkActionsBar.test.tsx
diff --git a/packages/web/src/components/BulkActionsBar.tsx b/packages/web/src/components/BulkActionsBar.tsx
new file mode 100644
index 0000000..d1cc4d7
--- /dev/null
+++ b/packages/web/src/components/BulkActionsBar.tsx
@@ -0,0 +1,119 @@
+import { useState } from "react";
+import type { TagsFile } from "@gitmarks/core";
+
+interface Props {
+ count: number;
+ tagsFile: TagsFile;
+ onAddTag: (tag: string) => Promise;
+ onRemoveTag: (tag: string) => Promise;
+ onSetFolder: (folder: string) => Promise;
+ onDelete: () => Promise;
+ onClear: () => void;
+}
+
+const inputClass =
+ "px-2 py-1 bg-mist border border-fog rounded text-cyan-soft text-sm focus:border-cyan focus:outline-none";
+const btnClass =
+ "px-3 py-1 rounded bg-fog text-cyan-soft text-sm hover:bg-cyan hover:text-ink disabled:opacity-40";
+const dangerClass =
+ "px-3 py-1 rounded border border-magenta text-magenta text-sm hover:bg-magenta hover:text-ink";
+
+export function BulkActionsBar({
+ count,
+ tagsFile,
+ onAddTag,
+ onRemoveTag,
+ onSetFolder,
+ onDelete,
+ onClear,
+}: Props) {
+ const [tagToAdd, setTagToAdd] = useState("");
+ const [tagToRemove, setTagToRemove] = useState("");
+ const [folder, setFolder] = useState("");
+ const tagOptions = Object.keys(tagsFile.tags).sort();
+
+ return (
+
+
{count} selected
+
+
+ setTagToAdd(e.target.value)}
+ placeholder="tag"
+ />
+ {
+ await onAddTag(tagToAdd);
+ setTagToAdd("");
+ }}
+ >
+ Add
+
+
+
+
+ setTagToRemove(e.target.value)}
+ >
+ (pick a tag)
+ {tagOptions.map((t) => (
+ {t}
+ ))}
+
+ {
+ await onRemoveTag(tagToRemove);
+ setTagToRemove("");
+ }}
+ >
+ Remove
+
+
+
+
+ setFolder(e.target.value)}
+ placeholder="folder path"
+ />
+ {
+ await onSetFolder(folder);
+ setFolder("");
+ }}
+ >
+ Set
+
+
+
+
{ void onDelete(); }}>
+ Move to trash
+
+
+
+ Clear
+
+
+ );
+}
diff --git a/packages/web/test/components.BulkActionsBar.test.tsx b/packages/web/test/components.BulkActionsBar.test.tsx
new file mode 100644
index 0000000..a7d108f
--- /dev/null
+++ b/packages/web/test/components.BulkActionsBar.test.tsx
@@ -0,0 +1,73 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import type { TagsFile } from "@gitmarks/core";
+import { BulkActionsBar } from "../src/components/BulkActionsBar.js";
+
+const tagsFile: TagsFile = {
+ version: 1,
+ tags: {
+ daily: { color: "#00FFFF", description: null },
+ reference: { color: "#00FF88", description: null },
+ },
+};
+
+function noopHandlers() {
+ return {
+ onAddTag: vi.fn().mockResolvedValue(undefined),
+ onRemoveTag: vi.fn().mockResolvedValue(undefined),
+ onSetFolder: vi.fn().mockResolvedValue(undefined),
+ onDelete: vi.fn().mockResolvedValue(undefined),
+ onClear: vi.fn(),
+ };
+}
+
+describe("BulkActionsBar", () => {
+ it("shows the selection count", () => {
+ render( );
+ expect(screen.getByText(/3 selected/i)).toBeInTheDocument();
+ });
+
+ it("calls onAddTag with the typed tag", async () => {
+ const user = userEvent.setup();
+ const handlers = noopHandlers();
+ render( );
+ await user.type(screen.getByLabelText(/add tag/i), "weekly");
+ await user.click(screen.getByRole("button", { name: /^add$/i }));
+ expect(handlers.onAddTag).toHaveBeenCalledWith("weekly");
+ });
+
+ it("calls onRemoveTag with the picked tag", async () => {
+ const user = userEvent.setup();
+ const handlers = noopHandlers();
+ render( );
+ await user.selectOptions(screen.getByLabelText(/remove tag/i), "reference");
+ await user.click(screen.getByRole("button", { name: /^remove$/i }));
+ expect(handlers.onRemoveTag).toHaveBeenCalledWith("reference");
+ });
+
+ it("calls onSetFolder with the typed folder", async () => {
+ const user = userEvent.setup();
+ const handlers = noopHandlers();
+ render( );
+ await user.type(screen.getByLabelText(/set folder/i), "Archive");
+ await user.click(screen.getByRole("button", { name: /^set$/i }));
+ expect(handlers.onSetFolder).toHaveBeenCalledWith("Archive");
+ });
+
+ it("calls onDelete when Move to trash is clicked", async () => {
+ const user = userEvent.setup();
+ const handlers = noopHandlers();
+ render( );
+ await user.click(screen.getByRole("button", { name: /move to trash/i }));
+ expect(handlers.onDelete).toHaveBeenCalled();
+ });
+
+ it("calls onClear when Clear is clicked", async () => {
+ const user = userEvent.setup();
+ const handlers = noopHandlers();
+ render( );
+ await user.click(screen.getByRole("button", { name: /^clear$/i }));
+ expect(handlers.onClear).toHaveBeenCalled();
+ });
+});
From dddafff324657835e9768a83a4d9c42990303665 Mon Sep 17 00:00:00 2001
From: Sidney von Katzendame
Date: Mon, 25 May 2026 19:37:01 -0400
Subject: [PATCH 07/18] feat(web): list page bulk select + bulk actions wired
to client.update
Wires useSelection + BulkActionsBar into ListPage, passing selected/onToggleSelect/onSetAll to BookmarkList. Adds two integration tests (91 web total). Also fixes BookmarkList select-all header counter text to avoid DOM ambiguity with BulkActionsBar's "N selected" label.
Co-Authored-By: Claude Sonnet 4.6
---
packages/web/src/components/BookmarkList.tsx | 2 +-
packages/web/src/routes/ListPage.tsx | 66 +++++++++++++++++--
.../web/test/ListPage.integration.test.tsx | 36 ++++++++++
3 files changed, 97 insertions(+), 7 deletions(-)
diff --git a/packages/web/src/components/BookmarkList.tsx b/packages/web/src/components/BookmarkList.tsx
index f6c8697..b73b0e1 100644
--- a/packages/web/src/components/BookmarkList.tsx
+++ b/packages/web/src/components/BookmarkList.tsx
@@ -42,7 +42,7 @@ export function BookmarkList({
}
}}
/>
- {selected?.size ?? 0} selected
+ {selected?.size ?? 0} of {items.length}
)}
diff --git a/packages/web/src/routes/ListPage.tsx b/packages/web/src/routes/ListPage.tsx
index 3100924..91f95c5 100644
--- a/packages/web/src/routes/ListPage.tsx
+++ b/packages/web/src/routes/ListPage.tsx
@@ -1,21 +1,31 @@
import { useMemo, useState } from "react";
-import type { GitHubClient } from "@gitmarks/core";
+import type { BookmarksFile, GitHubClient } from "@gitmarks/core";
import { useGitmarksData } from "../hooks/useGitmarksData.js";
+import { useSelection } from "../hooks/useSelection.js";
import { BookmarkList } from "../components/BookmarkList.js";
+import { BulkActionsBar } from "../components/BulkActionsBar.js";
import { SearchBar } from "../components/SearchBar.js";
import { TagFilter } from "../components/TagFilter.js";
import { Layout, type LayoutStatus } from "../components/Layout.js";
import { allUsedTags, searchBookmarks, visibleBookmarks } from "../lib/data.js";
+import {
+ bulkAddTag,
+ bulkRemoveTag,
+ bulkSetFolder,
+ bulkSoftDelete,
+} from "../lib/bulk-mutations.js";
interface Props {
client: GitHubClient;
}
export function ListPage({ client }: Props) {
- const { bookmarksFile, tagsFile, loading, error, refresh } = useGitmarksData(client);
+ const { bookmarksFile, tagsFile, loading, error, refresh, writeBookmarks } = useGitmarksData(client);
+ const selection = useSelection();
const [query, setQuery] = useState("");
const [selectedTag, setSelectedTag] = useState(null);
const [refreshing, setRefreshing] = useState(false);
+ const [writeError, setWriteError] = useState(null);
const visible = useMemo(
() => (bookmarksFile != null ? visibleBookmarks(bookmarksFile) : []),
@@ -33,9 +43,11 @@ export function ListPage({ client }: Props) {
const status: LayoutStatus = loading
? { kind: "loading", message: "loading…" }
- : error != null
- ? { kind: "err", message: error }
- : { kind: "ok", message: `${visible.length} bookmarks` };
+ : writeError != null
+ ? { kind: "err", message: writeError }
+ : error != null
+ ? { kind: "err", message: error }
+ : { kind: "ok", message: `${visible.length} bookmarks` };
const filteredFile = bookmarksFile != null
? { ...bookmarksFile, bookmarks: searched }
@@ -50,6 +62,23 @@ export function ListPage({ client }: Props) {
}
}
+ function ids(): string[] {
+ return [...selection.selected];
+ }
+
+ async function runBulk(
+ message: string,
+ mutator: (f: BookmarksFile) => BookmarksFile,
+ ) {
+ setWriteError(null);
+ try {
+ await writeBookmarks(mutator, message);
+ selection.clear();
+ } catch (err) {
+ setWriteError(err instanceof Error ? err.message : String(err));
+ }
+ }
+
return (
@@ -66,9 +95,34 @@ export function ListPage({ client }: Props) {
+ {selection.selected.size > 0 && tagsFile != null && (
+
+ runBulk(`bulk: add tag ${tag}`, bulkAddTag(ids(), tag, new Date().toISOString()))
+ }
+ onRemoveTag={(tag) =>
+ runBulk(`bulk: remove tag ${tag}`, bulkRemoveTag(ids(), tag, new Date().toISOString()))
+ }
+ onSetFolder={(folder) =>
+ runBulk(`bulk: set folder ${folder}`, bulkSetFolder(ids(), folder, new Date().toISOString()))
+ }
+ onDelete={() =>
+ runBulk(`bulk: move ${ids().length} to trash`, bulkSoftDelete(ids(), new Date().toISOString()))
+ }
+ onClear={() => selection.clear()}
+ />
+ )}
{filteredFile != null && tagsFile != null && (
-
+ selection.setAll(idsList)}
+ />
)}
diff --git a/packages/web/test/ListPage.integration.test.tsx b/packages/web/test/ListPage.integration.test.tsx
index 4d6be27..2a1ed68 100644
--- a/packages/web/test/ListPage.integration.test.tsx
+++ b/packages/web/test/ListPage.integration.test.tsx
@@ -76,4 +76,40 @@ describe("ListPage integration", () => {
await user.click(chip);
expect(screen.getByText("Tailwind")).toBeInTheDocument();
});
+
+ it("shows the bulk actions bar after selecting a row", async () => {
+ const user = userEvent.setup();
+ render(
+
+
+ ,
+ );
+ await screen.findByText("Hacker News");
+ await user.click(screen.getByLabelText(/select hacker news/i));
+ expect(screen.getByText(/1 selected/i)).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /move to trash/i })).toBeInTheDocument();
+ });
+
+ it("calls client.update on bookmarks.json when Move to trash is clicked", async () => {
+ const update = vi.fn().mockResolvedValue({ data: bookmarksFile, sha: "b2", etag: '"b2"' });
+ const client = {
+ read: vi.fn().mockImplementation(async (path: string) => {
+ if (path === "bookmarks.json") return { data: bookmarksFile, sha: "b", etag: '"b"' };
+ if (path === "tags.json") return { data: tagsFile, sha: "t", etag: '"t"' };
+ throw new Error("unexpected");
+ }),
+ readIfChanged: vi.fn().mockResolvedValue(null),
+ update,
+ } as any;
+ const user = userEvent.setup();
+ render(
+
+
+ ,
+ );
+ await screen.findByText("Hacker News");
+ await user.click(screen.getByLabelText(/select hacker news/i));
+ await user.click(screen.getByRole("button", { name: /move to trash/i }));
+ expect(update).toHaveBeenCalledWith("bookmarks.json", expect.any(Function), expect.stringContaining("trash"));
+ });
});
From a1079d67e50e326aae2af78439ba456dc0009d68 Mon Sep 17 00:00:00 2001
From: Sidney von Katzendame
Date: Mon, 25 May 2026 19:40:52 -0400
Subject: [PATCH 08/18] feat(web): trash route with restore via bulkRestore
Adds deletedBookmarks helper, TrashList component, TrashPage route, and
wires /trash into the hash router with single-item restore via bulkRestore.
Co-Authored-By: Claude Sonnet 4.6
---
packages/web/src/App.tsx | 7 ++
packages/web/src/components/TrashList.tsx | 45 +++++++++++
packages/web/src/lib/data.ts | 8 ++
packages/web/src/routes/TrashPage.tsx | 64 +++++++++++++++
.../web/test/components.TrashList.test.tsx | 81 +++++++++++++++++++
packages/web/test/lib.data.test.ts | 24 +++++-
6 files changed, 228 insertions(+), 1 deletion(-)
create mode 100644 packages/web/src/components/TrashList.tsx
create mode 100644 packages/web/src/routes/TrashPage.tsx
create mode 100644 packages/web/test/components.TrashList.test.tsx
diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx
index 4f8cbb0..60b3cba 100644
--- a/packages/web/src/App.tsx
+++ b/packages/web/src/App.tsx
@@ -12,6 +12,7 @@ import type { GitHubClient } from "@gitmarks/core";
import { SetupPage } from "./routes/SetupPage.js";
import { ListPage } from "./routes/ListPage.js";
import { TagsPage } from "./routes/TagsPage.js";
+import { TrashPage } from "./routes/TrashPage.js";
interface AppContext {
settings: Settings;
@@ -49,6 +50,11 @@ function TagsPageWithContext() {
return ;
}
+function TrashPageWithContext() {
+ const { client } = useAppContext();
+ return ;
+}
+
export function App() {
const router = useMemo(
() =>
@@ -59,6 +65,7 @@ export function App() {
children: [
{ path: "/", element: },
{ path: "/tags", element: },
+ { path: "/trash", element: },
],
},
]),
diff --git a/packages/web/src/components/TrashList.tsx b/packages/web/src/components/TrashList.tsx
new file mode 100644
index 0000000..1315451
--- /dev/null
+++ b/packages/web/src/components/TrashList.tsx
@@ -0,0 +1,45 @@
+import type { BookmarksFile, TagsFile } from "@gitmarks/core";
+import { TagChip } from "./TagChip.js";
+import { deletedBookmarks } from "../lib/data.js";
+
+interface Props {
+ bookmarksFile: BookmarksFile;
+ tagsFile: TagsFile;
+ nowIso: string;
+ onRestore: (id: string) => void | Promise;
+}
+
+export function TrashList({ bookmarksFile, tagsFile, nowIso, onRestore }: Props) {
+ const items = deletedBookmarks(bookmarksFile, nowIso);
+ if (items.length === 0) {
+ return Trash is empty.
;
+ }
+ return (
+
+ {items.map((b) => (
+
+
+
{b.title}
+
{b.url}
+
+ deleted {b.deleted_at} · folder {b.folder.length > 0 ? b.folder : "(root)"}
+
+ {b.tags.length > 0 && (
+
+ {b.tags.map((t) => )}
+
+ )}
+
+ { void onRestore(b.id); }}
+ >
+ Restore
+
+
+ ))}
+
+ );
+}
diff --git a/packages/web/src/lib/data.ts b/packages/web/src/lib/data.ts
index c7a5933..b95343c 100644
--- a/packages/web/src/lib/data.ts
+++ b/packages/web/src/lib/data.ts
@@ -20,3 +20,11 @@ export function allUsedTags(bookmarks: Bookmark[]): Set {
for (const b of bookmarks) for (const t of b.tags) out.add(t);
return out;
}
+
+export function deletedBookmarks(file: BookmarksFile, nowIso: string, gcDays = 30): Bookmark[] {
+ const cutoffMs = new Date(nowIso).getTime() - gcDays * 86_400_000;
+ return file.bookmarks.filter((b) => {
+ if (b.deleted_at == null) return false;
+ return new Date(b.deleted_at).getTime() > cutoffMs;
+ });
+}
diff --git a/packages/web/src/routes/TrashPage.tsx b/packages/web/src/routes/TrashPage.tsx
new file mode 100644
index 0000000..7309d09
--- /dev/null
+++ b/packages/web/src/routes/TrashPage.tsx
@@ -0,0 +1,64 @@
+import { useState } from "react";
+import type { GitHubClient } from "@gitmarks/core";
+import { Layout, type LayoutStatus } from "../components/Layout.js";
+import { TrashList } from "../components/TrashList.js";
+import { useGitmarksData } from "../hooks/useGitmarksData.js";
+import { bulkRestore } from "../lib/bulk-mutations.js";
+
+interface Props {
+ client: GitHubClient;
+}
+
+export function TrashPage({ client }: Props) {
+ const { bookmarksFile, tagsFile, loading, error, refresh, writeBookmarks } = useGitmarksData(client);
+ const [refreshing, setRefreshing] = useState(false);
+ const [writeError, setWriteError] = useState(null);
+
+ const status: LayoutStatus = loading
+ ? { kind: "loading", message: "loading…" }
+ : writeError != null
+ ? { kind: "err", message: writeError }
+ : error != null
+ ? { kind: "err", message: error }
+ : { kind: "ok", message: "trash" };
+
+ async function onRefresh() {
+ setRefreshing(true);
+ try {
+ await refresh();
+ } finally {
+ setRefreshing(false);
+ }
+ }
+
+ async function onRestore(id: string) {
+ setWriteError(null);
+ try {
+ const mutator = bulkRestore([id], new Date().toISOString());
+ await writeBookmarks(mutator, `restore bookmark ${id}`);
+ } catch (err) {
+ setWriteError(err instanceof Error ? err.message : String(err));
+ }
+ }
+
+ return (
+
+
+
Trash
+
+ Soft-deleted bookmarks within the 30-day GC window. After 30 days the
+ extension's gcTombstones removes them from bookmarks.json; git history
+ retains everything.
+
+ {bookmarksFile != null && tagsFile != null && (
+
+ )}
+
+
+ );
+}
diff --git a/packages/web/test/components.TrashList.test.tsx b/packages/web/test/components.TrashList.test.tsx
new file mode 100644
index 0000000..74040bd
--- /dev/null
+++ b/packages/web/test/components.TrashList.test.tsx
@@ -0,0 +1,81 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import type { BookmarksFile, TagsFile } from "@gitmarks/core";
+import { TrashList } from "../src/components/TrashList.js";
+
+const tagsFile: TagsFile = { version: 1, tags: {} };
+
+const bookmarksFile: BookmarksFile = {
+ version: 1,
+ updated_at: "2026-05-25T00:00:00Z",
+ bookmarks: [
+ {
+ id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CA",
+ url: "https://gone.example.com/",
+ title: "Recently deleted",
+ folder: "",
+ tags: [],
+ added_at: "2026-05-01T00:00:00Z",
+ updated_at: "2026-05-20T00:00:00Z",
+ added_from: "chrome@minerva",
+ deleted_at: "2026-05-20T00:00:00Z",
+ notes: null,
+ },
+ {
+ id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CB",
+ url: "https://alive.example.com/",
+ title: "Still alive",
+ folder: "",
+ tags: [],
+ added_at: "2026-05-01T00:00:00Z",
+ updated_at: "2026-05-01T00:00:00Z",
+ added_from: "chrome@minerva",
+ deleted_at: null,
+ notes: null,
+ },
+ ],
+};
+
+describe("TrashList", () => {
+ it("renders only deleted bookmarks within the GC window", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("Recently deleted")).toBeInTheDocument();
+ expect(screen.queryByText("Still alive")).not.toBeInTheDocument();
+ });
+
+ it("calls onRestore with the bookmark id when its restore button is clicked", async () => {
+ const onRestore = vi.fn();
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+ await user.click(screen.getByRole("button", { name: /restore recently deleted/i }));
+ expect(onRestore).toHaveBeenCalledWith("01HXYZ8K7M9P3RQ2V5W6Z8B0CA");
+ });
+
+ it("renders an empty state when no deletes are within the GC window", () => {
+ const empty: BookmarksFile = { ...bookmarksFile, bookmarks: [bookmarksFile.bookmarks[1]!] };
+ render(
+ ,
+ );
+ expect(screen.getByText(/trash is empty/i)).toBeInTheDocument();
+ });
+});
diff --git a/packages/web/test/lib.data.test.ts b/packages/web/test/lib.data.test.ts
index 7d2cb5e..1829ce6 100644
--- a/packages/web/test/lib.data.test.ts
+++ b/packages/web/test/lib.data.test.ts
@@ -1,6 +1,6 @@
import { describe, it, expect } from "vitest";
import type { Bookmark, BookmarksFile } from "@gitmarks/core";
-import { searchBookmarks, visibleBookmarks, allUsedTags } from "../src/lib/data.js";
+import { searchBookmarks, visibleBookmarks, allUsedTags, deletedBookmarks } from "../src/lib/data.js";
function mk(over: Partial = {}): Bookmark {
return {
@@ -76,3 +76,25 @@ describe("allUsedTags", () => {
expect(allUsedTags([])).toEqual(new Set());
});
});
+
+describe("deletedBookmarks", () => {
+ const fileWithDeletes: BookmarksFile = {
+ version: 1,
+ updated_at: "2026-05-25T00:00:00Z",
+ bookmarks: [
+ mk({ id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CA", deleted_at: null }),
+ mk({ id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CB", deleted_at: "2026-05-20T00:00:00Z" }),
+ mk({ id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CC", deleted_at: "2026-03-01T00:00:00Z" }),
+ ],
+ };
+
+ it("returns deleted bookmarks within the GC window", () => {
+ const got = deletedBookmarks(fileWithDeletes, "2026-05-25T00:00:00Z", 30);
+ expect(got.map((b) => b.id)).toEqual(["01HXYZ8K7M9P3RQ2V5W6Z8B0CB"]);
+ });
+
+ it("returns empty when all deletes are past the GC window", () => {
+ const got = deletedBookmarks(fileWithDeletes, "2027-01-01T00:00:00Z", 30);
+ expect(got).toEqual([]);
+ });
+});
From 1e882261adb677230d47b8683e784aab6c6b434a Mon Sep 17 00:00:00 2001
From: Sidney von Katzendame
Date: Mon, 25 May 2026 19:43:04 -0400
Subject: [PATCH 09/18] feat(web): Netscape HTML export utility (folder-aware,
XSS-safe)
Co-Authored-By: Claude Sonnet 4.6
---
packages/web/src/lib/netscape-export.ts | 97 ++++++++++++++++
packages/web/test/lib.netscape-export.test.ts | 108 ++++++++++++++++++
2 files changed, 205 insertions(+)
create mode 100644 packages/web/src/lib/netscape-export.ts
create mode 100644 packages/web/test/lib.netscape-export.test.ts
diff --git a/packages/web/src/lib/netscape-export.ts b/packages/web/src/lib/netscape-export.ts
new file mode 100644
index 0000000..13e09f9
--- /dev/null
+++ b/packages/web/src/lib/netscape-export.ts
@@ -0,0 +1,97 @@
+import type { Bookmark, BookmarksFile } from "@gitmarks/core";
+
+// Netscape Bookmark File Format reference:
+// https://msdn.microsoft.com/en-us/library/aa753582(v=vs.85).aspx
+// All major browsers import this format.
+
+interface FolderNode {
+ name: string;
+ bookmarks: Bookmark[];
+ children: Map;
+}
+
+function emptyFolder(name: string): FolderNode {
+ return { name, bookmarks: [], children: new Map() };
+}
+
+function buildTree(bookmarks: Bookmark[]): FolderNode {
+ const root = emptyFolder("");
+ for (const b of bookmarks) {
+ if (b.folder.length === 0) {
+ root.bookmarks.push(b);
+ continue;
+ }
+ const segments = b.folder.split("/").filter((s) => s.length > 0);
+ let cursor = root;
+ for (const seg of segments) {
+ let next = cursor.children.get(seg);
+ if (next === undefined) {
+ next = emptyFolder(seg);
+ cursor.children.set(seg, next);
+ }
+ cursor = next;
+ }
+ cursor.bookmarks.push(b);
+ }
+ return root;
+}
+
+function escapeHtml(s: string): string {
+ return s
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
+}
+
+function epochSeconds(iso: string): number | null {
+ const ms = Date.parse(iso);
+ if (Number.isNaN(ms)) return null;
+ return Math.floor(ms / 1000);
+}
+
+function renderBookmark(b: Bookmark, indent: string): string {
+ const attrs: string[] = [`HREF="${escapeHtml(b.url)}"`];
+ const added = epochSeconds(b.added_at);
+ if (added !== null) attrs.push(`ADD_DATE="${added}"`);
+ const updated = epochSeconds(b.updated_at);
+ if (updated !== null) attrs.push(`LAST_MODIFIED="${updated}"`);
+ if (b.tags.length > 0) attrs.push(`TAGS="${escapeHtml(b.tags.join(","))}"`);
+ return `${indent}${escapeHtml(b.title)} `;
+}
+
+function renderFolder(node: FolderNode, indent: string): string {
+ const lines: string[] = [];
+ if (node.name.length > 0) {
+ lines.push(`${indent}${escapeHtml(node.name)} `);
+ lines.push(`${indent}`);
+ }
+ const inner = node.name.length > 0 ? `${indent} ` : indent;
+ for (const child of [...node.children.values()].sort((a, b) => a.name.localeCompare(b.name))) {
+ lines.push(renderFolder(child, inner));
+ }
+ for (const b of node.bookmarks) {
+ lines.push(renderBookmark(b, inner));
+ }
+ if (node.name.length > 0) {
+ lines.push(`${indent}
`);
+ }
+ return lines.join("\n");
+}
+
+export function toNetscapeHtml(file: BookmarksFile): string {
+ const alive = file.bookmarks.filter((b) => b.deleted_at == null);
+ const root = buildTree(alive);
+ const body = renderFolder(root, " ");
+ return [
+ "",
+ "",
+ ' ',
+ "
Bookmarks ",
+ "Bookmarks ",
+ "",
+ body,
+ "
",
+ "",
+ ].join("\n");
+}
diff --git a/packages/web/test/lib.netscape-export.test.ts b/packages/web/test/lib.netscape-export.test.ts
new file mode 100644
index 0000000..6d75550
--- /dev/null
+++ b/packages/web/test/lib.netscape-export.test.ts
@@ -0,0 +1,108 @@
+import { describe, it, expect } from "vitest";
+import type { BookmarksFile } from "@gitmarks/core";
+import { toNetscapeHtml } from "../src/lib/netscape-export.js";
+
+const file: BookmarksFile = {
+ version: 1,
+ updated_at: "2026-05-25T00:00:00Z",
+ bookmarks: [
+ {
+ id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CA",
+ url: "https://news.ycombinator.com/",
+ title: "Hacker News",
+ folder: "",
+ tags: ["daily"],
+ added_at: "2026-05-01T08:00:00Z",
+ updated_at: "2026-05-01T08:00:00Z",
+ added_from: "chrome@minerva",
+ deleted_at: null,
+ notes: null,
+ },
+ {
+ id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CB",
+ url: "https://arxiv.org/abs/2310.00001",
+ title: "Paper",
+ folder: "Research/AI",
+ tags: ["to-read"],
+ added_at: "2026-05-02T09:00:00Z",
+ updated_at: "2026-05-02T09:00:00Z",
+ added_from: "firefox@minerva",
+ deleted_at: null,
+ notes: null,
+ },
+ {
+ id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CC",
+ url: "https://example.com/deleted",
+ title: "Gone",
+ folder: "",
+ tags: [],
+ added_at: "2026-04-01T00:00:00Z",
+ updated_at: "2026-05-10T00:00:00Z",
+ added_from: "chrome@minerva",
+ deleted_at: "2026-05-10T00:00:00Z",
+ notes: null,
+ },
+ ],
+};
+
+describe("toNetscapeHtml", () => {
+ it("emits the canonical Netscape DOCTYPE", () => {
+ const html = toNetscapeHtml(file);
+ expect(html).toContain("");
+ expect(html).toContain("
Bookmarks ");
+ });
+
+ it("renders each non-deleted bookmark as ", () => {
+ const html = toNetscapeHtml(file);
+ expect(html).toContain(' Hacker News ");
+ expect(html).toContain(' {
+ const html = toNetscapeHtml(file);
+ expect(html).not.toContain("https://example.com/deleted");
+ });
+
+ it("nests folder bookmarks under headings with ", () => {
+ const html = toNetscapeHtml(file);
+ expect(html).toMatch(/]*>Research<\/H3>[\s\S]*]*>AI<\/H3>/);
+ });
+
+ it("escapes HTML-sensitive characters in titles and URLs", () => {
+ const dangerous: BookmarksFile = {
+ version: 1,
+ updated_at: "2026-05-25T00:00:00Z",
+ bookmarks: [
+ {
+ id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CA",
+ url: "https://example.com/?a=1&b=2",
+ title: '',
+ folder: "",
+ tags: [],
+ added_at: "2026-05-01T00:00:00Z",
+ updated_at: "2026-05-01T00:00:00Z",
+ added_from: "chrome@minerva",
+ deleted_at: null,
+ notes: null,
+ },
+ ],
+ };
+ const html = toNetscapeHtml(dangerous);
+ expect(html).not.toContain("',
+ folder: "",
+ tags: [],
+ added_at: "2026-05-01T00:00:00Z",
+ updated_at: "2026-05-01T00:00:00Z",
+ added_from: "chrome@minerva",
+ deleted_at: null,
+ notes: null,
+ },
+ ],
+ };
+ const html = toNetscapeHtml(dangerous);
+ expect(html).not.toContain("")).toBe(false);
+ });
+
+ it("rejects vbscript:", () => {
+ expect(isSafeBookmarkUrl("vbscript:msgbox(1)")).toBe(false);
+ });
+
+ it("rejects malformed URLs", () => {
+ expect(isSafeBookmarkUrl("not a url")).toBe(false);
+ expect(isSafeBookmarkUrl("")).toBe(false);
+ });
+});
diff --git a/packages/extension-shared/src/lib/bookmark-factory.ts b/packages/extension-shared/src/lib/bookmark-factory.ts
index e3ad7a6..7b2460f 100644
--- a/packages/extension-shared/src/lib/bookmark-factory.ts
+++ b/packages/extension-shared/src/lib/bookmark-factory.ts
@@ -1,5 +1,5 @@
import type { Bookmark } from "@gitmarks/core";
-import { newUlid, normalizeUrl } from "@gitmarks/core";
+import { newUlid, normalizeUrl, isSafeBookmarkUrl } from "@gitmarks/core";
export interface BuildBookmarkInput {
url: string;
@@ -11,6 +11,9 @@ export interface BuildBookmarkInput {
}
export function buildBookmark(input: BuildBookmarkInput): Bookmark {
+ if (!isSafeBookmarkUrl(input.url)) {
+ throw new Error(`Refusing to save bookmark with unsafe URL scheme: ${input.url}`);
+ }
return {
id: newUlid(),
url: normalizeUrl(input.url, {
diff --git a/packages/extension-shared/test/bookmark-factory.test.ts b/packages/extension-shared/test/bookmark-factory.test.ts
index fa8498b..4be975a 100644
--- a/packages/extension-shared/test/bookmark-factory.test.ts
+++ b/packages/extension-shared/test/bookmark-factory.test.ts
@@ -91,4 +91,26 @@ describe("buildBookmark", () => {
});
expect(a.id).not.toBe(b.id);
});
+
+ it("throws when given a javascript: URL", () => {
+ expect(() =>
+ buildBookmark({
+ url: "javascript:alert(1)",
+ title: "Evil",
+ machineId: "ABCDE12F",
+ nowIso: "2026-05-23T14:32:11Z",
+ }),
+ ).toThrow(/unsafe URL scheme/i);
+ });
+
+ it("throws when given a data: URL", () => {
+ expect(() =>
+ buildBookmark({
+ url: "data:text/html,",
+ title: "Evil",
+ machineId: "ABCDE12F",
+ nowIso: "2026-05-23T14:32:11Z",
+ }),
+ ).toThrow(/unsafe URL scheme/i);
+ });
});
diff --git a/packages/web/src/components/BookmarkRow.tsx b/packages/web/src/components/BookmarkRow.tsx
index d057dcc..ceaf61e 100644
--- a/packages/web/src/components/BookmarkRow.tsx
+++ b/packages/web/src/components/BookmarkRow.tsx
@@ -1,4 +1,5 @@
import type { Bookmark, TagsFile } from "@gitmarks/core";
+import { isSafeBookmarkUrl } from "@gitmarks/core";
import { TagChip } from "./TagChip.js";
interface Props {
@@ -24,14 +25,23 @@ export function BookmarkRow({ bookmark, tagsFile, selected, onToggleSelect }: Pr
)}
{bookmark.url}
diff --git a/packages/web/test/components.BookmarkList.test.tsx b/packages/web/test/components.BookmarkList.test.tsx
index 94aff4d..95c013a 100644
--- a/packages/web/test/components.BookmarkList.test.tsx
+++ b/packages/web/test/components.BookmarkList.test.tsx
@@ -101,4 +101,58 @@ describe("BookmarkList", () => {
render(
);
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
});
+
+ it("renders a plain span (not an anchor) for unsafe URL schemes", () => {
+ const danger: BookmarksFile = {
+ version: 1,
+ updated_at: "2026-05-25T00:00:00Z",
+ bookmarks: [
+ {
+ id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CA",
+ url: "javascript:alert(1)",
+ title: "Click me",
+ folder: "",
+ tags: [],
+ added_at: "2026-05-01T00:00:00Z",
+ updated_at: "2026-05-01T00:00:00Z",
+ added_from: "chrome@minerva",
+ deleted_at: null,
+ notes: null,
+ },
+ ],
+ };
+ render(
);
+ expect(screen.queryByRole("link", { name: /click me/i })).not.toBeInTheDocument();
+ expect(screen.getByText("Click me")).toBeInTheDocument();
+ });
+
+ it("falls back to the default color when the tag color is malformed", () => {
+ const malformedTags: TagsFile = {
+ version: 1,
+ tags: { weird: { color: "red; background: url(x)", description: null } },
+ };
+ const bm: BookmarksFile = {
+ version: 1,
+ updated_at: "2026-05-25T00:00:00Z",
+ bookmarks: [
+ {
+ id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CA",
+ url: "https://example.com/",
+ title: "Tagged",
+ folder: "",
+ tags: ["weird"],
+ added_at: "2026-05-01T00:00:00Z",
+ updated_at: "2026-05-01T00:00:00Z",
+ added_from: "chrome@minerva",
+ deleted_at: null,
+ notes: null,
+ },
+ ],
+ };
+ render(
);
+ const chip = screen.getByText("weird");
+ // Inline style should reference the fallback #475569, not the attacker payload
+ expect(chip).toHaveAttribute("style");
+ expect(chip.getAttribute("style") ?? "").not.toContain("url(");
+ });
});
From dfd1ba9181e9c6580ccb81a53025a9b340967616 Mon Sep 17 00:00:00 2001
From: Sidney von Katzendame
Date: Mon, 25 May 2026 20:01:27 -0400
Subject: [PATCH 16/18] fix(security): validate tag color regex before CSS
interpolation
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
TagChip now checks rawColor against /^#[0-9A-Fa-f]{6}$/ before using it
in the inline style object. Any value that doesn't match (e.g. a CSS
injection payload like "red; background: url(…)") falls back to the default
#475569, so React never serializes untrusted content as raw CSS.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
packages/web/src/components/TagChip.tsx | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/packages/web/src/components/TagChip.tsx b/packages/web/src/components/TagChip.tsx
index 406c5fb..39c8633 100644
--- a/packages/web/src/components/TagChip.tsx
+++ b/packages/web/src/components/TagChip.tsx
@@ -6,10 +6,12 @@ interface Props {
}
const DEFAULT_COLOR = "#475569";
+const COLOR_RE = /^#[0-9A-Fa-f]{6}$/;
export function TagChip({ name, tagsFile }: Props) {
const tag = tagsFile.tags[name];
- const color = tag?.color ?? DEFAULT_COLOR;
+ const rawColor = tag?.color ?? DEFAULT_COLOR;
+ const color = COLOR_RE.test(rawColor) ? rawColor : DEFAULT_COLOR;
return (
Date: Mon, 25 May 2026 20:01:36 -0400
Subject: [PATCH 17/18] fix(security): re-validate remote bookmarks.json and
tags.json via Zod
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Replace the generic readOrEmpty helper with two typed functions—
readBookmarksOrEmpty and readTagsOrEmpty—each of which runs safeParse
after client.read returns. A schema failure (e.g. malformed attacker
commit) throws, propagates to loadInitial's catch block, and surfaces
as an error string instead of poisoning the in-memory state.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
packages/web/src/hooks/useGitmarksData.ts | 54 +++++++++++++------
.../web/test/hooks.useGitmarksData.test.ts | 14 +++++
2 files changed, 53 insertions(+), 15 deletions(-)
diff --git a/packages/web/src/hooks/useGitmarksData.ts b/packages/web/src/hooks/useGitmarksData.ts
index af91962..ab3d757 100644
--- a/packages/web/src/hooks/useGitmarksData.ts
+++ b/packages/web/src/hooks/useGitmarksData.ts
@@ -1,27 +1,50 @@
import { useCallback, useEffect, useRef, useState } from "react";
-import { GitHubNotFoundError } from "@gitmarks/core";
+import {
+ GitHubNotFoundError,
+ bookmarksFileSchema,
+ tagsFileSchema,
+} from "@gitmarks/core";
import type { BookmarksFile, GitHubClient, TagsFile } from "@gitmarks/core";
const EMPTY_BOOKMARKS: BookmarksFile = { version: 1, updated_at: "", bookmarks: [] };
const EMPTY_TAGS: TagsFile = { version: 1, tags: {} };
-async function readOrEmpty(
- client: GitHubClient,
- path: string,
- empty: T,
-): Promise<{ data: T; etag: string; sha: string }> {
+interface Loaded {
+ data: T;
+ etag: string;
+ sha: string;
+}
+
+async function readBookmarksOrEmpty(client: GitHubClient): Promise> {
try {
- return await client.read(path);
+ const result = await client.read("bookmarks.json");
+ const parsed = bookmarksFileSchema.safeParse(result.data);
+ if (!parsed.success) {
+ throw new Error(
+ `bookmarks.json failed schema validation: ${parsed.error.issues[0]?.message ?? "unknown"}`,
+ );
+ }
+ return { data: parsed.data, etag: result.etag, sha: result.sha };
} catch (err) {
- if (err instanceof GitHubNotFoundError) return { data: empty, etag: "", sha: "" };
+ if (err instanceof GitHubNotFoundError) return { data: EMPTY_BOOKMARKS, etag: "", sha: "" };
throw err;
}
}
-interface Loaded {
- data: T;
- etag: string;
- sha: string;
+async function readTagsOrEmpty(client: GitHubClient): Promise> {
+ try {
+ const result = await client.read("tags.json");
+ const parsed = tagsFileSchema.safeParse(result.data);
+ if (!parsed.success) {
+ throw new Error(
+ `tags.json failed schema validation: ${parsed.error.issues[0]?.message ?? "unknown"}`,
+ );
+ }
+ return { data: parsed.data, etag: result.etag, sha: result.sha };
+ } catch (err) {
+ if (err instanceof GitHubNotFoundError) return { data: EMPTY_TAGS, etag: "", sha: "" };
+ throw err;
+ }
}
export interface UseGitmarksData {
@@ -53,10 +76,11 @@ export function useGitmarksData(client: GitHubClient): UseGitmarksData {
try {
// 404 on either file is treated as empty — a freshly-set-up repo may not
// have bookmarks.json yet (extension creates it on first save) or tags.json
- // (created on first tag-manager mutation). All other errors propagate.
+ // (created on first tag-manager mutation). Schema failures and all other
+ // errors propagate to the catch block and surface as an error message.
const [b, t] = await Promise.all([
- readOrEmpty(client, "bookmarks.json", EMPTY_BOOKMARKS),
- readOrEmpty(client, "tags.json", EMPTY_TAGS),
+ readBookmarksOrEmpty(client),
+ readTagsOrEmpty(client),
]);
if (!mounted.current) return;
setBookmarks({ data: b.data, etag: b.etag, sha: b.sha });
diff --git a/packages/web/test/hooks.useGitmarksData.test.ts b/packages/web/test/hooks.useGitmarksData.test.ts
index d79b5a9..f597ea5 100644
--- a/packages/web/test/hooks.useGitmarksData.test.ts
+++ b/packages/web/test/hooks.useGitmarksData.test.ts
@@ -124,4 +124,18 @@ describe("useGitmarksData", () => {
expect(result.current.bookmarksFile).toEqual({ version: 1, updated_at: "", bookmarks: [] });
expect(result.current.tagsFile).toEqual(tagsFile);
});
+
+ it("sets error when bookmarks.json fails schema validation", async () => {
+ const client = fakeClient({
+ read: vi.fn().mockImplementation(async (path: string) => {
+ if (path === "bookmarks.json") return { data: { version: "wrong" }, sha: "b1", etag: '"b"' };
+ if (path === "tags.json") return { data: tagsFile, sha: "t1", etag: '"t"' };
+ throw new Error("unexpected path");
+ }),
+ } as any);
+ const { result } = renderHook(() => useGitmarksData(client));
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ expect(result.current.error).toMatch(/schema validation/i);
+ expect(result.current.bookmarksFile).toBeNull();
+ });
});
From d1e9dd7b80a4d64a057bbc3e6724f7e46b80158f Mon Sep 17 00:00:00 2001
From: Sidney von Katzendame
Date: Mon, 25 May 2026 20:01:43 -0400
Subject: [PATCH 18/18] fix(security): URL-encode owner and repo in GitHub
Contents API path
encodeURIComponent() guards against owner/repo values containing slashes
or other special characters that would otherwise corrupt the API path.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
packages/core/src/github/client.ts | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/packages/core/src/github/client.ts b/packages/core/src/github/client.ts
index 3053acd..77d8286 100644
--- a/packages/core/src/github/client.ts
+++ b/packages/core/src/github/client.ts
@@ -68,7 +68,9 @@ export class GitHubClient {
private contentsUrl(path: string): string {
const enc = path.split("/").map(encodeURIComponent).join("/");
- return `${this.baseUrl}/repos/${this.owner}/${this.repo}/contents/${enc}?ref=${encodeURIComponent(this.branch)}`;
+ const o = encodeURIComponent(this.owner);
+ const r = encodeURIComponent(this.repo);
+ return `${this.baseUrl}/repos/${o}/${r}/contents/${enc}?ref=${encodeURIComponent(this.branch)}`;
}
private throwForStatus(res: Response, path: string): void {