mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-21 18:55:36 +02:00
Compare commits
No commits in common. "64f94c6edab01628d92cba582398a9fb6f879da6" and "a4de5064956a32925a71406bc4345337adbc747b" have entirely different histories.
64f94c6eda
...
a4de506495
17 changed files with 1093 additions and 3435 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, 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.
|
||||
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.
|
||||
|
||||
Read `ARCHIVR-MENTAL-MODEL.md` before making structural changes.
|
||||
Read `ARCHIVR-MENTAL-MODEL.md` before making structural changes; `NEXT.md` tracks the roadmap (Tracks 1–7 done, Track 8 = Collections UI is next).
|
||||
|
||||
## 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/...`. `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.
|
||||
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/...`.
|
||||
|
||||
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,7 +31,6 @@ 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,16 +117,6 @@ 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:
|
||||
|
|
@ -152,17 +142,14 @@ 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` |
|
||||
| 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` |
|
||||
| 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` |
|
||||
|
||||
## Practical Feature Rule
|
||||
|
||||
|
|
@ -178,13 +165,12 @@ If a browser feature needs new data, the usual order is:
|
|||
2. Expose it in `archivr-server`.
|
||||
3. Render it in the static UI.
|
||||
|
||||
## Server Capabilities
|
||||
## Current Limitations
|
||||
|
||||
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`.
|
||||
The web server reads archive data and serves the UI. It does not yet implement capture.
|
||||
|
||||
**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`.
|
||||
Search is currently simple client-side filtering.
|
||||
|
||||
**Search** is client-side filtering over entries the frontend has already fetched.
|
||||
|
||||
**Admin view** covers mounted archives, users, sessions, and API tokens.
|
||||
**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`.
|
||||
|
||||
Admin is a mounted-archives view, not a management system.
|
||||
|
|
|
|||
225
NEXT.md
Normal file
225
NEXT.md
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
# 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,8 +85,6 @@ 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>,
|
||||
}
|
||||
|
||||
|
|
@ -198,10 +196,7 @@ 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,
|
||||
|
|
@ -341,18 +336,16 @@ 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.
|
||||
|
|
@ -420,7 +413,8 @@ 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
|
||||
|
|
@ -454,12 +448,9 @@ 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()))?;
|
||||
|
|
@ -531,13 +522,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);
|
||||
|
|
@ -550,14 +541,16 @@ 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 \
|
||||
|
|
@ -586,12 +579,14 @@ 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,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -692,65 +687,19 @@ 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();
|
||||
|
|
@ -764,21 +713,22 @@ pub fn list_tag_tree(conn: &rusqlite::Connection) -> Result<Vec<TagNode>> {
|
|||
slug: r.slug.clone(),
|
||||
full_path: r.full_path.clone(),
|
||||
},
|
||||
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),
|
||||
children: build_nodes(Some(r.id), by_parent),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
Ok(build_nodes(None, &by_parent, &counts, &subtree_counts))
|
||||
Ok(build_nodes(None, &by_parent))
|
||||
}
|
||||
|
||||
/// 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",
|
||||
|
|
@ -1161,14 +1111,10 @@ 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 {
|
||||
|
|
@ -1186,19 +1132,14 @@ 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 {
|
||||
|
|
@ -1216,8 +1157,7 @@ mod tests {
|
|||
source_metadata_json: "{}".to_string(),
|
||||
display_metadata_json: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
).unwrap();
|
||||
|
||||
conn
|
||||
}
|
||||
|
|
@ -1233,14 +1173,10 @@ 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");
|
||||
}
|
||||
|
|
@ -1248,14 +1184,10 @@ 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");
|
||||
}
|
||||
|
|
@ -1263,14 +1195,10 @@ 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"));
|
||||
}
|
||||
|
|
@ -1278,29 +1206,21 @@ 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);
|
||||
}
|
||||
|
||||
|
|
@ -1323,8 +1243,9 @@ 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 {
|
||||
|
|
@ -1342,8 +1263,7 @@ mod tests {
|
|||
source_metadata_json: "{}".to_string(),
|
||||
display_metadata_json: None,
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1355,143 +1275,32 @@ 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();
|
||||
|
|
@ -1501,15 +1310,7 @@ 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();
|
||||
|
||||
|
|
@ -1521,23 +1322,11 @@ 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();
|
||||
|
|
@ -1545,77 +1334,42 @@ 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,9 +87,6 @@ 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.
|
||||
|
|
@ -1041,20 +1038,7 @@ pub fn perform_capture(
|
|||
|
||||
// Source: web page — archive as a self-contained HTML snapshot via single-file-cli
|
||||
if source == Source::WebPage {
|
||||
// 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) {
|
||||
match downloader::singlefile::save(locator, store_path, ×tamp, &cookies, config.ublock_enabled, config.cookie_ext_enabled, config.reader_mode, config.modal_closer_enabled) {
|
||||
Ok(result) => {
|
||||
let file_extension = ".html".to_string();
|
||||
let temp_html = store_path
|
||||
|
|
@ -1065,26 +1049,23 @@ 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, html_title) =
|
||||
let (html_hash, byte_size, extracted_fonts) =
|
||||
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, title)
|
||||
(new_hash, size, fonts)
|
||||
} 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.title.clone())
|
||||
(result.html_hash.clone(), size, vec![])
|
||||
};
|
||||
|
||||
// 1. Move HTML to raw store (if this hash hasn't been seen before).
|
||||
|
|
@ -1121,17 +1102,6 @@ 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,
|
||||
|
|
@ -1144,7 +1114,7 @@ pub fn perform_capture(
|
|||
&html_hash,
|
||||
&file_extension,
|
||||
byte_size,
|
||||
entry_title,
|
||||
result.title,
|
||||
)?;
|
||||
|
||||
// 5. Add favicon artifact if we captured one.
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,4 @@
|
|||
use anyhow::{Context, Result, bail};
|
||||
use regex::Regex;
|
||||
use base64::engine::general_purpose::STANDARD as B64;
|
||||
use base64::Engine as _;
|
||||
use std::{
|
||||
|
|
@ -48,63 +47,12 @@ 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 =
|
||||
|
|
@ -288,7 +236,6 @@ 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());
|
||||
|
|
@ -307,7 +254,6 @@ 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;
|
||||
|
|
@ -432,7 +378,6 @@ 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")?;
|
||||
|
|
@ -519,36 +464,6 @@ 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")?;
|
||||
|
|
@ -669,10 +584,6 @@ 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)?;
|
||||
|
|
@ -742,32 +653,17 @@ fn base_single_file_cmd(
|
|||
|
||||
// ── HTML helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Reads up to 256 KiB of `path` and extracts the content of the first
|
||||
/// Reads the first 8 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::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 mut buf = vec![0u8; 8192];
|
||||
let n = f.read(&mut buf).ok()?;
|
||||
let buf = &buf[..n];
|
||||
let lower = String::from_utf8_lossy(buf).to_ascii_lowercase();
|
||||
let start = lower.find("<title>")? + "<title>".len();
|
||||
let end = lower[start..].find("</title>")? + start;
|
||||
|
|
@ -841,177 +737,6 @@ 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::*;
|
||||
|
|
@ -1049,19 +774,6 @@ 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.
|
||||
|
|
@ -1077,7 +789,6 @@ 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:#}");
|
||||
|
|
@ -1119,125 +830,9 @@ 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
47
crates/archivr-server/static/assets/index-Db35_tmT.js
Normal file
47
crates/archivr-server/static/assets/index-Db35_tmT.js
Normal file
File diff suppressed because one or more lines are too long
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-YmIQCrug.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DLdY9nrw.css">
|
||||
<script type="module" crossorigin src="/assets/index-Db35_tmT.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-C0COQCCD.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -111,26 +111,6 @@ 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`);
|
||||
}
|
||||
|
|
@ -142,13 +122,12 @@ 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, via_freedium?: bool }
|
||||
// extensions: { ublock_enabled?: bool, reader_mode?: bool, cookie_ext_enabled?: bool, modal_closer_enabled?: 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,7 +123,6 @@ 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(() => {
|
||||
|
|
@ -302,7 +301,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, via_freedium: freediumEnabled }
|
||||
const extensions = { ublock_enabled: ublockEnabled, reader_mode: readerMode, cookie_ext_enabled: cookieExtEnabled, modal_closer_enabled: modalCloserEnabled }
|
||||
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
|
||||
|
|
@ -518,22 +517,6 @@ 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,156 +1,14 @@
|
|||
import { useState, useRef, useEffect } from 'react';
|
||||
import { renameTag, deleteTag, createTag, moveTag } from '../api';
|
||||
import { useState, useRef } from 'react';
|
||||
import { renameTag, deleteTag } from '../api';
|
||||
|
||||
// ── 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,
|
||||
}) {
|
||||
function TagNode({ node, archiveId, tagFilter, onTagFilterSet, onViewChange, onTagRenamed, onTagDeleted, onTagsRefresh, humanizeTags }) {
|
||||
const isActive = tagFilter === node.tag.full_path;
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState('');
|
||||
const cancelRef = useRef(false);
|
||||
|
||||
function handleClick() {
|
||||
function handleFilterClick() {
|
||||
if (editing) return;
|
||||
if (moveSelectMode) {
|
||||
onMoveSourceSelect(node);
|
||||
return;
|
||||
}
|
||||
const next = isActive ? null : node.tag.full_path;
|
||||
onTagFilterSet(next);
|
||||
onViewChange('archive');
|
||||
|
|
@ -158,14 +16,16 @@ function TagNode({
|
|||
|
||||
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);
|
||||
|
|
@ -192,18 +52,11 @@ function TagNode({
|
|||
}
|
||||
}
|
||||
|
||||
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;
|
||||
const childProps = { archiveId, tagFilter, onTagFilterSet, onViewChange, onTagRenamed, onTagDeleted, onTagsRefresh, humanizeTags };
|
||||
|
||||
return (
|
||||
<li>
|
||||
<div className={`tag-node-row${moveSelectMode ? ' tag-node-row--move-select' : ''}`}>
|
||||
<div className="tag-node-row">
|
||||
{editing ? (
|
||||
<input
|
||||
className="tag-rename-input"
|
||||
|
|
@ -215,66 +68,50 @@ function TagNode({
|
|||
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' : ''}${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}
|
||||
className={`tag-node-btn${isActive ? ' is-active' : ''}`}
|
||||
title={node.tag.full_path}
|
||||
onClick={handleFilterClick}
|
||||
onDoubleClick={startEdit}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
{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>
|
||||
</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>
|
||||
)}
|
||||
<button
|
||||
className="remove tag-node-delete"
|
||||
title={`Delete tag ${node.tag.full_path}`}
|
||||
onClick={handleDelete}
|
||||
aria-label={`Delete tag ${node.tag.full_path}`}
|
||||
>×</button>
|
||||
</div>
|
||||
{(hasChildren || showCreateInput) && (
|
||||
{node.children?.length > 0 && (
|
||||
<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>
|
||||
)}
|
||||
|
|
@ -282,162 +119,35 @@ function TagNode({
|
|||
);
|
||||
}
|
||||
|
||||
// ── 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__';
|
||||
|
||||
export default function TagsView({ archiveId, tagNodes, tagFilter, onTagFilterSet, onViewChange, onTagRenamed, onTagDeleted, onTagsRefresh, humanizeTags }) {
|
||||
return (
|
||||
<section id="tags-view" className="view is-active">
|
||||
<div className="tag-tree">
|
||||
<div className="tag-tree-header">
|
||||
{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>
|
||||
</>
|
||||
<span className="tag-tree-title">Tags</span>
|
||||
{tagFilter && (
|
||||
<span className="tag-tree-active">Filtering: {tagFilter}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tagNodes.length === 0 && !showRootCreateInput ? (
|
||||
{tagNodes.length === 0 ? (
|
||||
<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} {...nodeProps} />
|
||||
<TagNode key={node.tag.tag_uid} node={node}
|
||||
archiveId={archiveId}
|
||||
tagFilter={tagFilter}
|
||||
onTagFilterSet={onTagFilterSet}
|
||||
onViewChange={onViewChange}
|
||||
onTagRenamed={onTagRenamed}
|
||||
onTagDeleted={onTagDeleted}
|
||||
onTagsRefresh={onTagsRefresh}
|
||||
humanizeTags={humanizeTags}
|
||||
/>
|
||||
))}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,167 +0,0 @@
|
|||
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,135 +1279,6 @@ 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