1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-21 18:55:36 +02:00

Compare commits

...

13 commits

Author SHA1 Message Date
64f94c6eda
feat: archive paywalled articles through Freedium (#30)
* feat(core): add via_freedium to CaptureConfig; route WebPage captures through Freedium mirror

When via_freedium is true and the locator is not already a Freedium URL,
perform_capture passes https://freedium-mirror.cfd/<original-url> to
singlefile::save() so paywalled articles are fetched via the mirror.
The original locator is kept for requested_locator and canonical_locator
in the DB entry. An empty cookie map is used for the mirror fetch to
prevent original-domain credentials from being sent to freedium-mirror.cfd.

* feat(server): expose via_freedium in capture API (default on)

CaptureBody gains via_freedium: Option<bool>; absent defaults to true.
The rearchive handler sets it to false — existing entries should not be
silently re-fetched through a mirror.

* feat(frontend): add Freedium mirror toggle to capture advanced options

- freediumEnabled state defaults to true (on by default)
- via_freedium forwarded through submitCapture to the capture API
- Toggle rendered last in the advanced panel, matching existing rows
- Built frontend static assets included

* fix(core): Freedium capture fixes — title, notifications, reader images

- extract_html_title: take().read_to_end() up to 256 KiB (single read() can
  short-read, leaving title at byte ~106 K undiscovered); regression test added
- Strip " - Freedium" suffix before storing entry title
- is_freedium_fetch keys off actual fetch_url host
- Remove Freedium toast overlay ([data-sonner-toaster]) before capture
- Resolve lazy images (data-zoom-src etc.) before Readability in reader mode

* fix(core): extract HTML title after font stripping, not before

SingleFile embeds fonts as base64 data URIs in <style> blocks in the
<head>, pushing the <title> tag to ~1.2 MB in the raw temp file.
The 256 KiB read window in extract_html_title missed it.

Font extraction rewrites the HTML in-place (2.4 MB → 1.26 MB) before
hashing, so the title is accessible at byte ~106 KB in that content.

Fix: add extract_html_title_str() that operates on a &str; call it on
the in-memory rewritten string after font extraction (server path).
CLI path (no font extraction) falls back to result.title as before.

* fix(core): inline reader-mode images via Rust post-processing

SingleFile cannot inline resources added to the DOM at before-capture
time — only resources tracked during the page's initial load cycle get
embedded. Article images in Readability output fall into this gap when
the page framework has already resolved their lazy src to a CDN URL
that isn't in SingleFile's resource cache for the new DOM elements.

Fix in three parts:
- Browser script: after body.innerHTML = article.content, stamp
  data-archivr-src=<absolute-proxy-url> on any image whose src is
  not an already-inlined large data URI. Remove loading attr. Don't
  touch src (let SingleFile try; Rust handles the rest).
- Rust (save_with): after SingleFile writes the file and before hashing,
  call inline_archivr_img_srcs() to scan for data-archivr-src markers.
- inline_archivr_img_srcs(): fetches each marked URL with blocking
  reqwest (10 s timeout, 5 redirects, image/* Content-Type guard,
  20 MiB cap), base64-encodes, replaces src with the data URI, and
  removes the marker attr. Non-fatal — fetch failures are logged.

Also: Freedium UI cleanup now removes footer, #progress, and empty
data-nosnippet wrappers in addition to the existing nav/toaster removal.

* fix(core): clean up Freedium chrome and drop reader-mode debug counters

Two fixes:

1. freedium_cleanup: remove remaining Freedium article chrome that
   survived the existing nav/footer/toaster pass:
   - <header class="p-6 bg-gray-50 ..."> — author/metadata bar with
     profile pictures and byline, a Freedium wrapper around the article
   - <section> containing [data-slot="dropdown-menu-trigger"] or
     [aria-haspopup="menu"] — the "Download article" dropdown

   Selectors target stable Tailwind utility class prefixes (p-6, bg-gray-50,
   bg-zinc-800) for the header and the WAI-ARIA menu role for the button,
   both resilient to minor Freedium UI updates.

2. Reader-mode meta tag: strip per-capture debug counters.
   _archivrReaderMark now writes 'applied' instead of
   'applied:pre_s=N,...:post_s=N,...' — the counters were useful during
   development but have no place as permanent archive content.

* fix(core): prevent lazy-resolver double-processing on Freedium reader images

_archivrResolveLazyImgs is called twice: before Readability (_pre) and
after the post-body stamp pass (_post). The stamp pass sets data-archivr-src
but previously left data-src/data-zoom-src/etc intact, so _post's
'lazySrc && _isPlaceholder' condition fired on the same images and
rewrote src to the CDN URL — which SingleFile then tried and failed to
fetch from the Freedium proxy context, redundantly.

Fix: strip all lazy attrs (data-src, data-lazy-src, data-zoom-src,
data-original, data-lazy) when stamping data-archivr-src. _post then
finds no lazySrc on those images and skips them cleanly. Rust owns
them via data-archivr-src. _post continues to handle any remaining
placeholder images not covered by the stamp pass (non-Freedium reader
captures).

* fix(core): address Codex review findings in reader image post-processor

P1 — Enforce size cap before buffering (singlefile.rs fetch_image_as_data_uri):
resp.bytes() buffered the entire response before checking MAX_BYTES, enabling
OOM on oversized or attacker-controlled images. Fix: reject via Content-Length
header when present, then stream at most max_bytes+1 bytes with Read::take so
the cap is enforced without materialising the full body first.

P2 — Decode HTML entities in extracted image URLs (inline_archivr_img_srcs):
The browser's HTML serialiser encodes & as &amp; in attribute values, so CDN
signed URLs with query parameters (e.g. ?a=1&b=2) arrived as &amp;-escaped
strings. reqwest sent the wrong URL, breaking signed or transformed CDN
images. Fix: unescape &amp; &lt; &gt; &quot; before passing to the fetcher.

P2 — Preserve authentication for same-origin lazy images (inline_archivr_img_srcs):
The post-processor created a bare reqwest client with no cookies, causing 401/403
for reader-mode captures of auth-gated sites whose lazy images are same-origin.
Fix: pass capture_url and cookies into inline_archivr_img_srcs; attach a Cookie
header only when domain_from_url(img_url) == domain_from_url(capture_url) and
cookies is non-empty. Third-party image hosts and Freedium fetches (which receive
empty cookies in capture.rs) are unaffected.

* test(core): extract helpers and add unit tests for Codex review fixes

Extract two testable pure functions from inline_archivr_img_srcs:
- html_attr_decode: decodes HTML character references in attribute values.
  Fixes decode order: &amp; runs LAST so &amp;lt; → &lt; (one layer
  removed), not < (two layers). Previous order caused double-decoding.
- same_origin_cookie_header: returns a Cookie header value only when
  img_url's domain matches capture_url's domain.

Add 11 unit tests covering:
- html_attr_decode: plain URL no-op, &amp; in CDN query params, single-layer
  decode (&amp;lt; → &lt; not <), double-encoded amp (&amp;amp; → &amp;),
  direct &lt;/&gt;/&quot; decode
- same_origin_cookie_header: same host attaches cookies, third-party returns
  None, empty cookies returns None, Freedium empty-cookies no-op
- bounded_read: Read::take stops at max+1 bytes (guard fires), allows
  exactly-at-limit payloads (guard does not fire)

* docs: refresh AGENTS.md and mental model; drop NEXT.md

AGENTS.md: mention Freedium mirror in the overview and capture flow,
add vendor/readability/ to Key Directories, drop the NEXT.md pointer.

ARCHIVR-MENTAL-MODEL.md: fix stale references to legacy static/ paths
in Where To Edit (frontend now lives in frontend/src/); add capture.rs
and auth.rs rows; replace the outdated 'Current Limitations' section
(which claimed no capture and no auth) with a Server Capabilities
section reflecting async capture jobs, the auth model, search, and
admin scope; add a Web Capture Pipeline section covering the Freedium
mirror, SingleFile+Chromium, vendored Readability, cleanup, and Rust
post-processing.

NEXT.md: remove; the roadmap tracking is stale and unused.
2026-07-19 17:45:07 +02:00
15660e6532
docs: refresh AGENTS.md and mental model; drop NEXT.md
AGENTS.md: mention Freedium mirror in the overview and capture flow,
add vendor/readability/ to Key Directories, drop the NEXT.md pointer.

ARCHIVR-MENTAL-MODEL.md: fix stale references to legacy static/ paths
in Where To Edit (frontend now lives in frontend/src/); add capture.rs
and auth.rs rows; replace the outdated 'Current Limitations' section
(which claimed no capture and no auth) with a Server Capabilities
section reflecting async capture jobs, the auth model, search, and
admin scope; add a Web Capture Pipeline section covering the Freedium
mirror, SingleFile+Chromium, vendored Readability, cleanup, and Rust
post-processing.

NEXT.md: remove; the roadmap tracking is stale and unused.
2026-07-19 17:42:40 +02:00
e5fb6ea9bb
test(core): extract helpers and add unit tests for Codex review fixes
Extract two testable pure functions from inline_archivr_img_srcs:
- html_attr_decode: decodes HTML character references in attribute values.
  Fixes decode order: &amp; runs LAST so &amp;lt; → &lt; (one layer
  removed), not < (two layers). Previous order caused double-decoding.
- same_origin_cookie_header: returns a Cookie header value only when
  img_url's domain matches capture_url's domain.

Add 11 unit tests covering:
- html_attr_decode: plain URL no-op, &amp; in CDN query params, single-layer
  decode (&amp;lt; → &lt; not <), double-encoded amp (&amp;amp; → &amp;),
  direct &lt;/&gt;/&quot; decode
- same_origin_cookie_header: same host attaches cookies, third-party returns
  None, empty cookies returns None, Freedium empty-cookies no-op
- bounded_read: Read::take stops at max+1 bytes (guard fires), allows
  exactly-at-limit payloads (guard does not fire)
2026-07-19 15:18:16 +02:00
8c19d90e94
fix(core): address Codex review findings in reader image post-processor
P1 — Enforce size cap before buffering (singlefile.rs fetch_image_as_data_uri):
resp.bytes() buffered the entire response before checking MAX_BYTES, enabling
OOM on oversized or attacker-controlled images. Fix: reject via Content-Length
header when present, then stream at most max_bytes+1 bytes with Read::take so
the cap is enforced without materialising the full body first.

P2 — Decode HTML entities in extracted image URLs (inline_archivr_img_srcs):
The browser's HTML serialiser encodes & as &amp; in attribute values, so CDN
signed URLs with query parameters (e.g. ?a=1&b=2) arrived as &amp;-escaped
strings. reqwest sent the wrong URL, breaking signed or transformed CDN
images. Fix: unescape &amp; &lt; &gt; &quot; before passing to the fetcher.

P2 — Preserve authentication for same-origin lazy images (inline_archivr_img_srcs):
The post-processor created a bare reqwest client with no cookies, causing 401/403
for reader-mode captures of auth-gated sites whose lazy images are same-origin.
Fix: pass capture_url and cookies into inline_archivr_img_srcs; attach a Cookie
header only when domain_from_url(img_url) == domain_from_url(capture_url) and
cookies is non-empty. Third-party image hosts and Freedium fetches (which receive
empty cookies in capture.rs) are unaffected.
2026-07-19 15:09:38 +02:00
571da72649
fix(core): prevent lazy-resolver double-processing on Freedium reader images
_archivrResolveLazyImgs is called twice: before Readability (_pre) and
after the post-body stamp pass (_post). The stamp pass sets data-archivr-src
but previously left data-src/data-zoom-src/etc intact, so _post's
'lazySrc && _isPlaceholder' condition fired on the same images and
rewrote src to the CDN URL — which SingleFile then tried and failed to
fetch from the Freedium proxy context, redundantly.

Fix: strip all lazy attrs (data-src, data-lazy-src, data-zoom-src,
data-original, data-lazy) when stamping data-archivr-src. _post then
finds no lazySrc on those images and skips them cleanly. Rust owns
them via data-archivr-src. _post continues to handle any remaining
placeholder images not covered by the stamp pass (non-Freedium reader
captures).
2026-07-19 14:50:59 +02:00
3d95df8d5a
fix(core): clean up Freedium chrome and drop reader-mode debug counters
Two fixes:

1. freedium_cleanup: remove remaining Freedium article chrome that
   survived the existing nav/footer/toaster pass:
   - <header class="p-6 bg-gray-50 ..."> — author/metadata bar with
     profile pictures and byline, a Freedium wrapper around the article
   - <section> containing [data-slot="dropdown-menu-trigger"] or
     [aria-haspopup="menu"] — the "Download article" dropdown

   Selectors target stable Tailwind utility class prefixes (p-6, bg-gray-50,
   bg-zinc-800) for the header and the WAI-ARIA menu role for the button,
   both resilient to minor Freedium UI updates.

2. Reader-mode meta tag: strip per-capture debug counters.
   _archivrReaderMark now writes 'applied' instead of
   'applied:pre_s=N,...:post_s=N,...' — the counters were useful during
   development but have no place as permanent archive content.
2026-07-19 14:46:22 +02:00
453a8c6487
fix(core): inline reader-mode images via Rust post-processing
SingleFile cannot inline resources added to the DOM at before-capture
time — only resources tracked during the page's initial load cycle get
embedded. Article images in Readability output fall into this gap when
the page framework has already resolved their lazy src to a CDN URL
that isn't in SingleFile's resource cache for the new DOM elements.

Fix in three parts:
- Browser script: after body.innerHTML = article.content, stamp
  data-archivr-src=<absolute-proxy-url> on any image whose src is
  not an already-inlined large data URI. Remove loading attr. Don't
  touch src (let SingleFile try; Rust handles the rest).
- Rust (save_with): after SingleFile writes the file and before hashing,
  call inline_archivr_img_srcs() to scan for data-archivr-src markers.
- inline_archivr_img_srcs(): fetches each marked URL with blocking
  reqwest (10 s timeout, 5 redirects, image/* Content-Type guard,
  20 MiB cap), base64-encodes, replaces src with the data URI, and
  removes the marker attr. Non-fatal — fetch failures are logged.

Also: Freedium UI cleanup now removes footer, #progress, and empty
data-nosnippet wrappers in addition to the existing nav/toaster removal.
2026-07-19 14:06:54 +02:00
331fe7fd61
fix(core): extract HTML title after font stripping, not before
SingleFile embeds fonts as base64 data URIs in <style> blocks in the
<head>, pushing the <title> tag to ~1.2 MB in the raw temp file.
The 256 KiB read window in extract_html_title missed it.

Font extraction rewrites the HTML in-place (2.4 MB → 1.26 MB) before
hashing, so the title is accessible at byte ~106 KB in that content.

Fix: add extract_html_title_str() that operates on a &str; call it on
the in-memory rewritten string after font extraction (server path).
CLI path (no font extraction) falls back to result.title as before.
2026-07-19 13:15:35 +02:00
f9759c08a3
fix(core): Freedium capture fixes — title, notifications, reader images
- extract_html_title: take().read_to_end() up to 256 KiB (single read() can
  short-read, leaving title at byte ~106 K undiscovered); regression test added
- Strip " - Freedium" suffix before storing entry title
- is_freedium_fetch keys off actual fetch_url host
- Remove Freedium toast overlay ([data-sonner-toaster]) before capture
- Resolve lazy images (data-zoom-src etc.) before Readability in reader mode
2026-07-19 12:49:18 +02:00
5db18122f7
feat(frontend): add Freedium mirror toggle to capture advanced options
- freediumEnabled state defaults to true (on by default)
- via_freedium forwarded through submitCapture to the capture API
- Toggle rendered last in the advanced panel, matching existing rows
- Built frontend static assets included
2026-07-19 12:09:42 +02:00
b33bf8dbd7
feat(server): expose via_freedium in capture API (default on)
CaptureBody gains via_freedium: Option<bool>; absent defaults to true.
The rearchive handler sets it to false — existing entries should not be
silently re-fetched through a mirror.
2026-07-19 12:09:37 +02:00
a926d7cf2d
feat(core): add via_freedium to CaptureConfig; route WebPage captures through Freedium mirror
When via_freedium is true and the locator is not already a Freedium URL,
perform_capture passes https://freedium-mirror.cfd/<original-url> to
singlefile::save() so paywalled articles are fetched via the mirror.
The original locator is kept for requested_locator and canonical_locator
in the DB entry. An empty cookie map is used for the mirror fetch to
prevent original-domain credentials from being sent to freedium-mirror.cfd.
2026-07-19 12:09:30 +02:00
289037235c
feat(tags): revamp tags tab (#29)
feat(tags): revamp tags tab — tooltips, entry counts, Create/Move flows, Esc handling (#29)
2026-07-19 11:02:57 +02:00
17 changed files with 3436 additions and 1094 deletions

View file

@ -2,9 +2,9 @@
## Project Overview
Archivr is a self-hosted archival tool that captures and preserves digital content — YouTube/Twitter/Instagram/TikTok/Reddit posts, arbitrary URLs, full web pages (via SingleFile + Chromium), and local files — into self-contained, SQLite-backed archive directories with blob deduplication, hierarchical tags, collections, and role-based auth. Rust workspace + React frontend.
Archivr is a self-hosted archival tool that captures and preserves digital content — YouTube/Twitter/Instagram/TikTok/Reddit posts, arbitrary URLs, full web pages (via SingleFile + Chromium, optionally through a Freedium mirror for paywalled articles), and local files — into self-contained, SQLite-backed archive directories with blob deduplication, hierarchical tags, collections, and role-based auth. Rust workspace + React frontend.
Read `ARCHIVR-MENTAL-MODEL.md` before making structural changes; `NEXT.md` tracks the roadmap (Tracks 17 done, Track 8 = Collections UI is next).
Read `ARCHIVR-MENTAL-MODEL.md` before making structural changes.
## Architecture & Data Flow
@ -14,7 +14,7 @@ Three crates with a strict ownership split — **core owns truth; CLI and server
- `crates/archivr-server` — Axum HTTP API + auth + static frontend serving.
- `crates/archivr-cli` — clap-based CLI (`archivr` binary): `init`, `archive` subcommands.
Capture flow: locator → `determine_source()` (`crates/archivr-core/src/capture.rs`) routes by platform/shorthand (`yt:`, `x:`, `tweet:` …) → platform downloader (`downloader/ytdlp.rs`, `tweets.rs`, `singlefile.rs`, `http.rs`, `local.rs`) stages into `temp/` → SHA3-256 dedup (`hash.rs`, `downloader/store.rs`) moves blobs to `raw/A/B/HASH.EXT` → rows written to `archivr.sqlite` (runs, entries, artifacts, blobs) → served via `/api/archives/:id/...`.
Capture flow: locator → `determine_source()` (`crates/archivr-core/src/capture.rs`) routes by platform/shorthand (`yt:`, `x:`, `tweet:` …) → platform downloader (`downloader/ytdlp.rs`, `tweets.rs`, `singlefile.rs`, `http.rs`, `local.rs`) stages into `temp/` → SHA3-256 dedup (`hash.rs`, `downloader/store.rs`) moves blobs to `raw/A/B/HASH.EXT` → rows written to `archivr.sqlite` (runs, entries, artifacts, blobs) → served via `/api/archives/:id/...`. `CaptureConfig` carries per-request toggles (uBlock, reader mode, Freedium mirror, etc.); when `via_freedium` is set, the fetch URL is rewritten through `freedium-mirror.cfd` while the canonical DB URL stays the original locator.
Per-archive layout (created by `archivr init`): `.archivr/` (name, store_path, `archivr.sqlite`) + sibling `store/` (`raw/`, `raw_tweets/`, `structured/`, `temp/`). Server-level auth lives in a **separate** `archivr-auth.sqlite` (users, sessions, API tokens, role bits GUEST=1/USER=2/ADMIN=4/OWNER=8).
@ -31,6 +31,7 @@ The server mounts multiple archives from a TOML registry (`crates/archivr-server
| `docs/` | User docs (`README.md`), `superpowers/plans/` and `superpowers/specs/` (dated design docs — write plans there before large features) |
| `modules/nixos/` | NixOS module (`services.archivr-server`) |
| `vendor/twitter/` | Vendored Twitter scraper (active; the Python the server shells out to). Don't refactor casually. |
| `vendor/readability/` | Mozilla `Readability.js`, concatenated into the SingleFile reader-mode browser script by `downloader/singlefile.rs`. |
| `testing/` | Legacy scraping scripts + sample data. `testing/creds.txt` holds real tokens — never read, commit, or print it. |
## Development Commands

View file

@ -117,6 +117,16 @@ sequenceDiagram
CLI->>User: terminal result
```
## Web Capture Pipeline
Web pages (`Source::WebPage`) take a longer path than yt-dlp or tweets:
1. **Fetch URL selection.** If `via_freedium` is set and the locator isn't already a Freedium URL, the downloader fetches the page through `freedium-mirror.cfd` with no forwarded cookies. The canonical DB URL stays the original locator.
2. **Browser capture.** `downloader/singlefile.rs` shells out to `single-file-cli` driving headless Chromium. Extensions (uBlock, cookie-consent) and injected browser scripts (modal closer, reader mode) attach per `CaptureConfig`.
3. **Reader mode** (optional) concatenates Mozilla's `Readability.js` from `vendor/readability/` into the SingleFile browser script and stamps absolute URLs on lazy images so the serialised DOM points to fetchable sources.
4. **Freedium cleanup** strips mirror UI (nav, footer, toaster, author header, download control) using multi-signal selectors so article-authored controls aren't hit.
5. **Rust post-processing.** After SingleFile writes the HTML, a Rust pass fetches any images the browser couldn't inline (bounded reads, same-origin cookie forwarding) and embeds them as data URIs. Title extraction runs after embedded font blocks are stripped so large fonts don't push `<title>` beyond the read window.
## Read Data Flow
When opening the web UI:
@ -142,14 +152,17 @@ sequenceDiagram
| Feature kind | Edit here |
|---|---|
| DB schema, inserts, archive runs, entries, tags | `crates/archivr-core/src/database.rs` |
| Capture orchestration, `Source` routing, `CaptureConfig` | `crates/archivr-core/src/capture.rs` |
| Archive opening, listing entries, entry detail, runs | `crates/archivr-core/src/archive.rs` |
| Download/save behavior | `crates/archivr-core/src/downloader/` |
| CLI commands, argument parsing, terminal output | `crates/archivr-cli/src/main.rs` |
| Server API routes | `crates/archivr-server/src/routes.rs` |
| Auth model (users, sessions, tokens, roles) | `crates/archivr-server/src/auth.rs` |
| Mounted archive config model | `crates/archivr-server/src/registry.rs` |
| Browser UI behavior | `crates/archivr-server/static/app.js` |
| Browser UI layout | `crates/archivr-server/static/index.html` |
| Browser UI styling | `crates/archivr-server/static/styles.css` |
| Frontend root state + routing | `frontend/src/App.jsx` |
| Frontend API client | `frontend/src/api.js` |
| Frontend components | `frontend/src/components/` |
| Frontend styling | `frontend/src/styles.css` |
## Practical Feature Rule
@ -165,12 +178,13 @@ If a browser feature needs new data, the usual order is:
2. Expose it in `archivr-server`.
3. Render it in the static UI.
## Current Limitations
## Server Capabilities
The web server reads archive data and serves the UI. It does not yet implement capture.
The server both reads and writes archive data. Capture jobs are asynchronous: `POST /api/archives/:id/captures` inserts a job row, spawns a blocking task, and returns immediately; the frontend polls until the job completes or fails. Heavy work stays synchronous inside `archivr-core`.
Search is currently simple client-side filtering.
**Auth model.** A separate `archivr-auth.sqlite` (path derived from the server config directory) holds users, sessions, and API tokens. Role bits are `u32` flags (`GUEST`, `USER`, `ADMIN`, `OWNER`) so a single bitmask value covers assignment, checks, and visibility. The middleware stack is `setup_guard``login_rate_limit``security_headers`; route families are classified `READ / ADMIN / WRITE / STATIC` in `routes.rs`.
**Auth and session model:** The server binds to `127.0.0.1` by default and has no authentication middleware. This is intentional — Archivr is a local tool. The bind address is configurable via the TOML `bind` field or `ARCHIVR_BIND` env var; a non-loopback address triggers a startup warning. Route families are classified (READ / ADMIN / WRITE / STATIC) in `crates/archivr-server/src/routes.rs` as the decision record for when middleware is eventually added. See the "Security and Deployment" section in `docs/README.md`.
**Search** is client-side filtering over entries the frontend has already fetched.
**Admin view** covers mounted archives, users, sessions, and API tokens.
Admin is a mounted-archives view, not a management system.

225
NEXT.md
View file

@ -1,225 +0,0 @@
# Archivr Next Work Plan
> **For future agentic workers:** Read `ARCHIVR-MENTAL-MODEL.md` first, then this file.
> All six tracks from the previous handoff are complete. This document is the current roadmap.
## Current State
All five product tracks from the prior session are done:
| Feature | Status |
|---|---|
| Entry detail + artifact serving | Done |
| Server-side search with prefix filters | Done |
| Hierarchical tags (CRUD, tree, entry assignment, filter) | Done |
| Browser capture button and POST endpoint | Done |
| Local-only auth boundary, bind address config, docs | Done |
The frontend is a React + Vite app (`frontend/`) that builds into `crates/archivr-server/static/`.
The remaining gaps come from `docs/README.md` milestones that were never in the prior tracks.
---
## Remaining Work (in order)
### ~~1. Generic URL capture — plain file download~~ ✅ Done
**Implemented:** `crates/archivr-core/src/downloader/http.rs` (`download` fn, extension helpers,
HTML rejection), `Source::Url` variant in `capture.rs`, `determine_source` routes all unmatched
`http://`/`https://` URLs to `Source::Url`, `perform_capture` arm calls the downloader and flows
through the existing temp → hash → raw store pipeline. `reqwest 0.12` (blocking) added to workspace.
`docs/README.md` "URLs" milestone checked off. 112 tests green.
---
### ~~2. Web page archiving — single-file HTML snapshots~~ ✅ Done
**Implemented:** `crates/archivr-core/src/downloader/singlefile.rs` (new module; shells out to
`single-file-cli` via `ARCHIVR_SINGLE_FILE` env var; launches headless Chromium via
`ARCHIVR_CHROME`; flags: `--browser-wait-until=networkidle2`, `--remove-unused-styles=false`,
`--block-scripts=false`, `--remove-alternative-medias=false` for full CSS/font fidelity).
`Source::WebPage` variant added to `capture.rs`; HEAD-first probe (`probe_url_kind` in `http.rs`)
routes `text/html` responses to `Source::WebPage` and everything else to `Source::Url`.
Font deduplication implemented as a follow-on: `font_extractor.rs` post-processes the saved HTML,
extracts embedded `@font-face` base64 blobs, stores each as a deduplicated artifact in the raw
store, and rewrites the HTML to reference them via `/api/archives/:id/blobs/:sha256`.
`hash_bytes` added to `hash.rs`; `get_blob_by_sha256` added to `database.rs`;
`GET /api/archives/:id/blobs/:sha256` route added to `routes.rs`.
`flake.nix` updated: `pkgs.single-file-cli` and (Linux-only) `pkgs.chromium` added to
`buildInputs`; `ARCHIVR_SINGLE_FILE` and `ARCHIVR_CHROME` env vars wired into both wrappers.
`docs/README.md` "Archive web pages" milestone checked off. 143 tests green.
---
### 3. Async capture jobs
**What:** Capture currently runs synchronously on the HTTP request thread. A YouTube video,
a large tweet thread, or a slow monolith page will stall the browser request. This track
adds a job queue so `POST /api/archives/:id/captures` returns immediately and the UI polls
for completion.
**Where to edit:**
| File | Change |
|---|---|
| `crates/archivr-core/src/database.rs` | Add `capture_jobs` table to the schema: `(id, job_uid, run_uid, archive_id, status TEXT CHECK(status IN ('pending','running','completed','failed')), error_text, created_at, updated_at)`. Add `create_capture_job`, `update_capture_job_status`, `get_capture_job` helpers. |
| `crates/archivr-core/src/archive.rs` | Add `CaptureJob` summary type and `get_capture_job` query. |
| `crates/archivr-server/src/routes.rs` | `POST /api/archives/:id/captures` → insert job row, spawn `tokio::task::spawn_blocking` for the capture call, return `{ job_uid, status: "pending" }` immediately. Add `GET /api/archives/:id/capture_jobs/:job_uid` route. |
| `frontend/src/api.js` | Add `pollCaptureJob(archiveId, jobUid)` |
| `frontend/src/components/CaptureDialog.jsx` | After submit: enter polling loop (500 ms interval), show "Running…" state, handle `completed` (close + refresh) and `failed` (show error). |
**Acceptance criteria:**
- `POST /captures` returns within 50 ms regardless of capture duration.
- UI shows "Running…" and updates when the job finishes.
- Failed captures surface the error message in the dialog.
- A server restart while a job is `running` leaves it stalled as `running`; on next startup,
jobs stuck in `running` should be marked `failed` with a note ("interrupted by server restart").
- Existing `cargo test` passes.
**Key risk:** `spawn_blocking` runs on a Tokio thread pool thread. A very long capture
(15-minute YouTube video) will occupy one thread for the duration. For now that is acceptable.
A proper queue (channel + worker task) can replace it later without changing the DB schema or API.
---
### ~~4. Auth foundation — session + role + setup~~ ✅ Done
**Implemented:** `crates/archivr-server/src/auth.rs` (new; `AuthUser` extractor, Argon2id password
hashing, token generation, role bit constants). `crates/archivr-core/src/database.rs`:
`initialize_auth_schema` (roles, user_roles, sessions, api_tokens, instance_settings tables);
`open_auth_db` (separate server-level auth SQLite); `create_owner`, `compute_role_bits`,
`create_session`, `get_session`, `create_api_token`, `get_user_for_token` and related helpers.
`crates/archivr-server/src/routes.rs`: `AppState` gains `auth_db_path`; `setup_guard` middleware
returns 503 until owner created; `POST /api/auth/login`, `POST /api/auth/logout`,
`GET /api/auth/me`, `GET|POST /api/auth/setup`, `GET|POST|DELETE /api/auth/tokens` endpoints;
WRITE routes (captures, tags) guarded by `ROLE_USER`. `crates/archivr-server/src/main.rs`:
computes auth DB path from config directory; session cleanup background task.
Frontend: `LoginPage.jsx`, `SetupPage.jsx`, `AuthContext` in `App.jsx`, user menu in `Topbar.jsx`.
`docs/specs/2026-06-25-auth-foundation-design.md` has the full design. Roles use a bitmask:
guest=1, user=2, admin=4, owner=8. All tests green.
---
### ~~5. User management~~ ✅ Done
**Implemented:** `crates/archivr-core/src/database.rs`: `UserSummary` and `RoleRecord` structs;
10 new pub functions (`invalidate_user_sessions`, `get_user_id_by_uid`, `list_users`,
`get_user_by_uid`, `create_user`, `set_user_status`, `assign_role`, `remove_role`, `list_roles`,
`create_custom_role`). `crates/archivr-server/src/routes.rs`: 5 admin routes
(`GET|POST /api/admin/users`, `PATCH /api/admin/users/:uid/status`,
`POST|DELETE /api/admin/users/:uid/roles`, `GET|POST /api/admin/roles`); 7 handler functions;
request body structs. `frontend/src/api.js`: 7 admin helpers (`listAdminUsers`,
`createAdminUser`, `setUserStatus`, `assignRole`, `removeRole`, `listRoles`, `createRole`).
`frontend/src/components/AdminView.jsx`: two-tab admin panel (Users / Roles) with ban/unban,
create user form, create custom role form. Role bitmask: guest=1, user=2, admin=4, owner=8;
custom roles get bit_position≥4. Ban invalidates all sessions. Only-owner guard on role removal.
169 tests green.
---
### ~~6. Permissions & visibility — collection model~~ ✅ Done
**Implemented:** `database.rs`: `collections` + `collection_entries` tables; seed default collection ('All Entries');
migration inserts existing entries into default collection with `visibility_bits` derived from legacy `visibility` string
(`'public'`→3, `'unlisted'`→2, `'private'`→0). 8 new pub functions (`ensure_default_collection`, `create_collection`,
`list_collections`, `get_collection_by_uid`, `add_entry_to_collection`, `update_collection_entry_visibility`,
`remove_entry_from_collection`, `get_entry_collection_memberships`). `create_archived_entry` auto-enrolls new entries
into the default collection. `archive.rs`: `list_root_entries` + `search_entries` accept `caller_bits: u32` and enforce
collection visibility (`admin/owner bypass`; others filtered by `visibility_bits & caller_bits`). `list_entries_for_collection`,
`get_entry_collections`, `EntryCollectionMembership` added. `routes.rs`: read routes extract auth and pass caller_bits;
5 collection-entry route groups (`GET|POST /api/archives/:id/collections`, `GET /api/archives/:id/collections/:uid`,
`POST|DELETE|PATCH /api/archives/:id/collections/:uid/entries(/:entry_uid)`,
`GET /api/archives/:id/entries/:uid/collections`). Frontend: `CollectionsView.jsx` (list + detail + create form);
`collections` nav item in Topbar; entry collection memberships + visibility shown in ContextRail. 169 tests green.
Depends on Track 5.
---
### ~~7. Settings~~ ✅ Done
**Implemented:** `database.rs`: `display_name TEXT` column migration (ALTER TABLE, idempotent);
`InstanceSettings` struct; 6 new pub fns (`get_instance_settings`, `update_instance_settings`,
`update_user_display_name`, `update_user_password`, `get_user_password_hash`,
`get_user_display_name`). `routes.rs`: `PATCH /api/auth/me` (display name update + current-password-
verified password change); `GET|PATCH /api/admin/instance-settings` (ROLE_ADMIN);
`auth_me` now returns `display_name`. `frontend/src/api.js`: 7 new helpers (`updateProfile`,
`changePassword`, `listTokens`, `createToken`, `deleteToken`, `getInstanceSettings`,
`updateInstanceSettings`). `frontend/src/components/SettingsView.jsx`: tabbed settings page
(Profile — display name + password change; API Tokens — create/list/revoke with one-time reveal;
Instance — public index, public content, open registration toggles + default visibility select).
`Topbar.jsx`: settings nav button; user-menu shows display_name ?? username.
`App.jsx`: renders SettingsView for `view === 'settings'`. 176 tests green. Depends on Track 5.
---
### 8. Collections UI
**What:** Full collection management — create, rename, delete, add/remove entries,
set per-entry visibility within a collection, make collections public.
Depends on Tracks 56.
---
### 9. Cloud backup — S3-compatible
**What:** A command or scheduled operation that syncs the archive store to an S3-compatible
bucket (AWS S3, Cloudflare R2, Backblaze B2). Incremental: only uploads blobs not already
present in the bucket. The DB is also backed up.
**Design decisions to make before implementing:**
- CLI subcommand (`archivr backup`) vs. scheduled via the server vs. both.
- Per-archive config vs. a global backup profile.
- Whether to back up only the blob store or also the SQLite DB.
**Recommended shape:**
```toml
# In the archive's own config or in archivr-server.toml
[backup.s3]
bucket = "my-archivr-backup"
prefix = "personal/"
endpoint = "https://..." # optional, for R2/B2
region = "us-east-1"
# credentials via AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY env vars
```
**Where to edit (when ready):**
| File | Change |
|---|---|
| `crates/archivr-core/src/backup.rs` | New module. Walk `store/raw/` tree, list objects in bucket, diff, upload new blobs. Also snapshot and upload `archivr.sqlite`. |
| `crates/archivr-cli/src/main.rs` | Add `archivr backup [--archive <path>]` subcommand |
| `Cargo.toml` | Add `aws-sdk-s3` or `object_store` crate dependency |
| `docs/README.md` | Document backup config and command |
**Acceptance criteria:**
- `archivr backup` uploads all blobs not already in the bucket.
- A second run uploads nothing (idempotent).
- DB snapshot is included.
- Missing or invalid credentials return a clear error before any uploads begin.
---
### 10. Cloud storage archiving (Google Drive, Dropbox, OneDrive)
Deferred. Each requires per-service OAuth, API clients, and download logic. Implement after
Tracks 14 are stable.
When approached, add a `Source::GoogleDrive`, `Source::Dropbox`, etc. variant in `capture.rs`
and a corresponding downloader module. Consider `rclone` as a shell-out strategy analogous to
`yt-dlp` and `monolith` — it handles auth and download for all three services.
---
## What to Do First
Tracks 1, 2, 3, 4, 5, and 6 are complete. Track 7 (settings) is the next priority.
Open the next thread with:
```text
Read ARCHIVR-MENTAL-MODEL.md and NEXT.md. I want to implement Track 7: settings —
account profile, password change, instance settings UI, API token management.
Create a task-level implementation plan first, then wait for approval.
```

View file

@ -85,6 +85,8 @@ pub struct Tag {
#[derive(Debug, Clone, serde::Serialize)]
pub struct TagNode {
pub tag: Tag,
pub entry_count: i64,
pub subtree_count: i64,
pub children: Vec<TagNode>,
}
@ -196,7 +198,10 @@ pub fn initialize_store_directories(store_path: &Path) -> Result<()> {
Ok(())
}
pub fn list_root_entries(conn: &rusqlite::Connection, caller_bits: u32) -> Result<Vec<EntrySummary>> {
pub fn list_root_entries(
conn: &rusqlite::Connection,
caller_bits: u32,
) -> Result<Vec<EntrySummary>> {
let mut stmt = conn.prepare(
"SELECT
e.entry_uid,
@ -336,16 +341,18 @@ pub fn get_capture_job(
conn: &rusqlite::Connection,
job_uid: &str,
) -> Result<Option<CaptureJobSummary>> {
Ok(database::get_capture_job(conn, job_uid)?.map(|r| CaptureJobSummary {
job_uid: r.job_uid,
archive_id: r.archive_id,
run_uid: r.run_uid,
status: r.status,
error_text: r.error_text,
notes_json: r.notes_json,
created_at: r.created_at,
updated_at: r.updated_at,
}))
Ok(
database::get_capture_job(conn, job_uid)?.map(|r| CaptureJobSummary {
job_uid: r.job_uid,
archive_id: r.archive_id,
run_uid: r.run_uid,
status: r.status,
error_text: r.error_text,
notes_json: r.notes_json,
created_at: r.created_at,
updated_at: r.updated_at,
}),
)
}
/// Lists all collections in the archive.
@ -413,8 +420,7 @@ pub fn list_entries_for_collection(
OR (ce.visibility_bits & CAST(?2 AS INTEGER)) != 0) \
GROUP BY e.id \
ORDER BY e.archived_at DESC, e.id DESC",
ENTRY_SELECT_COLS,
ENTRY_FROM_JOINS,
ENTRY_SELECT_COLS, ENTRY_FROM_JOINS,
);
let mut stmt = conn.prepare(&sql)?;
let entries = stmt
@ -448,9 +454,12 @@ pub fn resolve_artifact_path(
artifact: &EntryArtifactSummary,
) -> Result<PathBuf> {
let joined = store_path.join(&artifact.relpath);
let canonical_store = store_path
.canonicalize()
.with_context(|| format!("failed to canonicalize store path: {}", store_path.display()))?;
let canonical_store = store_path.canonicalize().with_context(|| {
format!(
"failed to canonicalize store path: {}",
store_path.display()
)
})?;
let canonical_artifact = joined
.canonicalize()
.with_context(|| format!("artifact path does not exist: {}", joined.display()))?;
@ -522,13 +531,13 @@ pub fn parse_search_query(raw: &str) -> Result<SearchEntriesQuery, String> {
match prefix {
"source" => query.source_kind = Some(value),
"type" => query.entity_kind = Some(value),
"url" => query.url = Some(value),
"title" => query.title = Some(value),
"after" => query.after = Some(value),
"type" => query.entity_kind = Some(value),
"url" => query.url = Some(value),
"title" => query.title = Some(value),
"after" => query.after = Some(value),
"before" => query.before = Some(value),
"tag" => query.tag = Some(value),
other => return Err(other.to_string()),
"tag" => query.tag = Some(value),
other => return Err(other.to_string()),
}
} else {
free_text_tokens.push(token);
@ -541,16 +550,14 @@ pub fn parse_search_query(raw: &str) -> Result<SearchEntriesQuery, String> {
Ok(query)
}
const ENTRY_SELECT_COLS: &str =
"SELECT e.entry_uid, e.archived_at, e.source_kind, e.entity_kind, e.title, \
const ENTRY_SELECT_COLS: &str = "SELECT e.entry_uid, e.archived_at, e.source_kind, e.entity_kind, e.title, \
e.visibility, si.canonical_url, COUNT(ea.id) AS artifact_count, \
COALESCE(SUM(b.byte_size), 0) AS total_artifact_bytes, \
parent.entry_uid AS parent_entry_uid, \
EXISTS(SELECT 1 FROM entry_artifacts fav WHERE fav.entry_id = e.id AND fav.artifact_role = 'favicon') AS has_favicon, \
e.cached_bytes";
const ENTRY_FROM_JOINS: &str =
"FROM archived_entries e \
const ENTRY_FROM_JOINS: &str = "FROM archived_entries e \
JOIN source_identities si ON si.id = e.source_identity_id \
LEFT JOIN entry_artifacts ea ON ea.entry_id = e.id \
LEFT JOIN blobs b ON b.id = ea.blob_id \
@ -579,14 +586,12 @@ pub fn search_entries(
JOIN entry_tag_assignments eta ON eta.entry_id = e.id \
JOIN descendants d ON eta.tag_id = d.id \
WHERE 1=1",
ENTRY_SELECT_COLS,
ENTRY_FROM_JOINS,
ENTRY_SELECT_COLS, ENTRY_FROM_JOINS,
);
} else {
sql = format!(
"{} {} WHERE e.parent_entry_id IS NULL",
ENTRY_SELECT_COLS,
ENTRY_FROM_JOINS,
ENTRY_SELECT_COLS, ENTRY_FROM_JOINS,
);
}
@ -687,19 +692,65 @@ pub fn create_tag(conn: &rusqlite::Connection, full_path: &str) -> Result<Tag> {
}
/// Returns the full tag tree with root nodes at the top level and children nested.
/// Each node includes a direct entry count and a subtree count (unique entries
/// assigned to the tag itself or any descendant).
pub fn list_tag_tree(conn: &rusqlite::Connection) -> Result<Vec<TagNode>> {
use std::collections::HashMap;
let records = database::list_all_tags(conn)?;
// Fetch direct entry counts for all tags in a single query.
let mut counts: HashMap<String, i64> = HashMap::new();
{
let mut stmt = conn.prepare(
"SELECT t.tag_uid, COUNT(eta.entry_id) \
FROM tags t \
LEFT JOIN entry_tag_assignments eta ON eta.tag_id = t.id \
GROUP BY t.id",
)?;
for row in stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)))? {
let (uid, cnt) = row?;
counts.insert(uid, cnt);
}
}
// Fetch subtree entry counts: for each tag, count distinct entries assigned
// to it or any descendant. COUNT(DISTINCT) ensures an entry tagged at both
// a parent and a child is counted only once.
let mut subtree_counts: HashMap<String, i64> = HashMap::new();
{
let mut stmt = conn.prepare(
"WITH RECURSIVE descendants(ancestor_id, descendant_id) AS ( \
SELECT id, id FROM tags \
UNION ALL \
SELECT d.ancestor_id, t.id \
FROM tags t JOIN descendants d ON t.parent_tag_id = d.descendant_id \
) \
SELECT t.tag_uid, COUNT(DISTINCT eta.entry_id) \
FROM tags t \
JOIN descendants d ON d.ancestor_id = t.id \
LEFT JOIN entry_tag_assignments eta ON eta.tag_id = d.descendant_id \
GROUP BY t.id",
)?;
for row in stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)))? {
let (uid, cnt) = row?;
subtree_counts.insert(uid, cnt);
}
}
let mut by_parent: HashMap<Option<i64>, Vec<database::TagRecord>> = HashMap::new();
for record in records {
by_parent.entry(record.parent_tag_id).or_default().push(record);
by_parent
.entry(record.parent_tag_id)
.or_default()
.push(record);
}
fn build_nodes(
parent_id: Option<i64>,
by_parent: &HashMap<Option<i64>, Vec<database::TagRecord>>,
counts: &HashMap<String, i64>,
subtree_counts: &HashMap<String, i64>,
) -> Vec<TagNode> {
let Some(children) = by_parent.get(&parent_id) else {
return Vec::new();
@ -713,22 +764,21 @@ pub fn list_tag_tree(conn: &rusqlite::Connection) -> Result<Vec<TagNode>> {
slug: r.slug.clone(),
full_path: r.full_path.clone(),
},
children: build_nodes(Some(r.id), by_parent),
entry_count: counts.get(&r.tag_uid).copied().unwrap_or(0),
subtree_count: subtree_counts.get(&r.tag_uid).copied().unwrap_or(0),
children: build_nodes(Some(r.id), by_parent, counts, subtree_counts),
})
.collect()
}
Ok(build_nodes(None, &by_parent))
Ok(build_nodes(None, &by_parent, &counts, &subtree_counts))
}
/// Returns the tags assigned to an entry.
///
/// Returns `Ok(None)` if the entry_uid does not exist (caller maps to 404).
/// Returns `Ok(Some([]))` if the entry exists but has no tags.
pub fn get_entry_tags(
conn: &rusqlite::Connection,
entry_uid: &str,
) -> Result<Option<Vec<Tag>>> {
pub fn get_entry_tags(conn: &rusqlite::Connection, entry_uid: &str) -> Result<Option<Vec<Tag>>> {
let Some(entry_id) = conn
.query_row(
"SELECT id FROM archived_entries WHERE entry_uid = ?1",
@ -1111,10 +1161,14 @@ mod tests {
// Entry 1: tweet by source x
let si1 = database::upsert_source_identity(
&conn, "x", "tweet", Some("t-1"),
&conn,
"x",
"tweet",
Some("t-1"),
Some("https://x.com/user/status/1"),
"https://x.com/user/status/1",
).unwrap();
)
.unwrap();
database::create_archived_entry(
&conn,
&database::NewEntry {
@ -1132,14 +1186,19 @@ mod tests {
source_metadata_json: "{}".to_string(),
display_metadata_json: None,
},
).unwrap();
)
.unwrap();
// Entry 2: web page
let si2 = database::upsert_source_identity(
&conn, "web", "page", Some("page-1"),
&conn,
"web",
"page",
Some("page-1"),
Some("https://medium.com/article"),
"https://medium.com/article",
).unwrap();
)
.unwrap();
database::create_archived_entry(
&conn,
&database::NewEntry {
@ -1157,7 +1216,8 @@ mod tests {
source_metadata_json: "{}".to_string(),
display_metadata_json: None,
},
).unwrap();
)
.unwrap();
conn
}
@ -1173,10 +1233,14 @@ mod tests {
#[test]
fn search_q_filters_on_title() {
let conn = make_test_db_with_entries();
let results = search_entries(&conn, &SearchEntriesQuery {
q: Some("polymarket".to_string()),
..Default::default()
}).unwrap();
let results = search_entries(
&conn,
&SearchEntriesQuery {
q: Some("polymarket".to_string()),
..Default::default()
},
)
.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].entity_kind, "tweet");
}
@ -1184,10 +1248,14 @@ mod tests {
#[test]
fn search_entity_kind_exact_match() {
let conn = make_test_db_with_entries();
let results = search_entries(&conn, &SearchEntriesQuery {
entity_kind: Some("page".to_string()),
..Default::default()
}).unwrap();
let results = search_entries(
&conn,
&SearchEntriesQuery {
entity_kind: Some("page".to_string()),
..Default::default()
},
)
.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].source_kind, "web");
}
@ -1195,10 +1263,14 @@ mod tests {
#[test]
fn search_url_like_filter() {
let conn = make_test_db_with_entries();
let results = search_entries(&conn, &SearchEntriesQuery {
url: Some("medium.com".to_string()),
..Default::default()
}).unwrap();
let results = search_entries(
&conn,
&SearchEntriesQuery {
url: Some("medium.com".to_string()),
..Default::default()
},
)
.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].title.as_deref(), Some("Resume Templates"));
}
@ -1206,21 +1278,29 @@ mod tests {
#[test]
fn search_no_match_returns_empty() {
let conn = make_test_db_with_entries();
let results = search_entries(&conn, &SearchEntriesQuery {
q: Some("zzznonexistent".to_string()),
..Default::default()
}).unwrap();
let results = search_entries(
&conn,
&SearchEntriesQuery {
q: Some("zzznonexistent".to_string()),
..Default::default()
},
)
.unwrap();
assert!(results.is_empty());
}
#[test]
fn search_multiple_filters_compound() {
let conn = make_test_db_with_entries();
let results = search_entries(&conn, &SearchEntriesQuery {
source_kind: Some("x".to_string()),
entity_kind: Some("tweet".to_string()),
..Default::default()
}).unwrap();
let results = search_entries(
&conn,
&SearchEntriesQuery {
source_kind: Some("x".to_string()),
entity_kind: Some("tweet".to_string()),
..Default::default()
},
)
.unwrap();
assert_eq!(results.len(), 1);
}
@ -1243,9 +1323,8 @@ mod tests {
title: &str,
url: &str,
) -> database::ArchivedEntry {
let si = database::upsert_source_identity(
conn, "web", "page", None, Some(url), url,
).unwrap();
let si =
database::upsert_source_identity(conn, "web", "page", None, Some(url), url).unwrap();
database::create_archived_entry(
conn,
&database::NewEntry {
@ -1263,7 +1342,8 @@ mod tests {
source_metadata_json: "{}".to_string(),
display_metadata_json: None,
},
).unwrap()
)
.unwrap()
}
#[test]
@ -1275,32 +1355,143 @@ mod tests {
let tree = list_tag_tree(&conn).unwrap();
assert_eq!(tree.len(), 2, "expected two root nodes");
let science = tree.iter().find(|n| n.tag.slug == "science").expect("science root missing");
let science = tree
.iter()
.find(|n| n.tag.slug == "science")
.expect("science root missing");
assert_eq!(science.children.len(), 1, "science should have one child");
assert_eq!(science.children[0].tag.slug, "cs");
let art = tree.iter().find(|n| n.tag.slug == "art").expect("art root missing");
let art = tree
.iter()
.find(|n| n.tag.slug == "art")
.expect("art root missing");
assert!(art.children.is_empty(), "art should have no children");
}
#[test]
fn tag_tree_entry_counts_direct_and_subtree() {
let (conn, user_id, run_id) = make_tag_test_db();
// Tree: /science -> /science/cs -> /science/cs/algorithms
create_tag(&conn, "/science/cs/algorithms").unwrap();
let e1 = make_entry_in_db(
&conn,
user_id,
run_id,
None,
None,
"E1",
"https://example.com/e1",
);
let e2 = make_entry_in_db(
&conn,
user_id,
run_id,
None,
None,
"E2",
"https://example.com/e2",
);
// e1 → /science/cs/algorithms (leaf), e2 → /science (root)
assign_entry_tag(&conn, &e1.entry_uid, "/science/cs/algorithms").unwrap();
assign_entry_tag(&conn, &e2.entry_uid, "/science").unwrap();
let tree = list_tag_tree(&conn).unwrap();
let science = tree.iter().find(|n| n.tag.slug == "science").unwrap();
let cs = science
.children
.iter()
.find(|n| n.tag.slug == "cs")
.unwrap();
let algo = cs
.children
.iter()
.find(|n| n.tag.slug == "algorithms")
.unwrap();
assert_eq!(science.entry_count, 1, "science direct = 1");
assert_eq!(
science.subtree_count, 2,
"science subtree = 2 (e1 via algo, e2 direct)"
);
assert_eq!(cs.entry_count, 0, "cs direct = 0");
assert_eq!(cs.subtree_count, 1, "cs subtree = 1 (e1 via algo)");
assert_eq!(algo.entry_count, 1, "algo direct = 1");
assert_eq!(algo.subtree_count, 1, "algo subtree = 1");
}
#[test]
fn tag_tree_subtree_count_deduplicates_shared_entry() {
// Regression: an entry assigned to both a parent and a child must count
// as 1 in the parent's subtree_count, not 2.
let (conn, user_id, run_id) = make_tag_test_db();
create_tag(&conn, "/science/cs").unwrap();
let e = make_entry_in_db(
&conn,
user_id,
run_id,
None,
None,
"E",
"https://example.com/ded",
);
assign_entry_tag(&conn, &e.entry_uid, "/science").unwrap();
assign_entry_tag(&conn, &e.entry_uid, "/science/cs").unwrap();
let tree = list_tag_tree(&conn).unwrap();
let science = tree.iter().find(|n| n.tag.slug == "science").unwrap();
assert_eq!(
science.subtree_count, 1,
"entry assigned to both parent and child must not be double-counted"
);
assert_eq!(science.entry_count, 1, "science direct = 1");
assert_eq!(science.children[0].subtree_count, 1, "cs subtree = 1");
}
#[test]
fn assign_entry_tag_is_idempotent() {
let (conn, user_id, run_id) = make_tag_test_db();
let entry = make_entry_in_db(&conn, user_id, run_id, None, None, "Test", "https://example.com/t1");
let entry = make_entry_in_db(
&conn,
user_id,
run_id,
None,
None,
"Test",
"https://example.com/t1",
);
assign_entry_tag(&conn, &entry.entry_uid, "/science").unwrap();
assign_entry_tag(&conn, &entry.entry_uid, "/science").unwrap();
let tags = get_entry_tags(&conn, &entry.entry_uid).unwrap().unwrap();
assert_eq!(tags.len(), 1, "idempotent assign should yield exactly one tag");
assert_eq!(
tags.len(),
1,
"idempotent assign should yield exactly one tag"
);
}
#[test]
fn remove_entry_tag_clears_assignment() {
let (conn, user_id, run_id) = make_tag_test_db();
let entry = make_entry_in_db(&conn, user_id, run_id, None, None, "Test", "https://example.com/t2");
let entry = make_entry_in_db(
&conn,
user_id,
run_id,
None,
None,
"Test",
"https://example.com/t2",
);
let tag = assign_entry_tag(&conn, &entry.entry_uid, "/science").unwrap().unwrap();
let tag = assign_entry_tag(&conn, &entry.entry_uid, "/science")
.unwrap()
.unwrap();
remove_entry_tag(&conn, &entry.entry_uid, &tag.tag_uid).unwrap();
let tags = get_entry_tags(&conn, &entry.entry_uid).unwrap().unwrap();
@ -1310,7 +1501,15 @@ mod tests {
#[test]
fn entries_for_tag_includes_descendants() {
let (conn, user_id, run_id) = make_tag_test_db();
let entry = make_entry_in_db(&conn, user_id, run_id, None, None, "Compilers Paper", "https://example.com/c1");
let entry = make_entry_in_db(
&conn,
user_id,
run_id,
None,
None,
"Compilers Paper",
"https://example.com/c1",
);
assign_entry_tag(&conn, &entry.entry_uid, "/science/cs/compilers").unwrap();
@ -1322,11 +1521,23 @@ mod tests {
#[test]
fn entries_for_tag_includes_child_entries() {
let (conn, user_id, run_id) = make_tag_test_db();
let parent = make_entry_in_db(&conn, user_id, run_id, None, None, "Playlist", "https://example.com/pl");
let parent = make_entry_in_db(
&conn,
user_id,
run_id,
None,
None,
"Playlist",
"https://example.com/pl",
);
let child = make_entry_in_db(
&conn, user_id, run_id,
Some(parent.id), Some(parent.id),
"Video 1", "https://example.com/pl/v1",
&conn,
user_id,
run_id,
Some(parent.id),
Some(parent.id),
"Video 1",
"https://example.com/pl/v1",
);
assign_entry_tag(&conn, &child.entry_uid, "/science").unwrap();
@ -1334,42 +1545,77 @@ mod tests {
let results = entries_for_tag(&conn, "/science").unwrap();
assert_eq!(results.len(), 1, "only the tagged child should appear");
assert_eq!(results[0].entry_uid, child.entry_uid);
assert_eq!(results[0].parent_entry_uid.as_deref(), Some(parent.entry_uid.as_str()));
assert_eq!(
results[0].parent_entry_uid.as_deref(),
Some(parent.entry_uid.as_str())
);
}
#[test]
fn search_with_tag_filter_works() {
let (conn, user_id, run_id) = make_tag_test_db();
let entry = make_entry_in_db(&conn, user_id, run_id, None, None, "Science Article", "https://example.com/s1");
let entry = make_entry_in_db(
&conn,
user_id,
run_id,
None,
None,
"Science Article",
"https://example.com/s1",
);
assign_entry_tag(&conn, &entry.entry_uid, "/science").unwrap();
let results = search_entries(&conn, &SearchEntriesQuery {
tag: Some("/science".to_string()),
..Default::default()
}).unwrap();
let results = search_entries(
&conn,
&SearchEntriesQuery {
tag: Some("/science".to_string()),
..Default::default()
},
)
.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].entry_uid, entry.entry_uid);
let empty = search_entries(&conn, &SearchEntriesQuery {
tag: Some("/art".to_string()),
..Default::default()
}).unwrap();
let empty = search_entries(
&conn,
&SearchEntriesQuery {
tag: Some("/art".to_string()),
..Default::default()
},
)
.unwrap();
assert!(empty.is_empty(), "no entries under /art");
}
#[test]
fn search_without_tag_returns_roots_only() {
let (conn, user_id, run_id) = make_tag_test_db();
let parent = make_entry_in_db(&conn, user_id, run_id, None, None, "Parent", "https://example.com/par");
let parent = make_entry_in_db(
&conn,
user_id,
run_id,
None,
None,
"Parent",
"https://example.com/par",
);
let _child = make_entry_in_db(
&conn, user_id, run_id,
Some(parent.id), Some(parent.id),
"Child", "https://example.com/par/c",
&conn,
user_id,
run_id,
Some(parent.id),
Some(parent.id),
"Child",
"https://example.com/par/c",
);
let results = search_entries(&conn, &SearchEntriesQuery::default()).unwrap();
assert_eq!(results.len(), 1, "plain search should return root entries only");
assert_eq!(
results.len(),
1,
"plain search should return root entries only"
);
assert_eq!(results[0].entry_uid, parent.entry_uid);
}
}

View file

@ -87,6 +87,9 @@ pub struct CaptureConfig {
pub reader_mode: bool,
/// Override for modal-closer browser-script behavior during WebPage captures.
pub modal_closer_enabled: Option<bool>,
/// Route WebPage captures through the Freedium mirror to bypass paywalls.
/// The original locator is still recorded in the DB; only the fetch URL changes.
pub via_freedium: bool,
}
/// Resolves which cookies apply to `url` by evaluating all rules in ordinal order.
@ -1038,7 +1041,20 @@ pub fn perform_capture(
// Source: web page — archive as a self-contained HTML snapshot via single-file-cli
if source == Source::WebPage {
match downloader::singlefile::save(locator, store_path, &timestamp, &cookies, config.ublock_enabled, config.cookie_ext_enabled, config.reader_mode, config.modal_closer_enabled) {
// When via_freedium is enabled and the URL is not already a freedium mirror,
// fetch through the mirror to bypass paywalls. Store the original locator in DB.
// Use an empty cookie jar for the mirror URL: cookies resolved for the original
// domain (e.g. NYT, Medium) must not be sent to freedium-mirror.cfd.
let (fetch_url, fetch_cookies): (String, HashMap<String, String>) =
if config.via_freedium && !locator.starts_with("https://freedium-mirror.cfd/") {
(format!("https://freedium-mirror.cfd/{}", locator), HashMap::new())
} else {
(locator.to_string(), cookies.clone())
};
// Key cleanup/title-stripping off the actual fetch host so that
// user-supplied freedium-mirror.cfd URLs are also handled correctly.
let is_freedium_fetch = fetch_url.starts_with("https://freedium-mirror.cfd/");
match downloader::singlefile::save(&fetch_url, store_path, &timestamp, &fetch_cookies, config.ublock_enabled, config.cookie_ext_enabled, config.reader_mode, config.modal_closer_enabled, is_freedium_fetch) {
Ok(result) => {
let file_extension = ".html".to_string();
let temp_html = store_path
@ -1049,23 +1065,26 @@ pub fn perform_capture(
// Font extraction: rewrite the HTML in-place before hashing.
// Only runs when archive_id is known (server context). CLI passes
// None and keeps fonts embedded — no behaviour change for CLI.
let (html_hash, byte_size, extracted_fonts) =
let (html_hash, byte_size, extracted_fonts, html_title) =
if let Some(aid) = archive_id {
let content = fs::read_to_string(&temp_html)
.with_context(|| format!("failed to read {}", temp_html.display()))?;
let (rewritten, fonts) =
downloader::font_extractor::extract_and_rewrite(&content, store_path, aid)
.unwrap_or_else(|_| (content.clone(), vec![])); // non-fatal
// Extract title after font-stripping so the title tag is not buried
// behind multi-MB embedded font data that would exceed the 256 KiB window.
let title = downloader::singlefile::extract_html_title_str(&rewritten);
fs::write(&temp_html, rewritten.as_bytes())
.with_context(|| "failed to write rewritten HTML")?;
let size = rewritten.len() as i64;
let new_hash = crate::hash::hash_bytes(rewritten.as_bytes());
(new_hash, size, fonts)
(new_hash, size, fonts, title)
} else {
let size = fs::metadata(&temp_html)
.with_context(|| format!("failed to stat {}", temp_html.display()))?
.len() as i64;
(result.html_hash.clone(), size, vec![])
(result.html_hash.clone(), size, vec![], result.title.clone())
};
// 1. Move HTML to raw store (if this hash hasn't been seen before).
@ -1102,6 +1121,17 @@ pub fn perform_capture(
let _ = fs::remove_dir_all(store_path.join("temp").join(&timestamp));
// 4. Create the entry + primary_media artifact.
// Strip mirror branding from title when fetched via Freedium.
let entry_title = if is_freedium_fetch {
html_title.as_deref().map(|t| {
t.trim_end_matches(" - Freedium")
.trim_end_matches(" \u{2014} Freedium")
.trim()
.to_string()
})
} else {
html_title
};
let entry = record_media_entry(
&conn,
store_path,
@ -1114,7 +1144,7 @@ pub fn perform_capture(
&html_hash,
&file_extension,
byte_size,
result.title,
entry_title,
)?;
// 5. Add favicon artifact if we captured one.

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,5 @@
use anyhow::{Context, Result, bail};
use regex::Regex;
use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine as _;
use std::{
@ -47,12 +48,63 @@ const READER_MODE_SCRIPT: &str = concat!(
_archivrReaderMark('failed:no-readability');
return;
}
// Helper: resolve lazy-loaded images (src="data:," placeholders) to absolute URLs.
// Called before the Readability clone so the clone has real src values, and again
// after body replacement because Readability serialises src attributes as-is from
// the clone — any remaining placeholders in article.content must also be fixed.
function _archivrResolveLazyImgs(base) {
var seen=0,lazy=0,placeholders=0,fixed=0;
document.querySelectorAll('img').forEach(function(img) {
seen++;
var lazySrc = img.getAttribute('data-src') || img.getAttribute('data-lazy-src') ||
img.getAttribute('data-zoom-src') || img.getAttribute('data-original') ||
img.getAttribute('data-lazy');
if(lazySrc) lazy++;
var curSrc = img.getAttribute('src') || '';
var _isPlaceholder = !curSrc || curSrc === 'data:,' ||
(curSrc.startsWith('data:') && curSrc.length < 512);
if(_isPlaceholder) placeholders++;
if (lazySrc && _isPlaceholder) {
try { lazySrc = new URL(lazySrc, base).href; } catch(e) {}
img.setAttribute('src', lazySrc);
img.removeAttribute('loading');
fixed++;
}
});
return {seen:seen,lazy:lazy,placeholders:placeholders,fixed:fixed};
}
var _base = document.baseURI || location.href;
var _pre = _archivrResolveLazyImgs(_base);
var article = new Readability(document.cloneNode(true)).parse();
if (!article || !article.content || article.content.length < 100) {
_archivrReaderMark('failed:no-article');
return;
}
document.body.innerHTML = article.content;
// Post-Readability pass: stamp data-archivr-src on article images so the Rust
// post-processor can fetch and inline them after SingleFile writes the file.
// SingleFile cannot inline resources introduced at before-capture time; don't
// touch src here — Rust replaces it from the marker. Skip already-inlined images.
document.querySelectorAll('img').forEach(function(img) {
var lazySrc = img.getAttribute('data-src') || img.getAttribute('data-lazy-src') ||
img.getAttribute('data-zoom-src') || img.getAttribute('data-original') ||
img.getAttribute('data-lazy');
if (!lazySrc) return;
var curSrc = img.getAttribute('src') || '';
if (curSrc.startsWith('data:image/') && curSrc.length > 1000) return;
try { lazySrc = new URL(lazySrc, _base).href; } catch(e) {}
img.setAttribute('data-archivr-src', lazySrc);
img.removeAttribute('loading');
// Remove lazy attrs so _archivrResolveLazyImgs (called below) cannot
// rewrite src to a CDN URL that SingleFile cannot fetch from the proxy
// context — Rust owns these images via data-archivr-src instead.
img.removeAttribute('data-src');
img.removeAttribute('data-lazy-src');
img.removeAttribute('data-zoom-src');
img.removeAttribute('data-original');
img.removeAttribute('data-lazy');
});
var _post = _archivrResolveLazyImgs(_base);
if (article.title) document.title = article.title;
var hdr = document.createElement('header');
hdr.innerHTML =
@ -236,6 +288,7 @@ pub fn save(
cookie_ext_enabled: Option<bool>,
reader_mode: bool,
modal_closer_enabled: Option<bool>,
freedium_cleanup: bool,
) -> Result<SaveResult> {
let single_file =
env::var("ARCHIVR_SINGLE_FILE").unwrap_or_else(|_| "single-file".to_string());
@ -254,6 +307,7 @@ pub fn save(
cookie_ext.as_deref(),
reader_mode,
modal_closer,
freedium_cleanup,
)?;
result.ublock_skipped = ublock_skipped;
result.cookie_ext_skipped = cookie_ext_skipped;
@ -378,6 +432,7 @@ fn save_with(
cookie_ext: Option<&Path>,
reader_mode: bool,
modal_closer: bool,
freedium_cleanup: bool,
) -> Result<SaveResult> {
let temp_dir = store_path.join("temp").join(timestamp);
std::fs::create_dir_all(&temp_dir).context("failed to create temp dir")?;
@ -464,6 +519,36 @@ fn save_with(
_archivr_mc_run();"
);
}
if freedium_cleanup {
strip_scripts.push_str(
// Sonner toast overlay (data attribute is stable across Freedium updates).
"document.querySelectorAll('[data-sonner-toaster]').forEach(function(el){\
var s=el.closest('section')||el;s.remove();\
});\
document.querySelectorAll('nav#header').forEach(function(el){el.remove();});\
document.querySelectorAll('nav').forEach(function(el){\
if(el.querySelector('[aria-label=\"Go back\"]'))el.remove();\
});\
document.querySelectorAll('nav').forEach(function(el){\
if(el.querySelector('a[href*=\"freedium-mirror.cfd/\"]'))el.remove();\
});\
document.querySelectorAll('footer').forEach(function(el){\
if(el.querySelector('a[href*=\"freedium-mirror.cfd/\"]')||\
el.textContent.toLowerCase().indexOf('freedium')>-1)el.remove();\
});\
var _pr=document.getElementById('progress');if(_pr)_pr.remove();\
document.querySelectorAll('[data-nosnippet]').forEach(function(el){\
if(!el.firstElementChild&&!el.textContent.trim())el.remove();\
});\
document.querySelectorAll('[data-slot=\"dropdown-menu-trigger\"]').forEach(function(btn){\
if(!/download/i.test(btn.textContent||''))return;\
var sec=btn.closest('section');if(!sec)return;\
var prev=sec.previousElementSibling;\
if(prev&&prev.tagName==='HEADER')prev.remove();\
sec.remove();\
});",
);
}
strip_scripts.push_str("});");
std::fs::write(&strip_scripts_path, &strip_scripts)
.context("failed to write single-file user script")?;
@ -584,6 +669,10 @@ fn save_with(
stderr.trim(),
);
}
// Post-process: fetch and inline images that SingleFile couldn't inline from the
// page resource cache (resources introduced via DOM manipulation at before-capture
// time are not tracked). Browser script stamped data-archivr-src on those images.
inline_archivr_img_srcs(&out_file, url, cookies);
let title = extract_html_title(&out_file);
let html_hash = hash_file(&out_file)?;
@ -653,17 +742,32 @@ fn base_single_file_cmd(
// ── HTML helpers ──────────────────────────────────────────────────────────────
/// Reads the first 8 KiB of `path` and extracts the content of the first
/// Reads up to 256 KiB of `path` and extracts the content of the first
/// `<title>…</title>` element. Returns `None` if absent or empty.
///
/// Uses `to_ascii_lowercase` for case-insensitive tag matching. ASCII-only
/// lowercasing is byte-length-preserving, so byte offsets derived from the
/// lowercased buffer are valid indices into the original buffer.
///
/// Uses `take().read_to_end()` rather than a single `read()` call so the
/// full 256 KiB is always consumed even when the OS short-reads.
fn extract_html_title(path: &Path) -> Option<String> {
let mut f = std::fs::File::open(path).ok()?;
let mut buf = vec![0u8; 8192];
let n = f.read(&mut buf).ok()?;
let buf = &buf[..n];
let mut buf = Vec::new();
f.take(256 * 1024).read_to_end(&mut buf).ok()?;
extract_html_title_from_buf(&buf)
}
/// Extracts the `<title>` content from an HTML string.
///
/// Used in the server capture path where the font-extracted HTML is already
/// in memory — avoids a re-read and operates on the smaller post-extraction
/// content where the title is guaranteed to be within range.
pub fn extract_html_title_str(html: &str) -> Option<String> {
extract_html_title_from_buf(html.as_bytes())
}
fn extract_html_title_from_buf(buf: &[u8]) -> Option<String> {
let lower = String::from_utf8_lossy(buf).to_ascii_lowercase();
let start = lower.find("<title>")? + "<title>".len();
let end = lower[start..].find("</title>")? + start;
@ -737,6 +841,177 @@ fn mime_to_favicon_ext(mime: &str) -> Option<&'static str> {
}
}
/// Decodes the minimal set of HTML character references that browsers encode
/// in attribute values during serialisation. Covers the four characters
/// browsers must escape (`&`, `<`, `>`, `"`) — sufficient for URL query
/// strings and signed CDN parameters embedded in `data-*` attributes.
fn html_attr_decode(s: &str) -> String {
// Decode &amp; LAST so a value like `&amp;lt;` becomes `&lt;` (one layer
// removed) rather than `<` (two layers). If `&amp;` ran first it would
// produce `&lt;` from the remainder, which the subsequent `&lt;` pass
// would then incorrectly collapse to `<`.
s.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&amp;", "&")
}
/// Returns a `Cookie: …` header value when `img_url` is same-domain as
/// `capture_url` and `cookies` is non-empty; `None` otherwise.
/// Uses `domain_from_url` for consistency with the SingleFile cookie-file writer.
fn same_origin_cookie_header(
capture_url: &str,
img_url: &str,
cookies: &HashMap<String, String>,
) -> Option<String> {
if cookies.is_empty() {
return None;
}
let cap = domain_from_url(capture_url);
let img = domain_from_url(img_url);
if cap.is_empty() || img.is_empty() || cap != img {
return None;
}
Some(
cookies
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join("; "),
)
}
/// Scans a reader-mode HTML file for `<img data-archivr-src="…">` elements whose
/// `src` is not already a large inlined image, fetches those URLs with blocking
/// reqwest, and replaces `src` with `data:<mime>;base64,…`. Non-fatal: any failure
/// is logged and the image is left as-is. Guardrails: 10 s timeout, 5 redirects,
/// `Content-Type: image/*` required, 20 MiB body cap (enforced via `Content-Length`
/// and a bounded read). Cookies are forwarded only when the image host matches the
/// captured page's domain; Freedium fetches pass empty cookies so this is a no-op.
fn inline_archivr_img_srcs(path: &Path, capture_url: &str, cookies: &HashMap<String, String>) {
const MAX_BYTES: usize = 20 * 1024 * 1024;
let html = match std::fs::read_to_string(path) {
Ok(h) => h,
Err(_) => return,
};
if !html.contains("data-archivr-src=") {
return;
}
let client = match reqwest::blocking::Client::builder()
.user_agent("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36")
.redirect(reqwest::redirect::Policy::limited(5))
.timeout(std::time::Duration::from_secs(10))
.build()
{
Ok(c) => c,
Err(_) => return,
};
let re_img = Regex::new(r"(?si)<img\b[^>]*>").unwrap();
let re_archivr = Regex::new(r#"(?i)\bdata-archivr-src="([^"]+)""#).unwrap();
let re_src = Regex::new(r#"(?i)\bsrc="([^"]*)""#).unwrap();
let re_rm_archivr = Regex::new(r#"(?i)\s*data-archivr-src="[^"]*""#).unwrap();
let re_set_src = Regex::new(r#"(?i)\bsrc="[^"]*""#).unwrap();
// Collect (start, end, replacement) in document order, then apply in reverse
// so earlier byte positions are still valid when we reach them.
let mut replacements: Vec<(usize, usize, String)> = Vec::new();
for m in re_img.find_iter(&html) {
let tag = m.as_str();
let raw_url = match re_archivr.captures(tag).map(|c| c[1].to_string()) {
Some(u) => u,
None => continue,
};
// HTML-decode entities the browser's serialiser encodes in attribute values
// (e.g. & → &amp; in CDN signed/query URLs).
let img_url = html_attr_decode(&raw_url);
// Already a large inlined image — leave it alone.
let cur_src = re_src.captures(tag).map(|c| c[1].to_string()).unwrap_or_default();
if cur_src.starts_with("data:image/") && cur_src.len() > 1000 {
continue;
}
let cookie_header = same_origin_cookie_header(capture_url, &img_url, cookies);
let data_uri = match fetch_image_as_data_uri(&client, &img_url, MAX_BYTES, cookie_header) {
Ok(u) => u,
Err(e) => {
eprintln!("warn: reader image inline ({img_url}): {e}");
continue;
}
};
let new_tag = if re_src.is_match(tag) {
re_set_src.replace(tag, format!("src=\"{}\"", data_uri)).into_owned()
} else {
tag.replacen("<img", &format!("<img src=\"{}\"", data_uri), 1)
};
let new_tag = re_rm_archivr.replace(&new_tag, "").into_owned();
replacements.push((m.start(), m.end(), new_tag));
}
if replacements.is_empty() {
return;
}
let mut html = html;
for (start, end, new_tag) in replacements.into_iter().rev() {
html.replace_range(start..end, &new_tag);
}
if let Err(e) = std::fs::write(path, html.as_bytes()) {
eprintln!("warn: reader image inline write {}: {e}", path.display());
}
}
/// Fetches `url` and returns a `data:<mime>;base64,…` URI.
/// Requires `Content-Type: image/*`, enforces `max_bytes` cap via `Content-Length`
/// rejection and a bounded streaming read. Attaches `cookie_header` when supplied.
fn fetch_image_as_data_uri(
client: &reqwest::blocking::Client,
url: &str,
max_bytes: usize,
cookie_header: Option<String>,
) -> Result<String> {
let mut req = client.get(url);
if let Some(cookie) = cookie_header {
req = req.header(reqwest::header::COOKIE, cookie);
}
let resp = req.send().context("request failed")?;
if !resp.status().is_success() {
bail!("HTTP {}", resp.status());
}
let ct = resp
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let mime = ct.split(';').next().unwrap_or("").trim().to_string();
if !mime.starts_with("image/") {
bail!("non-image Content-Type: {mime}");
}
// Reject early when the server advertises a body larger than the cap.
if let Some(cl) = resp.content_length() {
if cl as usize > max_bytes {
bail!("Content-Length {} exceeds {} MiB cap", cl, max_bytes / (1024 * 1024));
}
}
// Stream at most max_bytes + 1 bytes so we never buffer an unbounded body.
// Reading one byte past the limit lets us distinguish "exactly at cap" from
// "over cap" without a separate Content-Length check.
let mut buf = Vec::new();
resp.take((max_bytes as u64) + 1)
.read_to_end(&mut buf)
.context("reading body")?;
if buf.len() > max_bytes {
bail!("image too large: > {} MiB", max_bytes / (1024 * 1024));
}
Ok(format!("data:{};base64,{}", mime, B64.encode(&buf)))
}
#[cfg(test)]
mod tests {
use super::*;
@ -774,6 +1049,19 @@ mod tests {
assert_eq!(extract_html_title(f.path()), None);
}
#[test]
fn extract_html_title_after_100kb_preamble() {
// Freedium / SingleFile pages can embed large CSS blocks before <title>.
// Verify take().read_to_end() reads the full 256 KiB window.
let mut f = NamedTempFile::new().unwrap();
let padding = " ".repeat(100 * 1024); // 100 KiB of whitespace
write!(f, "{}<html><head><title>Deep Title</title></head></html>", padding).unwrap();
assert_eq!(
extract_html_title(f.path()),
Some("Deep Title".to_string())
);
}
#[test]
fn save_with_missing_binary_returns_clear_error() {
// Calls save_with directly — no env mutation, safe in parallel test runs.
@ -789,6 +1077,7 @@ mod tests {
None, // no cookie ext
false, // reader mode off
false, // modal closer off
false, // freedium cleanup off
);
let err = result.unwrap_err();
let msg = format!("{err:#}");
@ -830,9 +1119,125 @@ mod tests {
enabled.eq_ignore_ascii_case("false") || enabled == "0";
assert!(is_disabled);
let enabled = "0";
let is_disabled =
enabled.eq_ignore_ascii_case("false") || enabled == "0";
assert!(is_disabled);
}
// ── html_attr_decode ─────────────────────────────────────────────────────
#[test]
fn html_attr_decode_plain_url_unchanged() {
assert_eq!(
html_attr_decode("https://cdn.example.com/img.jpg"),
"https://cdn.example.com/img.jpg",
);
}
#[test]
fn html_attr_decode_ampersand_in_query() {
// CDN signed URL with & encoded as &amp; by the browser's HTML serialiser.
assert_eq!(
html_attr_decode("https://cdn.example.com/img.jpg?a=1&amp;b=2&amp;sig=abc"),
"https://cdn.example.com/img.jpg?a=1&b=2&sig=abc",
);
}
#[test]
fn html_attr_decode_single_layer_only() {
// &amp;lt; → &lt; (one browser-serialisation layer removed, not two).
// If &amp; ran first it would produce &lt; then < — wrong.
assert_eq!(html_attr_decode("&amp;lt;"), "&lt;");
}
#[test]
fn html_attr_decode_double_encoded_amp() {
// &amp;amp; → &amp; (the attribute value is a literal &amp;).
assert_eq!(html_attr_decode("&amp;amp;"), "&amp;");
}
#[test]
fn html_attr_decode_lt_gt_quot_direct() {
// Directly encoded entities (no surrounding &amp;) decode normally.
assert_eq!(html_attr_decode("&lt;tag&gt;"), "<tag>");
assert_eq!(html_attr_decode("say &quot;hi&quot;"), "say \"hi\"");
}
// ── same_origin_cookie_header ─────────────────────────────────────────────
#[test]
fn same_origin_cookie_header_same_host_attaches_cookies() {
let mut cookies = HashMap::new();
cookies.insert("session".to_string(), "abc".to_string());
cookies.insert("tok".to_string(), "xyz".to_string());
let h = same_origin_cookie_header(
"https://example.com/article",
"https://example.com/img/photo.jpg",
&cookies,
).expect("expected Some for same host");
assert!(h.contains("session=abc"), "missing session: {h}");
assert!(h.contains("tok=xyz"), "missing tok: {h}");
}
#[test]
fn same_origin_cookie_header_third_party_returns_none() {
let mut cookies = HashMap::new();
cookies.insert("session".to_string(), "abc".to_string());
assert!(same_origin_cookie_header(
"https://example.com/article",
"https://cdn.third-party.com/img.jpg",
&cookies,
).is_none(), "should not forward cookies to third-party host");
}
#[test]
fn same_origin_cookie_header_empty_cookies_returns_none() {
assert!(same_origin_cookie_header(
"https://example.com/article",
"https://example.com/img.jpg",
&HashMap::new(),
).is_none());
}
#[test]
fn same_origin_cookie_header_freedium_empty_cookies_noop() {
// capture.rs passes empty cookies for Freedium fetches — confirm no-op.
assert!(same_origin_cookie_header(
"https://freedium-mirror.cfd/https://example.com/",
"https://freedium-mirror.cfd/img/example.com/photo.jpg",
&HashMap::new(),
).is_none());
}
// ── bounded read (size cap) ───────────────────────────────────────────────
#[test]
fn bounded_read_stops_at_limit() {
// Exercises the same Read::take logic used in fetch_image_as_data_uri.
let max: usize = 10;
let data: Vec<u8> = (0u8..100).collect();
let mut buf = Vec::new();
std::io::Cursor::new(&data)
.take((max as u64) + 1)
.read_to_end(&mut buf)
.unwrap();
assert!(buf.len() > max, "take should read up to max+1");
assert!(buf.len() <= max + 1, "take should not exceed max+1");
}
#[test]
fn bounded_read_allows_at_limit() {
let max: usize = 10;
let data: Vec<u8> = vec![0u8; max];
let mut buf = Vec::new();
std::io::Cursor::new(&data)
.take((max as u64) + 1)
.read_to_end(&mut buf)
.unwrap();
// Exactly at cap: buf.len() == max, guard does NOT fire.
assert_eq!(buf.len(), max);
assert!(buf.len() <= max);
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -4,8 +4,8 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Archivr</title>
<script type="module" crossorigin src="/assets/index-Db35_tmT.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C0COQCCD.css">
<script type="module" crossorigin src="/assets/index-YmIQCrug.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DLdY9nrw.css">
</head>
<body>
<div id="root"></div>

View file

@ -111,6 +111,26 @@ export async function deleteTag(archiveId, tagUid) {
if (!res.ok) throw new Error(await res.text());
}
export async function createTag(archiveId, path) {
const res = await fetch(`/api/archives/${archiveId}/tags`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path }),
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function moveTag(archiveId, tagUid, parentUid) {
const res = await fetch(`/api/archives/${archiveId}/tags/${tagUid}/move`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ parent_uid: parentUid ?? null }),
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function fetchRuns(archiveId) {
return getJson(`/api/archives/${archiveId}/runs`);
}
@ -122,12 +142,13 @@ export async function fetchTags(archiveId) {
export async function submitCapture(archiveId, locator, quality = null, extensions = null) {
const payload = { locator }
if (quality && quality !== 'best') payload.quality = quality
// extensions: { ublock_enabled?: bool, reader_mode?: bool, cookie_ext_enabled?: bool, modal_closer_enabled?: bool }
// extensions: { ublock_enabled?: bool, reader_mode?: bool, cookie_ext_enabled?: bool, modal_closer_enabled?: bool, via_freedium?: bool }
if (extensions) {
if (typeof extensions.ublock_enabled === 'boolean') payload.ublock_enabled = extensions.ublock_enabled
if (typeof extensions.reader_mode === 'boolean') payload.reader_mode = extensions.reader_mode
if (typeof extensions.cookie_ext_enabled === 'boolean') payload.cookie_ext_enabled = extensions.cookie_ext_enabled
if (typeof extensions.modal_closer_enabled === 'boolean') payload.modal_closer_enabled = extensions.modal_closer_enabled
if (typeof extensions.via_freedium === 'boolean') payload.via_freedium = extensions.via_freedium
}
const res = await fetch(`/api/archives/${archiveId}/captures`, {
method: "POST",

View file

@ -123,6 +123,7 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
// Cookie consent: session-level only, initialized from server default
const [cookieExtEnabled, setCookieExtEnabled] = useState(true)
const [modalCloserEnabled, setModalCloserEnabled] = useState(true)
const [freediumEnabled, setFreediumEnabled] = useState(true)
// Load global settings from server once on mount
useEffect(() => {
@ -301,7 +302,7 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
const qual = item.quality || 'best'
setItems(prev => prev.map(it => it.id === item.id ? { ...it, status: 'submitting', error: null } : it))
try {
const extensions = { ublock_enabled: ublockEnabled, reader_mode: readerMode, cookie_ext_enabled: cookieExtEnabled, modal_closer_enabled: modalCloserEnabled }
const extensions = { ublock_enabled: ublockEnabled, reader_mode: readerMode, cookie_ext_enabled: cookieExtEnabled, modal_closer_enabled: modalCloserEnabled, via_freedium: freediumEnabled }
const job = await submitCapture(aid, loc, qual, extensions)
setItems(prev => prev.map(it =>
it.id === item.id ? { ...it, status: 'running', jobUid: job.job_uid, archiveId: aid } : it
@ -517,6 +518,22 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
<span className="ext-toggle-knob" />
</button>
</label>
<label className="capture-ext-row" style={{ marginTop: 8 }}>
<span className="capture-ext-label">
<span className="capture-ext-name">Freedium mirror</span>
<span className="capture-ext-desc">Route paywalled articles through a Freedium mirror (Medium, NYT, WaPo, etc.)</span>
</span>
<button
type="button"
role="switch"
aria-checked={freediumEnabled}
className={`ext-toggle ext-toggle--sm${freediumEnabled ? ' ext-toggle--on' : ''}`}
onClick={() => setFreediumEnabled(v => !v)}
aria-label="Toggle Freedium mirror for this capture"
>
<span className="ext-toggle-knob" />
</button>
</label>
</div>
)}
</div>

View file

@ -1,14 +1,156 @@
import { useState, useRef } from 'react';
import { renameTag, deleteTag } from '../api';
import { useState, useRef, useEffect } from 'react';
import { renameTag, deleteTag, createTag, moveTag } from '../api';
function TagNode({ node, archiveId, tagFilter, onTagFilterSet, onViewChange, onTagRenamed, onTagDeleted, onTagsRefresh, humanizeTags }) {
// TagPickerNode
// A node inside the destination picker modal.
function TagPickerNode({ node, onPick }) {
return (
<li>
<button
className="tag-picker-node-btn"
title={node.tag.full_path}
onClick={() => onPick(node.tag)}
>
{node.tag.slug}
</button>
{node.children?.length > 0 && (
<div className="tag-children">
<ul className="tag-tree-list">
{node.children.map(child => (
<TagPickerNode key={child.tag.tag_uid} node={child} onPick={onPick} />
))}
</ul>
</div>
)}
</li>
);
}
// TagPickerModal
// Shared modal for "create under" and "move under" destination selection.
// `excludeUid` hides that tag and all its descendants (used for move).
// `onPick(tag | null)` null means "make root / no parent".
function TagPickerModal({ title, tagNodes, excludeUid, onPick, onCancel }) {
useEffect(() => {
function onKey(e) { if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); onCancel(); } }
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [onCancel]);
function filterTree(nodes) {
return nodes
.filter(n => n.tag.tag_uid !== excludeUid)
.map(n => ({ ...n, children: filterTree(n.children) }));
}
const visibleNodes = excludeUid ? filterTree(tagNodes) : tagNodes;
return (
<div
className="tag-picker-backdrop"
onClick={e => { if (e.target === e.currentTarget) onCancel(); }}
>
<div className="tag-picker-modal" role="dialog" aria-modal="true">
<div className="tag-picker-header">
<span className="tag-picker-title">{title}</span>
<button
className="tag-picker-close"
onClick={onCancel}
title="Cancel"
aria-label="Cancel"
>×</button>
</div>
<div className="tag-picker-body">
<button
className="tag-picker-root-btn"
onClick={() => onPick(null)}
title="Place at root level (no parent)"
>
Root tag (no parent)
</button>
{visibleNodes.length > 0 ? (
<ul className="tag-tree-list tag-picker-tree">
{visibleNodes.map(node => (
<TagPickerNode key={node.tag.tag_uid} node={node} onPick={onPick} />
))}
</ul>
) : (
<p className="tag-picker-empty">No other tags available.</p>
)}
</div>
</div>
</div>
);
}
// CreateInput
// Inline text input that appears in the tag tree when creating a new tag.
// Matches the rename-input UX: Enter saves, Escape cancels, blur saves.
function CreateInput({ parentPath, archiveId, onDone, onCancel }) {
const [draft, setDraft] = useState('');
const cancelRef = useRef(false);
async function submit() {
const name = draft.trim();
if (!name) { onCancel(); return; }
const path = parentPath ? `${parentPath}/${name}` : `/${name}`;
try {
await createTag(archiveId, path);
onDone();
} catch (err) {
alert(err.message || 'Create failed');
onCancel();
}
}
return (
<input
className="tag-rename-input"
autoFocus
placeholder="tag name"
value={draft}
onChange={e => setDraft(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') e.currentTarget.blur();
if (e.key === 'Escape') { cancelRef.current = true; e.currentTarget.blur(); }
}}
onBlur={() => {
if (cancelRef.current) { cancelRef.current = false; onCancel(); }
else { submit(); }
}}
/>
);
}
// TagNode
function TagNode({
node,
archiveId,
tagFilter,
onTagFilterSet,
onViewChange,
onTagRenamed,
onTagDeleted,
onTagsRefresh,
humanizeTags,
// Move source-selection mode: clicking a tag selects it as the thing to move.
moveSelectMode,
onMoveSourceSelect,
// Pending create: the tag_uid of the parent that should render an inline input,
// or '__root__' for the root list (handled in TagsView, not here).
pendingCreateParentUid,
onCreateDone,
onCreateCancel,
}) {
const isActive = tagFilter === node.tag.full_path;
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState('');
const cancelRef = useRef(false);
function handleFilterClick() {
function handleClick() {
if (editing) return;
if (moveSelectMode) {
onMoveSourceSelect(node);
return;
}
const next = isActive ? null : node.tag.full_path;
onTagFilterSet(next);
onViewChange('archive');
@ -16,16 +158,14 @@ function TagNode({ node, archiveId, tagFilter, onTagFilterSet, onViewChange, onT
function startEdit(e) {
e.stopPropagation();
if (moveSelectMode) return;
setDraft(node.tag.slug);
setEditing(true);
}
async function handleRenameSave() {
const value = draft.trim();
if (!value || value === node.tag.slug) {
setEditing(false);
return;
}
if (!value || value === node.tag.slug) { setEditing(false); return; }
try {
const updated = await renameTag(archiveId, node.tag.tag_uid, value);
onTagRenamed(node.tag.full_path, updated.full_path);
@ -52,11 +192,18 @@ function TagNode({ node, archiveId, tagFilter, onTagFilterSet, onViewChange, onT
}
}
const childProps = { archiveId, tagFilter, onTagFilterSet, onViewChange, onTagRenamed, onTagDeleted, onTagsRefresh, humanizeTags };
const childProps = {
archiveId, tagFilter, onTagFilterSet, onViewChange, onTagRenamed, onTagDeleted,
onTagsRefresh, humanizeTags, moveSelectMode, onMoveSourceSelect,
pendingCreateParentUid, onCreateDone, onCreateCancel,
};
const showCreateInput = pendingCreateParentUid === node.tag.tag_uid;
const hasChildren = node.children?.length > 0;
return (
<li>
<div className="tag-node-row">
<div className={`tag-node-row${moveSelectMode ? ' tag-node-row--move-select' : ''}`}>
{editing ? (
<input
className="tag-rename-input"
@ -68,50 +215,66 @@ function TagNode({ node, archiveId, tagFilter, onTagFilterSet, onViewChange, onT
if (e.key === 'Escape') { cancelRef.current = true; e.currentTarget.blur(); }
}}
onBlur={() => {
if (cancelRef.current) {
cancelRef.current = false;
setEditing(false);
} else {
handleRenameSave();
}
if (cancelRef.current) { cancelRef.current = false; setEditing(false); }
else { handleRenameSave(); }
}}
/>
) : (
<button
className={`tag-node-btn${isActive ? ' is-active' : ''}`}
title={node.tag.full_path}
onClick={handleFilterClick}
onDoubleClick={startEdit}
className={`tag-node-btn${isActive ? ' is-active' : ''}${moveSelectMode ? ' tag-node-btn--move-select' : ''}`}
title={moveSelectMode ? `Select "${node.tag.full_path}" to move` : node.tag.full_path}
onClick={handleClick}
onDoubleClick={moveSelectMode ? undefined : startEdit}
>
{humanizeTags ? node.tag.name : node.tag.slug}
<svg
className="edit-icon"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
onClick={e => { e.stopPropagation(); startEdit(e); }}
>
<path d="M11.5 2.5a1.5 1.5 0 0 1 2 2L5 13l-3 1 1-3 8.5-8.5z"/>
</svg>
<span className="tag-node-label">{humanizeTags ? node.tag.name : node.tag.slug}</span>
<span className="tag-node-count">
{node.children.length === 0
? `(${node.entry_count})`
: `(${node.entry_count}) (${node.subtree_count} Total)`}
</span>
{!moveSelectMode && (
<svg
className="edit-icon"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
title="Rename tag"
onClick={e => { e.stopPropagation(); startEdit(e); }}
>
<path d="M11.5 2.5a1.5 1.5 0 0 1 2 2L5 13l-3 1 1-3 8.5-8.5z"/>
</svg>
)}
</button>
)}
<button
className="remove tag-node-delete"
title={`Delete tag ${node.tag.full_path}`}
onClick={handleDelete}
aria-label={`Delete tag ${node.tag.full_path}`}
>×</button>
{!editing && !moveSelectMode && (
<button
className="remove tag-node-delete"
title={`Delete "${node.tag.full_path}"`}
onClick={handleDelete}
aria-label={`Delete "${node.tag.full_path}"`}
>×</button>
)}
</div>
{node.children?.length > 0 && (
{(hasChildren || showCreateInput) && (
<div className="tag-children">
<ul className="tag-tree-list">
{node.children.map(child => (
<TagNode key={child.tag.tag_uid} node={child} {...childProps} />
))}
{showCreateInput && (
<li>
<CreateInput
parentPath={node.tag.full_path}
archiveId={archiveId}
onDone={onCreateDone}
onCancel={onCreateCancel}
/>
</li>
)}
</ul>
</div>
)}
@ -119,35 +282,162 @@ function TagNode({ node, archiveId, tagFilter, onTagFilterSet, onViewChange, onT
);
}
export default function TagsView({ archiveId, tagNodes, tagFilter, onTagFilterSet, onViewChange, onTagRenamed, onTagDeleted, onTagsRefresh, humanizeTags }) {
// TagsView
export default function TagsView({
archiveId, tagNodes, tagFilter, onTagFilterSet, onViewChange,
onTagRenamed, onTagDeleted, onTagsRefresh, humanizeTags,
}) {
// Move state
// moveStep: null | 'select-source' | 'select-dest'
const [moveStep, setMoveStep] = useState(null);
const [moveSourceNode, setMoveSourceNode] = useState(null);
function startMoveMode() { setMoveStep('select-source'); setMoveSourceNode(null); }
function cancelMove() { setMoveStep(null); setMoveSourceNode(null); }
// Esc while in source-selection mode cancels the move.
useEffect(() => {
if (moveStep !== 'select-source') return;
function onKey(e) { if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); cancelMove(); } }
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [moveStep]);
function handleMoveSourceSelect(node) {
setMoveSourceNode(node);
setMoveStep('select-dest');
}
async function handleMoveDest(destTag) {
// destTag: Tag object | null (null = make root)
const sourceOldPath = moveSourceNode.tag.full_path;
const sourceUid = moveSourceNode.tag.tag_uid;
const destUid = destTag?.tag_uid ?? null;
cancelMove();
try {
const updated = await moveTag(archiveId, sourceUid, destUid);
// Keep the active tag filter consistent when the moved tag/subtree was active.
onTagRenamed(sourceOldPath, updated.full_path);
onTagsRefresh();
} catch (err) {
alert(err.message || 'Move failed');
}
}
// Create state
// createStep: null | 'select-parent' | 'input'
const [createStep, setCreateStep] = useState(null);
const [createParentTag, setCreateParentTag] = useState(undefined);
function startCreateMode() { setCreateStep('select-parent'); setCreateParentTag(undefined); }
function cancelCreate() { setCreateStep(null); setCreateParentTag(undefined); }
function handleCreateParentPick(tag) {
// tag: Tag | null (null = root)
setCreateParentTag(tag ?? null);
setCreateStep('input');
}
function handleCreateDone() {
setCreateStep(null);
setCreateParentTag(undefined);
onTagsRefresh();
}
// Derived
const moveSelectMode = moveStep === 'select-source';
// The tag_uid of the parent that should render a create input inside its
// children list. '__root__' means the root-level list handles it.
const pendingCreateParentUid = createStep === 'input'
? (createParentTag ? createParentTag.tag_uid : '__root__')
: undefined;
const nodeProps = {
archiveId, tagFilter, onTagFilterSet, onViewChange, onTagRenamed, onTagDeleted,
onTagsRefresh, humanizeTags, moveSelectMode, onMoveSourceSelect: handleMoveSourceSelect,
pendingCreateParentUid, onCreateDone: handleCreateDone, onCreateCancel: cancelCreate,
};
const showRootCreateInput = pendingCreateParentUid === '__root__';
return (
<section id="tags-view" className="view is-active">
<div className="tag-tree">
<div className="tag-tree-header">
<span className="tag-tree-title">Tags</span>
{tagFilter && (
<span className="tag-tree-active">Filtering: {tagFilter}</span>
{moveSelectMode ? (
<>
<span className="tag-tree-title tag-tree-title--move">Select a tag to move</span>
<button
className="tag-tree-action-btn tag-tree-action-btn--cancel"
onClick={cancelMove}
>Cancel</button>
</>
) : (
<>
<span className="tag-tree-title">Tags</span>
{tagFilter && (
<span className="tag-tree-active" title={tagFilter}>Filtering: {tagFilter}</span>
)}
<div className="tag-tree-actions">
<button
className="tag-tree-action-btn"
onClick={startCreateMode}
title="Create a new tag"
disabled={!!createStep || !!moveStep}
>+ New</button>
<button
className="tag-tree-action-btn"
onClick={startMoveMode}
title="Move a tag to a different parent"
disabled={!!createStep || !!moveStep || tagNodes.length === 0}
>Move</button>
</div>
</>
)}
</div>
{tagNodes.length === 0 ? (
{tagNodes.length === 0 && !showRootCreateInput ? (
<p className="muted" style={{ padding: '8px 0' }}>No tags yet.</p>
) : (
<ul className="tag-tree-list">
{tagNodes.map(node => (
<TagNode key={node.tag.tag_uid} node={node}
archiveId={archiveId}
tagFilter={tagFilter}
onTagFilterSet={onTagFilterSet}
onViewChange={onViewChange}
onTagRenamed={onTagRenamed}
onTagDeleted={onTagDeleted}
onTagsRefresh={onTagsRefresh}
humanizeTags={humanizeTags}
/>
<TagNode key={node.tag.tag_uid} node={node} {...nodeProps} />
))}
{showRootCreateInput && (
<li>
<CreateInput
parentPath={null}
archiveId={archiveId}
onDone={handleCreateDone}
onCancel={cancelCreate}
/>
</li>
)}
</ul>
)}
</div>
{/* Create: pick parent modal */}
{createStep === 'select-parent' && (
<TagPickerModal
title="Create tag under…"
tagNodes={tagNodes}
excludeUid={null}
onPick={handleCreateParentPick}
onCancel={cancelCreate}
/>
)}
{/* Move: pick destination modal (shown after source is selected) */}
{moveStep === 'select-dest' && moveSourceNode && (
<TagPickerModal
title={`Move "${moveSourceNode.tag.slug}" under…`}
tagNodes={tagNodes}
excludeUid={moveSourceNode.tag.tag_uid}
onPick={handleMoveDest}
onCancel={cancelMove}
/>
)}
</section>
);
}

View file

@ -0,0 +1,167 @@
import { useState, useEffect } from 'react';
import TagsView from './TagsView';
// Installs a per-story fetch stub that intercepts /api/ tag mutations and
// returns realistic Tag shapes so renameTag/moveTag/createTag don't throw.
// Installed in useEffect so it never leaks into other stories and won't
// double-wrap on re-renders.
function ApiStub({ children }) {
useEffect(() => {
const realFetch = window.fetch.bind(window);
window.fetch = (url, opts) => {
if (typeof url === 'string' && url.startsWith('/api/')) {
const method = (opts?.method ?? 'GET').toUpperCase();
if (method === 'DELETE') {
return Promise.resolve(new Response(null, { status: 204 }));
}
// Parse request body to construct a realistic Tag shape.
// renameTag sends { name }, moveTag/createTag send path info.
// full_path must be present or callers throw on updated.full_path.
let body = {};
try { body = JSON.parse(opts?.body ?? '{}'); } catch { /* ignore */ }
const slug = (body.name ?? body.path ?? 'stub')
.trim().replace(/\s+/g, '-').replace(/[^a-zA-Z0-9-]/g, '').replace(/^-+|-+$/g, '') || 'stub';
const stubTag = {
tag_uid: 'stub-uid',
name: slug.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()),
slug,
full_path: `/${slug}`,
};
return Promise.resolve(
new Response(JSON.stringify(stubTag), {
status: method === 'POST' ? 201 : 200,
headers: { 'Content-Type': 'application/json' },
})
);
}
return realFetch(url, opts);
};
return () => { window.fetch = realFetch; };
}, []);
return children;
}
function withApiStub(Story) {
return <ApiStub><Story /></ApiStub>;
}
export default {
component: TagsView,
tags: ['autodocs'],
parameters: { layout: 'padded' },
decorators: [withApiStub],
};
// Shared fixtures
const noop = () => {};
function tag(tag_uid, name, slug, full_path, entry_count = 0, children = [], subtree_count = null) {
return { tag: { tag_uid, name, slug, full_path }, entry_count, subtree_count: subtree_count ?? entry_count, children };
}
const sampleTree = [
tag('t1', 'Science', 'science', '/science', 12, [
tag('t2', 'Computer Science', 'computer-science', '/science/computer-science', 7, [
tag('t3', 'Algorithms', 'algorithms', '/science/computer-science/algorithms', 3),
tag('t4', 'Compilers', 'compilers', '/science/computer-science/compilers', 1),
], 11),
tag('t5', 'Physics', 'physics', '/science/physics', 4),
], 23),
tag('t6', 'History', 'history', '/history', 5, [
tag('t7', 'Ancient', 'ancient', '/history/ancient', 2),
], 7),
tag('t8', 'Reading List', 'reading-list', '/reading-list', 0),
];
// Wrapper that wires local state so onTagsRefresh/onTagRenamed callbacks
// keep the tree consistent within a story session.
function TagsViewSandbox({ initialNodes = sampleTree, tagFilter = null, humanizeTags = false }) {
const [nodes, setNodes] = useState(initialNodes);
const [filter, setFilter] = useState(tagFilter);
function handleTagRenamed(oldPath, newPath) {
if (filter === oldPath) setFilter(newPath);
else if (filter?.startsWith(oldPath + '/')) setFilter(newPath + filter.slice(oldPath.length));
}
function handleTagDeleted(deletedPath) {
if (filter === deletedPath || filter?.startsWith(deletedPath + '/')) setFilter(null);
}
// onTagsRefresh is a no-op here: in a real app it re-fetches; the stub
// tag returned from fetch won't match our fixture tree, so the tree stays
// as-is after mutations. That is acceptable for visual QA purposes.
return (
<div style={{ maxWidth: 340, fontFamily: 'Helvetica Neue, sans-serif' }}>
<TagsView
archiveId="demo"
tagNodes={nodes}
tagFilter={filter}
onTagFilterSet={setFilter}
onViewChange={noop}
onTagRenamed={handleTagRenamed}
onTagDeleted={handleTagDeleted}
onTagsRefresh={noop}
humanizeTags={humanizeTags}
/>
</div>
);
}
// Stories
/** Default view with a nested tag tree. All interactions are exercisable:
* click + New / Move to open the picker modal; click a tag to filter;
* double-click or pencil to rename; × to delete.
* API mutations are stubbed the tree won't re-fetch after saves,
* but no network errors will occur. */
export const Default = {
render: () => <TagsViewSandbox />,
};
/** Empty archive — "No tags yet." shown; + New still works. */
export const Empty = {
render: () => <TagsViewSandbox initialNodes={[]} />,
};
/** Humanize mode: slugs displayed as Title Case names. */
export const HumanizedNames = {
render: () => <TagsViewSandbox humanizeTags />,
};
/** Active tag filter — header shows the current filter path. */
export const WithActiveFilter = {
render: () => (
<TagsViewSandbox tagFilter="/science/computer-science" />
),
};
/** Flat list with no nesting. */
export const FlatList = {
render: () => (
<TagsViewSandbox
initialNodes={[
tag('a1', 'Books', 'books', '/books', 8),
tag('a2', 'Films', 'films', '/films', 3),
tag('a3', 'Music', 'music', '/music', 0),
tag('a4', 'Podcasts', 'podcasts', '/podcasts', 14),
]}
/>
),
};
/** Tags with large entry counts — verifies count badge layout. */
export const HighCounts = {
render: () => (
<TagsViewSandbox
initialNodes={[
tag('h1', 'All', 'all', '/all', 9999, [
tag('h2', 'Starred', 'starred', '/all/starred', 432),
tag('h3', 'Archive', 'archive', '/all/archive', 1204),
]),
]}
/>
),
};

View file

@ -1279,6 +1279,135 @@ select {
outline: none;
}
/* entry count badge next to tag name */
.tag-node-label { flex-shrink: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tag-node-count {
font-size: 11px;
color: var(--muted-2);
font-weight: 400;
flex-shrink: 0;
margin-left: 3px;
}
/* header action buttons (+ New, Move) */
.tag-tree-header { align-items: center; }
.tag-tree-actions { margin-left: auto; display: flex; gap: 4px; }
.tag-tree-action-btn {
font-size: 11px;
padding: 2px 7px;
border: 1px solid var(--line);
border-radius: var(--r);
background: transparent;
color: var(--muted);
cursor: pointer;
line-height: 1.5;
white-space: nowrap;
transition: background 0.1s, color 0.1s;
}
.tag-tree-action-btn:hover:not(:disabled) { background: var(--paper-2); color: var(--ink); }
.tag-tree-action-btn:disabled { opacity: 0.4; cursor: default; }
.tag-tree-action-btn--cancel { border-color: var(--accent); color: var(--accent); }
.tag-tree-action-btn--cancel:hover { background: color-mix(in srgb, var(--accent) 8%, transparent); }
.tag-tree-title--move { font-style: italic; color: var(--accent-2); }
/* move source-select mode: nodes act as clickable targets */
.tag-node-row--move-select .tag-node-btn { cursor: crosshair; color: var(--ink); }
.tag-node-row--move-select .tag-node-btn:hover {
background: color-mix(in srgb, var(--link) 10%, transparent);
text-decoration: none;
border-radius: var(--r);
}
/* ── Tag picker modal (create/move destination) ──────────────────────────── */
.tag-picker-backdrop {
position: fixed;
inset: 0;
z-index: 200;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.tag-picker-modal {
background: var(--paper);
border-radius: var(--r3);
box-shadow: 0 16px 56px rgba(0, 0, 0, 0.24);
width: 300px;
max-width: 100%;
display: flex;
flex-direction: column;
max-height: 60vh;
}
.tag-picker-header {
display: flex;
align-items: center;
padding: 12px 14px;
border-bottom: 1px solid var(--line);
flex-shrink: 0;
gap: 8px;
}
.tag-picker-title {
flex: 1;
font-size: 13px;
font-weight: 600;
color: var(--ink);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tag-picker-close {
width: 26px;
height: 26px;
border-radius: 50%;
border: none;
background: transparent;
color: var(--muted-2);
font-size: 18px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.tag-picker-close:hover { background: var(--paper-2); color: var(--ink); }
.tag-picker-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 8px 12px 12px;
}
.tag-picker-root-btn {
display: block;
width: 100%;
text-align: left;
padding: 5px 8px;
margin-bottom: 6px;
border: 1px dashed var(--line);
border-radius: var(--r);
background: transparent;
color: var(--muted);
font-size: 12px;
cursor: pointer;
}
.tag-picker-root-btn:hover { background: var(--paper-2); color: var(--ink); border-color: var(--muted-2); }
.tag-picker-tree { border-top: 1px solid var(--line-soft); padding-top: 6px; margin-top: 2px; }
.tag-picker-node-btn {
border: 0;
background: transparent;
color: var(--link);
cursor: pointer;
padding: 3px 6px;
text-align: left;
font-size: 13px;
display: block;
width: 100%;
border-radius: var(--r);
}
.tag-picker-node-btn:hover { background: var(--paper-2); text-decoration: none; }
.tag-picker-empty { font-size: 13px; color: var(--muted-2); margin: 6px 0 0; }
/* ── Collections page (sidebar layout) ──────────────────────────────────── */
.collections-view {
padding: 24px;