diff --git a/CLAUDE.md b/CLAUDE.md
index 42f47bc..ae330c3 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -9,11 +9,11 @@ Five packages are merged to main and working:
- `@gitmarks/extension-shared` (`packages/extension-shared/`) — canonical owner of the cross-browser extension code: popup, options, background, all of `src/lib/`, and the chrome/browser stub. 96 unit tests live here. Consumed by both browser shells via `workspace:*`. Uses `browser.*` via `webextension-polyfill`.
- `@gitmarks/extension-chrome` (`packages/extension-chrome/`) — Chrome MV3 shell. Manifest + Vite/crxjs build + Playwright e2e (4 passing, 2 skipped — see issue history for the activeTab/Playwright limitation). Source files are thin entries that re-export from `extension-shared` via its `exports` map.
- `@gitmarks/extension-firefox` (`packages/extension-firefox/`) — Firefox MV3 shell. Manifest + plain Vite build + manual smoke test (Playwright Firefox doesn't reliably drive WebExtensions). Targets Firefox 121+ for MV3 SW parity. Load via `about:debugging` → "Load Temporary Add-on".
-- `@gitmarks/web` (`packages/web/`) — Vite + React + Tailwind SPA. Read-side web UI: list, search, tag management. Talks directly to GitHub via `@gitmarks/core`. Hash routing (`#/setup`, `#/`, `#/tags`). 67 unit + component tests.
+- `@gitmarks/web` (`packages/web/`) — Vite + React + Tailwind SPA. List, search, tag management, bulk operations, trash, Netscape HTML export. Talks directly to GitHub via `@gitmarks/core`. Hash routing (`#/setup`, `#/`, `#/tags`, `#/trash`). 105 unit + component tests.
-Total: 228 unit + component tests across the monorepo, plus 6 Playwright e2e (4 passing, 2 skipped) in the Chrome shell.
+Total: 272 unit + component tests across the monorepo, plus 6 Playwright e2e (4 passing, 2 skipped) in the Chrome shell.
-Pending packages (in dependency order): web UI v2 (write + bulk ops), Safari.
+Pending packages (in dependency order): Safari.
`spec.md` remains the source of truth for design decisions that aren't visible in the code.
@@ -83,9 +83,12 @@ Vite + React 18 + Tailwind 3 SPA. Read-side: list, search, tag management. Hash
- **Settings** (`src/lib/settings.ts`): Zod-validated `localStorage` wrapper for `{token, owner, repo, branch}`. Same PAT model as the extensions.
- **Client wrapper** (`src/lib/client.ts`): `makeClient(settings, fetch?)` builds a `GitHubClient`; `validateConnection` returns a discriminated `ValidateResult` (`ok-with-files` | `ok-no-files` | `auth-failed` | `repo-not-found` | `network-error`).
- **Data hook** (`src/hooks/useGitmarksData.ts`): loads `bookmarks.json` + `tags.json` on mount; tracks ETags and uses `readIfChanged` on `refresh()`. Seeds empty files on 404 so freshly-set-up users see the empty state, not an error. `writeTags(mutator, message)` delegates to `client.update("tags.json", …)` for 409 retry-replay.
-- **Pure helpers** (`src/lib/data.ts`, `src/lib/tag-mutations.ts`): `visibleBookmarks` (filters tombstones), `searchBookmarks` (case-insensitive substring across title/url/tags/notes), `allUsedTags`, plus `addTag`/`renameTag`/`setTagColor`/`deleteTag`. All pure so they can be replayed inside `client.update`.
-- **Routes** (`src/routes/`): `SetupPage` (PAT entry + Validate + Save), `ListPage` (search + tag-filter sidebar + BookmarkList), `TagsPage` (TagManager wired to `writeTags`).
-- **Layout** (`src/components/Layout.tsx`): header, nav, status pill (loading/ok/warn/err), Sync-from-GitHub button.
+- **Pure helpers** (`src/lib/data.ts`, `src/lib/tag-mutations.ts`): `visibleBookmarks` (filters tombstones), `deletedBookmarks` (returns soft-deleted within GC window), `searchBookmarks` (case-insensitive substring across title/url/tags/notes), `allUsedTags`, plus `addTag`/`renameTag`/`setTagColor`/`deleteTag`. All pure so they can be replayed inside `client.update`.
+- **Routes** (`src/routes/`): `SetupPage` (PAT entry + Validate + Save), `ListPage` (search + tag-filter sidebar + BookmarkList), `TagsPage` (TagManager wired to `writeTags`), `TrashPage` (deleted bookmarks with restore).
+- **Layout** (`src/components/Layout.tsx`): header, nav, status pill (loading/ok/warn/err), Sync-from-GitHub button, Export button.
+- **Bulk operations** (`src/lib/bulk-mutations.ts`, `src/components/BulkActionsBar.tsx`, `src/hooks/useSelection.ts`): multi-select state; bulk add tag / remove tag / set folder / soft-delete; each fires one `client.update` call per action.
+- **Trash** (`src/routes/TrashPage.tsx`, `src/components/TrashList.tsx`): filters `deleted_at != null` within the 30-day GC window; restore clears `deleted_at` via `bulkRestore`.
+- **Export** (`src/lib/netscape-export.ts`, `src/lib/download.ts`): generates Netscape Bookmark File Format and triggers a browser download via Blob.
**Tag rename is decoupled from bookmark refs by design** — `renameTag` only mutates `tags.json`. Bookmark `tags[]` entries still reference the old name until updated by the extension's save path. Per `spec.md` §"`tags.json`": "Separate file so renaming a tag doesn't churn every bookmark."
@@ -120,10 +123,10 @@ pnpm --filter @gitmarks/extension-chrome e2e
3. ✅ Chrome native tree integration
4. ✅ Firefox MV3 add-on (`webextension-polyfill` + extension-shared) — issue [#23](https://github.com/paperhurts/gitmarks/issues/23)
5. ✅ Web UI v1: list / search / tag management — issue [#24](https://github.com/paperhurts/gitmarks/issues/24)
-6. ⬜ Web UI v2: bulk operations + trash + export — issue [#25](https://github.com/paperhurts/gitmarks/issues/25)
+6. ✅ Web UI v2: bulk operations + trash + export — issue [#25](https://github.com/paperhurts/gitmarks/issues/25)
7. ⬜ Safari (`safari-web-extension-converter`) — issue [#26](https://github.com/paperhurts/gitmarks/issues/26)
-For next-piece-of-work: pick one of #23–#26. Each has a scope block in its issue description. The plan-driven workflow (`docs/superpowers/plans/YYYY-MM-DD-.md`) is the expected approach for anything larger than ~3 commits.
+For next-piece-of-work: pick from the remaining open issues (#26 Safari). Each has a scope block in its issue description. The plan-driven workflow (`docs/superpowers/plans/YYYY-MM-DD-.md`) is the expected approach for anything larger than ~3 commits.
## Non-goals (do not implement)
diff --git a/README.md b/README.md
index b3085a9..2b16ee7 100644
--- a/README.md
+++ b/README.md
@@ -26,7 +26,7 @@ export) and Safari are next in the roadmap. See `spec.md` for the full design.
on the next 5-minute poll
- Concurrent edits from multiple devices reconcile automatically via
GitHub's file SHA + optimistic retry-replay
-- 228 automated unit + component tests + 6 Playwright e2e (against real Chromium)
+- 272 automated unit + component tests + 6 Playwright e2e (against real Chromium)
- Optional **tracking-param stripping** (utm_*, fbclid, gclid, etc.) at save time — opt-in via settings
## Packages
@@ -129,7 +129,7 @@ The load-bearing invariants:
- ✅ Tracking-param stripping (opt-in)
- ✅ Firefox MV3 add-on ([#23](https://github.com/paperhurts/gitmarks/issues/23))
- ✅ Web UI v1: list + search + tag management ([#24](https://github.com/paperhurts/gitmarks/issues/24))
-- ⬜ Web UI v2: bulk operations + trash + export ([#25](https://github.com/paperhurts/gitmarks/issues/25))
+- ✅ Web UI v2: bulk operations + trash + export ([#25](https://github.com/paperhurts/gitmarks/issues/25))
- ⬜ Safari ([#26](https://github.com/paperhurts/gitmarks/issues/26))
## Files in this repo
diff --git a/docs/superpowers/plans/2026-05-25-gitmarks-web-ui-v2.md b/docs/superpowers/plans/2026-05-25-gitmarks-web-ui-v2.md
new file mode 100644
index 0000000..c504f96
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-25-gitmarks-web-ui-v2.md
@@ -0,0 +1,2221 @@
+# Web UI v2 Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Ship write-side features for `@gitmarks/web`: bulk operations on the listing, a trash view with restore, and Netscape HTML export.
+
+**Architecture:** Extend existing `@gitmarks/web` with multi-select state, a `BulkActionsBar`, and a `/trash` route. All writes go through `client.update("bookmarks.json", …)` from `@gitmarks/core` for 409 retry-replay. Add pure bulk mutators to `@gitmarks/core` so they sit alongside the existing single-item mutations and are reusable by future clients. Netscape HTML export is a pure utility plus a single Blob-download button.
+
+**Tech Stack:** Same as v1 — Vite 5, React 18, TypeScript 5.4, Tailwind 3, `@gitmarks/core` (workspace), Vitest 2 + jsdom + @testing-library/react.
+
+**Scope (in):**
+- Multi-select UX on the list page (checkbox per row, select-all, clear-selection)
+- Bulk add tag / remove tag / set folder / move to trash (one batched `client.update()` per bulk action — single commit on GitHub)
+- `/trash` route: list `deleted_at != null` (within the 30-day GC window), single + bulk restore
+- Netscape HTML export — pure generator, browser download via Blob
+- Nav links: List / Tags / Trash / Export
+
+**Scope (out, deferred):**
+- Permanent delete from trash (the extension's `gcTombstones` handles GC after 30 days)
+- Tag rename "and update bookmark refs" — still decoupled per spec
+- Conflict UI beyond what `client.update`'s retry already provides
+
+**Branch:** `feat/web-ui-v2`
+
+---
+
+## File Structure
+
+```
+packages/core/src/
+ mutate.ts # ADD: updateBookmarks, restoreBookmark
+ index.ts # ADD: re-export updateBookmarks, restoreBookmark
+
+packages/web/src/
+ hooks/
+ useGitmarksData.ts # MODIFY: add writeBookmarks
+ useSelection.ts # NEW: Set selection state
+ lib/
+ bulk-mutations.ts # NEW: pure factories — addTagToMany, removeTagFromMany, setFolderForMany, softDeleteMany, restoreMany
+ netscape-export.ts # NEW: BookmarksFile → Netscape HTML string
+ download.ts # NEW: trigger browser download from a string blob
+ components/
+ BookmarkRow.tsx # MODIFY: optional selected + onToggleSelect props (checkbox column)
+ BookmarkList.tsx # MODIFY: pass selection through, render select-all header when used
+ BulkActionsBar.tsx # NEW: visible when selection > 0; add/remove tag, set folder, delete, clear
+ TrashRow.tsx # NEW: row variant for trash listing (no edit, has restore button)
+ TrashList.tsx # NEW: filter to deleted bookmarks within GC window
+ Layout.tsx # MODIFY: add Trash nav link + Export button
+ routes/
+ ListPage.tsx # MODIFY: useSelection + BulkActionsBar
+ TrashPage.tsx # NEW: filter deleted, restore actions
+ SetupPage.tsx, TagsPage.tsx # unchanged
+ App.tsx # MODIFY: add /trash route
+ ...
+packages/web/test/
+ hooks.useGitmarksData.test.ts # ADD writeBookmarks tests
+ hooks.useSelection.test.ts # NEW
+ lib.bulk-mutations.test.ts # NEW
+ lib.netscape-export.test.ts # NEW
+ components.BulkActionsBar.test.tsx # NEW
+ components.TrashList.test.tsx # NEW
+ ListPage.integration.test.tsx # ADD bulk-selection tests
+```
+
+Other files modified:
+- `README.md` — feature list + roadmap line for #25
+- `CLAUDE.md` — `@gitmarks/web` subsection: mention bulk + trash + export
+- `packages/web/README.md` — add v2 routes + smoke test extensions
+
+---
+
+## Task 1: Core — `updateBookmarks` and `restoreBookmark` pure mutations
+
+**Files:**
+- Modify: `packages/core/src/mutate.ts`
+- Modify: `packages/core/src/index.ts`
+- Modify: `packages/core/test/mutate.test.ts`
+
+- [ ] **Step 1: Write the failing tests**
+
+Open `packages/core/test/mutate.test.ts`. After the existing tests, append:
+
+```typescript
+import { restoreBookmark, updateBookmarks } from "../src/mutate.js";
+
+describe("updateBookmarks (bulk)", () => {
+ it("applies a patch to every listed id and stamps updated_at", () => {
+ const file: BookmarksFile = {
+ version: 1,
+ updated_at: "2026-01-01T00:00:00Z",
+ bookmarks: [
+ { ...sampleBookmark, id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CA" },
+ { ...sampleBookmark, id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CB" },
+ { ...sampleBookmark, 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); // unchanged
+ 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: BookmarksFile = {
+ version: 1,
+ updated_at: "2026-01-01T00:00:00Z",
+ bookmarks: [{ ...sampleBookmark, 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: BookmarksFile = {
+ version: 1,
+ updated_at: "2026-01-01T00:00:00Z",
+ bookmarks: [{ ...sampleBookmark, 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: BookmarksFile = {
+ version: 1,
+ updated_at: "2026-01-01T00:00:00Z",
+ bookmarks: [{ ...sampleBookmark, 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: BookmarksFile = {
+ version: 1,
+ updated_at: "2026-01-01T00:00:00Z",
+ bookmarks: [
+ { ...sampleBookmark, 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: BookmarksFile = {
+ version: 1,
+ updated_at: "2026-01-01T00:00:00Z",
+ bookmarks: [],
+ };
+ expect(() => restoreBookmark(file, "01HXYZ8K7M9P3RQ2V5W6Z8B0CZ", "2026-05-25T00:00:00Z")).toThrow(/not found/);
+ });
+});
+```
+
+If the existing test file doesn't already have a `sampleBookmark` fixture, the implementer should define one or reuse the existing fixture name. Check the file first.
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+```bash
+pnpm --filter @gitmarks/core test
+```
+
+Expected: FAIL on `updateBookmarks is not a function` / `restoreBookmark is not a function`.
+
+- [ ] **Step 3: Implement** — append to `packages/core/src/mutate.ts`:
+
+```typescript
+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);
+}
+```
+
+- [ ] **Step 4: Re-export from `packages/core/src/index.ts`**
+
+Find the existing `mutate.js` re-export block and extend it:
+
+```typescript
+export {
+ addBookmark,
+ updateBookmark,
+ updateBookmarks,
+ type BookmarkPatch,
+ softDeleteBookmark,
+ restoreBookmark,
+ gcTombstones,
+} from "./mutate.js";
+```
+
+- [ ] **Step 5: Run tests + typecheck + build**
+
+```bash
+pnpm --filter @gitmarks/core test
+pnpm --filter @gitmarks/core typecheck
+pnpm --filter @gitmarks/core build
+```
+
+All green. Core gains 6 new tests.
+
+- [ ] **Step 6: Branch + commit**
+
+```bash
+git checkout -b feat/web-ui-v2
+git add packages/core/src/mutate.ts packages/core/src/index.ts packages/core/test/mutate.test.ts
+git commit -m "feat(core): add updateBookmarks (bulk) and restoreBookmark pure mutations"
+```
+
+---
+
+## Task 2: Web — `writeBookmarks` on `useGitmarksData`
+
+**Files:**
+- Modify: `packages/web/src/hooks/useGitmarksData.ts`
+- Modify: `packages/web/test/hooks.useGitmarksData.test.ts`
+
+- [ ] **Step 1: Add a failing test for `writeBookmarks`**
+
+Append to `packages/web/test/hooks.useGitmarksData.test.ts` inside the existing `describe("useGitmarksData", …)` block (before the closing `});`):
+
+```typescript
+ 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);
+ });
+```
+
+Run: `pnpm --filter @gitmarks/web test test/hooks.useGitmarksData.test.ts` — should FAIL on `result.current.writeBookmarks is not a function`.
+
+- [ ] **Step 2: Extend the hook**
+
+Open `packages/web/src/hooks/useGitmarksData.ts`. Modify the `UseGitmarksData` interface and add the `writeBookmarks` implementation, mirroring `writeTags`:
+
+```typescript
+export interface UseGitmarksData {
+ bookmarksFile: BookmarksFile | null;
+ tagsFile: TagsFile | null;
+ loading: boolean;
+ error: string | null;
+ refresh: () => Promise;
+ writeBookmarks: (
+ mutate: (f: BookmarksFile) => BookmarksFile,
+ message: string,
+ ) => Promise;
+ writeTags: (
+ mutate: (f: TagsFile) => TagsFile,
+ message: string,
+ ) => Promise;
+}
+```
+
+Below `writeTags`, before the `useEffect`, add:
+
+```typescript
+ 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],
+ );
+```
+
+Add `writeBookmarks` to the returned object:
+
+```typescript
+ return {
+ bookmarksFile: bookmarks?.data ?? null,
+ tagsFile: tags?.data ?? null,
+ loading,
+ error,
+ refresh,
+ writeBookmarks,
+ writeTags,
+ };
+```
+
+- [ ] **Step 3: Verify**
+
+```bash
+pnpm --filter @gitmarks/web test
+pnpm --filter @gitmarks/web typecheck
+```
+
+All previous tests + 1 new = pass.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add packages/web/src/hooks/useGitmarksData.ts packages/web/test/hooks.useGitmarksData.test.ts
+git commit -m "feat(web): add writeBookmarks to useGitmarksData hook"
+```
+
+---
+
+## Task 3: `useSelection` hook
+
+**Files:**
+- Create: `packages/web/src/hooks/useSelection.ts`
+- Create: `packages/web/test/hooks.useSelection.test.ts`
+
+- [ ] **Step 1: Write the failing test** — `packages/web/test/hooks.useSelection.test.ts`:
+
+```typescript
+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);
+ });
+});
+```
+
+Run: `pnpm --filter @gitmarks/web test test/hooks.useSelection.test.ts` — should FAIL on missing module.
+
+- [ ] **Step 2: Implement** — `packages/web/src/hooks/useSelection.ts`:
+
+```typescript
+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 };
+}
+```
+
+- [ ] **Step 3: Verify**
+
+```bash
+pnpm --filter @gitmarks/web test
+```
+
+5 new tests pass.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add packages/web/src/hooks/useSelection.ts packages/web/test/hooks.useSelection.test.ts
+git commit -m "feat(web): add useSelection hook for multi-select state"
+```
+
+---
+
+## Task 4: Pure bulk mutation factories
+
+**Files:**
+- Create: `packages/web/src/lib/bulk-mutations.ts`
+- Create: `packages/web/test/lib.bulk-mutations.test.ts`
+
+These functions return `(file: BookmarksFile) => BookmarksFile` mutators suitable for passing to `client.update`. They close over the selected ids + arguments, but the produced mutators are pure (so they can be replayed on 409).
+
+- [ ] **Step 1: Write the failing tests** — `packages/web/test/lib.bulk-mutations.test.ts`:
+
+```typescript
+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/);
+ });
+});
+```
+
+Run: `pnpm --filter @gitmarks/web test test/lib.bulk-mutations.test.ts` — should FAIL on missing module.
+
+- [ ] **Step 2: Implement** — `packages/web/src/lib/bulk-mutations.ts`:
+
+```typescript
+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);
+}
+```
+
+- [ ] **Step 3: Verify**
+
+```bash
+pnpm --filter @gitmarks/web test
+pnpm --filter @gitmarks/web typecheck
+```
+
+All previous + new tests pass.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add packages/web/src/lib/bulk-mutations.ts packages/web/test/lib.bulk-mutations.test.ts
+git commit -m "feat(web): pure bulk-mutation factories built on core's updateBookmarks"
+```
+
+---
+
+## Task 5: BookmarkRow selection prop + select-all in BookmarkList
+
+**Files:**
+- Modify: `packages/web/src/components/BookmarkRow.tsx`
+- Modify: `packages/web/src/components/BookmarkList.tsx`
+- Modify: `packages/web/test/components.BookmarkList.test.tsx`
+
+- [ ] **Step 1: Add a failing test for selection rendering**
+
+In `packages/web/test/components.BookmarkList.test.tsx`, add a new test inside the existing `describe`:
+
+```typescript
+ 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();
+ });
+```
+
+Add the userEvent import at the top of the test if not present:
+
+```typescript
+import userEvent from "@testing-library/user-event";
+import { vi } from "vitest";
+```
+
+Run: `pnpm --filter @gitmarks/web test test/components.BookmarkList.test.tsx` — should FAIL because props don't exist.
+
+- [ ] **Step 2: Update `BookmarkRow.tsx`** — add optional selection props:
+
+```typescript
+import type { Bookmark, TagsFile } from "@gitmarks/core";
+import { TagChip } from "./TagChip.js";
+
+interface Props {
+ bookmark: Bookmark;
+ tagsFile: TagsFile;
+ selected?: boolean;
+ onToggleSelect?: (id: string) => void;
+}
+
+export function BookmarkRow({ bookmark, tagsFile, selected, onToggleSelect }: Props) {
+ const folder = bookmark.folder.length > 0 ? bookmark.folder : "(root)";
+ const showCheckbox = onToggleSelect !== undefined;
+ return (
+
+ {showCheckbox && (
+ onToggleSelect(bookmark.id)}
+ className="mt-1.5"
+ />
+ )}
+
+
+
{bookmark.url}
+ {bookmark.tags.length > 0 && (
+
+ {bookmark.tags.map((t) => (
+
+ ))}
+
+ )}
+ {bookmark.notes != null && (
+
{bookmark.notes}
+ )}
+
+
+ );
+}
+```
+
+- [ ] **Step 3: Update `BookmarkList.tsx`** — pass selection through and add select-all header:
+
+```typescript
+import type { BookmarksFile, TagsFile } from "@gitmarks/core";
+import { BookmarkRow } from "./BookmarkRow.js";
+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,
+ selected,
+ onToggleSelect,
+ onSetAll,
+}: Props) {
+ const items = visibleBookmarks(bookmarksFile);
+ if (items.length === 0) {
+ return (
+
+ No bookmarks yet. Save one from a browser extension to see it here.
+
+ );
+ }
+ const showSelectAll = onToggleSelect !== undefined && onSetAll !== undefined;
+ const allSelected =
+ showSelectAll && selected !== undefined && items.every((b) => selected.has(b.id));
+ return (
+
+ {showSelectAll && (
+
+ onSetAll(allSelected ? [] : items.map((b) => b.id))}
+ />
+ {selected?.size ?? 0} selected
+
+ )}
+
+ {items.map((b) => (
+
+ ))}
+
+
+ );
+}
+```
+
+- [ ] **Step 4: Verify**
+
+```bash
+pnpm --filter @gitmarks/web test
+pnpm --filter @gitmarks/web typecheck
+```
+
+All previous + 3 new BookmarkList tests pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add packages/web/src/components/BookmarkRow.tsx packages/web/src/components/BookmarkList.tsx packages/web/test/components.BookmarkList.test.tsx
+git commit -m "feat(web): optional selection props on BookmarkRow + select-all header"
+```
+
+---
+
+## Task 6: BulkActionsBar component
+
+**Files:**
+- Create: `packages/web/src/components/BulkActionsBar.tsx`
+- Create: `packages/web/test/components.BulkActionsBar.test.tsx`
+
+- [ ] **Step 1: Write the failing test** — `packages/web/test/components.BulkActionsBar.test.tsx`:
+
+```typescript
+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();
+ });
+});
+```
+
+Run: `pnpm --filter @gitmarks/web test test/components.BulkActionsBar.test.tsx` — FAIL on missing module.
+
+- [ ] **Step 2: Implement** — `packages/web/src/components/BulkActionsBar.tsx`:
+
+```typescript
+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
+
+
+ );
+}
+```
+
+- [ ] **Step 3: Verify**
+
+```bash
+pnpm --filter @gitmarks/web test test/components.BulkActionsBar.test.tsx
+pnpm --filter @gitmarks/web typecheck
+```
+
+6 new tests pass.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add packages/web/src/components/BulkActionsBar.tsx packages/web/test/components.BulkActionsBar.test.tsx
+git commit -m "feat(web): bulk actions bar (add tag, remove tag, set folder, delete, clear)"
+```
+
+---
+
+## Task 7: ListPage selection + BulkActionsBar integration
+
+**Files:**
+- Modify: `packages/web/src/routes/ListPage.tsx`
+- Modify: `packages/web/test/ListPage.integration.test.tsx`
+
+- [ ] **Step 1: Add failing integration tests**
+
+In `packages/web/test/ListPage.integration.test.tsx`, append two more tests inside the existing `describe`:
+
+```typescript
+ 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"));
+ });
+```
+
+Run: `pnpm --filter @gitmarks/web test test/ListPage.integration.test.tsx` — should FAIL.
+
+- [ ] **Step 2: Rewrite `ListPage.tsx`** to wire selection + BulkActionsBar:
+
+```typescript
+import { useMemo, useState } from "react";
+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, 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) : []),
+ [bookmarksFile],
+ );
+ const tagFiltered = useMemo(
+ () => (selectedTag == null ? visible : visible.filter((b) => b.tags.includes(selectedTag))),
+ [visible, selectedTag],
+ );
+ const searched = useMemo(
+ () => searchBookmarks(tagFiltered, query),
+ [tagFiltered, query],
+ );
+ const used = useMemo(() => allUsedTags(visible), [visible]);
+
+ const status: LayoutStatus = loading
+ ? { kind: "loading", message: "loading…" }
+ : 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 }
+ : null;
+
+ async function onRefresh() {
+ setRefreshing(true);
+ try {
+ await refresh();
+ } finally {
+ setRefreshing(false);
+ }
+ }
+
+ 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 (
+
+
+
+ Tags
+ {tagsFile != null && (
+
+ )}
+
+
+
+ {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)}
+ />
+
+ )}
+
+
+
+ );
+}
+```
+
+- [ ] **Step 3: Verify**
+
+```bash
+pnpm --filter @gitmarks/web test
+pnpm --filter @gitmarks/web typecheck
+```
+
+Existing integration tests + 2 new tests pass.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add packages/web/src/routes/ListPage.tsx packages/web/test/ListPage.integration.test.tsx
+git commit -m "feat(web): list page bulk select + bulk actions wired to client.update"
+```
+
+---
+
+## Task 8: TrashList component + TrashPage route
+
+**Files:**
+- Create: `packages/web/src/components/TrashList.tsx`
+- Create: `packages/web/src/routes/TrashPage.tsx`
+- Create: `packages/web/test/components.TrashList.test.tsx`
+- Modify: `packages/web/src/App.tsx`
+- Modify: `packages/web/src/lib/data.ts`
+
+- [ ] **Step 1: Add a `deletedBookmarks` helper to `packages/web/src/lib/data.ts`**
+
+Append:
+
+```typescript
+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;
+ });
+}
+```
+
+Add a quick test in `packages/web/test/lib.data.test.ts` (append to the existing file):
+
+```typescript
+import { deletedBookmarks } from "../src/lib/data.js";
+
+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" }), // beyond 30d
+ ],
+ };
+
+ 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([]);
+ });
+});
+```
+
+Run: `pnpm --filter @gitmarks/web test test/lib.data.test.ts`. Should FAIL on missing helper. Implement, watch pass.
+
+- [ ] **Step 2: Write the failing `TrashList` test** — `packages/web/test/components.TrashList.test.tsx`:
+
+```typescript
+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();
+ });
+});
+```
+
+- [ ] **Step 3: Implement `TrashList.tsx`** — `packages/web/src/components/TrashList.tsx`:
+
+```typescript
+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
+
+
+ ))}
+
+ );
+}
+```
+
+- [ ] **Step 4: Write `TrashPage.tsx`** — `packages/web/src/routes/TrashPage.tsx`:
+
+```typescript
+import { useState } from "react";
+import type { BookmarksFile, 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` will remove them from bookmarks.json;
+ git history retains everything.
+
+ {bookmarksFile != null && tagsFile != null && (
+
+ )}
+
+
+ );
+}
+```
+
+- [ ] **Step 5: Wire `/trash` route in `App.tsx`**
+
+Find the `createHashRouter([…])` block. Add a `TrashPageWithContext` wrapper alongside the existing `ListPageWithContext` and `TagsPageWithContext`, then add the route:
+
+```typescript
+import { TrashPage } from "./routes/TrashPage.js";
+
+function TrashPageWithContext() {
+ const { client } = useAppContext();
+ return ;
+}
+
+// inside createHashRouter([...]):
+{
+ element: ,
+ children: [
+ { path: "/", element: },
+ { path: "/tags", element: },
+ { path: "/trash", element: },
+ ],
+},
+```
+
+- [ ] **Step 6: Verify**
+
+```bash
+pnpm --filter @gitmarks/web test
+pnpm --filter @gitmarks/web typecheck
+```
+
+All previous + 5 new tests (3 TrashList + 2 data) pass.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add packages/web/src/components/TrashList.tsx packages/web/src/routes/TrashPage.tsx packages/web/src/App.tsx packages/web/src/lib/data.ts packages/web/test/components.TrashList.test.tsx packages/web/test/lib.data.test.ts
+git commit -m "feat(web): trash route with restore via bulkRestore"
+```
+
+---
+
+## Task 9: Netscape HTML export utility
+
+**Files:**
+- Create: `packages/web/src/lib/netscape-export.ts`
+- Create: `packages/web/test/lib.netscape-export.test.ts`
+
+The Netscape Bookmark File Format is the lingua franca of bookmark export — Chrome / Firefox / Safari can all import it. Spec: https://msdn.microsoft.com/en-us/library/aa753582(v=vs.85).aspx (canonical Microsoft reference, still widely followed).
+
+- [ ] **Step 1: Write the failing test** — `packages/web/test/lib.netscape-export.test.ts`:
+
+```typescript
+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("")).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/README.md b/packages/web/README.md
index 0f0e956..0df28c2 100644
--- a/packages/web/README.md
+++ b/packages/web/README.md
@@ -15,6 +15,7 @@ The dev server runs at `http://localhost:5173/`. Hash routes:
- `#/setup` — PAT + owner + repo + branch entry, with a Validate step
- `#/` — list page (search + tag filter sidebar)
- `#/tags` — tag manager (rename, recolor, add, delete)
+- `#/trash` — soft-deleted bookmarks within the 30-day GC window, with restore
On first load with no settings stored, the router redirects to `#/setup`.
@@ -45,11 +46,29 @@ After running `pnpm --filter @gitmarks/web dev`:
- [ ] Open `#/tags`. Rename a tag, change its color, add a new tag, delete
a tag. Each action commits to `tags.json` immediately. Refresh the
page and confirm the changes persisted.
-
-## Scope (v1)
-
-Read-side only. Bookmark creation, editing, bulk operations, trash view, and
-Netscape HTML export are tracked separately as [#25 Web UI v2](https://github.com/paperhurts/gitmarks/issues/25).
+- [ ] Select multiple rows via their checkboxes → the BulkActionsBar appears with
+ the count + add-tag/remove-tag/set-folder/move-to-trash/clear actions.
+- [ ] Add a tag via the bar → all selected rows show the new tag. One commit
+ lands on `bookmarks.json`.
+- [ ] Move several rows to trash → they disappear from the list, the BulkActionsBar
+ clears, and the entries get `deleted_at` set on GitHub.
+- [ ] Open `#/trash` → the moved rows are listed. Click **Restore** on one →
+ it disappears from trash and reappears in the list. One commit lands.
+- [ ] Click **Export** in the header → a file `gitmarks.html` downloads. Open
+ it in another browser's bookmark-import → the bookmarks appear, folders
+ nested correctly. Tombstones are not exported.
+
+## Scope (v1 + v2)
+
+Read + write side. Bookmark creation still happens via the extension (per
+spec); the web UI does NOT create bookmarks.
+
+Web UI scope, today:
+- List + search + tag filter
+- Tag manager (rename / recolor / add / delete)
+- Multi-select + bulk operations (add tag, remove tag, set folder, move to trash)
+- Trash view with restore
+- Netscape HTML export
## Architecture
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/BookmarkList.tsx b/packages/web/src/components/BookmarkList.tsx
index c59443d..b73b0e1 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} of {items.length}
+
+ )}
+
+ {items.map((b) => (
+
+ ))}
+
+
);
}
diff --git a/packages/web/src/components/BookmarkRow.tsx b/packages/web/src/components/BookmarkRow.tsx
index 17b644d..ceaf61e 100644
--- a/packages/web/src/components/BookmarkRow.tsx
+++ b/packages/web/src/components/BookmarkRow.tsx
@@ -1,37 +1,61 @@
import type { Bookmark, TagsFile } from "@gitmarks/core";
+import { isSafeBookmarkUrl } from "@gitmarks/core";
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"
+ />
)}
+
+
+ {isSafeBookmarkUrl(bookmark.url) ? (
+
+ {bookmark.title}
+
+ ) : (
+
+ {bookmark.title}
+
+ )}
+
{folder}
+
+
{bookmark.url}
+ {bookmark.tags.length > 0 && (
+
+ {bookmark.tags.map((t) => (
+
+ ))}
+
+ )}
+ {bookmark.notes != null && (
+
{bookmark.notes}
+ )}
+
);
}
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/src/components/Layout.tsx b/packages/web/src/components/Layout.tsx
index c63c088..2a8ebb8 100644
--- a/packages/web/src/components/Layout.tsx
+++ b/packages/web/src/components/Layout.tsx
@@ -11,6 +11,7 @@ interface Props {
children: ReactNode;
status: LayoutStatus;
onRefresh: () => void;
+ onExport?: () => void;
refreshing: boolean;
}
@@ -18,7 +19,7 @@ const navLinkBase = "px-3 py-1 rounded";
const navLinkActive = "bg-fog text-cyan";
const navLinkInactive = "text-cyan-soft hover:text-cyan";
-export function Layout({ children, status, onRefresh, refreshing }: Props) {
+export function Layout({ children, status, onRefresh, onExport, refreshing }: Props) {
return (
} />
@@ -50,4 +51,15 @@ describe("App routing", () => {
render( );
expect(screen.getByTestId("tags-page")).toBeInTheDocument();
});
+
+ it("renders the trash page at /trash", async () => {
+ saveSettings({
+ token: "ghp_fake",
+ owner: "paperhurts",
+ repo: "bookmarks",
+ branch: "main",
+ });
+ render( );
+ expect(await screen.findByTestId("trash-page")).toBeInTheDocument();
+ });
});
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"));
+ });
});
diff --git a/packages/web/test/components.BookmarkList.test.tsx b/packages/web/test/components.BookmarkList.test.tsx
index 7961d1b..95c013a 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,94 @@ 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();
+ });
+
+ 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(");
+ });
});
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();
+ });
+});
diff --git a/packages/web/test/components.Layout.test.tsx b/packages/web/test/components.Layout.test.tsx
index bb9ffbb..5db9350 100644
--- a/packages/web/test/components.Layout.test.tsx
+++ b/packages/web/test/components.Layout.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 { MemoryRouter } from "react-router-dom";
import { Layout } from "../src/components/Layout.js";
@@ -23,14 +24,34 @@ describe("Layout", () => {
expect(screen.getByTestId("outlet")).toBeInTheDocument();
});
- it("renders nav links for List and Tags", () => {
+ it("renders nav links for List, Tags, and Trash", () => {
rendered();
expect(screen.getByRole("link", { name: /list/i })).toHaveAttribute("href", "/");
expect(screen.getByRole("link", { name: /tags/i })).toHaveAttribute("href", "/tags");
+ expect(screen.getByRole("link", { name: /trash/i })).toHaveAttribute("href", "/trash");
});
it("shows the status pill", () => {
rendered();
expect(screen.getByText(/synced 12s ago/i)).toBeInTheDocument();
});
+
+ it("renders an Export button when onExport is provided and invokes it", async () => {
+ const onExport = vi.fn();
+ const user = userEvent.setup();
+ render(
+
+ {}}
+ onExport={onExport}
+ refreshing={false}
+ >
+
+
+ ,
+ );
+ await user.click(screen.getByRole("button", { name: /export/i }));
+ expect(onExport).toHaveBeenCalled();
+ });
});
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/hooks.useGitmarksData.test.ts b/packages/web/test/hooks.useGitmarksData.test.ts
index ed69c1e..f597ea5 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")),
@@ -104,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();
+ });
});
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);
+ });
+});
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/);
+ });
+});
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([]);
+ });
+});
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("