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.title} + + {folder} +
    +
    {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" + /> + +
    + +
    + + +
    + +
    + setFolder(e.target.value)} + placeholder="folder path" + /> + +
    + + + + +
    + ); +} +``` + +- [ ] **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 ( + +
    + +
    + + {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) => )} +
      + )} +
      + +
    • + ))} +
    + ); +} +``` + +- [ ] **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" + /> + +
    + +
    + + +
    + +
    + setFolder(e.target.value)} + placeholder="folder path" + /> + +
    + + + + +
    + ); +} 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 (
    @@ -41,9 +42,26 @@ export function Layout({ children, status, onRefresh, refreshing }: Props) { > Tags + + `${navLinkBase} ${isActive ? navLinkActive : navLinkInactive}` + } + > + Trash +
    + {onExport !== undefined && ( + + )} + + ))} + + ); +} diff --git a/packages/web/src/hooks/useGitmarksData.ts b/packages/web/src/hooks/useGitmarksData.ts index e008dea..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 { @@ -30,6 +53,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, @@ -49,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 }); @@ -84,6 +112,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 +144,7 @@ export function useGitmarksData(client: GitHubClient): UseGitmarksData { loading, error, refresh, + writeBookmarks, writeTags, }; } 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/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/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/lib/download.ts b/packages/web/src/lib/download.ts new file mode 100644 index 0000000..8f94d0f --- /dev/null +++ b/packages/web/src/lib/download.ts @@ -0,0 +1,16 @@ +// Triggers a browser file download for an in-memory string blob. +// Uses URL.createObjectURL + a synthetic anchor click, which is the standard +// approach that works across all evergreen browsers without polyfills. +export function downloadString(content: string, filename: string, mimeType = "text/html"): void { + const blob = new Blob([content], { type: `${mimeType};charset=utf-8` }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + // Revoke after the click so the download completes; setTimeout(0) is enough + // because the browser has already started the download by the next tick. + setTimeout(() => URL.revokeObjectURL(url), 0); +} 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/src/routes/ListPage.tsx b/packages/web/src/routes/ListPage.tsx index 3100924..ab55cd1 100644 --- a/packages/web/src/routes/ListPage.tsx +++ b/packages/web/src/routes/ListPage.tsx @@ -1,21 +1,33 @@ 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"; +import { toNetscapeHtml } from "../lib/netscape-export.js"; +import { downloadString } from "../lib/download.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 +45,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,8 +64,30 @@ export function ListPage({ client }: Props) { } } + function onExport() { + if (bookmarksFile == null) return; + downloadString(toNetscapeHtml(bookmarksFile), "gitmarks.html", "text/html"); + } + + 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 ( - +

    + {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/src/routes/TagsPage.tsx b/packages/web/src/routes/TagsPage.tsx index 45a2f34..3cdf6d4 100644 --- a/packages/web/src/routes/TagsPage.tsx +++ b/packages/web/src/routes/TagsPage.tsx @@ -3,13 +3,15 @@ import type { GitHubClient, TagsFile } from "@gitmarks/core"; import { TagManager } from "../components/TagManager.js"; import { Layout, type LayoutStatus } from "../components/Layout.js"; import { useGitmarksData } from "../hooks/useGitmarksData.js"; +import { toNetscapeHtml } from "../lib/netscape-export.js"; +import { downloadString } from "../lib/download.js"; interface Props { client: GitHubClient; } export function TagsPage({ client }: Props) { - const { tagsFile, loading, error, refresh, writeTags } = useGitmarksData(client); + const { bookmarksFile, tagsFile, loading, error, refresh, writeTags } = useGitmarksData(client); const [refreshing, setRefreshing] = useState(false); const [writeError, setWriteError] = useState(null); @@ -32,6 +34,11 @@ export function TagsPage({ client }: Props) { } } + function onExport() { + if (bookmarksFile == null) return; + downloadString(toNetscapeHtml(bookmarksFile), "gitmarks.html", "text/html"); + } + async function onMutate(mutator: (f: TagsFile) => TagsFile) { setWriteError(null); try { @@ -42,7 +49,7 @@ export function TagsPage({ client }: Props) { } return ( - +
    {tagsFile != null && }
    diff --git a/packages/web/src/routes/TrashPage.tsx b/packages/web/src/routes/TrashPage.tsx new file mode 100644 index 0000000..08d6324 --- /dev/null +++ b/packages/web/src/routes/TrashPage.tsx @@ -0,0 +1,71 @@ +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"; +import { toNetscapeHtml } from "../lib/netscape-export.js"; +import { downloadString } from "../lib/download.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); + } + } + + function onExport() { + if (bookmarksFile == null) return; + downloadString(toNetscapeHtml(bookmarksFile), "gitmarks.html", "text/html"); + } + + 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/App.routing.test.tsx b/packages/web/test/App.routing.test.tsx index 3d26cc3..1ef8f86 100644 --- a/packages/web/test/App.routing.test.tsx +++ b/packages/web/test/App.routing.test.tsx @@ -13,6 +13,7 @@ function AppRoutes({ initialPath = "/" }: { initialPath?: string }) { }> list
    } /> tags
    } /> + trash
    } /> @@ -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("