mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-21 18:55:36 +02:00
Compare commits
13 commits
a4de506495
...
64f94c6eda
| Author | SHA1 | Date | |
|---|---|---|---|
| 64f94c6eda | |||
| 15660e6532 | |||
| e5fb6ea9bb | |||
| 8c19d90e94 | |||
| 571da72649 | |||
| 3d95df8d5a | |||
| 453a8c6487 | |||
| 331fe7fd61 | |||
| f9759c08a3 | |||
| 5db18122f7 | |||
| b33bf8dbd7 | |||
| a926d7cf2d | |||
| 289037235c |
17 changed files with 3436 additions and 1094 deletions
|
|
@ -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 1–7 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
|
||||
|
|
|
|||
|
|
@ -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
225
NEXT.md
|
|
@ -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 5–6.
|
||||
|
||||
---
|
||||
|
||||
### 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 1–4 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.
|
||||
```
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, ×tamp, &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, ×tamp, &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(×tamp));
|
||||
|
||||
// 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
|
|
@ -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 & LAST so a value like `&lt;` becomes `<` (one layer
|
||||
// removed) rather than `<` (two layers). If `&` ran first it would
|
||||
// produce `<` from the remainder, which the subsequent `<` pass
|
||||
// would then incorrectly collapse to `<`.
|
||||
s.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("&", "&")
|
||||
}
|
||||
|
||||
/// 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. & → & 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 & by the browser's HTML serialiser.
|
||||
assert_eq!(
|
||||
html_attr_decode("https://cdn.example.com/img.jpg?a=1&b=2&sig=abc"),
|
||||
"https://cdn.example.com/img.jpg?a=1&b=2&sig=abc",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_attr_decode_single_layer_only() {
|
||||
// &lt; → < (one browser-serialisation layer removed, not two).
|
||||
// If & ran first it would produce < then < — wrong.
|
||||
assert_eq!(html_attr_decode("&lt;"), "<");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_attr_decode_double_encoded_amp() {
|
||||
// &amp; → & (the attribute value is a literal &).
|
||||
assert_eq!(html_attr_decode("&amp;"), "&");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_attr_decode_lt_gt_quot_direct() {
|
||||
// Directly encoded entities (no surrounding &) decode normally.
|
||||
assert_eq!(html_attr_decode("<tag>"), "<tag>");
|
||||
assert_eq!(html_attr_decode("say "hi""), "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
47
crates/archivr-server/static/assets/index-YmIQCrug.js
Normal file
47
crates/archivr-server/static/assets/index-YmIQCrug.js
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
167
frontend/src/components/TagsView.stories.jsx
Normal file
167
frontend/src/components/TagsView.stories.jsx
Normal 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),
|
||||
]),
|
||||
]}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue