1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-22 03:05:32 +02:00
Commit graph

46 commits

Author SHA1 Message Date
339076e6a2
feat: add orphan blob cleanup to Settings > Storage tab (#18)
- database.rs: add has_active_capture_jobs(), list_orphaned_blob_rows(),
  all_referenced_file_relpaths(), delete_orphaned_blob_rows()
- routes.rs: GET/DELETE /api/archives/:id/blob-cleanup (ROLE_ADMIN)
  - GET returns {orphaned_blob_rows, deletable_files, total_bytes}
  - DELETE has two active-capture guards (before and after disk walk)
    to prevent deleting files mid-capture; walks raw/ and raw_tweets/
  - Referenced set = entry_artifacts.relpath ∪ live blobs' raw_relpath,
    so a file is never deleted if any artifact still points at its path
- api.js: scanOrphanBlobs(), deleteOrphanBlobs()
- App.jsx: pass archiveId to SettingsView; add 'storage' to SETTINGS_TABS
  so /settings/storage survives refresh/back navigation
- SettingsView.jsx: new Storage tab (admin-only) with idle→scanning→
  scanned→deleting→done/error state machine; shows file/record counts
  and human-readable byte sizes before a btn-danger confirm

Tests (14 new, 248 total passing):
- database.rs: has_active_capture_jobs for pending/running/completed,
  list_orphaned_blob_rows, all_referenced_file_relpaths edge cases,
  delete_orphaned_blob_rows preserves referenced rows
- routes.rs: auth (401), active-capture 409 on GET and DELETE,
  end-to-end delete preserving referenced file and removing orphan
  blob file + extra disk-only file
2026-07-06 14:01:38 +02:00
b8e496457f
feat: add video quality selection for yt-dlp captures (#17)
- ytdlp::download() accepts quality: Option<&str>; quality_format()
  maps best/1080p/720p/480p/360p to yt-dlp -f format strings
- perform_capture() threads quality through to the downloader
- CaptureBody gains optional quality field; capture_handler validates
  it against the allowlist (400 on unknown values) before spawning
- CLI passes None (preserves existing best-quality behaviour)
- Frontend: isVideoSource() mirrors determine_source() exactly —
  shows quality picker only for yt-dlp-backed sources, excludes
  playlist/channel shorthands and tweet/thread paths
- submitCapture(archiveId, locator, quality) sends quality in POST body
- CSS: .capture-quality styles the inline select to fit the capture row
- Tests: quality_format unit tests in ytdlp.rs; two new route tests
  (valid quality accepted, invalid quality rejected with 400)
- Docs: video quality section added under Supported Platforms
2026-07-05 20:42:41 +02:00
84c5730b6a
singlefile: force --window-size=1920,1080 for viewport-dependent CSS preservation
Add --window-size=1920,1080 to the Chromium flags passed via --browser-args.
This makes the existing --remove-unused-styles=false and
--remove-alternative-medias=false effective for real @media rules
and responsive styles (headless default is small).

Also document in ARCHIVR_CHROME_ARGS that users can override by
supplying their own --window-size in the env var.
2026-07-05 15:40:58 +02:00
fb1115a409
feat: non-blocking batch capture dialog, toasts on failure, fix /runs errors
CaptureDialog:
- Replace single textarea with multi-row inputs; + button adds rows
- Submit fires all pending rows in parallel, dialog stays open/usable
- Polling intervals live on a persistent ref (not cleared on close) so
  toasts fire even after the dialog is dismissed
- archiveId stored per item at submit time; page-refresh reconnect uses
  it.archiveId instead of the possibly-null prop
- Completed rows flash green then self-remove; failed rows show inline
  error + retry button
- Cancel becomes Close while jobs are in flight

ToastStack (new component):
- Fixed bottom-right overlay with spring-in animation
- Error toast: truncated locator, View error / Hide toggle expanding
  full error_text in a monospace pre block
- Auto-dismisses after 7 s; timer pauses while detail is expanded

RunsView:
- Failed rows are clickable and expand a full-width detail row showing
  error_summary in a scrollable monospace block

capture.rs (archivr-core):
- Staging dir is now "{millis}-{uuid}" — parallel captures in the same
  millisecond can no longer collide on temp paths
- create_archive_run moved before URL Content-Type probe so every
  attempt appears in /runs regardless of outcome
- Probe failures now call create_archive_run_item with source_metadata
  fallback then fail_run, recording error_text on the item and
  error_summary on the run with correct failed_count

styles.css:
- Capture dialog: header row, multi-row layout, status dots, spinner,
  add-row dashed button, per-row error text
- Toast stack: fixed overlay, error card with coloured left border,
  monospace detail expansion
- Run error rows: clickable hover tint, expand hint chevron, detail pre
2026-07-05 13:56:00 +02:00
ed1f883ff1
feat: implement entry deletion
- database: cascade_cached_bytes_after_subtree_delete — one-pass set-aware
  SQL that excludes the entire subtree simultaneously, avoiding sibling-blob
  cross-counting bug
- database: delete_entry — collects subtree IDs, runs set-aware cascade,
  NULLs archive_run_items.produced_entry_id (FK blocker), deletes children
  then root; ON DELETE CASCADE handles artifacts/tags/collections
- server: DELETE /api/archives/:archive_id/entries/:entry_uid route,
  wrapped in a transaction for atomicity
- frontend: deleteEntry API call, handleEntryDeleted callback in App.jsx
  (optimistic list removal + selection clear), Delete entry button in
  ContextRail with confirm guard
- tests: 4 database tests (unknown uid, subtree removal, run_item nulling,
  cached_bytes recalculation) + 3 route tests (204+gone, 404, 401)
2026-07-04 13:46:19 +02:00
085742e934
fix: restore yt-dlp metadata title fetch in perform_capture
8082895 removed the ytdlp_metadata_json fetch, local_filename_title
derivation, and entry_title computation, replacing the title arg with
a None stub. Restores all three blocks so YouTube, Instagram, Reddit,
TikTok, Facebook, Snapchat, X, and local file entries receive proper
titles again.
2026-07-03 18:13:23 +02:00
8b2e8172c3
fix: emit yt-dlp stderr on fetch_metadata failure for debuggability 2026-07-03 18:03:22 +02:00
55f85134df
feat: tag delete, rename, and per-user humanize-tags display setting (#16)
* feat: add tag delete and rename (backend + frontend)

- DELETE /api/archives/:id/tags/:tag_uid — deletes tag subtree via
  recursive CTE; entry_tag_assignments cascade automatically
- PATCH  /api/archives/:id/tags/:tag_uid { name } — renames a tag
  segment (case-preserving slug), cascades full_path to all descendants
  in one transaction using hierarchy CTE (not LIKE), returns updated Tag
- database: rename_tag + delete_tag with 7 unit tests covering subtree
  cascade, collision detection, descendant path rewrite, slug stripping
- TagsView: inline rename (pencil icon / double-click → input), × delete
  with confirmation dialog (warns about child tags)
- App.jsx: handleTagRenamed rewrites tagFilter for exact + descendant
  paths; handleTagDeleted clears filter for deleted subtree
- api.js: renameTag (PATCH, returns Tag) + deleteTag (DELETE)
- ContextRail: tag pills show displayPath(full_path) (humanized) with
  raw full_path in title tooltip; humanize-tags setting coming next

* feat: per-user humanize-tags display setting

- auth DB: idempotent migration adds users.humanize_slugs INTEGER DEFAULT 0
- GET /api/auth/me: returns humanize_slugs bool
- PATCH /api/auth/me: accepts { humanize_slugs: bool }, persisted via
  database::update_user_humanize_slugs
- displayPath() helper moved to utils.js (was local to ContextRail)
- TagsView: node label shows tag.name vs tag.slug based on humanizeTags
- ContextRail: pill label applies displayPath conditionally
- App.jsx: derives humanizeTags from currentUser.humanize_slugs, passes
  to TagsView + ContextRail; filter badge label humanized when on
- Settings > Profile: Display Preferences section with checkbox toggle,
  updates backend and currentUser immediately via setCurrentUser
- api.js: patchMe(patch) for generic PATCH /api/auth/me
- Tests: auth_me default=false, PATCH persists=true (2 route tests)
2026-07-03 14:26:45 +02:00
2502de45b6
feat: inline entry title renaming (#14)
* feat: inline entry title renaming

- database.rs: add update_entry_title(conn, entry_uid, title) -> Result<bool>
  targets archived_entries table; returns false for unknown uid
- routes.rs: PATCH /api/archives/:archive_id/entries/:entry_uid (ROLE_USER)
  maps false -> 404; 3 tests (401 unauthed, 204+persists, 404 unknown uid)
- api.js: updateEntryTitle(archiveId, entryUid, title)
- ContextRail.jsx: click title h2 -> inline input; Enter/blur commits,
  Escape cancels (cancel ref prevents double-save on blur after Escape);
  pencil icon fades in on hover as edit affordance
- App.jsx: handleEntryTitleChange mutates entries + selectedEntry in-place
- styles.css: rail-title--editable cursor/hover, pencil icon show/hide,
  rail-title-input underline style
- rebuild frontend bundle

* chore: remove + prefix from Capture button label
2026-07-03 13:01:34 +02:00
90d17c9d25
feat: precompute cached_bytes per entry in DB (#13)
Store how many bytes of each entry's artifacts are already on disk from
an earlier entry (content-addressed blob deduplication means shared
blobs are only stored once).

Design
------
- Add `cached_bytes INTEGER NOT NULL DEFAULT 0` to `archived_entries`
- Precompute at capture time via `database::refresh_entry_cached_bytes`
  called after all artifacts are saved for every capture path
  (web page, generic URL, tweet, yt-dlp/local)
- One-time migration in `initialize_schema`: detects missing column via
  PRAGMA table_info, ALTERs the table, then back-fills all existing rows
  with the correlated subquery
- `database::cascade_cached_bytes_after_delete` ready for when entry
  deletion is implemented; designed to run asynchronously after the
  delete is acknowledged to the user
- `cached_bytes` included in `EntrySummary` and all four SELECT paths
  (list_root_entries, search_entries, list_entries_for_collection,
  entries_for_tag) via the shared ENTRY_SELECT_COLS constant

Frontend
--------
- `EntryRow` shows a `% cached` sub-line under the size when non-zero,
  with a tooltip showing the raw cached byte count
- No separate API endpoint or extra fetch — value rides in the existing
  entries list response at zero extra query cost per read
2026-07-03 11:56:55 +02:00
c4f1c7a137
fix: use supported --browser-wait-until=networkidle2 for single-file-cli (reverts invalid networkAlmostIdle from previous commit) 2026-07-01 14:10:54 +02:00
2414acf0df
chore: add Dockerfile, docker-compose, and Docker hosting docs (#12)
* chore: add Dockerfile, docker-compose, and Docker docs

- Multi-stage Dockerfile: Rust builder stage + debian:bookworm-slim runtime
  with Chromium, Node/single-file-cli, Python venv (yt-dlp + twitter-api-client)
- docker-compose.yml: wires ARCHIVR_BIND, config volume, and persistent data volume
- docker/config.example.toml: annotated TOML template for Docker deployments
- docs/README.md: add Hosting with Docker section; add ARCHIVR_BIND and
  ARCHIVR_STATIC_DIR to the Environment Variables reference

* fix: address code review issues with Docker setup

- .gitignore: whitelist Dockerfile, docker-compose.yml, docker/ so they
  are actually tracked (the * catch-all was silently dropping them)
- Dockerfile: build and ship the archivr CLI alongside archivr-server so
  users can run `archivr init` inside the container on first setup
- docker/config.example.toml: fix archive_path to point at the .archivr
  subdirectory that archivr init creates (not the parent directory), which
  is what read_archive_paths expects
- docs/README.md: replace the bare mkdir quickstart step with
  `archivr init`, explain why mkdir is insufficient; add a callout that
  auth_db_path must be set explicitly to a writable path when the config
  mount is read-only

* fix: address second round of Docker review issues

Chromium sandbox (P2):
- singlefile.rs: add ARCHIVR_CHROME_ARGS env var (space-separated flags
  appended to Chromium's --browser-args JSON array); Dockerfile sets it
  to --no-sandbox because Chromium refuses to start as root without it

Store-path outside volume (P1):
- README: pass explicit absolute store-path as the second positional arg
  to `archivr init` so the blob store lands on /data instead of the
  container layer (CLI default is ./.archivr/store, resolved from cwd,
  which is / with no WORKDIR set)

ENTRYPOINT vs CMD (P2):
- Dockerfile: switch from ENTRYPOINT to CMD so `docker compose run
  archivr archivr init …` overrides the full command instead of being
  appended to the server invocation

ffmpeg missing (P2):
- Dockerfile: add ffmpeg to the apt-get install block (required by
  yt-dlp --merge-output-format mp4 for bestvideo+bestaudio streams)

Node version (P2):
- Dockerfile: replace Debian bookworm's nodejs (18.x) with Node 20 via
  the NodeSource setup script (single-file-cli declares engines.node >=20)

Build context secrets (P2):
- Add .dockerignore excluding config/ and docker/ from the build context
  so runtime secrets (e.g. twitter-cookies.txt) are never sent to the builder
- Whitelist .dockerignore in .gitignore

docs:
- README: document ARCHIVR_CHROME_ARGS in the Environment Variables section

* fix: third round of Docker review issues

Rust toolchain (P1):
- Dockerfile: bump builder from rust:1.87 to rust:1.88; time@0.3.51,
  time-core@0.1.9, and time-macros@0.2.30 (present in Cargo.lock) all
  require MSRV 1.88, so the real cargo build --release step was failing

single-file-cli wait mode (P2):
- singlefile.rs: replace --browser-wait-until=networkidle2 with
  networkAlmostIdle; the single-file-cli option only accepts
  InteractiveTime/networkIdle/networkAlmostIdle/load/domContentLoaded
  (verified in options.js); networkidle2 is a Puppeteer concept that the
  CLI does not recognise, causing silent fallback to the earliest state
  and incomplete captures. networkAlmostIdle is the closest equivalent
  (<=2 open connections, matching Puppeteer's networkidle2 semantics)

Build context size (P3):
- .dockerignore: add target/, frontend/node_modules/, frontend/dist/;
  these can reach 1.4G+ after a local dev build and are never read by
  the Dockerfile, so sending them to the builder wastes time and memory
2026-06-30 14:35:43 +02:00
533e9d5d2e
feat(collections): db — update_collection, delete_collection helpers 2026-06-26 17:33:35 +02:00
69af2d2060
feat(settings): db — display_name column, InstanceSettings struct, profile and instance-settings helpers 2026-06-26 17:17:34 +02:00
24c5321f6e
feat(collections): collection CRUD + entry-visibility routes 2026-06-26 17:00:19 +02:00
02cab149fa
feat(collections): visibility-filtered entry queries + collection helpers 2026-06-26 16:51:48 +02:00
ab054f6256
feat(collections): schema — collections + collection_entries tables 2026-06-26 16:48:22 +02:00
48da356eee
feat(users): user management DB helpers (list_users, create_user, assign_role, custom roles) 2026-06-26 15:03:40 +02:00
27b37c3b6d
feat(capture): CaptureJobSummary type and archive::get_capture_job 2026-06-26 12:56:38 +02:00
d86a4d92a8
feat(capture): capture_jobs schema and DB helpers 2026-06-26 12:56:10 +02:00
37fd057c1f
fix(auth): use rfc3339 for session expires_at; update tag tests for auth guards 2026-06-26 12:03:13 +02:00
86e8049f1b
feat(auth): session and token DB helpers 2026-06-26 11:32:32 +02:00
f482707b75
feat(auth): user and role DB helpers (create_owner, compute_role_bits) 2026-06-26 11:31:20 +02:00
696a5e1ac7
feat(auth): add initialize_auth_schema, open_auth_db, and auth record types 2026-06-26 11:30:02 +02:00
5fba6987a3
fix(capture): remove debug println! from hash_exists 2026-06-25 16:19:33 +02:00
351e74df09
feat(fonts): wire font extraction into capture pipeline + blob serving route
- capture.rs: add archive_id: Option<&str> to perform_capture; when Some,
  call font_extractor::extract_and_rewrite before hashing HTML, register
  each font as a deduplicated blob + 'font' artifact
- main.rs: pass None as archive_id (CLI keeps fonts embedded)
- routes.rs: add GET /api/archives/:id/blobs/:sha256 (serve_blob handler),
  pass Some(&archive_id) to perform_capture in capture_handler,
  add ApiError::internal constructor
2026-06-25 16:07:50 +02:00
22b0a55730
feat(fonts): hash_bytes, get_blob_by_sha256, font_extractor module
- fix(hash): hash raw bytes instead of lossy UTF-8; add hash_bytes
- feat(database): add get_blob_by_sha256 lookup
- feat(font_extractor): extract embedded font data-URIs from archived HTML
2026-06-25 16:02:57 +02:00
f6d636fce2
feat(singlefile): add UA, disable-web-security, and user-data-dir
- --user-agent: realistic Chrome UA so servers don't block headless string
- --browser-args=[--disable-web-security, --user-data-dir]: lets single-file
  inline fonts from any cross-origin CDN (e.g. fonts.gstatic.com) regardless
  of ACAO headers; user-data-dir required for --disable-web-security to take
  effect in newer Chromium builds (otherwise silently ignored)
2026-06-25 11:39:24 +02:00
ab353f9209
fix(singlefile): improve font capture and eliminate ES module viewer errors
Font fidelity:
- --browser-wait-delay=2000: Cloudflare Fonts injects @font-face CSS after
  HTML parse; the font hook needs extra time to see it after networkidle2
- --remove-unused-fonts=false: preserve @font-face rules even when fonts
  haven't rendered yet (font-display:swap, off-screen text)
- --remove-alternative-fonts=false: preserve unicode-range subsets instead
  of stripping them as 'alternatives'

ES module viewer error fix:
- Write a user script (sf-strip-scripts.js) that listens for
  single-file-on-before-capture-start and removes all <script> elements
  (except application/ld+json) from the live DOM before serialization.
  Scripts still execute during capture for CSS fidelity; none end up in
  the saved file, so no data:-URL base ES module resolution errors.
2026-06-24 21:30:14 +02:00
852cc45956
fix(singlefile): disable CSS stripping and script blocking for full render fidelity
Defaults that were destroying CSS fidelity:
- --remove-unused-styles=true: strips CSS nesting rules (site uses & selector)
  and any rule targeting JS-applied classes
- --remove-alternative-medias=true: deletes @media blocks that don't match
  the capture viewport, breaking responsive layout
- --block-scripts=true: prevents JS from applying classes before CSS snapshot

All three now set to false.
2026-06-24 21:15:36 +02:00
cc2dbf4ac2
feat(archive): add has_favicon to EntrySummary 2026-06-24 19:16:21 +02:00
653efa9705
feat(capture): store favicon artifact for WebPage entries 2026-06-24 19:16:21 +02:00
ee697625fb
feat(singlefile): add SaveResult, favicon extraction, wait for networkidle2 2026-06-24 19:16:21 +02:00
fe9ff2dafe
feat(capture): add Source::WebPage, route HTML URLs to singlefile downloader 2026-06-24 18:39:02 +02:00
fd06632073
feat(singlefile): add single-file-cli downloader module 2026-06-24 18:38:57 +02:00
b7ab12898e
feat(http): add UrlKind enum and probe_url_kind HEAD probe 2026-06-24 18:38:52 +02:00
143f71bc17
feat(core): auto-title for HTTP/S URL downloads
http::download now returns (hash, extension, Option<title_hint>).
Title is derived from Content-Disposition filename header (RFC 5987
filename*= preferred over plain filename=), falling back to the last
path segment of the final URL after redirects (percent-decoded).

capture.rs Source::Url arm passes the title through to record_media_entry
instead of None, so entries like 'Facharbeit.pdf' or 'facharbeit' appear
in the Title column instead of the raw entry_uid.

Adds percent_decode(), title_from_content_disposition(), title_from_url()
helpers with 10 new unit tests (122 total, all green).
2026-06-24 14:56:48 +02:00
03abfb4d18
feat(core): generic HTTP/S file URL capture (Track 1)
- Add crates/archivr-core/src/downloader/http.rs
  - download(url, store_path, timestamp) -> Result<(hash, extension)>
  - Rejects text/html responses with a clear error
  - Derives extension from URL path or Content-Type header
  - Follows redirects (capped at 10), user-agent archivr/0.1
  - 10 unit tests for extension/content-type helpers
- Add Source::Url variant to capture::Source enum
- determine_source: unmatched http/https URLs route to Source::Url
- source_metadata: Source::Url => ("web", "file", "file")
- generate_entry_title: Source::Url arm -> "Downloaded File" fallback
- perform_capture: Source::Url arm calls http::download, uses existing
  temp -> hash_exists -> move_temp_to_raw -> record_media_entry pipeline
- Update test expectations: 3 plain https:// cases now expect Source::Url
- Add reqwest 0.12 (blocking) to workspace and archivr-core deps
- Mark URLs milestone done in docs/README.md
- Update NEXT.md Track 1 status
2026-06-24 14:37:08 +02:00
80828951e2
feat: wire metadata titles through perform_capture and record_tweet_entry
- perform_capture: fetch yt-dlp metadata (separate --dump-json call)
  before download; derive local title from file:// path filename
- Compute entry_title before record_media_entry call; pass it through
  instead of the temporary None placeholder
- record_tweet_entry: read tweet JSON before entry creation so title
  can be set on NewEntry; add tweet_metadata_from_json helper
2026-06-23 22:12:34 +02:00
3e289383a9
feat: platform-aware entry titling for all 13 source types
- Add PlatformMetadata struct and generate_entry_title() covering all 13
  Source variants (YouTubeVideo, YouTubePlaylist, YouTubeChannel, X, Tweet,
  TweetThread, Instagram, Facebook, TikTok, Reddit, Snapchat, Local, Other)
- Add downloader/metadata.rs: extract_from_ytdlp_json() parses yt-dlp
  --dump-json output into PlatformMetadata; extracts Reddit subreddit
  from webpage_url via regex
- Add ytdlp::fetch_metadata(): separate --dump-json invocation that does
  not interfere with the actual download call
- Extend record_media_entry() with title: Option<String> param; wire
  yt-dlp metadata fetch + local filename extraction in perform_capture
- Restructure record_tweet_entry() to read tweet JSON before entry
  creation; extract title via tweet_metadata_from_json()
- 16 title-generation unit tests + 6 metadata extraction unit tests
2026-06-23 21:49:34 +02:00
5ea0c396b8
fix: expand yt:/youtube: shorthands to real URLs before yt-dlp
expand_shorthand_to_url handled instagram:/facebook:/tiktok: etc. but
not yt:/youtube:. yt-dlp doesn't know the yt: scheme, so shorthands
like yt:video/ID failed with 'Unsupported url scheme'.

Add YouTube cases for video/, short/, shorts/, playlist/, channel/,
c/, user/, and @handle. Full https:// URLs pass through unchanged.

Add tests pinning all new expansions.
2026-06-22 22:42:41 +02:00
8d0352c2c4
feat: browser capture button (Track 4)
- Add archivr-core/src/capture.rs: Source enum, all capture helpers,
  fail_run (replaces process::exit fail_archive_and_exit), and
  perform_capture() as the public entry point
- Slim archivr-cli/src/main.rs to 103 lines: thin adapter over
  archivr_core::capture::perform_capture
- Move all source-classification and tweet-entry tests from CLI to
  archivr-core/src/capture.rs (they test core logic, belong in core)
- Add POST /api/archives/:archive_id/captures route in archivr-server:
  validates non-empty locator (400), resolves archive (404), delegates
  to perform_capture, returns {run_uid, status}
- Add capture dialog to browser UI: dialog markup in index.html,
  showModal/submit/cancel/loading/error wiring in app.js, dialog
  styles in styles.css
- 77 tests pass, clean build (0 warnings)
2026-06-22 22:18:44 +02:00
db953de67a
feat(core): hierarchical tag API
- Tag/TagNode types, create_tag, list_tag_tree, assign_entry_tag,
  remove_entry_tag, entries_for_tag
- Tag-filtered search returns child entries (no root restriction)
- parent_entry_uid added to EntrySummary
- 8 new tests
2026-06-22 14:48:46 +02:00
83f909585f
feat: server-side search with structured prefix query language 2026-06-22 14:48:46 +02:00
dcf9e127bb
feat: entry detail view and artifact file serving 2026-06-22 14:48:46 +02:00
b56c969624
feat: add db and multi-archive web UI foundation (#8)
* Add SQLite metadata database support

* Implement archive metadata database

* chore: let's guess cargoHash because there's something wrong with nixpkgs!

* Gate test-only database helpers behind cfg(test)

* Fix archive database row identity

* Use serde for archive metadata JSON

* Finalize archive runs at command level

* Handle archive command errors without panics

* Cover tweet entry metadata recording

* Document static regex invariants

* docs: add web UI design spec

* docs: add web UI implementation plan

* chore: move cli into workspace crate

* chore: track workspace crates directory

* refactor: extract archive core crate

* refactor: add core archive opening APIs

* refactor: rename taxonomy model to tags

* feat: add archive query APIs

* feat: add web server registry

* feat: expose archive server APIs

* feat: add archive table web UI

* fix: complete web UI smoke path

* docs: add architecture mental model

* docs: remove private superpowers plans

* nix: split cli and server packages

* chore: remove PLAN.md
2026-06-14 00:27:16 +02:00