diff --git a/.github/workflows/update-ytdlp.yml b/.github/workflows/update-ytdlp.yml index 3b9a921..624e566 100644 --- a/.github/workflows/update-ytdlp.yml +++ b/.github/workflows/update-ytdlp.yml @@ -13,39 +13,126 @@ jobs: pull-requests: write steps: - - uses: actions/checkout@v4 + - name: Check out repository + uses: actions/checkout@v4 - - uses: DeterminateSystems/nix-installer-action@main + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@main - - uses: DeterminateSystems/magic-nix-cache-action@main + - name: Enable Nix cache + uses: DeterminateSystems/magic-nix-cache-action@main - - name: Get current yt-dlp version - id: before + - name: Read currently pinned yt-dlp version + id: current run: | - rev=$(jq -r '.nodes.nixpkgs.locked.rev' flake.lock) - version=$(nix eval --raw "github:nixos/nixpkgs/${rev}#yt-dlp.version") - echo "version=${version}" >> "$GITHUB_OUTPUT" + set -euo pipefail + current=$(sed -n '/ytDlp = pkgs.stdenv.mkDerivation/,/^ };$/{ s/^ *version = "\([^"]*\)";/\1/p; }' flake.nix | head -1) + if [ -z "$current" ]; then + echo "::error::Could not read the pinned yt-dlp version from flake.nix. Did the ytDlp derivation move or get renamed?" + exit 1 + fi + echo "Currently pinned yt-dlp: $current" + echo "version=${current}" >> "$GITHUB_OUTPUT" - - name: Update nixpkgs - run: nix flake update nixpkgs - - - name: Get new yt-dlp version - id: after + - name: Query latest yt-dlp release + id: latest + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - rev=$(jq -r '.nodes.nixpkgs.locked.rev' flake.lock) - version=$(nix eval --raw "github:nixos/nixpkgs/${rev}#yt-dlp.version") - echo "version=${version}" >> "$GITHUB_OUTPUT" + set -euo pipefail + latest=$(curl -sSL \ + -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + https://api.github.com/repos/yt-dlp/yt-dlp/releases/latest | jq -r .tag_name) + if [ -z "$latest" ] || [ "$latest" = "null" ]; then + echo "::error::Could not determine the latest yt-dlp release tag from the GitHub API." + exit 1 + fi + echo "Latest yt-dlp release: $latest" + echo "version=${latest}" >> "$GITHUB_OUTPUT" - - name: Open PR if yt-dlp was updated - if: steps.before.outputs.version != steps.after.outputs.version + - name: Decide whether an update is needed + id: check + env: + CURRENT: ${{ steps.current.outputs.version }} + LATEST: ${{ steps.latest.outputs.version }} + run: | + set -euo pipefail + if [ "$CURRENT" = "$LATEST" ]; then + echo "Already at $CURRENT" + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "Update available: $CURRENT -> $LATEST" + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + + - name: Compute SRI hash of the new release + id: hash + if: steps.check.outputs.changed == 'true' + env: + LATEST: ${{ steps.latest.outputs.version }} + run: | + set -euo pipefail + curl -sSL --fail \ + "https://github.com/yt-dlp/yt-dlp/releases/download/${LATEST}/yt-dlp" \ + -o /tmp/yt-dlp + hash=$(nix hash file --sri --type sha256 /tmp/yt-dlp) + case "$hash" in + sha256-*) ;; + *) + echo "::error::Computed hash '${hash}' is not an SRI sha256 hash." + exit 1 + ;; + esac + echo "SRI hash: $hash" + echo "hash=${hash}" >> "$GITHUB_OUTPUT" + + - name: Rewrite the yt-dlp pin in flake.nix + if: steps.check.outputs.changed == 'true' + env: + CURRENT: ${{ steps.current.outputs.version }} + LATEST: ${{ steps.latest.outputs.version }} + HASH: ${{ steps.hash.outputs.hash }} + run: | + set -euo pipefail + sed -i \ + -e "/ytDlp = pkgs.stdenv.mkDerivation/,/^ };\$/{ s|^\( *version = \"\)[^\"]*\(\";\)|\1${LATEST}\2|; }" \ + -e "/ytDlp = pkgs.stdenv.mkDerivation/,/^ };\$/{ s|\(url = \"https://github.com/yt-dlp/yt-dlp/releases/download/\)[^/]*\(/yt-dlp\";\)|\1${LATEST}\2|; }" \ + -e "/ytDlp = pkgs.stdenv.mkDerivation/,/^ };\$/{ s|^\( *hash = \"\)sha256-[^\"]*\(\";\)|\1${HASH}\2|; }" \ + flake.nix + + echo "--- git diff --stat ---" + git diff --stat flake.nix + + changed_files=$(git diff --name-only) + if [ "$changed_files" != "flake.nix" ]; then + echo "::error::Expected only flake.nix to change, got: ${changed_files}" + exit 1 + fi + + occurrences=$(grep -c "$LATEST" flake.nix || true) + if [ "$occurrences" -ne 2 ]; then + echo "::error::Expected the new version ${LATEST} to appear twice in flake.nix (version line + URL), found ${occurrences}." + exit 1 + fi + + if ! grep -q "$HASH" flake.nix; then + echo "::error::New SRI hash was not written into flake.nix." + exit 1 + fi + + echo "Rewrote yt-dlp pin: ${CURRENT} -> ${LATEST}" + + - name: Open pull request + if: steps.check.outputs.changed == 'true' uses: peter-evans/create-pull-request@v6 with: branch: auto/yt-dlp-update delete-branch: true - commit-message: "chore: yt-dlp ${{ steps.before.outputs.version }} → ${{ steps.after.outputs.version }}" - title: "chore: yt-dlp ${{ steps.before.outputs.version }} → ${{ steps.after.outputs.version }}" + commit-message: "chore(nix): yt-dlp ${{ steps.current.outputs.version }} → ${{ steps.latest.outputs.version }}" + title: "chore(nix): yt-dlp ${{ steps.current.outputs.version }} → ${{ steps.latest.outputs.version }}" body: | - Automated `flake.lock` update. yt-dlp bumped from `${{ steps.before.outputs.version }}` to `${{ steps.after.outputs.version }}`. + Automated bump of the pinned yt-dlp release. Old: `${{ steps.current.outputs.version }}`. New: `${{ steps.latest.outputs.version }}`. SRI hash: `${{ steps.hash.outputs.hash }}`. - Triggered by the weekly nixpkgs check. + Upstream release: https://github.com/yt-dlp/yt-dlp/releases/tag/${{ steps.latest.outputs.version }} labels: dependencies diff --git a/.gitignore b/.gitignore index 9c3bd3c..b951746 100644 --- a/.gitignore +++ b/.gitignore @@ -9,8 +9,17 @@ !docs/README* !docs/branding/ !docs/branding/** +# Dated design specs are tracked; docs/superpowers/plans/ stays ignored. +!docs/superpowers/ +!docs/superpowers/specs/ +!docs/superpowers/specs/** !crates !crates/** +# Static assets are built by Nix (frontendStatic derivation in flake.nix). +# Do not commit them; `nix build` produces them from frontend/src/. +crates/archivr-server/static/ +crates/archivr-server/static/** + !vendor !vendor/** @@ -42,5 +51,3 @@ frontend/result/** frontend/dist/ frontend/dist/** .DS_Store -frontend/storybook-static/ -frontend/storybook-static/** diff --git a/AGENTS.md b/AGENTS.md index 10b5963..0b92f12 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,13 +12,59 @@ Three crates with a strict ownership split — **core owns truth; CLI and server - `crates/archivr-core` — domain library: capture orchestration, SQLite schema/CRUD, downloaders, hashing. New archive features start here. - `crates/archivr-server` — Axum HTTP API + auth + static frontend serving. -- `crates/archivr-cli` — clap-based CLI (`archivr` binary): `init`, `archive` subcommands. +- `crates/archivr-cli` — clap-based CLI (`archivr` binary): `init`, `archive`, `yt-dlp status|update` 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/...`. `CaptureConfig` carries per-request toggles (uBlock, reader mode, Freedium mirror, YouTube subtitles, etc.); when `via_freedium` is set, the fetch URL is rewritten through `freedium-mirror.cfd` while the canonical DB URL stays the original locator. -YouTube playlists and channels produce a **parent container entry** with each video captured as a child entry. `downloader/ytdlp.rs` handles the playlist probe (fetching per-video quality metadata before archiving), the multi-video download loop, and sync mode (skipping already-archived videos when re-archiving a playlist or channel). +YouTube playlists and channels produce a **parent container entry** with each video captured as a child entry. `downloader/ytdlp.rs` handles the flat-playlist probe (fetching per-video quality metadata before archiving); the multi-video download loop and sync mode (skipping already-archived videos when re-archiving a playlist or channel) live in `capture.rs`. Child order is persisted in `archived_entries.position` (append-only at insert in `database::create_archived_entry`; rewritten only by `database::reorder_child_entries` behind `PUT …/entries/:entry_uid/children/order` (allowed roles = `InstanceSettings::reorder_children_role_bits`, default ADMIN|OWNER, Owner-editable)). -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). +YouTube videos (`Source::YouTubeVideo`: single videos and playlist/channel children; no other platform) also get up +to two subtitle tracks (English + original language, manual over auto, VTT/SRT) in the same yt-dlp call by default. +`CaptureConfig::download_subtitles` (manual `Default` = true) gates it; the API body field `download_subtitles` (absent += true), the CaptureDialog "Download subtitles" toggle and CLI `archivr archive --no-subtitles` feed it. Files are +archived into `raw/` and registered as `subtitle` artifacts (`crates/archivr-core/src/subtitles.rs`). Subtitle +failures are `eprintln!` warnings, never capture failures. + +Pasted text takes a much shorter path: `perform_text_capture()` (`capture.rs`) skips source detection +and every downloader shell-out — `downloader/text.rs` stages the body under `temp/`, hashes it, and the +blob lands in `raw/` like any other artifact. Entrypoint is +`POST /api/archives/:archive_id/captures/text`. Its body is byte-preserving, its entry has no fabricated +`original_url`, and its normal text preview opens from the entry rail. + +LLM summaries are a post-capture, manual-only subsystem: `crates/archivr-core/src/summarizer.rs` behind +`GET`/`POST /api/archives/:archive_id/entries/:entry_uid/summary`, cached in the `entry_summaries` +table per (entry, provider, model, prompt version, input hash). See `ARCHIVR-MENTAL-MODEL.md` for the +provider set and the status lifecycle. + +YouTube video entries are summarized from the best-ranked `subtitle` artifact reduced to a transcript, not the mp4. +With no usable track the POST still returns 202: the row is created `pending` with the placeholder `input_sha256` +`pending-subtitle-fetch`, and the background task fetches subtitles only (`build_summary_input_with_subtitle_fetch` → +`subtitles::fetch_subtitles_for_entry`), writes the real hash, then runs the provider. The order is fixed (user +requirement): archived subtitles → fetched subtitles → local transcription (`transcriber::transcribe_entry`, only when +the POST body names a `transcribe_engine` and both earlier steps gave nothing) → error. If there are still none, the +row fails with `NO_SUBTITLES_SUMMARY_MESSAGE` (or a transcription-specific copy when an engine ran) and no provider is +called. Transcripts are `subtitle` artifacts with `kind: "transcribed"`, `origin: "transcription"`, `engine`, `model`. +Spec + deviations: `docs/superpowers/specs/2026-10-05-local-transcription-fallback.md`. + +X thread titles: `POST /api/archives/:archive_id/entries/:entry_uid/thread-title` (`ROLE_USER`, same gate as title +PATCH; body `{provider}`) runs `thread_title::generate_thread_title` synchronously in one blocking task and saves +`Thread about — @author` via `database::update_entry_title`. Never touches `entry_summaries`. + +The requested provider model is the cache identity; a provider-returned resolved model is display attribution. +At startup, pending/running attempts interrupted by shutdown are failed. A regeneration keeps the previous completed +summary visible until its replacement completes; public readers receive completed content only, never diagnostics. + +`SummaryBuildOptions` keeps summaries text-only unless `include_images` is set. The input digest includes that flag and +the selected blobs' SHA-256, MIME types, and sizes, so a distinct image selection cannot reuse a text-only cache row. +Candidates are `media` artifacts only: `jpg`/`jpeg`, `png`, `webp`, `gif`, and `avif`, capped at four images, 5 MiB each, +and 12 MiB in aggregate. Anthropic HTTP, OpenAI-compatible HTTP, and Codex support images; Claude CLI does not. The core +remains synchronous: the server puts provider work in its blocking boundary rather than introducing async to +`archivr-core`. + +Entry free-text search includes summary text (and generated JSON tags inside it) from the latest completed summary only. +Pending and failed rows do not match, and a newer pending or failed request does not hide an older completed summary. + +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; per-action role masks (e.g. `reorder_children_role_bits`) and per-provider thread-title models (`title_model_`) live on its `instance_settings` row). The server mounts multiple archives from a TOML registry (`crates/archivr-server/src/registry.rs`); routes are parameterized by `:archive_id`. @@ -32,6 +78,7 @@ The server mounts multiple archives from a TOML registry (`crates/archivr-server | `frontend/src/` | React app: `App.jsx` (root state + custom routing), `api.js` (fetch client), `components/`, `styles.css` | | `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`) | +| `.github/workflows/` | `update-ytdlp.yml` — weekly cron that PRs a yt-dlp version bump into `flake.nix` | | `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. | @@ -48,12 +95,13 @@ cargo build --release -p archivr-server # Frontend (Bun, from frontend/) bun install bun run dev # Vite dev server -bun run build # → ../crates/archivr-server/static (served by the server) -bun run storybook # Storybook on :6006 +bun run build # → ../crates/archivr-server/static (gitignored; only needed for bare cargo run) # Nix -nix develop # devshell: yt-dlp, nushell, uv, twitter-api-client -nix build .#archivr-server # also .#archivr-cli, .#archivr-all +nix develop # devshell: yt-dlp, deno, nushell, uv, twitter-api-client +nix build .#archivr-server # also .#archivr-cli, .#archivr-all; builds frontend automatically + # If frontendDeps hash is stale (after bun.lock/package.json change): + # nix build 2>&1 | grep "got:" → paste hash into flake.nix frontendDepsHash # Docker docker compose up -d # port 8080; config in ./config/archivr-server.toml (see docker/config.example.toml) @@ -71,18 +119,49 @@ No CI is configured; no rustfmt.toml/clippy.toml — default `cargo fmt`/`clippy - **State**: Axum `AppState { registry, auth_db_path, login_attempts }` via `State` extractor; middleware stack = `setup_guard` (503 until owner exists) → `login_rate_limit` (5/15min per IP) → `security_headers`. - **Auth extraction**: `AuthUser` implements `FromRequestParts` — session cookie (`session`) or `Authorization: Bearer` token (stored SHA3-256-hashed). Passwords are Argon2. - **Logging**: `eprintln!` with `info:`/`warn:` prefixes. No `tracing`/`log` — don't add structured logging piecemeal. -- **External tools by env var**: `ARCHIVR_YT_DLP`, `ARCHIVR_CHROME`, `ARCHIVR_SINGLE_FILE`, `ARCHIVR_TWEET_PYTHON`, `ARCHIVR_TWEET_SCRAPER`, `ARCHIVR_STATIC_DIR`, `ARCHIVR_BIND`. Downloaders shell out to subprocesses; resolve binaries through these vars. -- **Frontend**: JSX (no TypeScript), PascalCase components in `frontend/src/components/`, kebab-case CSS classes, plain CSS with custom properties in `styles.css` (no Tailwind/CSS-in-JS). No router — `App.jsx` parses `window.location.pathname` + `history.pushState`. State = `useState` + one `AuthContext`; `sessionStorage` for refresh-resilient dialog state (see `CaptureDialog.jsx` job polling, 500ms). All API calls through `frontend/src/api.js` with relative `/api/*` URLs — add new endpoints there, not inline `fetch`. +- **External tools by env var**: `ARCHIVR_YT_DLP`, `ARCHIVR_DENO`, `ARCHIVR_JS_RUNTIME`, `ARCHIVR_CHROME`, `ARCHIVR_SINGLE_FILE`, `ARCHIVR_TWEET_PYTHON`, `ARCHIVR_TWEET_SCRAPER`, `ARCHIVR_STATIC_DIR`, `ARCHIVR_BIND`, `ARCHIVR_FFMPEG`. Local transcription: `ARCHIVR_TRANSCRIBE_ENGINES` (the gate; unset = off), `ARCHIVR_TRANSCRIBE_TIMEOUT` (default 3600s, whole job), `ARCHIVR_WHISPER_BACKEND` (`whisper_cpp`|`script`) / `ARCHIVR_WHISPER_CLI` / `ARCHIVR_WHISPER_MODEL` / `ARCHIVR_WHISPER_LANGUAGES`, `ARCHIVR_PARAKEET_CLI` / `ARCHIVR_PARAKEET_MODEL` / `ARCHIVR_PARAKEET_LANGUAGES`, `ARCHIVR_PHONON2_CLI` / `ARCHIVR_PHONON2_MODEL`. Downloaders shell out to subprocesses; resolve binaries through these vars. Env helpers (`required_env`, `env_or`, `optional_env`, `env_timeout`, `resolve_cli`) live in `env_config.rs`; bounded subprocesses go through `process::run_with_timeout` (yt-dlp calls keep `ytdlp.rs`'s private runner). +- **yt-dlp is resolved, not just read**: `ARCHIVR_YT_DLP` (set by the flake wrappers) is only the + *pinned candidate* handed to `resolve_yt_dlp()` (`downloader/ytdlp.rs`), which compares it against a + self-updated copy in the state dir. `ARCHIVR_YT_DLP_FORCE` (absolute path) bypasses that comparison + entirely; `archivr yt-dlp status` shows and chooses that forced candidate when it applies; `ARCHIVR_STATE_DIR` + relocates the state dir. The JS runtime works the same way: `resolve_js_runtime()` (`downloader/js_runtime.rs`) + picks the newest Deno ≥ 2.3.0 from `ARCHIVR_DENO` and `/deno/deno` (ties → state dir), then PATH; + `ARCHIVR_JS_RUNTIME=RUNTIME[:ABS_PATH]` forces it. Both caches are `RwLock`s refreshed by `refresh_yt_dlp()` / + `refresh_js_runtime()` after each successful component install (`ytdlp_tools::update_tools`), so a UI update needs + no restart; `resolve_js_runtime()` returns an owned `Option`. Never cache a resolved path across + operations. Never spawn bare yt-dlp — build every command with + `yt_dlp_command()` so the resolver-chosen binary *and* `--js-runtimes` args are applied. +- **LLM summaries by env var**: `ARCHIVR_ANTHROPIC_API_KEY` / `ARCHIVR_ANTHROPIC_URL` / `ARCHIVR_ANTHROPIC_MODEL`, `ARCHIVR_OPENAI_API_KEY` / `ARCHIVR_OPENAI_URL` / `ARCHIVR_OPENAI_MODEL`, `ARCHIVR_CLAUDE_CLI` / `ARCHIVR_CLAUDE_MODEL`, `ARCHIVR_CODEX_CLI` / `ARCHIVR_CODEX_MODEL`, plus `ARCHIVR_SUMMARY_HTTP_TIMEOUT` (default 120s) and `ARCHIVR_SUMMARY_CLI_TIMEOUT` (default 300s). Thread titles use `ARCHIVR_ANTHROPIC_TITLE_MODEL` (default `claude-haiku-4-5`), `ARCHIVR_OPENAI_TITLE_MODEL` (`gpt-4o-mini`), `ARCHIVR_CLAUDE_TITLE_MODEL` (`haiku`), `ARCHIVR_CODEX_TITLE_MODEL` (`gpt-6-luna`, override if unavailable) — never the summary model; `thread_title.rs` reuses provider transports via `summarizer::complete_plain`. The one exception to env-only config: admins may override the title model per provider in the auth DB (`instance_settings.title_model_{anthropic_http,openai_compatible,claude_cli,codex_cli}`; PATCH `/api/admin/instance-settings` trims, blank clears, rejects overlong or whitespace/control chars; GET returns `title_models.` with `model`/`source`/`fallback`). Precedence instance > `ARCHIVR_*_TITLE_MODEL` > default; the server passes the instance value to core as an `Option<&str>` override — core never reads the auth DB. Same convention as above — never TOML, which also keeps API keys out of anything the archive persists. Summaries are manual-only: nothing in `capture.rs` triggers them. The two CLI vars are optional overrides: unset, `resolve_cli()` auto-discovers well-known absolute installs first (`/opt/homebrew/bin/claude`, `/usr/local/bin/claude`; `/Applications/ChatGPT.app/Contents/Resources/codex`, `/opt/homebrew/bin/codex`, `/usr/local/bin/codex`), then `$HOME/.local/bin/`, then the bare name on PATH — the absolute defaults matter because the ChatGPT desktop app ships `codex` off PATH. Frontend static output is generated; never hand-edit `crates/archivr-server/static/`. +- **Frontend**: JSX (no TypeScript), PascalCase components in `frontend/src/components/`, kebab-case CSS classes, plain CSS with custom properties in `styles.css` (no Tailwind/CSS-in-JS). No router — `App.jsx` parses `window.location.pathname` + `history.pushState`. State = `useState` + one `AuthContext`; `sessionStorage` for refresh-resilient dialog state (see `CaptureDialog.jsx` job polling, 500ms). All API calls through `frontend/src/api.js` with relative `/api/*` URLs — add new endpoints there, not inline `fetch`. Summary polling and generate callbacks must remain scoped to the currently selected entry; image-inclusion behavior is unchanged. In-progress captures render through `SkeletonEntryRow.jsx`, which is a compact spinner + locator + "Archiving…" line (with a playlist/channel hint), **not** a grey skeleton block — don't reintroduce placeholder shimmer. Layout comes from semantic classes (e.g. `.capture-text-row` in `styles.css`), never from fallthrough on a generic row class; give a new row shape its own class. +- **CLI providers parse a file, not stdout**: `codex` is invoked as `codex exec --output-last-message -` (prompt on stdin) and the reply is read back from that file — raw stdout carries a runtime header, an echo of the user prompt, and a `tokens used` footer that the JSON extractor will happily mistake for the answer. There is a positional-prompt fallback for older builds that reject `-`. Keep any new CLI provider on the same "give me only the final message" contract. +- **Transcription engines follow the same rule**: whisper.cpp writes `-ovtt`; script engines (Whisper `script`, Parakeet) are run as `\ +

Hello world

"; + let text = strip_html(html); + assert!(text.contains("Hello world")); + assert!(!text.contains("color:red")); + assert!(!text.contains("var x")); + } + + #[test] + fn strip_html_keeps_paragraphs_apart() { + let text = strip_html("

One

Two

"); + // Without block-level break handling these would run together as + // "OneTwo", which reads as a single garbled sentence to the model. + assert!(text.contains("One")); + assert!(text.contains("Two")); + assert!(!text.contains("OneTwo")); + } + + #[test] + fn strip_html_decodes_common_entities() { + assert_eq!( + strip_html("

a & b  c

").replace('\u{a0}', " "), + "a & b c" + ); + } + + #[test] + fn extract_tweet_text_handles_flat_and_wrapped_shapes() { + let flat = serde_json::json!({ "full_text": "tweet body" }); + assert_eq!(extract_tweet_text(&flat).unwrap(), "tweet body"); + + let wrapped = serde_json::json!({ "tweet": { "text": "wrapped body" } }); + assert_eq!(extract_tweet_text(&wrapped).unwrap(), "wrapped body"); + + let threaded = serde_json::json!({ + "full_text": "first", + "thread": [{ "full_text": "second" }], + }); + assert_eq!(extract_tweet_text(&threaded).unwrap(), "first\n\nsecond"); + + assert!(extract_tweet_text(&serde_json::json!({ "id": 1 })).is_none()); + } + + #[test] + fn extract_tweet_text_prefers_x_article_plain_text_over_tco_body() { + let tweet = serde_json::json!({ + "full_text": "https://t.co/article", + "article": { "title": "Skin guide", "plain_text": "Use sunscreen daily." } + }); + assert_eq!( + extract_tweet_text(&tweet).as_deref(), + Some("Skin guide\n\nUse sunscreen daily.") + ); + } + + #[test] + fn extract_tweet_text_uses_article_blocks_when_plain_text_is_empty() { + let tweet = serde_json::json!({"article": { + "title": "Blocks", "plain_text": " ", + "blocks": [ + {"text": "First", "id": "ignored", "media_url": "https://example.test/image"}, + {"children": [{"text": "Second", "enabled": true}]} + ] + }}); + assert_eq!( + extract_tweet_text(&tweet).as_deref(), + Some("Blocks\n\nFirst\n\nSecond") + ); + } + + #[test] + fn extract_tweet_text_keeps_article_block_object_field_order() { + let tweet: serde_json::Value = serde_json::from_str( + r#"{ + "article": { + "title": "Ordered block", + "blocks": [{"heading": "Opening", "content": "Body copy"}] + } + }"#, + ) + .unwrap(); + assert_eq!( + extract_tweet_text(&tweet).as_deref(), + Some("Ordered block\n\nOpening\n\nBody copy") + ); + } + + #[test] + fn extract_tweet_text_falls_back_from_article_preview_to_summary_then_tweet_body() { + let preview = serde_json::json!({ + "full_text": "https://t.co/fallback", + "article": { "title": "Preview", "preview_text": "Preview copy", "summary_text": "Later" } + }); + assert_eq!( + extract_tweet_text(&preview).as_deref(), + Some("Preview\n\nPreview copy") + ); + + let summary = serde_json::json!({ + "full_text": "https://t.co/fallback", + "article": { "title": "Summary", "summary_text": "Summary copy" } + }); + assert_eq!( + extract_tweet_text(&summary).as_deref(), + Some("Summary\n\nSummary copy") + ); + + let empty_article = serde_json::json!({ + "full_text": "https://t.co/fallback", + "article": { "title": "Only a title", "blocks": [{"id": "not text"}] } + }); + assert_eq!( + extract_tweet_text(&empty_article).as_deref(), + Some("https://t.co/fallback") + ); + } + + #[test] + fn build_summary_input_joins_article_backed_tweet_artifacts() { + let temp = tempfile::tempdir().unwrap(); + let paths = crate::archive::initialize_archive( + temp.path(), + &temp.path().join("store"), + "Test archive", + false, + ) + .unwrap(); + let conn = database::open_or_initialize(&paths.archive_path).unwrap(); + let user_id = database::ensure_default_user(&conn).unwrap(); + let run = database::create_archive_run(&conn, user_id, 1).unwrap(); + let source_id = database::upsert_source_identity( + &conn, + "twitter", + "tweet_thread", + Some("thread-1"), + Some("https://x.com/example/status/1"), + "x:thread-1", + ) + .unwrap(); + let entry = database::create_archived_entry( + &conn, + &database::NewEntry { + source_identity_id: source_id, + archive_run_id: run.id, + parent_entry_id: None, + root_entry_id: None, + created_by_user_id: user_id, + owned_by_user_id: user_id, + source_kind: "twitter".to_string(), + entity_kind: "tweet_thread".to_string(), + title: Some("Article thread".to_string()), + visibility: "private".to_string(), + representation_kind: "tweet_thread".to_string(), + source_metadata_json: "{}".to_string(), + display_metadata_json: None, + }, + ) + .unwrap(); + + for (ordinal, (relpath, body)) in [ + ("raw_tweets/article-one.json", "First article body."), + ("raw_tweets/article-two.json", "Second article body."), + ] + .into_iter() + .enumerate() + { + let path = paths.store_path.join(relpath); + std::fs::write( + &path, + serde_json::json!({ + "full_text": format!("https://t.co/{ordinal}"), + "article": { "title": format!("Article {}", ordinal + 1), "plain_text": body } + }) + .to_string(), + ) + .unwrap(); + database::add_entry_artifact( + &conn, + &database::NewArtifact { + entry_id: entry.id, + artifact_role: "raw_tweet_json".to_string(), + storage_area: "raw_tweets".to_string(), + relpath: relpath.to_string(), + blob_id: None, + logical_path: None, + metadata_json: None, + }, + ) + .unwrap(); + } + + let input = + build_summary_input(&paths, &entry.entry_uid, SummaryBuildOptions::default()).unwrap(); + assert!( + input + .request + .content + .contains("First article body.\n\n---\n\nArticle 2\n\nSecond article body.") + ); + } + + fn summary_image_fixture() -> (tempfile::TempDir, ArchivePaths, database::ArchivedEntry) { + let temp = tempfile::tempdir().unwrap(); + let paths = crate::archive::initialize_archive( + temp.path(), + &temp.path().join("store"), + "Test archive", + false, + ) + .unwrap(); + let conn = database::open_or_initialize(&paths.archive_path).unwrap(); + let user_id = database::ensure_default_user(&conn).unwrap(); + let run = database::create_archive_run(&conn, user_id, 1).unwrap(); + let source_id = database::upsert_source_identity( + &conn, + "web", + "page", + Some("summary-image-test"), + Some("https://example.test/summary-image-test"), + "https://example.test/summary-image-test", + ) + .unwrap(); + let entry = database::create_archived_entry( + &conn, + &database::NewEntry { + source_identity_id: source_id, + archive_run_id: run.id, + parent_entry_id: None, + root_entry_id: None, + created_by_user_id: user_id, + owned_by_user_id: user_id, + source_kind: "web".to_string(), + entity_kind: "page".to_string(), + title: Some("Image test".to_string()), + visibility: "private".to_string(), + representation_kind: "webpage".to_string(), + source_metadata_json: "{}".to_string(), + display_metadata_json: None, + }, + ) + .unwrap(); + + let text_relpath = "raw/summary-image-test.txt"; + std::fs::write(paths.store_path.join(text_relpath), "Summary source text.").unwrap(); + database::add_entry_artifact( + &conn, + &database::NewArtifact { + entry_id: entry.id, + artifact_role: "primary_media".to_string(), + storage_area: "raw".to_string(), + relpath: text_relpath.to_string(), + blob_id: None, + logical_path: None, + metadata_json: None, + }, + ) + .unwrap(); + (temp, paths, entry) + } + + #[test] + fn unsupported_summary_content_errors_are_classified_without_relabeling_other_errors() { + let (_temp, paths, entry) = summary_image_fixture(); + let conn = database::open_or_initialize(&paths.archive_path).unwrap(); + conn.execute( + "DELETE FROM entry_artifacts WHERE entry_id = ?1", + [entry.id], + ) + .unwrap(); + + let no_artifact = + build_summary_input(&paths, &entry.entry_uid, SummaryBuildOptions::default()) + .unwrap_err(); + assert!(is_unsupported_summary_content_error(&no_artifact)); + + add_summary_image_artifact(&paths, entry.id, 99, "primary_media", "mp4", "video/mp4", 1); + let video = build_summary_input(&paths, &entry.entry_uid, SummaryBuildOptions::default()) + .unwrap_err(); + assert!(is_unsupported_summary_content_error(&video)); + + let conn = database::open_or_initialize(&paths.archive_path).unwrap(); + conn.execute( + "DELETE FROM entry_artifacts WHERE entry_id = ?1", + [entry.id], + ) + .unwrap(); + let empty_relpath = "raw/empty-summary.txt"; + std::fs::write(paths.store_path.join(empty_relpath), "").unwrap(); + database::add_entry_artifact( + &conn, + &database::NewArtifact { + entry_id: entry.id, + artifact_role: "primary_media".to_string(), + storage_area: "raw".to_string(), + relpath: empty_relpath.to_string(), + blob_id: None, + logical_path: None, + metadata_json: None, + }, + ) + .unwrap(); + drop(conn); + let empty_text = + build_summary_input(&paths, &entry.entry_uid, SummaryBuildOptions::default()) + .unwrap_err(); + assert!(is_unsupported_summary_content_error(&empty_text)); + + std::fs::remove_file(paths.store_path.join(empty_relpath)).unwrap(); + let read_error = + build_summary_input(&paths, &entry.entry_uid, SummaryBuildOptions::default()) + .unwrap_err(); + assert!(!is_unsupported_summary_content_error(&read_error)); + assert_eq!( + UNSUPPORTED_SUMMARY_CONTENT_MESSAGE, + "This entry can’t be summarized yet.\n\nIt doesn’t contain archived text that a summary provider can read. Summaries currently support text notes, web pages, X posts and threads, X Articles, and YouTube videos with subtitles. Other video, audio, and image-only entries need a transcript or text source." + ); + + assert!(!is_unsupported_summary_content_error(&anyhow!( + "provider timeout" + ))); + assert!(!is_unsupported_summary_content_error(&anyhow!( + "entry not found: {}", + entry.entry_uid + ))); + } + + fn video_summary_fixture( + source_kind: &str, + ) -> (tempfile::TempDir, ArchivePaths, String, i64) { + video_summary_fixture_with_url( + source_kind, + &format!("https://{source_kind}.example/watch?v=abc123"), + ) + } + + fn video_summary_fixture_with_url( + source_kind: &str, + url: &str, + ) -> (tempfile::TempDir, ArchivePaths, String, i64) { + let temp = tempfile::tempdir().unwrap(); + let paths = crate::archive::initialize_archive( + temp.path(), + &temp.path().join("store"), + "Test archive", + false, + ) + .unwrap(); + let conn = database::open_or_initialize(&paths.archive_path).unwrap(); + let user_id = database::ensure_default_user(&conn).unwrap(); + let run = database::create_archive_run(&conn, user_id, 1).unwrap(); + let source_id = database::upsert_source_identity( + &conn, + source_kind, + "video", + Some("abc123"), + Some(url), + &format!("{source_kind}:abc123"), + ) + .unwrap(); + let entry = database::create_archived_entry( + &conn, + &database::NewEntry { + source_identity_id: source_id, + archive_run_id: run.id, + parent_entry_id: None, + root_entry_id: None, + created_by_user_id: user_id, + owned_by_user_id: user_id, + source_kind: source_kind.to_string(), + entity_kind: "video".to_string(), + title: Some("A video".to_string()), + visibility: "private".to_string(), + representation_kind: "video".to_string(), + source_metadata_json: "{}".to_string(), + display_metadata_json: None, + }, + ) + .unwrap(); + drop(conn); + add_summary_image_artifact(&paths, entry.id, 0, "primary_media", "mp4", "video/mp4", 1); + (temp, paths, entry.entry_uid, entry.id) + } + + fn youtube_summary_fixture() -> (tempfile::TempDir, ArchivePaths, String, i64) { + video_summary_fixture("youtube") + } + + /// YouTube video whose canonical URL is not http(s), so the on-demand + /// subtitle fetch returns without spawning yt-dlp. + fn youtube_offline_summary_fixture() -> (tempfile::TempDir, ArchivePaths, String, i64) { + video_summary_fixture_with_url("youtube", "youtube-test:offline") + } + + fn add_subtitle_artifact( + paths: &ArchivePaths, + entry_id: i64, + name: &str, + body: &str, + language: &str, + kind: &str, + ) { + let relpath = format!("raw/{name}"); + std::fs::write(paths.store_path.join(&relpath), body).unwrap(); + let format = if name.ends_with(".srt") { "srt" } else { "vtt" }; + let conn = database::open_or_initialize(&paths.archive_path).unwrap(); + database::add_entry_artifact( + &conn, + &database::NewArtifact { + entry_id, + artifact_role: subtitles::SUBTITLE_ARTIFACT_ROLE.to_string(), + storage_area: "raw".to_string(), + relpath, + blob_id: None, + logical_path: None, + metadata_json: Some( + serde_json::json!({ + "language": language, + "kind": kind, + "format": format, + "original_language": "en", + "origin": "capture", + }) + .to_string(), + ), + }, + ) + .unwrap(); + } + + const AUTO_EN_VTT: &str = "WEBVTT\nKind: captions\nLanguage: en\n\n00:00:00.000 --> 00:00:02.000 align:start position:0%\nauto caption words\n \n\n00:00:02.000 --> 00:00:04.000\nmore auto words\n"; + const MANUAL_EN_SRT: &str = + "1\n00:00:00,000 --> 00:00:02,000\nManual line one.\n\n2\n00:00:02,000 --> 00:00:04,000\nManual line two.\n"; + + #[test] + fn youtube_summary_uses_best_subtitle_track_transcript() { + let (_temp, paths, entry_uid, entry_id) = youtube_summary_fixture(); + add_subtitle_artifact(&paths, entry_id, "auto.en.vtt", AUTO_EN_VTT, "en", "auto"); + add_subtitle_artifact(&paths, entry_id, "manual.en.srt", MANUAL_EN_SRT, "en", "manual"); + + let input = + build_summary_input(&paths, &entry_uid, SummaryBuildOptions::default()).unwrap(); + let content = &input.request.content; + assert!( + content.starts_with("Transcript (en, manual subtitles):"), + "got: {content}" + ); + assert!(content.contains("Manual line one.")); + assert!(content.contains("Manual line two.")); + assert!(!content.contains("auto caption")); + assert!(!content.contains("-->")); + } + + #[test] + fn youtube_summary_skips_unusable_tracks_and_labels_unknown_language() { + let (_temp, paths, entry_uid, entry_id) = youtube_summary_fixture(); + // Better-ranked but empty manual track must be skipped. + add_subtitle_artifact(&paths, entry_id, "empty.en.srt", "", "en", "manual"); + add_subtitle_artifact(&paths, entry_id, "auto.vtt", AUTO_EN_VTT, "", "auto"); + + let input = + build_summary_input(&paths, &entry_uid, SummaryBuildOptions::default()).unwrap(); + assert!( + input + .request + .content + .starts_with("Transcript (unknown language, auto subtitles):\nauto caption words") + ); + } + + #[test] + fn youtube_summary_digest_changes_when_subtitles_added() { + let (_temp, paths, entry_uid, entry_id) = youtube_summary_fixture(); + add_subtitle_artifact(&paths, entry_id, "auto.en.vtt", AUTO_EN_VTT, "en", "auto"); + let sha1 = build_summary_input(&paths, &entry_uid, SummaryBuildOptions::default()) + .unwrap() + .input_sha256; + add_subtitle_artifact(&paths, entry_id, "manual.en.srt", MANUAL_EN_SRT, "en", "manual"); + let sha2 = build_summary_input(&paths, &entry_uid, SummaryBuildOptions::default()) + .unwrap() + .input_sha256; + assert_ne!(sha1, sha2); + } + + #[test] + fn youtube_summary_without_subtitles_is_no_subtitles_error() { + let (_temp, paths, entry_uid, _entry_id) = youtube_summary_fixture(); + let err = build_summary_input(&paths, &entry_uid, SummaryBuildOptions::default()) + .unwrap_err(); + assert!(is_no_subtitles_error(&err)); + assert!(!is_unsupported_summary_content_error(&err)); + } + + #[test] + fn non_youtube_video_still_unsupported() { + let (_temp, paths, entry_uid, entry_id) = video_summary_fixture("tiktok"); + // Subtitle artifacts are ignored outside YouTube videos. + add_subtitle_artifact(&paths, entry_id, "manual.en.srt", MANUAL_EN_SRT, "en", "manual"); + let err = build_summary_input(&paths, &entry_uid, SummaryBuildOptions::default()) + .unwrap_err(); + assert!(is_unsupported_summary_content_error(&err)); + assert!(!is_no_subtitles_error(&err)); + } + + #[test] + fn no_subtitles_error_classification() { + assert!(is_no_subtitles_error(&no_subtitles_error())); + assert!(is_no_subtitles_error( + &no_subtitles_error().context("while building summary input") + )); + assert_eq!(no_subtitles_error().to_string(), "no subtitles available"); + assert!(!is_no_subtitles_error(&unsupported_summary_content_error())); + assert!(!is_no_subtitles_error(&anyhow!("provider timeout"))); + assert!(!is_unsupported_summary_content_error(&no_subtitles_error())); + } + + fn add_summary_image_artifact( + paths: &ArchivePaths, + entry_id: i64, + ordinal: usize, + role: &str, + extension: &str, + mime_type: &str, + byte_size: u64, + ) { + let conn = database::open_or_initialize(&paths.archive_path).unwrap(); + let relpath = format!("raw/summary-image-{ordinal}.{extension}"); + std::fs::write(paths.store_path.join(&relpath), "image fixture").unwrap(); + let blob_id = database::upsert_blob( + &conn, + &database::BlobRecord { + sha256: format!("summary-image-{ordinal:02}"), + byte_size: byte_size.try_into().unwrap(), + mime_type: Some(mime_type.to_string()), + extension: Some(extension.to_string()), + raw_relpath: relpath.clone(), + }, + ) + .unwrap(); + database::add_entry_artifact( + &conn, + &database::NewArtifact { + entry_id, + artifact_role: role.to_string(), + storage_area: "raw".to_string(), + relpath, + blob_id: Some(blob_id), + logical_path: None, + metadata_json: None, + }, + ) + .unwrap(); + } + + #[test] + fn summary_image_selection_filters_candidates_and_stops_at_image_count() { + let (_temp, paths, entry) = summary_image_fixture(); + add_summary_image_artifact(&paths, entry.id, 0, "avatar", "jpg", "image/jpeg", 1); + add_summary_image_artifact(&paths, entry.id, 1, "video", "mp4", "video/mp4", 1); + add_summary_image_artifact(&paths, entry.id, 2, "audio", "mp3", "audio/mpeg", 1); + add_summary_image_artifact(&paths, entry.id, 3, "media", "svg", "image/svg+xml", 1); + add_summary_image_artifact(&paths, entry.id, 4, "media", "png", "image/jpeg", 1); + add_summary_image_artifact( + &paths, + entry.id, + 5, + "media", + "jpg", + "image/jpeg", + MAX_SUMMARY_IMAGE_BYTES + 1, + ); + add_summary_image_artifact(&paths, entry.id, 6, "media", "jpg", "image/jpeg", 1); + add_summary_image_artifact(&paths, entry.id, 7, "media", "png", "image/png", 2); + add_summary_image_artifact(&paths, entry.id, 8, "media", "webp", "image/webp", 3); + add_summary_image_artifact(&paths, entry.id, 9, "media", "gif", "image/gif", 4); + add_summary_image_artifact(&paths, entry.id, 10, "media", "avif", "image/avif", 5); + + let text_only = build_summary_input( + &paths, + &entry.entry_uid, + SummaryBuildOptions { + include_images: false, + }, + ) + .unwrap(); + let visual = build_summary_input( + &paths, + &entry.entry_uid, + SummaryBuildOptions { + include_images: true, + }, + ) + .unwrap(); + + assert!(text_only.request.images.is_empty()); + assert_ne!(text_only.input_sha256, visual.input_sha256); + assert_eq!(visual.request.images.len(), MAX_SUMMARY_IMAGES); + assert_eq!( + visual + .request + .images + .iter() + .map(|image| image.sha256.as_str()) + .collect::>(), + vec![ + "summary-image-06", + "summary-image-07", + "summary-image-08", + "summary-image-09", + ] + ); + assert!( + visual + .request + .images + .iter() + .all(|image| image.byte_size <= MAX_SUMMARY_IMAGE_BYTES) + ); + } + + #[test] + fn summary_image_selection_enforces_aggregate_limit_and_keeps_scanning() { + let (_temp, paths, entry) = summary_image_fixture(); + add_summary_image_artifact( + &paths, + entry.id, + 0, + "media", + "jpg", + "image/jpeg", + 4 * 1024 * 1024, + ); + add_summary_image_artifact( + &paths, + entry.id, + 1, + "media", + "png", + "image/png", + 4 * 1024 * 1024, + ); + add_summary_image_artifact( + &paths, + entry.id, + 2, + "media", + "webp", + "image/webp", + 4 * 1024 * 1024, + ); + add_summary_image_artifact(&paths, entry.id, 3, "media", "gif", "image/gif", 1); + + let visual = build_summary_input( + &paths, + &entry.entry_uid, + SummaryBuildOptions { + include_images: true, + }, + ) + .unwrap(); + + assert_eq!(visual.request.images.len(), 3); + assert_eq!( + visual + .request + .images + .iter() + .map(|image| image.byte_size) + .sum::(), + MAX_SUMMARY_IMAGE_TOTAL_BYTES + ); + assert!( + visual + .request + .images + .iter() + .all(|image| image.sha256 != "summary-image-03") + ); + } + + #[test] + fn summarize_entry_uses_requested_alias_for_cache_and_response_model_for_display() { + struct ResolvedModelProvider; + + impl SummaryProvider for ResolvedModelProvider { + fn kind(&self) -> &'static str { + "anthropic_http" + } + fn model(&self) -> Option<&str> { + Some("claude-3-5-sonnet-latest") + } + fn summarize(&self, _: &SummaryRequest) -> Result { + Ok(SummaryOutput { + text: r#"{"tldr":"t","summary":"s","tags":[]}"#.to_string(), + model: Some("claude-3-5-sonnet-20241022".to_string()), + }) + } + } + + let (_temp, paths, entry) = summary_image_fixture(); + let record = summarize_entry( + &paths, + &entry.entry_uid, + SummaryBuildOptions::default(), + &ResolvedModelProvider, + "v1", + ) + .unwrap(); + + assert_eq!( + record.provider_model.as_deref(), + Some("claude-3-5-sonnet-latest") + ); + assert_eq!( + record.resolved_model.as_deref(), + Some("claude-3-5-sonnet-20241022") + ); + } + + // ── Output normalization ─────────────────────────────────────────────── + + #[test] + fn normalize_summary_json_passes_through_clean_json() { + let raw = r#"{"tldr":"t","summary":"s","tags":["a"]}"#; + let v: serde_json::Value = serde_json::from_str(&normalize_summary_json(raw)).unwrap(); + assert_eq!(v["tldr"], "t"); + assert_eq!(v["tags"][0], "a"); + } + + #[test] + fn normalize_summary_json_strips_markdown_fences() { + let raw = "```json\n{\"tldr\":\"t\",\"summary\":\"s\",\"tags\":[]}\n```"; + let v: serde_json::Value = serde_json::from_str(&normalize_summary_json(raw)).unwrap(); + assert_eq!(v["summary"], "s"); + } + + #[test] + fn normalize_summary_json_recovers_json_wrapped_in_prose() { + let raw = + "Sure! Here you go:\n{\"tldr\":\"t\",\"summary\":\"s\",\"tags\":[]}\nHope that helps."; + let v: serde_json::Value = serde_json::from_str(&normalize_summary_json(raw)).unwrap(); + assert_eq!(v["tldr"], "t"); + } + + #[test] + fn normalize_summary_json_wraps_unparseable_output_rather_than_losing_it() { + // A model that ignored the format instruction still produced something + // a human can read; discarding it would be worse than a missing tldr. + let v: serde_json::Value = + serde_json::from_str(&normalize_summary_json("just prose")).unwrap(); + assert_eq!(v["summary"], "just prose"); + assert_eq!(v["tldr"], ""); + } + + // ── CLI runner ───────────────────────────────────────────────────────── + + #[test] + fn run_cli_round_trips_stdin_to_stdout() { + // `cat` stands in for a provider CLI: it proves the prompt reaches the + // child's stdin and the child's stdout comes back intact. + let out = run_cli(Path::new("cat"), &[], "prompt text", 30).unwrap(); + assert_eq!(out, "prompt text"); + } + + #[test] + fn run_cli_kills_a_child_that_overruns_its_timeout() { + let err = run_cli(Path::new("sleep"), &["30"], "", 1) + .unwrap_err() + .to_string(); + assert!(err.contains("timed out"), "got: {err}"); + } + + #[test] + fn run_cli_reports_a_nonzero_exit() { + let err = run_cli(Path::new("false"), &[], "", 30) + .unwrap_err() + .to_string(); + assert!(err.contains("exited with"), "got: {err}"); + } + + #[test] + fn anthropic_plain_body_uses_system_field_and_max_tokens() { + let body = anthropic_plain_body("claude-haiku-4-5", "sys prompt", "user text", 64); + assert_eq!(body["model"], "claude-haiku-4-5"); + assert_eq!(body["system"], "sys prompt"); + assert_eq!(body["max_tokens"], 64); + let messages = body["messages"].as_array().unwrap(); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0]["role"], "user"); + assert_eq!(messages[0]["content"], "user text"); + assert!(!body.to_string().contains(SYSTEM_PROMPT.trim())); + } + + #[test] + fn openai_plain_body_has_system_then_user() { + let body = openai_plain_body("gpt-4o-mini", "sys prompt", "user text"); + assert_eq!(body["model"], "gpt-4o-mini"); + let messages = body["messages"].as_array().unwrap(); + assert_eq!(messages.len(), 2); + assert_eq!(messages[0]["role"], "system"); + assert_eq!(messages[0]["content"], "sys prompt"); + assert_eq!(messages[1]["role"], "user"); + assert_eq!(messages[1]["content"], "user text"); + assert!(body.get("max_tokens").is_none()); + } + + #[test] + fn claude_cli_args_append_model_only_when_set() { + assert_eq!(claude_cli_args(None), vec!["-p", "--output-format", "text"]); + assert_eq!( + claude_cli_args(Some("haiku")), + vec!["-p", "--output-format", "text", "--model", "haiku"] + ); + } + + // ── Local transcription fallback ─────────────────────────────────────── + + /// In-process engine: writes `vtt` as the transcript and counts calls. + struct FakeTranscriber { + kind: &'static str, + label: &'static str, + vtt: &'static str, + languages: Option>, + calls: std::sync::Arc, + } + + impl transcriber::Transcriber for FakeTranscriber { + fn kind(&self) -> &'static str { + self.kind + } + fn label(&self) -> &'static str { + self.label + } + fn model(&self) -> &str { + "fake-model" + } + fn timeout_secs(&self) -> u64 { + 30 + } + fn supports_language(&self, original_language: Option<&str>) -> bool { + match (&self.languages, original_language) { + (Some(allowed), Some(lang)) => { + allowed.contains(&crate::downloader::ytdlp::language_base(lang)) + } + _ => true, + } + } + fn supported_languages(&self) -> Option<&[String]> { + self.languages.as_deref() + } + fn transcribe( + &self, + _audio_wav: &Path, + _lang_hint: Option<&str>, + out_dir: &Path, + _deadline: std::time::Instant, + ) -> Result { + self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let vtt_path = out_dir.join("transcript.vtt"); + std::fs::write(&vtt_path, self.vtt)?; + Ok(transcriber::TranscriptOutput { + vtt_path, + language: Some("en".to_string()), + }) + } + } + + const FAKE_TRANSCRIPT_VTT: &str = + "WEBVTT\n\n00:00:00.000 --> 00:00:02.000\nlocally transcribed words\n"; + + fn fake_transcription( + bin_dir: &Path, + kind: &'static str, + label: &'static str, + languages: Option>, + ) -> ( + transcriber::TranscriptionRequest, + std::sync::Arc, + ) { + let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let ffmpeg = transcriber::test_support::write_stub_script( + bin_dir, + "ffmpeg", + transcriber::test_support::STUB_FFMPEG, + ); + let request = transcriber::TranscriptionRequest { + transcriber: Box::new(FakeTranscriber { + kind, + label, + vtt: FAKE_TRANSCRIPT_VTT, + languages, + calls: calls.clone(), + }), + settings: transcriber::TranscriptionSettings { ffmpeg }, + }; + (request, calls) + } + + fn transcription_lock() -> std::sync::MutexGuard<'static, ()> { + transcriber::TRANSCRIBE_TEST_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()) + } + + #[cfg(unix)] + #[test] + fn subtitle_fetch_with_transcriber_uses_transcribed_track() { + let _lock = transcription_lock(); + let (temp, paths, entry_uid, _entry_id) = youtube_offline_summary_fixture(); + let (request, calls) = fake_transcription(temp.path(), "whisper", "Whisper", None); + let input = build_summary_input_with_subtitle_fetch( + &paths, + &entry_uid, + SummaryBuildOptions::default(), + &[], + Some(&request), + ) + .unwrap(); + let content = &input.request.content; + assert!( + content.starts_with("Transcript (en, transcribed subtitles):"), + "got: {content}" + ); + assert!(content.contains("locally transcribed words")); + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1); + assert_ne!(input.input_sha256, SUBTITLE_FETCH_PENDING_INPUT_SHA256); + } + + #[test] + fn subtitle_fetch_without_transcriber_keeps_no_subtitles_error() { + let (_temp, paths, entry_uid, _entry_id) = youtube_offline_summary_fixture(); + let err = build_summary_input_with_subtitle_fetch( + &paths, + &entry_uid, + SummaryBuildOptions::default(), + &[], + None, + ) + .unwrap_err(); + assert!(is_no_subtitles_error(&err)); + assert_eq!(transcriber::transcription_user_message(&err), None); + } + + #[cfg(unix)] + #[test] + fn transcriber_not_called_when_usable_subtitles_exist() { + let _lock = transcription_lock(); + let (temp, paths, entry_uid, entry_id) = youtube_offline_summary_fixture(); + add_subtitle_artifact(&paths, entry_id, "auto.en.vtt", AUTO_EN_VTT, "en", "auto"); + let (request, calls) = fake_transcription(temp.path(), "whisper", "Whisper", None); + let input = build_summary_input_with_subtitle_fetch( + &paths, + &entry_uid, + SummaryBuildOptions::default(), + &[], + Some(&request), + ) + .unwrap(); + assert!(input.request.content.starts_with("Transcript (en, auto subtitles):")); + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0); + } + + #[cfg(unix)] + #[test] + fn youtube_summary_digest_changes_when_transcript_added() { + let _lock = transcription_lock(); + let (temp, paths, entry_uid, entry_id) = youtube_offline_summary_fixture(); + let (request, _calls) = fake_transcription(temp.path(), "whisper", "Whisper", None); + let transcribed = build_summary_input_with_subtitle_fetch( + &paths, + &entry_uid, + SummaryBuildOptions::default(), + &[], + Some(&request), + ) + .unwrap(); + // A rebuild reuses the stored transcript: same digest, no second run. + let rebuilt = + build_summary_input(&paths, &entry_uid, SummaryBuildOptions::default()).unwrap(); + assert_eq!(transcribed.input_sha256, rebuilt.input_sha256); + // A better-ranked manual track replaces it and changes the digest. + add_subtitle_artifact(&paths, entry_id, "manual.en.srt", MANUAL_EN_SRT, "en", "manual"); + let manual = + build_summary_input(&paths, &entry_uid, SummaryBuildOptions::default()).unwrap(); + assert_ne!(transcribed.input_sha256, manual.input_sha256); + } + + #[cfg(unix)] + #[test] + fn phonon2_non_english_original_language_fails_before_audio_work() { + let _lock = transcription_lock(); + let (temp, paths, entry_uid, entry_id) = youtube_offline_summary_fixture(); + // An unusable (empty) track whose metadata records the original language. + std::fs::write(paths.store_path.join("raw/empty.de.vtt"), "WEBVTT\n").unwrap(); + let conn = database::open_or_initialize(&paths.archive_path).unwrap(); + database::add_entry_artifact( + &conn, + &database::NewArtifact { + entry_id, + artifact_role: subtitles::SUBTITLE_ARTIFACT_ROLE.to_string(), + storage_area: "raw".to_string(), + relpath: "raw/empty.de.vtt".to_string(), + blob_id: None, + logical_path: None, + metadata_json: Some( + serde_json::json!({ + "language": "de", + "kind": "auto", + "format": "vtt", + "original_language": "de", + "origin": "capture", + }) + .to_string(), + ), + }, + ) + .unwrap(); + drop(conn); + let (request, calls) = fake_transcription( + temp.path(), + "phonon2", + "Phonon-2", + Some(vec!["en".to_string()]), + ); + let err = build_summary_input_with_subtitle_fetch( + &paths, + &entry_uid, + SummaryBuildOptions::default(), + &[], + Some(&request), + ) + .unwrap_err(); + let msg = transcriber::transcription_user_message(&err).unwrap(); + assert_eq!( + msg, + transcriber::transcription_language_unsupported_message( + "Phonon-2", + "de", + &["en".to_string()] + ) + ); + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0); + let temp_dir = paths.store_path.join("temp"); + assert!(std::fs::read_dir(&temp_dir).map_or(true, |rd| rd + .flatten() + .all(|e| !e.file_name().to_string_lossy().starts_with("transcribe-")))); + } + + #[cfg(unix)] + #[test] + fn empty_transcript_after_transcription_is_no_speech_copy() { + let _lock = transcription_lock(); + let (temp, paths, entry_uid, _entry_id) = youtube_offline_summary_fixture(); + let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let ffmpeg = transcriber::test_support::write_stub_script( + temp.path(), + "ffmpeg", + transcriber::test_support::STUB_FFMPEG, + ); + let request = transcriber::TranscriptionRequest { + transcriber: Box::new(FakeTranscriber { + kind: "whisper", + label: "Whisper", + vtt: "WEBVTT\n\n00:00:00.000 --> 00:00:01.000\n \n", + languages: None, + calls: calls.clone(), + }), + settings: transcriber::TranscriptionSettings { ffmpeg }, + }; + let err = build_summary_input_with_subtitle_fetch( + &paths, + &entry_uid, + SummaryBuildOptions::default(), + &[], + Some(&request), + ) + .unwrap_err(); + assert_eq!( + transcriber::transcription_user_message(&err).as_deref(), + Some(NO_SUBTITLES_AFTER_TRANSCRIPTION_MESSAGE) + ); + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1); + } +} diff --git a/crates/archivr-core/src/thread_title.rs b/crates/archivr-core/src/thread_title.rs new file mode 100644 index 0000000..9245e24 --- /dev/null +++ b/crates/archivr-core/src/thread_title.rs @@ -0,0 +1,650 @@ +//! On-demand titles for archived X threads. +//! +//! A user asks for a title from the entry rail; the ordered thread text is sent +//! to the selected summary provider with a cheap per-provider model and the +//! result is saved as `Thread about — @author` (just `Thread about +//! ` when the author is unknown). +//! +//! The title model never inherits the summary model (`ARCHIVR_*_MODEL`). It is +//! resolved as: the admin's instance setting (passed in by the caller; core +//! never reads the auth DB) > `ARCHIVR_ANTHROPIC_TITLE_MODEL` / +//! `ARCHIVR_OPENAI_TITLE_MODEL` / `ARCHIVR_CLAUDE_TITLE_MODEL` / +//! `ARCHIVR_CODEX_TITLE_MODEL` > a built-in small default. Endpoint, key, CLI +//! path and timeout still come from `summarizer::provider_from_env`. +//! +//! The model returns only the topic phrase; the server builds the rest so the +//! title format (prefix and author suffix) is guaranteed regardless of output. + +use anyhow::{Context, Result, bail}; +use rusqlite::OptionalExtension; +use std::path::Path; + +use crate::archive::ArchivePaths; +use crate::database; +use crate::env_config::optional_env; +use crate::summarizer::{self, ProviderConfig}; + +pub const TITLE_MAX_TOKENS: u32 = 64; +const MAX_TITLE_INPUT_CHARS: usize = 8_000; +const MAX_TOPIC_WORDS: usize = 10; +const MAX_TOPIC_CHARS: usize = 80; + +const TITLE_SYSTEM_PROMPT: &str = "You name archived X (Twitter) threads for a personal archive index. Reply with ONLY a short topic phrase of 3 to 8 words that completes the sentence 'Thread about …' (for example: migrating a home server to NixOS). Plain text on one line: no quotes, no markdown, no hashtags, no emoji, no @mentions, no trailing punctuation, and do not repeat the words 'Thread about'."; + +const QUOTE_CHARS: &[char] = &['"', '\'', '`', '“', '”', '‘', '’', '«', '»', '*', '_', '#']; +const TRAILING_PUNCT: &[char] = &['.', ',', ';', ':', '!', '?', '…']; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ThreadTitleInput { + pub entry_uid: String, + /// Empty when no status JSON names the author. + pub author: String, + pub content: String, +} + +pub fn title_model_env(kind: &str) -> Option<&'static str> { + match kind { + "anthropic_http" => Some("ARCHIVR_ANTHROPIC_TITLE_MODEL"), + "openai_compatible" => Some("ARCHIVR_OPENAI_TITLE_MODEL"), + "claude_cli" => Some("ARCHIVR_CLAUDE_TITLE_MODEL"), + "codex_cli" => Some("ARCHIVR_CODEX_TITLE_MODEL"), + _ => None, + } +} + +pub fn default_title_model(kind: &str) -> Option<&'static str> { + match kind { + "anthropic_http" => Some("claude-haiku-4-5"), + "openai_compatible" => Some("gpt-4o-mini"), + "claude_cli" => Some("haiku"), + "codex_cli" => Some("gpt-6-luna"), + _ => None, + } +} + +pub fn with_title_model(cfg: ProviderConfig, model: String) -> ProviderConfig { + match cfg { + ProviderConfig::AnthropicHttp(mut c) => { + c.model = model; + ProviderConfig::AnthropicHttp(c) + } + ProviderConfig::OpenAiCompatible(mut c) => { + c.model = model; + ProviderConfig::OpenAiCompatible(c) + } + ProviderConfig::ClaudeCli(mut c) => { + c.model = Some(model); + ProviderConfig::ClaudeCli(c) + } + ProviderConfig::CodexCli(mut c) => { + c.model = Some(model); + ProviderConfig::CodexCli(c) + } + } +} + +/// Where an effective title model came from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TitleModelSource { + Instance, + Env, + Default, +} + +impl TitleModelSource { + pub fn as_str(self) -> &'static str { + match self { + Self::Instance => "instance", + Self::Env => "env", + Self::Default => "default", + } + } +} + +/// Effective title model for `kind`: non-empty trimmed `instance_override` > +/// non-empty title env var > built-in default. `None` for unknown kinds. +pub fn resolve_title_model( + kind: &str, + instance_override: Option<&str>, +) -> Option<(String, TitleModelSource)> { + let (var, default) = (title_model_env(kind)?, default_title_model(kind)?); + if let Some(m) = instance_override.map(str::trim).filter(|m| !m.is_empty()) { + return Some((m.to_string(), TitleModelSource::Instance)); + } + if let Some(m) = optional_env(var).map(|m| m.trim().to_string()).filter(|m| !m.is_empty()) { + return Some((m, TitleModelSource::Env)); + } + Some((default.to_string(), TitleModelSource::Default)) +} + +/// Provider config for title generation: transport settings from the summary +/// env, model from [`resolve_title_model`]. +pub fn title_provider_from_env( + kind: &str, + instance_override: Option<&str>, +) -> Result { + // Validates `kind` and keeps the summary path's missing-key messages. + let cfg = summarizer::provider_from_env(kind)?; + let Some((model, _)) = resolve_title_model(kind, instance_override) else { + bail!("unknown summary provider: {kind}"); + }; + Ok(with_title_model(cfg, model)) +} + +/// Expected, user-facing failure of [`load_thread_title_input`] (entry is not a +/// thread, or has no archived text). Anything else is an internal error. +#[derive(Debug)] +pub struct ThreadTitleUserError(pub String); + +impl std::fmt::Display for ThreadTitleUserError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for ThreadTitleUserError {} + +/// The [`ThreadTitleUserError`] message carried by `error`, if any. +pub fn thread_title_user_message(error: &anyhow::Error) -> Option { + error.downcast_ref::().map(|m| m.0.clone()) +} + +/// Loads the thread text and author. `Ok(None)` means the entry does not exist. +pub fn load_thread_title_input( + paths: &ArchivePaths, + entry_uid: &str, +) -> Result> { + let conn = database::open_or_initialize(&paths.archive_path)?; + let Some((id, entity_kind, source_metadata_json)) = conn + .query_row( + "SELECT id, entity_kind, source_metadata_json FROM archived_entries WHERE entry_uid = ?1", + [entry_uid], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?)), + ) + .optional()? + else { + return Ok(None); + }; + if entity_kind != "tweet_thread" { + return Err(anyhow::Error::new(ThreadTitleUserError(format!( + "entry is '{entity_kind}', not an X thread; titles can only be generated for threads" + )))); + } + + let content = summarizer::artifact_text_content(&conn, &paths.store_path, id, &entity_kind) + .map_err(|e| { + if summarizer::is_unsupported_summary_content_error(&e) { + anyhow::Error::new(ThreadTitleUserError( + "this thread has no archived text to generate a title from".to_string(), + )) + } else { + e + } + })?; + let content: String = content.chars().take(MAX_TITLE_INPUT_CHARS).collect(); + + let root_tweet_id = serde_json::from_str::(&source_metadata_json) + .ok() + .and_then(|v| v["tweet_id"].as_str().map(str::to_string)); + let author = thread_author( + &conn, + &paths.store_path, + id, + root_tweet_id.as_deref(), + entry_uid, + )?; + + Ok(Some(ThreadTitleInput { + entry_uid: entry_uid.to_string(), + author, + content, + })) +} + +/// Author of the root status (`tweet-.json`, as in +/// capture's `Thread by @…`), else of the first readable status JSON. +fn thread_author( + conn: &rusqlite::Connection, + store_path: &Path, + entry_id: i64, + root_tweet_id: Option<&str>, + entry_uid: &str, +) -> Result { + let root_file = root_tweet_id.map(|id| format!("tweet-{id}.json")); + for role in ["raw_tweet_json", "primary_media"] { + let mut artifacts: Vec<_> = database::list_entry_artifacts_by_role(conn, entry_id, role)? + .into_iter() + .filter(|a| a.relpath.ends_with(".json")) + .collect(); + if artifacts.is_empty() { + continue; + } + // Root status first; the rest keep insertion order (stable sort). + if let Some(root_file) = root_file.as_deref() { + artifacts.sort_by_key(|a| { + !(Path::new(&a.relpath).file_name().and_then(|n| n.to_str()) == Some(root_file)) + }); + } + for artifact in &artifacts { + let abs = store_path.join(&artifact.relpath); + let parsed = std::fs::read_to_string(&abs) + .with_context(|| format!("failed to read {}", abs.display())) + .and_then(|raw| { + serde_json::from_str::(&raw) + .with_context(|| format!("{} is not valid JSON", abs.display())) + }); + let json = match parsed { + Ok(json) => json, + Err(e) => { + eprintln!("warn: thread title {entry_uid}: {e:#}"); + continue; + } + }; + let name = json["author"]["screen_name"] + .as_str() + .map(|s| s.trim().trim_start_matches('@').trim()) + .filter(|s| !s.is_empty()); + if let Some(name) = name { + return Ok(name.to_string()); + } + } + // Only the first role that has JSON artifacts is consulted, matching + // `artifact_text_content`'s legacy `primary_media` fallback. + break; + } + Ok(String::new()) +} + +pub fn build_title_user_prompt(input: &ThreadTitleInput) -> String { + if input.author.is_empty() { + format!("Thread:\n{}\n", input.content) + } else { + format!("Author: @{}\n\nThread:\n{}\n", input.author, input.content) + } +} + +fn strip_prefix_ci<'a>(s: &'a str, prefix: &str) -> Option<&'a str> { + let head = s.get(..prefix.len())?; + head.eq_ignore_ascii_case(prefix).then(|| &s[prefix.len()..]) +} + +fn trim_trailing_punct(s: &str) -> &str { + s.trim_end_matches(|c: char| TRAILING_PUNCT.contains(&c) || QUOTE_CHARS.contains(&c)) + .trim_end() +} + +/// Reduces a model reply to a single short topic phrase. +pub fn sanitize_topic(raw: &str) -> Result { + let line = raw + .lines() + .filter(|l| !l.trim().starts_with("```")) + .map(str::trim) + .find(|l| !l.is_empty()) + .unwrap_or(""); + let line: String = line.chars().filter(|c| !c.is_control()).collect(); + + let mut s: &str = line.trim(); + for prefix in ["title:", "topic:"] { + if let Some(rest) = strip_prefix_ci(s, prefix) { + s = rest; + break; + } + } + loop { + let before = s; + s = s.trim().trim_matches(|c: char| QUOTE_CHARS.contains(&c)); + if let Some(rest) = strip_prefix_ci(s, "thread about ") { + s = rest; + } + if let Some(rest) = strip_prefix_ci(s, "about ") { + s = rest; + } + if s == before { + break; + } + } + for sep in [" — @", " – @", " - @"] { + if let Some(idx) = s.find(sep) { + s = &s[..idx]; + } + } + + let collapsed = s.split_whitespace().collect::>().join(" "); + let trimmed = trim_trailing_punct(&collapsed); + let mut topic = trimmed + .split(' ') + .filter(|w| !w.is_empty()) + .take(MAX_TOPIC_WORDS) + .collect::>() + .join(" "); + + if topic.chars().count() > MAX_TOPIC_CHARS { + let head: String = topic.chars().take(MAX_TOPIC_CHARS).collect(); + // `head` is MAX_TOPIC_CHARS chars and the next char exists, so a space + // at the cut point is preserved by checking the following char too. + let next_is_space = topic.chars().nth(MAX_TOPIC_CHARS) == Some(' '); + let cut = if next_is_space { + head.as_str() + } else { + match head.rfind(' ') { + Some(idx) => &head[..idx], + None => head.as_str(), + } + }; + topic = trim_trailing_punct(cut).to_string(); + } + + if topic.is_empty() { + bail!("title provider returned no usable title"); + } + Ok(topic) +} + +/// `author` is empty when unknown; the ` — @…` suffix is then omitted. +pub fn format_thread_title(topic: &str, author: &str) -> String { + if author.is_empty() { + format!("Thread about {topic}") + } else { + format!("Thread about {topic} — @{author}") + } +} + +fn short(s: &str) -> String { + s.chars().take(200).collect() +} + +/// Asks the provider for a topic and builds the final title. +pub fn generate_thread_title(cfg: &ProviderConfig, input: &ThreadTitleInput) -> Result { + let out = summarizer::complete_plain( + cfg, + TITLE_SYSTEM_PROMPT, + &build_title_user_prompt(input), + TITLE_MAX_TOKENS, + ) + .context("title generation failed")?; + let topic = + sanitize_topic(&out.text).with_context(|| format!("raw reply: {}", short(&out.text)))?; + Ok(format_thread_title(&topic, &input.author)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::summarizer::{CliProviderConfig, HttpProviderConfig}; + + #[test] + fn sanitize_topic_cleans_model_replies() { + let ok = |raw: &str| sanitize_topic(raw).unwrap(); + assert_eq!(ok("\"Rust async runtimes compared.\""), "Rust async runtimes compared"); + assert_eq!(ok("Thread about NixOS on a Pi"), "NixOS on a Pi"); + assert_eq!(ok("```\nTitle: **Home lab networking**\n```"), "Home lab networking"); + assert_eq!(ok("\nFirst line topic\nSecond line"), "First line topic"); + assert_eq!(ok("Foo bar — @alice"), "Foo bar"); + let twenty = (1..=20).map(|i| format!("w{i}")).collect::>().join(" "); + assert_eq!(ok(&twenty), "w1 w2 w3 w4 w5 w6 w7 w8 w9 w10"); + let long_word = "x".repeat(120); + assert!(ok(&long_word).chars().count() <= MAX_TOPIC_CHARS); + let long_words = vec!["abcdefghijk"; 10].join(" "); + let cut = ok(&long_words); + assert!(cut.chars().count() <= MAX_TOPIC_CHARS); + assert!(!cut.ends_with(' ')); + assert!(sanitize_topic(" ").is_err()); + assert!(sanitize_topic("\"\"").is_err()); + } + + #[test] + fn format_thread_title_uses_em_dash_suffix() { + assert_eq!(format_thread_title("x y", "bob"), "Thread about x y — @bob"); + assert_eq!(format_thread_title("x y", ""), "Thread about x y"); + } + + #[test] + fn title_models_cover_all_provider_kinds() { + for kind in summarizer::PROVIDER_KINDS { + assert!(title_model_env(kind).is_some(), "{kind}"); + assert!(default_title_model(kind).is_some(), "{kind}"); + } + assert_eq!(title_model_env("claude_cli"), Some("ARCHIVR_CLAUDE_TITLE_MODEL")); + assert_eq!(default_title_model("codex_cli"), Some("gpt-6-luna")); + assert_eq!(default_title_model("anthropic_http"), Some("claude-haiku-4-5")); + assert_eq!(title_model_env("gemini"), None); + assert_eq!(default_title_model("gemini"), None); + } + + #[test] + fn with_title_model_sets_model_on_every_variant() { + let http = HttpProviderConfig { + endpoint: "https://example.invalid".into(), + api_key: "k".into(), + model: "big".into(), + timeout_secs: 1, + }; + let cli = CliProviderConfig { + executable: "claude".into(), + model: None, + timeout_secs: 1, + }; + let m = || "small".to_string(); + match with_title_model(ProviderConfig::AnthropicHttp(http.clone()), m()) { + ProviderConfig::AnthropicHttp(c) => assert_eq!(c.model, "small"), + other => panic!("{other:?}"), + } + match with_title_model(ProviderConfig::OpenAiCompatible(http), m()) { + ProviderConfig::OpenAiCompatible(c) => assert_eq!(c.model, "small"), + other => panic!("{other:?}"), + } + match with_title_model(ProviderConfig::ClaudeCli(cli.clone()), m()) { + ProviderConfig::ClaudeCli(c) => assert_eq!(c.model.as_deref(), Some("small")), + other => panic!("{other:?}"), + } + match with_title_model(ProviderConfig::CodexCli(cli), m()) { + ProviderConfig::CodexCli(c) => assert_eq!(c.model.as_deref(), Some("small")), + other => panic!("{other:?}"), + } + } + + /// Archive with one entry of `entity_kind` and the given raw tweet JSON files. + fn fixture( + entity_kind: &str, + tweets: &[(&str, serde_json::Value)], + ) -> (tempfile::TempDir, ArchivePaths, database::ArchivedEntry) { + let temp = tempfile::tempdir().unwrap(); + let paths = crate::archive::initialize_archive( + temp.path(), + &temp.path().join("store"), + "Test archive", + false, + ) + .unwrap(); + let conn = database::open_or_initialize(&paths.archive_path).unwrap(); + let user_id = database::ensure_default_user(&conn).unwrap(); + let run = database::create_archive_run(&conn, user_id, 1).unwrap(); + let source_id = database::upsert_source_identity( + &conn, + "x", + entity_kind, + Some("9001"), + Some("https://x.com/alice/status/9001"), + "x:thread:9001", + ) + .unwrap(); + let entry = database::create_archived_entry( + &conn, + &database::NewEntry { + source_identity_id: source_id, + archive_run_id: run.id, + parent_entry_id: None, + root_entry_id: None, + created_by_user_id: user_id, + owned_by_user_id: user_id, + source_kind: "x".to_string(), + entity_kind: entity_kind.to_string(), + title: Some("Thread by @alice".to_string()), + visibility: "private".to_string(), + representation_kind: entity_kind.to_string(), + source_metadata_json: r#"{"tweet_id":"9001"}"#.to_string(), + display_metadata_json: None, + }, + ) + .unwrap(); + std::fs::create_dir_all(paths.store_path.join("raw_tweets")).unwrap(); + for (relpath, body) in tweets { + std::fs::write(paths.store_path.join(relpath), body.to_string()).unwrap(); + database::add_entry_artifact( + &conn, + &database::NewArtifact { + entry_id: entry.id, + artifact_role: "raw_tweet_json".to_string(), + storage_area: "raw_tweets".to_string(), + relpath: relpath.to_string(), + blob_id: None, + logical_path: None, + metadata_json: None, + }, + ) + .unwrap(); + } + (temp, paths, entry) + } + + fn alice_thread() -> Vec<(&'static str, serde_json::Value)> { + vec![ + ( + "raw_tweets/tweet-9001.json", + serde_json::json!({ + "full_text": "1/ Comparing Rust async runtimes.", + "author": { "screen_name": "@alice" } + }), + ), + ( + "raw_tweets/tweet-9002.json", + serde_json::json!({ + "full_text": "2/ Tokio wins on ecosystem.", + "author": { "screen_name": "alice" } + }), + ), + ] + } + + #[test] + fn load_thread_title_input_reads_thread_text_and_author() { + let (_temp, paths, entry) = fixture("tweet_thread", &alice_thread()); + let input = load_thread_title_input(&paths, &entry.entry_uid) + .unwrap() + .unwrap(); + assert_eq!(input.entry_uid, entry.entry_uid); + assert_eq!(input.author, "alice"); + assert!( + input + .content + .contains("1/ Comparing Rust async runtimes.\n\n---\n\n2/ Tokio wins on ecosystem."), + "{}", + input.content + ); + assert!(!input.content.contains("Thread by @alice")); + } + + #[test] + fn load_thread_title_input_prefers_root_status_author() { + let tweets = vec![ + ( + "raw_tweets/tweet-8000.json", + serde_json::json!({ "full_text": "quoted", "author": { "screen_name": "bob" } }), + ), + ( + "raw_tweets/tweet-9001.json", + serde_json::json!({ "full_text": "root", "author": { "screen_name": "alice" } }), + ), + ]; + let (_temp, paths, entry) = fixture("tweet_thread", &tweets); + let input = load_thread_title_input(&paths, &entry.entry_uid) + .unwrap() + .unwrap(); + assert_eq!(input.author, "alice"); + } + + #[test] + fn load_thread_title_input_rejects_non_threads_and_empty_threads() { + let (_temp, paths, entry) = fixture("page", &[]); + let err = load_thread_title_input(&paths, &entry.entry_uid).unwrap_err(); + assert!(format!("{err:#}").contains("not an X thread"), "{err:#}"); + assert!(thread_title_user_message(&err).is_some()); + assert!(load_thread_title_input(&paths, "no-such-uid").unwrap().is_none()); + + let empty = vec![( + "raw_tweets/tweet-9001.json", + serde_json::json!({ "full_text": "", "author": { "screen_name": "alice" } }), + )]; + let (_temp2, paths2, entry2) = fixture("tweet_thread", &empty); + let err = load_thread_title_input(&paths2, &entry2.entry_uid).unwrap_err(); + assert!(format!("{err:#}").contains("no archived text"), "{err:#}"); + assert!(thread_title_user_message(&err).is_some()); + } + + #[cfg(unix)] + #[test] + fn generate_thread_title_runs_claude_cli_with_title_model() { + let dir = tempfile::tempdir().unwrap(); + let script = dir.path().join("fake-claude"); + crate::downloader::write_script( + &script, + "#!/bin/sh\ncat >/dev/null\ncase \" $* \" in *\" --model haiku \"*) ;; *) echo \"bad args: $*\" >&2; exit 3;; esac\nprintf '%s\\n' '\"Rust async runtimes compared.\"'\n", + ); + let cfg = ProviderConfig::ClaudeCli(CliProviderConfig { + executable: script, + model: Some("haiku".into()), + timeout_secs: 30, + }); + let input = ThreadTitleInput { + entry_uid: "uid".into(), + author: "alice".into(), + content: "1/ Comparing Rust async runtimes.".into(), + }; + // Parallel tests forking while the script fd was open can briefly make + // exec fail with ETXTBSY (rust-lang/rust#114554); retry that case only. + let mut attempt = 0; + let title = loop { + match generate_thread_title(&cfg, &input) { + Err(e) if attempt < 20 && format!("{e:#}").contains("Text file busy") => { + attempt += 1; + std::thread::sleep(std::time::Duration::from_millis(50)); + } + other => break other.unwrap(), + } + }; + assert_eq!(title, "Thread about Rust async runtimes compared — @alice"); + } + + #[test] + fn resolve_title_model_prefers_instance_then_env_then_default() { + // Only this test touches the codex title env var; restored below. + let var = "ARCHIVR_CODEX_TITLE_MODEL"; + let previous = std::env::var_os(var); + unsafe { std::env::remove_var(var) }; + assert_eq!( + resolve_title_model("codex_cli", None), + Some(("gpt-6-luna".into(), TitleModelSource::Default)) + ); + assert_eq!( + resolve_title_model("codex_cli", Some(" ")), + Some(("gpt-6-luna".into(), TitleModelSource::Default)) + ); + unsafe { std::env::set_var(var, " env-model ") }; + assert_eq!( + resolve_title_model("codex_cli", None), + Some(("env-model".into(), TitleModelSource::Env)) + ); + assert_eq!( + resolve_title_model("codex_cli", Some(" inst-model ")), + Some(("inst-model".into(), TitleModelSource::Instance)) + ); + unsafe { + match previous { + Some(v) => std::env::set_var(var, v), + None => std::env::remove_var(var), + } + } + assert_eq!(resolve_title_model("gemini", Some("x")), None); + assert_eq!(TitleModelSource::Env.as_str(), "env"); + } +} diff --git a/crates/archivr-core/src/transcriber.rs b/crates/archivr-core/src/transcriber.rs new file mode 100644 index 0000000..e6f7330 --- /dev/null +++ b/crates/archivr-core/src/transcriber.rs @@ -0,0 +1,1742 @@ +//! Local transcription fallback for YouTube summaries. +//! +//! When a summary is requested for a `youtube`/`video` entry that has no usable +//! subtitles (none archived, none fetchable from the original video), the +//! summary worker can transcribe the audio locally with an engine the user +//! picked: Whisper (whisper.cpp or a wrapper script), NVIDIA Parakeet (wrapper +//! script) or Fermion Research Phonon-2 (English only). The transcript is stored +//! as an ordinary `subtitle` artifact with `kind: "transcribed"`, so ranking, +//! reduction and digesting need no special cases. +//! +//! Engines are configured by `ARCHIVR_*` env vars only; `ARCHIVR_TRANSCRIBE_ENGINES` +//! is the gate. Every engine ends with a VTT file inside the job's temp dir. + +use anyhow::{Context, Result, anyhow, bail}; +use std::{ + ffi::OsString, + fs, + path::{Path, PathBuf}, + sync::{Condvar, Mutex}, + time::{Duration, Instant}, +}; +use uuid::Uuid; + +use crate::{ + archive::ArchivePaths, + capture, database, + downloader::ytdlp::{self, StagedSubtitle, SubtitleKind, is_safe_language_code, language_base}, + env_config::{env_or, env_timeout, optional_env, required_env, resolve_cli}, + subtitles, + summarizer::NO_SUBTITLES_AFTER_TRANSCRIPTION_MESSAGE, +}; + +pub const TRANSCRIBE_ENGINE_KINDS: [&str; 3] = ["whisper", "parakeet", "phonon2"]; +pub const DEFAULT_TRANSCRIBE_TIMEOUT_SECS: u64 = 3600; +pub const TRANSCRIBE_SAMPLE_RATE_HZ: u32 = 16_000; + +/// Bytes per second of the 16 kHz mono s16le WAV every engine receives. +const WAV_BYTES_PER_SEC: f64 = (TRANSCRIBE_SAMPLE_RATE_HZ * 2) as f64; +/// Canonical PCM WAV header size written by ffmpeg. +const WAV_HEADER_BYTES: u64 = 44; +/// Phonon `words` grouping limits for one cue. +const PHONON_CUE_MAX_SECS: f64 = 7.0; +const PHONON_CUE_MAX_CHARS: usize = 84; + +const AUDIO_EXTENSIONS: [&str; 12] = [ + "mp4", "m4a", "webm", "mkv", "mov", "mp3", "opus", "ogg", "oga", "flac", "wav", "aac", +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WhisperBackend { + WhisperCpp, + Script, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TranscriberConfig { + /// One of [`TRANSCRIBE_ENGINE_KINDS`]. + pub kind: &'static str, + pub executable: PathBuf, + pub model: String, + /// Ignored unless `kind == "whisper"`. + pub whisper_backend: WhisperBackend, + /// Base language codes; `None` = any. phonon2: always `Some(["en"])`. + pub languages: Option>, + pub timeout_secs: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TranscriptionSettings { + pub ffmpeg: PathBuf, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub struct TranscriberInfo { + pub kind: &'static str, + /// "Whisper" | "NVIDIA Parakeet" | "Phonon-2" + pub label: &'static str, + /// `languages == Some(["en"])`. + pub english_only: bool, + pub languages: Option>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TranscriptOutput { + /// Inside the job's `out_dir`. + pub vtt_path: PathBuf, + /// Detected or assumed language; validated with `is_safe_language_code`. + pub language: Option, +} + +/// `Send + Sync` so a boxed transcriber can cross into the server's `spawn_blocking` worker. +pub trait Transcriber: Send + Sync { + fn kind(&self) -> &'static str; + fn label(&self) -> &'static str; + fn model(&self) -> &str; + fn timeout_secs(&self) -> u64; + /// `None` = language unknown → always true. + fn supports_language(&self, original_language: Option<&str>) -> bool; + fn supported_languages(&self) -> Option<&[String]>; + fn transcribe( + &self, + audio_wav: &Path, + lang_hint: Option<&str>, + out_dir: &Path, + deadline: Instant, + ) -> Result; +} + +pub struct TranscriptionRequest { + pub transcriber: Box, + pub settings: TranscriptionSettings, +} + +// ── User-visible copy ────────────────────────────────────────────────────── + +/// User-safe copy for a failed transcription, attached as context to the +/// detailed diagnostic. Only this text reaches the summary row; the +/// diagnostic goes to the server log. +#[derive(Debug)] +pub struct TranscriptionUserMessage(pub String); + +impl std::fmt::Display for TranscriptionUserMessage { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for TranscriptionUserMessage {} + +/// The [`TranscriptionUserMessage`] carried by `error`, if any. +/// +/// Context layers are not reachable through `chain()` downcasts, only through +/// `anyhow::Error::downcast_ref`, so both are tried. +pub fn transcription_user_message(error: &anyhow::Error) -> Option { + error + .chain() + .find_map(|c| c.downcast_ref::()) + .or_else(|| error.downcast_ref::()) + .map(|m| m.0.clone()) +} + +pub fn transcription_language_unsupported_message( + label: &str, + lang: &str, + supported: &[String], +) -> String { + let supported = if supported.len() == 1 && supported[0] == "en" { + "English".to_string() + } else { + format!("these languages: {}", supported.join(", ")) + }; + format!( + "This video can’t be transcribed with {label} because it only supports {supported}, and the video’s original language is “{lang}”. Choose a different transcription engine." + ) +} + +fn no_audio_message(label: &str) -> String { + format!( + "Local transcription with {label} couldn’t start: the archived media file is missing and the original video couldn’t be downloaded." + ) +} + +fn extraction_failed_message(label: &str) -> String { + format!( + "Local transcription with {label} failed: the audio couldn’t be extracted from this video (it may have no audio track)." + ) +} + +fn engine_exit_message(label: &str) -> String { + format!( + "Local transcription with {label} failed: the transcription engine exited with an error. Check the server log for details." + ) +} + +fn no_transcript_file_message(label: &str) -> String { + format!( + "Local transcription with {label} failed: the transcription engine produced no subtitle file. Check the server log for details." + ) +} + +fn timed_out_message(label: &str, timeout_secs: u64) -> String { + format!( + "Local transcription with {label} timed out after {timeout_secs} seconds. Raise ARCHIVR_TRANSCRIBE_TIMEOUT or choose a faster engine." + ) +} + +fn busy_message(label: &str) -> String { + format!( + "Local transcription with {label} didn’t start because another transcription was still running. Try again later." + ) +} + +fn with_user_message(error: anyhow::Error, copy: String) -> anyhow::Error { + error.context(TranscriptionUserMessage(copy)) +} + +/// Marker for an engine that ran but left no usable transcript output +/// (e.g. unparsable Phonon JSON). +#[derive(Debug)] +struct NoTranscriptOutput; + +impl std::fmt::Display for NoTranscriptOutput { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("transcription engine produced no usable transcript") + } +} + +impl std::error::Error for NoTranscriptOutput {} + +fn is_no_transcript_output(error: &anyhow::Error) -> bool { + error + .chain() + .find_map(|c| c.downcast_ref::()) + .or_else(|| error.downcast_ref::()) + .is_some() +} + +// ── Configuration ────────────────────────────────────────────────────────── + +fn engine_label(kind: &str) -> &'static str { + match kind { + "whisper" => "Whisper", + "parakeet" => "NVIDIA Parakeet", + "phonon2" => "Phonon-2", + _ => "Unknown engine", + } +} + +fn unknown_engine_error(kind: &str) -> anyhow::Error { + anyhow!( + "unknown transcription engine: {kind} (expected one of {})", + TRANSCRIBE_ENGINE_KINDS.join(", ") + ) +} + +/// Comma-separated list → trimmed, lowercased, non-empty, deduplicated +/// (first wins). An empty result is `None`. Used for the engine list and the +/// language allowlists. +fn parse_language_list(raw: Option<&str>) -> Option> { + let mut out: Vec = Vec::new(); + for item in raw?.split(',') { + let item = item.trim().to_ascii_lowercase(); + if !item.is_empty() && !out.contains(&item) { + out.push(item); + } + } + (!out.is_empty()).then_some(out) +} + +/// Engine kinds listed in `ARCHIVR_TRANSCRIBE_ENGINES`. Unknown names are +/// logged and ignored. +pub fn enabled_engine_kinds() -> Vec<&'static str> { + let Some(list) = parse_language_list(optional_env("ARCHIVR_TRANSCRIBE_ENGINES").as_deref()) + else { + return Vec::new(); + }; + let mut kinds = Vec::new(); + for item in list { + match TRANSCRIBE_ENGINE_KINDS.iter().find(|k| **k == item) { + Some(kind) => kinds.push(*kind), + None => eprintln!( + "warn: ARCHIVR_TRANSCRIBE_ENGINES: unknown transcription engine '{item}' ignored" + ), + } + } + kinds +} + +/// Builds an engine configuration for `kind` purely from the environment. +pub fn transcriber_from_env(kind: &str) -> Result { + let timeout_secs = env_timeout("ARCHIVR_TRANSCRIBE_TIMEOUT", DEFAULT_TRANSCRIBE_TIMEOUT_SECS); + match kind { + "whisper" => { + let backend = match env_or("ARCHIVR_WHISPER_BACKEND", "whisper_cpp").trim() { + "whisper_cpp" => WhisperBackend::WhisperCpp, + "script" => WhisperBackend::Script, + other => bail!( + "invalid ARCHIVR_WHISPER_BACKEND: {other} (expected whisper_cpp or script)" + ), + }; + let executable = match backend { + // Auto-discovery would find whisper-cli, which does not follow + // the script contract. + WhisperBackend::Script => { + PathBuf::from(required_env("ARCHIVR_WHISPER_CLI")?.trim()) + } + WhisperBackend::WhisperCpp => resolve_cli( + "ARCHIVR_WHISPER_CLI", + &["/opt/homebrew/bin/whisper-cli", "/usr/local/bin/whisper-cli"], + "whisper-cli", + ), + }; + Ok(TranscriberConfig { + kind: "whisper", + executable, + model: required_env("ARCHIVR_WHISPER_MODEL")?.trim().to_string(), + whisper_backend: backend, + languages: parse_language_list(optional_env("ARCHIVR_WHISPER_LANGUAGES").as_deref()), + timeout_secs, + }) + } + "parakeet" => Ok(TranscriberConfig { + kind: "parakeet", + executable: PathBuf::from(required_env("ARCHIVR_PARAKEET_CLI")?.trim()), + model: env_or("ARCHIVR_PARAKEET_MODEL", "nvidia/parakeet-tdt-0.6b-v3") + .trim() + .to_string(), + whisper_backend: WhisperBackend::Script, + languages: parse_language_list(optional_env("ARCHIVR_PARAKEET_LANGUAGES").as_deref()), + timeout_secs, + }), + "phonon2" => Ok(TranscriberConfig { + kind: "phonon2", + executable: resolve_cli( + "ARCHIVR_PHONON2_CLI", + &["/opt/homebrew/bin/fermion", "/usr/local/bin/fermion"], + "fermion", + ), + model: env_or("ARCHIVR_PHONON2_MODEL", "phonon-2").trim().to_string(), + whisper_backend: WhisperBackend::Script, + // English only, regardless of env. + languages: Some(vec!["en".to_string()]), + timeout_secs, + }), + other => Err(unknown_engine_error(other)), + } +} + +pub fn transcriber_from_config(cfg: TranscriberConfig) -> Box { + match (cfg.kind, cfg.whisper_backend) { + ("phonon2", _) => Box::new(Phonon2Transcriber(cfg)), + ("whisper", WhisperBackend::WhisperCpp) => Box::new(WhisperCppTranscriber(cfg)), + _ => Box::new(ScriptTranscriber(cfg)), + } +} + +pub fn transcription_settings_from_env() -> TranscriptionSettings { + TranscriptionSettings { + ffmpeg: PathBuf::from(env_or("ARCHIVR_FFMPEG", "ffmpeg").trim()), + } +} + +/// Enabled AND configured engines, in [`TRANSCRIBE_ENGINE_KINDS`] order. A +/// configuration error is logged once per call and the engine is left out. +pub fn available_transcribers() -> Vec { + let enabled = enabled_engine_kinds(); + TRANSCRIBE_ENGINE_KINDS + .iter() + .filter(|kind| enabled.contains(*kind)) + .filter_map(|kind| match transcriber_from_env(kind) { + Ok(cfg) => Some(TranscriberInfo { + kind: cfg.kind, + label: engine_label(cfg.kind), + english_only: cfg.languages.as_deref().is_some_and(|l| l == ["en"]), + languages: cfg.languages, + }), + Err(e) => { + eprintln!("warn: transcription engine '{kind}' is enabled but not configured: {e:#}"); + None + } + }) + .collect() +} + +/// Server entry point: `kind` must be known, enabled in +/// `ARCHIVR_TRANSCRIBE_ENGINES`, and fully configured. +pub fn request_from_env(kind: &str) -> Result { + let kind = kind.trim().to_ascii_lowercase(); + if !TRANSCRIBE_ENGINE_KINDS.contains(&kind.as_str()) { + return Err(unknown_engine_error(&kind)); + } + if !enabled_engine_kinds().contains(&kind.as_str()) { + bail!("transcription engine '{kind}' is not enabled (add it to ARCHIVR_TRANSCRIBE_ENGINES)"); + } + let cfg = transcriber_from_env(&kind)?; + Ok(TranscriptionRequest { + transcriber: transcriber_from_config(cfg), + settings: transcription_settings_from_env(), + }) +} + +// ── Engines ──────────────────────────────────────────────────────────────── + +fn language_supported(languages: Option<&[String]>, original_language: Option<&str>) -> bool { + match (languages, original_language) { + (None, _) | (_, None) => true, + (Some(allowed), Some(lang)) => allowed.contains(&language_base(lang)), + } +} + +/// Time left before `deadline`; a spent budget is a timeout error before any +/// subprocess is spawned. +fn remaining_budget(deadline: Instant) -> Result { + let left = deadline.saturating_duration_since(Instant::now()); + if left.is_zero() { + return Err(anyhow::Error::new(crate::process::ProcessTimedOut { secs: 0 }) + .context("transcription budget exhausted")); + } + Ok(left) +} + +fn safe_language(raw: &str) -> Option { + let trimmed = raw.trim(); + (!trimmed.is_empty() && is_safe_language_code(trimmed)).then(|| trimmed.to_string()) +} + +macro_rules! impl_engine_accessors { + () => { + fn kind(&self) -> &'static str { + self.0.kind + } + fn label(&self) -> &'static str { + engine_label(self.0.kind) + } + fn model(&self) -> &str { + &self.0.model + } + fn timeout_secs(&self) -> u64 { + self.0.timeout_secs + } + fn supports_language(&self, original_language: Option<&str>) -> bool { + language_supported(self.0.languages.as_deref(), original_language) + } + fn supported_languages(&self) -> Option<&[String]> { + self.0.languages.as_deref() + } + }; +} + +/// whisper.cpp's `whisper-cli`, invoked directly. +struct WhisperCppTranscriber(TranscriberConfig); + +impl Transcriber for WhisperCppTranscriber { + impl_engine_accessors!(); + + fn transcribe( + &self, + audio_wav: &Path, + lang_hint: Option<&str>, + out_dir: &Path, + deadline: Instant, + ) -> Result { + let hint = whisper_language_hint(lang_hint); + let args = whisper_cpp_args(&self.0.model, audio_wav, hint.as_deref(), &out_dir.join("transcript")); + crate::process::run_with_timeout(&self.0.executable, &args, None, remaining_budget(deadline)?)?; + // `-oj` JSON carries the detected language at `result.language`. + let detected = fs::read_to_string(out_dir.join("transcript.json")) + .ok() + .and_then(|raw| serde_json::from_str::(&raw).ok()) + .and_then(|v| v["result"]["language"].as_str().and_then(safe_language)); + Ok(TranscriptOutput { + vtt_path: out_dir.join("transcript.vtt"), + language: detected.or(hint), + }) + } +} + +/// Any executable following the script contract (Whisper `script` backend, Parakeet). +struct ScriptTranscriber(TranscriberConfig); + +impl Transcriber for ScriptTranscriber { + impl_engine_accessors!(); + + fn transcribe( + &self, + audio_wav: &Path, + lang_hint: Option<&str>, + out_dir: &Path, + deadline: Instant, + ) -> Result { + let vtt_path = out_dir.join("transcript.vtt"); + let hint = whisper_language_hint(lang_hint); + let args = script_args(audio_wav, &vtt_path, &self.0.model, hint.as_deref()); + crate::process::run_with_timeout(&self.0.executable, &args, None, remaining_budget(deadline)?)?; + let detected = fs::read_to_string(out_dir.join("transcript.vtt.lang")) + .ok() + .and_then(|raw| safe_language(&raw)); + Ok(TranscriptOutput { + vtt_path, + language: detected, + }) + } +} + +/// Fermion Research Phonon-2 via `fermion transcribe --json`. +struct Phonon2Transcriber(TranscriberConfig); + +impl Transcriber for Phonon2Transcriber { + impl_engine_accessors!(); + + fn transcribe( + &self, + audio_wav: &Path, + _lang_hint: Option<&str>, + out_dir: &Path, + deadline: Instant, + ) -> Result { + let args = phonon2_args(&self.0.model, audio_wav); + let out = crate::process::run_with_timeout( + &self.0.executable, + &args, + None, + remaining_budget(deadline)?, + )?; + if serde_json::from_str::(out.stdout.trim()) + .ok() + .and_then(|v| v.get("truncated").and_then(|t| t.as_bool())) + == Some(true) + { + eprintln!("warn: phonon2 reported truncated segments"); + } + let wav_len = fs::metadata(audio_wav).map(|m| m.len()).unwrap_or(0); + let vtt = phonon_json_to_vtt(&out.stdout, wav_duration_secs(wav_len)) + .map_err(|e| e.context(NoTranscriptOutput))?; + let vtt_path = out_dir.join("transcript.vtt"); + fs::write(&vtt_path, vtt) + .with_context(|| format!("failed to write {}", vtt_path.display()))?; + Ok(TranscriptOutput { + vtt_path, + language: Some("en".to_string()), + }) + } +} + +// ── Pure helpers ─────────────────────────────────────────────────────────── + +fn whisper_cpp_args( + model: &str, + wav: &Path, + lang_hint: Option<&str>, + out_prefix: &Path, +) -> Vec { + vec![ + "-m".into(), + model.into(), + "-f".into(), + wav.into(), + "-l".into(), + lang_hint.unwrap_or("auto").into(), + "-ovtt".into(), + "-oj".into(), + "-of".into(), + out_prefix.into(), + "-np".into(), + ] +} + +fn script_args(wav: &Path, out_vtt: &Path, model: &str, lang_hint: Option<&str>) -> Vec { + let mut args: Vec = vec![ + "--input".into(), + wav.into(), + "--output".into(), + out_vtt.into(), + "--model".into(), + model.into(), + ]; + if let Some(lang) = lang_hint { + args.push("--language".into()); + args.push(lang.into()); + } + args +} + +fn phonon2_args(model: &str, wav: &Path) -> Vec { + vec!["transcribe".into(), model.into(), wav.into(), "--json".into()] +} + +fn ffmpeg_resample_args(input: &Path, out_wav: &Path) -> Vec { + let mut args: Vec = ["-nostdin", "-hide_banner", "-loglevel", "error", "-y", "-i"] + .into_iter() + .map(OsString::from) + .collect(); + args.push(input.into()); + args.extend( + [ + "-map", "0:a:0", "-vn", "-sn", "-dn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", + ] + .into_iter() + .map(OsString::from), + ); + args.push(out_wav.into()); + args +} + +/// Base of `original_language` when it is a two-letter code (`de-orig` → `de`); +/// otherwise `None`. Never yields an unvalidated string. +fn whisper_language_hint(original_language: Option<&str>) -> Option { + let base = language_base(original_language?); + (base.len() == 2 && base.chars().all(|c| c.is_ascii_lowercase())).then_some(base) +} + +/// `HH:MM:SS.mmm`; negative or non-finite input clamps to zero. +fn format_vtt_timestamp(seconds: f64) -> String { + let seconds = if seconds.is_finite() { seconds.max(0.0) } else { 0.0 }; + let total_ms = (seconds * 1000.0).round() as u64; + let (h, rest) = (total_ms / 3_600_000, total_ms % 3_600_000); + let (m, rest) = (rest / 60_000, rest % 60_000); + let (s, ms) = (rest / 1000, rest % 1000); + format!("{h:02}:{m:02}:{s:02}.{ms:03}") +} + +/// Duration of a 16 kHz mono s16le WAV of `byte_len` bytes. +fn wav_duration_secs(byte_len: u64) -> f64 { + byte_len.saturating_sub(WAV_HEADER_BYTES) as f64 / WAV_BYTES_PER_SEC +} + +fn escape_vtt_text(text: &str) -> String { + text.split_whitespace() + .collect::>() + .join(" ") + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") +} + +fn number(v: &serde_json::Value, keys: &[&str]) -> Option { + keys.iter().find_map(|k| v.get(*k).and_then(|n| n.as_f64())) +} + +fn text_field<'a>(v: &'a serde_json::Value, keys: &[&str]) -> Option<&'a str> { + keys.iter().find_map(|k| v.get(*k).and_then(|t| t.as_str())) +} + +/// `(start, end, text)` items of a segment-like array; `None` unless every +/// item has numeric start/end and a text string. +fn timed_items(items: &[serde_json::Value], text_keys: &[&str]) -> Option> { + if items.is_empty() { + return None; + } + items + .iter() + .map(|item| { + Some(( + number(item, &["start", "start_s", "start_time"])?, + number(item, &["end", "end_s", "end_time"])?, + text_field(item, text_keys)?.to_string(), + )) + }) + .collect() +} + +/// Converts `fermion transcribe … --json` stdout to WebVTT. +/// +/// The vendor's key names are undocumented, so this accepts, in order: +/// a segment array (`segments`, else any other top-level array of objects +/// with numeric start/end and text); the `words` list grouped into cues of at +/// most 7 s or 84 characters; the top-level `text` as one cue spanning the WAV. +fn phonon_json_to_vtt(json: &str, wav_duration_secs: f64) -> Result { + let value: serde_json::Value = + serde_json::from_str(json.trim()).context("phonon2 output is not JSON")?; + let object = value + .as_object() + .ok_or_else(|| anyhow!("phonon2 output is not a JSON object"))?; + + let segment_arrays = object + .get("segments") + .into_iter() + .chain(object.iter().filter(|(k, _)| *k != "segments" && *k != "words").map(|(_, v)| v)); + let segments = segment_arrays + .filter_map(|v| v.as_array()) + .find_map(|items| timed_items(items, &["text", "segment", "transcript"])); + + let cues: Vec<(f64, f64, String)> = if let Some(segments) = segments { + segments + } else if let Some(words) = object + .get("words") + .and_then(|w| w.as_array()) + .and_then(|items| timed_items(items, &["word", "text"])) + { + group_words(&words) + } else if let Some(text) = object.get("text").and_then(|t| t.as_str()) { + vec![(0.0, wav_duration_secs, text.to_string())] + } else { + bail!("phonon2 JSON has no segments, words or text"); + }; + + let mut vtt = String::from("WEBVTT\n\n"); + for (start, end, text) in cues { + let text = escape_vtt_text(&text); + if text.is_empty() { + continue; + } + vtt.push_str(&format!( + "{} --> {}\n{text}\n\n", + format_vtt_timestamp(start), + format_vtt_timestamp(end.max(start)) + )); + } + Ok(vtt) +} + +fn group_words(words: &[(f64, f64, String)]) -> Vec<(f64, f64, String)> { + let mut cues: Vec<(f64, f64, String)> = Vec::new(); + let mut current: Option<(f64, f64, String)> = None; + for (start, end, word) in words { + let word = word.trim(); + if word.is_empty() { + continue; + } + if let Some((cue_start, cue_end, text)) = current.as_mut() { + let too_long = end - *cue_start > PHONON_CUE_MAX_SECS + || text.chars().count() + 1 + word.chars().count() > PHONON_CUE_MAX_CHARS; + if !too_long { + text.push(' '); + text.push_str(word); + *cue_end = *end; + continue; + } + cues.extend(current.take()); + } + current = Some((*start, *end, word.to_string())); + } + cues.extend(current); + cues +} + +/// The first `primary_media` artifact (id order) that is an audio/video file +/// present in the store. +fn select_audio_source(store_path: &Path, primary: &[database::RoleArtifact]) -> Option { + primary.iter().find_map(|artifact| { + let ext = Path::new(&artifact.relpath) + .extension() + .and_then(|e| e.to_str()) + .map(str::to_ascii_lowercase) + .unwrap_or_default(); + let mime = artifact.mime_type.as_deref().unwrap_or("").to_ascii_lowercase(); + let media = AUDIO_EXTENSIONS.contains(&ext.as_str()) + || mime.starts_with("audio/") + || mime.starts_with("video/"); + let path = store_path.join(&artifact.relpath); + (media && path.is_file()).then_some(path) + }) +} + +// ── Job orchestration ────────────────────────────────────────────────────── + +/// One transcription at a time per process: engines saturate CPU/GPU. +static SLOT: (Mutex, Condvar) = (Mutex::new(false), Condvar::new()); + +struct SlotGuard; + +impl Drop for SlotGuard { + fn drop(&mut self) { + let mut busy = SLOT.0.lock().unwrap_or_else(|e| e.into_inner()); + *busy = false; + drop(busy); + SLOT.1.notify_one(); + } +} + +fn acquire_slot(timeout: Duration) -> Option { + let guard = SLOT.0.lock().unwrap_or_else(|e| e.into_inner()); + let (mut busy, _) = SLOT + .1 + .wait_timeout_while(guard, timeout, |busy| *busy) + .unwrap_or_else(|e| e.into_inner()); + if *busy { + return None; + } + *busy = true; + Some(SlotGuard) +} + +/// Removes the job dir on success, error and panic. +struct TempDirGuard(PathBuf); + +impl Drop for TempDirGuard { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +#[cfg(test)] +pub(crate) static TRANSCRIBE_TEST_LOCK: Mutex<()> = Mutex::new(()); + +/// Transcribes a YouTube video entry's audio with `request`'s engine and +/// registers the transcript as a `subtitle` artifact (`kind: "transcribed"`). +/// +/// Returns the number of artifact rows inserted; `Ok(0)` without transcribing +/// when a usable subtitle track already exists (re-checked under the +/// process-wide slot). Every failure carries a [`TranscriptionUserMessage`]; +/// the full diagnostic is logged here. +pub fn transcribe_entry( + paths: &ArchivePaths, + entry_uid: &str, + request: &TranscriptionRequest, + original_language: Option<&str>, + cookie_rules: &[database::CookieRule], +) -> Result { + let result = transcribe_entry_inner(paths, entry_uid, request, original_language, cookie_rules); + if let Err(e) = &result { + eprintln!("warn: transcription {entry_uid}: {e:#}"); + } + result +} + +fn transcribe_entry_inner( + paths: &ArchivePaths, + entry_uid: &str, + request: &TranscriptionRequest, + original_language: Option<&str>, + cookie_rules: &[database::CookieRule], +) -> Result { + let transcriber = request.transcriber.as_ref(); + let kind = transcriber.kind(); + let label = transcriber.label(); + let timeout_secs = transcriber.timeout_secs(); + let timed_out = || timed_out_message(label, timeout_secs); + + // 1. Slot; queueing does not use up the job budget. + let _slot = acquire_slot(Duration::from_secs(timeout_secs)).ok_or_else(|| { + with_user_message( + anyhow!("another transcription held the slot for {timeout_secs}s"), + busy_message(label), + ) + })?; + let started = Instant::now(); + let deadline = started + Duration::from_secs(timeout_secs); + + // 2. Re-check under the slot. + let conn = database::open_or_initialize(&paths.archive_path)?; + let info = database::entry_source_info(&conn, entry_uid)? + .ok_or_else(|| anyhow!("entry not found: {entry_uid}"))?; + if info.source_kind != "youtube" || info.entity_kind != "video" { + bail!("entry {entry_uid} is not a YouTube video"); + } + let store_path = &paths.store_path; + if subtitles::usable_subtitle_count(&conn, store_path, info.entry_id)? > 0 { + eprintln!("info: transcription {entry_uid}: a usable subtitle track already exists; skipping"); + return Ok(0); + } + + // 3. Language gate. + if !transcriber.supports_language(original_language) { + let lang = original_language.unwrap_or_default(); + return Err(with_user_message( + anyhow!("{kind} does not support original language {lang}"), + transcription_language_unsupported_message( + label, + lang, + transcriber.supported_languages().unwrap_or_default(), + ), + )); + } + if original_language.is_none() && transcriber.supported_languages().is_some() { + eprintln!("warn: {kind}: original language unknown for {entry_uid}; assuming it is supported"); + } + + // 4. Job dir. + let job_name = format!("transcribe-{}", Uuid::new_v4().simple()); + let job = store_path.join("temp").join(&job_name); + fs::create_dir_all(&job).with_context(|| format!("failed to create {}", job.display()))?; + let _job_guard = TempDirGuard(job.clone()); + + // 5. Audio source: archived media, else an audio-only yt-dlp download. + let primary = database::list_entry_artifacts_by_role(&conn, info.entry_id, "primary_media")?; + let input = match select_audio_source(store_path, &primary) { + Some(path) => path, + None => { + let Some(url) = info + .canonical_url + .as_deref() + .filter(|u| u.starts_with("https://") || u.starts_with("http://")) + else { + return Err(with_user_message( + anyhow!("no archived media file and no http(s) URL to download audio from"), + no_audio_message(label), + )); + }; + let budget = remaining_budget(deadline).map_err(|e| with_user_message(e, timed_out()))?; + let cookies = capture::resolve_cookies_for_url(cookie_rules, url); + ytdlp::download_audio_for_transcription(url, store_path, &job_name, &cookies, budget) + .map_err(|e| { + let copy = if Instant::now() >= deadline { + timed_out() + } else { + no_audio_message(label) + }; + with_user_message(e, copy) + })? + } + }; + + // 6. Resample to 16 kHz mono PCM WAV. + let wav = job.join("audio.wav"); + let budget = remaining_budget(deadline).map_err(|e| with_user_message(e, timed_out()))?; + crate::process::run_with_timeout( + &request.settings.ffmpeg, + &ffmpeg_resample_args(&input, &wav), + None, + budget, + ) + .map_err(|e| { + let copy = if crate::process::is_process_timeout(&e) { + timed_out() + } else { + extraction_failed_message(label) + }; + with_user_message(e, copy) + })?; + + // 7. Transcribe. + remaining_budget(deadline).map_err(|e| with_user_message(e, timed_out()))?; + let output = transcriber + .transcribe(&wav, original_language, &job, deadline) + .map_err(|e| { + let copy = if crate::process::is_process_timeout(&e) { + timed_out() + } else if is_no_transcript_output(&e) { + no_transcript_file_message(label) + } else { + engine_exit_message(label) + }; + with_user_message(e, copy) + })?; + + // 8. Validate. + let raw = fs::read_to_string(&output.vtt_path) + .ok() + .filter(|raw| !raw.trim().is_empty()) + .ok_or_else(|| { + with_user_message( + anyhow!("{kind} wrote no transcript at {}", output.vtt_path.display()), + no_transcript_file_message(label), + ) + })?; + if subtitles::subtitle_to_transcript(&raw).is_empty() { + return Err(with_user_message( + anyhow!("{kind} transcript of {entry_uid} contains no speech"), + NO_SUBTITLES_AFTER_TRANSCRIPTION_MESSAGE.to_string(), + )); + } + + // 9. Stage and archive. + let language = if kind == "phonon2" { + "en".to_string() + } else { + output + .language + .as_deref() + .and_then(safe_language) + .or_else(|| original_language.and_then(safe_language)) + .unwrap_or_else(|| "und".to_string()) + }; + let staged = StagedSubtitle { + path: output.vtt_path.clone(), + language, + kind: SubtitleKind::Transcribed, + format: "vtt".to_string(), + original_language: original_language.map(str::to_string), + }; + let archived = subtitles::archive_staged_subtitles(store_path, vec![staged]) + .into_iter() + .next() + .ok_or_else(|| { + with_user_message( + anyhow!("failed to archive the transcript of {entry_uid}"), + no_transcript_file_message(label), + ) + })?; + + // 10. Register. + let added = subtitles::register_transcript_artifact( + &conn, + store_path, + info.entry_id, + &archived, + kind, + transcriber.model(), + )?; + eprintln!( + "info: transcribed {entry_uid} with {kind} ({}) in {:.1}s", + subtitles::sanitize_model_name(transcriber.model()), + started.elapsed().as_secs_f64() + ); + Ok(added) +} + +#[cfg(test)] +pub(crate) mod test_support { + use std::path::{Path, PathBuf}; + + /// Stub ffmpeg: writes a 44-byte header's worth plus 0.1 s of zeros to its last argument. + pub(crate) const STUB_FFMPEG: &str = + "#!/bin/sh\nfor a; do last=\"$a\"; done\nhead -c 3244 /dev/zero > \"$last\"\n"; + + /// Writes an executable `#!/bin/sh` stub into `dir` via + /// [`crate::downloader::write_script`] (ETXTBSY-safe). That helper probes + /// the script with `--version`, so a guard line answering that probe is + /// inserted after the shebang: stub bodies (hanging engines, ffmpeg writing + /// to its last argument) must not run their real behaviour during the probe. + #[cfg(unix)] + pub(crate) fn write_stub_script(dir: &Path, name: &str, body: &str) -> PathBuf { + let path = dir.join(name); + let (shebang, rest) = body.split_once('\n').unwrap_or((body, "")); + let guarded = format!("{shebang}\n[ \"$1\" = --version ] && exit 0\n{rest}"); + crate::downloader::write_script(&path, &guarded); + path + } +} + +#[cfg(test)] +mod tests { + use super::test_support::{STUB_FFMPEG, write_stub_script}; + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, MutexGuard}; + + const ENV_VARS: [&str; 12] = [ + "ARCHIVR_TRANSCRIBE_ENGINES", + "ARCHIVR_WHISPER_CLI", + "ARCHIVR_WHISPER_MODEL", + "ARCHIVR_WHISPER_BACKEND", + "ARCHIVR_WHISPER_LANGUAGES", + "ARCHIVR_PARAKEET_CLI", + "ARCHIVR_PARAKEET_MODEL", + "ARCHIVR_PARAKEET_LANGUAGES", + "ARCHIVR_PHONON2_CLI", + "ARCHIVR_PHONON2_MODEL", + "ARCHIVR_TRANSCRIBE_TIMEOUT", + "ARCHIVR_FFMPEG", + ]; + + /// Serializes with every other transcription test and clears the engine env. + struct EnvGuard(#[allow(dead_code)] MutexGuard<'static, ()>); + + impl Drop for EnvGuard { + fn drop(&mut self) { + for k in ENV_VARS { + unsafe { std::env::remove_var(k) }; + } + } + } + + fn env_guard() -> EnvGuard { + let guard = TRANSCRIBE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + for k in ENV_VARS { + unsafe { std::env::remove_var(k) }; + } + EnvGuard(guard) + } + + fn set(k: &str, v: &str) { + unsafe { std::env::set_var(k, v) }; + } + + fn os(v: &[&str]) -> Vec { + v.iter().map(OsString::from).collect() + } + + /// `languages` empty = any language. + fn config(kind: &'static str, executable: &Path, languages: &[&str]) -> TranscriberConfig { + TranscriberConfig { + kind, + executable: executable.to_path_buf(), + model: "/models/ggml-tiny.bin".to_string(), + whisper_backend: WhisperBackend::WhisperCpp, + languages: (!languages.is_empty()) + .then(|| languages.iter().map(|s| s.to_string()).collect()), + timeout_secs: 30, + } + } + + // ── Pure helpers ─────────────────────────────────────────────────────── + + #[test] + fn whisper_cpp_args_with_language_hint() { + let args = whisper_cpp_args("M", Path::new("W"), Some("de"), Path::new("P")); + assert_eq!( + args, + os(&["-m", "M", "-f", "W", "-l", "de", "-ovtt", "-oj", "-of", "P", "-np"]) + ); + } + + #[test] + fn whisper_cpp_args_without_hint_uses_auto() { + let args = whisper_cpp_args("M", Path::new("W"), None, Path::new("P")); + assert_eq!(args[5], OsString::from("auto")); + } + + #[test] + fn whisper_language_hint_only_for_two_letter_bases() { + assert_eq!(whisper_language_hint(Some("de-orig")).as_deref(), Some("de")); + assert_eq!(whisper_language_hint(Some("en-GB")).as_deref(), Some("en")); + assert_eq!(whisper_language_hint(Some("yue")), None); + assert_eq!(whisper_language_hint(Some("zh-Hans")).as_deref(), Some("zh")); + assert_eq!(whisper_language_hint(None), None); + assert_eq!(whisper_language_hint(Some("x;rm")), None); + } + + #[test] + fn script_args_follow_contract() { + let args = script_args(Path::new("a.wav"), Path::new("t.vtt"), "large-v3", Some("de")); + assert_eq!( + args, + os(&["--input", "a.wav", "--output", "t.vtt", "--model", "large-v3", "--language", "de"]) + ); + let args = script_args(Path::new("a.wav"), Path::new("t.vtt"), "m", None); + assert!(!args.contains(&OsString::from("--language"))); + } + + #[test] + fn phonon2_args_are_transcribe_model_wav_json() { + assert_eq!( + phonon2_args("phonon-2", Path::new("a.wav")), + os(&["transcribe", "phonon-2", "a.wav", "--json"]) + ); + } + + #[test] + fn ffmpeg_resample_args_are_16k_mono_pcm() { + let args = ffmpeg_resample_args(Path::new("in.mp4"), Path::new("out.wav")); + let joined: Vec = args.iter().map(|a| a.to_string_lossy().into_owned()).collect(); + let joined = joined.join(" "); + for needle in ["-map 0:a:0", "-ac 1", "-ar 16000", "-c:a pcm_s16le", "-nostdin", "-i in.mp4"] { + assert!(joined.contains(needle), "{needle} missing in {joined}"); + } + assert_eq!(args.last().unwrap(), &OsString::from("out.wav")); + } + + #[test] + fn phonon2_refuses_known_non_english_language() { + let t = transcriber_from_config(config("phonon2", Path::new("fermion"), &["en"])); + for lang in ["de", "de-orig", "pt-BR"] { + assert!(!t.supports_language(Some(lang)), "{lang}"); + } + } + + #[test] + fn phonon2_accepts_english_variants_and_unknown() { + let t = transcriber_from_config(config("phonon2", Path::new("fermion"), &["en"])); + for lang in [Some("en"), Some("en-US"), Some("en-orig"), None] { + assert!(t.supports_language(lang), "{lang:?}"); + } + assert_eq!(t.label(), "Phonon-2"); + } + + #[test] + fn allowlist_gating_for_whisper_and_parakeet() { + for kind in ["whisper", "parakeet"] { + let t = transcriber_from_config(config(kind, Path::new("x"), &["en", "de"])); + assert!(t.supports_language(Some("de-orig"))); + assert!(!t.supports_language(Some("fr"))); + let any = transcriber_from_config(config(kind, Path::new("x"), &[])); + assert!(any.supports_language(Some("fr"))); + } + } + + #[test] + fn parse_language_list_trims_lowercases_dedups() { + assert_eq!( + parse_language_list(Some(" EN, de ,,en, Fr ")), + Some(vec!["en".to_string(), "de".to_string(), "fr".to_string()]) + ); + assert_eq!(parse_language_list(Some(" , ")), None); + assert_eq!(parse_language_list(None), None); + } + + #[test] + fn enabled_engine_kinds_ignores_unknown_and_duplicates() { + let _g = env_guard(); + assert!(enabled_engine_kinds().is_empty()); + set("ARCHIVR_TRANSCRIBE_ENGINES", "Phonon2, bogus, whisper, phonon2"); + assert_eq!(enabled_engine_kinds(), vec!["phonon2", "whisper"]); + } + + #[test] + fn transcriber_from_env_whisper_missing_model_names_variable() { + let _g = env_guard(); + let err = transcriber_from_env("whisper").unwrap_err().to_string(); + assert!(err.contains("ARCHIVR_WHISPER_MODEL"), "{err}"); + } + + #[test] + fn transcriber_from_env_whisper_script_backend_requires_cli() { + let _g = env_guard(); + set("ARCHIVR_WHISPER_BACKEND", "script"); + set("ARCHIVR_WHISPER_MODEL", "large-v3-turbo"); + let err = transcriber_from_env("whisper").unwrap_err().to_string(); + assert!(err.contains("ARCHIVR_WHISPER_CLI"), "{err}"); + set("ARCHIVR_WHISPER_CLI", "/opt/fw.py"); + let cfg = transcriber_from_env("whisper").unwrap(); + assert_eq!(cfg.whisper_backend, WhisperBackend::Script); + assert_eq!(cfg.executable, PathBuf::from("/opt/fw.py")); + } + + #[test] + fn transcriber_from_env_rejects_bad_backend() { + let _g = env_guard(); + set("ARCHIVR_WHISPER_BACKEND", "cuda"); + set("ARCHIVR_WHISPER_MODEL", "m"); + let err = transcriber_from_env("whisper").unwrap_err().to_string(); + assert!(err.contains("ARCHIVR_WHISPER_BACKEND"), "{err}"); + assert!(err.contains("whisper_cpp or script"), "{err}"); + } + + #[test] + fn transcriber_from_env_parakeet_missing_cli_names_variable() { + let _g = env_guard(); + let err = transcriber_from_env("parakeet").unwrap_err().to_string(); + assert!(err.contains("ARCHIVR_PARAKEET_CLI"), "{err}"); + set("ARCHIVR_PARAKEET_CLI", "/opt/parakeet.py"); + set("ARCHIVR_PARAKEET_LANGUAGES", "en"); + let cfg = transcriber_from_env("parakeet").unwrap(); + assert_eq!(cfg.model, "nvidia/parakeet-tdt-0.6b-v3"); + assert_eq!(cfg.languages, Some(vec!["en".to_string()])); + } + + #[test] + fn transcriber_from_env_phonon2_defaults_to_fermion_and_phonon_2() { + let _g = env_guard(); + let cfg = transcriber_from_env("phonon2").unwrap(); + assert_eq!(cfg.executable.file_name().unwrap(), "fermion"); + assert_eq!(cfg.model, "phonon-2"); + assert_eq!(cfg.languages, Some(vec!["en".to_string()])); + } + + #[test] + fn transcriber_from_env_rejects_unknown_kind() { + let _g = env_guard(); + let err = transcriber_from_env("vosk").unwrap_err().to_string(); + assert_eq!( + err, + "unknown transcription engine: vosk (expected one of whisper, parakeet, phonon2)" + ); + } + + #[test] + fn transcriber_from_env_timeout_default_and_override() { + let _g = env_guard(); + assert_eq!( + transcriber_from_env("phonon2").unwrap().timeout_secs, + DEFAULT_TRANSCRIBE_TIMEOUT_SECS + ); + set("ARCHIVR_TRANSCRIBE_TIMEOUT", "90"); + assert_eq!(transcriber_from_env("phonon2").unwrap().timeout_secs, 90); + } + + #[test] + fn request_from_env_rejects_configured_but_not_enabled_engine() { + let _g = env_guard(); + set("ARCHIVR_PHONON2_CLI", "/usr/bin/false"); + let err = request_from_env("phonon2").err().unwrap().to_string(); + assert!(err.contains("ARCHIVR_TRANSCRIBE_ENGINES"), "{err}"); + let err = request_from_env("vosk").err().unwrap().to_string(); + assert!(err.contains("unknown transcription engine"), "{err}"); + set("ARCHIVR_TRANSCRIBE_ENGINES", "phonon2"); + set("ARCHIVR_FFMPEG", "/x/ffmpeg"); + let req = request_from_env(" phonon2 ").unwrap(); + assert_eq!(req.transcriber.kind(), "phonon2"); + assert_eq!(req.settings.ffmpeg, PathBuf::from("/x/ffmpeg")); + } + + #[test] + fn available_transcribers_lists_enabled_and_configured_only() { + let _g = env_guard(); + set("ARCHIVR_TRANSCRIBE_ENGINES", "phonon2,whisper"); + set("ARCHIVR_PHONON2_CLI", "/usr/bin/false"); + let list = available_transcribers(); + assert_eq!(list.len(), 1, "{list:?}"); + assert_eq!(list[0].kind, "phonon2"); + assert_eq!(list[0].label, "Phonon-2"); + assert!(list[0].english_only); + assert_eq!(list[0].languages, Some(vec!["en".to_string()])); + + set("ARCHIVR_WHISPER_MODEL", "/m/ggml-tiny.bin"); + let kinds: Vec<_> = available_transcribers().iter().map(|t| t.kind).collect(); + assert_eq!(kinds, vec!["whisper", "phonon2"]); + } + + #[test] + fn format_vtt_timestamp_formats_and_clamps() { + assert_eq!(format_vtt_timestamp(3661.5), "01:01:01.500"); + assert_eq!(format_vtt_timestamp(-1.0), "00:00:00.000"); + assert_eq!(format_vtt_timestamp(0.0004), "00:00:00.000"); + } + + #[test] + fn wav_duration_from_byte_length() { + assert_eq!(wav_duration_secs(44 + 32_000 * 3), 3.0); + assert_eq!(wav_duration_secs(10), 0.0); + } + + /// Real `fermion transcribe phonon-2 --json` stdout (fermion-research + /// 0.2.9, MLX backend, 2026-10-05), with the `words` list trimmed. + const PHONON2_SAMPLE_JSON: &str = r#"{"text": "Hello World. This is a short test of the transcription engine. It should produce a few segments of English speech.", "model": "FermionResearch/Phonon-2", "profile": "five-value", "backend": "phonon2-five-value", "engine": "mlx", "duration_seconds": 6.545, "decode_seconds": 3.117, "wall_seconds": 88.42, "segment_count": 1, "segments": [{"id": 0, "start": 0.0, "end": 6.545, "text": "Hello World. This is a short test of the transcription engine. It should produce a few segments of English speech."}], "words": [{"text": "Hello", "start": 0.0, "end": 0.4}, {"text": "World.", "start": 0.48, "end": 1.04}, {"text": "This", "start": 1.04, "end": 1.2}], "truncated": false}"#; + + #[test] + fn phonon_json_to_vtt_from_real_sample() { + let vtt = phonon_json_to_vtt(PHONON2_SAMPLE_JSON, 6.545).unwrap(); + assert_eq!( + vtt, + "WEBVTT\n\n00:00:00.000 --> 00:00:06.545\nHello World. This is a short test of the transcription engine. It should produce a few segments of English speech.\n\n" + ); + // Without segments, the real `words` shape (`text`/`start`/`end`) is used. + let mut value: serde_json::Value = serde_json::from_str(PHONON2_SAMPLE_JSON).unwrap(); + value.as_object_mut().unwrap().remove("segments"); + let vtt = phonon_json_to_vtt(&value.to_string(), 6.545).unwrap(); + assert!(vtt.contains("00:00:00.000 --> 00:00:01.200\nHello World. This\n"), "{vtt}"); + } + + #[test] + fn phonon_json_to_vtt_from_segments_fixture() { + let json = r#"{"text":"Hello world. Second & last.","model":"phonon-2","truncated":false, + "segments":[{"start":0.0,"end":1.5,"text":" Hello world."},{"start":1.5,"end":3.25,"text":"Second & last."}], + "words":[{"word":"Hello","start":0.0,"end":0.4}]}"#; + let vtt = phonon_json_to_vtt(json, 3.25).unwrap(); + assert!(vtt.starts_with("WEBVTT\n\n00:00:00.000 --> 00:00:01.500\nHello world.\n"), "{vtt}"); + assert!(vtt.contains("Second & last.")); + assert_eq!( + subtitles::subtitle_to_transcript(&vtt), + "Hello world.\nSecond & last." + ); + } + + #[test] + fn phonon_json_to_vtt_groups_words_when_no_segments() { + let words: Vec = (0..20) + .map(|i| format!(r#"{{"word":"w{i}","start":{},"end":{}}}"#, i as f64, i as f64 + 0.5)) + .collect(); + let json = format!(r#"{{"text":"ignored","words":[{}]}}"#, words.join(",")); + let vtt = phonon_json_to_vtt(&json, 20.0).unwrap(); + let cues = vtt.matches(" --> ").count(); + assert_eq!(cues, 3, "{vtt}"); + assert!(vtt.contains("00:00:00.000 --> 00:00:06.500\nw0 w1 w2 w3 w4 w5 w6\n"), "{vtt}"); + } + + #[test] + fn phonon_json_to_vtt_falls_back_to_single_cue_from_text() { + let vtt = phonon_json_to_vtt(r#"{"text":"just ","model":"phonon-2"}"#, 2.5).unwrap(); + assert_eq!(vtt, "WEBVTT\n\n00:00:00.000 --> 00:00:02.500\njust <text>\n\n"); + } + + #[test] + fn phonon_json_to_vtt_rejects_non_json() { + assert!(phonon_json_to_vtt("Hello world", 1.0).is_err()); + assert!(phonon_json_to_vtt("[1,2]", 1.0).is_err()); + assert!(phonon_json_to_vtt(r#"{"model":"phonon-2"}"#, 1.0).is_err()); + } + + #[test] + fn transcription_user_message_found_through_context_layers() { + let err = with_user_message(anyhow!("diagnostic /secret/path"), "copy".to_string()); + assert_eq!(transcription_user_message(&err).as_deref(), Some("copy")); + let wrapped = err.context("outer layer").context("outermost"); + assert_eq!(transcription_user_message(&wrapped).as_deref(), Some("copy")); + assert_eq!(transcription_user_message(&anyhow!("plain")), None); + assert!(is_no_transcript_output(&anyhow!("x").context(NoTranscriptOutput).context("y"))); + } + + #[test] + fn language_unsupported_copy_names_supported_languages() { + let en = transcription_language_unsupported_message("Phonon-2", "de", &["en".to_string()]); + assert!(en.contains("only supports English"), "{en}"); + assert!(en.contains("“de”"), "{en}"); + let many = transcription_language_unsupported_message( + "NVIDIA Parakeet", + "ja", + &["en".to_string(), "de".to_string()], + ); + assert!(many.contains("these languages: en, de"), "{many}"); + } + + // ── Archive fixtures ─────────────────────────────────────────────────── + + fn youtube_entry( + canonical_url: &str, + with_media: bool, + ) -> (tempfile::TempDir, ArchivePaths, String, i64) { + let temp = tempfile::tempdir().unwrap(); + let paths = crate::archive::initialize_archive( + temp.path(), + &temp.path().join("store"), + "Test archive", + false, + ) + .unwrap(); + let conn = database::open_or_initialize(&paths.archive_path).unwrap(); + let user_id = database::ensure_default_user(&conn).unwrap(); + let run = database::create_archive_run(&conn, user_id, 1).unwrap(); + let source_id = database::upsert_source_identity( + &conn, + "youtube", + "video", + Some("vid-1"), + Some(canonical_url), + canonical_url, + ) + .unwrap(); + let entry = database::create_archived_entry( + &conn, + &database::NewEntry { + source_identity_id: source_id, + archive_run_id: run.id, + parent_entry_id: None, + root_entry_id: None, + created_by_user_id: user_id, + owned_by_user_id: user_id, + source_kind: "youtube".to_string(), + entity_kind: "video".to_string(), + title: Some("A video".to_string()), + visibility: "private".to_string(), + representation_kind: "video".to_string(), + source_metadata_json: "{}".to_string(), + display_metadata_json: None, + }, + ) + .unwrap(); + if with_media { + add_primary_media(&paths, entry.id, "raw/media.mp4"); + } + (temp, paths, entry.entry_uid, entry.id) + } + + fn add_primary_media(paths: &ArchivePaths, entry_id: i64, relpath: &str) { + let file = paths.store_path.join(relpath); + fs::create_dir_all(file.parent().unwrap()).unwrap(); + fs::write(&file, b"fake mp4 bytes").unwrap(); + let conn = database::open_or_initialize(&paths.archive_path).unwrap(); + let blob_id = database::upsert_blob( + &conn, + &database::BlobRecord { + sha256: format!("media-{entry_id}"), + byte_size: 14, + mime_type: Some("video/mp4".to_string()), + extension: Some("mp4".to_string()), + raw_relpath: relpath.to_string(), + }, + ) + .unwrap(); + database::add_entry_artifact( + &conn, + &database::NewArtifact { + entry_id, + artifact_role: "primary_media".to_string(), + storage_area: "raw".to_string(), + relpath: relpath.to_string(), + blob_id: Some(blob_id), + logical_path: None, + metadata_json: None, + }, + ) + .unwrap(); + } + + fn subtitle_rows(paths: &ArchivePaths, entry_id: i64) -> Vec { + let conn = database::open_or_initialize(&paths.archive_path).unwrap(); + database::list_entry_artifacts_by_role(&conn, entry_id, subtitles::SUBTITLE_ARTIFACT_ROLE) + .unwrap() + } + + fn no_transcribe_dirs_left(paths: &ArchivePaths) -> bool { + let temp = paths.store_path.join("temp"); + !fs::read_dir(&temp).map_or(false, |rd| { + rd.flatten() + .any(|e| e.file_name().to_string_lossy().starts_with("transcribe-")) + }) + } + + const STUB_WHISPER: &str = "#!/bin/sh\nprefix=\"\"\nwhile [ $# -gt 0 ]; do\n if [ \"$1\" = \"-of\" ]; then prefix=\"$2\"; shift; fi\n shift\ndone\nprintf 'WEBVTT\\n\\n00:00:00.000 --> 00:00:02.000\\nhello world\\n' > \"$prefix.vtt\"\nprintf '{\"result\":{\"language\":\"en\"}}' > \"$prefix.json\"\n"; + + fn stub_request(dir: &Path, engine_body: &str, timeout_secs: u64) -> TranscriptionRequest { + let ffmpeg = write_stub_script(dir, "ffmpeg", STUB_FFMPEG); + let engine = write_stub_script(dir, "whisper-cli", engine_body); + let mut cfg = config("whisper", &engine, &[]); + cfg.timeout_secs = timeout_secs; + TranscriptionRequest { + transcriber: transcriber_from_config(cfg), + settings: TranscriptionSettings { ffmpeg }, + } + } + + #[test] + fn select_audio_source_prefers_existing_archived_media() { + let store = tempfile::tempdir().unwrap(); + fs::create_dir_all(store.path().join("raw")).unwrap(); + fs::write(store.path().join("raw/a.mp4"), b"x").unwrap(); + fs::write(store.path().join("raw/p.html"), b"x").unwrap(); + let artifact = |id, relpath: &str, mime: Option<&str>| database::RoleArtifact { + id, + relpath: relpath.to_string(), + mime_type: mime.map(str::to_string), + metadata_json: None, + }; + assert_eq!( + select_audio_source(store.path(), &[artifact(1, "raw/a.mp4", Some("video/mp4"))]), + Some(store.path().join("raw/a.mp4")) + ); + assert_eq!( + select_audio_source(store.path(), &[artifact(1, "raw/missing.mp4", None)]), + None + ); + assert_eq!( + select_audio_source(store.path(), &[artifact(1, "raw/p.html", Some("text/html"))]), + None + ); + assert_eq!( + select_audio_source( + store.path(), + &[artifact(1, "raw/missing.m4a", None), artifact(2, "raw/a.mp4", None)] + ), + Some(store.path().join("raw/a.mp4")) + ); + } + + #[cfg(unix)] + #[test] + fn transcribe_entry_with_stub_engine_registers_transcribed_artifact() { + let _g = env_guard(); + let (temp, paths, uid, entry_id) = youtube_entry("youtube-test:offline", true); + let request = stub_request(temp.path(), STUB_WHISPER, 30); + + assert_eq!(transcribe_entry(&paths, &uid, &request, Some("en"), &[]).unwrap(), 1); + + let rows = subtitle_rows(&paths, entry_id); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].mime_type.as_deref(), Some("text/vtt")); + assert!(rows[0].relpath.starts_with("raw/") && rows[0].relpath.ends_with(".vtt")); + assert!(paths.store_path.join(&rows[0].relpath).is_file()); + let meta: serde_json::Value = + serde_json::from_str(rows[0].metadata_json.as_deref().unwrap()).unwrap(); + assert_eq!(meta["kind"], "transcribed"); + assert_eq!(meta["origin"], "transcription"); + assert_eq!(meta["engine"], "whisper"); + assert_eq!(meta["model"], "ggml-tiny.bin"); + assert_eq!(meta["language"], "en"); + assert_eq!(meta["original_language"], "en"); + assert!(no_transcribe_dirs_left(&paths)); + } + + #[cfg(unix)] + #[test] + fn transcribe_entry_timeout_cleans_temp_and_returns_timeout_copy() { + let _g = env_guard(); + let (temp, paths, uid, _) = youtube_entry("youtube-test:offline", true); + let request = stub_request(temp.path(), "#!/bin/sh\nexec sleep 30\n", 1); + let started = Instant::now(); + let err = transcribe_entry(&paths, &uid, &request, None, &[]).unwrap_err(); + assert!(started.elapsed() < Duration::from_secs(10)); + let msg = transcription_user_message(&err).unwrap(); + assert!(msg.contains("timed out after 1 seconds"), "{msg}"); + assert!(no_transcribe_dirs_left(&paths)); + } + + #[cfg(unix)] + #[test] + fn transcribe_entry_engine_failure_message_is_sanitized() { + let _g = env_guard(); + let (temp, paths, uid, _) = youtube_entry("youtube-test:offline", true); + let request = stub_request( + temp.path(), + "#!/bin/sh\necho 'cannot open /secret/path' >&2\nexit 1\n", + 30, + ); + let err = transcribe_entry(&paths, &uid, &request, None, &[]).unwrap_err(); + let msg = transcription_user_message(&err).unwrap(); + assert_eq!(msg, engine_exit_message("Whisper")); + assert!(!msg.contains("/secret/path")); + assert!(format!("{err:#}").contains("/secret/path")); + assert!(no_transcribe_dirs_left(&paths)); + } + + #[cfg(unix)] + #[test] + fn transcribe_entry_empty_transcript_is_no_speech() { + let _g = env_guard(); + let (temp, paths, uid, entry_id) = youtube_entry("youtube-test:offline", true); + let body = "#!/bin/sh\nprefix=\"\"\nwhile [ $# -gt 0 ]; do\n if [ \"$1\" = \"-of\" ]; then prefix=\"$2\"; shift; fi\n shift\ndone\nprintf 'WEBVTT\\n' > \"$prefix.vtt\"\n"; + let request = stub_request(temp.path(), body, 30); + let err = transcribe_entry(&paths, &uid, &request, None, &[]).unwrap_err(); + assert_eq!( + transcription_user_message(&err).as_deref(), + Some(NO_SUBTITLES_AFTER_TRANSCRIPTION_MESSAGE) + ); + assert!(subtitle_rows(&paths, entry_id).is_empty()); + } + + #[cfg(unix)] + #[test] + fn transcribe_entry_missing_vtt_is_no_file_copy() { + let _g = env_guard(); + let (temp, paths, uid, _) = youtube_entry("youtube-test:offline", true); + let request = stub_request(temp.path(), "#!/bin/sh\nexit 0\n", 30); + let err = transcribe_entry(&paths, &uid, &request, None, &[]).unwrap_err(); + assert_eq!( + transcription_user_message(&err), + Some(no_transcript_file_message("Whisper")) + ); + } + + #[test] + fn transcribe_entry_skips_when_usable_subtitle_already_exists() { + let _g = env_guard(); + let (temp, paths, uid, entry_id) = youtube_entry("youtube-test:offline", true); + let staged_path = temp.path().join("existing.en.vtt"); + fs::write(&staged_path, "WEBVTT\n\n00:00:00.000 --> 00:00:01.000\nexisting words\n").unwrap(); + let archived = subtitles::archive_staged_subtitles( + &paths.store_path, + vec![StagedSubtitle { + path: staged_path, + language: "en".to_string(), + kind: SubtitleKind::Manual, + format: "vtt".to_string(), + original_language: None, + }], + ); + let conn = database::open_or_initialize(&paths.archive_path).unwrap(); + subtitles::register_subtitle_artifacts( + &conn, + &paths.store_path, + entry_id, + &archived, + subtitles::SUBTITLE_ORIGIN_CAPTURE, + ) + .unwrap(); + // Spawning either of these would fail. + let request = TranscriptionRequest { + transcriber: transcriber_from_config(config( + "whisper", + Path::new("/nonexistent/whisper-cli"), + &[], + )), + settings: TranscriptionSettings { + ffmpeg: PathBuf::from("/nonexistent/ffmpeg"), + }, + }; + assert_eq!(transcribe_entry(&paths, &uid, &request, None, &[]).unwrap(), 0); + assert_eq!(subtitle_rows(&paths, entry_id).len(), 1); + } + + #[test] + fn transcribe_entry_without_audio_and_non_http_url_fails_with_no_audio_copy() { + let _g = env_guard(); + let (_temp, paths, uid, _) = youtube_entry("youtube-test:offline", false); + let request = TranscriptionRequest { + transcriber: transcriber_from_config(config( + "whisper", + Path::new("/nonexistent/whisper-cli"), + &[], + )), + settings: TranscriptionSettings { + ffmpeg: PathBuf::from("/nonexistent/ffmpeg"), + }, + }; + let err = transcribe_entry(&paths, &uid, &request, None, &[]).unwrap_err(); + assert_eq!(transcription_user_message(&err), Some(no_audio_message("Whisper"))); + assert!(no_transcribe_dirs_left(&paths)); + } + + #[cfg(unix)] + #[test] + fn transcribe_entry_ffmpeg_failure_is_extraction_copy() { + let _g = env_guard(); + let (temp, paths, uid, _) = youtube_entry("youtube-test:offline", true); + let mut request = stub_request(temp.path(), STUB_WHISPER, 30); + request.settings.ffmpeg = + write_stub_script(temp.path(), "bad-ffmpeg", "#!/bin/sh\necho 'no audio stream' >&2\nexit 1\n"); + let err = transcribe_entry(&paths, &uid, &request, None, &[]).unwrap_err(); + assert_eq!( + transcription_user_message(&err), + Some(extraction_failed_message("Whisper")) + ); + } + + #[test] + fn transcribe_entry_refuses_unsupported_language_before_audio_work() { + let _g = env_guard(); + let (_temp, paths, uid, _) = youtube_entry("youtube-test:offline", false); + let request = TranscriptionRequest { + transcriber: transcriber_from_config(config("phonon2", Path::new("/nonexistent/fermion"), &["en"])), + settings: TranscriptionSettings { + ffmpeg: PathBuf::from("/nonexistent/ffmpeg"), + }, + }; + let err = transcribe_entry(&paths, &uid, &request, Some("de-orig"), &[]).unwrap_err(); + let msg = transcription_user_message(&err).unwrap(); + assert!(msg.contains("Phonon-2") && msg.contains("English") && msg.contains("de-orig"), "{msg}"); + } + + /// In-process engine that records how many jobs ran at once. + struct OverlapProbe { + active: Arc, + max_seen: Arc, + } + + impl Transcriber for OverlapProbe { + fn kind(&self) -> &'static str { + "whisper" + } + fn label(&self) -> &'static str { + "Whisper" + } + fn model(&self) -> &str { + "probe" + } + fn timeout_secs(&self) -> u64 { + 30 + } + fn supports_language(&self, _: Option<&str>) -> bool { + true + } + fn supported_languages(&self) -> Option<&[String]> { + None + } + fn transcribe(&self, _: &Path, _: Option<&str>, out_dir: &Path, _: Instant) -> Result { + let now = self.active.fetch_add(1, Ordering::SeqCst) + 1; + self.max_seen.fetch_max(now, Ordering::SeqCst); + std::thread::sleep(Duration::from_millis(200)); + self.active.fetch_sub(1, Ordering::SeqCst); + let vtt_path = out_dir.join("transcript.vtt"); + fs::write(&vtt_path, "WEBVTT\n\n00:00:00.000 --> 00:00:01.000\nprobe words\n")?; + Ok(TranscriptOutput { vtt_path, language: None }) + } + } + + #[cfg(unix)] + #[test] + fn transcription_slot_serializes_jobs() { + let _g = env_guard(); + let active = Arc::new(AtomicUsize::new(0)); + let max_seen = Arc::new(AtomicUsize::new(0)); + let bin = tempfile::tempdir().unwrap(); + let ffmpeg = write_stub_script(bin.path(), "ffmpeg", STUB_FFMPEG); + let handles: Vec<_> = (0..2) + .map(|_| { + let (active, max_seen, ffmpeg) = (active.clone(), max_seen.clone(), ffmpeg.clone()); + std::thread::spawn(move || { + let (_temp, paths, uid, _) = youtube_entry("youtube-test:offline", true); + let request = TranscriptionRequest { + transcriber: Box::new(OverlapProbe { active, max_seen }), + settings: TranscriptionSettings { ffmpeg }, + }; + transcribe_entry(&paths, &uid, &request, None, &[]).unwrap() + }) + }) + .collect(); + for h in handles { + assert_eq!(h.join().unwrap(), 1); + } + assert_eq!(max_seen.load(Ordering::SeqCst), 1); + } +} diff --git a/crates/archivr-server/src/main.rs b/crates/archivr-server/src/main.rs index b191e9e..740ddc3 100644 --- a/crates/archivr-server/src/main.rs +++ b/crates/archivr-server/src/main.rs @@ -98,6 +98,36 @@ async fn main() -> Result<()> { ), _ => {} } + match archivr_core::database::fail_stalled_entry_summaries(&conn) { + Ok(n) if n > 0 => eprintln!( + "info: marked {n} stalled summary attempt(s) as failed in '{}'", + archive.id + ), + Err(e) => eprintln!( + "warn: stalled summary cleanup failed for '{}': {e:#}", + archive.id + ), + _ => {} + } + } + } + + // Repair legacy bare-link titles on X Article tweets (idempotent; no-op once fixed). + for archive in ®istry.archives { + let Ok(paths) = archivr_core::archive::read_archive_paths(&archive.archive_path) else { + continue; + }; + match archivr_core::capture::backfill_x_article_titles(&paths) { + Ok(0) => {} + Ok(n) => eprintln!( + "info: retitled {n} X Article entr{} in '{}'", + if n == 1 { "y" } else { "ies" }, + archive.id + ), + Err(e) => eprintln!( + "warn: X Article title backfill failed for '{}': {e:#}", + archive.id + ), } } diff --git a/crates/archivr-server/src/routes.rs b/crates/archivr-server/src/routes.rs index ec8b474..5830cf5 100644 --- a/crates/archivr-server/src/routes.rs +++ b/crates/archivr-server/src/routes.rs @@ -11,13 +11,18 @@ // POST /api/archives/:id/tags // POST/DELETE /api/archives/:id/entries/:uid/tags // PATCH /api/archives/:id/entries/:uid -// ADMIN — requires ROLE_ADMIN: (future) -// OWNER — requires ROLE_OWNER: (future) +// PERMISSION — authenticated + caller role_bits ∩ instance-settings mask (else 403): +// PUT /api/archives/:id/entries/:uid/children/order +// (mask = reorder_children_role_bits, default ADMIN|OWNER) +// ADMIN — requires ROLE_ADMIN: /api/admin/* (users, roles, cookie-rules) +// OWNER — requires ROLE_OWNER: +// changing reorder_children_role_bits via PATCH /api/admin/instance-settings // AUTH_SELF — own resources, require_auth() only: // GET/POST/DELETE /api/auth/tokens // POST /api/auth/logout, GET/PATCH /api/auth/me -// SETTINGS — instance settings, require ROLE_ADMIN: -// GET/PATCH /api/admin/instance-settings +// SETTINGS — instance settings: +// GET/PATCH /api/admin/instance-settings (ROLE_ADMIN; the reorder mask field is OWNER-only) +// GET /api/admin/yt-dlp, POST /api/admin/yt-dlp/update (ROLE_ADMIN; 409 while an update runs) // ──────────────────────────────────────────────────────────────────────────── use parking_lot::Mutex; @@ -29,14 +34,14 @@ use std::{ time::{Duration, Instant}, }; -use archivr_core::{archive, capture, database, downloader}; +use archivr_core::{archive, capture, database, downloader, summarizer, thread_title}; use axum::{ Json, Router, extract::{ConnectInfo, DefaultBodyLimit, Multipart, Path, Query, Request, State}, http::StatusCode, middleware::Next, response::{IntoResponse, Response}, - routing::{delete, get, patch, post}, + routing::{delete, get, patch, post, put}, }; use tower::ServiceExt; use tower_http::services::{ServeDir, ServeFile}; @@ -49,6 +54,10 @@ use rusqlite::OptionalExtension; const LOGIN_WINDOW: Duration = Duration::from_secs(15 * 60); const LOGIN_MAX_ATTEMPTS: usize = 5; +const MAX_TEXT_CAPTURE_BODY_BYTES: usize = 2 * 1024 * 1024; +// JSON can expand each body byte into a six-byte `\\u00XX` escape sequence, +// plus a small request envelope. +const MAX_TEXT_CAPTURE_REQUEST_BYTES: usize = MAX_TEXT_CAPTURE_BODY_BYTES * 6 + 64 * 1024; // Short-lived token granting unauthenticated access to one specific artifact. // Used so Cast / AirPlay devices (which carry no session cookie) can fetch media. @@ -256,6 +265,10 @@ pub fn app_with_state(state: AppState) -> Router { "/api/archives/:archive_id/entries/:entry_uid/children", get(list_entry_children), ) + .route( + "/api/archives/:archive_id/entries/:entry_uid/children/order", + put(reorder_entry_children_handler), + ) .route( "/api/archives/:archive_id/entries/:entry_uid/artifacts/:artifact_index", get(serve_artifact), @@ -268,6 +281,18 @@ pub fn app_with_state(state: AppState) -> Router { "/api/archives/:archive_id/entries/:entry_uid/rearchive", post(rearchive_handler), ) + .route( + "/api/archives/:archive_id/entries/:entry_uid/thread-title", + post(generate_thread_title_handler), + ) + .route( + "/api/archives/:archive_id/entries/:entry_uid/summary", + get(entry_summary_handler).post(request_entry_summary_handler), + ) + .route( + "/api/summary/transcription-engines", + get(transcription_engines_handler), + ) .route( "/api/archives/:archive_id/entries/:entry_uid/favicon", get(serve_entry_favicon), @@ -275,6 +300,11 @@ pub fn app_with_state(state: AppState) -> Router { .route("/api/archives/:archive_id/blobs/:sha256", get(serve_blob)) .route("/api/archives/:archive_id/runs", get(list_runs)) .route("/api/archives/:archive_id/captures", post(capture_handler)) + .route( + "/api/archives/:archive_id/captures/text", + post(capture_text_handler) + .layer(DefaultBodyLimit::max(MAX_TEXT_CAPTURE_REQUEST_BYTES)), + ) .route( "/api/archives/:archive_id/uploads", post(upload_handler) @@ -352,6 +382,8 @@ pub fn app_with_state(state: AppState) -> Router { "/api/admin/instance-settings", get(get_instance_settings_handler).patch(update_instance_settings_handler), ) + .route("/api/admin/yt-dlp", get(get_yt_dlp_status_handler)) + .route("/api/admin/yt-dlp/update", post(update_yt_dlp_handler)) .route( "/api/admin/cookie-rules", get(list_cookie_rules_handler).post(create_cookie_rule_handler), @@ -508,11 +540,290 @@ async fn entry_detail( return Err(ApiError::unauthorized("login required")); } } - let detail = archive::get_entry_detail(&conn, &entry_uid)? + let mut detail = archive::get_entry_detail(&conn, &entry_uid)? .ok_or(ApiError::not_found("entry not found"))?; + if matches!(auth_user, AuthUser::Guest) { + let entry_id = database::entry_id_for_uid(&conn, &entry_uid)? + .ok_or(ApiError::not_found("entry not found"))?; + detail.latest_summary = database::latest_completed_entry_summary(&conn, entry_id)?; + detail.summary_attempt = None; + } Ok(Json(detail)) } +#[derive(Debug, serde::Deserialize)] +struct SummaryRequestBody { + provider: String, + #[serde(default)] + force: bool, + #[serde(default)] + include_images: bool, + /// Local transcription engine to use only if a YouTube video has no + /// subtitles; empty or absent means none. + #[serde(default)] + transcribe_engine: Option, +} + +/// `GET /api/archives/:id/entries/:uid/summary` +/// +/// Read-only, gated exactly like entry detail: a guest may read a summary only +/// for an entry whose content they could already read. Returns +/// `{ entry_uid, summary }` with a null summary when none has been requested, +/// which is also what the frontend polls while a job is running. +async fn entry_summary_handler( + State(state): State, + auth_user: AuthUser, + Path((archive_id, entry_uid)): Path<(String, String)>, +) -> Result, ApiError> { + let mounted = mounted_archive(&state, &archive_id)?; + let conn = database::open_or_initialize(&mounted.archive_path)?; + if matches!(auth_user, AuthUser::Guest) + && !database::is_entry_publicly_accessible(&conn, &entry_uid)? + { + return Err(ApiError::unauthorized("login required")); + } + let entry_id = database::entry_id_for_uid(&conn, &entry_uid)? + .ok_or(ApiError::not_found("entry not found"))?; + let (summary, attempt) = if matches!(auth_user, AuthUser::Guest) { + (database::latest_completed_entry_summary(&conn, entry_id)?, None) + } else { + ( + database::latest_completed_entry_summary(&conn, entry_id)?, + database::latest_entry_summary_attempt(&conn, entry_id)?, + ) + }; + let mut body = serde_json::json!({ "entry_uid": entry_uid, "summary": summary }); + if !matches!(auth_user, AuthUser::Guest) { + body["attempt"] = serde_json::to_value(attempt)?; + } + Ok(Json(body)) +} + +/// `POST /api/archives/:id/entries/:uid/summary` +/// +/// Manual-only summary generation. Body: `{ "provider": "...", "force": false, +/// "include_images": false, "transcribe_engine": "whisper" }`; the optional +/// `transcribe_engine` is used only for YouTube videos that end up without +/// subtitles, and the transcription runs in the background. +/// +/// Provider and transcription-engine configuration and content extraction are +/// all resolved *before* spawning, so a missing env var or an unsummarizable +/// artifact comes back as a synchronous 400 naming the exact problem rather +/// than as a background job the caller has to poll only to learn about a +/// config typo. +/// +/// Returns 200 with the existing row when an identical cache key already +/// completed and `force` is false; otherwise 202 with a pending row. +/// +/// YouTube videos without archived subtitles also return 202: the pending row +/// carries a placeholder input digest while subtitles are fetched in the +/// background, and a failed fetch fails the row with a safe explanation. +async fn request_entry_summary_handler( + State(state): State, + auth_user: AuthUser, + Path((archive_id, entry_uid)): Path<(String, String)>, + Json(body): Json, +) -> Result<(StatusCode, Json), ApiError> { + auth_user.require_role(ROLE_USER)?; + let mounted = mounted_archive(&state, &archive_id)?; + let archive_paths = + archive::read_archive_paths(&mounted.archive_path).map_err(ApiError::from)?; + + // 1. Provider config from the environment. The error text carries the exact + // variable name, which is the whole point of returning it as a 400. + let provider_cfg = summarizer::provider_from_env(&body.provider) + .map_err(|e| ApiError::bad_request(&format!("{e:#}")))?; + if body.include_images && matches!(provider_cfg, summarizer::ProviderConfig::ClaudeCli(_)) { + return Err(ApiError::bad_request( + "Claude CLI cannot attach local images; choose an HTTP provider or Codex CLI", + )); + } + let transcription = match body + .transcribe_engine + .as_deref() + .map(str::trim) + .filter(|k| !k.is_empty()) + { + Some(kind) => Some( + archivr_core::transcriber::request_from_env(kind) + .map_err(|e| ApiError::bad_request(&format!("{e:#}")))?, + ), + None => None, + }; + let provider = summarizer::provider_from_config(provider_cfg); + let summary_options = summarizer::SummaryBuildOptions { + include_images: body.include_images, + }; + + // 2. Preflight extraction and SQLite cache/attempt work are synchronous + // core operations, so keep them off the Axum runtime. This also means the + // input claimed here is passed directly to the provider worker below. + let preflight_paths = archive_paths.clone(); + let preflight_uid = entry_uid.clone(); + let provider_kind = provider.kind().to_string(); + let provider_model = provider.model().map(str::to_string); + let force = body.force; + enum PreflightOutcome { + Cached(database::EntrySummaryRecord), + Pending { input: summarizer::SummaryInput, summary_uid: String }, + FetchSubtitles { summary_uid: String }, + } + let outcome = tokio::task::spawn_blocking(move || -> anyhow::Result { + let conn = database::open_or_initialize(&preflight_paths.archive_path)?; + let entry_id = database::entry_id_for_uid(&conn, &preflight_uid)? + .ok_or_else(|| anyhow::anyhow!("entry not found"))?; + // Preserve the route's historical 404 before attempting content + // extraction, whose own missing-entry error includes the uid. + let input = match summarizer::build_summary_input(&preflight_paths, &preflight_uid, summary_options) { + Ok(input) => input, + Err(e) if summarizer::is_no_subtitles_error(&e) => { + // The real digest is unknown until subtitles are fetched, so + // there is no cache key to look up yet. + let summary_uid = database::upsert_pending_entry_summary( + &conn, entry_id, &provider_kind, provider_model.as_deref(), + summarizer::PROMPT_VERSION, summarizer::SUBTITLE_FETCH_PENDING_INPUT_SHA256, + )?; + return Ok(PreflightOutcome::FetchSubtitles { summary_uid }); + } + Err(e) => return Err(e), + }; + if !force { + if let Some(existing) = database::find_entry_summary( + &conn, entry_id, &provider_kind, provider_model.as_deref(), + summarizer::PROMPT_VERSION, &input.input_sha256, + )? { + if existing.status == "completed" { + return Ok(PreflightOutcome::Cached(existing)); + } + } + } + let summary_uid = database::upsert_pending_entry_summary( + &conn, entry_id, &provider_kind, provider_model.as_deref(), + summarizer::PROMPT_VERSION, &input.input_sha256, + )?; + Ok(PreflightOutcome::Pending { input, summary_uid }) + }) + .await + .map_err(|e| ApiError::internal(&format!("summary preflight task failed: {e}")))? + .map_err(|e| { + if summarizer::is_unsupported_summary_content_error(&e) { + ApiError::bad_request(summarizer::UNSUPPORTED_SUMMARY_CONTENT_MESSAGE) + } else if format!("{e:#}") == "entry not found" { + ApiError::not_found("entry not found") + } else { + ApiError::bad_request(&format!("{e:#}")) + } + })?; + let (input, summary_uid) = match outcome { + PreflightOutcome::Cached(existing) => return Ok(( + StatusCode::OK, + serde_json::to_value(&existing).map(Json) + .map_err(|e| ApiError::internal(&e.to_string()))?, + )), + PreflightOutcome::Pending { input, summary_uid } => (Some(input), summary_uid), + PreflightOutcome::FetchSubtitles { summary_uid } => (None, summary_uid), + }; + + let archive_path = mounted.archive_path.clone(); + let auth_db_path = state.auth_db_path.clone(); + let entry_uid_bg = entry_uid.clone(); + let summary_uid_bg = summary_uid.clone(); + tokio::task::spawn_blocking(move || { + // A prebuilt input was claimed during blocking preflight, so no row is + // reset and no archive content is read twice. Without one, subtitles + // are fetched first and the row gets its real input digest. + let run = || -> anyhow::Result<()> { + let input = match input { + Some(input) => input, + None => { + let cookie_rules = match database::open_auth_db(&auth_db_path) { + Ok(conn) => database::list_cookie_rules(&conn).unwrap_or_default(), + Err(_) => vec![], + }; + let input = summarizer::build_summary_input_with_subtitle_fetch( + &archive_paths, + &entry_uid_bg, + summary_options, + &cookie_rules, + transcription.as_ref(), + )?; + let conn = database::open_or_initialize(&archive_path)?; + database::update_entry_summary_input_sha256( + &conn, + &summary_uid_bg, + &input.input_sha256, + )?; + input + } + }; + summarizer::summarize_prebuilt_entry( + &archive_paths, + input, + &summary_uid_bg, + provider.as_ref(), + ) + .map(|_| ()) + }; + if let Err(e) = run() { + eprintln!("warn: summary {summary_uid_bg}: {e:#}"); + record_background_summary_failure(&archive_path, &summary_uid_bg, &e); + } + }); + + Ok(( + StatusCode::ACCEPTED, + Json(serde_json::json!({ + "summary_uid": summary_uid, + "status": "pending", + "entry_uid": entry_uid, + })), + )) +} + +fn summary_failure_error_text(error: &anyhow::Error) -> String { + if let Some(copy) = archivr_core::transcriber::transcription_user_message(error) { + copy + } else if summarizer::is_no_subtitles_error(error) { + summarizer::NO_SUBTITLES_SUMMARY_MESSAGE.to_string() + } else if summarizer::is_unsupported_summary_content_error(error) { + summarizer::UNSUPPORTED_SUMMARY_CONTENT_MESSAGE.to_string() + } else { + format!("{error:#}") + } +} + +/// `GET /api/summary/transcription-engines` +/// +/// Local transcription engines that are enabled (`ARCHIVR_TRANSCRIBE_ENGINES`) +/// and fully configured. Reads env only; an empty array means the feature is off. +async fn transcription_engines_handler( + auth_user: AuthUser, +) -> Result>, ApiError> { + auth_user.require_role(ROLE_USER)?; + Ok(Json(archivr_core::transcriber::available_transcribers())) +} + +fn record_background_summary_failure( + archive_path: &std::path::Path, + summary_uid: &str, + error: &anyhow::Error, +) { + if let Ok(conn) = database::open_or_initialize(archive_path) { + if let Ok(Some(row)) = database::get_entry_summary_by_uid(&conn, summary_uid) { + if row.status != "failed" { + database::update_entry_summary_status( + &conn, + summary_uid, + "failed", + None, + Some(&summary_failure_error_text(error)), + ) + .ok(); + } + } + } +} + async fn list_runs( State(state): State, auth_user: AuthUser, @@ -902,6 +1213,104 @@ async fn delete_entry_handler( } } +#[derive(Debug, serde::Deserialize)] +struct ThreadTitleBody { + provider: String, +} + +/// `POST /api/archives/:id/entries/:uid/thread-title` — names an X thread with a +/// cheap model (`thread_title`) and saves it as the entry title. Same role gate as +/// title PATCH. Synchronous: load, provider call and save run in one blocking task. +async fn generate_thread_title_handler( + State(state): State, + auth_user: AuthUser, + Path((archive_id, entry_uid)): Path<(String, String)>, + Json(body): Json, +) -> Result, ApiError> { + auth_user.require_role(ROLE_USER)?; + let mounted = mounted_archive(&state, &archive_id)?; + let paths = archive::read_archive_paths(&mounted.archive_path).map_err(ApiError::from)?; + let settings = database::get_instance_settings(&database::open_auth_db(&state.auth_db_path)?)?; + let cfg = thread_title::title_provider_from_env( + &body.provider, + settings.title_model_override(&body.provider), + ) + .map_err(|e| ApiError::bad_request(&format!("{e:#}")))?; + let uid = entry_uid.clone(); + let title = tokio::task::spawn_blocking(move || -> Result { + let input = thread_title::load_thread_title_input(&paths, &uid) + .map_err(|e| thread_title_load_error(&uid, e))? + .ok_or_else(|| ApiError::not_found("entry not found"))?; + let title = thread_title::generate_thread_title(&cfg, &input).map_err(|e| { + eprintln!("warn: thread title {uid}: {e:#}"); + ApiError { + status: StatusCode::BAD_GATEWAY, + message: format!("{e:#}"), + } + })?; + let conn = database::open_or_initialize(&paths.archive_path)?; + if !database::update_entry_title(&conn, &uid, Some(&title))? { + return Err(ApiError::not_found("entry not found")); + } + eprintln!("info: thread title {uid}: {title}"); + Ok(title) + }) + .await + .map_err(|e| ApiError::internal(&format!("thread title task failed: {e}")))??; + Ok(Json(serde_json::json!({ "entry_uid": entry_uid, "title": title }))) +} + +/// Expected load failures (not a thread, no archived text) are 400 with their +/// message; anything else (DB/IO) is logged and returned as a generic 500 so +/// absolute store paths never reach the client. +fn thread_title_load_error(uid: &str, error: anyhow::Error) -> ApiError { + match thread_title::thread_title_user_message(&error) { + Some(message) => ApiError::bad_request(&message), + None => { + eprintln!("error: thread title {uid}: {error:#}"); + ApiError::internal("failed to load thread for title generation") + } + } +} + +async fn reorder_entry_children_handler( + State(state): State, + auth_user: AuthUser, + Path((archive_id, entry_uid)): Path<(String, String)>, + Json(body): Json, +) -> Result { + let (_, role_bits) = auth_user.require_auth()?; // guests → 401 + let settings = database::get_instance_settings(&database::open_auth_db(&state.auth_db_path)?)?; + if !settings.can_reorder_children(role_bits) { + return Err(ApiError::forbidden( + "your role is not allowed to reorder child entries", + )); + } + let mounted = mounted_archive(&state, &archive_id)?; + let mut conn = database::open_or_initialize(&mounted.archive_path)?; + // IMMEDIATE: take the write lock before the set check so a concurrent + // sync capture cannot add a sibling between validation and the writes. + let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; + // A role granted reorder must still be able to see the parent's children + // (same rule as the children GET); hidden parents are indistinguishable + // from missing ones. + if !database::caller_sees_all_children(&tx, &entry_uid, role_bits)? { + return Err(ApiError::not_found("entry not found")); + } + match database::reorder_child_entries(&tx, &entry_uid, &body.child_uids)? { + database::ReorderChildrenOutcome::Reordered => { + tx.commit()?; + Ok(StatusCode::NO_CONTENT) + } + database::ReorderChildrenOutcome::ParentNotFound => { + Err(ApiError::not_found("entry not found")) + } + database::ReorderChildrenOutcome::ChildSetMismatch => Err(ApiError::bad_request( + "child_uids must list every current child of the entry exactly once", + )), + } +} + #[derive(Debug, serde::Deserialize)] struct CaptureBody { locator: String, @@ -913,6 +1322,8 @@ struct CaptureBody { modal_closer_enabled: Option, /// Route through Freedium mirror for WebPage captures. Absent = true (on by default). via_freedium: Option, + /// Download YouTube subtitles. Absent = true. + download_subtitles: Option, /// Per-video quality overrides for playlist captures. /// Keys are yt-dlp video IDs; values are quality strings ("best", "1080p", "audio", etc.). #[serde(default)] @@ -922,6 +1333,14 @@ struct CaptureBody { sync: bool, } +#[derive(Debug, serde::Deserialize)] +struct CaptureTextBody { + title: String, + body: String, + /// MIME type: "text/plain" or "text/markdown" (defaults to "text/markdown") + mime: Option, +} + #[derive(Debug, serde::Deserialize)] struct ProbeQuery { locator: String, @@ -954,6 +1373,12 @@ struct PatchEntryBody { title: Option, } +#[derive(Debug, serde::Deserialize)] +struct ReorderChildrenBody { + /// Every current direct child UID of the parent, in the desired order. + child_uids: Vec, +} + #[derive(Debug, serde::Deserialize)] struct PatchTagBody { name: String, @@ -1070,6 +1495,7 @@ async fn capture_handler( modal_closer_enabled: Some(effective_modal_closer), reader_mode: body.reader_mode.unwrap_or(false), via_freedium: body.via_freedium.unwrap_or(true), + download_subtitles: body.download_subtitles.unwrap_or(true), per_item_quality: body.per_item_quality.clone(), sync: body.sync, }; @@ -1187,6 +1613,105 @@ async fn capture_handler( )) } +async fn capture_text_handler( + State(state): State, + auth_user: AuthUser, + Path(archive_id): Path, + Json(body): Json, +) -> Result<(StatusCode, Json), ApiError> { + auth_user.require_role(ROLE_USER)?; + + // Validate title and body + if body.title.trim().is_empty() { + return Err(ApiError::bad_request("title must not be empty")); + } + if body.body.trim().is_empty() { + return Err(ApiError::bad_request("body must not be empty")); + } + if body.body.len() > MAX_TEXT_CAPTURE_BODY_BYTES { + return Err(ApiError::bad_request("body must not exceed 2 MiB")); + } + + // Determine MIME type (default to markdown) + let mime = body.mime.as_deref().unwrap_or("text/markdown"); + + // Validate MIME type + if mime != "text/plain" && mime != "text/markdown" { + return Err(ApiError::bad_request( + "unsupported MIME type: must be 'text/plain' or 'text/markdown'" + )); + } + + let mounted = mounted_archive(&state, &archive_id)?; + let archive_paths = + archive::read_archive_paths(&mounted.archive_path).map_err(ApiError::from)?; + + // Create job record in the archive DB. + let conn = database::open_or_initialize(&mounted.archive_path)?; + let job_uid = database::create_capture_job(&conn, &archive_id)?; + drop(conn); + + // Spawn background text capture. + let title = body.title.trim().to_string(); + let text_body = body.body; + let mime_str = mime.to_string(); + let job_uid_bg = job_uid.clone(); + let archive_path = mounted.archive_path.clone(); + let archive_id_bg = archive_id.clone(); + + tokio::task::spawn_blocking(move || { + let conn = match database::open_or_initialize(&archive_path) { + Ok(c) => c, + Err(e) => { + eprintln!("warn: capture job {job_uid_bg}: db open failed: {e:#}"); + return; + } + }; + database::update_capture_job_status(&conn, &job_uid_bg, "running", None, None, None).ok(); + + match capture::perform_text_capture( + &archive_paths, + &title, + &text_body, + &mime_str, + Some(&archive_id_bg), + ) { + Ok(result) => { + let job_status = if result.status == "completed" { + "completed" + } else { + "failed" + }; + database::update_capture_job_status( + &conn, + &job_uid_bg, + job_status, + Some(&result.run_uid), + None, + None, + ) + .ok(); + } + Err(e) => { + database::update_capture_job_status( + &conn, + &job_uid_bg, + "failed", + None, + Some(&format!("{e:#}")), + None, + ) + .ok(); + } + } + }); + + Ok(( + StatusCode::ACCEPTED, + Json(serde_json::json!({ "job_uid": job_uid, "status": "pending" })), + )) +} + async fn upload_handler( State(state): State, auth_user: AuthUser, @@ -1385,6 +1910,7 @@ async fn rearchive_handler( modal_closer_enabled: None, reader_mode: false, via_freedium: false, + download_subtitles: true, per_item_quality: std::collections::HashMap::new(), sync: false, }; @@ -1603,6 +2129,8 @@ async fn auth_login( return Err(ApiError::unauthorized("invalid_credentials")); } let role_bits = database::compute_role_bits(&conn, user.id)?; + let can_reorder_children = + database::get_instance_settings(&conn)?.can_reorder_children(role_bits); let user_agent = headers.get("user-agent").and_then(|v| v.to_str().ok()); let session_uid = database::create_session(&conn, user.id, role_bits, user_agent)?; @@ -1631,6 +2159,7 @@ async fn auth_login( "user_uid": user.user_uid, "username": user.username, "role_bits": role_bits, + "can_reorder_children": can_reorder_children, })), )) } @@ -1667,11 +2196,13 @@ async fn auth_me( ) .map_err(|e| ApiError::from(anyhow::anyhow!("db error: {e}")))?; let humanize_slugs = humanize_slugs_int != 0; + let settings = database::get_instance_settings(&conn)?; Ok(Json(serde_json::json!({ "role_bits": role_bits, "username": username, "display_name": display_name, "humanize_slugs": humanize_slugs, + "can_reorder_children": settings.can_reorder_children(role_bits), }))) } @@ -1740,6 +2271,26 @@ async fn get_instance_settings_handler( "cookie_ext_available".into(), serde_json::Value::Bool(cookie_ext_available), ); + let title_models: serde_json::Map = summarizer::PROVIDER_KINDS + .iter() + .filter_map(|kind| { + let instance = settings.title_model_override(kind); + let (model, source) = thread_title::resolve_title_model(kind, instance)?; + // Fallback = what applies if the instance value is cleared (placeholder). + let (fallback, fallback_source) = thread_title::resolve_title_model(kind, None)?; + Some(( + kind.to_string(), + serde_json::json!({ + "model": model, + "source": source.as_str(), + "fallback_model": fallback, + "fallback_source": fallback_source.as_str(), + "env_var": thread_title::title_model_env(kind), + }), + )) + }) + .collect(); + obj.insert("title_models".into(), serde_json::Value::Object(title_models)); } Ok(Json(val)) } @@ -1750,8 +2301,12 @@ async fn update_instance_settings_handler( Json(body): Json, ) -> Result { auth_user.require_role(ROLE_ADMIN)?; - let conn = database::open_auth_db(&state.auth_db_path)?; - let mut settings = database::get_instance_settings(&conn)?; + let mut conn = database::open_auth_db(&state.auth_db_path)?; + // IMMEDIATE: the read-merge-write below rewrites every column, so it must + // not interleave with another PATCH (an admin save could otherwise write a + // stale reorder mask over the owner's change). + let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; + let mut settings = database::get_instance_settings(&tx)?; if let Some(v) = body.public_index_enabled { settings.public_index_enabled = v; } @@ -1773,10 +2328,171 @@ async fn update_instance_settings_handler( if let Some(v) = body.modal_closer_enabled { settings.modal_closer_enabled = v; } - database::update_instance_settings(&conn, &settings)?; + if let Some(mask) = body.reorder_children_role_bits { + if mask != settings.reorder_children_role_bits { + if !auth_user.has_role(ROLE_OWNER) { + return Err(ApiError::forbidden( + "only the owner can change who may reorder child entries", + )); + } + let grantable = database::grantable_role_bits(&tx)?; + if mask & !grantable != 0 { + return Err(ApiError::bad_request( + "reorder_children_role_bits may only contain bits of existing non-guest roles", + )); + } + settings.reorder_children_role_bits = mask; + } + } + for (kind, value) in [ + ("anthropic_http", body.title_model_anthropic_http), + ("openai_compatible", body.title_model_openai_compatible), + ("claude_cli", body.title_model_claude_cli), + ("codex_cli", body.title_model_codex_cli), + ] { + if let Some(raw) = value { + let model = validate_title_model(kind, &raw)?; + if let Some(slot) = settings.title_model_slot_mut(kind) { + *slot = model; + } + } + } + database::update_instance_settings(&tx, &settings)?; + tx.commit()?; Ok(StatusCode::NO_CONTENT) } +const MAX_TITLE_MODEL_CHARS: usize = 100; + +/// Trims an admin-supplied title model; empty clears it (`None`). Rejects +/// overlong values and any whitespace/control characters inside. +fn validate_title_model(kind: &str, raw: &str) -> Result, ApiError> { + let model = raw.trim(); + if model.is_empty() { + return Ok(None); + } + if model.chars().count() > MAX_TITLE_MODEL_CHARS { + return Err(ApiError::bad_request(&format!( + "title_model_{kind} must be at most {MAX_TITLE_MODEL_CHARS} characters" + ))); + } + if model.chars().any(|c| c.is_whitespace() || c.is_control()) { + return Err(ApiError::bad_request(&format!( + "title_model_{kind} must not contain spaces or control characters" + ))); + } + Ok(Some(model.to_string())) +} + +// ── yt-dlp tools ────────────────────────────────────────────────────────────── + +use std::sync::atomic::{AtomicBool, Ordering}; + +/// Process-wide: the state dir the update writes into is process-global too. +static YT_DLP_UPDATE_RUNNING: AtomicBool = AtomicBool::new(false); + +/// Holds the update slot; released on drop (including on panic in the blocking task). +struct YtDlpUpdateGuard; + +impl YtDlpUpdateGuard { + fn try_acquire() -> Option { + YT_DLP_UPDATE_RUNNING + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .ok() + .map(|_| Self) + } +} + +impl Drop for YtDlpUpdateGuard { + fn drop(&mut self) { + YT_DLP_UPDATE_RUNNING.store(false, Ordering::Release); + } +} + +/// `ToolsStatus` plus this server's live state: whether an update is running and the +/// cached JS runtime that new yt-dlp processes actually get. +fn tools_status_json( + status: downloader::ytdlp_tools::ToolsStatus, + in_use: Option, +) -> serde_json::Value { + let mut val = serde_json::to_value(&status).unwrap_or_default(); + if let Some(obj) = val.as_object_mut() { + obj.insert( + "update_running".into(), + serde_json::Value::Bool(YT_DLP_UPDATE_RUNNING.load(Ordering::Acquire)), + ); + obj.insert( + "js_runtime_in_use".into(), + in_use.map_or(serde_json::Value::Null, |rt| { + serde_json::json!({ + "kind": rt.kind.as_str(), + "path": rt.path.map(|p| p.display().to_string()), + }) + }), + ); + } + val +} + +fn component_outcome(r: &anyhow::Result) -> serde_json::Value { + match r { + Ok(m) => serde_json::json!({ "ok": true, "message": m }), + Err(e) => serde_json::json!({ "ok": false, "message": format!("{e:#}") }), + } +} + +async fn get_yt_dlp_status_handler( + auth_user: AuthUser, +) -> Result, ApiError> { + auth_user.require_role(ROLE_ADMIN)?; + // Both probe binaries (the first resolve_js_runtime() call may too): keep them off + // the async workers. + let (status, in_use) = tokio::task::spawn_blocking(|| { + ( + downloader::ytdlp_tools::tools_status(), + downloader::js_runtime::resolve_js_runtime(), + ) + }) + .await + .map_err(|e| ApiError::internal(&format!("yt-dlp status task failed: {e}")))?; + Ok(Json(tools_status_json(status, in_use))) +} + +async fn update_yt_dlp_handler( + auth_user: AuthUser, +) -> Result, ApiError> { + auth_user.require_role(ROLE_ADMIN)?; + let guard = YtDlpUpdateGuard::try_acquire() + .ok_or_else(|| ApiError::conflict("a yt-dlp update is already running"))?; + // The guard moves into the blocking task, so a client disconnecting mid-update + // can't release it before the install finishes. + let (report, status, in_use) = tokio::task::spawn_blocking(move || { + let _guard = guard; + let report = downloader::ytdlp_tools::update_tools( + None, + concat!("archivr-server/", env!("CARGO_PKG_VERSION")), + true, + &mut |l| eprintln!("info: yt-dlp update: {l}"), + )?; + for (component, result) in [("yt-dlp", &report.yt_dlp), ("deno", &report.deno)] { + if let Err(e) = result { + eprintln!("warn: yt-dlp update: {component} failed: {e:#}"); + } + } + let status = downloader::ytdlp_tools::tools_status(); + let in_use = downloader::js_runtime::resolve_js_runtime(); + anyhow::Ok((report, status, in_use)) + }) + .await + .map_err(|e| ApiError::internal(&format!("yt-dlp update task failed: {e}")))? + .map_err(|e| ApiError::internal(&format!("{e:#}")))?; + Ok(Json(serde_json::json!({ + "yt_dlp": component_outcome(&report.yt_dlp), + "deno": component_outcome(&report.deno), + "status": tools_status_json(status, in_use), + }))) +} + // ── Cookie rules ────────────────────────────────────────────────────────────── async fn list_cookie_rules_handler( @@ -1915,6 +2631,11 @@ async fn blob_cleanup_scan_handler( "captures are in progress; wait for them to finish before scanning", )); } + if database::has_pending_subtitle_fetches(&conn)? { + return Err(ApiError::conflict( + "subtitle fetches are in progress; wait for summaries to finish before scanning", + )); + } let referenced = database::all_referenced_file_relpaths(&conn)?; let orphaned_blob_rows = database::list_orphaned_blob_rows(&conn)?.len(); @@ -1948,6 +2669,11 @@ async fn blob_cleanup_delete_handler( "captures are in progress; wait for them to finish before cleaning up", )); } + if database::has_pending_subtitle_fetches(&conn)? { + return Err(ApiError::conflict( + "subtitle fetches are in progress; wait for summaries to finish before cleaning up", + )); + } // Collect the set of protected relpaths and the files to delete BEFORE mutating the DB. // This ensures the referenced set is consistent with the rows we're about to remove. @@ -1963,6 +2689,11 @@ async fn blob_cleanup_delete_handler( "a capture started during the scan; retry after all captures finish", )); } + if database::has_pending_subtitle_fetches(&conn)? { + return Err(ApiError::conflict( + "a subtitle fetch started during the scan; retry after summaries finish", + )); + } // Delete orphaned blob rows from the database. let deleted_blob_rows = database::delete_orphaned_blob_rows(&conn)?; @@ -2121,6 +2852,11 @@ struct UpdateInstanceSettingsBody { ublock_enabled: Option, cookie_ext_enabled: Option, modal_closer_enabled: Option, + reorder_children_role_bits: Option, + title_model_anthropic_http: Option, + title_model_openai_compatible: Option, + title_model_claude_cli: Option, + title_model_codex_cli: Option, } async fn admin_list_users( @@ -2712,6 +3448,78 @@ mod tests { format!("session={}", sess_uid) } + /// Creates an active user holding `roles` (assign_role adds the cumulative ones: + /// user for any non-guest, admin for owner) and returns a session cookie. + fn make_role_session(auth_path: &std::path::Path, username: &str, roles: &[&str]) -> String { + let conn = archivr_core::database::open_auth_db(auth_path).unwrap(); + let owner_id: i64 = conn + .query_row( + "SELECT id FROM users WHERE username = 'testowner'", + [], + |r| r.get(0), + ) + .unwrap(); + let uid = database::create_user(&conn, username, None, "dummy", owner_id).unwrap(); + let user_id = database::get_user_id_by_uid(&conn, &uid).unwrap().unwrap(); + for role in roles { + database::assign_role(&conn, user_id, role, owner_id).unwrap(); + } + // assign_role deletes sessions, so create the session afterwards. + let bits = database::compute_role_bits(&conn, user_id).unwrap(); + format!( + "session={}", + database::create_session(&conn, user_id, bits, None).unwrap() + ) + } + + fn patch_settings_request(body: serde_json::Value, cookie: &str) -> Request { + Request::builder() + .method("PATCH") + .uri("/api/admin/instance-settings") + .header("content-type", "application/json") + .header("cookie", cookie) + .body(Body::from(body.to_string())) + .unwrap() + } + + async fn get_settings_json( + registry: ServerRegistry, + auth_path: std::path::PathBuf, + cookie: &str, + ) -> serde_json::Value { + let resp = app(registry, auth_path) + .oneshot( + Request::builder() + .uri("/api/admin/instance-settings") + .header("cookie", cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + body_json(resp).await + } + + async fn me_json( + registry: ServerRegistry, + auth_path: std::path::PathBuf, + cookie: &str, + ) -> serde_json::Value { + let resp = app(registry, auth_path) + .oneshot( + Request::builder() + .uri("/api/auth/me") + .header("cookie", cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + body_json(resp).await + } + fn make_test_entry(archive_path: &std::path::Path) -> archivr_core::database::ArchivedEntry { let conn = database::open_or_initialize(archive_path).unwrap(); let user_id = database::ensure_default_user(&conn).unwrap(); @@ -2746,6 +3554,115 @@ mod tests { .unwrap() } + fn make_test_youtube_entry( + archive_path: &std::path::Path, + canonical_url: &str, + ) -> archivr_core::database::ArchivedEntry { + let conn = database::open_or_initialize(archive_path).unwrap(); + let user_id = database::ensure_default_user(&conn).unwrap(); + let run = database::create_archive_run(&conn, user_id, 1).unwrap(); + let si = database::upsert_source_identity( + &conn, + "youtube", + "video", + None, + Some(canonical_url), + canonical_url, + ) + .unwrap(); + database::create_archived_entry( + &conn, + &database::NewEntry { + source_identity_id: si, + archive_run_id: run.id, + parent_entry_id: None, + root_entry_id: None, + created_by_user_id: user_id, + owned_by_user_id: user_id, + source_kind: "youtube".to_string(), + entity_kind: "video".to_string(), + title: Some("Test Video".to_string()), + visibility: "private".to_string(), + representation_kind: "video".to_string(), + source_metadata_json: "{}".to_string(), + display_metadata_json: None, + }, + ) + .unwrap() + } + + fn make_test_child( + archive_path: &std::path::Path, + parent_id: i64, + title: &str, + url: &str, + ) -> archivr_core::database::ArchivedEntry { + let conn = database::open_or_initialize(archive_path).unwrap(); + let user_id = database::ensure_default_user(&conn).unwrap(); + let run = database::create_archive_run(&conn, user_id, 1).unwrap(); + let si = database::upsert_source_identity(&conn, "web", "page", None, Some(url), url) + .unwrap(); + database::create_archived_entry( + &conn, + &database::NewEntry { + source_identity_id: si, + archive_run_id: run.id, + parent_entry_id: Some(parent_id), + root_entry_id: Some(parent_id), + created_by_user_id: user_id, + owned_by_user_id: user_id, + source_kind: "web".to_string(), + entity_kind: "page".to_string(), + title: Some(title.to_string()), + visibility: "private".to_string(), + representation_kind: "html".to_string(), + source_metadata_json: "{}".to_string(), + display_metadata_json: None, + }, + ) + .unwrap() + } + + fn add_summary_test_artifact( + archive_path: &std::path::Path, + entry_id: i64, + relpath: &str, + role: &str, + mime_type: &str, + contents: &[u8], + ) { + let paths = archive::read_archive_paths(archive_path).unwrap(); + let full_path = paths.store_path.join(relpath); + std::fs::create_dir_all(full_path.parent().unwrap()).unwrap(); + std::fs::write(&full_path, contents).unwrap(); + + let conn = database::open_or_initialize(archive_path).unwrap(); + let blob_id = database::upsert_blob( + &conn, + &database::BlobRecord { + sha256: format!("test-summary-{}", relpath.replace('/', "-")), + byte_size: contents.len().try_into().unwrap(), + mime_type: Some(mime_type.to_string()), + extension: relpath.rsplit('.').next().map(str::to_string), + raw_relpath: relpath.to_string(), + }, + ) + .unwrap(); + database::add_entry_artifact( + &conn, + &database::NewArtifact { + entry_id, + artifact_role: role.to_string(), + storage_area: "raw".to_string(), + relpath: relpath.to_string(), + blob_id: Some(blob_id), + logical_path: None, + metadata_json: None, + }, + ) + .unwrap(); + } + async fn body_json(response: axum::response::Response) -> serde_json::Value { let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) .await @@ -2757,6 +3674,448 @@ mod tests { Body::from(serde_json::to_vec(payload).unwrap()) } + #[tokio::test] + async fn summary_include_images_rejects_claude_cli_without_creating_row() { + let dir = tempfile::tempdir().unwrap(); + let (registry, archive_path, auth_path) = make_test_registry(&dir); + let entry = make_test_entry(&archive_path); + let session_cookie = make_test_session(&auth_path); + + let response = app(registry, auth_path) + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/api/archives/test/entries/{}/summary", entry.entry_uid)) + .header("content-type", "application/json") + .header("cookie", &session_cookie) + .body(json_body(&serde_json::json!({ + "provider": "claude_cli", + "include_images": true, + }))) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!( + body_json(response).await["error"], + "Claude CLI cannot attach local images; choose an HTTP provider or Codex CLI" + ); + let conn = database::open_or_initialize(&archive_path).unwrap(); + assert!(database::latest_entry_summary(&conn, entry.id).unwrap().is_none()); + } + + #[tokio::test] + async fn summary_preflight_returns_safe_message_for_unsupported_video_content() { + let dir = tempfile::tempdir().unwrap(); + let (registry, archive_path, auth_path) = make_test_registry(&dir); + let entry = make_test_entry(&archive_path); + add_summary_test_artifact( + &archive_path, + entry.id, + "raw/private-video.mp4", + "primary_media", + "video/mp4", + b"video fixture", + ); + let session_cookie = make_test_session(&auth_path); + let previous_codex_cli = std::env::var_os("ARCHIVR_CODEX_CLI"); + unsafe { std::env::set_var("ARCHIVR_CODEX_CLI", "/usr/bin/false") }; + + let response = app(registry, auth_path) + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/api/archives/test/entries/{}/summary", entry.entry_uid)) + .header("content-type", "application/json") + .header("cookie", &session_cookie) + .body(json_body(&serde_json::json!({ "provider": "codex_cli" }))) + .unwrap(), + ) + .await + .unwrap(); + match previous_codex_cli { + Some(value) => unsafe { std::env::set_var("ARCHIVR_CODEX_CLI", value) }, + None => unsafe { std::env::remove_var("ARCHIVR_CODEX_CLI") }, + } + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let error = body_json(response).await["error"].as_str().unwrap().to_string(); + assert_eq!(error, summarizer::UNSUPPORTED_SUMMARY_CONTENT_MESSAGE); + assert!(!error.contains("raw/")); + assert!(!error.contains("mime")); + assert!(!error.contains("v1 unsupported")); + } + + #[tokio::test] + async fn summary_request_for_missing_entry_returns_not_found_before_preflight() { + let dir = tempfile::tempdir().unwrap(); + let (registry, _archive_path, auth_path) = make_test_registry(&dir); + let session_cookie = make_test_session(&auth_path); + let previous_codex_cli = std::env::var_os("ARCHIVR_CODEX_CLI"); + unsafe { std::env::set_var("ARCHIVR_CODEX_CLI", "/usr/bin/false") }; + + let response = app(registry, auth_path) + .oneshot( + Request::builder() + .method("POST") + .uri("/api/archives/test/entries/ent_missing/summary") + .header("content-type", "application/json") + .header("cookie", &session_cookie) + .body(json_body(&serde_json::json!({ "provider": "codex_cli" }))) + .unwrap(), + ) + .await + .unwrap(); + match previous_codex_cli { + Some(value) => unsafe { std::env::set_var("ARCHIVR_CODEX_CLI", value) }, + None => unsafe { std::env::remove_var("ARCHIVR_CODEX_CLI") }, + } + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_eq!(body_json(response).await["error"], "entry not found"); + } + + #[tokio::test] + async fn public_summary_endpoints_hide_failed_diagnostics_but_authenticated_users_keep_them() { + let dir = tempfile::tempdir().unwrap(); + let (registry, archive_path, auth_path) = make_test_registry(&dir); + let entry = make_test_entry(&archive_path); + let session = make_test_session(&auth_path); + let conn = database::open_or_initialize(&archive_path).unwrap(); + let completed_uid = database::upsert_pending_entry_summary( + &conn, entry.id, "codex_cli", None, summarizer::PROMPT_VERSION, "completed-public-test", + ) + .unwrap(); + database::update_entry_summary_status( + &conn, &completed_uid, "completed", Some("previous completed summary"), None, + ) + .unwrap(); + let summary_uid = database::upsert_pending_entry_summary( + &conn, entry.id, "codex_cli", None, summarizer::PROMPT_VERSION, "failed-public-test", + ) + .unwrap(); + database::update_entry_summary_status( + &conn, &summary_uid, "failed", None, Some("provider secret: raw diagnostic"), + ) + .unwrap(); + drop(conn); + let collection = api_make_collection( + registry.clone(), auth_path.clone(), &session, "Public summaries", "public-summaries", 3, false, + ) + .await; + api_add_to_coll( + registry.clone(), auth_path.clone(), &session, &collection, &entry.entry_uid, 3, + ) + .await; + + let public_summary = app(registry.clone(), auth_path.clone()) + .oneshot(Request::builder() + .uri(format!("/api/archives/test/entries/{}/summary", entry.entry_uid)) + .body(Body::empty()).unwrap()) + .await.unwrap(); + assert_eq!(public_summary.status(), StatusCode::OK); + let public_summary = body_json(public_summary).await; + assert_eq!(public_summary["summary"]["summary_uid"], completed_uid); + assert!(public_summary.get("attempt").is_none()); + + let public_detail = app(registry.clone(), auth_path.clone()) + .oneshot(Request::builder() + .uri(format!("/api/archives/test/entries/{}", entry.entry_uid)) + .body(Body::empty()).unwrap()) + .await.unwrap(); + assert_eq!(public_detail.status(), StatusCode::OK); + let public_detail = body_json(public_detail).await; + assert_eq!(public_detail["latest_summary"]["summary_uid"], completed_uid); + assert!(public_detail["summary_attempt"].is_null()); + + let authenticated_summary = app(registry.clone(), auth_path.clone()) + .oneshot(Request::builder() + .uri(format!("/api/archives/test/entries/{}/summary", entry.entry_uid)) + .header("cookie", &session) + .body(Body::empty()).unwrap()) + .await.unwrap(); + let authenticated_summary = body_json(authenticated_summary).await; + assert_eq!(authenticated_summary["summary"]["summary_uid"], completed_uid); + assert_eq!(authenticated_summary["summary"]["summary_text"], "previous completed summary"); + assert_eq!(authenticated_summary["attempt"]["status"], "failed"); + assert_eq!(authenticated_summary["attempt"]["error_text"], "provider secret: raw diagnostic"); + + let authenticated_detail = app(registry, auth_path) + .oneshot(Request::builder() + .uri(format!("/api/archives/test/entries/{}", entry.entry_uid)) + .header("cookie", &session) + .body(Body::empty()).unwrap()) + .await.unwrap(); + let authenticated_detail = body_json(authenticated_detail).await; + assert_eq!(authenticated_detail["latest_summary"]["summary_uid"], completed_uid); + assert_eq!(authenticated_detail["latest_summary"]["summary_text"], "previous completed summary"); + assert_eq!(authenticated_detail["summary_attempt"]["status"], "failed"); + assert_eq!(authenticated_detail["summary_attempt"]["error_text"], "provider secret: raw diagnostic"); + } + + #[test] + fn background_summary_failure_stores_safe_copy_only_for_unsupported_content() { + let dir = tempfile::tempdir().unwrap(); + let (_registry, archive_path, _auth_path) = make_test_registry(&dir); + let entry = make_test_entry(&archive_path); + add_summary_test_artifact( + &archive_path, + entry.id, + "raw/private-video.mp4", + "primary_media", + "video/mp4", + b"video fixture", + ); + let unsupported = summarizer::build_summary_input( + &archive::read_archive_paths(&archive_path).unwrap(), + &entry.entry_uid, + summarizer::SummaryBuildOptions::default(), + ) + .unwrap_err(); + assert_eq!( + summary_failure_error_text(&unsupported), + summarizer::UNSUPPORTED_SUMMARY_CONTENT_MESSAGE + ); + + let conn = database::open_or_initialize(&archive_path).unwrap(); + let summary_uid = database::upsert_pending_entry_summary( + &conn, + entry.id, + "codex_cli", + Some("test"), + summarizer::PROMPT_VERSION, + "unsupported-content-test", + ) + .unwrap(); + drop(conn); + record_background_summary_failure(&archive_path, &summary_uid, &unsupported); + let conn = database::open_or_initialize(&archive_path).unwrap(); + let stored = database::get_entry_summary_by_uid(&conn, &summary_uid) + .unwrap() + .unwrap(); + assert_eq!(stored.status, "failed"); + assert_eq!( + stored.error_text.as_deref(), + Some(summarizer::UNSUPPORTED_SUMMARY_CONTENT_MESSAGE) + ); + + let provider = anyhow::anyhow!("provider response was malformed"); + assert_eq!( + summary_failure_error_text(&provider), + "provider response was malformed" + ); + } + + async fn post_codex_summary( + registry: ServerRegistry, + auth_path: std::path::PathBuf, + entry_uid: &str, + ) -> axum::response::Response { + let session_cookie = make_test_session(&auth_path); + let previous_codex_cli = std::env::var_os("ARCHIVR_CODEX_CLI"); + unsafe { std::env::set_var("ARCHIVR_CODEX_CLI", "/usr/bin/false") }; + let response = app(registry, auth_path) + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/api/archives/test/entries/{entry_uid}/summary")) + .header("content-type", "application/json") + .header("cookie", &session_cookie) + .body(json_body(&serde_json::json!({ "provider": "codex_cli" }))) + .unwrap(), + ) + .await + .unwrap(); + match previous_codex_cli { + Some(value) => unsafe { std::env::set_var("ARCHIVR_CODEX_CLI", value) }, + None => unsafe { std::env::remove_var("ARCHIVR_CODEX_CLI") }, + } + response + } + + #[tokio::test] + async fn youtube_summary_without_subtitles_fails_row_with_clear_message() { + let dir = tempfile::tempdir().unwrap(); + let (registry, archive_path, auth_path) = make_test_registry(&dir); + // Non-HTTP canonical URL: the subtitle fetch never spawns yt-dlp. + let entry = make_test_youtube_entry(&archive_path, "youtube-test:offline"); + add_summary_test_artifact( + &archive_path, + entry.id, + "raw/youtube-video.mp4", + "primary_media", + "video/mp4", + b"video fixture", + ); + + let response = post_codex_summary(registry, auth_path, &entry.entry_uid).await; + assert_eq!(response.status(), StatusCode::ACCEPTED); + let body = body_json(response).await; + assert_eq!(body["status"], "pending"); + let summary_uid = body["summary_uid"].as_str().unwrap().to_string(); + + let mut stored = None; + for _ in 0..100 { + let conn = database::open_or_initialize(&archive_path).unwrap(); + let row = database::get_entry_summary_by_uid(&conn, &summary_uid) + .unwrap() + .unwrap(); + if row.status == "failed" { + stored = Some(row); + break; + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + let stored = stored.expect("summary row should fail"); + // `/usr/bin/false` would yield a different error, so this also proves + // the provider never ran. + assert_eq!( + stored.error_text.as_deref(), + Some(summarizer::NO_SUBTITLES_SUMMARY_MESSAGE) + ); + assert_eq!(stored.input_sha256, summarizer::SUBTITLE_FETCH_PENDING_INPUT_SHA256); + } + + #[tokio::test] + async fn youtube_summary_with_subtitle_artifact_preflights_synchronously() { + let dir = tempfile::tempdir().unwrap(); + let (registry, archive_path, auth_path) = make_test_registry(&dir); + let entry = make_test_youtube_entry(&archive_path, "youtube-test:offline"); + add_summary_test_artifact( + &archive_path, + entry.id, + "raw/youtube-video.mp4", + "primary_media", + "video/mp4", + b"video fixture", + ); + add_summary_test_artifact( + &archive_path, + entry.id, + "raw/youtube-video.en.vtt", + "subtitle", + "text/vtt", + b"WEBVTT\n\n00:00:00.000 --> 00:00:02.000\nHello from the transcript\n", + ); + + let response = post_codex_summary(registry, auth_path, &entry.entry_uid).await; + assert_eq!(response.status(), StatusCode::ACCEPTED); + let summary_uid = body_json(response).await["summary_uid"] + .as_str() + .unwrap() + .to_string(); + let conn = database::open_or_initialize(&archive_path).unwrap(); + let stored = database::get_entry_summary_by_uid(&conn, &summary_uid) + .unwrap() + .unwrap(); + assert_ne!(stored.input_sha256, summarizer::SUBTITLE_FETCH_PENDING_INPUT_SHA256); + } + + #[test] + fn background_summary_failure_maps_no_subtitles_copy() { + let dir = tempfile::tempdir().unwrap(); + let (_registry, archive_path, _auth_path) = make_test_registry(&dir); + let entry = make_test_youtube_entry(&archive_path, "youtube-test:offline"); + let no_subtitles = summarizer::build_summary_input( + &archive::read_archive_paths(&archive_path).unwrap(), + &entry.entry_uid, + summarizer::SummaryBuildOptions::default(), + ) + .unwrap_err(); + assert!(summarizer::is_no_subtitles_error(&no_subtitles)); + assert_eq!( + summary_failure_error_text(&no_subtitles), + summarizer::NO_SUBTITLES_SUMMARY_MESSAGE + ); + } + + #[test] + fn capture_body_download_subtitles_defaults_to_none() { + let absent: CaptureBody = + serde_json::from_value(serde_json::json!({ "locator": "x" })).unwrap(); + assert_eq!(absent.download_subtitles, None); + assert!(absent.download_subtitles.unwrap_or(true)); + let disabled: CaptureBody = serde_json::from_value( + serde_json::json!({ "locator": "x", "download_subtitles": false }), + ) + .unwrap(); + assert_eq!(disabled.download_subtitles, Some(false)); + } + + #[tokio::test] + async fn summary_include_images_uses_distinct_cache_identity() { + let dir = tempfile::tempdir().unwrap(); + let (registry, archive_path, auth_path) = make_test_registry(&dir); + let entry = make_test_entry(&archive_path); + add_summary_test_artifact( + &archive_path, + entry.id, + "raw/summary-test.html", + "primary_media", + "text/html", + b"
summary fixture
", + ); + add_summary_test_artifact( + &archive_path, + entry.id, + "raw/summary-test.jpg", + "media", + "image/jpeg", + b"image fixture", + ); + let session_cookie = make_test_session(&auth_path); + let previous_codex_cli = std::env::var_os("ARCHIVR_CODEX_CLI"); + unsafe { std::env::set_var("ARCHIVR_CODEX_CLI", "/usr/bin/false") }; + + let text_response = app(registry.clone(), auth_path.clone()) + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/api/archives/test/entries/{}/summary", entry.entry_uid)) + .header("content-type", "application/json") + .header("cookie", &session_cookie) + .body(json_body(&serde_json::json!({ "provider": "codex_cli" }))) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(text_response.status(), StatusCode::ACCEPTED); + + let image_response = app(registry, auth_path) + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/api/archives/test/entries/{}/summary", entry.entry_uid)) + .header("content-type", "application/json") + .header("cookie", &session_cookie) + .body(json_body(&serde_json::json!({ + "provider": "codex_cli", + "include_images": true, + }))) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(image_response.status(), StatusCode::ACCEPTED); + match previous_codex_cli { + Some(value) => unsafe { std::env::set_var("ARCHIVR_CODEX_CLI", value) }, + None => unsafe { std::env::remove_var("ARCHIVR_CODEX_CLI") }, + } + + let conn = database::open_or_initialize(&archive_path).unwrap(); + let input_hashes: Vec = conn + .prepare("SELECT input_sha256 FROM entry_summaries WHERE entry_id = ?1") + .unwrap() + .query_map([entry.id], |row| row.get(0)) + .unwrap() + .collect::>() + .unwrap(); + assert_eq!(input_hashes.len(), 2); + assert_ne!(input_hashes[0], input_hashes[1]); + } + #[tokio::test] async fn archives_endpoint_lists_mounted_archives() { let dir = tempfile::tempdir().unwrap(); @@ -3555,6 +4914,35 @@ mod tests { assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } + #[tokio::test] + async fn login_response_includes_can_reorder_children() { + let dir = tempfile::tempdir().unwrap(); + let auth_path = dir.path().join("auth.sqlite"); + { + let conn = archivr_core::database::open_auth_db(&auth_path).unwrap(); + let hash = crate::auth::hash_password("pw").unwrap(); + archivr_core::database::create_owner(&conn, "owner", &hash).unwrap(); + } + let registry = ServerRegistry { + archives: vec![], + bind: None, + auth_db_path: None, + }; + let response = app(registry, auth_path) + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/login") + .header("content-type", "application/json") + .body(Body::from(r#"{"username":"owner","password":"pw"}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(body_json(response).await["can_reorder_children"], true); + } + #[tokio::test] async fn create_token_requires_auth() { let (test_app, _dir) = make_test_app(); @@ -3680,6 +5068,291 @@ mod tests { ); } + #[tokio::test] + async fn text_capture_post_returns_accepted_with_job_uid() { + let dir = tempfile::tempdir().unwrap(); + let (registry, _, auth_path) = make_test_registry(&dir); + let session_cookie = make_test_session(&auth_path); + let response = app(registry, auth_path) + .oneshot( + Request::builder() + .method("POST") + .uri("/api/archives/test/captures/text") + .header("content-type", "application/json") + .header("cookie", &session_cookie) + .body(Body::from( + r#"{"title":"Test Note","body":"Test content","mime":"text/markdown"}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::ACCEPTED); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert!( + json["job_uid"].as_str().is_some(), + "response must have job_uid" + ); + assert_eq!(json["status"], "pending"); + } + + #[tokio::test] + async fn text_capture_accepts_a_body_just_below_two_mebibytes() { + let dir = tempfile::tempdir().unwrap(); + let (registry, _, auth_path) = make_test_registry(&dir); + let session_cookie = make_test_session(&auth_path); + let body = "a".repeat(2 * 1024 * 1024 - 1); + let response = app(registry, auth_path) + .oneshot( + Request::builder() + .method("POST") + .uri("/api/archives/test/captures/text") + .header("content-type", "application/json") + .header("cookie", &session_cookie) + .body(json_body(&serde_json::json!({ + "title": "Maximum-size note", + "body": body, + "mime": "text/plain" + }))) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::ACCEPTED); + } + + #[tokio::test] + async fn text_capture_accepts_a_two_mebibyte_body_with_json_escaped_controls() { + let dir = tempfile::tempdir().unwrap(); + let (registry, _, auth_path) = make_test_registry(&dir); + let session_cookie = make_test_session(&auth_path); + let body = "\0".repeat(2 * 1024 * 1024); + let response = app(registry, auth_path) + .oneshot( + Request::builder() + .method("POST") + .uri("/api/archives/test/captures/text") + .header("content-type", "application/json") + .header("cookie", &session_cookie) + .body(json_body(&serde_json::json!({ + "title": "Escaped control note", + "body": body, + "mime": "text/plain" + }))) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::ACCEPTED); + } + + #[tokio::test] + async fn text_capture_rejects_a_body_over_two_mebibytes_with_validation_error() { + let dir = tempfile::tempdir().unwrap(); + let (registry, _, auth_path) = make_test_registry(&dir); + let session_cookie = make_test_session(&auth_path); + let body = "a".repeat(2 * 1024 * 1024 + 1); + let response = app(registry, auth_path) + .oneshot( + Request::builder() + .method("POST") + .uri("/api/archives/test/captures/text") + .header("content-type", "application/json") + .header("cookie", &session_cookie) + .body(json_body(&serde_json::json!({ + "title": "Oversized note", + "body": body, + "mime": "text/plain" + }))) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let response_body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&response_body).unwrap(); + assert_eq!(json["error"], "body must not exceed 2 MiB"); + } + + #[tokio::test] + async fn text_capture_post_preserves_intentional_whitespace() { + let dir = tempfile::tempdir().unwrap(); + let (registry, archive_path, auth_path) = make_test_registry(&dir); + let session_cookie = make_test_session(&auth_path); + let text_body = " \n# Heading\n\nContent with a final newline\n\t \n"; + let response = app(registry, auth_path) + .oneshot( + Request::builder() + .method("POST") + .uri("/api/archives/test/captures/text") + .header("content-type", "application/json") + .header("cookie", &session_cookie) + .body(json_body(&serde_json::json!({ + "title": "Whitespace Note", + "body": text_body, + "mime": "text/markdown" + }))) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::ACCEPTED); + let response_body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let job_uid = serde_json::from_slice::(&response_body).unwrap()["job_uid"] + .as_str() + .unwrap() + .to_string(); + + let status = tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + let conn = archivr_core::database::open_or_initialize(&archive_path).unwrap(); + let job = archivr_core::database::get_capture_job(&conn, &job_uid) + .unwrap() + .unwrap(); + if job.status != "pending" && job.status != "running" { + break job.status; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + }) + .await + .expect("text capture job should finish"); + assert_eq!(status, "completed"); + + let archive_paths = archivr_core::archive::read_archive_paths(&archive_path).unwrap(); + let conn = archivr_core::database::open_or_initialize(&archive_path).unwrap(); + let raw_relpath: String = conn + .query_row( + "SELECT b.raw_relpath + FROM entry_artifacts ea + JOIN blobs b ON b.id = ea.blob_id + WHERE ea.artifact_role = 'primary_media'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + std::fs::read(archive_paths.store_path.join(raw_relpath)).unwrap(), + text_body.as_bytes() + ); + } + + #[tokio::test] + async fn text_capture_rejects_empty_title() { + let dir = tempfile::tempdir().unwrap(); + let (registry, _, auth_path) = make_test_registry(&dir); + let session_cookie = make_test_session(&auth_path); + let response = app(registry, auth_path) + .oneshot( + Request::builder() + .method("POST") + .uri("/api/archives/test/captures/text") + .header("content-type", "application/json") + .header("cookie", &session_cookie) + .body(Body::from( + r#"{"title":"","body":"Test content","mime":"text/plain"}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert!(json["error"] + .as_str() + .is_some_and(|e| e.contains("title must not be empty"))); + } + + #[tokio::test] + async fn text_capture_rejects_empty_body() { + let dir = tempfile::tempdir().unwrap(); + let (registry, _, auth_path) = make_test_registry(&dir); + let session_cookie = make_test_session(&auth_path); + let response = app(registry, auth_path) + .oneshot( + Request::builder() + .method("POST") + .uri("/api/archives/test/captures/text") + .header("content-type", "application/json") + .header("cookie", &session_cookie) + .body(Body::from( + r#"{"title":"Test Note","body":"","mime":"text/plain"}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert!(json["error"] + .as_str() + .is_some_and(|e| e.contains("body must not be empty"))); + } + + #[tokio::test] + async fn text_capture_rejects_unsupported_mime() { + let dir = tempfile::tempdir().unwrap(); + let (registry, _, auth_path) = make_test_registry(&dir); + let session_cookie = make_test_session(&auth_path); + let response = app(registry, auth_path) + .oneshot( + Request::builder() + .method("POST") + .uri("/api/archives/test/captures/text") + .header("content-type", "application/json") + .header("cookie", &session_cookie) + .body(Body::from( + r#"{"title":"Test Note","body":"Test content","mime":"text/html"}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert!(json["error"] + .as_str() + .is_some_and(|e| e.contains("unsupported MIME type"))); + } + + #[tokio::test] + async fn text_capture_requires_auth() { + let (test_app, _dir) = make_test_app(); + let response = test_app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/archives/test/captures/text") + .header("content-type", "application/json") + .body(Body::from( + r#"{"title":"Test","body":"Content"}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + #[tokio::test] async fn probe_requires_auth() { let (test_app, _dir) = make_test_app(); @@ -3891,6 +5564,7 @@ mod tests { let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(json["public_index_enabled"], false); assert_eq!(json["open_registration_enabled"], false); + assert_eq!(json["reorder_children_role_bits"], 12); } #[tokio::test] @@ -3912,6 +5586,98 @@ mod tests { .unwrap(); assert_eq!(response.status(), StatusCode::NO_CONTENT); } + + #[tokio::test] + async fn yt_dlp_status_requires_admin() { + let (test_app, _dir) = make_test_app(); + let response = test_app + .oneshot( + Request::builder() + .uri("/api/admin/yt-dlp") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + + let dir = tempfile::tempdir().unwrap(); + let (registry, _, auth_path) = make_test_registry(&dir); + let plain = make_role_session(&auth_path, "plain", &["user"]); + let app = app(registry, auth_path); + for (method, uri) in [("GET", "/api/admin/yt-dlp"), ("POST", "/api/admin/yt-dlp/update")] { + let response = app + .clone() + .oneshot( + Request::builder() + .method(method) + .uri(uri) + .header("cookie", &plain) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN, "{method} {uri}"); + } + } + + #[tokio::test] + async fn yt_dlp_status_returns_candidates() { + let dir = tempfile::tempdir().unwrap(); + let (registry, _, auth_path) = make_test_registry(&dir); + let session_cookie = make_test_session(&auth_path); + let response = app(registry, auth_path) + .oneshot( + Request::builder() + .uri("/api/admin/yt-dlp") + .header("cookie", &session_cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let json = body_json(response).await; + let yt_dlp = json["yt_dlp"].as_array().expect("yt_dlp array"); + assert_eq!(yt_dlp.len(), 4); + for row in yt_dlp { + assert!(row["role"].is_string() && row["label"].is_string(), "{row}"); + assert!(row["chosen"].is_boolean(), "{row}"); + } + assert_eq!(json["js_runtime"].as_array().map(Vec::len), Some(4)); + assert!(json["yt_dlp_chosen"]["path"].is_string()); + // Value not asserted: the concurrent-run test may hold the guard right now. + assert!(json["update_running"].is_boolean()); + let in_use = json.get("js_runtime_in_use").expect("js_runtime_in_use key"); + assert!(in_use.is_null() || in_use["kind"].is_string(), "{in_use}"); + } + + #[tokio::test] + async fn yt_dlp_update_rejects_concurrent_run() { + let dir = tempfile::tempdir().unwrap(); + let (registry, _, auth_path) = make_test_registry(&dir); + let session_cookie = make_test_session(&auth_path); + let guard = YtDlpUpdateGuard::try_acquire().expect("no other update running"); + let response = app(registry, auth_path) + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/yt-dlp/update") + .header("cookie", &session_cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::CONFLICT); + let json = body_json(response).await; + assert!( + json["error"].as_str().is_some_and(|e| e.contains("already running")), + "{json}" + ); + drop(guard); + } #[tokio::test] async fn cookie_rules_require_admin() { // Non-admin (no session) should get 401. @@ -5216,6 +6982,578 @@ mod tests { assert_eq!(response.status(), StatusCode::NOT_FOUND); } + fn make_test_thread_entry(archive_path: &std::path::Path) -> archivr_core::database::ArchivedEntry { + let conn = database::open_or_initialize(archive_path).unwrap(); + let user_id = database::ensure_default_user(&conn).unwrap(); + let run = database::create_archive_run(&conn, user_id, 1).unwrap(); + let si = database::upsert_source_identity( + &conn, + "x", + "tweet_thread", + Some("9001"), + Some("https://x.com/alice/status/9001"), + "x:thread:9001", + ) + .unwrap(); + let entry = database::create_archived_entry( + &conn, + &database::NewEntry { + source_identity_id: si, + archive_run_id: run.id, + parent_entry_id: None, + root_entry_id: None, + created_by_user_id: user_id, + owned_by_user_id: user_id, + source_kind: "x".to_string(), + entity_kind: "tweet_thread".to_string(), + title: Some("Thread by @alice".to_string()), + visibility: "private".to_string(), + representation_kind: "tweet_thread".to_string(), + source_metadata_json: r#"{"tweet_id":"9001"}"#.to_string(), + display_metadata_json: None, + }, + ) + .unwrap(); + add_summary_test_artifact( + archive_path, + entry.id, + "raw_tweets/tweet-9001.json", + "raw_tweet_json", + "application/json", + br#"{"full_text":"1/ Comparing Rust async runtimes.","author":{"screen_name":"alice"}}"#, + ); + add_summary_test_artifact( + archive_path, + entry.id, + "raw_tweets/tweet-9002.json", + "raw_tweet_json", + "application/json", + br#"{"full_text":"2/ Tokio wins on ecosystem.","author":{"screen_name":"alice"}}"#, + ); + entry + } + + fn thread_title_request(entry_uid: &str, provider: &str, cookie: Option<&str>) -> Request { + let mut builder = Request::builder() + .method("POST") + .uri(format!("/api/archives/test/entries/{entry_uid}/thread-title")) + .header("content-type", "application/json"); + if let Some(cookie) = cookie { + builder = builder.header("cookie", cookie); + } + builder + .body(json_body(&serde_json::json!({ "provider": provider }))) + .unwrap() + } + + #[tokio::test] + async fn thread_title_requires_auth() { + let dir = tempfile::tempdir().unwrap(); + let (registry, archive_path, auth_path) = make_test_registry(&dir); + let entry = make_test_thread_entry(&archive_path); + let response = app(registry, auth_path) + .oneshot(thread_title_request(&entry.entry_uid, "claude_cli", None)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn thread_title_unknown_entry_returns_404() { + let dir = tempfile::tempdir().unwrap(); + let (registry, _, auth_path) = make_test_registry(&dir); + let session_cookie = make_test_session(&auth_path); + let response = app(registry, auth_path) + .oneshot(thread_title_request("no-such-uid", "claude_cli", Some(&session_cookie))) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_eq!(body_json(response).await["error"], "entry not found"); + } + + #[tokio::test] + async fn thread_title_rejects_non_thread_entry() { + let dir = tempfile::tempdir().unwrap(); + let (registry, archive_path, auth_path) = make_test_registry(&dir); + let session_cookie = make_test_session(&auth_path); + let entry = make_test_entry(&archive_path); + let response = app(registry, auth_path) + .oneshot(thread_title_request(&entry.entry_uid, "claude_cli", Some(&session_cookie))) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let error = body_json(response).await["error"].as_str().unwrap().to_string(); + assert!(error.contains("not an X thread"), "{error}"); + } + + #[test] + fn thread_title_load_error_maps_user_errors_to_400_and_others_to_500() { + let user = anyhow::Error::new(thread_title::ThreadTitleUserError("not a thread".into())); + let e = thread_title_load_error("uid", user); + assert_eq!(e.status, StatusCode::BAD_REQUEST); + assert_eq!(e.message, "not a thread"); + + let io = anyhow::anyhow!("disk gone").context("failed to read /abs/store/x.json"); + let e = thread_title_load_error("uid", io); + assert_eq!(e.status, StatusCode::INTERNAL_SERVER_ERROR); + assert!(!e.message.contains("/abs"), "{}", e.message); + } + + #[tokio::test] + async fn thread_title_unknown_provider_returns_400() { + let dir = tempfile::tempdir().unwrap(); + let (registry, archive_path, auth_path) = make_test_registry(&dir); + let session_cookie = make_test_session(&auth_path); + let entry = make_test_thread_entry(&archive_path); + let response = app(registry, auth_path) + .oneshot(thread_title_request(&entry.entry_uid, "gemini", Some(&session_cookie))) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let error = body_json(response).await["error"].as_str().unwrap().to_string(); + assert!(error.contains("unknown summary provider"), "{error}"); + } + + #[cfg(unix)] + #[tokio::test] + async fn thread_title_generates_and_persists() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let (registry, archive_path, auth_path) = make_test_registry(&dir); + let session_cookie = make_test_session(&auth_path); + let entry = make_test_thread_entry(&archive_path); + let script = dir.path().join("fake-claude"); + std::fs::write( + &script, + "#!/bin/sh\ncat >/dev/null\ncase \" $* \" in *\" --model haiku \"*) printf '%s\\n' '\"Rust async runtimes compared.\"';; *\" --model custom-title-1 \"*) printf '%s\\n' 'Instance model topic';; *) echo \"bad args: $*\" >&2; exit 3;; esac\n", + ) + .unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let previous_cli = std::env::var_os("ARCHIVR_CLAUDE_CLI"); + let previous_model = std::env::var_os("ARCHIVR_CLAUDE_TITLE_MODEL"); + unsafe { + std::env::set_var("ARCHIVR_CLAUDE_CLI", &script); + std::env::remove_var("ARCHIVR_CLAUDE_TITLE_MODEL"); + } + // Parallel tests forking while the script fd was open can briefly make + // exec fail with ETXTBSY (rust-lang/rust#114554); retry that case only. + let run = || async { + let mut attempt = 0; + loop { + let response = app(registry.clone(), auth_path.clone()) + .oneshot(thread_title_request(&entry.entry_uid, "claude_cli", Some(&session_cookie))) + .await + .unwrap(); + let status = response.status(); + let body = body_json(response).await; + let busy = body["error"].as_str().is_some_and(|e| e.contains("Text file busy")); + if busy && attempt < 20 { + attempt += 1; + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + continue; + } + break (status, body); + } + }; + let stored_title = || -> Option { + database::open_or_initialize(&archive_path) + .unwrap() + .query_row( + "SELECT title FROM archived_entries WHERE entry_uid = ?1", + [&entry.entry_uid], + |row| row.get(0), + ) + .unwrap() + }; + let (status, body) = run().await; + let first_stored = stored_title(); + // Instance setting beats env/default: the fake CLI sees --model custom-title-1. + let patch = app(registry.clone(), auth_path.clone()) + .oneshot(patch_settings_request( + serde_json::json!({ "title_model_claude_cli": " custom-title-1 " }), + &session_cookie, + )) + .await + .unwrap(); + assert_eq!(patch.status(), StatusCode::NO_CONTENT); + let (instance_status, instance_body) = run().await; + unsafe { + match previous_cli { + Some(value) => std::env::set_var("ARCHIVR_CLAUDE_CLI", value), + None => std::env::remove_var("ARCHIVR_CLAUDE_CLI"), + } + if let Some(value) = previous_model { + std::env::set_var("ARCHIVR_CLAUDE_TITLE_MODEL", value); + } + } + + let expected = "Thread about Rust async runtimes compared — @alice"; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["entry_uid"], entry.entry_uid.as_str()); + assert_eq!(body["title"], expected); + assert_eq!(first_stored.as_deref(), Some(expected)); + let instance_expected = "Thread about Instance model topic — @alice"; + assert_eq!(instance_status, StatusCode::OK, "{instance_body}"); + assert_eq!(instance_body["title"], instance_expected); + assert_eq!(stored_title().as_deref(), Some(instance_expected)); + } + + #[tokio::test] + async fn instance_settings_title_models_validate_and_report_source() { + let dir = tempfile::tempdir().unwrap(); + let (registry, _, auth_path) = make_test_registry(&dir); + let owner = make_test_session(&auth_path); + let patch = |body: serde_json::Value| { + app(registry.clone(), auth_path.clone()).oneshot(patch_settings_request(body, &owner)) + }; + let long = "m".repeat(101); + for bad in ["has space", "tab\there", "ctl\u{7}x", long.as_str()] { + let resp = patch(serde_json::json!({ "title_model_codex_cli": bad })).await.unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST, "{bad:?}"); + } + let resp = patch(serde_json::json!({ "title_model_anthropic_http": " claude-x-1 " })) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + let json = get_settings_json(registry.clone(), auth_path.clone(), &owner).await; + assert_eq!(json["title_model_anthropic_http"], "claude-x-1"); + assert!(json["title_model_codex_cli"].is_null()); + let anthropic = &json["title_models"]["anthropic_http"]; + assert_eq!(anthropic["model"], "claude-x-1"); + assert_eq!(anthropic["source"], "instance"); + assert_ne!(anthropic["fallback_source"], "instance"); + assert_eq!(anthropic["env_var"], "ARCHIVR_ANTHROPIC_TITLE_MODEL"); + // Empty clears back to NULL. + let resp = patch(serde_json::json!({ "title_model_anthropic_http": " " })).await.unwrap(); + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + let json = get_settings_json(registry, auth_path, &owner).await; + assert!(json["title_model_anthropic_http"].is_null()); + assert_ne!(json["title_models"]["anthropic_http"]["source"], "instance"); + } + + fn reorder_request(parent_uid: &str, uids: &[&str], cookie: Option<&str>) -> Request { + let mut builder = Request::builder() + .method("PUT") + .uri(format!("/api/archives/test/entries/{parent_uid}/children/order")) + .header("content-type", "application/json"); + if let Some(cookie) = cookie { + builder = builder.header("cookie", cookie); + } + builder + .body(Body::from(serde_json::json!({ "child_uids": uids }).to_string())) + .unwrap() + } + + async fn child_uids_via_api( + registry: ServerRegistry, + auth_path: std::path::PathBuf, + cookie: &str, + parent_uid: &str, + ) -> Vec { + let resp = app(registry, auth_path) + .oneshot( + Request::builder() + .uri(format!("/api/archives/test/entries/{parent_uid}/children")) + .header("cookie", cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + body_json(resp) + .await + .as_array() + .unwrap() + .iter() + .map(|e| e["entry_uid"].as_str().unwrap().to_string()) + .collect() + } + + #[tokio::test] + async fn reorder_entry_children_requires_auth() { + let dir = tempfile::tempdir().unwrap(); + let (registry, archive_path, auth_path) = make_test_registry(&dir); + let parent = make_test_entry(&archive_path); + let a = make_test_child(&archive_path, parent.id, "A", "https://example.com/a"); + let resp = app(registry, auth_path) + .oneshot(reorder_request(&parent.entry_uid, &[&a.entry_uid], None)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + } + + // owner is allowed by the default mask + #[tokio::test] + async fn reorder_entry_children_persists_order() { + let dir = tempfile::tempdir().unwrap(); + let (registry, archive_path, auth_path) = make_test_registry(&dir); + let cookie = make_test_session(&auth_path); + let parent = make_test_entry(&archive_path); + let a = make_test_child(&archive_path, parent.id, "A", "https://example.com/a"); + let b = make_test_child(&archive_path, parent.id, "B", "https://example.com/b"); + let c = make_test_child(&archive_path, parent.id, "C", "https://example.com/c"); + let order = [c.entry_uid.as_str(), a.entry_uid.as_str(), b.entry_uid.as_str()]; + let resp = app(registry.clone(), auth_path.clone()) + .oneshot(reorder_request(&parent.entry_uid, &order, Some(&cookie))) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + assert_eq!( + child_uids_via_api(registry, auth_path, &cookie, &parent.entry_uid).await, + order + ); + } + + #[tokio::test] + async fn reorder_entry_children_rejects_mismatched_set() { + let dir = tempfile::tempdir().unwrap(); + let (registry, archive_path, auth_path) = make_test_registry(&dir); + let cookie = make_test_session(&auth_path); + let parent = make_test_entry(&archive_path); + let a = make_test_child(&archive_path, parent.id, "A", "https://example.com/a"); + let b = make_test_child(&archive_path, parent.id, "B", "https://example.com/b"); + let other = make_test_entry(&archive_path); + let (a_u, b_u) = (a.entry_uid.as_str(), b.entry_uid.as_str()); + let bad: [&[&str]; 3] = [&[a_u], &[a_u, b_u, other.entry_uid.as_str()], &[a_u, a_u]]; + for uids in bad { + let resp = app(registry.clone(), auth_path.clone()) + .oneshot(reorder_request(&parent.entry_uid, uids, Some(&cookie))) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST, "{uids:?}"); + } + assert_eq!( + child_uids_via_api(registry, auth_path, &cookie, &parent.entry_uid).await, + [a_u, b_u] + ); + } + + #[tokio::test] + async fn reorder_entry_children_returns_404_for_unknown_parent() { + let dir = tempfile::tempdir().unwrap(); + let (registry, _, auth_path) = make_test_registry(&dir); + let cookie = make_test_session(&auth_path); + let resp = app(registry, auth_path) + .oneshot(reorder_request("no-such-uid", &[], Some(&cookie))) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn reorder_entry_children_denies_plain_user_by_default() { + let dir = tempfile::tempdir().unwrap(); + let (registry, archive_path, auth_path) = make_test_registry(&dir); + let owner = make_test_session(&auth_path); + let plain = make_role_session(&auth_path, "plain", &[]); + let parent = make_test_entry(&archive_path); + let a = make_test_child(&archive_path, parent.id, "A", "https://example.com/a"); + let b = make_test_child(&archive_path, parent.id, "B", "https://example.com/b"); + let (a_u, b_u) = (a.entry_uid.as_str(), b.entry_uid.as_str()); + let resp = app(registry.clone(), auth_path.clone()) + .oneshot(reorder_request(&parent.entry_uid, &[b_u, a_u], Some(&plain))) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + assert_eq!( + child_uids_via_api(registry, auth_path, &owner, &parent.entry_uid).await, + [a_u, b_u] + ); + } + + #[tokio::test] + async fn reorder_entry_children_allows_admin_by_default() { + let dir = tempfile::tempdir().unwrap(); + let (registry, archive_path, auth_path) = make_test_registry(&dir); + let admin = make_role_session(&auth_path, "adm", &["admin"]); + let parent = make_test_entry(&archive_path); + let a = make_test_child(&archive_path, parent.id, "A", "https://example.com/a"); + let b = make_test_child(&archive_path, parent.id, "B", "https://example.com/b"); + let (a_u, b_u) = (a.entry_uid.as_str(), b.entry_uid.as_str()); + let resp = app(registry.clone(), auth_path.clone()) + .oneshot(reorder_request(&parent.entry_uid, &[b_u, a_u], Some(&admin))) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + assert_eq!( + child_uids_via_api(registry, auth_path, &admin, &parent.entry_uid).await, + [b_u, a_u] + ); + } + + #[tokio::test] + async fn reorder_entry_children_allows_custom_role_after_owner_grants_it() { + let dir = tempfile::tempdir().unwrap(); + let (registry, archive_path, auth_path) = make_test_registry(&dir); + let owner = make_test_session(&auth_path); + { + let conn = archivr_core::database::open_auth_db(&auth_path).unwrap(); + let role = database::create_custom_role(&conn, "editor", "Editor").unwrap(); + assert_eq!(role.bit_position, 4); + } + let editor = make_role_session(&auth_path, "ed", &["editor"]); + let parent = make_test_entry(&archive_path); + let a = make_test_child(&archive_path, parent.id, "A", "https://example.com/a"); + let b = make_test_child(&archive_path, parent.id, "B", "https://example.com/b"); + let order = [b.entry_uid.as_str(), a.entry_uid.as_str()]; + let resp = app(registry.clone(), auth_path.clone()) + .oneshot(reorder_request(&parent.entry_uid, &order, Some(&editor))) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + let resp = app(registry.clone(), auth_path.clone()) + .oneshot(patch_settings_request( + serde_json::json!({ "reorder_children_role_bits": 4 | 8 | 16 }), + &owner, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + let resp = app(registry, auth_path) + .oneshot(reorder_request(&parent.entry_uid, &order, Some(&editor))) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + } + + #[tokio::test] + async fn reorder_entry_children_hides_parent_invisible_to_granted_role() { + let dir = tempfile::tempdir().unwrap(); + let (registry, archive_path, auth_path) = make_test_registry(&dir); + let owner = make_test_session(&auth_path); + let user = make_role_session(&auth_path, "plain", &["user"]); + let parent = make_test_entry(&archive_path); + let a = make_test_child(&archive_path, parent.id, "A", "https://example.com/a"); + let b = make_test_child(&archive_path, parent.id, "B", "https://example.com/b"); + // Admin/owner-only membership: the USER role cannot see the parent or children. + database::open_or_initialize(&archive_path) + .unwrap() + .execute("UPDATE collection_entries SET visibility_bits = 12", []) + .unwrap(); + let resp = app(registry.clone(), auth_path.clone()) + .oneshot(patch_settings_request( + serde_json::json!({ "reorder_children_role_bits": 2 | 4 | 8 }), + &owner, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + let order = [b.entry_uid.as_str(), a.entry_uid.as_str()]; + let resp = app(registry.clone(), auth_path.clone()) + .oneshot(reorder_request(&parent.entry_uid, &order, Some(&user))) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND, "granted but cannot see the parent"); + let resp = app(registry, auth_path) + .oneshot(reorder_request(&parent.entry_uid, &order, Some(&owner))) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NO_CONTENT, "owner sees every entry"); + } + + #[tokio::test] + async fn reorder_entry_children_empty_mask_denies_everyone() { + let dir = tempfile::tempdir().unwrap(); + let (registry, archive_path, auth_path) = make_test_registry(&dir); + let owner = make_test_session(&auth_path); + let parent = make_test_entry(&archive_path); + let a = make_test_child(&archive_path, parent.id, "A", "https://example.com/a"); + let resp = app(registry.clone(), auth_path.clone()) + .oneshot(patch_settings_request( + serde_json::json!({ "reorder_children_role_bits": 0 }), + &owner, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + let resp = app(registry, auth_path) + .oneshot(reorder_request(&parent.entry_uid, &[&a.entry_uid], Some(&owner))) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn instance_settings_reorder_mask_requires_owner_to_change() { + let dir = tempfile::tempdir().unwrap(); + let (registry, _, auth_path) = make_test_registry(&dir); + let owner = make_test_session(&auth_path); + let admin = make_role_session(&auth_path, "adm", &["admin"]); + let resp = app(registry.clone(), auth_path.clone()) + .oneshot(patch_settings_request( + serde_json::json!({ "reorder_children_role_bits": 2 }), + &admin, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + let json = get_settings_json(registry.clone(), auth_path.clone(), &admin).await; + assert_eq!(json["reorder_children_role_bits"], 12); + let resp = app(registry.clone(), auth_path.clone()) + .oneshot(patch_settings_request( + serde_json::json!({ "reorder_children_role_bits": 12, "open_registration_enabled": true }), + &admin, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + let json = get_settings_json(registry.clone(), auth_path.clone(), &admin).await; + assert_eq!(json["open_registration_enabled"], true); + let resp = app(registry.clone(), auth_path.clone()) + .oneshot(patch_settings_request( + serde_json::json!({ "reorder_children_role_bits": 14 }), + &owner, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + let json = get_settings_json(registry, auth_path, &owner).await; + assert_eq!(json["reorder_children_role_bits"], 14); + } + + #[tokio::test] + async fn instance_settings_reorder_mask_rejects_invalid_bits() { + let dir = tempfile::tempdir().unwrap(); + let (registry, _, auth_path) = make_test_registry(&dir); + let owner = make_test_session(&auth_path); + for mask in [16u32, 13, 1u32 << 31] { + let resp = app(registry.clone(), auth_path.clone()) + .oneshot(patch_settings_request( + serde_json::json!({ "reorder_children_role_bits": mask }), + &owner, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST, "{mask}"); + } + let json = get_settings_json(registry, auth_path, &owner).await; + assert_eq!(json["reorder_children_role_bits"], 12); + } + + #[tokio::test] + async fn auth_me_reports_can_reorder_children() { + let dir = tempfile::tempdir().unwrap(); + let (registry, _, auth_path) = make_test_registry(&dir); + let owner = make_test_session(&auth_path); + let plain = make_role_session(&auth_path, "plain", &[]); + let me = me_json(registry.clone(), auth_path.clone(), &owner).await; + assert_eq!(me["can_reorder_children"], true); + let me = me_json(registry.clone(), auth_path.clone(), &plain).await; + assert_eq!(me["can_reorder_children"], false); + let resp = app(registry.clone(), auth_path.clone()) + .oneshot(patch_settings_request( + serde_json::json!({ "reorder_children_role_bits": 14 }), + &owner, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + let me = me_json(registry, auth_path, &plain).await; + assert_eq!(me["can_reorder_children"], true); + } + #[tokio::test] async fn auth_me_returns_humanize_slugs_false_by_default() { let dir = tempfile::tempdir().unwrap(); @@ -6000,24 +8338,7 @@ mod tests { // Create parent entry. let parent = make_test_entry(&archive_path); // Create child entry referencing parent. - let child = { - let conn = database::open_or_initialize(&archive_path).unwrap(); - let user_id = database::ensure_default_user(&conn).unwrap(); - let run = database::create_archive_run(&conn, user_id, 1).unwrap(); - let si = database::upsert_source_identity( - &conn, "web", "page", None, - Some("https://example.com/child"), "https://example.com/child", - ).unwrap(); - database::create_archived_entry(&conn, &database::NewEntry { - source_identity_id: si, archive_run_id: run.id, - parent_entry_id: Some(parent.id), root_entry_id: Some(parent.id), - created_by_user_id: user_id, owned_by_user_id: user_id, - source_kind: "web".to_string(), entity_kind: "page".to_string(), - title: Some("Child Entry".to_string()), visibility: "private".to_string(), - representation_kind: "html".to_string(), - source_metadata_json: "{}".to_string(), display_metadata_json: None, - }).unwrap() - }; + let child = make_test_child(&archive_path, parent.id, "Child Entry", "https://example.com/child"); // Put parent in a public collection with guest visibility. let coll = api_make_collection( registry.clone(), auth_path.clone(), &session, "PubParent", "pub-parent", 3, false, @@ -6119,4 +8440,297 @@ mod tests { assert!(names.contains(&"PublicColl"), "public collection must be returned to guests; got {:?}", names); } + + // ── Local transcription fallback ─────────────────────────────────────── + + /// Serializes tests that set transcription env vars (std Mutex: the + /// workspace tokio features don't include `sync`). + static TRANSCRIBE_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + const TRANSCRIBE_ENV_VARS: [&str; 13] = [ + "ARCHIVR_TRANSCRIBE_ENGINES", + "ARCHIVR_WHISPER_CLI", + "ARCHIVR_WHISPER_MODEL", + "ARCHIVR_WHISPER_BACKEND", + "ARCHIVR_WHISPER_LANGUAGES", + "ARCHIVR_PARAKEET_CLI", + "ARCHIVR_PARAKEET_MODEL", + "ARCHIVR_PARAKEET_LANGUAGES", + "ARCHIVR_PHONON2_CLI", + "ARCHIVR_PHONON2_MODEL", + "ARCHIVR_TRANSCRIBE_TIMEOUT", + "ARCHIVR_FFMPEG", + "ARCHIVR_CODEX_CLI", + ]; + + /// Holds the lock, clears every transcription var, sets `vars`, and + /// restores the previous values on drop. + struct TranscribeEnv { + saved: Vec<(&'static str, Option)>, + _guard: std::sync::MutexGuard<'static, ()>, + } + + impl Drop for TranscribeEnv { + fn drop(&mut self) { + for (key, value) in &self.saved { + match value { + Some(v) => unsafe { std::env::set_var(key, v) }, + None => unsafe { std::env::remove_var(key) }, + } + } + } + } + + fn transcribe_env(vars: &[(&str, &str)]) -> TranscribeEnv { + let guard = TRANSCRIBE_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let saved = TRANSCRIBE_ENV_VARS + .iter() + .map(|k| (*k, std::env::var_os(k))) + .collect(); + for k in TRANSCRIBE_ENV_VARS { + unsafe { std::env::remove_var(k) }; + } + for (k, v) in vars { + unsafe { std::env::set_var(k, v) }; + } + TranscribeEnv { + saved, + _guard: guard, + } + } + + /// Writes an executable `#!/bin/sh` stub, then waits until it can be exec'd + /// (same ETXTBSY retry as core's `downloader::write_script`: a child forked by + /// a parallel test may still hold our write fd, rust-lang/rust#114554). A guard + /// line after the shebang makes the `--version` probe a no-op so stub bodies + /// don't write files during it. + #[cfg(unix)] + fn write_transcribe_stub(dir: &std::path::Path, name: &str, body: &str) -> std::path::PathBuf { + use std::os::unix::fs::PermissionsExt; + let path = dir.join(name); + let (shebang, rest) = body.split_once('\n').unwrap_or((body, "")); + std::fs::write(&path, format!("{shebang}\n[ \"$1\" = --version ] && exit 0\n{rest}")).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + for _ in 0..200 { + match std::process::Command::new(&path).arg("--version").output() { + Err(e) if e.kind() == std::io::ErrorKind::ExecutableFileBusy => { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + _ => return path, + } + } + panic!("{} stayed busy (ETXTBSY)", path.display()); + } + + async fn post_summary_body( + registry: ServerRegistry, + auth_path: std::path::PathBuf, + entry_uid: &str, + payload: serde_json::Value, + ) -> axum::response::Response { + let session_cookie = make_test_session(&auth_path); + app(registry, auth_path) + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/api/archives/test/entries/{entry_uid}/summary")) + .header("content-type", "application/json") + .header("cookie", &session_cookie) + .body(json_body(&payload)) + .unwrap(), + ) + .await + .unwrap() + } + + #[tokio::test] + async fn transcription_engines_endpoint_requires_user() { + let dir = tempfile::tempdir().unwrap(); + let (registry, _archive_path, auth_path) = make_test_registry(&dir); + let response = app(registry, auth_path) + .oneshot( + Request::builder() + .uri("/api/summary/transcription-engines") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + #[allow(clippy::await_holding_lock)] + async fn transcription_engines_endpoint_lists_enabled_engines() { + let dir = tempfile::tempdir().unwrap(); + let (registry, _archive_path, auth_path) = make_test_registry(&dir); + let session_cookie = make_test_session(&auth_path); + let _env = transcribe_env(&[ + ("ARCHIVR_TRANSCRIBE_ENGINES", "phonon2"), + ("ARCHIVR_PHONON2_CLI", "/usr/bin/false"), + ]); + let response = app(registry, auth_path) + .oneshot( + Request::builder() + .uri("/api/summary/transcription-engines") + .header("cookie", &session_cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = body_json(response).await; + assert_eq!( + body, + serde_json::json!([{ + "kind": "phonon2", + "label": "Phonon-2", + "english_only": true, + "languages": ["en"], + }]) + ); + } + + #[tokio::test] + #[allow(clippy::await_holding_lock)] + async fn summary_post_rejects_unconfigured_transcribe_engine_with_400() { + let dir = tempfile::tempdir().unwrap(); + let (registry, archive_path, auth_path) = make_test_registry(&dir); + let entry = make_test_youtube_entry(&archive_path, "youtube-test:offline"); + let _env = transcribe_env(&[ + ("ARCHIVR_TRANSCRIBE_ENGINES", "whisper"), + ("ARCHIVR_CODEX_CLI", "/usr/bin/false"), + ]); + let response = post_summary_body( + registry, + auth_path, + &entry.entry_uid, + serde_json::json!({ "provider": "codex_cli", "transcribe_engine": "whisper" }), + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = body_json(response).await.to_string(); + assert!(body.contains("ARCHIVR_WHISPER_MODEL"), "{body}"); + let conn = database::open_or_initialize(&archive_path).unwrap(); + assert!(database::latest_entry_summary_attempt(&conn, entry.id) + .unwrap() + .is_none()); + } + + #[tokio::test] + #[allow(clippy::await_holding_lock)] + async fn summary_post_rejects_engine_not_enabled() { + let dir = tempfile::tempdir().unwrap(); + let (registry, archive_path, auth_path) = make_test_registry(&dir); + let entry = make_test_youtube_entry(&archive_path, "youtube-test:offline"); + let _env = transcribe_env(&[ + ("ARCHIVR_PHONON2_CLI", "/usr/bin/false"), + ("ARCHIVR_CODEX_CLI", "/usr/bin/false"), + ]); + let response = post_summary_body( + registry, + auth_path, + &entry.entry_uid, + serde_json::json!({ "provider": "codex_cli", "transcribe_engine": "phonon2" }), + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = body_json(response).await.to_string(); + assert!(body.contains("ARCHIVR_TRANSCRIBE_ENGINES"), "{body}"); + let conn = database::open_or_initialize(&archive_path).unwrap(); + assert!(database::latest_entry_summary_attempt(&conn, entry.id) + .unwrap() + .is_none()); + } + + #[cfg(unix)] + #[tokio::test] + #[allow(clippy::await_holding_lock)] + async fn youtube_summary_with_stub_transcription_reaches_provider() { + let dir = tempfile::tempdir().unwrap(); + let (registry, archive_path, auth_path) = make_test_registry(&dir); + // Non-HTTP canonical URL: the subtitle fetch never spawns yt-dlp. + let entry = make_test_youtube_entry(&archive_path, "youtube-test:offline"); + add_summary_test_artifact( + &archive_path, + entry.id, + "raw/youtube-video.mp4", + "primary_media", + "video/mp4", + b"video fixture", + ); + let bin = tempfile::tempdir().unwrap(); + let ffmpeg = write_transcribe_stub( + bin.path(), + "ffmpeg", + "#!/bin/sh\nfor a; do last=\"$a\"; done\nhead -c 3244 /dev/zero > \"$last\"\n", + ); + let whisper = write_transcribe_stub( + bin.path(), + "whisper-cli", + "#!/bin/sh\nprefix=\"\"\nwhile [ $# -gt 0 ]; do\n if [ \"$1\" = \"-of\" ]; then prefix=\"$2\"; shift; fi\n shift\ndone\nprintf 'WEBVTT\\n\\n00:00:00.000 --> 00:00:02.000\\nhello from the stub transcript\\n' > \"$prefix.vtt\"\nprintf '{\"result\":{\"language\":\"en\"}}' > \"$prefix.json\"\n", + ); + let _env = transcribe_env(&[ + ("ARCHIVR_TRANSCRIBE_ENGINES", "whisper"), + ("ARCHIVR_WHISPER_CLI", whisper.to_str().unwrap()), + ("ARCHIVR_WHISPER_MODEL", "/tmp/x/ggml-tiny.bin"), + ("ARCHIVR_FFMPEG", ffmpeg.to_str().unwrap()), + ("ARCHIVR_CODEX_CLI", "/usr/bin/false"), + ]); + + let response = post_summary_body( + registry, + auth_path, + &entry.entry_uid, + serde_json::json!({ "provider": "codex_cli", "transcribe_engine": "whisper" }), + ) + .await; + assert_eq!(response.status(), StatusCode::ACCEPTED); + let summary_uid = body_json(response).await["summary_uid"] + .as_str() + .unwrap() + .to_string(); + + let mut stored = None; + for _ in 0..200 { + let conn = database::open_or_initialize(&archive_path).unwrap(); + let row = database::get_entry_summary_by_uid(&conn, &summary_uid) + .unwrap() + .unwrap(); + if row.status == "failed" { + stored = Some(row); + break; + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + let stored = stored.expect("summary row should fail at the /usr/bin/false provider"); + let error_text = stored.error_text.unwrap_or_default(); + // Neither the no-subtitles copy nor a transcription copy: transcription + // succeeded and the (failing) provider ran. + assert_ne!(error_text, summarizer::NO_SUBTITLES_SUMMARY_MESSAGE); + assert_ne!(error_text, summarizer::NO_SUBTITLES_AFTER_TRANSCRIPTION_MESSAGE); + assert!(!error_text.starts_with("Local transcription"), "{error_text}"); + assert!(!error_text.contains("can’t be transcribed"), "{error_text}"); + assert_ne!(stored.input_sha256, summarizer::SUBTITLE_FETCH_PENDING_INPUT_SHA256); + + let conn = database::open_or_initialize(&archive_path).unwrap(); + let subtitles = database::list_entry_artifacts_by_role(&conn, entry.id, "subtitle").unwrap(); + assert_eq!(subtitles.len(), 1); + let meta: serde_json::Value = + serde_json::from_str(subtitles[0].metadata_json.as_deref().unwrap()).unwrap(); + assert_eq!(meta["kind"], "transcribed"); + assert_eq!(meta["origin"], "transcription"); + assert_eq!(meta["engine"], "whisper"); + assert_eq!(meta["model"], "ggml-tiny.bin"); + } + + #[test] + fn summary_failure_error_text_prefers_transcription_copy() { + let copy = "Local transcription with Whisper failed: the transcription engine exited with an error. Check the server log for details."; + let error = anyhow::anyhow!("whisper-cli exited with 1: /secret/path") + .context(archivr_core::transcriber::TranscriptionUserMessage(copy.to_string())) + .context("outer"); + assert_eq!(summary_failure_error_text(&error), copy); + } } diff --git a/crates/archivr-server/static/assets/index-B67momER.js b/crates/archivr-server/static/assets/index-B67momER.js deleted file mode 100644 index 0bff979..0000000 --- a/crates/archivr-server/static/assets/index-B67momER.js +++ /dev/null @@ -1,47 +0,0 @@ -(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const l of document.querySelectorAll('link[rel="modulepreload"]'))r(l);new MutationObserver(l=>{for(const i of l)if(i.type==="childList")for(const a of i.addedNodes)a.tagName==="LINK"&&a.rel==="modulepreload"&&r(a)}).observe(document,{childList:!0,subtree:!0});function n(l){const i={};return l.integrity&&(i.integrity=l.integrity),l.referrerPolicy&&(i.referrerPolicy=l.referrerPolicy),l.crossOrigin==="use-credentials"?i.credentials="include":l.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function r(l){if(l.ep)return;l.ep=!0;const i=n(l);fetch(l.href,i)}})();var $u={exports:{}},ks={},Ou={exports:{}},ne={};/** - * @license React - * react.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */var dl=Symbol.for("react.element"),cf=Symbol.for("react.portal"),df=Symbol.for("react.fragment"),ff=Symbol.for("react.strict_mode"),pf=Symbol.for("react.profiler"),hf=Symbol.for("react.provider"),mf=Symbol.for("react.context"),gf=Symbol.for("react.forward_ref"),yf=Symbol.for("react.suspense"),vf=Symbol.for("react.memo"),xf=Symbol.for("react.lazy"),po=Symbol.iterator;function wf(e){return e===null||typeof e!="object"?null:(e=po&&e[po]||e["@@iterator"],typeof e=="function"?e:null)}var Au={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},Mu=Object.assign,Fu={};function yr(e,t,n){this.props=e,this.context=t,this.refs=Fu,this.updater=n||Au}yr.prototype.isReactComponent={};yr.prototype.setState=function(e,t){if(typeof e!="object"&&typeof e!="function"&&e!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,e,t,"setState")};yr.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,"forceUpdate")};function Bu(){}Bu.prototype=yr.prototype;function da(e,t,n){this.props=e,this.context=t,this.refs=Fu,this.updater=n||Au}var fa=da.prototype=new Bu;fa.constructor=da;Mu(fa,yr.prototype);fa.isPureReactComponent=!0;var ho=Array.isArray,Uu=Object.prototype.hasOwnProperty,pa={current:null},Hu={key:!0,ref:!0,__self:!0,__source:!0};function Wu(e,t,n){var r,l={},i=null,a=null;if(t!=null)for(r in t.ref!==void 0&&(a=t.ref),t.key!==void 0&&(i=""+t.key),t)Uu.call(t,r)&&!Hu.hasOwnProperty(r)&&(l[r]=t[r]);var o=arguments.length-2;if(o===1)l.children=n;else if(1>>1,ee=D[te];if(0>>1;tel(ye,H))cel(Ee,ye)?(D[te]=Ee,D[ce]=H,te=ce):(D[te]=ye,D[Y]=H,te=Y);else if(cel(Ee,H))D[te]=Ee,D[ce]=H,te=ce;else break e}}return P}function l(D,P){var H=D.sortIndex-P.sortIndex;return H!==0?H:D.id-P.id}if(typeof performance=="object"&&typeof performance.now=="function"){var i=performance;e.unstable_now=function(){return i.now()}}else{var a=Date,o=a.now();e.unstable_now=function(){return a.now()-o}}var u=[],c=[],y=1,g=null,h=3,v=!1,x=!1,S=!1,N=typeof setTimeout=="function"?setTimeout:null,p=typeof clearTimeout=="function"?clearTimeout:null,f=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function m(D){for(var P=n(c);P!==null;){if(P.callback===null)r(c);else if(P.startTime<=D)r(c),P.sortIndex=P.expirationTime,t(u,P);else break;P=n(c)}}function w(D){if(S=!1,m(D),!x)if(n(u)!==null)x=!0,Ae(C);else{var P=n(c);P!==null&&ge(w,P.startTime-D)}}function C(D,P){x=!1,S&&(S=!1,p(k),k=-1),v=!0;var H=h;try{for(m(P),g=n(u);g!==null&&(!(g.expirationTime>P)||D&&!q());){var te=g.callback;if(typeof te=="function"){g.callback=null,h=g.priorityLevel;var ee=te(g.expirationTime<=P);P=e.unstable_now(),typeof ee=="function"?g.callback=ee:g===n(u)&&r(u),m(P)}else r(u);g=n(u)}if(g!==null)var Re=!0;else{var Y=n(c);Y!==null&&ge(w,Y.startTime-P),Re=!1}return Re}finally{g=null,h=H,v=!1}}var E=!1,b=null,k=-1,T=5,L=-1;function q(){return!(e.unstable_now()-LD||125te?(D.sortIndex=H,t(c,D),n(u)===null&&D===n(c)&&(S?(p(k),k=-1):S=!0,ge(w,H-te))):(D.sortIndex=ee,t(u,D),x||v||(x=!0,Ae(C))),D},e.unstable_shouldYield=q,e.unstable_wrapCallback=function(D){var P=h;return function(){var H=h;h=P;try{return D.apply(this,arguments)}finally{h=H}}}})(Xu);qu.exports=Xu;var Lf=qu.exports;/** - * @license React - * react-dom.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */var Rf=d,ht=Lf;function R(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,n=1;n"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),vi=Object.prototype.hasOwnProperty,zf=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,go={},yo={};function If(e){return vi.call(yo,e)?!0:vi.call(go,e)?!1:zf.test(e)?yo[e]=!0:(go[e]=!0,!1)}function Df(e,t,n,r){if(n!==null&&n.type===0)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return r?!1:n!==null?!n.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function $f(e,t,n,r){if(t===null||typeof t>"u"||Df(e,t,n,r))return!0;if(r)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function nt(e,t,n,r,l,i,a){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=r,this.attributeNamespace=l,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=i,this.removeEmptyString=a}var We={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){We[e]=new nt(e,0,!1,e,null,!1,!1)});[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];We[t]=new nt(t,1,!1,e[1],null,!1,!1)});["contentEditable","draggable","spellCheck","value"].forEach(function(e){We[e]=new nt(e,2,!1,e.toLowerCase(),null,!1,!1)});["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){We[e]=new nt(e,2,!1,e,null,!1,!1)});"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){We[e]=new nt(e,3,!1,e.toLowerCase(),null,!1,!1)});["checked","multiple","muted","selected"].forEach(function(e){We[e]=new nt(e,3,!0,e,null,!1,!1)});["capture","download"].forEach(function(e){We[e]=new nt(e,4,!1,e,null,!1,!1)});["cols","rows","size","span"].forEach(function(e){We[e]=new nt(e,6,!1,e,null,!1,!1)});["rowSpan","start"].forEach(function(e){We[e]=new nt(e,5,!1,e.toLowerCase(),null,!1,!1)});var ma=/[\-:]([a-z])/g;function ga(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var t=e.replace(ma,ga);We[t]=new nt(t,1,!1,e,null,!1,!1)});"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(ma,ga);We[t]=new nt(t,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)});["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(ma,ga);We[t]=new nt(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)});["tabIndex","crossOrigin"].forEach(function(e){We[e]=new nt(e,1,!1,e.toLowerCase(),null,!1,!1)});We.xlinkHref=new nt("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1);["src","href","action","formAction"].forEach(function(e){We[e]=new nt(e,1,!1,e.toLowerCase(),null,!0,!0)});function ya(e,t,n,r){var l=We.hasOwnProperty(t)?We[t]:null;(l!==null?l.type!==0:r||!(2o||l[a]!==i[o]){var u=` -`+l[a].replace(" at new "," at ");return e.displayName&&u.includes("")&&(u=u.replace("",e.displayName)),u}while(1<=a&&0<=o);break}}}finally{Us=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:"")?Rr(e):""}function Of(e){switch(e.tag){case 5:return Rr(e.type);case 16:return Rr("Lazy");case 13:return Rr("Suspense");case 19:return Rr("SuspenseList");case 0:case 2:case 15:return e=Hs(e.type,!1),e;case 11:return e=Hs(e.type.render,!1),e;case 1:return e=Hs(e.type,!0),e;default:return""}}function ji(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case Qn:return"Fragment";case Vn:return"Portal";case xi:return"Profiler";case va:return"StrictMode";case wi:return"Suspense";case ki:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case Yu:return(e.displayName||"Context")+".Consumer";case Gu:return(e._context.displayName||"Context")+".Provider";case xa:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case wa:return t=e.displayName||null,t!==null?t:ji(e.type)||"Memo";case ln:t=e._payload,e=e._init;try{return ji(e(t))}catch{}}return null}function Af(e){var t=e.type;switch(e.tag){case 24:return"Cache";case 9:return(t.displayName||"Context")+".Consumer";case 10:return(t._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=t.render,e=e.displayName||e.name||"",t.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return t;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return ji(t);case 8:return t===va?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t=="function")return t.displayName||t.name||null;if(typeof t=="string")return t}return null}function xn(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function ec(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function Mf(e){var t=ec(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),r=""+e[t];if(!e.hasOwnProperty(t)&&typeof n<"u"&&typeof n.get=="function"&&typeof n.set=="function"){var l=n.get,i=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return l.call(this)},set:function(a){r=""+a,i.call(this,a)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(a){r=""+a},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function xl(e){e._valueTracker||(e._valueTracker=Mf(e))}function tc(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r="";return e&&(r=ec(e)?e.checked?"true":"false":e.value),e=r,e!==n?(t.setValue(e),!0):!1}function Xl(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function Si(e,t){var n=t.checked;return Se({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function xo(e,t){var n=t.defaultValue==null?"":t.defaultValue,r=t.checked!=null?t.checked:t.defaultChecked;n=xn(t.value!=null?t.value:n),e._wrapperState={initialChecked:r,initialValue:n,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function nc(e,t){t=t.checked,t!=null&&ya(e,"checked",t,!1)}function Ni(e,t){nc(e,t);var n=xn(t.value),r=t.type;if(n!=null)r==="number"?(n===0&&e.value===""||e.value!=n)&&(e.value=""+n):e.value!==""+n&&(e.value=""+n);else if(r==="submit"||r==="reset"){e.removeAttribute("value");return}t.hasOwnProperty("value")?_i(e,t.type,n):t.hasOwnProperty("defaultValue")&&_i(e,t.type,xn(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function wo(e,t,n){if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var r=t.type;if(!(r!=="submit"&&r!=="reset"||t.value!==void 0&&t.value!==null))return;t=""+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,n!==""&&(e.name=n)}function _i(e,t,n){(t!=="number"||Xl(e.ownerDocument)!==e)&&(n==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+n&&(e.defaultValue=""+n))}var zr=Array.isArray;function rr(e,t,n,r){if(e=e.options,t){t={};for(var l=0;l"+t.valueOf().toString()+"",t=wl.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function qr(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var Ar={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},Ff=["Webkit","ms","Moz","O"];Object.keys(Ar).forEach(function(e){Ff.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),Ar[t]=Ar[e]})});function ic(e,t,n){return t==null||typeof t=="boolean"||t===""?"":n||typeof t!="number"||t===0||Ar.hasOwnProperty(e)&&Ar[e]?(""+t).trim():t+"px"}function ac(e,t){e=e.style;for(var n in t)if(t.hasOwnProperty(n)){var r=n.indexOf("--")===0,l=ic(n,t[n],r);n==="float"&&(n="cssFloat"),r?e.setProperty(n,l):e[n]=l}}var Bf=Se({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function bi(e,t){if(t){if(Bf[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(R(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(R(60));if(typeof t.dangerouslySetInnerHTML!="object"||!("__html"in t.dangerouslySetInnerHTML))throw Error(R(61))}if(t.style!=null&&typeof t.style!="object")throw Error(R(62))}}function Ti(e,t){if(e.indexOf("-")===-1)return typeof t.is=="string";switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var Pi=null;function ka(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var Li=null,lr=null,sr=null;function So(e){if(e=hl(e)){if(typeof Li!="function")throw Error(R(280));var t=e.stateNode;t&&(t=Cs(t),Li(e.stateNode,e.type,t))}}function oc(e){lr?sr?sr.push(e):sr=[e]:lr=e}function uc(){if(lr){var e=lr,t=sr;if(sr=lr=null,So(e),t)for(e=0;e>>=0,e===0?32:31-(Yf(e)/Zf|0)|0}var kl=64,jl=4194304;function Ir(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function Zl(e,t){var n=e.pendingLanes;if(n===0)return 0;var r=0,l=e.suspendedLanes,i=e.pingedLanes,a=n&268435455;if(a!==0){var o=a&~l;o!==0?r=Ir(o):(i&=a,i!==0&&(r=Ir(i)))}else a=n&~l,a!==0?r=Ir(a):i!==0&&(r=Ir(i));if(r===0)return 0;if(t!==0&&t!==r&&!(t&l)&&(l=r&-r,i=t&-t,l>=i||l===16&&(i&4194240)!==0))return t;if(r&4&&(r|=n&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=r;0n;n++)t.push(e);return t}function fl(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-Rt(t),e[t]=n}function rp(e,t){var n=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var r=e.eventTimes;for(e=e.expirationTimes;0=Fr),Ro=" ",zo=!1;function Tc(e,t){switch(e){case"keyup":return Lp.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Pc(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var Kn=!1;function zp(e,t){switch(e){case"compositionend":return Pc(t);case"keypress":return t.which!==32?null:(zo=!0,Ro);case"textInput":return e=t.data,e===Ro&&zo?null:e;default:return null}}function Ip(e,t){if(Kn)return e==="compositionend"||!Ta&&Tc(e,t)?(e=Ec(),Ml=Ca=un=null,Kn=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=Oo(n)}}function Ic(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Ic(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function Dc(){for(var e=window,t=Xl();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href=="string"}catch{n=!1}if(n)e=t.contentWindow;else break;t=Xl(e.document)}return t}function Pa(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}function Hp(e){var t=Dc(),n=e.focusedElem,r=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&Ic(n.ownerDocument.documentElement,n)){if(r!==null&&Pa(n)){if(t=r.start,e=r.end,e===void 0&&(e=t),"selectionStart"in n)n.selectionStart=t,n.selectionEnd=Math.min(e,n.value.length);else if(e=(t=n.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var l=n.textContent.length,i=Math.min(r.start,l);r=r.end===void 0?i:Math.min(r.end,l),!e.extend&&i>r&&(l=r,r=i,i=l),l=Ao(n,i);var a=Ao(n,r);l&&a&&(e.rangeCount!==1||e.anchorNode!==l.node||e.anchorOffset!==l.offset||e.focusNode!==a.node||e.focusOffset!==a.offset)&&(t=t.createRange(),t.setStart(l.node,l.offset),e.removeAllRanges(),i>r?(e.addRange(t),e.extend(a.node,a.offset)):(t.setEnd(a.node,a.offset),e.addRange(t)))}}for(t=[],e=n;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof n.focus=="function"&&n.focus(),n=0;n=document.documentMode,qn=null,Oi=null,Ur=null,Ai=!1;function Mo(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;Ai||qn==null||qn!==Xl(r)||(r=qn,"selectionStart"in r&&Pa(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),Ur&&el(Ur,r)||(Ur=r,r=ns(Oi,"onSelect"),0Gn||(e.current=Wi[Gn],Wi[Gn]=null,Gn--)}function fe(e,t){Gn++,Wi[Gn]=e.current,e.current=t}var wn={},Ge=jn(wn),it=jn(!1),zn=wn;function dr(e,t){var n=e.type.contextTypes;if(!n)return wn;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var l={},i;for(i in n)l[i]=t[i];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=l),l}function at(e){return e=e.childContextTypes,e!=null}function ls(){me(it),me(Ge)}function Qo(e,t,n){if(Ge.current!==wn)throw Error(R(168));fe(Ge,t),fe(it,n)}function Wc(e,t,n){var r=e.stateNode;if(t=t.childContextTypes,typeof r.getChildContext!="function")return n;r=r.getChildContext();for(var l in r)if(!(l in t))throw Error(R(108,Af(e)||"Unknown",l));return Se({},n,r)}function ss(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||wn,zn=Ge.current,fe(Ge,e),fe(it,it.current),!0}function Ko(e,t,n){var r=e.stateNode;if(!r)throw Error(R(169));n?(e=Wc(e,t,zn),r.__reactInternalMemoizedMergedChildContext=e,me(it),me(Ge),fe(Ge,e)):me(it),fe(it,n)}var Qt=null,Es=!1,ri=!1;function Vc(e){Qt===null?Qt=[e]:Qt.push(e)}function th(e){Es=!0,Vc(e)}function Sn(){if(!ri&&Qt!==null){ri=!0;var e=0,t=oe;try{var n=Qt;for(oe=1;e>=a,l-=a,Kt=1<<32-Rt(t)+l|n<k?(T=b,b=null):T=b.sibling;var L=h(p,b,m[k],w);if(L===null){b===null&&(b=T);break}e&&b&&L.alternate===null&&t(p,b),f=i(L,f,k),E===null?C=L:E.sibling=L,E=L,b=T}if(k===m.length)return n(p,b),ve&&_n(p,k),C;if(b===null){for(;kk?(T=b,b=null):T=b.sibling;var q=h(p,b,L.value,w);if(q===null){b===null&&(b=T);break}e&&b&&q.alternate===null&&t(p,b),f=i(q,f,k),E===null?C=q:E.sibling=q,E=q,b=T}if(L.done)return n(p,b),ve&&_n(p,k),C;if(b===null){for(;!L.done;k++,L=m.next())L=g(p,L.value,w),L!==null&&(f=i(L,f,k),E===null?C=L:E.sibling=L,E=L);return ve&&_n(p,k),C}for(b=r(p,b);!L.done;k++,L=m.next())L=v(b,p,k,L.value,w),L!==null&&(e&&L.alternate!==null&&b.delete(L.key===null?k:L.key),f=i(L,f,k),E===null?C=L:E.sibling=L,E=L);return e&&b.forEach(function(F){return t(p,F)}),ve&&_n(p,k),C}function N(p,f,m,w){if(typeof m=="object"&&m!==null&&m.type===Qn&&m.key===null&&(m=m.props.children),typeof m=="object"&&m!==null){switch(m.$$typeof){case vl:e:{for(var C=m.key,E=f;E!==null;){if(E.key===C){if(C=m.type,C===Qn){if(E.tag===7){n(p,E.sibling),f=l(E,m.props.children),f.return=p,p=f;break e}}else if(E.elementType===C||typeof C=="object"&&C!==null&&C.$$typeof===ln&&Jo(C)===E.type){n(p,E.sibling),f=l(E,m.props),f.ref=br(p,E,m),f.return=p,p=f;break e}n(p,E);break}else t(p,E);E=E.sibling}m.type===Qn?(f=Rn(m.props.children,p.mode,w,m.key),f.return=p,p=f):(w=Kl(m.type,m.key,m.props,null,p.mode,w),w.ref=br(p,f,m),w.return=p,p=w)}return a(p);case Vn:e:{for(E=m.key;f!==null;){if(f.key===E)if(f.tag===4&&f.stateNode.containerInfo===m.containerInfo&&f.stateNode.implementation===m.implementation){n(p,f.sibling),f=l(f,m.children||[]),f.return=p,p=f;break e}else{n(p,f);break}else t(p,f);f=f.sibling}f=di(m,p.mode,w),f.return=p,p=f}return a(p);case ln:return E=m._init,N(p,f,E(m._payload),w)}if(zr(m))return x(p,f,m,w);if(Sr(m))return S(p,f,m,w);Tl(p,m)}return typeof m=="string"&&m!==""||typeof m=="number"?(m=""+m,f!==null&&f.tag===6?(n(p,f.sibling),f=l(f,m),f.return=p,p=f):(n(p,f),f=ci(m,p.mode,w),f.return=p,p=f),a(p)):n(p,f)}return N}var pr=Xc(!0),Jc=Xc(!1),os=jn(null),us=null,er=null,Ia=null;function Da(){Ia=er=us=null}function $a(e){var t=os.current;me(os),e._currentValue=t}function Ki(e,t,n){for(;e!==null;){var r=e.alternate;if((e.childLanes&t)!==t?(e.childLanes|=t,r!==null&&(r.childLanes|=t)):r!==null&&(r.childLanes&t)!==t&&(r.childLanes|=t),e===n)break;e=e.return}}function ar(e,t){us=e,Ia=er=null,e=e.dependencies,e!==null&&e.firstContext!==null&&(e.lanes&t&&(st=!0),e.firstContext=null)}function Nt(e){var t=e._currentValue;if(Ia!==e)if(e={context:e,memoizedValue:t,next:null},er===null){if(us===null)throw Error(R(308));er=e,us.dependencies={lanes:0,firstContext:e}}else er=er.next=e;return t}var bn=null;function Oa(e){bn===null?bn=[e]:bn.push(e)}function Gc(e,t,n,r){var l=t.interleaved;return l===null?(n.next=n,Oa(t)):(n.next=l.next,l.next=n),t.interleaved=n,Yt(e,r)}function Yt(e,t){e.lanes|=t;var n=e.alternate;for(n!==null&&(n.lanes|=t),n=e,e=e.return;e!==null;)e.childLanes|=t,n=e.alternate,n!==null&&(n.childLanes|=t),n=e,e=e.return;return n.tag===3?n.stateNode:null}var sn=!1;function Aa(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function Yc(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function Xt(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function mn(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,se&2){var l=r.pending;return l===null?t.next=t:(t.next=l.next,l.next=t),r.pending=t,Yt(e,n)}return l=r.interleaved,l===null?(t.next=t,Oa(r)):(t.next=l.next,l.next=t),r.interleaved=t,Yt(e,n)}function Bl(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,(n&4194240)!==0)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,Sa(e,n)}}function Go(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var l=null,i=null;if(n=n.firstBaseUpdate,n!==null){do{var a={eventTime:n.eventTime,lane:n.lane,tag:n.tag,payload:n.payload,callback:n.callback,next:null};i===null?l=i=a:i=i.next=a,n=n.next}while(n!==null);i===null?l=i=t:i=i.next=t}else l=i=t;n={baseState:r.baseState,firstBaseUpdate:l,lastBaseUpdate:i,shared:r.shared,effects:r.effects},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}function cs(e,t,n,r){var l=e.updateQueue;sn=!1;var i=l.firstBaseUpdate,a=l.lastBaseUpdate,o=l.shared.pending;if(o!==null){l.shared.pending=null;var u=o,c=u.next;u.next=null,a===null?i=c:a.next=c,a=u;var y=e.alternate;y!==null&&(y=y.updateQueue,o=y.lastBaseUpdate,o!==a&&(o===null?y.firstBaseUpdate=c:o.next=c,y.lastBaseUpdate=u))}if(i!==null){var g=l.baseState;a=0,y=c=u=null,o=i;do{var h=o.lane,v=o.eventTime;if((r&h)===h){y!==null&&(y=y.next={eventTime:v,lane:0,tag:o.tag,payload:o.payload,callback:o.callback,next:null});e:{var x=e,S=o;switch(h=t,v=n,S.tag){case 1:if(x=S.payload,typeof x=="function"){g=x.call(v,g,h);break e}g=x;break e;case 3:x.flags=x.flags&-65537|128;case 0:if(x=S.payload,h=typeof x=="function"?x.call(v,g,h):x,h==null)break e;g=Se({},g,h);break e;case 2:sn=!0}}o.callback!==null&&o.lane!==0&&(e.flags|=64,h=l.effects,h===null?l.effects=[o]:h.push(o))}else v={eventTime:v,lane:h,tag:o.tag,payload:o.payload,callback:o.callback,next:null},y===null?(c=y=v,u=g):y=y.next=v,a|=h;if(o=o.next,o===null){if(o=l.shared.pending,o===null)break;h=o,o=h.next,h.next=null,l.lastBaseUpdate=h,l.shared.pending=null}}while(!0);if(y===null&&(u=g),l.baseState=u,l.firstBaseUpdate=c,l.lastBaseUpdate=y,t=l.shared.interleaved,t!==null){l=t;do a|=l.lane,l=l.next;while(l!==t)}else i===null&&(l.shared.lanes=0);$n|=a,e.lanes=a,e.memoizedState=g}}function Yo(e,t,n){if(e=t.effects,t.effects=null,e!==null)for(t=0;tn?n:4,e(!0);var r=si.transition;si.transition={};try{e(!1),t()}finally{oe=n,si.transition=r}}function md(){return _t().memoizedState}function sh(e,t,n){var r=yn(e);if(n={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null},gd(e))yd(t,n);else if(n=Gc(e,t,n,r),n!==null){var l=et();zt(n,e,r,l),vd(n,t,r)}}function ih(e,t,n){var r=yn(e),l={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null};if(gd(e))yd(t,l);else{var i=e.alternate;if(e.lanes===0&&(i===null||i.lanes===0)&&(i=t.lastRenderedReducer,i!==null))try{var a=t.lastRenderedState,o=i(a,n);if(l.hasEagerState=!0,l.eagerState=o,It(o,a)){var u=t.interleaved;u===null?(l.next=l,Oa(t)):(l.next=u.next,u.next=l),t.interleaved=l;return}}catch{}finally{}n=Gc(e,t,l,r),n!==null&&(l=et(),zt(n,e,r,l),vd(n,t,r))}}function gd(e){var t=e.alternate;return e===je||t!==null&&t===je}function yd(e,t){Hr=fs=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function vd(e,t,n){if(n&4194240){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,Sa(e,n)}}var ps={readContext:Nt,useCallback:qe,useContext:qe,useEffect:qe,useImperativeHandle:qe,useInsertionEffect:qe,useLayoutEffect:qe,useMemo:qe,useReducer:qe,useRef:qe,useState:qe,useDebugValue:qe,useDeferredValue:qe,useTransition:qe,useMutableSource:qe,useSyncExternalStore:qe,useId:qe,unstable_isNewReconciler:!1},ah={readContext:Nt,useCallback:function(e,t){return Mt().memoizedState=[e,t===void 0?null:t],e},useContext:Nt,useEffect:eu,useImperativeHandle:function(e,t,n){return n=n!=null?n.concat([e]):null,Hl(4194308,4,cd.bind(null,t,e),n)},useLayoutEffect:function(e,t){return Hl(4194308,4,e,t)},useInsertionEffect:function(e,t){return Hl(4,2,e,t)},useMemo:function(e,t){var n=Mt();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var r=Mt();return t=n!==void 0?n(t):t,r.memoizedState=r.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},r.queue=e,e=e.dispatch=sh.bind(null,je,e),[r.memoizedState,e]},useRef:function(e){var t=Mt();return e={current:e},t.memoizedState=e},useState:Zo,useDebugValue:Qa,useDeferredValue:function(e){return Mt().memoizedState=e},useTransition:function(){var e=Zo(!1),t=e[0];return e=lh.bind(null,e[1]),Mt().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var r=je,l=Mt();if(ve){if(n===void 0)throw Error(R(407));n=n()}else{if(n=t(),Fe===null)throw Error(R(349));Dn&30||nd(r,t,n)}l.memoizedState=n;var i={value:n,getSnapshot:t};return l.queue=i,eu(ld.bind(null,r,i,e),[e]),r.flags|=2048,ol(9,rd.bind(null,r,i,n,t),void 0,null),n},useId:function(){var e=Mt(),t=Fe.identifierPrefix;if(ve){var n=qt,r=Kt;n=(r&~(1<<32-Rt(r)-1)).toString(32)+n,t=":"+t+"R"+n,n=il++,0<\/script>",e=e.removeChild(e.firstChild)):typeof r.is=="string"?e=a.createElement(n,{is:r.is}):(e=a.createElement(n),n==="select"&&(a=e,r.multiple?a.multiple=!0:r.size&&(a.size=r.size))):e=a.createElementNS(e,n),e[Ft]=t,e[rl]=r,bd(e,t,!1,!1),t.stateNode=e;e:{switch(a=Ti(n,r),n){case"dialog":he("cancel",e),he("close",e),l=r;break;case"iframe":case"object":case"embed":he("load",e),l=r;break;case"video":case"audio":for(l=0;lgr&&(t.flags|=128,r=!0,Tr(i,!1),t.lanes=4194304)}else{if(!r)if(e=ds(a),e!==null){if(t.flags|=128,r=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),Tr(i,!0),i.tail===null&&i.tailMode==="hidden"&&!a.alternate&&!ve)return Xe(t),null}else 2*Ce()-i.renderingStartTime>gr&&n!==1073741824&&(t.flags|=128,r=!0,Tr(i,!1),t.lanes=4194304);i.isBackwards?(a.sibling=t.child,t.child=a):(n=i.last,n!==null?n.sibling=a:t.child=a,i.last=a)}return i.tail!==null?(t=i.tail,i.rendering=t,i.tail=t.sibling,i.renderingStartTime=Ce(),t.sibling=null,n=ke.current,fe(ke,r?n&1|2:n&1),t):(Xe(t),null);case 22:case 23:return Ya(),r=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==r&&(t.flags|=8192),r&&t.mode&1?ct&1073741824&&(Xe(t),t.subtreeFlags&6&&(t.flags|=8192)):Xe(t),null;case 24:return null;case 25:return null}throw Error(R(156,t.tag))}function mh(e,t){switch(Ra(t),t.tag){case 1:return at(t.type)&&ls(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return hr(),me(it),me(Ge),Ba(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 5:return Fa(t),null;case 13:if(me(ke),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(R(340));fr()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return me(ke),null;case 4:return hr(),null;case 10:return $a(t.type._context),null;case 22:case 23:return Ya(),null;case 24:return null;default:return null}}var Ll=!1,Je=!1,gh=typeof WeakSet=="function"?WeakSet:Set,U=null;function tr(e,t){var n=e.ref;if(n!==null)if(typeof n=="function")try{n(null)}catch(r){_e(e,t,r)}else n.current=null}function na(e,t,n){try{n()}catch(r){_e(e,t,r)}}var du=!1;function yh(e,t){if(Mi=es,e=Dc(),Pa(e)){if("selectionStart"in e)var n={start:e.selectionStart,end:e.selectionEnd};else e:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var l=r.anchorOffset,i=r.focusNode;r=r.focusOffset;try{n.nodeType,i.nodeType}catch{n=null;break e}var a=0,o=-1,u=-1,c=0,y=0,g=e,h=null;t:for(;;){for(var v;g!==n||l!==0&&g.nodeType!==3||(o=a+l),g!==i||r!==0&&g.nodeType!==3||(u=a+r),g.nodeType===3&&(a+=g.nodeValue.length),(v=g.firstChild)!==null;)h=g,g=v;for(;;){if(g===e)break t;if(h===n&&++c===l&&(o=a),h===i&&++y===r&&(u=a),(v=g.nextSibling)!==null)break;g=h,h=g.parentNode}g=v}n=o===-1||u===-1?null:{start:o,end:u}}else n=null}n=n||{start:0,end:0}}else n=null;for(Fi={focusedElem:e,selectionRange:n},es=!1,U=t;U!==null;)if(t=U,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,U=e;else for(;U!==null;){t=U;try{var x=t.alternate;if(t.flags&1024)switch(t.tag){case 0:case 11:case 15:break;case 1:if(x!==null){var S=x.memoizedProps,N=x.memoizedState,p=t.stateNode,f=p.getSnapshotBeforeUpdate(t.elementType===t.type?S:Tt(t.type,S),N);p.__reactInternalSnapshotBeforeUpdate=f}break;case 3:var m=t.stateNode.containerInfo;m.nodeType===1?m.textContent="":m.nodeType===9&&m.documentElement&&m.removeChild(m.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(R(163))}}catch(w){_e(t,t.return,w)}if(e=t.sibling,e!==null){e.return=t.return,U=e;break}U=t.return}return x=du,du=!1,x}function Wr(e,t,n){var r=t.updateQueue;if(r=r!==null?r.lastEffect:null,r!==null){var l=r=r.next;do{if((l.tag&e)===e){var i=l.destroy;l.destroy=void 0,i!==void 0&&na(t,n,i)}l=l.next}while(l!==r)}}function Ps(e,t){if(t=t.updateQueue,t=t!==null?t.lastEffect:null,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var r=n.create;n.destroy=r()}n=n.next}while(n!==t)}}function ra(e){var t=e.ref;if(t!==null){var n=e.stateNode;switch(e.tag){case 5:e=n;break;default:e=n}typeof t=="function"?t(e):t.current=e}}function Ld(e){var t=e.alternate;t!==null&&(e.alternate=null,Ld(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[Ft],delete t[rl],delete t[Hi],delete t[Zp],delete t[eh])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function Rd(e){return e.tag===5||e.tag===3||e.tag===4}function fu(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||Rd(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function la(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=rs));else if(r!==4&&(e=e.child,e!==null))for(la(e,t,n),e=e.sibling;e!==null;)la(e,t,n),e=e.sibling}function sa(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(e=e.child,e!==null))for(sa(e,t,n),e=e.sibling;e!==null;)sa(e,t,n),e=e.sibling}var Ue=null,Pt=!1;function rn(e,t,n){for(n=n.child;n!==null;)zd(e,t,n),n=n.sibling}function zd(e,t,n){if(Bt&&typeof Bt.onCommitFiberUnmount=="function")try{Bt.onCommitFiberUnmount(js,n)}catch{}switch(n.tag){case 5:Je||tr(n,t);case 6:var r=Ue,l=Pt;Ue=null,rn(e,t,n),Ue=r,Pt=l,Ue!==null&&(Pt?(e=Ue,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):Ue.removeChild(n.stateNode));break;case 18:Ue!==null&&(Pt?(e=Ue,n=n.stateNode,e.nodeType===8?ni(e.parentNode,n):e.nodeType===1&&ni(e,n),Yr(e)):ni(Ue,n.stateNode));break;case 4:r=Ue,l=Pt,Ue=n.stateNode.containerInfo,Pt=!0,rn(e,t,n),Ue=r,Pt=l;break;case 0:case 11:case 14:case 15:if(!Je&&(r=n.updateQueue,r!==null&&(r=r.lastEffect,r!==null))){l=r=r.next;do{var i=l,a=i.destroy;i=i.tag,a!==void 0&&(i&2||i&4)&&na(n,t,a),l=l.next}while(l!==r)}rn(e,t,n);break;case 1:if(!Je&&(tr(n,t),r=n.stateNode,typeof r.componentWillUnmount=="function"))try{r.props=n.memoizedProps,r.state=n.memoizedState,r.componentWillUnmount()}catch(o){_e(n,t,o)}rn(e,t,n);break;case 21:rn(e,t,n);break;case 22:n.mode&1?(Je=(r=Je)||n.memoizedState!==null,rn(e,t,n),Je=r):rn(e,t,n);break;default:rn(e,t,n)}}function pu(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new gh),t.forEach(function(r){var l=Ch.bind(null,e,r);n.has(r)||(n.add(r),r.then(l,l))})}}function bt(e,t){var n=t.deletions;if(n!==null)for(var r=0;rl&&(l=a),r&=~i}if(r=l,r=Ce()-r,r=(120>r?120:480>r?480:1080>r?1080:1920>r?1920:3e3>r?3e3:4320>r?4320:1960*xh(r/1960))-r,10e?16:e,cn===null)var r=!1;else{if(e=cn,cn=null,gs=0,se&6)throw Error(R(331));var l=se;for(se|=4,U=e.current;U!==null;){var i=U,a=i.child;if(U.flags&16){var o=i.deletions;if(o!==null){for(var u=0;uCe()-Ja?Ln(e,0):Xa|=n),ot(e,t)}function Bd(e,t){t===0&&(e.mode&1?(t=jl,jl<<=1,!(jl&130023424)&&(jl=4194304)):t=1);var n=et();e=Yt(e,t),e!==null&&(fl(e,t,n),ot(e,n))}function _h(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),Bd(e,n)}function Ch(e,t){var n=0;switch(e.tag){case 13:var r=e.stateNode,l=e.memoizedState;l!==null&&(n=l.retryLane);break;case 19:r=e.stateNode;break;default:throw Error(R(314))}r!==null&&r.delete(t),Bd(e,n)}var Ud;Ud=function(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps||it.current)st=!0;else{if(!(e.lanes&n)&&!(t.flags&128))return st=!1,ph(e,t,n);st=!!(e.flags&131072)}else st=!1,ve&&t.flags&1048576&&Qc(t,as,t.index);switch(t.lanes=0,t.tag){case 2:var r=t.type;Wl(e,t),e=t.pendingProps;var l=dr(t,Ge.current);ar(t,n),l=Ha(null,t,r,e,l,n);var i=Wa();return t.flags|=1,typeof l=="object"&&l!==null&&typeof l.render=="function"&&l.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,at(r)?(i=!0,ss(t)):i=!1,t.memoizedState=l.state!==null&&l.state!==void 0?l.state:null,Aa(t),l.updater=Ts,t.stateNode=l,l._reactInternals=t,Xi(t,r,e,n),t=Yi(null,t,r,!0,i,n)):(t.tag=0,ve&&i&&La(t),Ze(null,t,l,n),t=t.child),t;case 16:r=t.elementType;e:{switch(Wl(e,t),e=t.pendingProps,l=r._init,r=l(r._payload),t.type=r,l=t.tag=bh(r),e=Tt(r,e),l){case 0:t=Gi(null,t,r,e,n);break e;case 1:t=ou(null,t,r,e,n);break e;case 11:t=iu(null,t,r,e,n);break e;case 14:t=au(null,t,r,Tt(r.type,e),n);break e}throw Error(R(306,r,""))}return t;case 0:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Tt(r,l),Gi(e,t,r,l,n);case 1:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Tt(r,l),ou(e,t,r,l,n);case 3:e:{if(_d(t),e===null)throw Error(R(387));r=t.pendingProps,i=t.memoizedState,l=i.element,Yc(e,t),cs(t,r,null,n);var a=t.memoizedState;if(r=a.element,i.isDehydrated)if(i={element:r,isDehydrated:!1,cache:a.cache,pendingSuspenseBoundaries:a.pendingSuspenseBoundaries,transitions:a.transitions},t.updateQueue.baseState=i,t.memoizedState=i,t.flags&256){l=mr(Error(R(423)),t),t=uu(e,t,r,n,l);break e}else if(r!==l){l=mr(Error(R(424)),t),t=uu(e,t,r,n,l);break e}else for(ft=hn(t.stateNode.containerInfo.firstChild),pt=t,ve=!0,Lt=null,n=Jc(t,null,r,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling;else{if(fr(),r===l){t=Zt(e,t,n);break e}Ze(e,t,r,n)}t=t.child}return t;case 5:return Zc(t),e===null&&Qi(t),r=t.type,l=t.pendingProps,i=e!==null?e.memoizedProps:null,a=l.children,Bi(r,l)?a=null:i!==null&&Bi(r,i)&&(t.flags|=32),Nd(e,t),Ze(e,t,a,n),t.child;case 6:return e===null&&Qi(t),null;case 13:return Cd(e,t,n);case 4:return Ma(t,t.stateNode.containerInfo),r=t.pendingProps,e===null?t.child=pr(t,null,r,n):Ze(e,t,r,n),t.child;case 11:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Tt(r,l),iu(e,t,r,l,n);case 7:return Ze(e,t,t.pendingProps,n),t.child;case 8:return Ze(e,t,t.pendingProps.children,n),t.child;case 12:return Ze(e,t,t.pendingProps.children,n),t.child;case 10:e:{if(r=t.type._context,l=t.pendingProps,i=t.memoizedProps,a=l.value,fe(os,r._currentValue),r._currentValue=a,i!==null)if(It(i.value,a)){if(i.children===l.children&&!it.current){t=Zt(e,t,n);break e}}else for(i=t.child,i!==null&&(i.return=t);i!==null;){var o=i.dependencies;if(o!==null){a=i.child;for(var u=o.firstContext;u!==null;){if(u.context===r){if(i.tag===1){u=Xt(-1,n&-n),u.tag=2;var c=i.updateQueue;if(c!==null){c=c.shared;var y=c.pending;y===null?u.next=u:(u.next=y.next,y.next=u),c.pending=u}}i.lanes|=n,u=i.alternate,u!==null&&(u.lanes|=n),Ki(i.return,n,t),o.lanes|=n;break}u=u.next}}else if(i.tag===10)a=i.type===t.type?null:i.child;else if(i.tag===18){if(a=i.return,a===null)throw Error(R(341));a.lanes|=n,o=a.alternate,o!==null&&(o.lanes|=n),Ki(a,n,t),a=i.sibling}else a=i.child;if(a!==null)a.return=i;else for(a=i;a!==null;){if(a===t){a=null;break}if(i=a.sibling,i!==null){i.return=a.return,a=i;break}a=a.return}i=a}Ze(e,t,l.children,n),t=t.child}return t;case 9:return l=t.type,r=t.pendingProps.children,ar(t,n),l=Nt(l),r=r(l),t.flags|=1,Ze(e,t,r,n),t.child;case 14:return r=t.type,l=Tt(r,t.pendingProps),l=Tt(r.type,l),au(e,t,r,l,n);case 15:return jd(e,t,t.type,t.pendingProps,n);case 17:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Tt(r,l),Wl(e,t),t.tag=1,at(r)?(e=!0,ss(t)):e=!1,ar(t,n),xd(t,r,l),Xi(t,r,l,n),Yi(null,t,r,!0,e,n);case 19:return Ed(e,t,n);case 22:return Sd(e,t,n)}throw Error(R(156,t.tag))};function Hd(e,t){return gc(e,t)}function Eh(e,t,n,r){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function jt(e,t,n,r){return new Eh(e,t,n,r)}function eo(e){return e=e.prototype,!(!e||!e.isReactComponent)}function bh(e){if(typeof e=="function")return eo(e)?1:0;if(e!=null){if(e=e.$$typeof,e===xa)return 11;if(e===wa)return 14}return 2}function vn(e,t){var n=e.alternate;return n===null?(n=jt(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function Kl(e,t,n,r,l,i){var a=2;if(r=e,typeof e=="function")eo(e)&&(a=1);else if(typeof e=="string")a=5;else e:switch(e){case Qn:return Rn(n.children,l,i,t);case va:a=8,l|=8;break;case xi:return e=jt(12,n,t,l|2),e.elementType=xi,e.lanes=i,e;case wi:return e=jt(13,n,t,l),e.elementType=wi,e.lanes=i,e;case ki:return e=jt(19,n,t,l),e.elementType=ki,e.lanes=i,e;case Zu:return Rs(n,l,i,t);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case Gu:a=10;break e;case Yu:a=9;break e;case xa:a=11;break e;case wa:a=14;break e;case ln:a=16,r=null;break e}throw Error(R(130,e==null?e:typeof e,""))}return t=jt(a,n,t,l),t.elementType=e,t.type=r,t.lanes=i,t}function Rn(e,t,n,r){return e=jt(7,e,r,t),e.lanes=n,e}function Rs(e,t,n,r){return e=jt(22,e,r,t),e.elementType=Zu,e.lanes=n,e.stateNode={isHidden:!1},e}function ci(e,t,n){return e=jt(6,e,null,t),e.lanes=n,e}function di(e,t,n){return t=jt(4,e.children!==null?e.children:[],e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function Th(e,t,n,r,l){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=Vs(0),this.expirationTimes=Vs(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=Vs(0),this.identifierPrefix=r,this.onRecoverableError=l,this.mutableSourceEagerHydrationData=null}function to(e,t,n,r,l,i,a,o,u){return e=new Th(e,t,n,o,u),t===1?(t=1,i===!0&&(t|=8)):t=0,i=jt(3,null,null,t),e.current=i,i.stateNode=e,i.memoizedState={element:r,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},Aa(i),e}function Ph(e,t,n){var r=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(Kd)}catch(e){console.error(e)}}Kd(),Ku.exports=mt;var Dh=Ku.exports,qd,ku=Dh;qd=ku.createRoot,ku.hydrateRoot;async function Le(e){const t=await fetch(e);if(!t.ok)throw new Error(`${t.status} ${t.statusText}`);return t.json()}async function ju(){return Le("/api/archives")}async function Su(e,t=null){const n=t?`/api/archives/${e}/entries?collection=${encodeURIComponent(t)}`:`/api/archives/${e}/entries`;return Le(n)}async function $h(e,t,n,r=null){const l=new URLSearchParams;return t&&l.set("q",t),n&&l.set("tag",n),r&&l.set("collection",r),Le(`/api/archives/${e}/entries/search?${l}`)}async function $r(e,t){return Le(`/api/archives/${e}/entries/${t}`)}async function Oh(e,t){return Le(`/api/archives/${e}/entries/${t}/children`)}function Ah(e,t,n){return Promise.all(n.map(r=>Le(`/api/archives/${e}/entries/${t}/artifacts/${r}`)))}async function Mh(e){if(!e||e.length===0)return{};const t=await fetch("/api/util/resolve-tco",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)});return t.ok?t.json():{}}async function Fh(e,t,n){const r=await fetch(`/api/archives/${e}/entries/${t}`,{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({title:n??null})});if(!r.ok)throw new Error(await r.text())}async function Il(e,t){return Le(`/api/archives/${e}/entries/${t}/tags`)}async function Nu(e,t,n){const r=await fetch(`/api/archives/${e}/entries/${t}/tags`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({tag_path:n})});if(!r.ok)throw new Error(`Failed to add tag (${r.status})`)}async function Bh(e,t,n){const r=await fetch(`/api/archives/${e}/entries/${t}/tags/${n}`,{method:"DELETE"});if(!r.ok)throw new Error(`Remove failed (${r.status})`)}async function _u(e,t){const n=await fetch(`/api/archives/${e}/entries/${t}`,{method:"DELETE"});if(!n.ok)throw new Error(`Delete failed (${n.status})`)}async function Uh(e,t,n){const r=await fetch(`/api/archives/${e}/tags/${t}`,{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:n})});if(!r.ok)throw new Error(await r.text());return r.json()}async function Hh(e,t){const n=await fetch(`/api/archives/${e}/tags/${t}`,{method:"DELETE"});if(!n.ok)throw new Error(await n.text())}async function Wh(e,t){const n=await fetch(`/api/archives/${e}/tags`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({path:t})});if(!n.ok)throw new Error(await n.text());return n.json()}async function Vh(e,t,n){const r=await fetch(`/api/archives/${e}/tags/${t}/move`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({parent_uid:n??null})});if(!r.ok)throw new Error(await r.text());return r.json()}async function fi(e){return Le(`/api/archives/${e}/runs`)}async function Dl(e){return Le(`/api/archives/${e}/tags`)}async function Qh(e,t,n=null,r=null){const l={locator:t};n&&n!=="best"&&(l.quality=n),r&&(typeof r.ublock_enabled=="boolean"&&(l.ublock_enabled=r.ublock_enabled),typeof r.reader_mode=="boolean"&&(l.reader_mode=r.reader_mode),typeof r.cookie_ext_enabled=="boolean"&&(l.cookie_ext_enabled=r.cookie_ext_enabled),typeof r.modal_closer_enabled=="boolean"&&(l.modal_closer_enabled=r.modal_closer_enabled),typeof r.via_freedium=="boolean"&&(l.via_freedium=r.via_freedium),r.per_item_quality&&typeof r.per_item_quality=="object"&&Object.keys(r.per_item_quality).length>0&&(l.per_item_quality=r.per_item_quality),r.sync===!0&&(l.sync=!0));const i=await fetch(`/api/archives/${e}/captures`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(l)});if(!i.ok){const a=await i.json().catch(()=>({})),o=new Error(a.error||`HTTP ${i.status}`);throw o.status=i.status,o}return i.json()}async function Kh(e,t){return Le(`/api/archives/${e}/captures/probe?locator=${encodeURIComponent(t)}`)}async function qh(e,t){const n=await fetch(`/api/archives/${e}/captures/probe-playlist`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locator:t})});if(!n.ok){const r=await n.json().catch(()=>({}));throw new Error(r.error||`HTTP ${n.status}`)}return n.json()}async function Xd(e,t){return Le(`/api/archives/${e}/capture_jobs/${t}`)}async function Xh(){return(await(await fetch("/api/auth/setup")).json()).setup_required===!0}async function Jh(e,t){const n=await fetch("/api/auth/setup",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({username:e,password:t})});if(!n.ok)throw new Error((await n.json()).error||"Setup failed");return n.json()}async function Gh(e,t){const n=await fetch("/api/auth/login",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({username:e,password:t})});if(!n.ok)throw new Error((await n.json()).error||"Login failed");return n.json()}async function Yh(){await fetch("/api/auth/logout",{method:"POST"})}async function Zh(){const e=await fetch("/api/auth/me");return e.status===401?null:e.json()}async function em(e){const t=await fetch("/api/auth/me",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({display_name:e})});if(!t.ok)throw new Error(await t.text())}async function tm(e){const t=await fetch("/api/auth/me",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)});if(!t.ok)throw new Error(await t.text())}async function nm(e,t){const n=await fetch("/api/auth/me",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({current_password:e,new_password:t})});if(!n.ok){let r=await n.text();try{r=JSON.parse(r).error??r}catch{}throw new Error(r)}}async function rm(){return Le("/api/auth/tokens")}async function lm(e){const t=await fetch("/api/auth/tokens",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:e})});if(!t.ok)throw new Error(await t.text());return t.json()}async function sm(e){const t=await fetch(`/api/auth/tokens/${e}`,{method:"DELETE"});if(!t.ok)throw new Error(await t.text())}async function so(){return Le("/api/admin/instance-settings")}async function ql(e){const t=await fetch("/api/admin/instance-settings",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)});if(!t.ok)throw new Error(await t.text())}async function im(){return Le("/api/admin/users")}async function am(e,t,n){const r=await fetch("/api/admin/users",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:e,password:t,email:n||void 0})});if(!r.ok){const l=await r.json().catch(()=>({}));throw new Error(l.error||`HTTP ${r.status}`)}return r.json()}async function om(e,t){const n=await fetch(`/api/admin/users/${e}/status`,{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({status:t})});if(!n.ok){const r=await n.json().catch(()=>({}));throw new Error(r.error||`HTTP ${n.status}`)}return n.json()}async function um(){return Le("/api/admin/roles")}async function cm(e,t){const n=await fetch("/api/admin/roles",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({slug:e,name:t})});if(!n.ok){const r=await n.json().catch(()=>({}));throw new Error(r.error||`HTTP ${n.status}`)}return n.json()}async function io(e){return Le(`/api/archives/${e}/collections`)}async function dm(e,t,n,r=2,l=!0){const i=await fetch(`/api/archives/${e}/collections`,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({name:t,slug:n,default_visibility_bits:r,requires_auth:l})});if(!i.ok){const a=await i.json().catch(()=>({error:i.statusText}));throw new Error(a.error||i.statusText)}return i.json()}async function fm(e,t){return Le(`/api/archives/${e}/collections/${t}`)}async function ca(e,t,n,r=2){const l=await fetch(`/api/archives/${e}/collections/${t}/entries`,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({entry_uid:n,visibility_bits:r})});if(!l.ok){const i=await l.json().catch(()=>({error:l.statusText}));throw new Error(i.error||l.statusText)}}async function pm(e,t,n){const r=await fetch(`/api/archives/${e}/collections/${t}/entries/${n}`,{method:"DELETE"});if(!r.ok){const l=await r.json().catch(()=>({error:r.statusText}));throw new Error(l.error||r.statusText)}}async function hm(e,t,n,r){const l=await fetch(`/api/archives/${e}/collections/${t}/entries/${n}`,{method:"PATCH",headers:{"content-type":"application/json"},body:JSON.stringify({visibility_bits:r})});if(!l.ok){const i=await l.json().catch(()=>({error:l.statusText}));throw new Error(i.error||l.statusText)}}async function Cu(e,t){return Le(`/api/archives/${e}/entries/${t}/collections`)}async function pi(e,t,n){const r=await fetch(`/api/archives/${e}/collections/${t}`,{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify(n)});if(!r.ok){const l=await r.json().catch(()=>({}));throw new Error(l.error??r.statusText)}}async function mm(e,t){const n=await fetch(`/api/archives/${e}/collections/${t}`,{method:"DELETE"});if(!n.ok){const r=await n.json().catch(()=>({}));throw new Error(r.error??n.statusText)}}async function gm(e){return Le(`/api/archives/${e}/blob-cleanup`)}async function ym(e){const t=await fetch(`/api/archives/${e}/blob-cleanup`,{method:"DELETE"});if(!t.ok){const n=await t.json().catch(()=>({}));throw new Error(n.error??t.statusText)}return t.json()}async function vm(e,t){const n=await fetch(`/api/archives/${e}/entries/${t}/rearchive`,{method:"POST"});if(!n.ok){const r=await n.json().catch(()=>({}));throw new Error(r.message||`rearchive failed: ${n.status}`)}return n.json()}async function xm(e,t,n){const r=await fetch(`/api/archives/${e}/entries/${t}/artifacts/${n}/media-token`,{method:"POST"});if(!r.ok)throw new Error(`media-token ${r.status}`);return r.json()}async function wm(){return Le("/api/admin/cookie-rules")}async function km(e,t,n){const r=await fetch("/api/admin/cookie-rules",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({url_pattern:e||null,pattern_kind:t,cookies_json:n})});if(!r.ok){const l=await r.json().catch(()=>({}));throw new Error(l.message||`HTTP ${r.status}`)}return r.json()}async function jm(e,t){const n=await fetch(`/api/admin/cookie-rules/${e}`,{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)});if(!n.ok){const r=await n.json().catch(()=>({}));throw new Error(r.message||`HTTP ${n.status}`)}}async function Sm(e){const t=await fetch(`/api/admin/cookie-rules/${e}`,{method:"DELETE"});if(!t.ok){const n=await t.json().catch(()=>({}));throw new Error(n.message||`HTTP ${t.status}`)}}function Nm(e,t,n){let r;return{promise:new Promise((i,a)=>{const o=new FormData;o.append("file",t),r=new XMLHttpRequest,r.open("POST",`/api/archives/${e}/uploads`),r.upload.addEventListener("progress",u=>{u.lengthComputable&&n&&n(Math.round(u.loaded/u.total*100))}),r.addEventListener("load",()=>{if(r.status>=200&&r.status<300)try{i(JSON.parse(r.responseText))}catch{a(new Error("Invalid server response"))}else{let u=`Upload failed (${r.status})`;try{u=JSON.parse(r.responseText).message||u}catch{}a(new Error(u))}}),r.addEventListener("error",()=>a(new Error("Network error during upload"))),r.addEventListener("abort",()=>a(new Error("Upload cancelled"))),r.send(o)}),abort:()=>r==null?void 0:r.abort()}}async function hi(e,t){try{await fetch(`/api/archives/${e}/uploads`,{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({locator:t})})}catch{}}const _m=window.fetch;window.fetch=async(...e)=>{var n;const t=await _m(...e);return t.status===401&&((typeof e[0]=="string"?e[0]:((n=e[0])==null?void 0:n.url)??"").includes("/api/auth/")||window.dispatchEvent(new CustomEvent("auth:expired"))),t};function Cm({onLogin:e}){const[t,n]=d.useState(""),[r,l]=d.useState(""),[i,a]=d.useState(null),[o,u]=d.useState(!1);async function c(y){y.preventDefault(),a(null),u(!0);try{const g=await Gh(t,r);e(g)}catch(g){a(g.message)}finally{u(!1)}}return s.jsx("div",{className:"login-page",children:s.jsxs("div",{className:"login-card",children:[s.jsx("h1",{className:"login-brand",children:"Archivr"}),s.jsx("p",{className:"login-tagline",children:"Sign in to your archive"}),s.jsxs("form",{onSubmit:c,children:[s.jsxs("div",{className:"login-field",children:[s.jsx("label",{className:"login-label",htmlFor:"login-username",children:"Username"}),s.jsx("input",{className:"login-input",id:"login-username",type:"text",value:t,onChange:y=>n(y.target.value),autoFocus:!0,required:!0,autoComplete:"username"})]}),s.jsxs("div",{className:"login-field",children:[s.jsx("label",{className:"login-label",htmlFor:"login-password",children:"Password"}),s.jsx("input",{className:"login-input",id:"login-password",type:"password",value:r,onChange:y=>l(y.target.value),required:!0,autoComplete:"current-password"})]}),i&&s.jsx("p",{className:"login-error",children:i}),s.jsx("button",{className:"login-submit",type:"submit",disabled:o,children:o?"Signing in…":"Sign in"})]})]})})}function Em({onComplete:e}){const[t,n]=d.useState(""),[r,l]=d.useState(""),[i,a]=d.useState(""),[o,u]=d.useState(null),[c,y]=d.useState(!1);async function g(h){if(h.preventDefault(),r!==i){u("Passwords do not match");return}if(r.length<8){u("Password must be at least 8 characters");return}u(null),y(!0);try{await Jh(t,r),e()}catch(v){u(v.message)}finally{y(!1)}}return s.jsx("div",{className:"setup-page",children:s.jsxs("div",{className:"setup-card",children:[s.jsx("h1",{className:"setup-brand",children:"Archivr"}),s.jsx("p",{className:"setup-tagline",children:"Create your owner account to get started."}),s.jsxs("form",{onSubmit:g,children:[s.jsxs("div",{className:"setup-field",children:[s.jsx("label",{className:"setup-label",htmlFor:"setup-username",children:"Username"}),s.jsx("input",{className:"setup-input",id:"setup-username",type:"text",value:t,onChange:h=>n(h.target.value),autoFocus:!0,required:!0,autoComplete:"username"})]}),s.jsxs("div",{className:"setup-field",children:[s.jsx("label",{className:"setup-label",htmlFor:"setup-password",children:"Password"}),s.jsx("input",{className:"setup-input",id:"setup-password",type:"password",value:r,onChange:h=>l(h.target.value),required:!0,autoComplete:"new-password"})]}),s.jsxs("div",{className:"setup-field",children:[s.jsx("label",{className:"setup-label",htmlFor:"setup-confirm",children:"Confirm password"}),s.jsx("input",{className:"setup-input",id:"setup-confirm",type:"password",value:i,onChange:h=>a(h.target.value),required:!0,autoComplete:"new-password"})]}),o&&s.jsx("p",{className:"setup-error",children:o}),s.jsx("button",{className:"setup-submit",type:"submit",disabled:c,children:c?"Creating account…":"Create account"})]})]})})}function bm({archives:e,archiveId:t,onArchiveChange:n,view:r,onViewChange:l,onCaptureClick:i,collections:a,selectedCollectionUid:o,onCollectionChange:u,isPublicSession:c,onSignInClick:y}){const{currentUser:g,setCurrentUser:h}=d.useContext(As)??{},[v,x]=d.useState(!1);async function S(){x(!0),await Yh(),h(null),window.location.reload()}const N=a.find(m=>m.slug==="_default_"),p=!c||!!N&&!N.requires_auth,f=a.filter(m=>m.slug!=="_default_").filter(m=>!c||!m.requires_auth);return s.jsxs("header",{className:"topbar",children:[s.jsx("div",{className:"brand",children:"Archivr"}),s.jsxs("div",{className:"switchers",children:[!c&&s.jsx("div",{className:"switcher",children:s.jsx("select",{"aria-label":"Select archive",value:t??"",onChange:m=>n(m.target.value),children:e.map(m=>s.jsx("option",{value:m.id,children:m.label},m.id))})}),f.length>0&&s.jsx("div",{className:"switcher",children:s.jsxs("select",{"aria-label":"Select collection",value:o??"",onChange:m=>u(m.target.value||null),children:[p&&s.jsx("option",{value:"",children:"All entries"}),f.map(m=>s.jsx("option",{value:m.collection_uid,children:m.name},m.collection_uid))]})})]}),c?s.jsxs(s.Fragment,{children:[s.jsx("span",{style:{flex:1}}),s.jsx("button",{className:"logout-btn",onClick:y,children:"Sign in"})]}):s.jsxs(s.Fragment,{children:[s.jsx("nav",{className:"nav","aria-label":"Primary",children:["archive","tags","collections","runs","admin","settings"].map(m=>s.jsx("button",{className:`nav-link${r===m?" is-active":""}`,onClick:()=>l(m),children:m.charAt(0).toUpperCase()+m.slice(1)},m))}),s.jsx("button",{className:"capture-button",onClick:i,children:"Capture"}),g&&s.jsxs("div",{className:"user-menu",children:[s.jsx("span",{className:"user-name",children:g.display_name||g.username}),s.jsx("button",{className:"logout-btn",onClick:S,disabled:v,children:v?"Logging out…":"Log out"})]})]})]})}let xs=1;function Jd(e){const t=e.trim(),n=t.toLowerCase();for(const r of["yt:","youtube:"])if(n.startsWith(r)){const l=n.slice(r.length);return!!(l.startsWith("video/")||l.startsWith("short/")||l.startsWith("shorts/")||/^[a-z0-9_-]{11}$/i.test(l))}if(n.startsWith("ytm:"))return!n.slice(4).startsWith("playlist/");for(const r of["x:","twitter:","tweet:"])if(n.startsWith(r))return n.slice(r.length).startsWith("media:");if(n.startsWith("spotify:"))return!1;if(n.startsWith("instagram:")||n.startsWith("facebook:")||n.startsWith("tiktok:")||n.startsWith("reddit:")||n.startsWith("snapchat:"))return!0;if(n.startsWith("http://")||n.startsWith("https://")){if(/^https?:\/\/music\.youtube\.com\/watch/.test(n)||/^https?:\/\/(?:www\.)?(?:youtu\.be\/[0-9A-Za-z_-]+|youtube\.com\/watch\?v=[0-9A-Za-z_-]+|youtube\.com\/shorts\/[0-9A-Za-z_-]+)/.test(t)||n.startsWith("https://x.com/")||n.startsWith("http://x.com/")||/^https?:\/\/(?:www\.)?instagram\.com\//.test(n)||/^https?:\/\/(?:www\.)?facebook\.com\//.test(n)||n.startsWith("https://fb.watch/")||n.startsWith("http://fb.watch/")||/^https?:\/\/(?:www\.)?tiktok\.com\//.test(n)||/^https?:\/\/(?:www\.)?reddit\.com\//.test(n)||n.startsWith("https://redd.it/")||n.startsWith("http://redd.it/")||/^https?:\/\/(?:www\.)?snapchat\.com\//.test(n))return!0;if(n.startsWith("https://open.spotify.com/")||n.startsWith("http://open.spotify.com/"))return!1}return!1}function Pn(e){const t=e.trim(),n=t.toLowerCase();for(const r of["yt:","youtube:"])if(n.startsWith(r)){const l=n.slice(r.length);return l.startsWith("playlist/")||l.startsWith("@")||l.startsWith("channel/")||l.startsWith("c/")||l.startsWith("user/")}if(n.startsWith("ytm:"))return n.slice(4).startsWith("playlist/");if(n.startsWith("spotify:")){const r=n.slice(8);return r.startsWith("album:")||r.startsWith("playlist:")}if(n.startsWith("http://")||n.startsWith("https://"))try{const r=new URL(t),l=r.hostname;if((l==="youtube.com"||l==="www.youtube.com")&&(r.pathname==="/playlist"&&r.searchParams.has("list")||r.pathname.startsWith("/@")||r.pathname.startsWith("/channel/")||r.pathname.startsWith("/c/")||r.pathname.startsWith("/user/"))||l==="music.youtube.com"&&r.pathname==="/playlist"&&r.searchParams.has("list")||l==="open.spotify.com"&&(r.pathname.startsWith("/album/")||r.pathname.startsWith("/playlist/")))return!0}catch{}return!1}function Wn(e=""){return{id:xs++,locator:e,quality:"best",probeState:"idle",probeQualities:null,probeHasAudio:!1,status:"idle",error:null,jobUid:null,archiveId:null,playlistProbeState:"idle",playlistInfo:null,playlistItems:null,playlistQuality:null,playlistExpanded:!1,syncEnabled:!1}}function Tm(e){return{id:xs++,kind:"file",filename:e,uploadProgress:0,uploadStatus:"uploading",uploadLocator:null,uploadError:null,locator:"",quality:"best",probeState:"idle",probeQualities:null,probeHasAudio:!1,playlistProbeState:"idle",playlistInfo:null,playlistItems:null,playlistQuality:null,playlistExpanded:!1,syncEnabled:!1,error:null,status:"idle"}}function Pm(e,t){return e==="best"?t.map(n=>({...n,quality:"best"})):e==="audio"?t.map(n=>n.has_audio?{...n,quality:"audio"}:n.quality!==null?n:{...n,quality:null}):t.map(n=>n.qualities.includes(e)?{...n,quality:e}:n.quality!==null?n:{...n,quality:null})}function Eu(e){return Array.isArray(e.playlistItems)&&e.playlistItems.some(t=>t.quality===null)}function Lm({open:e,archiveId:t,onClose:n,onCaptured:r,onToast:l,onJobStarted:i,onJobSettled:a,activeJobs:o=[]}){const u=d.useRef(null),c=d.useRef(!0),y=d.useRef(new Map),g=d.useRef(new Map),h=d.useRef(new Map),v=d.useRef(t);d.useEffect(()=>{v.current=t},[t]);const x=d.useRef(null),[S,N]=d.useState(!1),p=d.useRef([]),f=d.useRef(!1),m=d.useRef(new Map),w=d.useRef(r),C=d.useRef(l);d.useEffect(()=>{w.current=r},[r]),d.useEffect(()=>{C.current=l},[l]);const E=d.useRef(i),b=d.useRef(a);d.useEffect(()=>{E.current=i},[i]),d.useEffect(()=>{b.current=a},[a]);const[k,T]=d.useState(()=>{try{const j=JSON.parse(sessionStorage.getItem("captureItems")||"null");if(Array.isArray(j)&&j.length>0){const B=j.filter(M=>!M.status||M.status==="idle");if(B.length>0)return B.forEach(M=>{M.id>=xs&&(xs=M.id+1)}),B.map(M=>({...Wn(M.locator),...M}))}}catch{}return[Wn()]});d.useEffect(()=>{sessionStorage.setItem("captureItems",JSON.stringify(k.filter(j=>j.kind!=="file")))},[k]),d.useEffect(()=>{p.current=k},[k]);const[L,q]=d.useState(!1),[F,X]=d.useState(null),[Z,ue]=d.useState(null),[Ae,ge]=d.useState(!0),[D,P]=d.useState(!0),[H,te]=d.useState(!0);d.useEffect(()=>{so().then(j=>{ue(j),ge(j.cookie_ext_enabled??!0),P(j.modal_closer_enabled??!0)}).catch(()=>ue({}))},[]);const ee=F!==null?F:(Z==null?void 0:Z.ublock_enabled)??!0,[Re,Y]=d.useState(!1);d.useEffect(()=>{["captureDialogLocator","captureDialogError","captureDialogBusy","captureDialogJobStatus","captureDialogJobUid"].forEach(j=>sessionStorage.removeItem(j)),o.forEach(j=>{j.jobUid&&!y.current.has(j.jobUid)&&ye(j.id,j.jobUid,j.locator,j.archiveId,null)})},[]),d.useEffect(()=>{const j=u.current;if(!j)return;const B=()=>{if(g.current.forEach(M=>clearTimeout(M)),g.current.clear(),!f.current){const M=v.current;p.current.forEach(A=>{var W;A.kind==="file"&&(A.uploadStatus==="uploading"?((W=m.current.get(A.id))==null||W(),m.current.delete(A.id)):A.uploadStatus==="done"&&A.uploadLocator&&hi(M,A.uploadLocator).catch(()=>{}))})}f.current=!1,n()};return j.addEventListener("close",B),()=>j.removeEventListener("close",B)},[n]),d.useEffect(()=>{const j=u.current;j&&(e?(c.current||T([Wn()]),c.current=!1,j.open||j.showModal()):(g.current.forEach(B=>clearTimeout(B)),g.current.clear(),j.open&&j.close()))},[e]),d.useEffect(()=>()=>{y.current.forEach(j=>clearInterval(j)),g.current.forEach(j=>clearTimeout(j))},[]);function ye(j,B,M,A,W=null){if(y.current.has(B))return;const V=setInterval(async()=>{var pe,ae,xe,ie;try{const Qe=await Xd(A,B);if(Qe.status==="completed"){clearInterval(y.current.get(B)),y.current.delete(B),await Promise.resolve((pe=w.current)==null?void 0:pe.call(w)),(ae=b.current)==null||ae.call(b,j);try{const we=Qe.notes_json?JSON.parse(Qe.notes_json):null;if(we!=null&&we.ublock_skipped||we!=null&&we.cookie_ext_skipped){const xt=we.ublock_skipped&&we.cookie_ext_skipped?"Captured without ad-blocking or cookie-consent extension. Check ARCHIVR_UBLOCK_EXT and ARCHIVR_COOKIE_EXT in your server config.":we.ublock_skipped?"Captured without ad-blocking. ARCHIVR_UBLOCK=true but ARCHIVR_UBLOCK_EXT is not set or the path is invalid.":"Captured without cookie-consent extension. ARCHIVR_COOKIE_EXT is not set or the path is invalid.";C.current(xt,M,"warning"),ce(W,"warning",M)}else W||C.current(null,M,"success"),ce(W,"archived")}catch{W||C.current(null,M,"success"),ce(W,"archived")}}else if(Qe.status==="failed"){clearInterval(y.current.get(B)),y.current.delete(B),(xe=b.current)==null||xe.call(b,j);const we=Qe.error_text||"Capture failed.";C.current(we,M),ce(W,"failed",M)}}catch(Qe){clearInterval(y.current.get(B)),y.current.delete(B),(ie=b.current)==null||ie.call(b,j);const we=Qe.message||"Network error";C.current(we,M),ce(W,"failed",M)}},500);y.current.set(B,V)}function ce(j,B,M=null){if(!j)return;const A=h.current.get(j);if(!A||(B==="archived"||B==="warning"?(A.archived++,B==="warning"&&(A.warnings++,M&&A.warningLocators.push(M))):(A.failed++,M&&A.failedLocators.push(M)),A.archived+A.failed0?`${W} archived (${V} with warnings)`:`${W} archived`;ie=pe>0?`${xt}, ${pe} failed`:xt}const Qe=W===0?"error":pe>0||V>0?"warning":"success",we=[];ae.length>0&&we.push(`Failed: -${ae.map(xt=>` ${xt}`).join(` -`)}`),xe.length>0&&we.push(`With warnings: -${xe.map(xt=>` ${xt}`).join(` -`)}`);const Un=we.length>0?we.join(` -`):null;C.current(Un,null,Qe,ie)}async function Ee(j,B,M,A={}){var ae,xe;const W=v.current,V=((ae=crypto.randomUUID)==null?void 0:ae.call(crypto))??`job-${Date.now()}-${Math.random()}`,pe={ublock_enabled:ee,reader_mode:Re,cookie_ext_enabled:Ae,modal_closer_enabled:D,via_freedium:H,...A};try{const ie=await Qh(W,j,B,pe);(xe=E.current)==null||xe.call(E,{id:V,jobUid:ie.job_uid,locator:j,archiveId:W}),ye(V,ie.job_uid,j,W,M)}catch(ie){const Qe=ie.message||"Submission failed.";C.current(Qe,j),ce(M,"failed",j),j.startsWith("file://")&&ie.status&&hi(W,j).catch(()=>{})}}function Ye(){var A,W;if(k.some(V=>V.kind==="file"&&V.uploadStatus==="uploading"))return;const j=k.filter(V=>V.kind==="file"?V.uploadStatus==="done"&&V.uploadLocator:V.locator.trim());if(j.length===0||j.some(V=>V.kind!=="file"&&Eu(V))||j.some(V=>V.kind!=="file"&&(V.probeState==="probing"||Pn(V.locator)&&V.playlistProbeState!=="done"))||j.some(V=>V.kind!=="file"&&Array.isArray(V.playlistItems)&&V.playlistItems.length===0))return;const B=j.length>1?((A=crypto.randomUUID)==null?void 0:A.call(crypto))??`batch-${Date.now()}`:null;B&&h.current.set(B,{total:j.length,archived:0,warnings:0,failed:0,failedLocators:[],warningLocators:[]});const M=j.map(V=>V.kind==="file"?{locator:V.uploadLocator,quality:"best",extraExtensions:{}}:{locator:V.locator.trim(),quality:V.playlistItems!==null?null:V.quality||"best",extraExtensions:V.playlistItems!==null?{per_item_quality:Object.fromEntries(V.playlistItems.map(pe=>[pe.id,pe.quality])),sync:V.syncEnabled}:{}});f.current=!0,T([Wn()]),(W=u.current)==null||W.close(),M.forEach(({locator:V,quality:pe,extraExtensions:ae})=>Ee(V,pe,B,ae))}function Ct(){T(j=>[...j,Wn()])}function yt(j){var M;clearTimeout(g.current.get(j)),g.current.delete(j);const B=p.current.find(A=>A.id===j);(B==null?void 0:B.kind)==="file"&&(B.uploadStatus==="uploading"?((M=m.current.get(j))==null||M(),m.current.delete(j)):B.uploadStatus==="done"&&B.uploadLocator&&hi(v.current,B.uploadLocator).catch(()=>{})),T(A=>{const W=A.filter(V=>V.id!==j);return W.length===0?[Wn()]:W})}function Ht(j,B){if(clearTimeout(g.current.get(j)),T(M=>M.map(A=>A.id===j?{...A,locator:B,probeState:"idle",probeQualities:null,probeHasAudio:!1,quality:"best",playlistProbeState:"idle",playlistInfo:null,playlistItems:null,playlistQuality:null,playlistExpanded:!1}:A)),Jd(B)){const M=setTimeout(async()=>{g.current.delete(j),T(A=>A.map(W=>W.id===j?{...W,probeState:"probing"}:W));try{const A=await Kh(v.current,B.trim());T(W=>W.map(V=>{if(V.id!==j||V.locator!==B)return V;const pe=A.qualities??[],ae=A.has_audio??!1,xe=pe.length===0&&ae?"audio":"best";return{...V,probeState:"done",probeQualities:pe,probeHasAudio:ae,quality:xe}}))}catch{T(A=>A.map(W=>W.id===j?{...W,probeState:"idle",probeQualities:null}:W))}},600);g.current.set(j,M)}else if(Pn(B)){const M=setTimeout(async()=>{g.current.delete(j),T(A=>A.map(W=>W.id===j?{...W,playlistProbeState:"probing"}:W));try{const A=await qh(v.current,B.trim());T(W=>W.map(V=>V.id!==j||V.locator!==B?V:{...V,playlistProbeState:"done",playlistInfo:A,playlistItems:A.items.map(pe=>({...pe,quality:null})),playlistQuality:null}))}catch{T(A=>A.map(W=>W.id===j?{...W,playlistProbeState:"error"}:W))}},800);g.current.set(j,M)}}function ut(j,B){T(M=>M.map(A=>A.id===j?{...A,quality:B}:A))}function rt(j,B){T(M=>M.map(A=>{if(A.id!==j)return A;const W=Pm(B,A.playlistItems);return{...A,playlistQuality:B,playlistItems:W}}))}function vt(j,B,M){T(A=>A.map(W=>W.id!==j?W:{...W,playlistItems:W.playlistItems.map(V=>V.id===B?{...V,quality:M}:V)}))}function Et(j){T(B=>B.map(M=>M.id===j?{...M,playlistExpanded:!M.playlistExpanded}:M))}function O(j,B){T(M=>M.map(A=>A.id===j?{...A,syncEnabled:B}:A))}function K(j,B){T(M=>M.map(A=>A.id!==j?A:{...A,playlistItems:A.playlistItems.filter(W=>W.id!==B)}))}function de(j){const B=Array.from(j);B.length!==0&&B.forEach(M=>{const A=Tm(M.name);T(ae=>ae.length===1&&ae[0].kind!=="file"&&!ae[0].locator.trim()?[A]:[...ae,A]);const W=v.current,{promise:V,abort:pe}=Nm(W,M,ae=>{T(xe=>xe.map(ie=>ie.id===A.id?{...ie,uploadProgress:ae}:ie))});m.current.set(A.id,pe),V.then(ae=>{m.current.delete(A.id),T(xe=>xe.map(ie=>ie.id===A.id?{...ie,uploadStatus:"done",uploadLocator:ae.locator,uploadProgress:100}:ie))}).catch(ae=>{m.current.delete(A.id),ae.message!=="Upload cancelled"&&T(xe=>xe.map(ie=>ie.id===A.id?{...ie,uploadStatus:"error",uploadError:ae.message}:ie))})})}function Be(j){j.preventDefault(),j.stopPropagation(),j.dataTransfer.types.includes("Files")&&N(!0)}function Dt(j){j.currentTarget.contains(j.relatedTarget)||N(!1)}function ze(j){j.preventDefault(),j.stopPropagation(),N(!1),de(j.dataTransfer.files)}function Fn(j){de(j.target.files),j.target.value=""}const Nn=k.some(j=>j.kind==="file"&&j.uploadStatus==="uploading"),tn=k.filter(j=>j.kind==="file"?j.uploadStatus==="done"&&j.uploadLocator:j.locator.trim()).length,Ve=k.some(j=>j.kind!=="file"&&Eu(j)),wr=k.some(j=>j.kind!=="file"&&Array.isArray(j.playlistItems)&&j.playlistItems.length===0),Bn=k.some(j=>j.kind!=="file"&&(j.probeState==="probing"||Pn(j.locator)&&j.playlistProbeState!=="done"));return s.jsx("dialog",{ref:u,className:`capture-dialog${S?" capture-dialog--dragover":""}`,onDragOver:Be,onDragLeave:Dt,onDrop:ze,children:s.jsxs("div",{className:"capture-dialog-inner",children:[s.jsxs("div",{className:"capture-dialog-header",children:[s.jsx("h2",{className:"capture-dialog-title",children:"Capture"}),s.jsx("button",{type:"button",className:"capture-dialog-close",onClick:()=>{var j;return(j=u.current)==null?void 0:j.close()},"aria-label":"Close",children:s.jsxs("svg",{viewBox:"0 0 16 16",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",children:[s.jsx("line",{x1:"3",y1:"3",x2:"13",y2:"13"}),s.jsx("line",{x1:"13",y1:"3",x2:"3",y2:"13"})]})})]}),s.jsx("div",{className:"capture-rows",children:k.map((j,B)=>j.kind==="file"?s.jsx(zm,{item:j,onRemove:()=>yt(j.id)},j.id):s.jsx(Rm,{item:j,autoFocus:B===k.length-1,onLocatorChange:M=>Ht(j.id,M),onQualityChange:M=>ut(j.id,M),onRemove:()=>yt(j.id),onSubmit:Ye,onPlaylistQualityChange:M=>rt(j.id,M),onPlaylistItemQualityChange:(M,A)=>vt(j.id,M,A),onPlaylistToggle:()=>Et(j.id),onSyncChange:M=>O(j.id,M),onPlaylistItemDelete:M=>K(j.id,M)},j.id))}),s.jsx("input",{ref:x,type:"file",multiple:!0,hidden:!0,onChange:Fn,"aria-hidden":"true",tabIndex:-1}),s.jsxs("div",{className:"capture-add-row-group",children:[s.jsxs("button",{type:"button",className:"capture-add-row",onClick:Ct,children:[s.jsxs("svg",{viewBox:"0 0 16 16",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",children:[s.jsx("line",{x1:"8",y1:"2",x2:"8",y2:"14"}),s.jsx("line",{x1:"2",y1:"8",x2:"14",y2:"8"})]}),"Add URL"]}),s.jsxs("button",{type:"button",className:"capture-add-row capture-add-file",onClick:()=>{var j;return(j=x.current)==null?void 0:j.click()},children:[s.jsxs("svg",{viewBox:"0 0 16 16",fill:"none",stroke:"currentColor",strokeWidth:"1.75",strokeLinecap:"round",strokeLinejoin:"round",children:[s.jsx("rect",{x:"3",y:"1",width:"10",height:"14",rx:"1.5"}),s.jsx("line",{x1:"5.5",y1:"5.5",x2:"10.5",y2:"5.5"}),s.jsx("line",{x1:"5.5",y1:"8",x2:"10.5",y2:"8"}),s.jsx("line",{x1:"5.5",y1:"10.5",x2:"8.5",y2:"10.5"})]}),"Upload file"]})]}),s.jsxs("div",{className:"capture-advanced",children:[s.jsxs("button",{type:"button",className:"capture-advanced-toggle",onClick:()=>q(j=>!j),"aria-expanded":L,children:[s.jsx("svg",{className:`capture-chevron${L?" capture-chevron--open":""}`,viewBox:"0 0 16 16",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:s.jsx("polyline",{points:"4 6 8 10 12 6"})}),"Advanced options"]}),L&&s.jsxs("div",{className:"capture-advanced-panel",children:[s.jsxs("label",{className:"capture-ext-row",children:[s.jsxs("span",{className:"capture-ext-label",children:[s.jsx("span",{className:"capture-ext-name",children:"uBlock Origin Lite"}),s.jsx("span",{className:"capture-ext-desc",children:"Block ads during this capture"})]}),s.jsx("button",{type:"button",role:"switch","aria-checked":ee,className:`ext-toggle ext-toggle--sm${ee?" ext-toggle--on":""}`,onClick:()=>X(j=>j===null?!ee:!j),"aria-label":"Toggle uBlock for this capture",children:s.jsx("span",{className:"ext-toggle-knob"})})]}),s.jsxs("label",{className:"capture-ext-row",style:{marginTop:8},children:[s.jsxs("span",{className:"capture-ext-label",children:[s.jsx("span",{className:"capture-ext-name",children:"Block cookie banners"}),s.jsx("span",{className:"capture-ext-desc",children:"Dismiss cookie consent banners during this capture"}),!(Z!=null&&Z.cookie_ext_available)&&s.jsxs("span",{className:"capture-ext-hint",children:["Not configured — set ",s.jsx("code",{children:"ARCHIVR_COOKIE_EXT"})]})]}),s.jsx("button",{type:"button",role:"switch","aria-checked":Ae,className:`ext-toggle ext-toggle--sm${Ae?" ext-toggle--on":""}`,onClick:()=>ge(j=>!j),"aria-label":"Toggle cookie banner blocking for this capture",children:s.jsx("span",{className:"ext-toggle-knob"})})]}),s.jsxs("label",{className:"capture-ext-row",style:{marginTop:8},children:[s.jsxs("span",{className:"capture-ext-label",children:[s.jsx("span",{className:"capture-ext-name",children:"Reader mode"}),s.jsx("span",{className:"capture-ext-desc",children:"Distil to article text via Readability (off by default)"})]}),s.jsx("button",{type:"button",role:"switch","aria-checked":Re,className:`ext-toggle ext-toggle--sm${Re?" ext-toggle--on":""}`,onClick:()=>Y(j=>!j),"aria-label":"Toggle reader mode for this capture",children:s.jsx("span",{className:"ext-toggle-knob"})})]}),s.jsxs("label",{className:"capture-ext-row",style:{marginTop:8},children:[s.jsxs("span",{className:"capture-ext-label",children:[s.jsx("span",{className:"capture-ext-name",children:"Close modals and dialogs"}),s.jsx("span",{className:"capture-ext-desc",children:"Auto-dismiss cookie banners and overlays during this capture"})]}),s.jsx("button",{type:"button",role:"switch","aria-checked":D,className:`ext-toggle ext-toggle--sm${D?" ext-toggle--on":""}`,onClick:()=>P(j=>!j),"aria-label":"Toggle modal closer for this capture",children:s.jsx("span",{className:"ext-toggle-knob"})})]}),s.jsxs("label",{className:"capture-ext-row",style:{marginTop:8},children:[s.jsxs("span",{className:"capture-ext-label",children:[s.jsx("span",{className:"capture-ext-name",children:"Freedium mirror"}),s.jsx("span",{className:"capture-ext-desc",children:"Route paywalled articles through a Freedium mirror (Medium, NYT, WaPo, etc.)"})]}),s.jsx("button",{type:"button",role:"switch","aria-checked":H,className:`ext-toggle ext-toggle--sm${H?" ext-toggle--on":""}`,onClick:()=>te(j=>!j),"aria-label":"Toggle Freedium mirror for this capture",children:s.jsx("span",{className:"ext-toggle-knob"})})]})]})]}),s.jsxs("div",{className:"capture-actions",children:[s.jsx("button",{type:"button",className:"capture-submit",onClick:Ye,disabled:tn===0||Ve||Bn||wr||Nn,children:tn>1?`Archive ${tn}`:"Archive"}),s.jsx("button",{type:"button",className:"capture-cancel",onClick:()=>{var j;return(j=u.current)==null?void 0:j.close()},children:"Cancel"})]})]})})}function Rm({item:e,autoFocus:t,onLocatorChange:n,onQualityChange:r,onRemove:l,onSubmit:i,onPlaylistQualityChange:a,onPlaylistItemQualityChange:o,onPlaylistToggle:u,onSyncChange:c,onPlaylistItemDelete:y}){const g=d.useRef(null);d.useEffect(()=>{var x;t&&((x=g.current)==null||x.focus())},[t]);const h=(()=>{if(Pn(e.locator)){if(e.playlistProbeState==="probing")return s.jsx("span",{className:"capture-quality-probing","aria-label":"Probing playlist…",children:"…"});if(e.playlistProbeState==="done"){const x=[...new Set(e.playlistItems.flatMap(p=>p.qualities.map(f=>parseInt(f))))].sort((p,f)=>f-p),S=e.playlistItems.every(p=>p.has_audio),N=e.playlistItems.filter(p=>p.quality===null).length;return s.jsxs(s.Fragment,{children:[s.jsxs("select",{className:"capture-quality",value:e.playlistQuality??"",onChange:p=>a(p.target.value),"aria-label":"Playlist quality",children:[!e.playlistQuality&&s.jsx("option",{value:"",disabled:!0,children:"Select quality…"}),s.jsx("option",{value:"best",children:"Best quality"}),x.map(p=>s.jsxs("option",{value:`${p}p`,children:[p,"p"]},p)),S&&s.jsx("option",{value:"audio",children:"Audio only"})]}),N>0&&s.jsxs("span",{className:"capture-conflict-badge",children:[N," need selection"]})]})}return e.playlistProbeState==="error"?s.jsx("span",{className:"capture-quality-hint capture-quality-hint--error",children:"Probe failed — edit URL to retry"}):null}if(!Jd(e.locator))return null;if(e.probeState==="probing")return s.jsx("span",{className:"capture-quality-probing","aria-label":"Checking available qualities",children:"…"});if(e.probeState==="done"){const x=e.probeQualities??[],S=e.probeHasAudio??!1;return x.length===0&&!S?s.jsx("span",{className:"capture-quality-hint",children:"No media detected"}):x.length===0&&S?s.jsx("select",{className:"capture-quality",value:"audio",onChange:N=>r(N.target.value),"aria-label":"Video quality",children:s.jsx("option",{value:"audio",children:"Audio only"})}):s.jsxs("select",{className:"capture-quality",value:e.quality||"best",onChange:N=>r(N.target.value),"aria-label":"Video quality",children:[s.jsx("option",{value:"best",children:"Best quality"}),x.map(N=>s.jsx("option",{value:N,children:N},N)),S&&s.jsx("option",{value:"audio",children:"Audio only"})]})}return null})(),v=Pn(e.locator)&&e.playlistProbeState==="done"?s.jsxs("label",{className:"capture-sync-row",children:[s.jsx("input",{type:"checkbox",checked:e.syncEnabled,onChange:x=>c(x.target.checked)}),s.jsx("span",{children:"Sync — skip already-archived videos"})]}):null;return s.jsxs("div",{className:"capture-row",children:[s.jsxs("div",{className:"capture-row-main",children:[Pn(e.locator)&&e.playlistProbeState==="done"?s.jsx("button",{type:"button",className:"capture-playlist-toggle capture-playlist-toggle--left",onClick:u,"aria-label":e.playlistExpanded?"Collapse video list":"Expand video list","aria-expanded":e.playlistExpanded,children:e.playlistExpanded?"▲":"▼"}):null,s.jsx("input",{ref:g,className:"capture-input",type:"text",placeholder:"https://… · yt:ID · ytm:ID · tweet:ID · x:ID",value:e.locator,onChange:x=>n(x.target.value),onKeyDown:x=>{x.key==="Enter"&&i()},autoComplete:"off",spellCheck:!1}),h,s.jsx("button",{type:"button",className:"capture-row-action capture-row-remove",onClick:l,"aria-label":"Remove",children:s.jsxs("svg",{viewBox:"0 0 16 16",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",children:[s.jsx("line",{x1:"3",y1:"3",x2:"13",y2:"13"}),s.jsx("line",{x1:"13",y1:"3",x2:"3",y2:"13"})]})})]}),e.error&&s.jsx("p",{className:"capture-row-error",children:e.error}),Pn(e.locator)&&e.playlistProbeState==="done"&&e.playlistExpanded?s.jsxs("div",{className:"capture-playlist-items",children:[e.playlistItems.map(x=>s.jsxs("div",{className:`capture-playlist-item${x.quality===null?" capture-playlist-item--conflict":""}`,children:[s.jsx("span",{className:"capture-playlist-item-title",children:x.title||x.url}),s.jsxs("select",{className:"capture-item-quality",value:x.quality??"",onChange:S=>o(x.id,S.target.value),"aria-label":`Quality for ${x.title||x.url}`,children:[x.quality===null&&s.jsx("option",{value:"",disabled:!0,children:"Choose…"}),s.jsx("option",{value:"best",children:"Best quality"}),x.qualities.map(S=>s.jsx("option",{value:S,children:S},S)),x.has_audio&&s.jsx("option",{value:"audio",children:"Audio only"})]}),x.quality===null&&s.jsx("span",{className:"capture-playlist-conflict-badge",children:"Choose quality"}),s.jsx("button",{type:"button",className:"capture-playlist-item-remove","aria-label":`Remove ${x.title||x.url}`,onClick:()=>y(x.id),children:"×"})]},x.id)),v]}):v]})}function zm({item:e,onRemove:t}){const n=e.uploadStatus==="uploading",r=e.uploadStatus==="done",l=e.uploadStatus==="error";return s.jsxs("div",{className:"capture-row",children:[s.jsxs("div",{className:"capture-row-main",children:[s.jsx("span",{className:"capture-file-icon","aria-hidden":"true",children:s.jsxs("svg",{viewBox:"0 0 16 16",fill:"none",stroke:"currentColor",strokeWidth:"1.75",strokeLinecap:"round",strokeLinejoin:"round",style:{width:14,height:14},children:[s.jsx("rect",{x:"3",y:"1",width:"10",height:"14",rx:"1.5"}),s.jsx("line",{x1:"5.5",y1:"5.5",x2:"10.5",y2:"5.5"}),s.jsx("line",{x1:"5.5",y1:"8",x2:"10.5",y2:"8"}),s.jsx("line",{x1:"5.5",y1:"10.5",x2:"8.5",y2:"10.5"})]})}),s.jsx("span",{className:`capture-file-name${l?" capture-file-name--error":""}`,children:e.filename}),n&&s.jsxs("span",{className:"capture-file-progress-wrap","aria-label":`Uploading ${e.uploadProgress}%`,children:[s.jsx("span",{className:"capture-file-progress-bar",children:s.jsx("span",{className:"capture-file-progress-fill",style:{width:`${e.uploadProgress}%`}})}),s.jsxs("span",{className:"capture-file-progress-pct",children:[e.uploadProgress,"%"]})]}),r&&s.jsx("span",{className:"capture-file-badge capture-file-badge--done","aria-label":"Upload complete",children:s.jsx("svg",{viewBox:"0 0 16 16",fill:"none",stroke:"currentColor",strokeWidth:"2.5",strokeLinecap:"round",strokeLinejoin:"round",style:{width:11,height:11},children:s.jsx("polyline",{points:"3 8.5 6.5 12 13 5"})})}),l&&s.jsx("span",{className:"capture-file-badge capture-file-badge--error",children:"!"}),s.jsx("button",{type:"button",className:"capture-row-action capture-row-remove",onClick:t,"aria-label":"Remove",disabled:n,children:s.jsxs("svg",{viewBox:"0 0 16 16",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",children:[s.jsx("line",{x1:"3",y1:"3",x2:"13",y2:"13"}),s.jsx("line",{x1:"13",y1:"3",x2:"3",y2:"13"})]})})]}),l&&s.jsx("p",{className:"capture-row-error",children:e.uploadError||"Upload failed"})]})}function Im(){return s.jsxs("div",{className:"skeleton-row",children:[s.jsx("div",{className:"col-check","aria-hidden":"true"}),s.jsx("div",{className:"col-added",children:s.jsx("span",{className:"skeleton-cell",style:{width:108,height:13}})}),s.jsxs("div",{className:"col-title",style:{gap:"0.42em",display:"flex",alignItems:"center"},children:[s.jsx("span",{className:"skeleton-cell",style:{width:14,height:14,borderRadius:"50%",flexShrink:0}}),s.jsx("span",{className:"skeleton-cell",style:{width:"58%",height:13}})]}),s.jsx("div",{className:"col-type",children:s.jsx("span",{className:"skeleton-cell",style:{width:58,height:20,borderRadius:99}})}),s.jsx("div",{className:"col-size",children:s.jsx("span",{className:"skeleton-cell",style:{width:44,height:12}})}),s.jsx("div",{className:"col-url",children:s.jsx("span",{className:"skeleton-cell",style:{width:"65%",height:12}})})]})}function cl(e){if(!e)return"0 B";const t=["B","KB","MB","GB"];let n=e,r=0;for(;n>=1024&&r").replace(/"/g,'"').replace(/'/g,"'").replace(/'/g,"'")}function dt(e){return Dm(e)??""}function ao(e){if(!e)return"";const t=new Date(e);if(isNaN(t))return e;const n=r=>String(r).padStart(2,"0");return`${t.getUTCFullYear()}-${n(t.getUTCMonth()+1)}-${n(t.getUTCDate())} ${n(t.getUTCHours())}:${n(t.getUTCMinutes())}`}const bu={youtube:'',youtube_music:'',spotify:'',x:'',instagram:'',facebook:'',tiktok:'',reddit:'',snapchat:'',local:'',web:'',other:'?'};function Os(e){return bu[e]??bu.other}function Gd(e){return e.split("/").map(t=>t?t.split("-").map(n=>n.charAt(0).toUpperCase()+n.slice(1)).join(" "):"").join("/")}function $m({entry:e,index:t,onRowClick:n,selectedUids:r}){const l=(r==null?void 0:r.size)===1&&r.has(e.entry_uid),i=(r==null?void 0:r.size)>=2&&r.has(e.entry_uid),a=["child-entry-row",t%2===0?"child-entry-row--light":"child-entry-row--dark",l&&"is-selected",i&&"is-multi-selected"].filter(Boolean).join(" ");return s.jsxs("div",{className:a,tabIndex:0,"data-entry-uid":e.entry_uid,onMouseDown:o=>{o.shiftKey&&o.preventDefault()},onClick:o=>n(e,o),onKeyDown:o=>{o.key==="Enter"&&n(e,o)},children:[s.jsx("div",{className:"col-check","aria-hidden":"true"}),s.jsx("div",{className:"col-added",children:ao(e.archived_at)}),s.jsxs("div",{className:"col-title",children:[s.jsx("span",{className:"source-icon",children:s.jsx("span",{dangerouslySetInnerHTML:{__html:Os(e.source_kind)}})}),s.jsx("span",{className:"entry-title",children:dt(e.title)||dt(e.entry_uid)})]}),s.jsx("div",{className:"col-type",children:s.jsx("span",{className:"type-pill",children:dt(e.entity_kind)})}),s.jsx("div",{className:"col-size",children:s.jsx("span",{className:"size-total",children:cl(e.total_artifact_bytes)})}),s.jsx("div",{className:"url-cell col-url",children:dt(e.original_url)})]})}function Om({entry:e,archiveId:t,rowIndex:n,isSelected:r,isMultiSelected:l,onRowClick:i,selectedUids:a,deletedUids:o,isPublicSession:u}){const[c,y]=d.useState(!1),[g,h]=d.useState(!1),[v,x]=d.useState(null),[S,N]=d.useState(!1),f=e.source_kind==="web"&&e.entity_kind==="page"&&e.has_favicon&&t&&!c?s.jsx("img",{src:`/api/archives/${t}/entries/${e.entry_uid}/favicon`,width:"16",height:"16",alt:"",onError:()=>y(!0),style:{objectFit:"contain"}}):s.jsx("span",{dangerouslySetInnerHTML:{__html:Os(e.source_kind)}}),m=r||l,w=e.child_count>0&&!u;function C(k){k.stopPropagation(),i(e,{ctrlKey:!0,metaKey:!1,shiftKey:!1,preventDefault(){}})}async function E(k){if(k.stopPropagation(),g){h(!1);return}if(h(!0),v===null&&!S){N(!0);try{const T=await Oh(t,e.entry_uid);x(T)}catch{x([])}finally{N(!1)}}}const b=["entry-row-outer",n%2===0?"entry-row-outer--light":"entry-row-outer--dark",r&&"is-selected",l&&"is-multi-selected"].filter(Boolean).join(" ");return s.jsxs("div",{className:b,"data-entry-uid":e.entry_uid,children:[s.jsxs("div",{className:"entry-row-main",tabIndex:0,onMouseDown:k=>{k.shiftKey&&k.preventDefault()},onClick:k=>i(e,k),onKeyDown:k=>{k.key==="Enter"&&i(e,k)},children:[s.jsx("div",{className:"col-check",children:s.jsx("button",{type:"button",className:`row-checkbox${m?" is-checked":""}`,"aria-pressed":m,"aria-label":m?"Deselect entry":"Select entry",onClick:C,onKeyDown:k=>k.stopPropagation()})}),s.jsx("div",{className:"col-added",children:ao(e.archived_at)}),s.jsxs("div",{className:"col-title",children:[w&&s.jsx("button",{type:"button",className:`entry-expand-btn${g?" is-expanded":""}`,"aria-label":g?"Collapse children":`Expand ${e.child_count} items`,"aria-expanded":g,onClick:E,onKeyDown:k=>k.stopPropagation()}),s.jsx("span",{className:"source-icon",children:f}),s.jsx("span",{className:"entry-title",children:dt(e.title)||dt(e.entry_uid)}),w&&s.jsx("span",{className:"child-count-badge","aria-hidden":"true",children:e.child_count})]}),s.jsx("div",{className:"col-type",children:s.jsx("span",{className:"type-pill",children:dt(e.entity_kind)})}),s.jsxs("div",{className:"col-size",children:[s.jsx("span",{className:"size-total",children:cl(e.total_artifact_bytes)}),e.cached_bytes>0&&e.cacheable_bytes>0&&s.jsxs("span",{className:"size-cached-pct",title:`${cl(e.cached_bytes)} already on disk from an earlier entry`,children:[Math.round(e.cached_bytes/e.cacheable_bytes*100),"% cached"]})]}),s.jsx("div",{className:"url-cell col-url",children:dt(e.original_url)})]}),g&&s.jsxs(s.Fragment,{children:[S&&s.jsx("div",{className:"child-entries-loading",children:"Loading…"}),s.jsx("div",{className:"child-entries","aria-label":`${e.child_count} child entries`,children:v&&v.filter(k=>!(o!=null&&o.has(k.entry_uid))).map((k,T)=>s.jsx($m,{entry:k,index:T,onRowClick:i,selectedUids:a},k.entry_uid))})]})]})}function Am({entries:e,selectedUids:t,onRowClick:n,archiveId:r,pendingCaptures:l=[],deletedUids:i,isPublicSession:a}){return s.jsx("section",{id:"archive-view",className:"view is-active",children:s.jsxs("div",{className:"entry-table",children:[s.jsxs("div",{className:"entry-header-row",children:[s.jsx("div",{className:"col-check","aria-hidden":"true"}),s.jsx("div",{className:"col-added",children:"Added"}),s.jsx("div",{className:"col-title",children:"Title"}),s.jsx("div",{className:"col-type",children:"Type"}),s.jsx("div",{className:"col-size",children:"Size"}),s.jsx("div",{className:"col-url",children:"Original URL"})]}),s.jsxs("div",{id:"entries-body",children:[l.filter(o=>o.archiveId===r).reverse().map(o=>s.jsx(Im,{},o.id)),e.map((o,u)=>s.jsx(Om,{entry:o,rowIndex:u,archiveId:r,isSelected:t.size===1&&t.has(o.entry_uid),isMultiSelected:t.size>=2&&t.has(o.entry_uid),onRowClick:n,selectedUids:t,deletedUids:i,isPublicSession:a},o.entry_uid))]})]})})}function Mm(e){if(!e)return"—";try{return new Date(e).toLocaleString(void 0,{year:"numeric",month:"short",day:"numeric",hour:"2-digit",minute:"2-digit"})}catch{return e}}function Fm({status:e}){const t=e==="completed"?"run-status--completed":e==="failed"?"run-status--failed":e==="in_progress"?"run-status--in-progress":"",n=e?e.replace(/_/g," "):"—";return s.jsx("span",{className:`run-status ${t}`,children:n})}function Bm({runs:e}){const[t,n]=d.useState(null);function r(l){n(i=>i===l?null:l)}return s.jsx("section",{id:"runs-view",className:"view is-active",children:s.jsxs("table",{className:"entry-table",children:[s.jsx("thead",{children:s.jsxs("tr",{children:[s.jsx("th",{children:"Started"}),s.jsx("th",{children:"Status"}),s.jsx("th",{children:"Requested"}),s.jsx("th",{children:"Completed"}),s.jsx("th",{children:"Failed"})]})}),s.jsx("tbody",{children:e.length===0?s.jsx("tr",{children:s.jsx("td",{colSpan:5,style:{color:"var(--muted)",padding:"24px 16px",textAlign:"center"},children:"No runs yet."})}):e.map(l=>{const i=l.status==="failed"&&l.error_summary,a=t===l.run_uid;return[s.jsxs("tr",{className:i?"run-row run-row--failed":"run-row",onClick:i?()=>r(l.run_uid):void 0,title:i?a?"Click to hide error":"Click to view error":void 0,children:[s.jsx("td",{children:Mm(l.started_at)}),s.jsxs("td",{children:[s.jsx(Fm,{status:l.status}),i&&s.jsx("span",{className:"run-expand-hint","aria-hidden":"true",children:a?"▴":"▾"})]}),s.jsx("td",{children:l.requested_count??"—"}),s.jsx("td",{children:l.completed_count??"—"}),s.jsx("td",{children:l.failed_count??"—"})]},l.run_uid),i&&a&&s.jsx("tr",{className:"run-error-row",children:s.jsx("td",{colSpan:5,children:s.jsx("pre",{className:"run-error-detail",children:l.error_summary})})},`${l.run_uid}-detail`)]})})]})})}const Um=4;function Hm({archives:e}){const{currentUser:t}=d.useContext(As)??{},n=t&&(t.role_bits&Um)!==0,[r,l]=d.useState("users"),[i,a]=d.useState([]),[o,u]=d.useState([]),[c,y]=d.useState(!1),[g,h]=d.useState(null),[v,x]=d.useState(""),[S,N]=d.useState(""),[p,f]=d.useState(""),[m,w]=d.useState(null),[C,E]=d.useState(!1),[b,k]=d.useState(""),[T,L]=d.useState(""),[q,F]=d.useState(null),[X,Z]=d.useState(!1),ue=d.useCallback(async()=>{if(n){y(!0),h(null);try{const[P,H]=await Promise.all([im(),um()]);a(P),u(H)}catch(P){h(P.message)}finally{y(!1)}}},[n]);d.useEffect(()=>{ue()},[ue]);async function Ae(P){const H=P.status==="active"?"disabled":"active";try{await om(P.user_uid,H),a(te=>te.map(ee=>ee.user_uid===P.user_uid?{...ee,status:H}:ee))}catch(te){h(te.message)}}async function ge(P){if(P.preventDefault(),!v.trim()||!S){w("Username and password required");return}E(!0),w(null);try{await am(v.trim(),S,p.trim()||void 0),x(""),N(""),f(""),await ue()}catch(H){w(H.message)}finally{E(!1)}}async function D(P){if(P.preventDefault(),!b.trim()||!T.trim()){F("Slug and name required");return}Z(!0),F(null);try{await cm(b.trim(),T.trim()),k(""),L(""),await ue()}catch(H){F(H.message)}finally{Z(!1)}}return n?s.jsxs("section",{id:"admin-view",className:"view admin-view is-active",children:[s.jsx("h1",{children:"Admin"}),s.jsxs("div",{className:"view-tabs",children:[s.jsx("button",{className:`view-tab${r==="users"?" is-active":""}`,onClick:()=>l("users"),children:"Users"}),s.jsx("button",{className:`view-tab${r==="roles"?" is-active":""}`,onClick:()=>l("roles"),children:"Roles"}),s.jsx("button",{className:`view-tab${r==="archives"?" is-active":""}`,onClick:()=>l("archives"),children:"Archives"})]}),g&&s.jsx("div",{className:"form-msg form-msg--err",children:g}),r==="users"&&s.jsxs("div",{className:"admin-section",children:[s.jsx("h2",{children:"Users"}),c?s.jsx("p",{className:"muted",children:"Loading…"}):s.jsxs("table",{className:"admin-table",children:[s.jsx("thead",{children:s.jsxs("tr",{children:[s.jsx("th",{children:"Username"}),s.jsx("th",{children:"Email"}),s.jsx("th",{children:"Roles"}),s.jsx("th",{children:"Status"}),s.jsx("th",{children:"Actions"})]})}),s.jsx("tbody",{children:i.map(P=>s.jsxs("tr",{className:P.status==="disabled"?"admin-row-disabled":"",children:[s.jsx("td",{children:P.username}),s.jsx("td",{className:"muted",children:P.email||"—"}),s.jsx("td",{children:P.role_slugs.join(", ")||"—"}),s.jsx("td",{children:s.jsx("span",{className:`status-badge status-${P.status}`,children:P.status})}),s.jsx("td",{children:s.jsx("button",{className:"admin-action-btn",onClick:()=>Ae(P),children:P.status==="active"?"Ban":"Unban"})})]},P.user_uid))})]}),s.jsx("h3",{children:"Create User"}),s.jsxs("form",{className:"admin-form",onSubmit:ge,children:[s.jsx("input",{className:"admin-input",placeholder:"Username",value:v,onChange:P=>x(P.target.value),required:!0}),s.jsx("input",{className:"admin-input",type:"password",placeholder:"Password (min 8 chars)",value:S,onChange:P=>N(P.target.value),required:!0}),s.jsx("input",{className:"admin-input",type:"email",placeholder:"Email (optional)",value:p,onChange:P=>f(P.target.value)}),m&&s.jsx("div",{className:"form-msg form-msg--err",children:m}),s.jsx("button",{className:"btn-primary",type:"submit",disabled:C,children:C?"Creating…":"Create User"})]})]}),r==="roles"&&s.jsxs("div",{className:"admin-section",children:[s.jsx("h2",{children:"Roles"}),c?s.jsx("p",{className:"muted",children:"Loading…"}):s.jsxs("table",{className:"admin-table",children:[s.jsx("thead",{children:s.jsxs("tr",{children:[s.jsx("th",{children:"Slug"}),s.jsx("th",{children:"Name"}),s.jsx("th",{children:"Level"}),s.jsx("th",{children:"Bit"}),s.jsx("th",{children:"Built-in"})]})}),s.jsx("tbody",{children:o.map(P=>s.jsxs("tr",{children:[s.jsx("td",{children:s.jsx("code",{children:P.slug})}),s.jsx("td",{children:P.name}),s.jsx("td",{children:P.level}),s.jsx("td",{children:P.bit_position}),s.jsx("td",{children:P.is_builtin?"✓":""})]},P.role_uid))})]}),s.jsx("h3",{children:"Create Custom Role"}),s.jsxs("form",{className:"admin-form",onSubmit:D,children:[s.jsx("input",{className:"admin-input",placeholder:"Slug (e.g. moderator)",value:b,onChange:P=>k(P.target.value),required:!0}),s.jsx("input",{className:"admin-input",placeholder:"Display Name (e.g. Moderator)",value:T,onChange:P=>L(P.target.value),required:!0}),q&&s.jsx("div",{className:"form-msg form-msg--err",children:q}),s.jsx("button",{className:"btn-primary",type:"submit",disabled:X,children:X?"Creating…":"Create Role"})]})]}),r==="archives"&&s.jsxs("div",{className:"admin-section",children:[s.jsx("h2",{children:"Mounted Archives"}),s.jsx("div",{className:"admin-list",children:e.map(P=>s.jsxs("div",{className:"admin-archive",children:[s.jsx("strong",{children:P.label}),s.jsx("div",{className:"muted",children:P.archive_path})]},P.id))})]})]}):s.jsxs("section",{id:"admin-view",className:"view admin-view is-active",children:[s.jsx("h1",{children:"Admin"}),s.jsx("p",{className:"muted",children:"You need admin privileges to access this panel."}),s.jsx("h2",{children:"Mounted Archives"}),s.jsx("div",{className:"admin-list",children:e.map(P=>s.jsxs("div",{className:"admin-archive",children:[s.jsx("strong",{children:P.label}),s.jsx("div",{className:"muted",children:P.archive_path})]},P.id))})]})}function Yd({node:e,onPick:t}){var n;return s.jsxs("li",{children:[s.jsx("button",{className:"tag-picker-node-btn",title:e.tag.full_path,onClick:()=>t(e.tag),children:e.tag.slug}),((n=e.children)==null?void 0:n.length)>0&&s.jsx("div",{className:"tag-children",children:s.jsx("ul",{className:"tag-tree-list",children:e.children.map(r=>s.jsx(Yd,{node:r,onPick:t},r.tag.tag_uid))})})]})}function Tu({title:e,tagNodes:t,excludeUid:n,onPick:r,onCancel:l}){d.useEffect(()=>{function o(u){u.key==="Escape"&&(u.preventDefault(),u.stopPropagation(),l())}return document.addEventListener("keydown",o),()=>document.removeEventListener("keydown",o)},[l]);function i(o){return o.filter(u=>u.tag.tag_uid!==n).map(u=>({...u,children:i(u.children)}))}const a=n?i(t):t;return s.jsx("div",{className:"tag-picker-backdrop",onClick:o=>{o.target===o.currentTarget&&l()},children:s.jsxs("div",{className:"tag-picker-modal",role:"dialog","aria-modal":"true",children:[s.jsxs("div",{className:"tag-picker-header",children:[s.jsx("span",{className:"tag-picker-title",children:e}),s.jsx("button",{className:"tag-picker-close",onClick:l,title:"Cancel","aria-label":"Cancel",children:"×"})]}),s.jsxs("div",{className:"tag-picker-body",children:[s.jsx("button",{className:"tag-picker-root-btn",onClick:()=>r(null),title:"Place at root level (no parent)",children:"↑ Root tag (no parent)"}),a.length>0?s.jsx("ul",{className:"tag-tree-list tag-picker-tree",children:a.map(o=>s.jsx(Yd,{node:o,onPick:r},o.tag.tag_uid))}):s.jsx("p",{className:"tag-picker-empty",children:"No other tags available."})]})]})})}function Zd({parentPath:e,archiveId:t,onDone:n,onCancel:r}){const[l,i]=d.useState(""),a=d.useRef(!1);async function o(){const u=l.trim();if(!u){r();return}const c=e?`${e}/${u}`:`/${u}`;try{await Wh(t,c),n()}catch(y){alert(y.message||"Create failed"),r()}}return s.jsx("input",{className:"tag-rename-input",autoFocus:!0,placeholder:"tag name",value:l,onChange:u=>i(u.target.value),onKeyDown:u=>{u.key==="Enter"&&u.currentTarget.blur(),u.key==="Escape"&&(a.current=!0,u.currentTarget.blur())},onBlur:()=>{a.current?(a.current=!1,r()):o()}})}function ef({node:e,archiveId:t,tagFilter:n,onTagFilterSet:r,onViewChange:l,onTagRenamed:i,onTagDeleted:a,onTagsRefresh:o,humanizeTags:u,moveSelectMode:c,onMoveSourceSelect:y,pendingCreateParentUid:g,onCreateDone:h,onCreateCancel:v}){var q;const x=n===e.tag.full_path,[S,N]=d.useState(!1),[p,f]=d.useState(""),m=d.useRef(!1);function w(){if(S)return;if(c){y(e);return}const F=x?null:e.tag.full_path;r(F),l("archive")}function C(F){F.stopPropagation(),!c&&(f(e.tag.slug),N(!0))}async function E(){const F=p.trim();if(!F||F===e.tag.slug){N(!1);return}try{const X=await Uh(t,e.tag.tag_uid,F);i(e.tag.full_path,X.full_path),o()}catch{}finally{N(!1)}}async function b(F){var Z;F.stopPropagation();const X=((Z=e.children)==null?void 0:Z.length)>0?`Delete tag "${e.tag.full_path}" and all its child tags? This cannot be undone.`:`Delete tag "${e.tag.full_path}"? This cannot be undone.`;if(window.confirm(X))try{await Hh(t,e.tag.tag_uid),a(e.tag.full_path),o()}catch{}}const k={archiveId:t,tagFilter:n,onTagFilterSet:r,onViewChange:l,onTagRenamed:i,onTagDeleted:a,onTagsRefresh:o,humanizeTags:u,moveSelectMode:c,onMoveSourceSelect:y,pendingCreateParentUid:g,onCreateDone:h,onCreateCancel:v},T=g===e.tag.tag_uid,L=((q=e.children)==null?void 0:q.length)>0;return s.jsxs("li",{children:[s.jsxs("div",{className:`tag-node-row${c?" tag-node-row--move-select":""}`,children:[S?s.jsx("input",{className:"tag-rename-input",autoFocus:!0,value:p,onChange:F=>f(F.target.value),onKeyDown:F=>{F.key==="Enter"&&F.currentTarget.blur(),F.key==="Escape"&&(m.current=!0,F.currentTarget.blur())},onBlur:()=>{m.current?(m.current=!1,N(!1)):E()}}):s.jsxs("button",{className:`tag-node-btn${x?" is-active":""}${c?" tag-node-btn--move-select":""}`,title:c?`Select "${e.tag.full_path}" to move`:e.tag.full_path,onClick:w,onDoubleClick:c?void 0:C,children:[s.jsx("span",{className:"tag-node-label",children:u?e.tag.name:e.tag.slug}),s.jsx("span",{className:"tag-node-count",children:e.children.length===0?`(${e.entry_count})`:`(${e.entry_count}) (${e.subtree_count} Total)`}),!c&&s.jsx("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:F=>{F.stopPropagation(),C(F)},children:s.jsx("path",{d:"M11.5 2.5a1.5 1.5 0 0 1 2 2L5 13l-3 1 1-3 8.5-8.5z"})})]}),!S&&!c&&s.jsx("button",{className:"remove tag-node-delete",title:`Delete "${e.tag.full_path}"`,onClick:b,"aria-label":`Delete "${e.tag.full_path}"`,children:"×"})]}),(L||T)&&s.jsx("div",{className:"tag-children",children:s.jsxs("ul",{className:"tag-tree-list",children:[e.children.map(F=>s.jsx(ef,{node:F,...k},F.tag.tag_uid)),T&&s.jsx("li",{children:s.jsx(Zd,{parentPath:e.tag.full_path,archiveId:t,onDone:h,onCancel:v})})]})})]})}function Wm({archiveId:e,tagNodes:t,tagFilter:n,onTagFilterSet:r,onViewChange:l,onTagRenamed:i,onTagDeleted:a,onTagsRefresh:o,humanizeTags:u}){const[c,y]=d.useState(null),[g,h]=d.useState(null);function v(){y("select-source"),h(null)}function x(){y(null),h(null)}d.useEffect(()=>{if(c!=="select-source")return;function X(Z){Z.key==="Escape"&&(Z.preventDefault(),Z.stopPropagation(),x())}return document.addEventListener("keydown",X),()=>document.removeEventListener("keydown",X)},[c]);function S(X){h(X),y("select-dest")}async function N(X){const Z=g.tag.full_path,ue=g.tag.tag_uid,Ae=(X==null?void 0:X.tag_uid)??null;x();try{const ge=await Vh(e,ue,Ae);i(Z,ge.full_path),o()}catch(ge){alert(ge.message||"Move failed")}}const[p,f]=d.useState(null),[m,w]=d.useState(void 0);function C(){f("select-parent"),w(void 0)}function E(){f(null),w(void 0)}function b(X){w(X??null),f("input")}function k(){f(null),w(void 0),o()}const T=c==="select-source",L=p==="input"?m?m.tag_uid:"__root__":void 0,q={archiveId:e,tagFilter:n,onTagFilterSet:r,onViewChange:l,onTagRenamed:i,onTagDeleted:a,onTagsRefresh:o,humanizeTags:u,moveSelectMode:T,onMoveSourceSelect:S,pendingCreateParentUid:L,onCreateDone:k,onCreateCancel:E},F=L==="__root__";return s.jsxs("section",{id:"tags-view",className:"view is-active",children:[s.jsxs("div",{className:"tag-tree",children:[s.jsx("div",{className:"tag-tree-header",children:T?s.jsxs(s.Fragment,{children:[s.jsx("span",{className:"tag-tree-title tag-tree-title--move",children:"Select a tag to move"}),s.jsx("button",{className:"tag-tree-action-btn tag-tree-action-btn--cancel",onClick:x,children:"Cancel"})]}):s.jsxs(s.Fragment,{children:[s.jsx("span",{className:"tag-tree-title",children:"Tags"}),n&&s.jsxs("span",{className:"tag-tree-active",title:n,children:["Filtering: ",n]}),s.jsxs("div",{className:"tag-tree-actions",children:[s.jsx("button",{className:"tag-tree-action-btn",onClick:C,title:"Create a new tag",disabled:!!p||!!c,children:"+ New"}),s.jsx("button",{className:"tag-tree-action-btn",onClick:v,title:"Move a tag to a different parent",disabled:!!p||!!c||t.length===0,children:"Move"})]})]})}),t.length===0&&!F?s.jsx("p",{className:"muted",style:{padding:"8px 0"},children:"No tags yet."}):s.jsxs("ul",{className:"tag-tree-list",children:[t.map(X=>s.jsx(ef,{node:X,...q},X.tag.tag_uid)),F&&s.jsx("li",{children:s.jsx(Zd,{parentPath:null,archiveId:e,onDone:k,onCancel:E})})]})]}),p==="select-parent"&&s.jsx(Tu,{title:"Create tag under…",tagNodes:t,excludeUid:null,onPick:b,onCancel:E}),c==="select-dest"&&g&&s.jsx(Tu,{title:`Move "${g.tag.slug}" under…`,tagNodes:t,excludeUid:g.tag.tag_uid,onPick:N,onCancel:x})]})}const Or=[{value:0,label:"Private"},{value:2,label:"Users only"},{value:3,label:"Public"}],Vm=e=>{var t;return((t=Or.find(n=>n.value===e))==null?void 0:t.label)??String(e)};function Qm({archiveId:e}){const[t,n]=d.useState([]),[r,l]=d.useState(!1),[i,a]=d.useState(null),[o,u]=d.useState(null),[c,y]=d.useState(null),[g,h]=d.useState(!1),[v,x]=d.useState(null),[S,N]=d.useState(""),[p,f]=d.useState(""),[m,w]=d.useState(2),[C,E]=d.useState(!1),[b,k]=d.useState(!0),[T,L]=d.useState(null),[q,F]=d.useState(""),[X,Z]=d.useState(2),[ue,Ae]=d.useState(!1),[ge,D]=d.useState(null),[P,H]=d.useState(!1),[te,ee]=d.useState(""),Re=d.useRef(null),Y=t.find(O=>O.collection_uid===o)??null,ye=(Y==null?void 0:Y.slug)==="_default_",ce=d.useCallback(async()=>{if(e){l(!0),a(null);try{const O=await io(e);n(O)}catch(O){a(O.message)}finally{l(!1)}}},[e]),Ee=d.useCallback(async O=>{if(!O){y(null);return}h(!0),x(null);try{const K=await fm(e,O);y(K)}catch(K){x(K.message)}finally{h(!1)}},[e]);d.useEffect(()=>{ce()},[ce]),d.useEffect(()=>{Ee(o)},[o,Ee]),d.useEffect(()=>{P&&Re.current&&Re.current.focus()},[P]),d.useEffect(()=>{c!=null&&Z(c.default_visibility_bits)},[c]);async function Ye(O){O.preventDefault();const K=S.trim(),de=p.trim();if(!(!K||!de)){E(!0),L(null);try{const Be=await dm(e,K,de,m,b);N(""),f(""),w(2),k(!0),await ce(),u(Be.collection_uid)}catch(Be){L(Be.message)}finally{E(!1)}}}async function Ct(){const O=te.trim();if(!O||!Y){H(!1);return}try{await pi(e,Y.collection_uid,{name:O}),await ce(),y(K=>K&&{...K,name:O})}catch(K){a(K.message)}finally{H(!1)}}async function yt(O){if(Y)try{await pi(e,Y.collection_uid,{default_visibility_bits:O}),await ce(),y(K=>K&&{...K,default_visibility_bits:O})}catch(K){a(K.message)}}async function Ht(O){if(Y)try{await pi(e,Y.collection_uid,{requires_auth:O}),await ce(),y(K=>K&&{...K,requires_auth:O})}catch(K){a(K.message)}}async function ut(){if(Y&&window.confirm(`Delete collection "${Y.name}"? Entries will not be deleted.`))try{await mm(e,Y.collection_uid),u(null),y(null),await ce()}catch(O){a(O.message)}}async function rt(O){O.preventDefault();const K=q.trim();if(!(!K||!Y)){Ae(!0),D(null);try{await ca(e,Y.collection_uid,K,X),F(""),await Ee(Y.collection_uid)}catch(de){D(de.message)}finally{Ae(!1)}}}async function vt(O){if(Y)try{await pm(e,Y.collection_uid,O),await Ee(Y.collection_uid)}catch(K){x(K.message)}}async function Et(O,K){if(Y)try{await hm(e,Y.collection_uid,O,K),y(de=>de&&{...de,entries:de.entries.map(Be=>Be.entry_uid===O?{...Be,collection_visibility_bits:K}:Be)})}catch(de){x(de.message)}}return e?s.jsxs("div",{className:"collections-view",children:[s.jsx("h2",{className:"collections-heading",children:"Collections"}),r&&s.jsx("div",{className:"muted",children:"Loading…"}),i&&s.jsxs("div",{className:"collections-error",children:[i," ",s.jsx("button",{onClick:()=>a(null),className:"coll-dismiss",children:"×"})]}),s.jsxs("div",{className:"collections-layout",children:[s.jsxs("div",{className:"collections-sidebar",children:[t.map(O=>s.jsxs("button",{className:`coll-sidebar-row${o===O.collection_uid?" is-active":""}`,onClick:()=>u(O.collection_uid),children:[s.jsx("span",{className:"coll-row-name",children:O.name}),s.jsx("span",{className:"coll-row-meta",children:Vm(O.default_visibility_bits)})]},O.collection_uid)),t.length===0&&!r&&s.jsx("div",{className:"muted",style:{padding:"8px 12px"},children:"No collections yet."})]}),Y?s.jsxs("div",{className:"coll-detail",children:[s.jsxs("div",{className:"coll-detail-header",children:[P?s.jsx("input",{ref:Re,className:"coll-rename-input",value:te,onChange:O=>ee(O.target.value),onBlur:Ct,onKeyDown:O=>{O.key==="Enter"&&Ct(),O.key==="Escape"&&H(!1)}}):s.jsxs("h3",{className:`coll-detail-name${ye?"":" coll-detail-name--editable"}`,title:ye?void 0:"Click to rename",onClick:()=>{ye||(ee(Y.name),H(!0))},children:[(c==null?void 0:c.name)??Y.name,!ye&&s.jsx("span",{className:"coll-edit-hint",children:" ✎"})]}),!ye&&s.jsx("button",{className:"coll-delete-btn",onClick:ut,title:"Delete collection",children:"Delete"})]}),s.jsxs("div",{className:"coll-detail-vis",children:[s.jsx("span",{className:"coll-vis-label",children:"Entries' default visibility"}),s.jsx("select",{className:"coll-vis-select",value:(c==null?void 0:c.default_visibility_bits)??Y.default_visibility_bits,onChange:O=>yt(Number(O.target.value)),children:Or.map(O=>s.jsx("option",{value:O.value,children:O.label},O.value))})]}),s.jsxs("div",{className:"coll-detail-vis",children:[s.jsx("span",{className:"coll-vis-label",children:"Collection access"}),s.jsxs("label",{className:"coll-auth-label",children:[s.jsx("input",{type:"checkbox",checked:!!((c==null?void 0:c.requires_auth)??Y.requires_auth),onChange:O=>Ht(O.target.checked)})," Require authentication to view"]})]}),s.jsxs("div",{className:"coll-entries-section",children:[s.jsx("div",{className:"coll-section-heading",children:"Entries"}),g&&s.jsx("div",{className:"muted",children:"Loading…"}),v&&s.jsx("div",{className:"collections-error",children:v}),!g&&c&&(c.entries.length===0?s.jsx("div",{className:"muted",children:"No entries in this collection."}):s.jsx("ul",{className:"coll-entries-list",children:c.entries.map(O=>s.jsxs("li",{className:"coll-entry-row",children:[s.jsxs("div",{className:"coll-entry-info",children:[s.jsx("span",{className:"coll-entry-title",children:O.title||O.entry_uid}),s.jsx("span",{className:"coll-entry-kind muted",children:O.source_kind})]}),s.jsxs("div",{className:"coll-entry-actions",children:[s.jsx("select",{className:"coll-entry-vis-select",value:O.collection_visibility_bits,onChange:K=>Et(O.entry_uid,Number(K.target.value)),children:Or.map(K=>s.jsx("option",{value:K.value,children:K.label},K.value))}),!ye&&s.jsx("button",{className:"coll-entry-remove",onClick:()=>vt(O.entry_uid),title:"Remove from collection",children:"×"})]})]},O.entry_uid))}))]}),!ye&&s.jsxs("form",{className:"coll-add-entry-form",onSubmit:rt,children:[s.jsx("div",{className:"coll-section-heading",children:"Add entry"}),s.jsxs("div",{className:"coll-add-entry-row",children:[s.jsx("input",{className:"coll-add-entry-input",type:"text",value:q,onChange:O=>F(O.target.value),placeholder:"entry_uid",required:!0}),s.jsx("select",{className:"coll-vis-select",value:X,onChange:O=>Z(Number(O.target.value)),children:Or.map(O=>s.jsx("option",{value:O.value,children:O.label},O.value))}),s.jsx("button",{className:"coll-add-btn",type:"submit",disabled:ue,children:ue?"…":"Add"})]}),ge&&s.jsx("div",{className:"collections-error",style:{marginTop:4},children:ge})]})]}):s.jsx("div",{className:"coll-detail coll-detail--empty",children:s.jsx("div",{className:"muted",children:"Select a collection to view details."})})]}),s.jsxs("details",{className:"coll-create-details",children:[s.jsx("summary",{children:"+ Create collection"}),s.jsxs("form",{className:"coll-create-form",onSubmit:Ye,children:[s.jsxs("div",{className:"form-field",children:[s.jsx("label",{className:"form-label",htmlFor:"coll-name",children:"Name"}),s.jsx("input",{className:"capture-input",id:"coll-name",type:"text",value:S,onChange:O=>{N(O.target.value),p||f(O.target.value.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,""))},placeholder:"My Collection",required:!0})]}),s.jsxs("div",{className:"form-field",children:[s.jsx("label",{className:"form-label",htmlFor:"coll-slug",children:"Slug"}),s.jsx("input",{className:"capture-input",id:"coll-slug",type:"text",value:p,onChange:O=>f(O.target.value),placeholder:"my-collection",required:!0})]}),s.jsxs("div",{className:"form-field",children:[s.jsx("label",{className:"form-label",htmlFor:"coll-vis",children:"Entries' default visibility"}),s.jsx("select",{className:"capture-input",id:"coll-vis",style:{height:42},value:m,onChange:O=>w(Number(O.target.value)),children:Or.map(O=>s.jsx("option",{value:O.value,children:O.label},O.value))})]}),s.jsx("div",{className:"form-field",children:s.jsxs("label",{className:"form-label",children:[s.jsx("input",{type:"checkbox",checked:b,onChange:O=>k(O.target.checked)})," Require authentication to view"]})}),T&&s.jsx("div",{className:"collections-error",children:T}),s.jsx("button",{className:"btn-primary",type:"submit",disabled:C,children:C?"Creating…":"Create collection"})]})]})]}):s.jsx("div",{className:"view-placeholder",children:"Select an archive."})}const Km=4;function qm({tab:e,onTabChange:t,archiveId:n}){const{currentUser:r,setCurrentUser:l}=d.useContext(As)??{},i=r&&(r.role_bits&Km)!==0,a=["profile","tokens",...i?["instance","cookies","extensions","storage"]:[]],o={profile:"Profile",tokens:"API Tokens",instance:"Instance",cookies:"Cookies",extensions:"Extensions",storage:"Storage"};return s.jsxs("section",{className:"admin-view",children:[s.jsx("h1",{children:"Settings"}),s.jsx("div",{className:"view-tabs",children:a.map(u=>s.jsx("button",{className:`view-tab${e===u?" is-active":""}`,onClick:()=>t(u),children:o[u]},u))}),e==="profile"&&s.jsx(Xm,{currentUser:r,setCurrentUser:l}),e==="tokens"&&s.jsx(Jm,{}),e==="instance"&&i&&s.jsx(Gm,{}),e==="cookies"&&i&&s.jsx(Zm,{}),e==="extensions"&&i&&s.jsx(eg,{}),e==="storage"&&i&&s.jsx(Ym,{archiveId:n})]})}function Xm({currentUser:e,setCurrentUser:t}){const[n,r]=d.useState((e==null?void 0:e.display_name)??""),[l,i]=d.useState(!1),[a,o]=d.useState(null),[u,c]=d.useState(""),[y,g]=d.useState(""),[h,v]=d.useState(""),[x,S]=d.useState(!1),[N,p]=d.useState(null);async function f(w){w.preventDefault(),i(!0),o(null);try{await em(n),t(C=>({...C,display_name:n||null})),o({ok:!0,text:"Saved."})}catch(C){o({ok:!1,text:C.message})}finally{i(!1)}}async function m(w){if(w.preventDefault(),y!==h){p({ok:!1,text:"Passwords do not match."});return}S(!0),p(null);try{await nm(u,y),c(""),g(""),v(""),p({ok:!0,text:"Password changed."})}catch(C){p({ok:!1,text:C.message})}finally{S(!1)}}return s.jsxs("div",{style:{maxWidth:440},children:[s.jsxs("div",{className:"form-section",children:[s.jsx("h2",{children:"Display Name"}),s.jsxs("form",{onSubmit:f,children:[s.jsxs("div",{className:"form-field",children:[s.jsx("label",{className:"form-label",htmlFor:"display-name",children:"Name shown in the UI"}),s.jsx("input",{className:"field-input",id:"display-name",placeholder:(e==null?void 0:e.username)??"",value:n,onChange:w=>r(w.target.value)})]}),a&&s.jsx("div",{className:`form-msg form-msg--${a.ok?"ok":"err"}`,children:a.text}),s.jsx("button",{className:"btn-primary",type:"submit",disabled:l,children:l?"Saving…":"Save"})]})]}),s.jsxs("div",{className:"form-section",children:[s.jsx("h2",{children:"Display Preferences"}),s.jsxs("label",{className:"checkbox-row",children:[s.jsx("input",{type:"checkbox",checked:(e==null?void 0:e.humanize_slugs)??!1,onChange:async w=>{const C=w.target.checked;try{await tm({humanize_slugs:C}),t(E=>({...E,humanize_slugs:C}))}catch{}}}),s.jsx("span",{className:"form-label",style:{margin:0},children:"Humanize tag display"})]}),s.jsx("p",{className:"muted",style:{fontSize:13,margin:"4px 0 0"},children:'When on, tag paths show as "X / Articles" instead of "x/articles".'})]}),s.jsxs("div",{className:"form-section",children:[s.jsx("h2",{children:"Change Password"}),s.jsxs("form",{onSubmit:m,children:[s.jsxs("div",{className:"form-field",children:[s.jsx("label",{className:"form-label",htmlFor:"cur-pw",children:"Current password"}),s.jsx("input",{className:"field-input",id:"cur-pw",type:"password",value:u,onChange:w=>c(w.target.value),required:!0})]}),s.jsxs("div",{className:"form-field",children:[s.jsx("label",{className:"form-label",htmlFor:"new-pw",children:"New password"}),s.jsx("input",{className:"field-input",id:"new-pw",type:"password",value:y,onChange:w=>g(w.target.value),required:!0})]}),s.jsxs("div",{className:"form-field",children:[s.jsx("label",{className:"form-label",htmlFor:"confirm-pw",children:"Confirm new password"}),s.jsx("input",{className:"field-input",id:"confirm-pw",type:"password",value:h,onChange:w=>v(w.target.value),required:!0})]}),N&&s.jsx("div",{className:`form-msg form-msg--${N.ok?"ok":"err"}`,children:N.text}),s.jsx("button",{className:"btn-primary",type:"submit",disabled:x,children:x?"Changing…":"Change Password"})]})]})]})}function Jm(){const[e,t]=d.useState([]),[n,r]=d.useState(!0),[l,i]=d.useState(null),[a,o]=d.useState(""),[u,c]=d.useState(!1),[y,g]=d.useState(null),h=d.useCallback(async()=>{r(!0),i(null);try{t(await rm())}catch(S){i(S.message)}finally{r(!1)}},[]);d.useEffect(()=>{h()},[h]);async function v(S){if(S.preventDefault(),!!a.trim()){c(!0);try{const N=await lm(a.trim());g(N),o(""),h()}catch(N){i(N.message)}finally{c(!1)}}}async function x(S){try{await sm(S),t(N=>N.filter(p=>p.token_uid!==S))}catch(N){i(N.message)}}return s.jsx("div",{style:{maxWidth:600},children:s.jsxs("div",{className:"form-section",children:[s.jsx("h2",{children:"API Tokens"}),y&&s.jsxs("div",{className:"token-banner",children:[s.jsx("strong",{children:"Token created."})," Copy it now — it won't be shown again.",s.jsx("code",{children:y.raw_token}),s.jsx("button",{className:"token-dismiss",onClick:()=>g(null),children:"Dismiss"})]}),s.jsxs("form",{className:"token-create-row",onSubmit:v,children:[s.jsx("input",{className:"field-input field-input--flex",placeholder:"Token name",value:a,onChange:S=>o(S.target.value),required:!0}),s.jsx("button",{className:"btn-primary",type:"submit",disabled:u,children:u?"Creating…":"Create token"})]}),l&&s.jsx("div",{className:"form-msg form-msg--err",children:l}),n?s.jsx("div",{className:"muted",children:"Loading…"}):s.jsxs("div",{children:[e.length===0&&s.jsx("div",{className:"muted",children:"No tokens yet."}),e.map(S=>s.jsxs("div",{className:"token-row",children:[s.jsxs("div",{className:"token-row-info",children:[s.jsx("strong",{children:S.name}),s.jsxs("div",{className:"muted",children:["Created ",S.created_at.slice(0,10),S.last_used_at&&` · Last used ${S.last_used_at.slice(0,10)}`]})]}),s.jsx("button",{className:"btn-danger",style:{fontSize:12,padding:"4px 10px"},onClick:()=>x(S.token_uid),children:"Revoke"})]},S.token_uid))]})]})})}function Gm(){const[e,t]=d.useState(null),[n,r]=d.useState(!0),[l,i]=d.useState(null),[a,o]=d.useState(!1),[u,c]=d.useState(null);d.useEffect(()=>{(async()=>{try{t(await so())}catch(g){i(g.message)}finally{r(!1)}})()},[]);async function y(g){g.preventDefault(),o(!0),c(null);try{await ql(e),c({ok:!0,text:"Saved."})}catch(h){c({ok:!1,text:h.message})}finally{o(!1)}}return n?s.jsx("div",{className:"muted",children:"Loading…"}):l?s.jsx("div",{className:"form-msg form-msg--err",children:l}):e?s.jsx("div",{style:{maxWidth:440},children:s.jsxs("div",{className:"form-section",children:[s.jsx("h2",{children:"Instance Settings"}),s.jsxs("form",{onSubmit:y,children:[[["public_index_enabled","Public index (unauthenticated browsing)"],["public_entry_content_enabled","Public entry content"],["open_registration_enabled","Open registration"]].map(([g,h])=>s.jsxs("label",{className:"checkbox-row",children:[s.jsx("input",{type:"checkbox",checked:!!e[g],onChange:v=>t(x=>({...x,[g]:v.target.checked}))}),h]},g)),s.jsxs("div",{className:"form-field",style:{marginTop:4},children:[s.jsx("label",{className:"form-label",children:"Default entry visibility"}),s.jsxs("select",{className:"field-input",value:e.default_entry_visibility,onChange:g=>t(h=>({...h,default_entry_visibility:Number(g.target.value)})),children:[s.jsx("option",{value:0,children:"Private"}),s.jsx("option",{value:2,children:"Unlisted"}),s.jsx("option",{value:3,children:"Public"})]})]}),u&&s.jsx("div",{className:`form-msg form-msg--${u.ok?"ok":"err"}`,children:u.text}),s.jsx("button",{className:"btn-primary",type:"submit",disabled:a,children:a?"Saving…":"Save Settings"})]})]})}):null}function mi(e){if(e===0)return"0 B";const t=["B","KB","MB","GB","TB"],n=Math.floor(Math.log(e)/Math.log(1024));return`${(e/Math.pow(1024,n)).toFixed(n===0?0:1)} ${t[n]}`}function Ym({archiveId:e}){const[t,n]=d.useState("idle"),[r,l]=d.useState(null),[i,a]=d.useState(null),[o,u]=d.useState(null);function c(){n("idle"),l(null),a(null),u(null)}async function y(){n("scanning"),u(null),l(null);try{const v=await gm(e);l(v),n("scanned")}catch(v){u(v.message),n("error")}}async function g(){n("deleting"),u(null);try{const v=await ym(e);a(v),n("done")}catch(v){u(v.message),n("error")}}const h=r&&r.deletable_files===0&&r.orphaned_blob_rows===0;return s.jsx("div",{style:{maxWidth:440},children:s.jsxs("div",{className:"form-section",children:[s.jsx("h2",{children:"Orphan Cleanup"}),s.jsxs("p",{className:"muted",style:{marginBottom:16},children:["Scan for blob files and database records that are no longer referenced by any archive entry and safely delete them."," ",s.jsx("strong",{children:"Cleanup is blocked while captures are running."})]}),!e&&s.jsx("div",{className:"muted",children:"No archive selected."}),e&&t==="idle"&&s.jsx("button",{className:"btn-ghost",onClick:y,children:"Scan for orphaned blobs"}),t==="scanning"&&s.jsx("div",{className:"muted",children:"Scanning…"}),t==="scanned"&&r&&h&&s.jsxs(s.Fragment,{children:[s.jsx("div",{className:"form-msg form-msg--ok",children:"Archive is clean — nothing to remove."}),s.jsx("button",{className:"btn-ghost",style:{marginTop:10},onClick:c,children:"Done"})]}),t==="scanned"&&r&&!h&&s.jsxs("div",{children:[s.jsxs("div",{style:{marginBottom:14,lineHeight:1.6},children:["Found ",s.jsx("strong",{children:r.deletable_files})," unreferenced file",r.deletable_files!==1?"s":""," ","and ",s.jsx("strong",{children:r.orphaned_blob_rows})," orphaned DB record",r.orphaned_blob_rows!==1?"s":""," ","— ",s.jsx("strong",{children:mi(r.total_bytes)})," recoverable."]}),s.jsxs("div",{style:{display:"flex",gap:8},children:[s.jsxs("button",{className:"btn-danger",onClick:g,children:["Delete (",mi(r.total_bytes),")"]}),s.jsx("button",{className:"btn-ghost",onClick:c,children:"Cancel"})]})]}),t==="deleting"&&s.jsx("div",{className:"muted",children:"Deleting…"}),t==="done"&&i&&s.jsxs("div",{children:[s.jsxs("div",{className:"form-msg form-msg--ok",children:["Freed ",s.jsx("strong",{children:mi(i.freed_bytes)})," ","— removed ",i.deleted_files," file",i.deleted_files!==1?"s":""," ","and ",i.deleted_blob_rows," DB record",i.deleted_blob_rows!==1?"s":"","."]}),i.errors&&i.errors.length>0&&s.jsxs("div",{className:"form-msg form-msg--err",style:{marginTop:6},children:[i.errors.length," file",i.errors.length!==1?"s":""," could not be deleted."]}),s.jsx("button",{className:"btn-ghost",style:{marginTop:10},onClick:c,children:"Scan again"})]}),t==="error"&&s.jsxs("div",{children:[s.jsx("div",{className:"form-msg form-msg--err",children:o}),s.jsx("button",{className:"btn-ghost",style:{marginTop:10},onClick:c,children:"Try again"})]})]})})}function Zm(){const[e,t]=d.useState(null),[n,r]=d.useState(!0),[l,i]=d.useState(null),[a,o]=d.useState("global"),[u,c]=d.useState(""),[y,g]=d.useState("{}"),[h,v]=d.useState(null),[x,S]=d.useState(!1),[N,p]=d.useState({});d.useEffect(()=>{f()},[]);async function f(){r(!0),i(null);try{t(await wm())}catch(k){i(k.message)}finally{r(!1)}}async function m(k){k.preventDefault();try{JSON.parse(y)}catch{v({ok:!1,text:'cookies must be valid JSON, e.g. {"session": "abc"}'});return}S(!0),v(null);try{await km(a==="global"?null:u.trim(),a,y),c(""),g("{}"),o("global"),v({ok:!0,text:"Rule added."}),await f()}catch(T){v({ok:!1,text:T.message})}finally{S(!1)}}async function w(k){try{await Sm(k),await f()}catch(T){i(T.message)}}function C(k){p(T=>({...T,[k.rule_uid]:{cookiesInput:k.cookies_json,saving:!1,msg:null}}))}function E(k){p(T=>{const L={...T};return delete L[k],L})}async function b(k){const T=N[k.rule_uid];try{JSON.parse(T.cookiesInput)}catch{p(L=>({...L,[k.rule_uid]:{...L[k.rule_uid],msg:{ok:!1,text:"Invalid JSON"}}}));return}p(L=>({...L,[k.rule_uid]:{...L[k.rule_uid],saving:!0,msg:null}}));try{await jm(k.rule_uid,{cookies_json:T.cookiesInput}),E(k.rule_uid),await f()}catch(L){p(q=>({...q,[k.rule_uid]:{...q[k.rule_uid],saving:!1,msg:{ok:!1,text:L.message}}}))}}return n?s.jsx("div",{className:"muted",children:"Loading…"}):l?s.jsx("div",{className:"form-msg form-msg--err",children:l}):s.jsx("div",{style:{maxWidth:560},children:s.jsxs("div",{className:"form-section",children:[s.jsx("h2",{children:"Cookie Rules"}),s.jsx("p",{className:"muted",style:{marginBottom:12},children:"Cookies are injected into every capture network request (yt-dlp, HTTP downloads, web-page snapshots). Global rules apply to all URLs; wildcard and regex rules apply only to matching URLs."}),e&&e.length>0?s.jsxs("table",{className:"data-table",style:{width:"100%",marginBottom:16},children:[s.jsx("thead",{children:s.jsxs("tr",{children:[s.jsx("th",{children:"Pattern"}),s.jsx("th",{children:"Cookies"}),s.jsx("th",{style:{width:100},children:"Actions"})]})}),s.jsx("tbody",{children:e.map(k=>{const T=N[k.rule_uid],L=k.url_pattern?s.jsxs(s.Fragment,{children:[s.jsxs("span",{className:"muted",children:[k.pattern_kind,":"]})," ",s.jsx("code",{children:k.url_pattern})]}):s.jsx("span",{className:"muted",children:"global (all URLs)"});return s.jsxs("tr",{children:[s.jsx("td",{children:L}),s.jsx("td",{children:T?s.jsxs(s.Fragment,{children:[s.jsx("textarea",{className:"field-input",style:{fontFamily:"monospace",fontSize:12,width:"100%",minHeight:60},value:T.cookiesInput,onChange:q=>p(F=>({...F,[k.rule_uid]:{...F[k.rule_uid],cookiesInput:q.target.value}}))}),T.msg&&s.jsx("div",{className:`form-msg form-msg--${T.msg.ok?"ok":"err"}`,children:T.msg.text}),s.jsxs("div",{style:{display:"flex",gap:8,marginTop:4},children:[s.jsx("button",{className:"btn-primary",style:{fontSize:12,padding:"2px 8px"},disabled:T.saving,onClick:()=>b(k),children:T.saving?"Saving…":"Save"}),s.jsx("button",{className:"btn-secondary",style:{fontSize:12,padding:"2px 8px"},onClick:()=>E(k.rule_uid),children:"Cancel"})]})]}):s.jsx("code",{style:{fontSize:12,wordBreak:"break-all"},children:k.cookies_json})}),s.jsx("td",{children:!T&&s.jsxs("div",{style:{display:"flex",gap:6},children:[s.jsx("button",{className:"btn-secondary",style:{fontSize:12,padding:"2px 8px"},onClick:()=>C(k),children:"Edit"}),s.jsx("button",{className:"btn-danger",style:{fontSize:12,padding:"2px 8px"},onClick:()=>w(k.rule_uid),children:"Del"})]})})]},k.rule_uid)})})]}):s.jsx("p",{className:"muted",style:{marginBottom:16},children:"No cookie rules defined."}),s.jsx("h3",{style:{marginBottom:8},children:"Add Rule"}),s.jsxs("form",{onSubmit:m,children:[s.jsxs("div",{className:"form-field",children:[s.jsx("label",{className:"form-label",children:"Pattern type"}),s.jsxs("select",{className:"field-input",value:a,onChange:k=>o(k.target.value),children:[s.jsx("option",{value:"global",children:"Global (all URLs)"}),s.jsx("option",{value:"wildcard",children:"Wildcard (e.g. *.youtube.com)"}),s.jsx("option",{value:"regex",children:"Regex (matched against full URL)"})]})]}),a!=="global"&&s.jsxs("div",{className:"form-field",children:[s.jsx("label",{className:"form-label",children:a==="wildcard"?"URL/hostname pattern":"Regex pattern"}),s.jsx("input",{className:"field-input",type:"text",value:u,onChange:k=>c(k.target.value),placeholder:a==="wildcard"?"*.youtube.com or https://example.com/*":".*\\.youtube\\.com.*",required:!0})]}),s.jsxs("div",{className:"form-field",children:[s.jsx("label",{className:"form-label",children:"Cookies (JSON object)"}),s.jsx("textarea",{className:"field-input",style:{fontFamily:"monospace",fontSize:13,minHeight:70},value:y,onChange:k=>g(k.target.value),placeholder:'{"SESSION": "abc123", "token": "xyz"}',required:!0})]}),h&&s.jsx("div",{className:`form-msg form-msg--${h.ok?"ok":"err"}`,children:h.text}),s.jsx("button",{className:"btn-primary",type:"submit",disabled:x,children:x?"Adding…":"Add Rule"})]})]})})}function eg(){const[e,t]=d.useState(null),[n,r]=d.useState(!0),[l,i]=d.useState(!1),[a,o]=d.useState(null);d.useEffect(()=>{(async()=>{try{t(await so())}catch(N){o({ok:!1,text:N.message})}finally{r(!1)}})()},[]);async function u(N){i(!0),o(null);try{await ql({ublock_enabled:N}),t(p=>({...p,ublock_enabled:N})),o({ok:!0,text:"Saved."})}catch(p){o({ok:!1,text:p.message})}finally{i(!1)}}async function c(N){i(!0),o(null);try{await ql({cookie_ext_enabled:N}),t(p=>({...p,cookie_ext_enabled:N})),o({ok:!0,text:"Saved."})}catch(p){o({ok:!1,text:p.message})}finally{i(!1)}}async function y(N){i(!0),o(null);try{await ql({modal_closer_enabled:N}),t(p=>({...p,modal_closer_enabled:N})),o({ok:!0,text:"Saved."})}catch(p){o({ok:!1,text:p.message})}finally{i(!1)}}if(n)return s.jsx("div",{className:"muted",children:"Loading\\u2026"});const g=(e==null?void 0:e.ublock_ext_available)??!1,h=(e==null?void 0:e.ublock_enabled)??!0,v=(e==null?void 0:e.cookie_ext_available)??!1,x=(e==null?void 0:e.cookie_ext_enabled)??!0,S=(e==null?void 0:e.modal_closer_enabled)??!0;return s.jsx("div",{children:s.jsxs("div",{className:"form-section",children:[s.jsx("h2",{children:"Extensions"}),s.jsx("p",{className:"form-hint",style:{marginBottom:20},children:"Extensions run inside the browser during WebPage captures and can block ads, accept cookie banners, and more. Changes take effect on the next capture."}),s.jsxs("div",{className:"ext-grid",children:[s.jsx("div",{className:"ext-card",children:s.jsxs("div",{className:"ext-card-header",children:[s.jsxs("div",{className:"ext-card-info",children:[s.jsx("span",{className:"ext-card-name",children:"uBlock Origin Lite"}),s.jsx("span",{className:"ext-card-desc",children:"Blocks ads, trackers, and other page clutter during archiving via Chrome’s declarativeNetRequest API (Manifest V3)."}),!g&&s.jsxs("span",{className:"ext-card-hint",children:["Not configured — set ",s.jsx("code",{children:"ARCHIVR_UBLOCK_EXT"})," to the unpacked extension directory to enable."]})]}),s.jsx("button",{type:"button",role:"switch","aria-checked":h,className:`ext-toggle${h?" ext-toggle--on":""}`,onClick:()=>u(!h),disabled:l,"aria-label":"Toggle uBlock Origin Lite",children:s.jsx("span",{className:"ext-toggle-knob"})})]})}),s.jsx("div",{className:"ext-card",children:s.jsxs("div",{className:"ext-card-header",children:[s.jsxs("div",{className:"ext-card-info",children:[s.jsx("span",{className:"ext-card-name",children:"I Still Don’t Care About Cookies"}),s.jsx("span",{className:"ext-card-desc",children:"Dismiss cookie consent banners during archiving."}),!v&&s.jsxs("span",{className:"ext-card-hint",children:["Not configured — set ",s.jsx("code",{children:"ARCHIVR_COOKIE_EXT"})," to the unpacked extension directory to enable."]})]}),s.jsx("button",{type:"button",role:"switch","aria-checked":x,className:`ext-toggle${x?" ext-toggle--on":""}`,onClick:()=>c(!x),disabled:l,"aria-label":"Toggle I Still Don't Care About Cookies",children:s.jsx("span",{className:"ext-toggle-knob"})})]})}),s.jsx("div",{className:"ext-card",children:s.jsxs("div",{className:"ext-card-header",children:[s.jsxs("div",{className:"ext-card-info",children:[s.jsx("span",{className:"ext-card-name",children:"Modal & Dialog Closer"}),s.jsx("span",{className:"ext-card-desc",children:"Auto-dismiss cookie banners, consent overlays, and other modal dialogs before a WebPage capture is taken. Implemented as an injected browser script; no external extension required."})]}),s.jsx("button",{type:"button",role:"switch","aria-checked":S,className:`ext-toggle${S?" ext-toggle--on":""}`,onClick:()=>y(!S),disabled:l,"aria-label":"Toggle Modal and Dialog Closer",children:s.jsx("span",{className:"ext-toggle-knob"})})]})})]}),a&&s.jsx("div",{className:`form-msg form-msg--${a.ok?"ok":"err"}`,children:a.text})]})})}const Pu={0:"Private",1:"Public",2:"Users only",3:"Public"},tg=()=>s.jsx("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:s.jsx("path",{d:"M7 17 17 7M9 7h8v8"})});function ng({archiveId:e,selectedEntry:t,selectedUids:n,selectedEntries:r,detail:l,onTagFilterSet:i,tagNodes:a,onTagsRefresh:o,onEntryTitleChange:u,onEntryDeleted:c,onBulkDeleted:y,humanizeTags:g,onDetailRefresh:h,onOpenPreview:v,onPlay:x,isPublicSession:S}){var gl;const[N,p]=d.useState([]),[f,m]=d.useState(""),[w,C]=d.useState([]),[E,b]=d.useState(""),k=d.useRef(0),T=d.useRef(!1),[L,q]=d.useState(!1),[F,X]=d.useState(""),[Z,ue]=d.useState("idle"),[Ae,ge]=d.useState(""),D=d.useRef(null),[P,H]=d.useState(!1);d.useEffect(()=>{H(!1)},[(gl=l==null?void 0:l.summary)==null?void 0:gl.entry_uid]);const te=(n==null?void 0:n.size)>=2,[ee,Re]=d.useState(""),[Y,ye]=d.useState("idle"),[ce,Ee]=d.useState(""),[Ye,Ct]=d.useState([]),[yt,Ht]=d.useState(""),[ut,rt]=d.useState("idle"),[vt,Et]=d.useState(""),[O,K]=d.useState("idle"),[de,Be]=d.useState(""),[Dt,ze]=d.useState("idle"),[Fn,Nn]=d.useState("");d.useEffect(()=>{const z=++k.current;if(D.current&&(clearInterval(D.current),D.current=null),ue("idle"),ge(""),!t||!e){p([]),C([]);return}if(S){p([]),C([]);return}q(!1),X(""),T.current=!1,p([]),Promise.all([Il(e,t.entry_uid),Cu(e,t.entry_uid)]).then(([re,be])=>{z===k.current&&(p(re),C(be))}).catch(()=>{})},[t,e,S]),d.useEffect(()=>()=>{clearInterval(D.current)},[]),d.useEffect(()=>{if(!e){Ct([]);return}io(e).then(Ct).catch(()=>Ct([]))},[e]),d.useEffect(()=>{Re(""),ye("idle"),Ee(""),Ht(""),rt("idle"),Et(""),K("idle"),Be(""),ze("idle"),Nn("")},[n]);async function tn(){const z=n.size;if(!window.confirm(`Delete ${z} entr${z===1?"y":"ies"}? This cannot be undone.`))return;K("running");const re=new Set;for(const be of n)try{await _u(e,be),re.add(be)}catch{}K("idle"),y==null||y(re)}async function Ve(){const z=ee.trim();if(z){ye("running"),Ee("");try{for(const re of n)await Nu(e,re,z);Re(""),ye("done"),o==null||o(),setTimeout(()=>ye("idle"),1800)}catch(re){Ee(re.message),ye("error")}}}async function wr(){if(!yt)return;rt("running"),Et("");const z=[],re=Ye.find(be=>be.collection_uid===yt);for(const be of n)try{await ca(e,yt,be,(re==null?void 0:re.default_visibility_bits)??2)}catch{z.push(be)}z.length>0?(Et(`Failed for ${z.length} entr${z.length===1?"y":"ies"}.`),rt("error")):(rt("done"),setTimeout(()=>rt("idle"),1800))}async function Bn(){if(!de||!t)return;ze("running"),Nn("");const z=Ye.find(re=>re.collection_uid===de);try{await ca(e,de,t.entry_uid,(z==null?void 0:z.default_visibility_bits)??2),ze("done"),Be("");const re=await Cu(e,t.entry_uid);C(re),setTimeout(()=>ze("idle"),1800)}catch(re){Nn(re.message),ze("error")}}async function j(){const z=F.trim()||null;try{await Fh(e,t.entry_uid,z),u==null||u(t.entry_uid,z)}catch{}finally{q(!1)}}async function B(){const z=f.trim();if(!(!z||!t))try{await Nu(e,t.entry_uid,z),m(""),b("");const re=await Il(e,t.entry_uid);p(re),o()}catch(re){b(re.message)}}async function M(z){try{await Bh(e,t.entry_uid,z);const re=await Il(e,t.entry_uid);p(re),o()}catch{}}async function A(){if(!(!t||!e)&&window.confirm("Delete this entry? This cannot be undone."))try{await _u(e,t.entry_uid),c==null||c(t.entry_uid)}catch{}}async function W(){if(!t||!e||Z==="running")return;const z=k.current,re=t.entry_uid;ue("running"),ge("");try{const{job_uid:be}=await vm(e,re);if(k.current!==z)return;D.current=setInterval(async()=>{try{const $t=await Xd(e,be);if($t.status==="completed"){if(clearInterval(D.current),D.current=null,k.current!==z)return;ue("done");const Wt=await Il(e,re);if(k.current!==z)return;p(Wt),h==null||h()}else if($t.status==="failed"){if(clearInterval(D.current),D.current=null,k.current!==z)return;ue("error"),ge($t.error_text||"Re-archive failed.")}}catch{if(clearInterval(D.current),D.current=null,k.current!==z)return;ue("error"),ge("Network error while polling.")}},500)}catch(be){if(k.current!==z)return;ue("error"),ge(be.message||"Failed to start re-archive.")}}const V=l?[["Added",ao(l.summary.archived_at)],["Source",l.summary.source_kind],["Type",l.summary.entity_kind],["Visibility",Pu[l.summary.visibility]??l.summary.visibility],["Root",l.structured_root_relpath]]:[],pe=new Set(["mp3","ogg","m4a","opus","wav","flac","aac"]),ae=new Set(["mp4","webm","mov","mkv","avi","m4v","ogv","pdf","html","htm","jpg","jpeg","png","gif","webp","avif","svg","bmp"]),xe=l?l.artifacts.findIndex(z=>z.artifact_role==="primary_media"):-1,ie=xe>=0?l.artifacts[xe]:null,Qe=ie?ie.relpath.split(".").pop().toLowerCase():"",we=ie&&pe.has(Qe),Un=xe>=0&&t?`/api/archives/${e}/entries/${t.entry_uid}/artifacts/${xe}`:null,xt=l&&!we&&(l.summary.entity_kind==="tweet"||l.summary.entity_kind==="tweet_thread"||ie&&ae.has(Qe));return s.jsxs("aside",{className:"context-rail",children:[s.jsx("div",{className:"rail-eyebrow",children:"Context"}),te?S?s.jsxs("p",{className:"bulk-count",children:[s.jsx("span",{className:"bulk-count-num",children:n.size})," entries selected"]}):s.jsxs("div",{className:"bulk-panel",children:[s.jsxs("p",{className:"bulk-count",children:[s.jsx("span",{className:"bulk-count-num",children:n.size})," entries selected"]}),s.jsxs("div",{className:"rail-section",children:[s.jsx("div",{className:"rail-section-heading",children:"Assign tag"}),ce&&s.jsx("p",{className:"form-msg form-msg--err",style:{margin:"0 0 8px"},children:ce}),s.jsxs("div",{className:"tag-input-wrap",children:[s.jsx("span",{className:"hash",children:"/"}),s.jsx("input",{className:"tag-input",type:"text",placeholder:"science/cs",autoComplete:"off",value:ee,onChange:z=>Re(z.target.value),onKeyDown:z=>{z.key==="Enter"&&Ve()}}),s.jsx("button",{className:"tag-add-btn",onClick:Ve,disabled:Y==="running"||!ee.trim(),children:Y==="running"?"…":Y==="done"?"✓":"Add"})]})]}),Ye.length>0&&s.jsxs("div",{className:"rail-section",children:[s.jsx("div",{className:"rail-section-heading",children:"Add to collection"}),s.jsxs("div",{className:"bulk-coll-row",children:[s.jsxs("select",{className:"bulk-coll-select",value:yt,onChange:z=>Ht(z.target.value),children:[s.jsx("option",{value:"",children:"Pick a collection…"}),Ye.filter(z=>z.slug!=="_default_").map(z=>s.jsx("option",{value:z.collection_uid,children:z.name},z.collection_uid))]}),s.jsx("button",{className:"tag-add-btn",onClick:wr,disabled:!yt||ut==="running",children:ut==="running"?"…":ut==="done"?"✓":ut==="error"?"!":"Add"})]}),vt&&s.jsx("p",{className:"form-msg form-msg--err",style:{margin:"6px 0 0"},children:vt})]}),s.jsx("div",{className:"rail-delete-zone",children:s.jsx("button",{className:"rail-delete-btn",onClick:tn,disabled:O==="running",children:O==="running"?"Deleting…":`Delete ${n.size} entr${n.size===1?"y":"ies"}`})})]}):t?l?s.jsxs(s.Fragment,{children:[S?s.jsx("h2",{className:"rail-title",children:dt(l.summary.title)||dt(l.summary.entry_uid)}):L?s.jsx("input",{className:"rail-title-input",autoFocus:!0,value:F,onChange:z=>X(z.target.value),onKeyDown:z=>{z.key==="Enter"&&z.currentTarget.blur(),z.key==="Escape"&&(T.current=!0,z.currentTarget.blur())},onBlur:()=>{T.current?q(!1):j(),T.current=!1}}):s.jsxs("h2",{className:"rail-title rail-title--editable",title:"Click to rename",onClick:()=>{X(l.summary.title??""),q(!0)},children:[dt(l.summary.title)||dt(l.summary.entry_uid),s.jsx("svg",{className:"edit-icon",viewBox:"0 0 16 16",fill:"none",stroke:"currentColor",strokeWidth:"1.5",strokeLinecap:"round",strokeLinejoin:"round","aria-hidden":"true",children:s.jsx("path",{d:"M11.5 2.5a1.5 1.5 0 0 1 2 2L5 13l-3 1 1-3 8.5-8.5z"})})]}),l.summary.original_url&&s.jsxs("a",{className:"url-tile",href:l.summary.original_url,target:"_blank",rel:"noopener noreferrer",children:[s.jsx("span",{className:"ico",dangerouslySetInnerHTML:{__html:Os(l.summary.source_kind)}}),s.jsx("span",{className:"u-text",children:l.summary.original_url}),s.jsx("span",{className:"ext",children:s.jsx(tg,{})})]}),we&&x&&s.jsx("button",{className:"rail-preview-btn",onClick:()=>x(Un,t),children:"▶ Play"}),xt&&v&&s.jsx("button",{className:"rail-preview-btn",onClick:v,children:"Preview"}),s.jsx("div",{className:"meta-list",children:V.filter(([,z])=>z!=null&&z!=="").map(([z,re])=>s.jsxs("div",{className:"meta-item",children:[s.jsx("span",{className:"meta-k",children:z}),s.jsx("span",{className:`meta-v${z==="Root"?" mono":""}`,children:dt(re)})]},z))}),l.artifacts.length>0&&(()=>{const z=l.artifacts.map((Ie,jr)=>({...Ie,_idx:jr})),re=z.filter(Ie=>Ie.artifact_role==="font"),be=z.filter(Ie=>Ie.artifact_role!=="font"),$t=re.reduce((Ie,jr)=>Ie+(jr.byte_size||0),0),Wt=l.summary.entry_uid,kr=Ie=>s.jsx("li",{children:s.jsxs("a",{href:`/api/archives/${e}/entries/${Wt}/artifacts/${Ie._idx}`,target:"_blank",rel:"noopener noreferrer",className:"artifact-link",children:[s.jsx("span",{className:"artifact-name",children:Ie.artifact_role==="font"?Ie.relpath.split("/").pop():Ie.artifact_role.replace(/_/g," ")}),s.jsx("span",{className:"artifact-size",children:Ie.byte_size!=null?cl(Ie.byte_size):"—"})]})},Ie._idx);return s.jsxs("div",{className:"rail-section",children:[s.jsxs("div",{className:"rail-section-heading",children:["Artifacts ",s.jsx("span",{className:"num",children:l.artifacts.length})]}),s.jsxs("ul",{className:"artifact-list",children:[be.map(kr),re.length>0&&s.jsxs("li",{className:"artifact-group",children:[s.jsxs("button",{type:"button",className:"artifact-group-header artifact-link","aria-expanded":P,onClick:()=>H(Ie=>!Ie),children:[s.jsxs("span",{className:"artifact-name",children:[s.jsx("span",{"aria-hidden":"true",className:`artifact-group-chevron${P?" open":""}`,children:"›"}),` fonts (${re.length})`]}),s.jsx("span",{className:"artifact-size",children:cl($t)})]}),P&&s.jsx("ul",{className:"artifact-list artifact-group-body",children:re.map(kr)})]})]})]})})()]}):s.jsx("p",{className:"tags-empty",children:"Loading\\u2026"}):s.jsx("p",{className:"tags-empty",children:"Select an entry."}),t&&!te&&!S&&s.jsxs(s.Fragment,{children:[s.jsxs("div",{className:"rail-section",children:[s.jsx("div",{className:"rail-section-heading",children:"Tags"}),N.length===0?s.jsx("p",{className:"tags-empty",children:"No tags yet."}):s.jsx("div",{className:"tags-wrap",children:N.map(z=>s.jsxs("span",{className:"tag-pill",title:z.full_path,children:[g?Gd(z.full_path):z.full_path,s.jsx("button",{className:"remove",title:`Remove tag ${z.full_path}`,onClick:()=>M(z.tag_uid),children:"×"})]},z.tag_uid))}),E&&s.jsx("p",{className:"form-msg form-msg--err",style:{margin:"0 0 8px"},children:E}),s.jsxs("div",{className:"tag-input-wrap",children:[s.jsx("span",{className:"hash",children:"/"}),s.jsx("input",{className:"tag-input",type:"text",placeholder:"science/cs",autoComplete:"off",value:f,onChange:z=>m(z.target.value),onKeyDown:z=>{z.key==="Enter"&&B()}}),s.jsx("button",{className:"tag-add-btn",onClick:B,children:"Add"})]})]}),(w.length>0||Ye.filter(z=>z.slug!=="_default_").length>0)&&s.jsxs("div",{className:"rail-section",children:[s.jsx("div",{className:"rail-section-heading",children:"Collections"}),w.map(z=>s.jsxs("div",{className:"coll-row",children:[s.jsx("span",{className:"coll-name",children:z.name}),s.jsx("span",{className:"vis-badge",children:Pu[z.visibility_bits]??`bits:${z.visibility_bits}`})]},z.collection_uid)),Ye.filter(z=>z.slug!=="_default_").length>0&&s.jsxs("div",{className:"bulk-coll-row",style:{marginTop:8},children:[s.jsxs("select",{className:"bulk-coll-select",value:de,onChange:z=>Be(z.target.value),children:[s.jsx("option",{value:"",children:"Add to collection…"}),Ye.filter(z=>z.slug!=="_default_").map(z=>s.jsx("option",{value:z.collection_uid,children:z.name},z.collection_uid))]}),s.jsx("button",{className:"tag-add-btn",onClick:Bn,disabled:!de||Dt==="running",children:Dt==="running"?"…":Dt==="done"?"✓":Dt==="error"?"!":"Add"})]}),Fn&&s.jsx("p",{className:"form-msg form-msg--err",style:{margin:"4px 0 0"},children:Fn})]}),l&&(l.summary.entity_kind==="tweet"||l.summary.entity_kind==="tweet_thread")&&s.jsxs("div",{className:"rail-section",children:[s.jsx("div",{className:"rail-section-heading",children:"Actions"}),s.jsx("button",{className:"rail-rearchive-btn",onClick:W,disabled:Z==="running",children:Z==="running"?"Re-archiving…":"Re-archive"}),Z==="done"&&s.jsx("p",{className:"form-msg form-msg--ok",style:{marginTop:"6px"},children:"Re-archived successfully."}),Z==="error"&&s.jsx("p",{className:"form-msg form-msg--err",style:{marginTop:"6px"},children:Ae})]}),s.jsx("div",{className:"rail-delete-zone",children:s.jsx("button",{className:"rail-delete-btn",onClick:A,children:"Delete entry"})})]})]})}const rg=/\/api\/archives\/([^/]+)\/entries\/([^/]+)\/artifacts\/(\d+)/;function lg(){return s.jsxs("svg",{width:"18",height:"18",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round","aria-hidden":"true",children:[s.jsx("path",{d:"M5 17H3a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h18a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-2"}),s.jsx("polygon",{points:"12 15 17 21 7 21 12 15"})]})}function sg({src:e,contentType:t="video/mp4"}){const n=d.useRef(null),[r,l]=d.useState(null),[i,a]=d.useState(!1),[o,u]=d.useState(!1);d.useEffect(()=>{if(!e){l(null);return}const g=e.match(rg);if(!g){l(e);return}let h=!1;return l(null),xm(g[1],g[2],parseInt(g[3],10)).then(({url:v})=>{h||l(v)}).catch(()=>{h||l(e)}),()=>{h=!0}},[e]),d.useEffect(()=>{n.current&&n.current.load()},[r]),d.useEffect(()=>{u(typeof HTMLVideoElement<"u"&&"webkitShowPlaybackTargetPicker"in HTMLVideoElement.prototype)},[]),d.useEffect(()=>{var h;const g=v=>{if(v)try{cast.framework.CastContext.getInstance().setOptions({receiverApplicationId:chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID,autoJoinPolicy:chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED}),a(!0)}catch{}};if((h=window.cast)!=null&&h.framework)g(!0);else if(window.__onGCastApiAvailable=g,!document.querySelector('script[src*="cast_sender"]')){const v=document.createElement("script");v.src="https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1",document.head.appendChild(v)}},[]),d.useEffect(()=>{if(!i||!r)return;const g=cast.framework.CastContext.getInstance(),{CastContextEventType:h,SessionState:v}=cast.framework,x=N=>{if(!N)return;const p=new chrome.cast.media.MediaInfo(window.location.origin+r,t);N.loadMedia(new chrome.cast.media.LoadRequest(p)).catch(()=>{})};x(g.getCurrentSession());const S=N=>{(N.sessionState===v.SESSION_STARTED||N.sessionState===v.SESSION_RESUMED)&&x(g.getCurrentSession())};return g.addEventListener(h.SESSION_STATE_CHANGED,S),()=>g.removeEventListener(h.SESSION_STATE_CHANGED,S)},[i,r,t]);const c=()=>{var g,h;(h=(g=n.current)==null?void 0:g.webkitShowPlaybackTargetPicker)==null||h.call(g)},y=i||o;return s.jsx("div",{className:"preview-video-wrap",style:{position:"relative"},children:e?s.jsxs(s.Fragment,{children:[r?s.jsxs("video",{ref:n,controls:!0,autoPlay:!1,playsInline:!0,"x-webkit-airplay":"allow",style:{width:"100%",maxHeight:"100%",display:"block"},children:[s.jsx("source",{src:r}),"Your browser does not support the video element."]}):s.jsx("div",{className:"video-tv-loading","aria-label":"Loading video…"}),y&&r&&s.jsxs("div",{className:"video-tv-controls",children:[o&&s.jsx("button",{className:"video-tv-btn",title:"AirPlay to device","aria-label":"AirPlay to device",onClick:c,children:s.jsx(lg,{})}),i&&s.jsx("google-cast-launcher",{className:"video-tv-btn",title:"Cast to TV"})]})]}):s.jsx("span",{style:{color:"var(--muted)",fontFamily:"var(--sans)",fontSize:"0.9rem"},children:"No video available"})})}function Lu({src:e,type:t,title:n,originalUrl:r}){const l=r||e,i=n||null;return s.jsxs("div",{className:"preview-iframe-wrap",style:{flex:1,minHeight:0,display:"flex",flexDirection:"column"},children:[s.jsxs("div",{className:"preview-iframe-toolbar",style:{display:"flex",flexDirection:"column",gap:"2px",padding:"6px 12px",borderBottom:"1px solid var(--line-soft)",background:"var(--paper-2)",flexShrink:0},children:[i&&s.jsx("span",{style:{fontSize:"0.85rem",fontWeight:600,color:"var(--ink)",fontFamily:"var(--sans)",overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"},children:i}),s.jsxs("div",{style:{display:"flex",alignItems:"center",gap:"8px"},children:[s.jsx("span",{style:{flex:1,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap",fontSize:"0.78rem",color:"var(--muted)",fontFamily:"var(--sans)"},children:l}),s.jsx("a",{href:e,target:"_blank",rel:"noreferrer noopener",style:{fontSize:"0.78rem",color:"var(--accent)",textDecoration:"none",whiteSpace:"nowrap",fontFamily:"var(--sans)",flexShrink:0},children:t==="pdf"?"Open PDF ↗":"Open in new tab ↗"})]})]}),s.jsx("iframe",{src:e,sandbox:"allow-same-origin allow-popups",allow:"autoplay 'none'",referrerPolicy:"no-referrer",style:{flex:1,border:"none",width:"100%",minHeight:0},title:i||(t==="pdf"?"PDF preview":"Page preview")})]})}function ig({src:e,alt:t}){return s.jsx("div",{className:"preview-image-wrap",style:{display:"flex",alignItems:"center",justifyContent:"center",width:"100%",height:"100%",background:"var(--paper-2)",overflow:"hidden"},children:s.jsx("a",{href:e,target:"_blank",rel:"noreferrer noopener",style:{display:"contents"},children:s.jsx("img",{src:e,alt:t||"",style:{objectFit:"contain",maxHeight:"100%",maxWidth:"100%",display:"block",cursor:"pointer"}})})})}function tf(e){return e?new Intl.DateTimeFormat("en-US",{month:"short",day:"numeric",year:"numeric"}).format(new Date(e*1e3)):""}function Ru(e){return!e||!e.includes("&")?e:e.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,'"').replace(/'/g,"'").replace(/'/g,"'")}const G={card:{background:"var(--paper)",border:"1px solid var(--line)",borderRadius:"12px",overflow:"hidden",maxWidth:"560px",margin:"0 auto",fontFamily:'var(--sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif)'},threadOuter:{border:"1px solid var(--line)",borderRadius:"12px",overflow:"hidden",maxWidth:"560px",margin:"0 auto",background:"var(--paper)",fontFamily:'var(--sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif)'},tweetRow:{display:"flex",gap:"10px",padding:"10px 12px"},tweetRowThread:{display:"flex",gap:"10px",padding:"10px 12px 0"},leftCol:{display:"flex",flexDirection:"column",alignItems:"center",flexShrink:0,width:"36px"},rightCol:{flex:1,minWidth:0,paddingBottom:"8px"},avatar:{width:"36px",height:"36px",borderRadius:"50%",objectFit:"cover",flexShrink:0,display:"block"},avatarPh:{width:"36px",height:"36px",borderRadius:"50%",background:"var(--line)",flexShrink:0},threadLine:{flex:1,width:"2px",background:"var(--line-soft, var(--line))",margin:"4px 0",minHeight:"12px",borderRadius:"1px"},authorRow:{display:"flex",alignItems:"baseline",gap:"4px",flexWrap:"wrap",marginBottom:"4px",lineHeight:"1.3"},authorName:{fontWeight:"700",fontSize:"14px",color:"var(--ink)"},authorHandle:{fontSize:"13px",color:"var(--muted)"},datePart:{fontSize:"13px",color:"var(--muted)"},tweetText:{fontSize:"14px",lineHeight:"1.5",color:"var(--ink)",whiteSpace:"pre-line",marginBottom:"8px",wordBreak:"break-word"},stats:{display:"flex",gap:"12px",fontSize:"13px",color:"var(--muted)"},link:{color:"var(--accent)",textDecoration:"none"},loading:{padding:"24px 16px",textAlign:"center",color:"var(--muted)",fontSize:"14px"},error:{padding:"24px 16px",textAlign:"center",color:"var(--alert)",fontSize:"14px"},mediaGrid:{marginBottom:"8px",borderRadius:"10px",overflow:"hidden",border:"1px solid var(--line)"},mediaImg:{display:"block",width:"100%",objectFit:"cover",maxHeight:"260px"},mediaVideo:{display:"block",width:"100%",maxHeight:"260px",background:"#000"},article:{maxWidth:"560px",margin:"0 auto",border:"1px solid var(--line)",borderRadius:"12px",overflow:"hidden",background:"var(--paper)",fontFamily:'var(--sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif)'},aCover:{width:"100%",display:"block",objectFit:"cover",maxHeight:"280px"},aMeta:{padding:"10px 14px 0"},aTweetTitle:{fontSize:"20px",fontWeight:"800",letterSpacing:"-0.3px",color:"var(--ink)",lineHeight:"1.3",marginBottom:"8px"},aAuthorRow:{display:"flex",alignItems:"center",gap:"8px",marginBottom:"8px"},aAvatar:{width:"36px",height:"36px",borderRadius:"50%",objectFit:"cover",flexShrink:0,display:"block"},aAvatarPh:{width:"36px",height:"36px",borderRadius:"50%",background:"var(--line)",flexShrink:0},aAuthorName:{fontSize:"14px",fontWeight:"700",color:"var(--ink)",lineHeight:"1.3"},aAuthorSub:{fontSize:"13px",color:"var(--muted)",lineHeight:"1.3"},aDivider:{border:"none",borderTop:"1px solid var(--line)",margin:"0"},aBody:{padding:"4px 14px 16px"},bH1:{fontSize:"22px",fontWeight:"800",letterSpacing:"-0.4px",color:"var(--ink)",lineHeight:"1.25",margin:"16px 0 6px"},bH2:{fontSize:"18px",fontWeight:"700",letterSpacing:"-0.2px",color:"var(--ink)",lineHeight:"1.3",margin:"14px 0 4px"},bP:{fontSize:"15px",color:"var(--ink)",lineHeight:"1.65",marginBottom:"12px",marginTop:"0"},bSpacer:{height:"4px",display:"block"},bQuote:{borderLeft:"3px solid var(--line)",padding:"2px 12px",margin:"12px 0",color:"var(--muted)",fontSize:"15px",lineHeight:"1.6"},bHr:{border:"none",borderTop:"1px solid var(--line)",margin:"14px 0"},bImg:{width:"100%",display:"block",borderRadius:"8px",margin:"12px 0"},bUl:{margin:"8px 0 12px",paddingLeft:"24px"},bOl:{margin:"8px 0 12px",paddingLeft:"24px"},bLi:{fontSize:"15px",color:"var(--ink)",lineHeight:"1.6",marginBottom:"4px"},bTweet:{display:"flex",alignItems:"center",gap:"10px",border:"1px solid var(--line)",borderRadius:"10px",padding:"10px 14px",margin:"12px 0",color:"var(--muted)",fontSize:"14px",textDecoration:"none"},bMdPre:{borderRadius:"8px",margin:"10px 0",overflow:"auto",background:"var(--paper-3, var(--field))",padding:"12px 14px"},bMdCode:{fontFamily:"ui-monospace, 'Cascadia Code', 'Fira Code', Menlo, Consolas, monospace",fontSize:"12px",lineHeight:"1.6",color:"var(--ink)",background:"transparent",display:"block"},iCode:{fontFamily:"ui-monospace, 'Cascadia Code', Menlo, Consolas, monospace",fontSize:"0.875em",background:"var(--paper-3, var(--field))",padding:"1px 5px",borderRadius:"4px",color:"var(--ink)"},qtBadge:{fontSize:"11px",color:"var(--muted)",display:"inline-flex",alignItems:"center",gap:"2px",marginLeft:"4px",letterSpacing:"0.03em",flexShrink:0},lightboxBackdrop:{position:"fixed",inset:0,zIndex:500,background:"rgba(0,0,0,0.92)",display:"flex",alignItems:"center",justifyContent:"center",padding:"20px"},lightboxContent:{display:"flex",flexDirection:"column",alignItems:"center",gap:"10px",maxWidth:"90vw",maxHeight:"90vh"},lightboxToolbar:{display:"flex",alignItems:"center",gap:"10px",alignSelf:"flex-end"},lightboxImg:{maxWidth:"88vw",maxHeight:"80vh",objectFit:"contain",borderRadius:"6px",display:"block"},lightboxNav:{display:"flex",gap:"12px",alignItems:"center"},lightboxNavBtn:{background:"rgba(255,255,255,0.18)",border:"none",color:"#fff",fontSize:"20px",cursor:"pointer",borderRadius:"50%",width:"36px",height:"36px",display:"flex",alignItems:"center",justifyContent:"center",lineHeight:1,flexShrink:0},lightboxBtn:{background:"rgba(255,255,255,0.18)",border:"none",color:"#fff",fontSize:"16px",cursor:"pointer",borderRadius:"50%",width:"30px",height:"30px",display:"flex",alignItems:"center",justifyContent:"center",lineHeight:1,flexShrink:0},lightboxLink:{color:"rgba(255,255,255,0.7)",textDecoration:"none",fontSize:"14px"},lightboxCounter:{color:"rgba(255,255,255,0.6)",fontSize:"13px"}},De={bg:"#000000",border:"#2f3336",text:"#e7e9ea",dim:"#71767b",accent:"#1d9bf0",codeBg:"#16181c"},gi={article:{background:De.bg,color:De.text,minHeight:"100%",fontFamily:'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'},articleInner:{maxWidth:"598px",margin:"0 auto",paddingBottom:"80px"},aCover:{width:"100%",display:"block",objectFit:"cover",maxHeight:"420px"},aMeta:{padding:"20px 16px 0"},aTweetTitle:{fontSize:"34px",fontWeight:"800",letterSpacing:"-0.5px",color:De.text,lineHeight:"44px",marginBottom:"16px"},aAuthorRow:{display:"flex",alignItems:"center",gap:"12px",marginBottom:"16px"},aAvatar:{width:"40px",height:"40px",borderRadius:"50%",objectFit:"cover",flexShrink:0,display:"block"},aAvatarPh:{width:"40px",height:"40px",borderRadius:"50%",background:De.border,flexShrink:0},aAuthorName:{fontSize:"15px",fontWeight:"700",color:De.text,lineHeight:"1.3"},aAuthorSub:{fontSize:"15px",color:De.dim,lineHeight:"1.3"},aDivider:{border:"none",borderTop:`1px solid ${De.border}`,margin:"0"},aBody:{padding:"4px 16px 0"},bH1:{fontSize:"26px",fontWeight:"800",letterSpacing:"-0.4px",color:De.text,lineHeight:"36px",margin:"24px 0 10px"},bH2:{fontSize:"26px",fontWeight:"800",letterSpacing:"-0.4px",color:De.text,lineHeight:"36px",margin:"24px 0 10px"},bP:{fontSize:"17px",color:De.text,lineHeight:"1.5",marginBottom:"16px",marginTop:"0"},bSpacer:{height:"6px",display:"block"},bQuote:{borderLeft:`3px solid ${De.border}`,padding:"2px 14px",margin:"14px 0",color:De.dim,fontSize:"17px",lineHeight:"1.5"},bHr:{border:"none",borderTop:`1px solid ${De.border}`,margin:"28px 0"},bImg:{width:"100%",display:"block",borderRadius:"12px",margin:"16px 0"},bUl:{margin:"10px 0 16px",paddingLeft:"28px"},bOl:{margin:"10px 0 16px",paddingLeft:"28px"},bLi:{fontSize:"17px",color:De.text,lineHeight:"1.5",marginBottom:"6px"},bTweet:{display:"flex",alignItems:"center",gap:"12px",border:`1px solid ${De.border}`,borderRadius:"12px",padding:"14px 16px",margin:"14px 0",color:De.dim,fontSize:"15px",textDecoration:"none"},bMdPre:{borderRadius:"12px",margin:"12px 0",overflow:"auto",background:"#1e2029"},bMdCode:{fontFamily:"ui-monospace, 'Cascadia Code', 'Fira Code', Menlo, Consolas, monospace",fontSize:"13px",lineHeight:"1.65",padding:"18px 20px",display:"block",color:De.text,background:"transparent"},iCode:{fontFamily:"ui-monospace, 'Cascadia Code', Menlo, Consolas, monospace",fontSize:"0.875em",background:De.codeBg,padding:"2px 6px",borderRadius:"4px",color:"#e6edf3"},link:{color:De.accent,textDecoration:"none"}};function ag(e,t,n){const r={};return n&&n.forEach((l,i)=>{l.relpath&&(r[l.relpath]=`/api/archives/${e}/entries/${t}/artifacts/${i}`)}),r}function ur(e,t,n){return e&&n[e]?n[e]:t||null}function nf(){return s.jsx("svg",{width:"20",height:"20",viewBox:"0 0 24 24",fill:"currentColor","aria-hidden":"true",style:{flexShrink:0,color:"var(--ink)"},children:s.jsx("path",{d:"M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.748l7.73-8.835L1.254 2.25H8.08l4.259 5.63L18.244 2.25zm-1.161 17.52h1.833L7.084 4.126H5.117L17.083 19.77z"})})}function oo(e,t,...n){var r;if(e.fromIndex!=null&&e.toIndex!=null)return{s:e.fromIndex,e:e.toIndex};if(((r=e.indices)==null?void 0:r.length)===2)return{s:e.indices[0],e:e.indices[1]};if(t)for(const l of n){if(!l)continue;const i=t.indexOf(l);if(i!==-1)return{s:i,e:i+l.length}}return null}function rf(e,t){const n=e.expanded_url||e.url||e.text||"",r=e.display_url||e.expanded_url||e.url||e.text||"",l=oo(e,t,e.url,e.text,e.display_url,e.expanded_url);return!l||!n?null:{...l,kind:"url",href:n,display:r}}function lf(e,t){const n=e.screen_name||e.name||e.text||"",r=n?`@${n}`:null,l=oo(e,t,r);return!l||!n?null:{...l,kind:"mention",screen_name:n}}const zu=/https?:\/\/[^\s<>"'\])]+/g,og=/[.,;:!?)()]+$/;function ws(e,t){const n=[];let r=0,l;for(zu.lastIndex=0;(l=zu.exec(e))!==null;){l.index>r&&n.push(e.slice(r,l.index));let i=l[0].replace(og,"");n.push(s.jsx("a",{href:i,target:"_blank",rel:"noopener noreferrer",style:t,children:i},l.index));const a=l[0].slice(i.length);a&&n.push(a),r=l.index+l[0].length}return r===0?e:(rrf(o,e)).filter(Boolean),...(t.user_mentions||[]).map(o=>lf(o,e)).filter(Boolean)];if(r.length===0&&n.length===0)return ws(Ru(e),G.link);const l=new Set([0,e.length]);for(const o of r)o.s>=0&&o.s<=e.length&&l.add(o.s),o.e>=0&&o.e<=e.length&&l.add(o.e);for(const[o,u]of n)o>=0&&o<=e.length&&l.add(o),u>=0&&u<=e.length&&l.add(u);const i=(o,u)=>n.some(([c,y])=>c<=o&&y>=u),a=[...l].sort((o,u)=>o-u);return a.slice(0,-1).map((o,u)=>{const c=a[u+1];if(i(o,c))return null;const y=e.slice(o,c),g=r.filter(x=>x.s<=o&&x.e>=c),h=g.find(x=>x.kind==="url");if(h)return s.jsx("a",{href:h.href,target:"_blank",rel:"noopener noreferrer",style:G.link,children:h.display||y},u);const v=g.find(x=>x.kind==="mention");return v?s.jsx("a",{href:`https://x.com/${v.screen_name}`,target:"_blank",rel:"noopener noreferrer",style:G.link,children:y},u):s.jsx("span",{children:ws(Ru(y),G.link)},u)}).filter(o=>o!==null)}function cg(e,t,n,r,l=G){if(!e)return null;t=t||[],n=n||[],r=r||[];const i=[];for(const u of t)u.length>0&&i.push({s:u.offset,e:u.offset+u.length,kind:"style",style:u.style});for(const u of n){const c=rf(u,e);c&&i.push(c)}for(const u of r){const c=lf(u,e);c&&i.push(c)}if(i.length===0)return ws(e,l.link);const a=new Set([0,e.length]);for(const u of i)u.s>=0&&u.s<=e.length&&a.add(u.s),u.e>=0&&u.e<=e.length&&a.add(u.e);const o=[...a].sort((u,c)=>u-c);return o.slice(0,-1).map((u,c)=>{const y=o[c+1],g=i.filter(N=>N.s<=u&&N.e>=y),h=e.slice(u,y);let v;if(h.includes(` -`)){const N=h.split(` -`);v=N.flatMap((p,f)=>fN.kind==="style"&&N.style==="Code")&&(v=s.jsx("code",{style:l.iCode,children:v})),g.some(N=>N.kind==="style"&&N.style==="Bold")&&(v=s.jsx("strong",{children:v})),g.some(N=>N.kind==="style"&&N.style==="Italic")&&(v=s.jsx("em",{children:v})),g.some(N=>N.kind==="style"&&N.style==="Underline")&&(v=s.jsx("u",{children:v})),g.some(N=>N.kind==="style"&&N.style==="Strikethrough")&&(v=s.jsx("s",{children:v}));const x=g.find(N=>N.kind==="url");if(x){const N=/^https?:\/\/t\.co\//i.test(v);v=s.jsx("a",{href:x.href,target:"_blank",rel:"noopener noreferrer",style:l.link,children:N?x.display:v})}const S=g.find(N=>N.kind==="mention");return S&&(v=s.jsx("a",{href:`https://x.com/${S.screen_name}`,target:"_blank",rel:"noopener noreferrer",style:l.link,children:v})),s.jsx("span",{children:typeof v=="string"?ws(v,l.link):v},c)})}function dg(e,t,n){const r=(n==null?void 0:n.st)||G,l=e.resolved_entities||[];return l.length===0?null:l.map((i,a)=>{var o;switch(i.type){case"divider":return s.jsx("hr",{style:r.bHr},a);case"media":{const u=ur(i.local_path,i.url,t);return u?s.jsx("a",{href:u,target:"_blank",rel:"noopener noreferrer",style:{display:"block",cursor:"zoom-in"},onClick:c=>{var y;!c.metaKey&&!c.ctrlKey&&(c.preventDefault(),(y=n==null?void 0:n.onImgClick)==null||y.call(n,u))},children:s.jsx("img",{src:u,style:r.bImg,loading:"lazy",alt:""})},a):null}case"tweet":return i.tweet_id?s.jsxs("a",{href:`https://x.com/i/status/${i.tweet_id}`,target:"_blank",rel:"noopener noreferrer",style:r.bTweet,children:[s.jsx(nf,{}),"View post on X"]},a):null;case"link":return i.url?s.jsx("p",{style:r.bP,children:s.jsx("a",{href:i.url,target:"_blank",rel:"noopener noreferrer",style:r.link,children:i.url})},a):null;case"markdown":{const u=i.markdown??((o=i.data)==null?void 0:o.markdown)??"";return s.jsx("pre",{style:r.bMdPre,children:s.jsx("code",{style:r.bMdCode,children:u})},a)}case"emoji":return i.url?s.jsx("img",{src:i.url,alt:"",style:{height:"1.2em",verticalAlign:"middle",margin:"0 1px"}},a):null;default:return null}})}function yi(e,t,n,r){const l=(r==null?void 0:r.st)||G,i=e.type||"",a=e.text||"",o=e.inline_style_ranges||[],u=e.data||{},c=cg(a,o,u.urls||[],u.mentions||[],l);switch(i){case"header-one":return s.jsx("h1",{style:l.bH1,children:c},t);case"header-two":{const y=a.match(/(?:x\.com|twitter\.com)\/i\/status\/(\d+)/);return y?s.jsxs("a",{href:`https://x.com/i/status/${y[1]}`,target:"_blank",rel:"noopener noreferrer",style:l.bTweet,children:[s.jsx(nf,{}),"View post on X"]},t):s.jsx("h2",{style:l.bH2,children:c},t)}case"unstyled":return a.trim()?s.jsx("p",{style:l.bP,children:c},t):s.jsx("span",{style:l.bSpacer},t);case"blockquote":return s.jsx("blockquote",{style:l.bQuote,children:c},t);case"unordered-list-item":case"ordered-list-item":return s.jsx("li",{style:l.bLi,children:c},t);case"atomic":return s.jsx("span",{children:dg(e,n,r)},t);default:return a?s.jsx("p",{style:l.bP,children:c},t):null}}function fg(e,t,n){const r=(n==null?void 0:n.st)||G,l=[];let i=0;for(;i{r&&(l==null||l(!0))},[r,l]);const o=r?gi:G,u=e.cover_media||{},c=e.author||t||{},y=e.first_published_at_secs?tf(e.first_published_at_secs):"",h=[c.screen_name?`@${c.screen_name}`:"",y].filter(Boolean).join(" · "),v=ur(u.local_path,u.url,n),x=ur(c.avatar_local_path,c.avatar_url,n),S=s.jsxs(s.Fragment,{children:[i&&s.jsx(sf,{items:[{src:i,alt:""}],startIndex:0,onClose:()=>a(null)}),v&&s.jsx("a",{href:v,target:"_blank",rel:"noopener noreferrer",style:{display:"block",cursor:"zoom-in"},onClick:N=>{!N.metaKey&&!N.ctrlKey&&(N.preventDefault(),a(v))},children:s.jsx("img",{src:v,style:o.aCover,alt:"Article cover"})}),s.jsxs("div",{style:o.aMeta,children:[e.title&&s.jsx("div",{style:o.aTweetTitle,children:e.title}),s.jsxs("div",{style:o.aAuthorRow,children:[x?s.jsx("img",{src:x,style:o.aAvatar,alt:c.name||""}):s.jsx("div",{style:o.aAvatarPh}),s.jsxs("div",{children:[s.jsx("div",{style:o.aAuthorName,children:c.name||c.screen_name||"Unknown"}),h&&s.jsx("div",{style:o.aAuthorSub,children:h})]})]})]}),s.jsx("hr",{style:o.aDivider}),s.jsx("div",{style:o.aBody,children:fg(e.blocks||[],n,{onImgClick:a,st:o})})]});return r?s.jsx("div",{style:gi.article,children:s.jsx("div",{style:gi.articleInner,children:S})}):s.jsx("div",{style:G.article,children:S})}function hg({photos:e,onOpen:t}){const n=e.length;if(n===0)return null;if(n===1)return s.jsx("a",{href:e[0].src,target:"_blank",rel:"noopener noreferrer",style:{display:"block"},onClick:l=>{!l.metaKey&&!l.ctrlKey&&(l.preventDefault(),t(0))},children:s.jsx("img",{src:e[0].src,alt:e[0].alt||"",style:G.mediaImg,loading:"lazy"})});const r=n<=2?"180px":"140px";return s.jsx("div",{style:{display:"grid",gridTemplateColumns:"1fr 1fr",gridTemplateRows:n===2?r:`${r} ${r}`,gap:"2px"},children:e.map((l,i)=>s.jsx("a",{href:l.src,target:"_blank",rel:"noopener noreferrer",style:{display:"block",overflow:"hidden",gridRow:n===3&&i===0?"span 2":void 0},onClick:a=>{!a.metaKey&&!a.ctrlKey&&(a.preventDefault(),t(i))},children:s.jsx("img",{src:l.src,alt:l.alt||"",loading:"lazy",style:{width:"100%",height:"100%",objectFit:"cover",display:"block"}})},i))})}function sf({items:e,startIndex:t,onClose:n}){const[r,l]=d.useState(t);d.useEffect(()=>{const a=o=>{(o.key==="Escape"||o.key==="ArrowLeft"||o.key==="ArrowRight")&&(o.stopPropagation(),o.preventDefault()),o.key==="Escape"&&n(),o.key==="ArrowRight"&&l(u=>Math.min(u+1,e.length-1)),o.key==="ArrowLeft"&&l(u=>Math.max(u-1,0))};return document.addEventListener("keydown",a),()=>document.removeEventListener("keydown",a)},[n,e.length]);const i=e[r];return s.jsx("div",{style:G.lightboxBackdrop,onClick:n,children:s.jsxs("div",{style:G.lightboxContent,onClick:a=>a.stopPropagation(),children:[s.jsxs("div",{style:G.lightboxToolbar,children:[e.length>1&&s.jsxs("span",{style:G.lightboxCounter,children:[r+1," / ",e.length]}),s.jsx("a",{href:i.src,target:"_blank",rel:"noopener noreferrer",style:G.lightboxLink,title:"Open in new tab",children:"↗"}),s.jsx("button",{style:G.lightboxBtn,onClick:n,"aria-label":"Close",children:"×"})]}),s.jsx("img",{src:i.src,alt:i.alt||"",style:G.lightboxImg}),e.length>1&&s.jsxs("div",{style:G.lightboxNav,children:[s.jsx("button",{style:G.lightboxNavBtn,onClick:()=>l(a=>Math.max(a-1,0)),disabled:r===0,children:"‹"}),s.jsx("button",{style:G.lightboxNavBtn,onClick:()=>l(a=>Math.min(a+1,e.length-1)),disabled:r===e.length-1,children:"›"})]})]})})}function Iu({tweet:e,isInThread:t,isLast:n,artifactMap:r}){var f,m;const[l,i]=d.useState(null),a=e.author||{},o=e.created_at_secs?tf(e.created_at_secs):"",u=e.entities||{},c=ur(a.avatar_local_path,a.avatar_url,r),y=t&&!n,g=e.is_quote_status===!0,h=t?G.tweetRowThread:G.tweetRow,v=e.full_text||"",x=((m=(f=e.extended_entities)==null?void 0:f.media)!=null&&m.length?e.extended_entities.media:u.media)||[],S=[],N=[];for(const w of x)if(w.type==="photo"){const C=ur(w.local_path,w.media_url_https,r);C&&S.push({kind:"photo",src:C,alt:w.alt_text||""})}else if(w.type==="video"||w.type==="animated_gif"){const C=w.local_path&&r[w.local_path]||(()=>{var b,k;return(k=(((b=w.video_info)==null?void 0:b.variants)||[]).filter(T=>T.content_type==="video/mp4").sort((T,L)=>(L.bitrate||0)-(T.bitrate||0))[0])==null?void 0:k.url})();C&&N.push({kind:w.type==="animated_gif"?"gif":"video",src:C})}const p=[];for(const w of x){if(!w.url||!(w.type==="photo"?S.some(b=>b.src===ur(w.local_path,w.media_url_https,r)):N.length>0))continue;const E=oo(w,v,w.url);E&&p.push([E.s,E.e])}return s.jsxs(s.Fragment,{children:[l!==null&&s.jsx(sf,{items:S,startIndex:l,onClose:()=>i(null)}),s.jsxs("div",{style:h,children:[s.jsxs("div",{style:G.leftCol,children:[c?s.jsx("img",{src:c,style:G.avatar,alt:a.name||""}):s.jsx("div",{style:G.avatarPh}),y&&s.jsx("div",{style:G.threadLine})]}),s.jsxs("div",{style:G.rightCol,children:[s.jsxs("div",{style:G.authorRow,children:[s.jsx("span",{style:G.authorName,children:a.name||a.screen_name||"Unknown"}),a.screen_name&&s.jsxs("span",{style:G.authorHandle,children:["@",a.screen_name]}),o&&s.jsxs("span",{style:G.datePart,children:["· ",o]}),g&&s.jsx("span",{style:G.qtBadge,title:"Quote tweet",children:"↻ QT"})]}),s.jsx("div",{style:G.tweetText,children:ug(v,u,p)}),S.length>0&&s.jsx("div",{style:G.mediaGrid,children:s.jsx(hg,{photos:S,onOpen:w=>i(w)})}),N.map((w,C)=>s.jsx("div",{style:G.mediaGrid,children:s.jsx("video",{src:w.src,style:G.mediaVideo,controls:!0,loop:w.kind==="gif",muted:w.kind==="gif",autoPlay:w.kind==="gif"})},C)),(e.retweet_count>0||e.favorite_count>0)&&s.jsxs("div",{style:G.stats,children:[e.favorite_count>0&&s.jsxs("span",{children:["❤️ ",e.favorite_count.toLocaleString()]}),e.retweet_count>0&&s.jsxs("span",{children:["🔁 ",e.retweet_count.toLocaleString()]})]})]})]})]})}function mg({archiveId:e,entryUid:t,artifacts:n,entityKind:r,fullPage:l,onXArticle:i}){const[a,o]=d.useState(!0),[u,c]=d.useState(null),[y,g]=d.useState([]);if(d.useEffect(()=>{if(o(!0),c(null),g([]),!n||!e||!t){o(!1);return}const x=n.map((N,p)=>({...N,index:p})).filter(N=>N.artifact_role==="raw_tweet_json");if(x.length===0){c("No tweet data found."),o(!1);return}let S=!1;return Ah(e,t,x.map(N=>N.index)).then(async N=>{if(S)return;const p=/https?:\/\/t\.co\/[A-Za-z0-9]+/g,f=b=>{var T;const k=b.full_text||"";return(((T=b.entities)==null?void 0:T.urls)||[]).map(L=>{var q;if(L.fromIndex!=null&&L.toIndex!=null)return[L.fromIndex,L.toIndex];if(((q=L.indices)==null?void 0:q.length)===2)return[L.indices[0],L.indices[1]];if(L.url){const F=k.indexOf(L.url);if(F!==-1)return[F,F+L.url.length]}return null}).filter(Boolean)},m=(b,k,T)=>b.some(([L,q])=>L<=k&&q>=T),w=new Set;for(const b of N){const k=b.full_text||"",T=f(b);let L;for(p.lastIndex=0;(L=p.exec(k))!==null;)m(T,L.index,L.index+L[0].length)||w.add(L[0])}const C=w.size>0?await Mh([...w]).catch(()=>({})):{},E=N.map(b=>{var F;const k=b.full_text||"",T=f(b),L=[];let q;for(p.lastIndex=0;(q=p.exec(k))!==null;){const X=q[0],Z=C[X];Z&&Z!==X&&!m(T,q.index,q.index+X.length)&&L.push({url:X,expanded_url:Z,display_url:(()=>{try{const ue=new URL(Z);return ue.hostname+(ue.pathname.length>1?"/…":"")}catch{return Z}})(),fromIndex:q.index,toIndex:q.index+X.length})}return L.length===0?b:{...b,entities:{...b.entities||{},urls:[...((F=b.entities)==null?void 0:F.urls)||[],...L]}}});S||g(E)}).catch(N=>{S||c(N.message||"Failed to load tweet.")}).finally(()=>{S||o(!1)}),()=>{S=!0}},[e,t,n]),a)return s.jsx("div",{style:G.loading,children:"Loading…"});if(u)return s.jsxs("div",{style:G.error,children:["Error: ",u]});if(y.length===0)return null;const h=ag(e,t,n);if(r==="tweet_thread")return s.jsx("div",{style:G.threadOuter,children:y.map((x,S)=>s.jsx(Iu,{tweet:x,isInThread:!0,isLast:S===y.length-1,artifactMap:h},x.id||S))});const v=y[0];return v.is_article&&v.article?s.jsx(pg,{article:v.article,tweetAuthor:v.author,artifactMap:h,fullPage:l,onXArticle:i}):s.jsx("div",{style:G.card,children:s.jsx(Iu,{tweet:v,isInThread:!1,isLast:!0,artifactMap:h})})}const gg=new Set(["mp4","webm","mov","mkv","avi","m4v","ogv"]),yg=new Set(["mp3","ogg","m4a","opus","wav","flac","aac"]),vg=new Set(["jpg","jpeg","png","gif","webp","avif","svg","bmp"]),xg={mp4:"video/mp4",webm:"video/webm",mov:"video/quicktime",mkv:"video/x-matroska",avi:"video/x-msvideo",m4v:"video/mp4",ogv:"video/ogg"};function af({archiveId:e,entry:t,detail:n,fullPage:r,onXArticle:l}){if(!t)return s.jsx("div",{className:"preview-panel preview-panel--empty",children:s.jsx("span",{style:{color:"var(--muted)",fontFamily:"var(--sans)",fontSize:"0.9rem"},children:"Select an entry to preview"})});if(!n)return s.jsx("div",{className:"preview-panel preview-panel--loading",children:s.jsx("span",{style:{color:"var(--muted)",fontFamily:"var(--sans)",fontSize:"0.9rem"},children:"Loading…"})});const{summary:i,artifacts:a}=n,o=i.entry_uid,u=i.entity_kind;if(u==="tweet"||u==="tweet_thread"){const v=s.jsx(mg,{archiveId:e,entryUid:o,artifacts:a,entityKind:u,fullPage:r,onXArticle:l});return r?v:s.jsx("div",{className:"preview-tweet-wrap",children:v})}const c=a.findIndex(v=>v.artifact_role==="primary_media");if(c===-1)return s.jsxs("div",{className:"preview-panel preview-panel--no-preview",children:[s.jsx("span",{style:{color:"var(--muted)",fontFamily:"var(--sans)",fontSize:"0.9rem"},children:"No preview available"}),a.length>0&&s.jsx("ul",{style:{marginTop:"12px",paddingLeft:"20px",fontSize:"0.8rem",color:"var(--muted-2)",fontFamily:"var(--sans)"},children:a.map((v,x)=>s.jsxs("li",{children:[v.artifact_role,": ",v.relpath]},x))})]});const y=a[c],g=`/api/archives/${e}/entries/${o}/artifacts/${c}`,h=y.relpath.split(".").pop().toLowerCase();return gg.has(h)?s.jsx("div",{className:"preview-panel",children:s.jsx(sg,{src:g,contentType:xg[h]||"video/mp4"})}):yg.has(h)?s.jsxs("div",{className:"preview-panel preview-panel--audio",style:{display:"flex",flexDirection:"column",alignItems:"center",justifyContent:"center",gap:"12px",padding:"24px",fontFamily:"var(--sans)"},children:[s.jsx("span",{style:{fontSize:"2rem"},children:"🎵"}),s.jsx("span",{style:{color:"var(--ink)",fontSize:"0.95rem",fontWeight:600},children:i.title||o}),s.jsx("audio",{src:g,controls:!0,style:{marginTop:"8px",width:"100%",maxWidth:"400px"}})]}):h==="pdf"?s.jsx("div",{className:"preview-panel",style:{flex:1,minHeight:0},children:s.jsx(Lu,{src:g,type:"pdf",title:i.title,originalUrl:i.original_url})}):h==="html"||h==="htm"?s.jsx("div",{className:"preview-panel",style:{flex:1,minHeight:0},children:s.jsx(Lu,{src:g,type:"page",title:i.title,originalUrl:i.original_url})}):vg.has(h)?s.jsx("div",{className:"preview-panel",style:{height:"100%"},children:s.jsx(ig,{src:g,alt:i.title||"Image"})}):s.jsxs("div",{className:"preview-panel preview-panel--no-preview",children:[s.jsx("span",{style:{color:"var(--muted)",fontFamily:"var(--sans)",fontSize:"0.9rem"},children:"No preview available"}),s.jsx("ul",{style:{marginTop:"12px",paddingLeft:"20px",fontSize:"0.8rem",color:"var(--muted-2)",fontFamily:"var(--sans)"},children:a.map((v,x)=>s.jsxs("li",{children:[v.artifact_role,": ",v.relpath]},x))})]})}function wg({archiveId:e,entry:t,detail:n,onClose:r}){var l,i;return d.useEffect(()=>{const a=o=>{o.key==="Escape"&&r()};return window.addEventListener("keydown",a),()=>window.removeEventListener("keydown",a)},[r]),s.jsx("div",{className:"preview-modal-backdrop",onClick:r,children:s.jsxs("div",{className:`preview-modal${((l=n==null?void 0:n.summary)==null?void 0:l.entity_kind)==="tweet"||((i=n==null?void 0:n.summary)==null?void 0:i.entity_kind)==="tweet_thread"?"":" preview-modal--full"}`,onClick:a=>a.stopPropagation(),children:[s.jsxs("div",{className:"preview-modal-header",children:[s.jsx("span",{className:"preview-modal-title",children:(t==null?void 0:t.title)||(t==null?void 0:t.entry_uid)||"Preview"}),s.jsx("a",{className:"preview-modal-newtab",href:`/preview/${e}/${t==null?void 0:t.entry_uid}`,target:"_blank",rel:"noopener noreferrer",title:"Open in new tab",children:"↗"}),s.jsx("button",{className:"preview-modal-close",onClick:r,"aria-label":"Close preview",children:"×"})]}),s.jsx("div",{className:"preview-modal-body",children:s.jsx(af,{archiveId:e,entry:t,detail:n})})]})})}function Du(e){if(!isFinite(e)||isNaN(e))return"--:--";const t=Math.floor(e/60),n=Math.floor(e%60);return`${t}:${String(n).padStart(2,"0")}`}function kg({entry:e,src:t,archiveId:n,onClose:r}){const l=d.useRef(null),[i,a]=d.useState(!1),[o,u]=d.useState(0),[c,y]=d.useState(NaN),[g,h]=d.useState(1);d.useEffect(()=>{!l.current||!t||(l.current.load(),u(0),y(NaN),a(!1))},[t]),d.useEffect(()=>{l.current&&(l.current.volume=g)},[g]);function v(){const f=l.current;f&&(i?f.pause():f.play().catch(()=>{}))}function x(f){const m=l.current;if(!m||!isFinite(c))return;const w=Number(f.target.value);m.currentTime=w,u(w)}function S(f){h(Number(f.target.value))}const N=(e==null?void 0:e.title)||(e==null?void 0:e.entry_uid)||"Unknown",p=(e==null?void 0:e.source_kind)||"other";return s.jsxs(s.Fragment,{children:[s.jsx("audio",{ref:l,src:t||void 0,preload:"auto",onPlay:()=>a(!0),onPause:()=>a(!1),onEnded:()=>a(!1),onTimeUpdate:()=>{var f;return u(((f=l.current)==null?void 0:f.currentTime)??0)},onLoadedMetadata:()=>{var f;return y(((f=l.current)==null?void 0:f.duration)??NaN)},onDurationChange:()=>{var f;return y(((f=l.current)==null?void 0:f.duration)??NaN)},style:{display:"none"}}),s.jsxs("div",{className:"audio-bar",style:{position:"fixed",bottom:0,left:0,right:0,zIndex:100,background:"var(--paper-3)",borderTop:"1px solid var(--line)",display:"flex",alignItems:"center",gap:"16px",padding:"0 16px",height:"56px",fontFamily:"var(--sans)"},children:[s.jsxs("div",{className:"audio-bar-info",style:{display:"flex",alignItems:"center",gap:"8px",minWidth:0,flex:"0 1 220px",overflow:"hidden"},children:[s.jsx("span",{className:"source-icon",style:{flexShrink:0,width:"18px",height:"18px",display:"flex",alignItems:"center"},dangerouslySetInnerHTML:{__html:Os(p)}}),s.jsx("span",{style:{overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap",fontSize:"0.85rem",color:"var(--ink)"},title:N,children:N})]}),s.jsxs("div",{className:"audio-bar-controls",style:{display:"flex",alignItems:"center",gap:"10px",flex:"1 1 0",minWidth:0},children:[s.jsx("button",{onClick:v,"aria-label":i?"Pause":"Play",style:{background:"none",border:"none",cursor:"pointer",padding:"4px",color:"var(--ink)",flexShrink:0,fontSize:"1.2rem",lineHeight:1},children:i?"⏸":"▶"}),s.jsx("span",{style:{fontSize:"0.75rem",color:"var(--muted)",whiteSpace:"nowrap",flexShrink:0},children:Du(o)}),s.jsx("input",{type:"range",min:0,max:isFinite(c)?c:0,step:.1,value:isFinite(o)?o:0,onChange:x,"aria-label":"Seek",style:{flex:1,minWidth:0,accentColor:"var(--accent)"}}),s.jsx("span",{style:{fontSize:"0.75rem",color:"var(--muted)",whiteSpace:"nowrap",flexShrink:0},children:Du(c)})]}),s.jsxs("div",{className:"audio-bar-right",style:{display:"flex",alignItems:"center",gap:"8px",flex:"0 1 160px"},children:[s.jsx("span",{style:{fontSize:"0.85rem",color:"var(--muted)",flexShrink:0},children:"🔊"}),s.jsx("input",{type:"range",min:0,max:1,step:.01,value:g,onChange:S,"aria-label":"Volume",style:{width:"80px",accentColor:"var(--accent)"}}),s.jsx("button",{onClick:r,"aria-label":"Close audio player",style:{background:"none",border:"none",cursor:"pointer",padding:"4px",color:"var(--muted)",fontSize:"1rem",lineHeight:1,marginLeft:"4px"},children:"✕"})]})]})]})}function jg({archiveId:e,entryUid:t}){var h,v;const[n,r]=d.useState(null),[l,i]=d.useState(!0),[a,o]=d.useState(null),[u,c]=d.useState(!1);d.useEffect(()=>{if(!u)return;const x=document.documentElement,S=document.body,N=x.style.colorScheme,p=S.style.background;return x.style.colorScheme="dark",S.style.background="#000",()=>{x.style.colorScheme=N,S.style.background=p}},[u]),d.useEffect(()=>{c(!1),$r(e,t).then(x=>{r(x),i(!1)}).catch(x=>{o((x==null?void 0:x.message)||"Failed to load entry"),i(!1)})},[e,t]);const y=((h=n==null?void 0:n.summary)==null?void 0:h.title)||t,g=(v=n==null?void 0:n.summary)==null?void 0:v.original_url;return s.jsxs("div",{style:{minHeight:"100vh",display:"flex",flexDirection:"column",background:u?"#000":"var(--paper)",fontFamily:"var(--sans)"},children:[s.jsxs("div",{style:{display:"flex",alignItems:"center",gap:"10px",padding:u?"8px 12px":"10px 16px",borderBottom:u?"none":"1px solid var(--line)",flexShrink:0,position:u?"sticky":"relative",top:0,zIndex:20,background:u?"rgba(0,0,0,0.82)":"var(--paper-2)",backdropFilter:u?"blur(12px)":"none",WebkitBackdropFilter:u?"blur(12px)":"none"},children:[s.jsx("a",{href:"/",style:{color:u?"#1d9bf0":"var(--accent)",textDecoration:"none",fontSize:"13px",flexShrink:0},children:"← Archive"}),s.jsx("span",{style:{flex:1,fontSize:"14px",fontWeight:600,color:u?"#e7e9ea":"var(--ink)",overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"},children:y}),g&&s.jsx("a",{href:g,target:"_blank",rel:"noopener noreferrer",style:{color:u?"#71767b":"var(--muted)",textDecoration:"none",fontSize:"13px",flexShrink:0},children:"Original ↗"})]}),s.jsxs("div",{style:u?{flex:1}:{flex:1,minHeight:0,overflow:"auto",display:"flex",flexDirection:"column"},children:[l&&s.jsx("div",{style:{padding:"48px",textAlign:"center",color:"var(--muted)",fontSize:"14px"},children:"Loading…"}),a&&s.jsx("div",{style:{padding:"48px",textAlign:"center",color:"var(--alert)",fontSize:"14px"},children:a}),n&&s.jsx(af,{archiveId:e,entry:n.summary,detail:n,fullPage:!0,onXArticle:c})]})]})}const Sg=7e3;function Ng({toasts:e,onDismiss:t,onIgnoreUblock:n}){return e.length?s.jsx("div",{className:"toast-stack",role:"log","aria-live":"polite","aria-label":"Notifications",children:e.map(r=>s.jsx(Cg,{toast:r,onDismiss:t,onIgnoreUblock:n},r.id))}):null}function _g(e){if(!e)return null;if(e.length<=52)return e;try{const{hostname:t,pathname:n,search:r}=new URL(e),l=n.split("/").filter(Boolean),i=(l[l.length-1]??"")+r,a=i?`${t}/…/${i}`:t;return a.length<=56?a:a.slice(0,53)+"…"}catch{return"…"+e.slice(-51)}}function Cg({toast:e,onDismiss:t,onIgnoreUblock:n}){const[r,l]=d.useState(!1),i=e.type==="warning",a=e.type==="success";d.useEffect(()=>{if(r)return;const u=setTimeout(()=>t(e.id),Sg);return()=>clearTimeout(u)},[r,e.id,t]);const o=_g(e.locator);return a?s.jsx("div",{className:"toast toast--success",role:"alert","aria-atomic":"true",children:s.jsxs("div",{className:"toast-top",children:[s.jsx("span",{className:"toast-icon","aria-hidden":"true",children:"✓"}),s.jsxs("div",{className:"toast-body",children:[s.jsx("span",{className:"toast-headline",children:e.headline||"Archived"}),o&&s.jsx("span",{className:"toast-locator",title:e.locator,children:o})]}),s.jsx("div",{className:"toast-btns",children:s.jsx("button",{type:"button",className:"toast-dismiss",onClick:()=>t(e.id),"aria-label":"Dismiss",children:"×"})})]})}):i?s.jsxs("div",{className:"toast toast--warning",role:"alert","aria-atomic":"true",children:[s.jsxs("div",{className:"toast-top",children:[s.jsx("span",{className:"toast-icon","aria-hidden":"true",children:"⚠"}),s.jsxs("div",{className:"toast-body",children:[s.jsx("span",{className:"toast-headline",children:e.headline||"Archived with warnings"}),o&&s.jsx("span",{className:"toast-locator",title:e.locator,children:o})]}),s.jsxs("div",{className:"toast-btns",children:[e.text&&s.jsx("button",{type:"button",className:"toast-view-btn",onClick:()=>l(u=>!u),"aria-expanded":r,children:r?"Hide":"Details"}),e.locator&&s.jsx("button",{type:"button",className:"toast-view-btn toast-ignore-btn",onClick:()=>{n==null||n(),t(e.id)},children:"Ignore"}),s.jsx("button",{type:"button",className:"toast-dismiss",onClick:()=>t(e.id),"aria-label":"Dismiss",children:"×"})]})]}),r&&s.jsx("p",{className:"toast-warning-detail",children:e.text||"The page was captured but one or more browser extensions were unavailable (ad-blocking or cookie-consent). Check ARCHIVR_UBLOCK_EXT and ARCHIVR_COOKIE_EXT in your server config."})]}):s.jsxs("div",{className:"toast toast--error",role:"alert","aria-atomic":"true",children:[s.jsxs("div",{className:"toast-top",children:[s.jsx("span",{className:"toast-icon","aria-hidden":"true",children:"✕"}),s.jsxs("div",{className:"toast-body",children:[s.jsx("span",{className:"toast-headline",children:e.headline||"Capture failed"}),o&&s.jsx("span",{className:"toast-locator",title:e.locator,children:o})]}),s.jsxs("div",{className:"toast-btns",children:[e.text&&s.jsx("button",{type:"button",className:"toast-view-btn",onClick:()=>l(u=>!u),children:r?"Hide":"View error"}),s.jsx("button",{type:"button",className:"toast-dismiss",onClick:()=>t(e.id),"aria-label":"Dismiss",children:"×"})]})]}),r&&e.text&&s.jsx("pre",{className:"toast-error-detail",children:e.text})]})}const As=d.createContext(null),Eg=["archive","tags","collections","runs","admin","settings"],bg=["profile","tokens","instance","cookies","extensions","storage"],Lr=(()=>{const e=window.location.pathname.match(/^\/preview\/([^/]+)\/([^/]+)/);return e?{archiveId:e[1],entryUid:e[2]}:null})();function At(){const e=window.location.pathname.split("/").filter(Boolean),t=Eg.includes(e[0])?e[0]:"archive",n=t==="settings"&&bg.includes(e[1])?e[1]:"profile",r=new URLSearchParams(window.location.search),l=r.get("q")??"",i=t==="archive"?r.get("tag")??null:null,a=t==="archive"?r.get("entry")??null:null,o=t==="archive"?r.get("collection")??null:null,u=o==="main"?null:o,c=r.get("archive")??null;return{view:t,settingsTab:n,q:l,tag:i,entry:a,collection:u,archive:c}}function Tg(e,t){return e==="archive"?"/":e==="settings"?`/settings/${t}`:`/${e}`}function Pg(){const[e,t]=d.useState("loading"),[n,r]=d.useState(null);d.useEffect(()=>{(async()=>{var $,Q,J;if(await Xh()){t("setup");return}const I=await Zh();if(!I){const{collection:le,archive:Te}=At();try{const Ke=await ju(),Ne=Te?(($=Ke.find(Ms=>Ms.id===Te))==null?void 0:$.id)??((Q=Ke[0])==null?void 0:Q.id):(J=Ke[0])==null?void 0:J.id;if(Ne){await Su(Ne,le),i(Ke),o(Ne),c(le),t("authenticated");return}}catch{}t("login");return}r(I),t("authenticated")})()},[]),d.useEffect(()=>{const _=()=>{n&&(r(null),t("login"))};return window.addEventListener("auth:expired",_),()=>window.removeEventListener("auth:expired",_)},[n]),d.useEffect(()=>{const _=()=>{const{view:I,settingsTab:$,q:Q,tag:J,entry:le,collection:Te}=At();T(I),q($),X(Q),b(J),p(le),m(null),C(le?new Set([le]):new Set),c(Te)};return window.addEventListener("popstate",_),()=>window.removeEventListener("popstate",_)},[]);const[l,i]=d.useState([]),[a,o]=d.useState(null),[u,c]=d.useState(()=>At().collection),[y,g]=d.useState([]),[h,v]=d.useState([]),[x,S]=d.useState(()=>new Set),[N,p]=d.useState(()=>At().entry),[f,m]=d.useState(null),[w,C]=d.useState(()=>{const _=At().entry;return _?new Set([_]):new Set}),[E,b]=d.useState(()=>At().tag),[k,T]=d.useState(()=>At().view),[L,q]=d.useState(()=>At().settingsTab),[F,X]=d.useState(()=>At().q),[Z,ue]=d.useState(""),[Ae,ge]=d.useState(!1),[D,P]=d.useState([]),[H,te]=d.useState([]),[ee,Re]=d.useState(()=>sessionStorage.getItem("captureDialogOpen")==="true"),[Y,ye]=d.useState([]),ce=d.useRef(0),[Ee,Ye]=d.useState(()=>{try{const _=JSON.parse(sessionStorage.getItem("pendingCaptures")||"[]");if(Array.isArray(_))return _}catch{}return[]});d.useEffect(()=>{sessionStorage.setItem("pendingCaptures",JSON.stringify(Ee))},[Ee]);const[Ct,yt]=d.useState(()=>sessionStorage.getItem("ublockWarningIgnored")==="true"),[Ht,ut]=d.useState(null),rt=d.useRef(0),vt=d.useRef(null),Et=d.useRef(!1),O=d.useRef(!0),K=d.useRef(null),de=d.useRef(0),Be=d.useRef(new Map),Dt=(n==null?void 0:n.humanize_slugs)??!1;d.useEffect(()=>{const _=++rt.current;ut(null),!(!f||!a)&&$r(a,f.entry_uid).then(I=>{_===rt.current&&ut(I)}).catch(()=>{})},[f,a]),d.useEffect(()=>{sessionStorage.setItem("captureDialogOpen",ee)},[ee]);const ze=d.useCallback(async(_,I,$,Q)=>{if(_){ge(!0);try{let J;I||$?J=await $h(_,I,$,Q):J=await Su(_,Q),v(J),C(le=>{if(le.size<2)return le;const Te=new Set(J.map(Ne=>Ne.entry_uid)),Ke=new Set([...le].filter(Ne=>Te.has(Ne)));return Ke.size===le.size?le:Ke}),ue(J.length===0?"No results":`${J.length} result${J.length===1?"":"s"}`)}catch{v([]),ue("Search failed. Try again.")}finally{ge(!1)}}},[]);d.useEffect(()=>{e==="authenticated"&&(a||ju().then(_=>{if(i(_),_.length>0){const{archive:I}=At(),$=I?_.find(Q=>Q.id===I):null;o(($==null?void 0:$.id)??_[0].id)}}))},[e,a]),d.useEffect(()=>{if(!a)return;const _=!n;if(O.current){O.current=!1;const $=[];_||($.push(fi(a).then(P)),$.push(Dl(a).then(te))),$.length&&Promise.all($);return}b(null),m(null),p(null),C(new Set),c(null);const I=[ze(a,"",null,null)];_||(I.push(fi(a).then(P)),I.push(Dl(a).then(te))),Promise.all(I)},[a,n]),d.useEffect(()=>{if(a===null)return;const _=setTimeout(()=>{ze(a,F,E,u)},300);return()=>clearTimeout(_)},[F,a,u]),d.useEffect(()=>{a!==null&&(E!==null&&T("archive"),ze(a,F,E,u))},[E,a,u]);const Fn=d.useCallback(_=>{o(_),c(null);const I=new URLSearchParams(window.location.search);I.set("archive",_),I.delete("collection");const $=I.toString();history.replaceState(null,"",window.location.pathname+($?"?"+$:""))},[]);d.useEffect(()=>{if(!a){g([]);return}io(a).then(g).catch(()=>g([]))},[a]);const Nn=d.useCallback(_=>{if(!n){const Q=_?y.find(J=>J.collection_uid===_):y.find(J=>J.slug==="_default_");if(Q!=null&&Q.requires_auth){t("login");return}}c(_),m(null),p(null),C(new Set),ze(a,F,E,_);const I=new URLSearchParams(window.location.search);_?(I.set("collection",_),I.set("archive",a)):(I.delete("collection"),a&&I.set("archive",a));const $=I.toString();history.replaceState(null,"",window.location.pathname+($?"?"+$:""))},[a,F,E,n,y,ze]),tn=d.useCallback(_=>{T(_),_==="tags"&&a&&Dl(a).then(te)},[a]);d.useEffect(()=>{if(Lr)return;const _=Tg(k,L);window.location.pathname!==_&&history.pushState(null,"",_+window.location.search)},[k,L]);const Ve=d.useCallback(_=>{p(_?_.entry_uid:null),m(_)},[]),wr=d.useCallback((_,I)=>{if(Be.current.set(_.entry_uid,_),++de.current,I.shiftKey&&K.current!==null){I.preventDefault();const $=[...document.querySelectorAll("#entries-body [data-entry-uid]")],Q=$.findIndex(Ne=>Ne.dataset.entryUid===K.current),J=$.findIndex(Ne=>Ne.dataset.entryUid===_.entry_uid);if(Q===-1||J===-1){K.current=_.entry_uid,C(new Set([_.entry_uid])),Ve(_);return}const le=Math.min(Q,J),Te=Math.max(Q,J),Ke=new Set($.slice(le,Te+1).map(Ne=>Ne.dataset.entryUid));C(Ke),Ke.size===1&&Ve(_)}else if(I.ctrlKey||I.metaKey){K.current=_.entry_uid;const $=new Set(w);if($.has(_.entry_uid)?$.delete(_.entry_uid):$.add(_.entry_uid),C($),$.size===0)Ve(null);else if($.size===1){const[Q]=$,J=Be.current.get(Q)??h.find(le=>le.entry_uid===Q)??null;if(J)Ve(J);else{const le=++de.current;$r(a,Q).then(Te=>{le===de.current&&(Te!=null&&Te.summary)&&Ve(Te.summary)}).catch(()=>{})}}else Ve(null)}else K.current=_.entry_uid,C(new Set([_.entry_uid])),Ve(_)},[h,w,Ve,a]),Bn=d.useCallback(_=>{b(_)},[]),j=d.useCallback(()=>{b(null)},[]),B=d.useCallback(()=>{a&&Dl(a).then(te)},[a]),M=d.useCallback((_,I)=>{E===_?b(I):E!=null&&E.startsWith(_+"/")&&b(I+E.slice(_.length))},[E]),A=d.useCallback(_=>{(E===_||E!=null&&E.startsWith(_+"/"))&&b(null)},[E]),W=d.useCallback((_,I)=>{v($=>$.map(Q=>Q.entry_uid===_?{...Q,title:I}:Q)),m($=>$&&$.entry_uid===_?{...$,title:I}:$),ut($=>$&&$.summary.entry_uid===_?{...$,summary:{...$.summary,title:I}}:$)},[]),V=d.useCallback(()=>{if(!a||!f)return;const _=++rt.current;$r(a,f.entry_uid).then(I=>{_===rt.current&&ut(I)}).catch(()=>{})},[a,f]),pe=d.useCallback(_=>{const I=h.some($=>$.entry_uid===_);S($=>{const Q=new Set($);return Q.add(_),Q}),v($=>$.filter(Q=>Q.entry_uid!==_)),m($=>($==null?void 0:$.entry_uid)===_?null:$),p($=>$===_?null:$),C($=>{const Q=new Set($);return Q.delete(_),Q}),I||ze(a,F,E,u)},[h,a,F,E,u,ze]),ae=d.useCallback(_=>{const I=new Set(h.map(Q=>Q.entry_uid)),$=[..._].some(Q=>!I.has(Q));S(Q=>{const J=new Set(Q);return _.forEach(le=>J.add(le)),J}),v(Q=>Q.filter(J=>!_.has(J.entry_uid))),C(new Set),m(null),p(null),$&&ze(a,F,E,u)},[h,a,F,E,u,ze]);d.useEffect(()=>{if(w.size>=2)p(null),m(null);else if(w.size===1){const[_]=w;p(_)}else p(null),m(null)},[w]);const xe=d.useMemo(()=>h.filter(_=>w.has(_.entry_uid)),[h,w]);d.useEffect(()=>{if(!N||f)return;const _=h.find(I=>I.entry_uid===N);_&&m(_)},[h,N,f]),d.useEffect(()=>{if(Lr)return;const _=new URLSearchParams(window.location.search).get("archive"),I=new URLSearchParams;F&&I.set("q",F),k==="archive"&&E&&I.set("tag",E),k==="archive"&&N&&I.set("entry",N),k==="archive"&&u?(I.set("collection",u),a&&I.set("archive",a)):_&&a&&I.set("archive",a);const $=I.toString(),Q=window.location.pathname+($?"?"+$:"");window.location.pathname+window.location.search!==Q&&history.replaceState(null,"",Q)},[F,E,N,u,a,k]),d.useEffect(()=>{const _=I=>{var le,Te,Ke,Ne;const $=I.key==="/"&&!I.metaKey&&!I.ctrlKey&&!I.altKey,Q=(I.metaKey||I.ctrlKey)&&I.key==="k";if(!$&&!Q)return;const J=(le=document.activeElement)==null?void 0:le.tagName;$&&(J==="INPUT"||J==="TEXTAREA"||J==="SELECT")||$&&((Te=document.activeElement)!=null&&Te.isContentEditable)||(I.preventDefault(),k==="archive"?((Ke=vt.current)==null||Ke.focus(),(Ne=vt.current)==null||Ne.select()):(Et.current=!0,T("archive")))};return document.addEventListener("keydown",_),()=>document.removeEventListener("keydown",_)},[k]),d.useEffect(()=>{const _=I=>{var co,fo;if(I.metaKey||I.ctrlKey||I.altKey||I.key!=="j"&&I.key!=="k")return;const $=(co=document.activeElement)==null?void 0:co.tagName;if($==="INPUT"||$==="TEXTAREA"||$==="SELECT"||(fo=document.activeElement)!=null&&fo.isContentEditable)return;const Q=[...document.querySelectorAll("#entries-body [data-entry-uid]")];if(Q.length===0)return;const[J]=w.size===1?w:[null],le=J?Q.findIndex(nn=>nn.dataset.entryUid===J):-1,Te=I.key==="j"?Math.min(le+1,Q.length-1):Math.max(le-1,0);if(Te===le&&le!==-1)return;const Ke=Q[Te<0?0:Te],Ne=Ke.dataset.entryUid;K.current=Ne;const Ms=++de.current;C(new Set([Ne]));const uo=Be.current.get(Ne)??h.find(nn=>nn.entry_uid===Ne)??null;uo?Ve(uo):$r(a,Ne).then(nn=>{Ms===de.current&&(nn!=null&&nn.summary)&&Ve(nn.summary)}).catch(()=>{}),Ke.scrollIntoView({block:"nearest"}),I.preventDefault()};return document.addEventListener("keydown",_),()=>document.removeEventListener("keydown",_)},[w,h,Ve,a]),d.useEffect(()=>{k==="archive"&&Et.current&&(Et.current=!1,requestAnimationFrame(()=>{var _,I;(_=vt.current)==null||_.focus(),(I=vt.current)==null||I.select()}))},[k]);const ie=d.useCallback(()=>{Re(!0)},[]),Qe=d.useCallback(()=>{Re(!1)},[]),we=d.useCallback(()=>{if(a)return Promise.allSettled([ze(a,F,E,u),fi(a).then(P)])},[a,F,E,u,ze]),Un=d.useCallback((_,I,$="error",Q=null)=>{if($==="warning"&&Ct&&I)return;const J=++ce.current;ye(le=>[...le,{id:J,text:_,locator:I,type:$,headline:Q}])},[Ct]),xt=d.useCallback(_=>{ye(I=>I.filter($=>$.id!==_))},[]),gl=d.useCallback(()=>{sessionStorage.setItem("ublockWarningIgnored","true"),yt(!0),ye(_=>_.filter(I=>!(I.type==="warning"&&I.locator)))},[]),z=d.useCallback(_=>{Ye(I=>[...I,_])},[]),re=d.useCallback(_=>{Ye(I=>I.filter($=>$.id!==_))},[]),[be,$t]=d.useState(null),[Wt,kr]=d.useState(null),Ie=d.useCallback(()=>{f&&$t(f.entry_uid)},[f]),jr=d.useCallback(()=>$t(null),[]),of=d.useCallback((_,I)=>{kr({src:_,entry:I})},[]),uf=d.useCallback(()=>kr(null),[]);return d.useEffect(()=>{$t(null)},[f]),d.useEffect(()=>{const _=I=>{var Q,J,le;if(I.key!=="Escape"||ee||be)return;const $=(Q=document.activeElement)==null?void 0:Q.tagName;$==="INPUT"||$==="TEXTAREA"||$==="SELECT"||w.size>0&&(I.preventDefault(),C(new Set),(le=(J=document.activeElement)==null?void 0:J.blur)==null||le.call(J))};return window.addEventListener("keydown",_),()=>window.removeEventListener("keydown",_)},[ee,be,w]),d.useEffect(()=>(document.body.classList.toggle("has-audio-bar",!!Wt),()=>document.body.classList.remove("has-audio-bar")),[Wt]),Lr?s.jsx(jg,{archiveId:Lr.archiveId,entryUid:Lr.entryUid}):e==="loading"?s.jsx("div",{className:"auth-loading",children:"Loading\\u2026"}):e==="setup"?s.jsx(Em,{onComplete:()=>t("login")}):e==="login"?s.jsx(Cm,{onLogin:_=>{r(_),t("authenticated")}}):s.jsx(As.Provider,{value:{currentUser:n,setCurrentUser:r},children:s.jsxs(s.Fragment,{children:[s.jsx(bm,{archives:l,archiveId:a,onArchiveChange:Fn,view:k,onViewChange:tn,onCaptureClick:ie,collections:y,selectedCollectionUid:u,onCollectionChange:Nn,isPublicSession:!n,onSignInClick:()=>t("login")}),s.jsxs("main",{className:"app-shell",children:[s.jsxs("div",{className:"workspace",children:[k==="archive"&&s.jsxs("div",{className:"toolbar",children:[s.jsxs("div",{className:"search-field",children:[s.jsx("span",{className:"ico","aria-hidden":"true",children:s.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",children:[s.jsx("circle",{cx:"11",cy:"11",r:"7"}),s.jsx("line",{x1:"16.5",y1:"16.5",x2:"21",y2:"21"})]})}),s.jsx("input",{ref:vt,className:"search-input",type:"search","aria-label":"Search archive","aria-busy":Ae,placeholder:"Search titles, URLs, types, tags…",value:F,onChange:_=>X(_.target.value)}),s.jsx("span",{className:"kbd",children:"⌘K"})]}),s.jsxs("span",{className:"result-count",children:[Z&&s.jsxs(s.Fragment,{children:[s.jsx("b",{children:Z.split(" ")[0]})," ",Z.split(" ").slice(1).join(" ")]}),E&&s.jsxs("button",{className:"tag-filter-badge",onClick:j,children:["× ",Dt?Gd(E):E]})]})]}),k==="archive"&&s.jsx(Am,{entries:h,selectedUids:w,onRowClick:wr,archiveId:a,pendingCaptures:Ee,deletedUids:x,isPublicSession:!n}),k==="runs"&&s.jsx(Bm,{runs:D}),k==="admin"&&s.jsx(Hm,{archives:l}),k==="tags"&&s.jsx(Wm,{archiveId:a,tagNodes:H,tagFilter:E,onTagFilterSet:Bn,onViewChange:tn,onTagRenamed:M,onTagDeleted:A,onTagsRefresh:B,humanizeTags:Dt}),k==="collections"&&s.jsx(Qm,{archiveId:a}),k==="settings"&&s.jsx(qm,{tab:L,onTabChange:q,archiveId:a})]}),s.jsx(ng,{archiveId:a,selectedEntry:f,selectedUids:w,selectedEntries:xe,detail:Ht,onTagFilterSet:Bn,tagNodes:H,onTagsRefresh:B,onEntryTitleChange:W,onEntryDeleted:pe,onBulkDeleted:ae,humanizeTags:Dt,onDetailRefresh:V,onOpenPreview:Ie,onPlay:of,isPublicSession:!n})]}),be&&f&&f.entry_uid===be&&s.jsx(wg,{archiveId:a,entry:f,detail:Ht,onClose:jr}),Wt&&s.jsx(kg,{entry:Wt.entry,src:Wt.src,archiveId:a,onClose:uf}),s.jsx(Lm,{open:ee,archiveId:a,onClose:Qe,onCaptured:we,onToast:Un,activeJobs:Ee,onJobStarted:z,onJobSettled:re}),s.jsx(Ng,{toasts:Y,onDismiss:xt,onIgnoreUblock:gl})]})})}qd(document.getElementById("root")).render(s.jsx(d.StrictMode,{children:s.jsx(Pg,{})})); diff --git a/crates/archivr-server/static/assets/index-CKGxin5o.js b/crates/archivr-server/static/assets/index-CKGxin5o.js new file mode 100644 index 0000000..6b965c1 --- /dev/null +++ b/crates/archivr-server/static/assets/index-CKGxin5o.js @@ -0,0 +1,49 @@ +(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const l of document.querySelectorAll('link[rel="modulepreload"]'))r(l);new MutationObserver(l=>{for(const i of l)if(i.type==="childList")for(const a of i.addedNodes)a.tagName==="LINK"&&a.rel==="modulepreload"&&r(a)}).observe(document,{childList:!0,subtree:!0});function n(l){const i={};return l.integrity&&(i.integrity=l.integrity),l.referrerPolicy&&(i.referrerPolicy=l.referrerPolicy),l.crossOrigin==="use-credentials"?i.credentials="include":l.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function r(l){if(l.ep)return;l.ep=!0;const i=n(l);fetch(l.href,i)}})();var Xu={exports:{}},Cs={},Ju={exports:{}},oe={};/** + * @license React + * react.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var gl=Symbol.for("react.element"),Nf=Symbol.for("react.portal"),Cf=Symbol.for("react.fragment"),Ef=Symbol.for("react.strict_mode"),bf=Symbol.for("react.profiler"),Tf=Symbol.for("react.provider"),Pf=Symbol.for("react.context"),Lf=Symbol.for("react.forward_ref"),Rf=Symbol.for("react.suspense"),zf=Symbol.for("react.memo"),If=Symbol.for("react.lazy"),So=Symbol.iterator;function $f(e){return e===null||typeof e!="object"?null:(e=So&&e[So]||e["@@iterator"],typeof e=="function"?e:null)}var Gu={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},Yu=Object.assign,Zu={};function jr(e,t,n){this.props=e,this.context=t,this.refs=Zu,this.updater=n||Gu}jr.prototype.isReactComponent={};jr.prototype.setState=function(e,t){if(typeof e!="object"&&typeof e!="function"&&e!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,e,t,"setState")};jr.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,"forceUpdate")};function ec(){}ec.prototype=jr.prototype;function _a(e,t,n){this.props=e,this.context=t,this.refs=Zu,this.updater=n||Gu}var Na=_a.prototype=new ec;Na.constructor=_a;Yu(Na,jr.prototype);Na.isPureReactComponent=!0;var _o=Array.isArray,tc=Object.prototype.hasOwnProperty,Ca={current:null},nc={key:!0,ref:!0,__self:!0,__source:!0};function rc(e,t,n){var r,l={},i=null,a=null;if(t!=null)for(r in t.ref!==void 0&&(a=t.ref),t.key!==void 0&&(i=""+t.key),t)tc.call(t,r)&&!nc.hasOwnProperty(r)&&(l[r]=t[r]);var o=arguments.length-2;if(o===1)l.children=n;else if(1>>1,se=I[ee];if(0>>1;eel(Ne,K))pel(U,Ne)?(I[ee]=U,I[pe]=K,ee=pe):(I[ee]=Ne,I[Y]=K,ee=Y);else if(pel(U,K))I[ee]=U,I[pe]=K,ee=pe;else break e}}return R}function l(I,R){var K=I.sortIndex-R.sortIndex;return K!==0?K:I.id-R.id}if(typeof performance=="object"&&typeof performance.now=="function"){var i=performance;e.unstable_now=function(){return i.now()}}else{var a=Date,o=a.now();e.unstable_now=function(){return a.now()-o}}var u=[],d=[],y=1,g=null,m=3,x=!1,w=!1,S=!1,_=typeof setTimeout=="function"?setTimeout:null,p=typeof clearTimeout=="function"?clearTimeout:null,f=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function h(I){for(var R=n(d);R!==null;){if(R.callback===null)r(d);else if(R.startTime<=I)r(d),R.sortIndex=R.expirationTime,t(u,R);else break;R=n(d)}}function k(I){if(S=!1,h(I),!w)if(n(u)!==null)w=!0,Le(E);else{var R=n(d);R!==null&&ve(k,R.startTime-I)}}function E(I,R){w=!1,S&&(S=!1,p(v),v=-1),x=!0;var K=m;try{for(h(R),g=n(u);g!==null&&(!(g.expirationTime>R)||I&&!X());){var ee=g.callback;if(typeof ee=="function"){g.callback=null,m=g.priorityLevel;var se=ee(g.expirationTime<=R);R=e.unstable_now(),typeof se=="function"?g.callback=se:g===n(u)&&r(u),h(R)}else r(u);g=n(u)}if(g!==null)var ie=!0;else{var Y=n(d);Y!==null&&ve(k,Y.startTime-R),ie=!1}return ie}finally{g=null,m=K,x=!1}}var P=!1,N=null,v=-1,b=5,L=-1;function X(){return!(e.unstable_now()-LI||125ee?(I.sortIndex=K,t(d,I),n(u)===null&&I===n(d)&&(S?(p(v),v=-1):S=!0,ve(k,K-ee))):(I.sortIndex=se,t(u,I),w||x||(w=!0,Le(E))),I},e.unstable_shouldYield=X,e.unstable_wrapCallback=function(I){var R=m;return function(){var K=m;m=R;try{return I.apply(this,arguments)}finally{m=K}}}})(oc);ac.exports=oc;var Kf=ac.exports;/** + * @license React + * react-dom.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Qf=c,jt=Kf;function O(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,n=1;n"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),Li=Object.prototype.hasOwnProperty,qf=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,Co={},Eo={};function Xf(e){return Li.call(Eo,e)?!0:Li.call(Co,e)?!1:qf.test(e)?Eo[e]=!0:(Co[e]=!0,!1)}function Jf(e,t,n,r){if(n!==null&&n.type===0)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return r?!1:n!==null?!n.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function Gf(e,t,n,r){if(t===null||typeof t>"u"||Jf(e,t,n,r))return!0;if(r)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function ct(e,t,n,r,l,i,a){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=r,this.attributeNamespace=l,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=i,this.removeEmptyString=a}var Ye={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){Ye[e]=new ct(e,0,!1,e,null,!1,!1)});[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];Ye[t]=new ct(t,1,!1,e[1],null,!1,!1)});["contentEditable","draggable","spellCheck","value"].forEach(function(e){Ye[e]=new ct(e,2,!1,e.toLowerCase(),null,!1,!1)});["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){Ye[e]=new ct(e,2,!1,e,null,!1,!1)});"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){Ye[e]=new ct(e,3,!1,e.toLowerCase(),null,!1,!1)});["checked","multiple","muted","selected"].forEach(function(e){Ye[e]=new ct(e,3,!0,e,null,!1,!1)});["capture","download"].forEach(function(e){Ye[e]=new ct(e,4,!1,e,null,!1,!1)});["cols","rows","size","span"].forEach(function(e){Ye[e]=new ct(e,6,!1,e,null,!1,!1)});["rowSpan","start"].forEach(function(e){Ye[e]=new ct(e,5,!1,e.toLowerCase(),null,!1,!1)});var ba=/[\-:]([a-z])/g;function Ta(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var t=e.replace(ba,Ta);Ye[t]=new ct(t,1,!1,e,null,!1,!1)});"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(ba,Ta);Ye[t]=new ct(t,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)});["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(ba,Ta);Ye[t]=new ct(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)});["tabIndex","crossOrigin"].forEach(function(e){Ye[e]=new ct(e,1,!1,e.toLowerCase(),null,!1,!1)});Ye.xlinkHref=new ct("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1);["src","href","action","formAction"].forEach(function(e){Ye[e]=new ct(e,1,!1,e.toLowerCase(),null,!0,!0)});function Pa(e,t,n,r){var l=Ye.hasOwnProperty(t)?Ye[t]:null;(l!==null?l.type!==0:r||!(2o||l[a]!==i[o]){var u=` +`+l[a].replace(" at new "," at ");return e.displayName&&u.includes("")&&(u=u.replace("",e.displayName)),u}while(1<=a&&0<=o);break}}}finally{ti=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:"")?$r(e):""}function Yf(e){switch(e.tag){case 5:return $r(e.type);case 16:return $r("Lazy");case 13:return $r("Suspense");case 19:return $r("SuspenseList");case 0:case 2:case 15:return e=ni(e.type,!1),e;case 11:return e=ni(e.type.render,!1),e;case 1:return e=ni(e.type,!0),e;default:return""}}function $i(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case Gn:return"Fragment";case Jn:return"Portal";case Ri:return"Profiler";case La:return"StrictMode";case zi:return"Suspense";case Ii:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case dc:return(e.displayName||"Context")+".Consumer";case cc:return(e._context.displayName||"Context")+".Provider";case Ra:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case za:return t=e.displayName||null,t!==null?t:$i(e.type)||"Memo";case dn:t=e._payload,e=e._init;try{return $i(e(t))}catch{}}return null}function Zf(e){var t=e.type;switch(e.tag){case 24:return"Cache";case 9:return(t.displayName||"Context")+".Consumer";case 10:return(t._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=t.render,e=e.displayName||e.name||"",t.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return t;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return $i(t);case 8:return t===La?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t=="function")return t.displayName||t.name||null;if(typeof t=="string")return t}return null}function Nn(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function pc(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function ep(e){var t=pc(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),r=""+e[t];if(!e.hasOwnProperty(t)&&typeof n<"u"&&typeof n.get=="function"&&typeof n.set=="function"){var l=n.get,i=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return l.call(this)},set:function(a){r=""+a,i.call(this,a)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(a){r=""+a},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function Nl(e){e._valueTracker||(e._valueTracker=ep(e))}function hc(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r="";return e&&(r=pc(e)?e.checked?"true":"false":e.value),e=r,e!==n?(t.setValue(e),!0):!1}function ts(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function Di(e,t){var n=t.checked;return Pe({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function To(e,t){var n=t.defaultValue==null?"":t.defaultValue,r=t.checked!=null?t.checked:t.defaultChecked;n=Nn(t.value!=null?t.value:n),e._wrapperState={initialChecked:r,initialValue:n,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function mc(e,t){t=t.checked,t!=null&&Pa(e,"checked",t,!1)}function Oi(e,t){mc(e,t);var n=Nn(t.value),r=t.type;if(n!=null)r==="number"?(n===0&&e.value===""||e.value!=n)&&(e.value=""+n):e.value!==""+n&&(e.value=""+n);else if(r==="submit"||r==="reset"){e.removeAttribute("value");return}t.hasOwnProperty("value")?Ai(e,t.type,n):t.hasOwnProperty("defaultValue")&&Ai(e,t.type,Nn(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function Po(e,t,n){if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var r=t.type;if(!(r!=="submit"&&r!=="reset"||t.value!==void 0&&t.value!==null))return;t=""+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,n!==""&&(e.name=n)}function Ai(e,t,n){(t!=="number"||ts(e.ownerDocument)!==e)&&(n==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+n&&(e.defaultValue=""+n))}var Dr=Array.isArray;function or(e,t,n,r){if(e=e.options,t){t={};for(var l=0;l"+t.valueOf().toString()+"",t=Cl.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function Yr(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var Br={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},tp=["Webkit","ms","Moz","O"];Object.keys(Br).forEach(function(e){tp.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),Br[t]=Br[e]})});function xc(e,t,n){return t==null||typeof t=="boolean"||t===""?"":n||typeof t!="number"||t===0||Br.hasOwnProperty(e)&&Br[e]?(""+t).trim():t+"px"}function wc(e,t){e=e.style;for(var n in t)if(t.hasOwnProperty(n)){var r=n.indexOf("--")===0,l=xc(n,t[n],r);n==="float"&&(n="cssFloat"),r?e.setProperty(n,l):e[n]=l}}var np=Pe({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function Bi(e,t){if(t){if(np[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(O(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(O(60));if(typeof t.dangerouslySetInnerHTML!="object"||!("__html"in t.dangerouslySetInnerHTML))throw Error(O(61))}if(t.style!=null&&typeof t.style!="object")throw Error(O(62))}}function Ui(e,t){if(e.indexOf("-")===-1)return typeof t.is=="string";switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var Hi=null;function Ia(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var Wi=null,ur=null,cr=null;function zo(e){if(e=xl(e)){if(typeof Wi!="function")throw Error(O(280));var t=e.stateNode;t&&(t=Ls(t),Wi(e.stateNode,e.type,t))}}function kc(e){ur?cr?cr.push(e):cr=[e]:ur=e}function jc(){if(ur){var e=ur,t=cr;if(cr=ur=null,zo(e),t)for(e=0;e>>=0,e===0?32:31-(pp(e)/hp|0)|0}var El=64,bl=4194304;function Or(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function ss(e,t){var n=e.pendingLanes;if(n===0)return 0;var r=0,l=e.suspendedLanes,i=e.pingedLanes,a=n&268435455;if(a!==0){var o=a&~l;o!==0?r=Or(o):(i&=a,i!==0&&(r=Or(i)))}else a=n&~l,a!==0?r=Or(a):i!==0&&(r=Or(i));if(r===0)return 0;if(t!==0&&t!==r&&!(t&l)&&(l=r&-r,i=t&-t,l>=i||l===16&&(i&4194240)!==0))return t;if(r&4&&(r|=n&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=r;0n;n++)t.push(e);return t}function yl(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-Ft(t),e[t]=n}function vp(e,t){var n=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var r=e.eventTimes;for(e=e.expirationTimes;0=Hr),Uo=" ",Ho=!1;function Uc(e,t){switch(e){case"keyup":return Kp.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Hc(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var Yn=!1;function qp(e,t){switch(e){case"compositionend":return Hc(t);case"keypress":return t.which!==32?null:(Ho=!0,Uo);case"textInput":return e=t.data,e===Uo&&Ho?null:e;default:return null}}function Xp(e,t){if(Yn)return e==="compositionend"||!Ua&&Uc(e,t)?(e=Fc(),Vl=Ma=mn=null,Yn=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=Qo(n)}}function Qc(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Qc(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function qc(){for(var e=window,t=ts();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href=="string"}catch{n=!1}if(n)e=t.contentWindow;else break;t=ts(e.document)}return t}function Ha(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}function lh(e){var t=qc(),n=e.focusedElem,r=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&Qc(n.ownerDocument.documentElement,n)){if(r!==null&&Ha(n)){if(t=r.start,e=r.end,e===void 0&&(e=t),"selectionStart"in n)n.selectionStart=t,n.selectionEnd=Math.min(e,n.value.length);else if(e=(t=n.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var l=n.textContent.length,i=Math.min(r.start,l);r=r.end===void 0?i:Math.min(r.end,l),!e.extend&&i>r&&(l=r,r=i,i=l),l=qo(n,i);var a=qo(n,r);l&&a&&(e.rangeCount!==1||e.anchorNode!==l.node||e.anchorOffset!==l.offset||e.focusNode!==a.node||e.focusOffset!==a.offset)&&(t=t.createRange(),t.setStart(l.node,l.offset),e.removeAllRanges(),i>r?(e.addRange(t),e.extend(a.node,a.offset)):(t.setEnd(a.node,a.offset),e.addRange(t)))}}for(t=[],e=n;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof n.focus=="function"&&n.focus(),n=0;n=document.documentMode,Zn=null,Ji=null,Vr=null,Gi=!1;function Xo(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;Gi||Zn==null||Zn!==ts(r)||(r=Zn,"selectionStart"in r&&Ha(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),Vr&&ll(Vr,r)||(Vr=r,r=os(Ji,"onSelect"),0nr||(e.current=ra[nr],ra[nr]=null,nr--)}function we(e,t){nr++,ra[nr]=e.current,e.current=t}var Cn={},lt=bn(Cn),ht=bn(!1),Mn=Cn;function gr(e,t){var n=e.type.contextTypes;if(!n)return Cn;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var l={},i;for(i in n)l[i]=t[i];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=l),l}function mt(e){return e=e.childContextTypes,e!=null}function cs(){_e(ht),_e(lt)}function nu(e,t,n){if(lt.current!==Cn)throw Error(O(168));we(lt,t),we(ht,n)}function rd(e,t,n){var r=e.stateNode;if(t=t.childContextTypes,typeof r.getChildContext!="function")return n;r=r.getChildContext();for(var l in r)if(!(l in t))throw Error(O(108,Zf(e)||"Unknown",l));return Pe({},n,r)}function ds(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||Cn,Mn=lt.current,we(lt,e),we(ht,ht.current),!0}function ru(e,t,n){var r=e.stateNode;if(!r)throw Error(O(169));n?(e=rd(e,t,Mn),r.__reactInternalMemoizedMergedChildContext=e,_e(ht),_e(lt),we(lt,e)):_e(ht),we(ht,n)}var Gt=null,Rs=!1,gi=!1;function ld(e){Gt===null?Gt=[e]:Gt.push(e)}function gh(e){Rs=!0,ld(e)}function Tn(){if(!gi&&Gt!==null){gi=!0;var e=0,t=ge;try{var n=Gt;for(ge=1;e>=a,l-=a,Yt=1<<32-Ft(t)+l|n<v?(b=N,N=null):b=N.sibling;var L=m(p,N,h[v],k);if(L===null){N===null&&(N=b);break}e&&N&&L.alternate===null&&t(p,N),f=i(L,f,v),P===null?E=L:P.sibling=L,P=L,N=b}if(v===h.length)return n(p,N),Ce&&Ln(p,v),E;if(N===null){for(;vv?(b=N,N=null):b=N.sibling;var X=m(p,N,L.value,k);if(X===null){N===null&&(N=b);break}e&&N&&X.alternate===null&&t(p,N),f=i(X,f,v),P===null?E=X:P.sibling=X,P=X,N=b}if(L.done)return n(p,N),Ce&&Ln(p,v),E;if(N===null){for(;!L.done;v++,L=h.next())L=g(p,L.value,k),L!==null&&(f=i(L,f,v),P===null?E=L:P.sibling=L,P=L);return Ce&&Ln(p,v),E}for(N=r(p,N);!L.done;v++,L=h.next())L=x(N,p,v,L.value,k),L!==null&&(e&&L.alternate!==null&&N.delete(L.key===null?v:L.key),f=i(L,f,v),P===null?E=L:P.sibling=L,P=L);return e&&N.forEach(function(Q){return t(p,Q)}),Ce&&Ln(p,v),E}function _(p,f,h,k){if(typeof h=="object"&&h!==null&&h.type===Gn&&h.key===null&&(h=h.props.children),typeof h=="object"&&h!==null){switch(h.$$typeof){case _l:e:{for(var E=h.key,P=f;P!==null;){if(P.key===E){if(E=h.type,E===Gn){if(P.tag===7){n(p,P.sibling),f=l(P,h.props.children),f.return=p,p=f;break e}}else if(P.elementType===E||typeof E=="object"&&E!==null&&E.$$typeof===dn&&iu(E)===P.type){n(p,P.sibling),f=l(P,h.props),f.ref=Lr(p,P,h),f.return=p,p=f;break e}n(p,P);break}else t(p,P);P=P.sibling}h.type===Gn?(f=An(h.props.children,p.mode,k,h.key),f.return=p,p=f):(k=Zl(h.type,h.key,h.props,null,p.mode,k),k.ref=Lr(p,f,h),k.return=p,p=k)}return a(p);case Jn:e:{for(P=h.key;f!==null;){if(f.key===P)if(f.tag===4&&f.stateNode.containerInfo===h.containerInfo&&f.stateNode.implementation===h.implementation){n(p,f.sibling),f=l(f,h.children||[]),f.return=p,p=f;break e}else{n(p,f);break}else t(p,f);f=f.sibling}f=_i(h,p.mode,k),f.return=p,p=f}return a(p);case dn:return P=h._init,_(p,f,P(h._payload),k)}if(Dr(h))return w(p,f,h,k);if(Cr(h))return S(p,f,h,k);$l(p,h)}return typeof h=="string"&&h!==""||typeof h=="number"?(h=""+h,f!==null&&f.tag===6?(n(p,f.sibling),f=l(f,h),f.return=p,p=f):(n(p,f),f=Si(h,p.mode,k),f.return=p,p=f),a(p)):n(p,f)}return _}var vr=od(!0),ud=od(!1),hs=bn(null),ms=null,sr=null,Qa=null;function qa(){Qa=sr=ms=null}function Xa(e){var t=hs.current;_e(hs),e._currentValue=t}function ia(e,t,n){for(;e!==null;){var r=e.alternate;if((e.childLanes&t)!==t?(e.childLanes|=t,r!==null&&(r.childLanes|=t)):r!==null&&(r.childLanes&t)!==t&&(r.childLanes|=t),e===n)break;e=e.return}}function fr(e,t){ms=e,Qa=sr=null,e=e.dependencies,e!==null&&e.firstContext!==null&&(e.lanes&t&&(pt=!0),e.firstContext=null)}function Tt(e){var t=e._currentValue;if(Qa!==e)if(e={context:e,memoizedValue:t,next:null},sr===null){if(ms===null)throw Error(O(308));sr=e,ms.dependencies={lanes:0,firstContext:e}}else sr=sr.next=e;return t}var In=null;function Ja(e){In===null?In=[e]:In.push(e)}function cd(e,t,n,r){var l=t.interleaved;return l===null?(n.next=n,Ja(t)):(n.next=l.next,l.next=n),t.interleaved=n,rn(e,r)}function rn(e,t){e.lanes|=t;var n=e.alternate;for(n!==null&&(n.lanes|=t),n=e,e=e.return;e!==null;)e.childLanes|=t,n=e.alternate,n!==null&&(n.childLanes|=t),n=e,e=e.return;return n.tag===3?n.stateNode:null}var fn=!1;function Ga(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function dd(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function en(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function kn(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,de&2){var l=r.pending;return l===null?t.next=t:(t.next=l.next,l.next=t),r.pending=t,rn(e,n)}return l=r.interleaved,l===null?(t.next=t,Ja(r)):(t.next=l.next,l.next=t),r.interleaved=t,rn(e,n)}function Ql(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,(n&4194240)!==0)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,Da(e,n)}}function au(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var l=null,i=null;if(n=n.firstBaseUpdate,n!==null){do{var a={eventTime:n.eventTime,lane:n.lane,tag:n.tag,payload:n.payload,callback:n.callback,next:null};i===null?l=i=a:i=i.next=a,n=n.next}while(n!==null);i===null?l=i=t:i=i.next=t}else l=i=t;n={baseState:r.baseState,firstBaseUpdate:l,lastBaseUpdate:i,shared:r.shared,effects:r.effects},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}function gs(e,t,n,r){var l=e.updateQueue;fn=!1;var i=l.firstBaseUpdate,a=l.lastBaseUpdate,o=l.shared.pending;if(o!==null){l.shared.pending=null;var u=o,d=u.next;u.next=null,a===null?i=d:a.next=d,a=u;var y=e.alternate;y!==null&&(y=y.updateQueue,o=y.lastBaseUpdate,o!==a&&(o===null?y.firstBaseUpdate=d:o.next=d,y.lastBaseUpdate=u))}if(i!==null){var g=l.baseState;a=0,y=d=u=null,o=i;do{var m=o.lane,x=o.eventTime;if((r&m)===m){y!==null&&(y=y.next={eventTime:x,lane:0,tag:o.tag,payload:o.payload,callback:o.callback,next:null});e:{var w=e,S=o;switch(m=t,x=n,S.tag){case 1:if(w=S.payload,typeof w=="function"){g=w.call(x,g,m);break e}g=w;break e;case 3:w.flags=w.flags&-65537|128;case 0:if(w=S.payload,m=typeof w=="function"?w.call(x,g,m):w,m==null)break e;g=Pe({},g,m);break e;case 2:fn=!0}}o.callback!==null&&o.lane!==0&&(e.flags|=64,m=l.effects,m===null?l.effects=[o]:m.push(o))}else x={eventTime:x,lane:m,tag:o.tag,payload:o.payload,callback:o.callback,next:null},y===null?(d=y=x,u=g):y=y.next=x,a|=m;if(o=o.next,o===null){if(o=l.shared.pending,o===null)break;m=o,o=m.next,m.next=null,l.lastBaseUpdate=m,l.shared.pending=null}}while(!0);if(y===null&&(u=g),l.baseState=u,l.firstBaseUpdate=d,l.lastBaseUpdate=y,t=l.shared.interleaved,t!==null){l=t;do a|=l.lane,l=l.next;while(l!==t)}else i===null&&(l.shared.lanes=0);Un|=a,e.lanes=a,e.memoizedState=g}}function ou(e,t,n){if(e=t.effects,t.effects=null,e!==null)for(t=0;tn?n:4,e(!0);var r=vi.transition;vi.transition={};try{e(!1),t()}finally{ge=n,vi.transition=r}}function bd(){return Pt().memoizedState}function wh(e,t,n){var r=Sn(e);if(n={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null},Td(e))Pd(t,n);else if(n=cd(e,t,n,r),n!==null){var l=ot();Bt(n,e,r,l),Ld(n,t,r)}}function kh(e,t,n){var r=Sn(e),l={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null};if(Td(e))Pd(t,l);else{var i=e.alternate;if(e.lanes===0&&(i===null||i.lanes===0)&&(i=t.lastRenderedReducer,i!==null))try{var a=t.lastRenderedState,o=i(a,n);if(l.hasEagerState=!0,l.eagerState=o,Ut(o,a)){var u=t.interleaved;u===null?(l.next=l,Ja(t)):(l.next=u.next,u.next=l),t.interleaved=l;return}}catch{}finally{}n=cd(e,t,l,r),n!==null&&(l=ot(),Bt(n,e,r,l),Ld(n,t,r))}}function Td(e){var t=e.alternate;return e===Te||t!==null&&t===Te}function Pd(e,t){Kr=vs=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function Ld(e,t,n){if(n&4194240){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,Da(e,n)}}var xs={readContext:Tt,useCallback:tt,useContext:tt,useEffect:tt,useImperativeHandle:tt,useInsertionEffect:tt,useLayoutEffect:tt,useMemo:tt,useReducer:tt,useRef:tt,useState:tt,useDebugValue:tt,useDeferredValue:tt,useTransition:tt,useMutableSource:tt,useSyncExternalStore:tt,useId:tt,unstable_isNewReconciler:!1},jh={readContext:Tt,useCallback:function(e,t){return Vt().memoizedState=[e,t===void 0?null:t],e},useContext:Tt,useEffect:cu,useImperativeHandle:function(e,t,n){return n=n!=null?n.concat([e]):null,Xl(4194308,4,Sd.bind(null,t,e),n)},useLayoutEffect:function(e,t){return Xl(4194308,4,e,t)},useInsertionEffect:function(e,t){return Xl(4,2,e,t)},useMemo:function(e,t){var n=Vt();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var r=Vt();return t=n!==void 0?n(t):t,r.memoizedState=r.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},r.queue=e,e=e.dispatch=wh.bind(null,Te,e),[r.memoizedState,e]},useRef:function(e){var t=Vt();return e={current:e},t.memoizedState=e},useState:uu,useDebugValue:so,useDeferredValue:function(e){return Vt().memoizedState=e},useTransition:function(){var e=uu(!1),t=e[0];return e=xh.bind(null,e[1]),Vt().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var r=Te,l=Vt();if(Ce){if(n===void 0)throw Error(O(407));n=n()}else{if(n=t(),Xe===null)throw Error(O(349));Bn&30||md(r,t,n)}l.memoizedState=n;var i={value:n,getSnapshot:t};return l.queue=i,cu(yd.bind(null,r,i,e),[e]),r.flags|=2048,fl(9,gd.bind(null,r,i,n,t),void 0,null),n},useId:function(){var e=Vt(),t=Xe.identifierPrefix;if(Ce){var n=Zt,r=Yt;n=(r&~(1<<32-Ft(r)-1)).toString(32)+n,t=":"+t+"R"+n,n=cl++,0<\/script>",e=e.removeChild(e.firstChild)):typeof r.is=="string"?e=a.createElement(n,{is:r.is}):(e=a.createElement(n),n==="select"&&(a=e,r.multiple?a.multiple=!0:r.size&&(a.size=r.size))):e=a.createElementNS(e,n),e[Kt]=t,e[al]=r,Bd(e,t,!1,!1),t.stateNode=e;e:{switch(a=Ui(n,r),n){case"dialog":Se("cancel",e),Se("close",e),l=r;break;case"iframe":case"object":case"embed":Se("load",e),l=r;break;case"video":case"audio":for(l=0;lkr&&(t.flags|=128,r=!0,Rr(i,!1),t.lanes=4194304)}else{if(!r)if(e=ys(a),e!==null){if(t.flags|=128,r=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),Rr(i,!0),i.tail===null&&i.tailMode==="hidden"&&!a.alternate&&!Ce)return nt(t),null}else 2*De()-i.renderingStartTime>kr&&n!==1073741824&&(t.flags|=128,r=!0,Rr(i,!1),t.lanes=4194304);i.isBackwards?(a.sibling=t.child,t.child=a):(n=i.last,n!==null?n.sibling=a:t.child=a,i.last=a)}return i.tail!==null?(t=i.tail,i.rendering=t,i.tail=t.sibling,i.renderingStartTime=De(),t.sibling=null,n=be.current,we(be,r?n&1|2:n&1),t):(nt(t),null);case 22:case 23:return fo(),r=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==r&&(t.flags|=8192),r&&t.mode&1?vt&1073741824&&(nt(t),t.subtreeFlags&6&&(t.flags|=8192)):nt(t),null;case 24:return null;case 25:return null}throw Error(O(156,t.tag))}function Ph(e,t){switch(Va(t),t.tag){case 1:return mt(t.type)&&cs(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return xr(),_e(ht),_e(lt),eo(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 5:return Za(t),null;case 13:if(_e(be),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(O(340));yr()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return _e(be),null;case 4:return xr(),null;case 10:return Xa(t.type._context),null;case 22:case 23:return fo(),null;case 24:return null;default:return null}}var Ol=!1,rt=!1,Lh=typeof WeakSet=="function"?WeakSet:Set,V=null;function ir(e,t){var n=e.ref;if(n!==null)if(typeof n=="function")try{n(null)}catch(r){ze(e,t,r)}else n.current=null}function ma(e,t,n){try{n()}catch(r){ze(e,t,r)}}var ku=!1;function Rh(e,t){if(Yi=is,e=qc(),Ha(e)){if("selectionStart"in e)var n={start:e.selectionStart,end:e.selectionEnd};else e:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var l=r.anchorOffset,i=r.focusNode;r=r.focusOffset;try{n.nodeType,i.nodeType}catch{n=null;break e}var a=0,o=-1,u=-1,d=0,y=0,g=e,m=null;t:for(;;){for(var x;g!==n||l!==0&&g.nodeType!==3||(o=a+l),g!==i||r!==0&&g.nodeType!==3||(u=a+r),g.nodeType===3&&(a+=g.nodeValue.length),(x=g.firstChild)!==null;)m=g,g=x;for(;;){if(g===e)break t;if(m===n&&++d===l&&(o=a),m===i&&++y===r&&(u=a),(x=g.nextSibling)!==null)break;g=m,m=g.parentNode}g=x}n=o===-1||u===-1?null:{start:o,end:u}}else n=null}n=n||{start:0,end:0}}else n=null;for(Zi={focusedElem:e,selectionRange:n},is=!1,V=t;V!==null;)if(t=V,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,V=e;else for(;V!==null;){t=V;try{var w=t.alternate;if(t.flags&1024)switch(t.tag){case 0:case 11:case 15:break;case 1:if(w!==null){var S=w.memoizedProps,_=w.memoizedState,p=t.stateNode,f=p.getSnapshotBeforeUpdate(t.elementType===t.type?S:Ot(t.type,S),_);p.__reactInternalSnapshotBeforeUpdate=f}break;case 3:var h=t.stateNode.containerInfo;h.nodeType===1?h.textContent="":h.nodeType===9&&h.documentElement&&h.removeChild(h.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(O(163))}}catch(k){ze(t,t.return,k)}if(e=t.sibling,e!==null){e.return=t.return,V=e;break}V=t.return}return w=ku,ku=!1,w}function Qr(e,t,n){var r=t.updateQueue;if(r=r!==null?r.lastEffect:null,r!==null){var l=r=r.next;do{if((l.tag&e)===e){var i=l.destroy;l.destroy=void 0,i!==void 0&&ma(t,n,i)}l=l.next}while(l!==r)}}function $s(e,t){if(t=t.updateQueue,t=t!==null?t.lastEffect:null,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var r=n.create;n.destroy=r()}n=n.next}while(n!==t)}}function ga(e){var t=e.ref;if(t!==null){var n=e.stateNode;switch(e.tag){case 5:e=n;break;default:e=n}typeof t=="function"?t(e):t.current=e}}function Wd(e){var t=e.alternate;t!==null&&(e.alternate=null,Wd(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[Kt],delete t[al],delete t[na],delete t[hh],delete t[mh])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function Vd(e){return e.tag===5||e.tag===3||e.tag===4}function ju(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||Vd(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function ya(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=us));else if(r!==4&&(e=e.child,e!==null))for(ya(e,t,n),e=e.sibling;e!==null;)ya(e,t,n),e=e.sibling}function va(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(e=e.child,e!==null))for(va(e,t,n),e=e.sibling;e!==null;)va(e,t,n),e=e.sibling}var Je=null,At=!1;function cn(e,t,n){for(n=n.child;n!==null;)Kd(e,t,n),n=n.sibling}function Kd(e,t,n){if(Qt&&typeof Qt.onCommitFiberUnmount=="function")try{Qt.onCommitFiberUnmount(Es,n)}catch{}switch(n.tag){case 5:rt||ir(n,t);case 6:var r=Je,l=At;Je=null,cn(e,t,n),Je=r,At=l,Je!==null&&(At?(e=Je,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):Je.removeChild(n.stateNode));break;case 18:Je!==null&&(At?(e=Je,n=n.stateNode,e.nodeType===8?mi(e.parentNode,n):e.nodeType===1&&mi(e,n),nl(e)):mi(Je,n.stateNode));break;case 4:r=Je,l=At,Je=n.stateNode.containerInfo,At=!0,cn(e,t,n),Je=r,At=l;break;case 0:case 11:case 14:case 15:if(!rt&&(r=n.updateQueue,r!==null&&(r=r.lastEffect,r!==null))){l=r=r.next;do{var i=l,a=i.destroy;i=i.tag,a!==void 0&&(i&2||i&4)&&ma(n,t,a),l=l.next}while(l!==r)}cn(e,t,n);break;case 1:if(!rt&&(ir(n,t),r=n.stateNode,typeof r.componentWillUnmount=="function"))try{r.props=n.memoizedProps,r.state=n.memoizedState,r.componentWillUnmount()}catch(o){ze(n,t,o)}cn(e,t,n);break;case 21:cn(e,t,n);break;case 22:n.mode&1?(rt=(r=rt)||n.memoizedState!==null,cn(e,t,n),rt=r):cn(e,t,n);break;default:cn(e,t,n)}}function Su(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new Lh),t.forEach(function(r){var l=Bh.bind(null,e,r);n.has(r)||(n.add(r),r.then(l,l))})}}function Dt(e,t){var n=t.deletions;if(n!==null)for(var r=0;rl&&(l=a),r&=~i}if(r=l,r=De()-r,r=(120>r?120:480>r?480:1080>r?1080:1920>r?1920:3e3>r?3e3:4320>r?4320:1960*Ih(r/1960))-r,10e?16:e,gn===null)var r=!1;else{if(e=gn,gn=null,js=0,de&6)throw Error(O(331));var l=de;for(de|=4,V=e.current;V!==null;){var i=V,a=i.child;if(V.flags&16){var o=i.deletions;if(o!==null){for(var u=0;uDe()-uo?On(e,0):oo|=n),gt(e,t)}function ef(e,t){t===0&&(e.mode&1?(t=bl,bl<<=1,!(bl&130023424)&&(bl=4194304)):t=1);var n=ot();e=rn(e,t),e!==null&&(yl(e,t,n),gt(e,n))}function Fh(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),ef(e,n)}function Bh(e,t){var n=0;switch(e.tag){case 13:var r=e.stateNode,l=e.memoizedState;l!==null&&(n=l.retryLane);break;case 19:r=e.stateNode;break;default:throw Error(O(314))}r!==null&&r.delete(t),ef(e,n)}var tf;tf=function(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps||ht.current)pt=!0;else{if(!(e.lanes&n)&&!(t.flags&128))return pt=!1,bh(e,t,n);pt=!!(e.flags&131072)}else pt=!1,Ce&&t.flags&1048576&&sd(t,ps,t.index);switch(t.lanes=0,t.tag){case 2:var r=t.type;Jl(e,t),e=t.pendingProps;var l=gr(t,lt.current);fr(t,n),l=no(null,t,r,e,l,n);var i=ro();return t.flags|=1,typeof l=="object"&&l!==null&&typeof l.render=="function"&&l.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,mt(r)?(i=!0,ds(t)):i=!1,t.memoizedState=l.state!==null&&l.state!==void 0?l.state:null,Ga(t),l.updater=Is,t.stateNode=l,l._reactInternals=t,oa(t,r,e,n),t=da(null,t,r,!0,i,n)):(t.tag=0,Ce&&i&&Wa(t),at(null,t,l,n),t=t.child),t;case 16:r=t.elementType;e:{switch(Jl(e,t),e=t.pendingProps,l=r._init,r=l(r._payload),t.type=r,l=t.tag=Hh(r),e=Ot(r,e),l){case 0:t=ca(null,t,r,e,n);break e;case 1:t=vu(null,t,r,e,n);break e;case 11:t=gu(null,t,r,e,n);break e;case 14:t=yu(null,t,r,Ot(r.type,e),n);break e}throw Error(O(306,r,""))}return t;case 0:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Ot(r,l),ca(e,t,r,l,n);case 1:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Ot(r,l),vu(e,t,r,l,n);case 3:e:{if(Ad(t),e===null)throw Error(O(387));r=t.pendingProps,i=t.memoizedState,l=i.element,dd(e,t),gs(t,r,null,n);var a=t.memoizedState;if(r=a.element,i.isDehydrated)if(i={element:r,isDehydrated:!1,cache:a.cache,pendingSuspenseBoundaries:a.pendingSuspenseBoundaries,transitions:a.transitions},t.updateQueue.baseState=i,t.memoizedState=i,t.flags&256){l=wr(Error(O(423)),t),t=xu(e,t,r,n,l);break e}else if(r!==l){l=wr(Error(O(424)),t),t=xu(e,t,r,n,l);break e}else for(wt=wn(t.stateNode.containerInfo.firstChild),kt=t,Ce=!0,Mt=null,n=ud(t,null,r,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling;else{if(yr(),r===l){t=ln(e,t,n);break e}at(e,t,r,n)}t=t.child}return t;case 5:return fd(t),e===null&&sa(t),r=t.type,l=t.pendingProps,i=e!==null?e.memoizedProps:null,a=l.children,ea(r,l)?a=null:i!==null&&ea(r,i)&&(t.flags|=32),Od(e,t),at(e,t,a,n),t.child;case 6:return e===null&&sa(t),null;case 13:return Md(e,t,n);case 4:return Ya(t,t.stateNode.containerInfo),r=t.pendingProps,e===null?t.child=vr(t,null,r,n):at(e,t,r,n),t.child;case 11:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Ot(r,l),gu(e,t,r,l,n);case 7:return at(e,t,t.pendingProps,n),t.child;case 8:return at(e,t,t.pendingProps.children,n),t.child;case 12:return at(e,t,t.pendingProps.children,n),t.child;case 10:e:{if(r=t.type._context,l=t.pendingProps,i=t.memoizedProps,a=l.value,we(hs,r._currentValue),r._currentValue=a,i!==null)if(Ut(i.value,a)){if(i.children===l.children&&!ht.current){t=ln(e,t,n);break e}}else for(i=t.child,i!==null&&(i.return=t);i!==null;){var o=i.dependencies;if(o!==null){a=i.child;for(var u=o.firstContext;u!==null;){if(u.context===r){if(i.tag===1){u=en(-1,n&-n),u.tag=2;var d=i.updateQueue;if(d!==null){d=d.shared;var y=d.pending;y===null?u.next=u:(u.next=y.next,y.next=u),d.pending=u}}i.lanes|=n,u=i.alternate,u!==null&&(u.lanes|=n),ia(i.return,n,t),o.lanes|=n;break}u=u.next}}else if(i.tag===10)a=i.type===t.type?null:i.child;else if(i.tag===18){if(a=i.return,a===null)throw Error(O(341));a.lanes|=n,o=a.alternate,o!==null&&(o.lanes|=n),ia(a,n,t),a=i.sibling}else a=i.child;if(a!==null)a.return=i;else for(a=i;a!==null;){if(a===t){a=null;break}if(i=a.sibling,i!==null){i.return=a.return,a=i;break}a=a.return}i=a}at(e,t,l.children,n),t=t.child}return t;case 9:return l=t.type,r=t.pendingProps.children,fr(t,n),l=Tt(l),r=r(l),t.flags|=1,at(e,t,r,n),t.child;case 14:return r=t.type,l=Ot(r,t.pendingProps),l=Ot(r.type,l),yu(e,t,r,l,n);case 15:return $d(e,t,t.type,t.pendingProps,n);case 17:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Ot(r,l),Jl(e,t),t.tag=1,mt(r)?(e=!0,ds(t)):e=!1,fr(t,n),Rd(t,r,l),oa(t,r,l,n),da(null,t,r,!0,e,n);case 19:return Fd(e,t,n);case 22:return Dd(e,t,n)}throw Error(O(156,t.tag))};function nf(e,t){return Tc(e,t)}function Uh(e,t,n,r){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function Et(e,t,n,r){return new Uh(e,t,n,r)}function ho(e){return e=e.prototype,!(!e||!e.isReactComponent)}function Hh(e){if(typeof e=="function")return ho(e)?1:0;if(e!=null){if(e=e.$$typeof,e===Ra)return 11;if(e===za)return 14}return 2}function _n(e,t){var n=e.alternate;return n===null?(n=Et(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function Zl(e,t,n,r,l,i){var a=2;if(r=e,typeof e=="function")ho(e)&&(a=1);else if(typeof e=="string")a=5;else e:switch(e){case Gn:return An(n.children,l,i,t);case La:a=8,l|=8;break;case Ri:return e=Et(12,n,t,l|2),e.elementType=Ri,e.lanes=i,e;case zi:return e=Et(13,n,t,l),e.elementType=zi,e.lanes=i,e;case Ii:return e=Et(19,n,t,l),e.elementType=Ii,e.lanes=i,e;case fc:return Os(n,l,i,t);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case cc:a=10;break e;case dc:a=9;break e;case Ra:a=11;break e;case za:a=14;break e;case dn:a=16,r=null;break e}throw Error(O(130,e==null?e:typeof e,""))}return t=Et(a,n,t,l),t.elementType=e,t.type=r,t.lanes=i,t}function An(e,t,n,r){return e=Et(7,e,r,t),e.lanes=n,e}function Os(e,t,n,r){return e=Et(22,e,r,t),e.elementType=fc,e.lanes=n,e.stateNode={isHidden:!1},e}function Si(e,t,n){return e=Et(6,e,null,t),e.lanes=n,e}function _i(e,t,n){return t=Et(4,e.children!==null?e.children:[],e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function Wh(e,t,n,r,l){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=li(0),this.expirationTimes=li(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=li(0),this.identifierPrefix=r,this.onRecoverableError=l,this.mutableSourceEagerHydrationData=null}function mo(e,t,n,r,l,i,a,o,u){return e=new Wh(e,t,n,o,u),t===1?(t=1,i===!0&&(t|=8)):t=0,i=Et(3,null,null,t),e.current=i,i.stateNode=e,i.memoizedState={element:r,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},Ga(i),e}function Vh(e,t,n){var r=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(af)}catch(e){console.error(e)}}af(),ic.exports=St;var Jh=ic.exports,of,Lu=Jh;of=Lu.createRoot,Lu.hydrateRoot;async function Oe(e,t){const n=await fetch(e,t);if(!n.ok)throw new Error(`${n.status} ${n.statusText}`);return n.json()}async function Ru(){return Oe("/api/archives")}async function zu(e,t=null){const n=t?`/api/archives/${e}/entries?collection=${encodeURIComponent(t)}`:`/api/archives/${e}/entries`;return Oe(n)}async function Gh(e,t,n,r=null){const l=new URLSearchParams;return t&&l.set("q",t),n&&l.set("tag",n),r&&l.set("collection",r),Oe(`/api/archives/${e}/entries/search?${l}`)}async function Mr(e,t){return Oe(`/api/archives/${e}/entries/${t}`)}async function Yh(e,t,{signal:n}={}){return Oe(`/api/archives/${e}/entries/${t}/summary`,{signal:n})}async function Zh(e,t,{provider:n,force:r=!1,includeImages:l=!1,signal:i}={}){const a=await fetch(`/api/archives/${e}/entries/${t}/summary`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({provider:n,force:r,include_images:l}),signal:i});if(!a.ok){const o=await a.text();let u=o.trim();try{u=JSON.parse(o).error||u}catch{}throw new Error(u||`Summary request failed (${a.status})`)}return a.json()}async function em(e,{signal:t}={}){const n=await fetch(e,{credentials:"same-origin",signal:t});if(!n.ok)throw new Error(`HTTP ${n.status}`);return n.text()}async function Iu(e,t){return Oe(`/api/archives/${e}/entries/${t}/children`)}async function tm(e,t,n){const r=await fetch(`/api/archives/${e}/entries/${t}/children/order`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({child_uids:n})});if(!r.ok){const l=await r.json().catch(()=>({})),i=new Error(l.error||`HTTP ${r.status}`);throw i.status=r.status,i}}function nm(e,t,n){return Promise.all(n.map(r=>Oe(`/api/archives/${e}/entries/${t}/artifacts/${r}`)))}async function rm(e){if(!e||e.length===0)return{};const t=await fetch("/api/util/resolve-tco",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)});return t.ok?t.json():{}}async function lm(e,t,n){const r=await fetch(`/api/archives/${e}/entries/${t}`,{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({title:n??null})});if(!r.ok)throw new Error(await r.text())}async function Fl(e,t){return Oe(`/api/archives/${e}/entries/${t}/tags`)}async function $u(e,t,n){const r=await fetch(`/api/archives/${e}/entries/${t}/tags`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({tag_path:n})});if(!r.ok)throw new Error(`Failed to add tag (${r.status})`)}async function sm(e,t,n){const r=await fetch(`/api/archives/${e}/entries/${t}/tags/${n}`,{method:"DELETE"});if(!r.ok)throw new Error(`Remove failed (${r.status})`)}async function Du(e,t){const n=await fetch(`/api/archives/${e}/entries/${t}`,{method:"DELETE"});if(!n.ok)throw new Error(`Delete failed (${n.status})`)}async function im(e,t,n){const r=await fetch(`/api/archives/${e}/tags/${t}`,{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:n})});if(!r.ok)throw new Error(await r.text());return r.json()}async function am(e,t){const n=await fetch(`/api/archives/${e}/tags/${t}`,{method:"DELETE"});if(!n.ok)throw new Error(await n.text())}async function om(e,t){const n=await fetch(`/api/archives/${e}/tags`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({path:t})});if(!n.ok)throw new Error(await n.text());return n.json()}async function um(e,t,n){const r=await fetch(`/api/archives/${e}/tags/${t}/move`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({parent_uid:n??null})});if(!r.ok)throw new Error(await r.text());return r.json()}async function Ni(e){return Oe(`/api/archives/${e}/runs`)}async function Bl(e){return Oe(`/api/archives/${e}/tags`)}async function cm(e,t,n=null,r=null){const l={locator:t};n&&n!=="best"&&(l.quality=n),r&&(typeof r.ublock_enabled=="boolean"&&(l.ublock_enabled=r.ublock_enabled),typeof r.reader_mode=="boolean"&&(l.reader_mode=r.reader_mode),typeof r.cookie_ext_enabled=="boolean"&&(l.cookie_ext_enabled=r.cookie_ext_enabled),typeof r.modal_closer_enabled=="boolean"&&(l.modal_closer_enabled=r.modal_closer_enabled),typeof r.via_freedium=="boolean"&&(l.via_freedium=r.via_freedium),r.per_item_quality&&typeof r.per_item_quality=="object"&&Object.keys(r.per_item_quality).length>0&&(l.per_item_quality=r.per_item_quality),r.sync===!0&&(l.sync=!0));const i=await fetch(`/api/archives/${e}/captures`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(l)});if(!i.ok){const a=await i.json().catch(()=>({})),o=new Error(a.error||`HTTP ${i.status}`);throw o.status=i.status,o}return i.json()}async function dm(e,{title:t,body:n,mime:r="text/markdown"}){const l={title:t,body:n};r&&r!=="text/markdown"&&(l.mime=r);const i=await fetch(`/api/archives/${e}/captures/text`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(l)});if(!i.ok){const a=await i.json().catch(()=>({})),o=new Error(a.error||`HTTP ${i.status}`);throw o.status=i.status,o}return i.json()}async function fm(e,t){return Oe(`/api/archives/${e}/captures/probe?locator=${encodeURIComponent(t)}`)}async function pm(e,t){const n=await fetch(`/api/archives/${e}/captures/probe-playlist`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locator:t})});if(!n.ok){const r=await n.json().catch(()=>({}));throw new Error(r.error||`HTTP ${n.status}`)}return n.json()}async function uf(e,t){return Oe(`/api/archives/${e}/capture_jobs/${t}`)}async function hm(){return(await(await fetch("/api/auth/setup")).json()).setup_required===!0}async function mm(e,t){const n=await fetch("/api/auth/setup",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({username:e,password:t})});if(!n.ok)throw new Error((await n.json()).error||"Setup failed");return n.json()}async function gm(e,t){const n=await fetch("/api/auth/login",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({username:e,password:t})});if(!n.ok)throw new Error((await n.json()).error||"Login failed");return n.json()}async function ym(){await fetch("/api/auth/logout",{method:"POST"})}async function cf(){const e=await fetch("/api/auth/me");return e.status===401?null:e.json()}async function vm(e){const t=await fetch("/api/auth/me",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({display_name:e})});if(!t.ok)throw new Error(await t.text())}async function xm(e){const t=await fetch("/api/auth/me",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)});if(!t.ok)throw new Error(await t.text())}async function wm(e,t){const n=await fetch("/api/auth/me",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({current_password:e,new_password:t})});if(!n.ok){let r=await n.text();try{r=JSON.parse(r).error??r}catch{}throw new Error(r)}}async function km(){return Oe("/api/auth/tokens")}async function jm(e){const t=await fetch("/api/auth/tokens",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:e})});if(!t.ok)throw new Error(await t.text());return t.json()}async function Sm(e){const t=await fetch(`/api/auth/tokens/${e}`,{method:"DELETE"});if(!t.ok)throw new Error(await t.text())}async function xo(){return Oe("/api/admin/instance-settings")}async function Jr(e){const t=await fetch("/api/admin/instance-settings",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)});if(!t.ok){const n=await t.json().catch(()=>({}));throw new Error(n.error||`HTTP ${t.status}`)}}async function _m(){return Oe("/api/admin/users")}async function Nm(e,t,n){const r=await fetch("/api/admin/users",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:e,password:t,email:n||void 0})});if(!r.ok){const l=await r.json().catch(()=>({}));throw new Error(l.error||`HTTP ${r.status}`)}return r.json()}async function Cm(e,t){const n=await fetch(`/api/admin/users/${e}/status`,{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({status:t})});if(!n.ok){const r=await n.json().catch(()=>({}));throw new Error(r.error||`HTTP ${n.status}`)}return n.json()}async function df(){return Oe("/api/admin/roles")}async function Em(e,t){const n=await fetch("/api/admin/roles",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({slug:e,name:t})});if(!n.ok){const r=await n.json().catch(()=>({}));throw new Error(r.error||`HTTP ${n.status}`)}return n.json()}async function wo(e){return Oe(`/api/archives/${e}/collections`)}async function bm(e,t,n,r=2,l=!0){const i=await fetch(`/api/archives/${e}/collections`,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({name:t,slug:n,default_visibility_bits:r,requires_auth:l})});if(!i.ok){const a=await i.json().catch(()=>({error:i.statusText}));throw new Error(a.error||i.statusText)}return i.json()}async function Tm(e,t){return Oe(`/api/archives/${e}/collections/${t}`)}async function Sa(e,t,n,r=2){const l=await fetch(`/api/archives/${e}/collections/${t}/entries`,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({entry_uid:n,visibility_bits:r})});if(!l.ok){const i=await l.json().catch(()=>({error:l.statusText}));throw new Error(i.error||l.statusText)}}async function Pm(e,t,n){const r=await fetch(`/api/archives/${e}/collections/${t}/entries/${n}`,{method:"DELETE"});if(!r.ok){const l=await r.json().catch(()=>({error:r.statusText}));throw new Error(l.error||r.statusText)}}async function Lm(e,t,n,r){const l=await fetch(`/api/archives/${e}/collections/${t}/entries/${n}`,{method:"PATCH",headers:{"content-type":"application/json"},body:JSON.stringify({visibility_bits:r})});if(!l.ok){const i=await l.json().catch(()=>({error:l.statusText}));throw new Error(i.error||l.statusText)}}async function Ou(e,t){return Oe(`/api/archives/${e}/entries/${t}/collections`)}async function Ci(e,t,n){const r=await fetch(`/api/archives/${e}/collections/${t}`,{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify(n)});if(!r.ok){const l=await r.json().catch(()=>({}));throw new Error(l.error??r.statusText)}}async function Rm(e,t){const n=await fetch(`/api/archives/${e}/collections/${t}`,{method:"DELETE"});if(!n.ok){const r=await n.json().catch(()=>({}));throw new Error(r.error??n.statusText)}}async function zm(e){return Oe(`/api/archives/${e}/blob-cleanup`)}async function Im(e){const t=await fetch(`/api/archives/${e}/blob-cleanup`,{method:"DELETE"});if(!t.ok){const n=await t.json().catch(()=>({}));throw new Error(n.error??t.statusText)}return t.json()}async function $m(e,t){const n=await fetch(`/api/archives/${e}/entries/${t}/rearchive`,{method:"POST"});if(!n.ok){const r=await n.json().catch(()=>({}));throw new Error(r.message||`rearchive failed: ${n.status}`)}return n.json()}async function Dm(e,t,n){const r=await fetch(`/api/archives/${e}/entries/${t}/artifacts/${n}/media-token`,{method:"POST"});if(!r.ok)throw new Error(`media-token ${r.status}`);return r.json()}async function Om(){return Oe("/api/admin/cookie-rules")}async function Am(e,t,n){const r=await fetch("/api/admin/cookie-rules",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({url_pattern:e||null,pattern_kind:t,cookies_json:n})});if(!r.ok){const l=await r.json().catch(()=>({}));throw new Error(l.message||`HTTP ${r.status}`)}return r.json()}async function Mm(e,t){const n=await fetch(`/api/admin/cookie-rules/${e}`,{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)});if(!n.ok){const r=await n.json().catch(()=>({}));throw new Error(r.message||`HTTP ${n.status}`)}}async function Fm(e){const t=await fetch(`/api/admin/cookie-rules/${e}`,{method:"DELETE"});if(!t.ok){const n=await t.json().catch(()=>({}));throw new Error(n.message||`HTTP ${t.status}`)}}function Bm(e,t,n){let r;return{promise:new Promise((i,a)=>{const o=new FormData;o.append("file",t),r=new XMLHttpRequest,r.open("POST",`/api/archives/${e}/uploads`),r.upload.addEventListener("progress",u=>{u.lengthComputable&&n&&n(Math.round(u.loaded/u.total*100))}),r.addEventListener("load",()=>{if(r.status>=200&&r.status<300)try{i(JSON.parse(r.responseText))}catch{a(new Error("Invalid server response"))}else{let u=`Upload failed (${r.status})`;try{u=JSON.parse(r.responseText).message||u}catch{}a(new Error(u))}}),r.addEventListener("error",()=>a(new Error("Network error during upload"))),r.addEventListener("abort",()=>a(new Error("Upload cancelled"))),r.send(o)}),abort:()=>r==null?void 0:r.abort()}}async function Ei(e,t){try{await fetch(`/api/archives/${e}/uploads`,{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({locator:t})})}catch{}}const Um=window.fetch;window.fetch=async(...e)=>{var n;const t=await Um(...e);return t.status===401&&((typeof e[0]=="string"?e[0]:((n=e[0])==null?void 0:n.url)??"").includes("/api/auth/")||window.dispatchEvent(new CustomEvent("auth:expired"))),t};function Hm({onLogin:e}){const[t,n]=c.useState(""),[r,l]=c.useState(""),[i,a]=c.useState(null),[o,u]=c.useState(!1);async function d(y){y.preventDefault(),a(null),u(!0);try{const g=await gm(t,r);e(g)}catch(g){a(g.message)}finally{u(!1)}}return s.jsx("div",{className:"login-page",children:s.jsxs("div",{className:"login-card",children:[s.jsx("h1",{className:"login-brand",children:"Archivr"}),s.jsx("p",{className:"login-tagline",children:"Sign in to your archive"}),s.jsxs("form",{onSubmit:d,children:[s.jsxs("div",{className:"login-field",children:[s.jsx("label",{className:"login-label",htmlFor:"login-username",children:"Username"}),s.jsx("input",{className:"login-input",id:"login-username",type:"text",value:t,onChange:y=>n(y.target.value),autoFocus:!0,required:!0,autoComplete:"username"})]}),s.jsxs("div",{className:"login-field",children:[s.jsx("label",{className:"login-label",htmlFor:"login-password",children:"Password"}),s.jsx("input",{className:"login-input",id:"login-password",type:"password",value:r,onChange:y=>l(y.target.value),required:!0,autoComplete:"current-password"})]}),i&&s.jsx("p",{className:"login-error",children:i}),s.jsx("button",{className:"login-submit",type:"submit",disabled:o,children:o?"Signing in…":"Sign in"})]})]})})}function Wm({onComplete:e}){const[t,n]=c.useState(""),[r,l]=c.useState(""),[i,a]=c.useState(""),[o,u]=c.useState(null),[d,y]=c.useState(!1);async function g(m){if(m.preventDefault(),r!==i){u("Passwords do not match");return}if(r.length<8){u("Password must be at least 8 characters");return}u(null),y(!0);try{await mm(t,r),e()}catch(x){u(x.message)}finally{y(!1)}}return s.jsx("div",{className:"setup-page",children:s.jsxs("div",{className:"setup-card",children:[s.jsx("h1",{className:"setup-brand",children:"Archivr"}),s.jsx("p",{className:"setup-tagline",children:"Create your owner account to get started."}),s.jsxs("form",{onSubmit:g,children:[s.jsxs("div",{className:"setup-field",children:[s.jsx("label",{className:"setup-label",htmlFor:"setup-username",children:"Username"}),s.jsx("input",{className:"setup-input",id:"setup-username",type:"text",value:t,onChange:m=>n(m.target.value),autoFocus:!0,required:!0,autoComplete:"username"})]}),s.jsxs("div",{className:"setup-field",children:[s.jsx("label",{className:"setup-label",htmlFor:"setup-password",children:"Password"}),s.jsx("input",{className:"setup-input",id:"setup-password",type:"password",value:r,onChange:m=>l(m.target.value),required:!0,autoComplete:"new-password"})]}),s.jsxs("div",{className:"setup-field",children:[s.jsx("label",{className:"setup-label",htmlFor:"setup-confirm",children:"Confirm password"}),s.jsx("input",{className:"setup-input",id:"setup-confirm",type:"password",value:i,onChange:m=>a(m.target.value),required:!0,autoComplete:"new-password"})]}),o&&s.jsx("p",{className:"setup-error",children:o}),s.jsx("button",{className:"setup-submit",type:"submit",disabled:d,children:d?"Creating account…":"Create account"})]})]})})}function Vm({archives:e,archiveId:t,onArchiveChange:n,view:r,onViewChange:l,onCaptureClick:i,collections:a,selectedCollectionUid:o,onCollectionChange:u,isPublicSession:d,onSignInClick:y}){const{currentUser:g,setCurrentUser:m}=c.useContext(Hs)??{},[x,w]=c.useState(!1);async function S(){w(!0),await ym(),m(null),window.location.reload()}const _=a.find(h=>h.slug==="_default_"),p=!d||!!_&&!_.requires_auth,f=a.filter(h=>h.slug!=="_default_").filter(h=>!d||!h.requires_auth);return s.jsxs("header",{className:"topbar",children:[s.jsx("div",{className:"brand",children:"Archivr"}),s.jsxs("div",{className:"switchers",children:[!d&&s.jsx("div",{className:"switcher",children:s.jsx("select",{"aria-label":"Select archive",value:t??"",onChange:h=>n(h.target.value),children:e.map(h=>s.jsx("option",{value:h.id,children:h.label},h.id))})}),f.length>0&&s.jsx("div",{className:"switcher",children:s.jsxs("select",{"aria-label":"Select collection",value:o??"",onChange:h=>u(h.target.value||null),children:[p&&s.jsx("option",{value:"",children:"All entries"}),f.map(h=>s.jsx("option",{value:h.collection_uid,children:h.name},h.collection_uid))]})})]}),d?s.jsxs(s.Fragment,{children:[s.jsx("span",{style:{flex:1}}),s.jsx("button",{className:"logout-btn",onClick:y,children:"Sign in"})]}):s.jsxs(s.Fragment,{children:[s.jsx("nav",{className:"nav","aria-label":"Primary",children:["archive","tags","collections","runs","admin","settings"].map(h=>s.jsx("button",{className:`nav-link${r===h?" is-active":""}`,onClick:()=>l(h),children:h.charAt(0).toUpperCase()+h.slice(1)},h))}),s.jsx("button",{className:"capture-button",onClick:i,children:"Capture"}),g&&s.jsxs("div",{className:"user-menu",children:[s.jsx("span",{className:"user-name",children:g.display_name||g.username}),s.jsx("button",{className:"logout-btn",onClick:S,disabled:x,children:x?"Logging out…":"Log out"})]})]})]})}let hl=1;function ff(e){const t=e.trim(),n=t.toLowerCase();for(const r of["yt:","youtube:"])if(n.startsWith(r)){const l=n.slice(r.length);return!!(l.startsWith("video/")||l.startsWith("short/")||l.startsWith("shorts/")||/^[a-z0-9_-]{11}$/i.test(l))}if(n.startsWith("ytm:"))return!n.slice(4).startsWith("playlist/");for(const r of["x:","twitter:","tweet:"])if(n.startsWith(r))return n.slice(r.length).startsWith("media:");if(n.startsWith("spotify:"))return!1;if(n.startsWith("instagram:")||n.startsWith("facebook:")||n.startsWith("tiktok:")||n.startsWith("reddit:")||n.startsWith("snapchat:"))return!0;if(n.startsWith("http://")||n.startsWith("https://")){if(/^https?:\/\/music\.youtube\.com\/watch/.test(n)||/^https?:\/\/(?:www\.)?(?:youtu\.be\/[0-9A-Za-z_-]+|youtube\.com\/watch\?v=[0-9A-Za-z_-]+|youtube\.com\/shorts\/[0-9A-Za-z_-]+)/.test(t)||n.startsWith("https://x.com/")||n.startsWith("http://x.com/")||/^https?:\/\/(?:www\.)?instagram\.com\//.test(n)||/^https?:\/\/(?:www\.)?facebook\.com\//.test(n)||n.startsWith("https://fb.watch/")||n.startsWith("http://fb.watch/")||/^https?:\/\/(?:www\.)?tiktok\.com\//.test(n)||/^https?:\/\/(?:www\.)?reddit\.com\//.test(n)||n.startsWith("https://redd.it/")||n.startsWith("http://redd.it/")||/^https?:\/\/(?:www\.)?snapchat\.com\//.test(n))return!0;if(n.startsWith("https://open.spotify.com/")||n.startsWith("http://open.spotify.com/"))return!1}return!1}function Dn(e){const t=e.trim(),n=t.toLowerCase();for(const r of["yt:","youtube:"])if(n.startsWith(r)){const l=n.slice(r.length);return l.startsWith("playlist/")||l.startsWith("@")||l.startsWith("channel/")||l.startsWith("c/")||l.startsWith("user/")}if(n.startsWith("ytm:"))return n.slice(4).startsWith("playlist/");if(n.startsWith("spotify:")){const r=n.slice(8);return r.startsWith("album:")||r.startsWith("playlist:")}if(n.startsWith("http://")||n.startsWith("https://"))try{const r=new URL(t),l=r.hostname;if((l==="youtube.com"||l==="www.youtube.com")&&(r.pathname==="/playlist"&&r.searchParams.has("list")||r.pathname.startsWith("/@")||r.pathname.startsWith("/channel/")||r.pathname.startsWith("/c/")||r.pathname.startsWith("/user/"))||l==="music.youtube.com"&&r.pathname==="/playlist"&&r.searchParams.has("list")||l==="open.spotify.com"&&(r.pathname.startsWith("/album/")||r.pathname.startsWith("/playlist/")))return!0}catch{}return!1}function Xn(e=""){return{id:hl++,locator:e,quality:"best",probeState:"idle",probeQualities:null,probeHasAudio:!1,status:"idle",error:null,jobUid:null,archiveId:null,playlistProbeState:"idle",playlistInfo:null,playlistItems:null,playlistQuality:null,playlistExpanded:!1,syncEnabled:!1}}function Km(e){return{id:hl++,kind:"file",filename:e,uploadProgress:0,uploadStatus:"uploading",uploadLocator:null,uploadError:null,locator:"",quality:"best",probeState:"idle",probeQualities:null,probeHasAudio:!1,playlistProbeState:"idle",playlistInfo:null,playlistItems:null,playlistQuality:null,playlistExpanded:!1,syncEnabled:!1,error:null,status:"idle"}}function Qm(){return{id:hl++,kind:"text",title:"",body:"",mime:"text/markdown",locator:"",quality:"best",probeState:"idle",probeQualities:null,probeHasAudio:!1,playlistProbeState:"idle",playlistInfo:null,playlistItems:null,playlistQuality:null,playlistExpanded:!1,syncEnabled:!1,error:null,status:"idle"}}function qm(e,t){return e==="best"?t.map(n=>({...n,quality:"best"})):e==="audio"?t.map(n=>n.has_audio?{...n,quality:"audio"}:n.quality!==null?n:{...n,quality:null}):t.map(n=>n.qualities.includes(e)?{...n,quality:e}:n.quality!==null?n:{...n,quality:null})}function Au(e){return Array.isArray(e.playlistItems)&&e.playlistItems.some(t=>t.quality===null)}function Xm({open:e,archiveId:t,onClose:n,onCaptured:r,onToast:l,onJobStarted:i,onJobSettled:a,activeJobs:o=[]}){const u=c.useRef(null),d=c.useRef(!0),y=c.useRef(new Map),g=c.useRef(new Map),m=c.useRef(new Map),x=c.useRef(t);c.useEffect(()=>{x.current=t},[t]);const w=c.useRef(null),[S,_]=c.useState(!1),p=c.useRef([]),f=c.useRef(!1),h=c.useRef(new Map),k=c.useRef(r),E=c.useRef(l);c.useEffect(()=>{k.current=r},[r]),c.useEffect(()=>{E.current=l},[l]);const P=c.useRef(i),N=c.useRef(a);c.useEffect(()=>{P.current=i},[i]),c.useEffect(()=>{N.current=a},[a]);const[v,b]=c.useState(()=>{try{const j=JSON.parse(sessionStorage.getItem("captureItems")||"null");if(Array.isArray(j)&&j.length>0){const B=j.filter(A=>!A.status||A.status==="idle");if(B.length>0)return B.forEach(A=>{A.id>=hl&&(hl=A.id+1)}),B.map(A=>({...Xn(A.locator),...A}))}}catch{}return[Xn()]});c.useEffect(()=>{sessionStorage.setItem("captureItems",JSON.stringify(v.filter(j=>j.kind!=="file")))},[v]),c.useEffect(()=>{p.current=v},[v]);const[L,X]=c.useState(!1),[Q,ne]=c.useState(null),[q,fe]=c.useState(null),[Le,ve]=c.useState(!0),[I,R]=c.useState(!0),[K,ee]=c.useState(!0);c.useEffect(()=>{xo().then(j=>{fe(j),ve(j.cookie_ext_enabled??!0),R(j.modal_closer_enabled??!0)}).catch(()=>fe({}))},[]);const se=Q!==null?Q:(q==null?void 0:q.ublock_enabled)??!0,[ie,Y]=c.useState(!1);c.useEffect(()=>{["captureDialogLocator","captureDialogError","captureDialogBusy","captureDialogJobStatus","captureDialogJobUid"].forEach(j=>sessionStorage.removeItem(j)),o.forEach(j=>{j.jobUid&&!y.current.has(j.jobUid)&&Ne(j.id,j.jobUid,j.locator,j.archiveId,null)})},[]),c.useEffect(()=>{const j=u.current;if(!j)return;const B=()=>{if(g.current.forEach(A=>clearTimeout(A)),g.current.clear(),!f.current){const A=x.current;p.current.forEach(D=>{var F;D.kind==="file"&&(D.uploadStatus==="uploading"?((F=h.current.get(D.id))==null||F(),h.current.delete(D.id)):D.uploadStatus==="done"&&D.uploadLocator&&Ei(A,D.uploadLocator).catch(()=>{}))})}f.current=!1,n()};return j.addEventListener("close",B),()=>j.removeEventListener("close",B)},[n]),c.useEffect(()=>{const j=u.current;j&&(e?(d.current||b([Xn()]),d.current=!1,j.open||j.showModal()):(g.current.forEach(B=>clearTimeout(B)),g.current.clear(),j.open&&j.close()))},[e]),c.useEffect(()=>()=>{y.current.forEach(j=>clearInterval(j)),g.current.forEach(j=>clearTimeout(j))},[]);function Ne(j,B,A,D,F=null){if(y.current.has(B))return;const H=setInterval(async()=>{var ke,he,Re,ye;try{const Fe=await uf(D,B);if(Fe.status==="completed"){clearInterval(y.current.get(B)),y.current.delete(B),await Promise.resolve((ke=k.current)==null?void 0:ke.call(k)),(he=N.current)==null||he.call(N,j);try{const ce=Fe.notes_json?JSON.parse(Fe.notes_json):null;if(ce!=null&&ce.ublock_skipped||ce!=null&&ce.cookie_ext_skipped){const dt=ce.ublock_skipped&&ce.cookie_ext_skipped?"Captured without ad-blocking or cookie-consent extension. Check ARCHIVR_UBLOCK_EXT and ARCHIVR_COOKIE_EXT in your server config.":ce.ublock_skipped?"Captured without ad-blocking. ARCHIVR_UBLOCK=true but ARCHIVR_UBLOCK_EXT is not set or the path is invalid.":"Captured without cookie-consent extension. ARCHIVR_COOKIE_EXT is not set or the path is invalid.";E.current(dt,A,"warning"),pe(F,"warning",A)}else F||E.current(null,A,"success"),pe(F,"archived")}catch{F||E.current(null,A,"success"),pe(F,"archived")}}else if(Fe.status==="failed"){clearInterval(y.current.get(B)),y.current.delete(B),(Re=N.current)==null||Re.call(N,j);const ce=Fe.error_text||"Capture failed.";E.current(ce,A),pe(F,"failed",A)}}catch(Fe){clearInterval(y.current.get(B)),y.current.delete(B),(ye=N.current)==null||ye.call(N,j);const ce=Fe.message||"Network error";E.current(ce,A),pe(F,"failed",A)}},500);y.current.set(B,H)}function pe(j,B,A=null){if(!j)return;const D=m.current.get(j);if(!D||(B==="archived"||B==="warning"?(D.archived++,B==="warning"&&(D.warnings++,A&&D.warningLocators.push(A))):(D.failed++,A&&D.failedLocators.push(A)),D.archived+D.failed0?`${F} archived (${H} with warnings)`:`${F} archived`;ye=ke>0?`${dt}, ${ke} failed`:dt}const Fe=F===0?"error":ke>0||H>0?"warning":"success",ce=[];he.length>0&&ce.push(`Failed: +${he.map(dt=>` ${dt}`).join(` +`)}`),Re.length>0&&ce.push(`With warnings: +${Re.map(dt=>` ${dt}`).join(` +`)}`);const it=ce.length>0?ce.join(` +`):null;E.current(it,null,Fe,ye)}async function U(j,B){var Re,ye,Fe;const A=x.current,D=((Re=crypto.randomUUID)==null?void 0:Re.call(crypto))??`job-${Date.now()}-${Math.random()}`;if(j.type==="text"){try{const ce=await dm(A,{title:j.title,body:j.body,mime:j.mime}),it=`text:${j.title}`;(ye=P.current)==null||ye.call(P,{id:D,jobUid:ce.job_uid,locator:it,archiveId:A}),Ne(D,ce.job_uid,it,A,B)}catch(ce){const it=ce.message||"Submission failed.";E.current(it,`text:${j.title}`),pe(B,"failed",`text:${j.title}`)}return}const F=j.locator,H=j.quality,ke=j.extraExtensions||{},he={ublock_enabled:se,reader_mode:ie,cookie_ext_enabled:Le,modal_closer_enabled:I,via_freedium:K,...ke};try{const ce=await cm(A,F,H,he);(Fe=P.current)==null||Fe.call(P,{id:D,jobUid:ce.job_uid,locator:F,archiveId:A}),Ne(D,ce.job_uid,F,A,B)}catch(ce){const it=ce.message||"Submission failed.";E.current(it,F),pe(B,"failed",F),F.startsWith("file://")&&ce.status&&Ei(A,F).catch(()=>{})}}function te(){var D,F;if(v.some(H=>H.kind==="file"&&H.uploadStatus==="uploading"))return;const j=v.filter(H=>H.kind==="file"?H.uploadStatus==="done"&&H.uploadLocator:H.kind==="text"?H.title.trim()&&H.body.trim():H.locator.trim());if(j.length===0||j.some(H=>H.kind!=="file"&&H.kind!=="text"&&Au(H))||j.some(H=>H.kind!=="file"&&H.kind!=="text"&&(H.probeState==="probing"||Dn(H.locator)&&H.playlistProbeState!=="done"))||j.some(H=>H.kind!=="file"&&H.kind!=="text"&&Array.isArray(H.playlistItems)&&H.playlistItems.length===0))return;const B=j.length>1?((D=crypto.randomUUID)==null?void 0:D.call(crypto))??`batch-${Date.now()}`:null;B&&m.current.set(B,{total:j.length,archived:0,warnings:0,failed:0,failedLocators:[],warningLocators:[]});const A=j.map(H=>H.kind==="file"?{type:"file",locator:H.uploadLocator,quality:"best",extraExtensions:{}}:H.kind==="text"?{type:"text",title:H.title.trim(),body:H.body,mime:H.mime}:{type:"url",locator:H.locator.trim(),quality:H.playlistItems!==null?null:H.quality||"best",extraExtensions:H.playlistItems!==null?{per_item_quality:Object.fromEntries(H.playlistItems.map(ke=>[ke.id,ke.quality])),sync:H.syncEnabled}:{}});f.current=!0,b([Xn()]),(F=u.current)==null||F.close(),A.forEach(H=>U(H,B))}function re(){b(j=>[...j,Xn()])}function Ue(j){var A;clearTimeout(g.current.get(j)),g.current.delete(j);const B=p.current.find(D=>D.id===j);(B==null?void 0:B.kind)==="file"&&(B.uploadStatus==="uploading"?((A=h.current.get(j))==null||A(),h.current.delete(j)):B.uploadStatus==="done"&&B.uploadLocator&&Ei(x.current,B.uploadLocator).catch(()=>{})),b(D=>{const F=D.filter(H=>H.id!==j);return F.length===0?[Xn()]:F})}function Lt(j,B){if(clearTimeout(g.current.get(j)),b(A=>A.map(D=>D.id===j?{...D,locator:B,probeState:"idle",probeQualities:null,probeHasAudio:!1,quality:"best",playlistProbeState:"idle",playlistInfo:null,playlistItems:null,playlistQuality:null,playlistExpanded:!1}:D)),ff(B)){const A=setTimeout(async()=>{g.current.delete(j),b(D=>D.map(F=>F.id===j?{...F,probeState:"probing"}:F));try{const D=await fm(x.current,B.trim());b(F=>F.map(H=>{if(H.id!==j||H.locator!==B)return H;const ke=D.qualities??[],he=D.has_audio??!1,Re=ke.length===0&&he?"audio":"best";return{...H,probeState:"done",probeQualities:ke,probeHasAudio:he,quality:Re}}))}catch{b(D=>D.map(F=>F.id===j?{...F,probeState:"idle",probeQualities:null}:F))}},600);g.current.set(j,A)}else if(Dn(B)){const A=setTimeout(async()=>{g.current.delete(j),b(D=>D.map(F=>F.id===j?{...F,playlistProbeState:"probing"}:F));try{const D=await pm(x.current,B.trim());b(F=>F.map(H=>H.id!==j||H.locator!==B?H:{...H,playlistProbeState:"done",playlistInfo:D,playlistItems:D.items.map(ke=>({...ke,quality:null})),playlistQuality:null}))}catch{b(D=>D.map(F=>F.id===j?{...F,playlistProbeState:"error"}:F))}},800);g.current.set(j,A)}}function yt(j,B){b(A=>A.map(D=>D.id===j?{...D,quality:B}:D))}function Ie(j,B){b(A=>A.map(D=>{if(D.id!==j)return D;const F=qm(B,D.playlistItems);return{...D,playlistQuality:B,playlistItems:F}}))}function st(j,B,A){b(D=>D.map(F=>F.id!==j?F:{...F,playlistItems:F.playlistItems.map(H=>H.id===B?{...H,quality:A}:H)}))}function Ze(j){b(B=>B.map(A=>A.id===j?{...A,playlistExpanded:!A.playlistExpanded}:A))}function $(j,B){b(A=>A.map(D=>D.id===j?{...D,syncEnabled:B}:D))}function Z(j,B){b(A=>A.map(D=>D.id!==j?D:{...D,playlistItems:D.playlistItems.filter(F=>F.id!==B)}))}function Ae(j){const B=Array.from(j);B.length!==0&&B.forEach(A=>{const D=Km(A.name);b(he=>he.length===1&&!he[0].kind&&!he[0].locator.trim()?[D]:[...he,D]);const F=x.current,{promise:H,abort:ke}=Bm(F,A,he=>{b(Re=>Re.map(ye=>ye.id===D.id?{...ye,uploadProgress:he}:ye))});h.current.set(D.id,ke),H.then(he=>{h.current.delete(D.id),b(Re=>Re.map(ye=>ye.id===D.id?{...ye,uploadStatus:"done",uploadLocator:he.locator,uploadProgress:100}:ye))}).catch(he=>{h.current.delete(D.id),he.message!=="Upload cancelled"&&b(Re=>Re.map(ye=>ye.id===D.id?{...ye,uploadStatus:"error",uploadError:he.message}:ye))})})}function Me(j){j.preventDefault(),j.stopPropagation(),j.dataTransfer.types.includes("Files")&&_(!0)}function Rt(j){j.currentTarget.contains(j.relatedTarget)||_(!1)}function zt(j){j.preventDefault(),j.stopPropagation(),_(!1),Ae(j.dataTransfer.files)}function It(j){Ae(j.target.files),j.target.value=""}const et=v.some(j=>j.kind==="file"&&j.uploadStatus==="uploading"),Xt=v.filter(j=>j.kind==="file"?j.uploadStatus==="done"&&j.uploadLocator:j.kind==="text"?j.title.trim()&&j.body.trim():j.locator.trim()).length,$t=v.some(j=>j.kind!=="file"&&j.kind!=="text"&&Au(j)),an=v.some(j=>j.kind!=="file"&&j.kind!=="text"&&Array.isArray(j.playlistItems)&&j.playlistItems.length===0),He=v.some(j=>j.kind!=="file"&&j.kind!=="text"&&(j.probeState==="probing"||Dn(j.locator)&&j.playlistProbeState!=="done"));return s.jsx("dialog",{ref:u,className:`capture-dialog${S?" capture-dialog--dragover":""}`,onDragOver:Me,onDragLeave:Rt,onDrop:zt,children:s.jsxs("div",{className:"capture-dialog-inner",children:[s.jsxs("div",{className:"capture-dialog-header",children:[s.jsx("h2",{className:"capture-dialog-title",children:"Capture"}),s.jsx("button",{type:"button",className:"capture-dialog-close",onClick:()=>{var j;return(j=u.current)==null?void 0:j.close()},"aria-label":"Close",children:s.jsxs("svg",{viewBox:"0 0 16 16",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",children:[s.jsx("line",{x1:"3",y1:"3",x2:"13",y2:"13"}),s.jsx("line",{x1:"13",y1:"3",x2:"3",y2:"13"})]})})]}),s.jsx("div",{className:"capture-rows",children:v.map((j,B)=>j.kind==="file"?s.jsx(Gm,{item:j,onRemove:()=>Ue(j.id)},j.id):j.kind==="text"?s.jsx(Ym,{item:j,autoFocus:B===v.length-1,onTitleChange:A=>b(D=>D.map(F=>F.id===j.id?{...F,title:A}:F)),onBodyChange:A=>b(D=>D.map(F=>F.id===j.id?{...F,body:A}:F)),onMimeChange:A=>b(D=>D.map(F=>F.id===j.id?{...F,mime:A}:F)),onRemove:()=>Ue(j.id),onSubmit:te},j.id):s.jsx(Jm,{item:j,autoFocus:B===v.length-1,onLocatorChange:A=>Lt(j.id,A),onQualityChange:A=>yt(j.id,A),onRemove:()=>Ue(j.id),onSubmit:te,onPlaylistQualityChange:A=>Ie(j.id,A),onPlaylistItemQualityChange:(A,D)=>st(j.id,A,D),onPlaylistToggle:()=>Ze(j.id),onSyncChange:A=>$(j.id,A),onPlaylistItemDelete:A=>Z(j.id,A)},j.id))}),s.jsx("input",{ref:w,type:"file",multiple:!0,hidden:!0,onChange:It,"aria-hidden":"true",tabIndex:-1}),s.jsxs("div",{className:"capture-add-row-group",children:[s.jsxs("button",{type:"button",className:"capture-add-row",onClick:re,children:[s.jsxs("svg",{viewBox:"0 0 16 16",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",children:[s.jsx("line",{x1:"8",y1:"2",x2:"8",y2:"14"}),s.jsx("line",{x1:"2",y1:"8",x2:"14",y2:"8"})]}),"Add URL"]}),s.jsxs("button",{type:"button",className:"capture-add-row capture-add-file",onClick:()=>{var j;return(j=w.current)==null?void 0:j.click()},children:[s.jsxs("svg",{viewBox:"0 0 16 16",fill:"none",stroke:"currentColor",strokeWidth:"1.75",strokeLinecap:"round",strokeLinejoin:"round",children:[s.jsx("rect",{x:"3",y:"1",width:"10",height:"14",rx:"1.5"}),s.jsx("line",{x1:"5.5",y1:"5.5",x2:"10.5",y2:"5.5"}),s.jsx("line",{x1:"5.5",y1:"8",x2:"10.5",y2:"8"}),s.jsx("line",{x1:"5.5",y1:"10.5",x2:"8.5",y2:"10.5"})]}),"Upload file"]}),s.jsxs("button",{type:"button",className:"capture-add-row capture-add-text",onClick:()=>b(j=>[...j,Qm()]),children:[s.jsx("svg",{viewBox:"0 0 16 16",fill:"none",stroke:"currentColor",strokeWidth:"1.75",strokeLinecap:"round",strokeLinejoin:"round",children:s.jsx("path",{d:"M2 3h12M2 7h12M2 11h8"})}),"Add text"]})]}),s.jsxs("div",{className:"capture-advanced",children:[s.jsxs("button",{type:"button",className:"capture-advanced-toggle",onClick:()=>X(j=>!j),"aria-expanded":L,children:[s.jsx("svg",{className:`capture-chevron${L?" capture-chevron--open":""}`,viewBox:"0 0 16 16",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:s.jsx("polyline",{points:"4 6 8 10 12 6"})}),"Advanced options"]}),L&&s.jsxs("div",{className:"capture-advanced-panel",children:[s.jsxs("label",{className:"capture-ext-row",children:[s.jsxs("span",{className:"capture-ext-label",children:[s.jsx("span",{className:"capture-ext-name",children:"uBlock Origin Lite"}),s.jsx("span",{className:"capture-ext-desc",children:"Block ads during this capture"})]}),s.jsx("button",{type:"button",role:"switch","aria-checked":se,className:`ext-toggle ext-toggle--sm${se?" ext-toggle--on":""}`,onClick:()=>ne(j=>j===null?!se:!j),"aria-label":"Toggle uBlock for this capture",children:s.jsx("span",{className:"ext-toggle-knob"})})]}),s.jsxs("label",{className:"capture-ext-row",style:{marginTop:8},children:[s.jsxs("span",{className:"capture-ext-label",children:[s.jsx("span",{className:"capture-ext-name",children:"Block cookie banners"}),s.jsx("span",{className:"capture-ext-desc",children:"Dismiss cookie consent banners during this capture"}),!(q!=null&&q.cookie_ext_available)&&s.jsxs("span",{className:"capture-ext-hint",children:["Not configured — set ",s.jsx("code",{children:"ARCHIVR_COOKIE_EXT"})]})]}),s.jsx("button",{type:"button",role:"switch","aria-checked":Le,className:`ext-toggle ext-toggle--sm${Le?" ext-toggle--on":""}`,onClick:()=>ve(j=>!j),"aria-label":"Toggle cookie banner blocking for this capture",children:s.jsx("span",{className:"ext-toggle-knob"})})]}),s.jsxs("label",{className:"capture-ext-row",style:{marginTop:8},children:[s.jsxs("span",{className:"capture-ext-label",children:[s.jsx("span",{className:"capture-ext-name",children:"Reader mode"}),s.jsx("span",{className:"capture-ext-desc",children:"Distil to article text via Readability (off by default)"})]}),s.jsx("button",{type:"button",role:"switch","aria-checked":ie,className:`ext-toggle ext-toggle--sm${ie?" ext-toggle--on":""}`,onClick:()=>Y(j=>!j),"aria-label":"Toggle reader mode for this capture",children:s.jsx("span",{className:"ext-toggle-knob"})})]}),s.jsxs("label",{className:"capture-ext-row",style:{marginTop:8},children:[s.jsxs("span",{className:"capture-ext-label",children:[s.jsx("span",{className:"capture-ext-name",children:"Close modals and dialogs"}),s.jsx("span",{className:"capture-ext-desc",children:"Auto-dismiss cookie banners and overlays during this capture"})]}),s.jsx("button",{type:"button",role:"switch","aria-checked":I,className:`ext-toggle ext-toggle--sm${I?" ext-toggle--on":""}`,onClick:()=>R(j=>!j),"aria-label":"Toggle modal closer for this capture",children:s.jsx("span",{className:"ext-toggle-knob"})})]}),s.jsxs("label",{className:"capture-ext-row",style:{marginTop:8},children:[s.jsxs("span",{className:"capture-ext-label",children:[s.jsx("span",{className:"capture-ext-name",children:"Freedium mirror"}),s.jsx("span",{className:"capture-ext-desc",children:"Route paywalled articles through a Freedium mirror (Medium, NYT, WaPo, etc.)"})]}),s.jsx("button",{type:"button",role:"switch","aria-checked":K,className:`ext-toggle ext-toggle--sm${K?" ext-toggle--on":""}`,onClick:()=>ee(j=>!j),"aria-label":"Toggle Freedium mirror for this capture",children:s.jsx("span",{className:"ext-toggle-knob"})})]})]})]}),s.jsxs("div",{className:"capture-actions",children:[s.jsx("button",{type:"button",className:"capture-submit",onClick:te,disabled:Xt===0||$t||He||an||et,children:Xt>1?`Archive ${Xt}`:"Archive"}),s.jsx("button",{type:"button",className:"capture-cancel",onClick:()=>{var j;return(j=u.current)==null?void 0:j.close()},children:"Cancel"})]})]})})}function Jm({item:e,autoFocus:t,onLocatorChange:n,onQualityChange:r,onRemove:l,onSubmit:i,onPlaylistQualityChange:a,onPlaylistItemQualityChange:o,onPlaylistToggle:u,onSyncChange:d,onPlaylistItemDelete:y}){const g=c.useRef(null);c.useEffect(()=>{var w;t&&((w=g.current)==null||w.focus())},[t]);const m=(()=>{if(Dn(e.locator)){if(e.playlistProbeState==="probing")return s.jsx("span",{className:"capture-quality-probing","aria-label":"Probing playlist…",children:"…"});if(e.playlistProbeState==="done"){const w=[...new Set(e.playlistItems.flatMap(p=>p.qualities.map(f=>parseInt(f))))].sort((p,f)=>f-p),S=e.playlistItems.every(p=>p.has_audio),_=e.playlistItems.filter(p=>p.quality===null).length;return s.jsxs(s.Fragment,{children:[s.jsxs("select",{className:"capture-quality",value:e.playlistQuality??"",onChange:p=>a(p.target.value),"aria-label":"Playlist quality",children:[!e.playlistQuality&&s.jsx("option",{value:"",disabled:!0,children:"Select quality…"}),s.jsx("option",{value:"best",children:"Best quality"}),w.map(p=>s.jsxs("option",{value:`${p}p`,children:[p,"p"]},p)),S&&s.jsx("option",{value:"audio",children:"Audio only"})]}),_>0&&s.jsxs("span",{className:"capture-conflict-badge",children:[_," need selection"]})]})}return e.playlistProbeState==="error"?s.jsx("span",{className:"capture-quality-hint capture-quality-hint--error",children:"Probe failed — edit URL to retry"}):null}if(!ff(e.locator))return null;if(e.probeState==="probing")return s.jsx("span",{className:"capture-quality-probing","aria-label":"Checking available qualities",children:"…"});if(e.probeState==="done"){const w=e.probeQualities??[],S=e.probeHasAudio??!1;return w.length===0&&!S?s.jsx("span",{className:"capture-quality-hint",children:"No media detected"}):w.length===0&&S?s.jsx("select",{className:"capture-quality",value:"audio",onChange:_=>r(_.target.value),"aria-label":"Video quality",children:s.jsx("option",{value:"audio",children:"Audio only"})}):s.jsxs("select",{className:"capture-quality",value:e.quality||"best",onChange:_=>r(_.target.value),"aria-label":"Video quality",children:[s.jsx("option",{value:"best",children:"Best quality"}),w.map(_=>s.jsx("option",{value:_,children:_},_)),S&&s.jsx("option",{value:"audio",children:"Audio only"})]})}return null})(),x=Dn(e.locator)&&e.playlistProbeState==="done"?s.jsxs("label",{className:"capture-sync-row",children:[s.jsx("input",{type:"checkbox",checked:e.syncEnabled,onChange:w=>d(w.target.checked)}),s.jsx("span",{children:"Sync — skip already-archived videos"})]}):null;return s.jsxs("div",{className:"capture-row",children:[s.jsxs("div",{className:"capture-row-main",children:[Dn(e.locator)&&e.playlistProbeState==="done"?s.jsx("button",{type:"button",className:"capture-playlist-toggle capture-playlist-toggle--left",onClick:u,"aria-label":e.playlistExpanded?"Collapse video list":"Expand video list","aria-expanded":e.playlistExpanded,children:e.playlistExpanded?"▲":"▼"}):null,s.jsx("input",{ref:g,className:"capture-input",type:"text",placeholder:"https://… · yt:ID · ytm:ID · tweet:ID · x:ID",value:e.locator,onChange:w=>n(w.target.value),onKeyDown:w=>{w.key==="Enter"&&i()},autoComplete:"off",spellCheck:!1}),m,s.jsx("button",{type:"button",className:"capture-row-action capture-row-remove",onClick:l,"aria-label":"Remove",children:s.jsxs("svg",{viewBox:"0 0 16 16",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",children:[s.jsx("line",{x1:"3",y1:"3",x2:"13",y2:"13"}),s.jsx("line",{x1:"13",y1:"3",x2:"3",y2:"13"})]})})]}),e.error&&s.jsx("p",{className:"capture-row-error",children:e.error}),Dn(e.locator)&&e.playlistProbeState==="done"&&e.playlistExpanded?s.jsxs("div",{className:"capture-playlist-items",children:[e.playlistItems.map(w=>s.jsxs("div",{className:`capture-playlist-item${w.quality===null?" capture-playlist-item--conflict":""}`,children:[s.jsx("span",{className:"capture-playlist-item-title",children:w.title||w.url}),s.jsxs("select",{className:"capture-item-quality",value:w.quality??"",onChange:S=>o(w.id,S.target.value),"aria-label":`Quality for ${w.title||w.url}`,children:[w.quality===null&&s.jsx("option",{value:"",disabled:!0,children:"Choose…"}),s.jsx("option",{value:"best",children:"Best quality"}),w.qualities.map(S=>s.jsx("option",{value:S,children:S},S)),w.has_audio&&s.jsx("option",{value:"audio",children:"Audio only"})]}),w.quality===null&&s.jsx("span",{className:"capture-playlist-conflict-badge",children:"Choose quality"}),s.jsx("button",{type:"button",className:"capture-playlist-item-remove","aria-label":`Remove ${w.title||w.url}`,onClick:()=>y(w.id),children:"×"})]},w.id)),x]}):x]})}function Gm({item:e,onRemove:t}){const n=e.uploadStatus==="uploading",r=e.uploadStatus==="done",l=e.uploadStatus==="error";return s.jsxs("div",{className:"capture-row",children:[s.jsxs("div",{className:"capture-row-main",children:[s.jsx("span",{className:"capture-file-icon","aria-hidden":"true",children:s.jsxs("svg",{viewBox:"0 0 16 16",fill:"none",stroke:"currentColor",strokeWidth:"1.75",strokeLinecap:"round",strokeLinejoin:"round",style:{width:14,height:14},children:[s.jsx("rect",{x:"3",y:"1",width:"10",height:"14",rx:"1.5"}),s.jsx("line",{x1:"5.5",y1:"5.5",x2:"10.5",y2:"5.5"}),s.jsx("line",{x1:"5.5",y1:"8",x2:"10.5",y2:"8"}),s.jsx("line",{x1:"5.5",y1:"10.5",x2:"8.5",y2:"10.5"})]})}),s.jsx("span",{className:`capture-file-name${l?" capture-file-name--error":""}`,children:e.filename}),n&&s.jsxs("span",{className:"capture-file-progress-wrap","aria-label":`Uploading ${e.uploadProgress}%`,children:[s.jsx("span",{className:"capture-file-progress-bar",children:s.jsx("span",{className:"capture-file-progress-fill",style:{width:`${e.uploadProgress}%`}})}),s.jsxs("span",{className:"capture-file-progress-pct",children:[e.uploadProgress,"%"]})]}),r&&s.jsx("span",{className:"capture-file-badge capture-file-badge--done","aria-label":"Upload complete",children:s.jsx("svg",{viewBox:"0 0 16 16",fill:"none",stroke:"currentColor",strokeWidth:"2.5",strokeLinecap:"round",strokeLinejoin:"round",style:{width:11,height:11},children:s.jsx("polyline",{points:"3 8.5 6.5 12 13 5"})})}),l&&s.jsx("span",{className:"capture-file-badge capture-file-badge--error",children:"!"}),s.jsx("button",{type:"button",className:"capture-row-action capture-row-remove",onClick:t,"aria-label":"Remove",disabled:n,children:s.jsxs("svg",{viewBox:"0 0 16 16",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",children:[s.jsx("line",{x1:"3",y1:"3",x2:"13",y2:"13"}),s.jsx("line",{x1:"13",y1:"3",x2:"3",y2:"13"})]})})]}),l&&s.jsx("p",{className:"capture-row-error",children:e.uploadError||"Upload failed"})]})}function Ym({item:e,autoFocus:t,onTitleChange:n,onBodyChange:r,onMimeChange:l,onRemove:i,onSubmit:a}){const o=c.useRef(null);return c.useEffect(()=>{var u;t&&((u=o.current)==null||u.focus())},[t]),s.jsx("div",{className:"capture-row capture-text-row",children:s.jsxs("div",{className:"capture-row-main",children:[s.jsx("span",{className:"capture-text-icon","aria-hidden":"true",children:s.jsx("svg",{viewBox:"0 0 16 16",fill:"none",stroke:"currentColor",strokeWidth:"1.75",strokeLinecap:"round",strokeLinejoin:"round",style:{width:14,height:14},children:s.jsx("path",{d:"M2 3h12M2 7h12M2 11h8"})})}),s.jsxs("div",{className:"capture-text-inputs",children:[s.jsx("input",{ref:o,className:"capture-text-input capture-text-title",type:"text",placeholder:"Title",value:e.title,onChange:u=>n(u.target.value),maxLength:500}),s.jsx("textarea",{className:"capture-text-input capture-text-body",placeholder:"Body (markdown or plain text)",value:e.body,onChange:u=>r(u.target.value),rows:6}),s.jsx("div",{className:"capture-text-footer",children:s.jsxs("select",{className:"capture-text-mime",value:e.mime,onChange:u=>l(u.target.value),children:[s.jsx("option",{value:"text/markdown",children:"Markdown"}),s.jsx("option",{value:"text/plain",children:"Plain text"})]})})]}),s.jsx("button",{type:"button",className:"capture-row-action capture-row-remove",onClick:i,"aria-label":"Remove",children:s.jsxs("svg",{viewBox:"0 0 16 16",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",children:[s.jsx("line",{x1:"3",y1:"3",x2:"13",y2:"13"}),s.jsx("line",{x1:"13",y1:"3",x2:"3",y2:"13"})]})})]})})}const Zm=["list=","/playlist/","/channel/","yt:playlist:","yt:channel:","ytm:playlist:","spotify:playlist:","spotify:album:"];function eg(e){const t=e.toLowerCase();return Zm.some(n=>t.includes(n))}function tg(e){return e.length>80?`${e.slice(0,79)}…`:e}function ng({locator:e=""}){const t=String(e),n=eg(t);return s.jsxs("div",{className:"in-progress-entry-row",role:"status","aria-live":"polite",children:[s.jsx("span",{className:"cap-spinner in-progress-entry-row__spinner","aria-hidden":"true"}),s.jsx("span",{className:"in-progress-entry-row__locator",title:t,children:tg(t)}),s.jsxs("span",{className:"in-progress-entry-row__status",children:["Archiving…",n&&s.jsx("span",{className:"in-progress-entry-row__kind",children:"(playlist)"})]})]})}function ml(e){if(!e)return"0 B";const t=["B","KB","MB","GB"];let n=e,r=0;for(;n>=1024&&r").replace(/"/g,'"').replace(/'/g,"'").replace(/'/g,"'")}function xt(e){return rg(e)??""}function ko(e){if(!e)return"";const t=new Date(e);if(isNaN(t))return e;const n=r=>String(r).padStart(2,"0");return`${t.getUTCFullYear()}-${n(t.getUTCMonth()+1)}-${n(t.getUTCDate())} ${n(t.getUTCHours())}:${n(t.getUTCMinutes())}`}const Mu={youtube:'',youtube_music:'',spotify:'',x:'',instagram:'',facebook:'',tiktok:'',reddit:'',snapchat:'',local:'',web:'',other:'?'};function Us(e){return Mu[e]??Mu.other}function pf(e){return e.split("/").map(t=>t?t.split("-").map(n=>n.charAt(0).toUpperCase()+n.slice(1)).join(" "):"").join("/")}function lg({entry:e,index:t,onRowClick:n,selectedUids:r,isFirst:l,isLast:i,reorderDisabled:a,onMove:o,canReorder:u,isDragging:d,dropEdge:y,onHandleDragStart:g,onHandleDragEnd:m,onRowDragOver:x,onRowDrop:w}){const S=(r==null?void 0:r.size)===1&&r.has(e.entry_uid),_=(r==null?void 0:r.size)>=2&&r.has(e.entry_uid),p=xt(e.title)||xt(e.entry_uid),f=["child-entry-row",t%2===0?"child-entry-row--light":"child-entry-row--dark",S&&"is-selected",_&&"is-multi-selected",d&&"is-dragging",y==="before"&&"is-drop-before",y==="after"&&"is-drop-after"].filter(Boolean).join(" ");return s.jsxs("div",{className:f,tabIndex:0,"data-entry-uid":e.entry_uid,onMouseDown:h=>{h.shiftKey&&h.preventDefault()},onClick:h=>n(e,h),onDragOver:u?x:void 0,onDrop:u?w:void 0,"aria-keyshortcuts":u?"Alt+ArrowUp Alt+ArrowDown":void 0,onKeyDown:h=>{if(u&&h.altKey&&(h.key==="ArrowUp"||h.key==="ArrowDown")){h.preventDefault(),a||o(h.key==="ArrowUp"?-1:1);return}h.key==="Enter"&&n(e,h)},children:[s.jsx("div",{className:"col-check","aria-hidden":"true"}),s.jsx("div",{className:"col-added",children:ko(e.archived_at)}),s.jsxs("div",{className:"col-title",children:[u&&s.jsxs("span",{className:"child-reorder-controls",children:[s.jsx("span",{className:"child-drag-handle",draggable:!a,title:"Drag to reorder (or Alt+↑/↓)","aria-hidden":"true",onClick:h=>h.stopPropagation(),onDragStart:g,onDragEnd:m,children:"⋮⋮"}),s.jsx("button",{type:"button",className:"child-move-btn","aria-label":`Move ${p} up`,disabled:a||l,onClick:h=>{h.stopPropagation(),o(-1)},onKeyDown:h=>h.stopPropagation(),children:"↑"}),s.jsx("button",{type:"button",className:"child-move-btn","aria-label":`Move ${p} down`,disabled:a||i,onClick:h=>{h.stopPropagation(),o(1)},onKeyDown:h=>h.stopPropagation(),children:"↓"})]}),s.jsx("span",{className:"source-icon",children:s.jsx("span",{dangerouslySetInnerHTML:{__html:Us(e.source_kind)}})}),s.jsx("span",{className:"entry-title",children:p})]}),s.jsx("div",{className:"col-type",children:s.jsx("span",{className:"type-pill",children:xt(e.entity_kind)})}),s.jsx("div",{className:"col-size",children:s.jsx("span",{className:"size-total",children:ml(e.total_artifact_bytes)})}),s.jsx("div",{className:"url-cell col-url",children:xt(e.original_url)})]})}function sg({entry:e,archiveId:t,rowIndex:n,isSelected:r,isMultiSelected:l,onRowClick:i,selectedUids:a,deletedUids:o,renamedTitles:u,isPublicSession:d,canReorder:y=!1,onReorderError:g}){const[m,x]=c.useState(!1),[w,S]=c.useState(!1),[_,p]=c.useState(null),[f,h]=c.useState(!1),[k,E]=c.useState(!1),[P,N]=c.useState(null),[v,b]=c.useState(null),L=c.useRef(null),X=c.useRef(null),ne=e.source_kind==="web"&&e.entity_kind==="page"&&e.has_favicon&&t&&!m?s.jsx("img",{src:`/api/archives/${t}/entries/${e.entry_uid}/favicon`,width:"16",height:"16",alt:"",onError:()=>x(!0),style:{objectFit:"contain"}}):s.jsx("span",{dangerouslySetInnerHTML:{__html:Us(e.source_kind)}}),q=r||l,fe=e.child_count>0&&!d;function Le(U){U.stopPropagation(),i(e,{ctrlKey:!0,metaKey:!1,shiftKey:!1,preventDefault(){}})}async function ve(U){if(U.stopPropagation(),w){S(!1);return}if(S(!0),_===null&&!f){h(!0);try{const te=await Iu(t,e.entry_uid);p(te)}catch{p([])}finally{h(!1)}}}const I=(_??[]).filter(U=>!(o!=null&&o.has(U.entry_uid))).map(U=>u!=null&&u.has(U.entry_uid)?{...U,title:u.get(U.entry_uid)}:U);async function R(U){const te=_;p(U),E(!0);try{await tm(t,e.entry_uid,U.map(re=>re.entry_uid))}catch(re){if(p(te),g==null||g(re.message),re.status===400)try{p(await Iu(t,e.entry_uid))}catch{}}finally{E(!1)}}function K(U,te){if(!y||k||U===te||te<0||te>=I.length)return;const re=I.slice(),[Ue]=re.splice(U,1);re.splice(te,0,Ue),R(re)}function ee(U,te){var re;X.current=((re=I[U])==null?void 0:re.entry_uid)??null,K(U,U+te)}c.useEffect(()=>{const U=X.current;if(!U||!L.current)return;X.current=null;const te=L.current.querySelector(`[data-entry-uid="${CSS.escape(U)}"]`);te&&!te.contains(document.activeElement)&&te.focus()},[_]);function se(U,te){te.stopPropagation(),te.dataTransfer.effectAllowed="move",te.dataTransfer.setData("text/plain",U);const re=te.currentTarget.closest(".child-entry-row");re&&te.dataTransfer.setDragImage(re,16,re.offsetHeight/2),N(U)}function ie(){N(null),b(null)}function Y(U,te){if(!P)return;te.preventDefault(),te.dataTransfer.dropEffect="move";const re=te.currentTarget.getBoundingClientRect(),Ue=te.clientY>re.top+re.height/2;((v==null?void 0:v.uid)!==U||(v==null?void 0:v.after)!==Ue)&&b({uid:U,after:Ue})}function Ne(U,te){if(!P)return;te.preventDefault();const re=I.findIndex(Ie=>Ie.entry_uid===P),Ue=I.findIndex(Ie=>Ie.entry_uid===U),Lt=(v==null?void 0:v.uid)===U?v.after:!1;if(ie(),re===-1||Ue===-1)return;let yt=Ue+(Lt?1:0);re{U.shiftKey&&U.preventDefault()},onClick:U=>i(e,U),onKeyDown:U=>{U.key==="Enter"&&i(e,U)},children:[s.jsx("div",{className:"col-check",children:s.jsx("button",{type:"button",className:`row-checkbox${q?" is-checked":""}`,"aria-pressed":q,"aria-label":q?"Deselect entry":"Select entry",onClick:Le,onKeyDown:U=>U.stopPropagation()})}),s.jsx("div",{className:"col-added",children:ko(e.archived_at)}),s.jsxs("div",{className:"col-title",children:[fe&&s.jsx("button",{type:"button",className:`entry-expand-btn${w?" is-expanded":""}`,"aria-label":w?"Collapse children":`Expand ${e.child_count} items`,"aria-expanded":w,onClick:ve,onKeyDown:U=>U.stopPropagation()}),s.jsx("span",{className:"source-icon",children:ne}),s.jsx("span",{className:"entry-title",children:xt(e.title)||xt(e.entry_uid)}),fe&&s.jsx("span",{className:"child-count-badge","aria-hidden":"true",children:e.child_count})]}),s.jsx("div",{className:"col-type",children:s.jsx("span",{className:"type-pill",children:xt(e.entity_kind)})}),s.jsxs("div",{className:"col-size",children:[s.jsx("span",{className:"size-total",children:ml(e.total_artifact_bytes)}),e.cached_bytes>0&&e.cacheable_bytes>0&&s.jsxs("span",{className:"size-cached-pct",title:`${ml(e.cached_bytes)} already on disk from an earlier entry`,children:[Math.round(e.cached_bytes/e.cacheable_bytes*100),"% cached"]})]}),s.jsx("div",{className:"url-cell col-url",children:xt(e.original_url)})]}),w&&s.jsxs(s.Fragment,{children:[f&&s.jsx("div",{className:"child-entries-loading",children:"Loading…"}),s.jsx("div",{className:"child-entries",ref:L,"aria-busy":k,"aria-label":`${e.child_count} child entries`,children:I.map((U,te)=>s.jsx(lg,{entry:U,index:te,onRowClick:i,selectedUids:a,isFirst:te===0,isLast:te===I.length-1,reorderDisabled:k,canReorder:y,onMove:re=>ee(te,re),isDragging:P===U.entry_uid,dropEdge:(v==null?void 0:v.uid)===U.entry_uid&&P!==U.entry_uid?v.after?"after":"before":null,onHandleDragStart:re=>se(U.entry_uid,re),onHandleDragEnd:ie,onRowDragOver:re=>Y(U.entry_uid,re),onRowDrop:re=>Ne(U.entry_uid,re)},U.entry_uid))})]})]})}function ig({entries:e,selectedUids:t,onRowClick:n,archiveId:r,pendingCaptures:l=[],deletedUids:i,renamedTitles:a,isPublicSession:o,canReorder:u=!1,onChildReorderError:d}){return s.jsx("section",{id:"archive-view",className:"view is-active",children:s.jsxs("div",{className:"entry-table",children:[s.jsxs("div",{className:"entry-header-row",children:[s.jsx("div",{className:"col-check","aria-hidden":"true"}),s.jsx("div",{className:"col-added",children:"Added"}),s.jsx("div",{className:"col-title",children:"Title"}),s.jsx("div",{className:"col-type",children:"Type"}),s.jsx("div",{className:"col-size",children:"Size"}),s.jsx("div",{className:"col-url",children:"Original URL"})]}),s.jsxs("div",{id:"entries-body",children:[l.filter(y=>y.archiveId===r).reverse().map(y=>s.jsx(ng,{locator:y.locator},y.id)),e.map((y,g)=>s.jsx(sg,{entry:y,rowIndex:g,archiveId:r,isSelected:t.size===1&&t.has(y.entry_uid),isMultiSelected:t.size>=2&&t.has(y.entry_uid),onRowClick:n,selectedUids:t,deletedUids:i,renamedTitles:a,isPublicSession:o,canReorder:u,onReorderError:d},y.entry_uid))]})]})})}function ag(e){if(!e)return"—";try{return new Date(e).toLocaleString(void 0,{year:"numeric",month:"short",day:"numeric",hour:"2-digit",minute:"2-digit"})}catch{return e}}function og({status:e}){const t=e==="completed"?"run-status--completed":e==="failed"?"run-status--failed":e==="in_progress"?"run-status--in-progress":"",n=e?e.replace(/_/g," "):"—";return s.jsx("span",{className:`run-status ${t}`,children:n})}function ug({runs:e}){const[t,n]=c.useState(null);function r(l){n(i=>i===l?null:l)}return s.jsx("section",{id:"runs-view",className:"view is-active",children:s.jsxs("table",{className:"entry-table",children:[s.jsx("thead",{children:s.jsxs("tr",{children:[s.jsx("th",{children:"Started"}),s.jsx("th",{children:"Status"}),s.jsx("th",{children:"Requested"}),s.jsx("th",{children:"Completed"}),s.jsx("th",{children:"Failed"})]})}),s.jsx("tbody",{children:e.length===0?s.jsx("tr",{children:s.jsx("td",{colSpan:5,style:{color:"var(--muted)",padding:"24px 16px",textAlign:"center"},children:"No runs yet."})}):e.map(l=>{const i=l.status==="failed"&&l.error_summary,a=t===l.run_uid;return[s.jsxs("tr",{className:i?"run-row run-row--failed":"run-row",onClick:i?()=>r(l.run_uid):void 0,title:i?a?"Click to hide error":"Click to view error":void 0,children:[s.jsx("td",{children:ag(l.started_at)}),s.jsxs("td",{children:[s.jsx(og,{status:l.status}),i&&s.jsx("span",{className:"run-expand-hint","aria-hidden":"true",children:a?"▴":"▾"})]}),s.jsx("td",{children:l.requested_count??"—"}),s.jsx("td",{children:l.completed_count??"—"}),s.jsx("td",{children:l.failed_count??"—"})]},l.run_uid),i&&a&&s.jsx("tr",{className:"run-error-row",children:s.jsx("td",{colSpan:5,children:s.jsx("pre",{className:"run-error-detail",children:l.error_summary})})},`${l.run_uid}-detail`)]})})]})})}const cg=4;function dg({archives:e}){const{currentUser:t}=c.useContext(Hs)??{},n=t&&(t.role_bits&cg)!==0,[r,l]=c.useState("users"),[i,a]=c.useState([]),[o,u]=c.useState([]),[d,y]=c.useState(!1),[g,m]=c.useState(null),[x,w]=c.useState(""),[S,_]=c.useState(""),[p,f]=c.useState(""),[h,k]=c.useState(null),[E,P]=c.useState(!1),[N,v]=c.useState(""),[b,L]=c.useState(""),[X,Q]=c.useState(null),[ne,q]=c.useState(!1),fe=c.useCallback(async()=>{if(n){y(!0),m(null);try{const[R,K]=await Promise.all([_m(),df()]);a(R),u(K)}catch(R){m(R.message)}finally{y(!1)}}},[n]);c.useEffect(()=>{fe()},[fe]);async function Le(R){const K=R.status==="active"?"disabled":"active";try{await Cm(R.user_uid,K),a(ee=>ee.map(se=>se.user_uid===R.user_uid?{...se,status:K}:se))}catch(ee){m(ee.message)}}async function ve(R){if(R.preventDefault(),!x.trim()||!S){k("Username and password required");return}P(!0),k(null);try{await Nm(x.trim(),S,p.trim()||void 0),w(""),_(""),f(""),await fe()}catch(K){k(K.message)}finally{P(!1)}}async function I(R){if(R.preventDefault(),!N.trim()||!b.trim()){Q("Slug and name required");return}q(!0),Q(null);try{await Em(N.trim(),b.trim()),v(""),L(""),await fe()}catch(K){Q(K.message)}finally{q(!1)}}return n?s.jsxs("section",{id:"admin-view",className:"view admin-view is-active",children:[s.jsx("h1",{children:"Admin"}),s.jsxs("div",{className:"view-tabs",children:[s.jsx("button",{className:`view-tab${r==="users"?" is-active":""}`,onClick:()=>l("users"),children:"Users"}),s.jsx("button",{className:`view-tab${r==="roles"?" is-active":""}`,onClick:()=>l("roles"),children:"Roles"}),s.jsx("button",{className:`view-tab${r==="archives"?" is-active":""}`,onClick:()=>l("archives"),children:"Archives"})]}),g&&s.jsx("div",{className:"form-msg form-msg--err",children:g}),r==="users"&&s.jsxs("div",{className:"admin-section",children:[s.jsx("h2",{children:"Users"}),d?s.jsx("p",{className:"muted",children:"Loading…"}):s.jsxs("table",{className:"admin-table",children:[s.jsx("thead",{children:s.jsxs("tr",{children:[s.jsx("th",{children:"Username"}),s.jsx("th",{children:"Email"}),s.jsx("th",{children:"Roles"}),s.jsx("th",{children:"Status"}),s.jsx("th",{children:"Actions"})]})}),s.jsx("tbody",{children:i.map(R=>s.jsxs("tr",{className:R.status==="disabled"?"admin-row-disabled":"",children:[s.jsx("td",{children:R.username}),s.jsx("td",{className:"muted",children:R.email||"—"}),s.jsx("td",{children:R.role_slugs.join(", ")||"—"}),s.jsx("td",{children:s.jsx("span",{className:`status-badge status-${R.status}`,children:R.status})}),s.jsx("td",{children:s.jsx("button",{className:"admin-action-btn",onClick:()=>Le(R),children:R.status==="active"?"Ban":"Unban"})})]},R.user_uid))})]}),s.jsx("h3",{children:"Create User"}),s.jsxs("form",{className:"admin-form",onSubmit:ve,children:[s.jsx("input",{className:"admin-input",placeholder:"Username",value:x,onChange:R=>w(R.target.value),required:!0}),s.jsx("input",{className:"admin-input",type:"password",placeholder:"Password (min 8 chars)",value:S,onChange:R=>_(R.target.value),required:!0}),s.jsx("input",{className:"admin-input",type:"email",placeholder:"Email (optional)",value:p,onChange:R=>f(R.target.value)}),h&&s.jsx("div",{className:"form-msg form-msg--err",children:h}),s.jsx("button",{className:"btn-primary",type:"submit",disabled:E,children:E?"Creating…":"Create User"})]})]}),r==="roles"&&s.jsxs("div",{className:"admin-section",children:[s.jsx("h2",{children:"Roles"}),d?s.jsx("p",{className:"muted",children:"Loading…"}):s.jsxs("table",{className:"admin-table",children:[s.jsx("thead",{children:s.jsxs("tr",{children:[s.jsx("th",{children:"Slug"}),s.jsx("th",{children:"Name"}),s.jsx("th",{children:"Level"}),s.jsx("th",{children:"Bit"}),s.jsx("th",{children:"Built-in"})]})}),s.jsx("tbody",{children:o.map(R=>s.jsxs("tr",{children:[s.jsx("td",{children:s.jsx("code",{children:R.slug})}),s.jsx("td",{children:R.name}),s.jsx("td",{children:R.level}),s.jsx("td",{children:R.bit_position}),s.jsx("td",{children:R.is_builtin?"✓":""})]},R.role_uid))})]}),s.jsx("h3",{children:"Create Custom Role"}),s.jsxs("form",{className:"admin-form",onSubmit:I,children:[s.jsx("input",{className:"admin-input",placeholder:"Slug (e.g. moderator)",value:N,onChange:R=>v(R.target.value),required:!0}),s.jsx("input",{className:"admin-input",placeholder:"Display Name (e.g. Moderator)",value:b,onChange:R=>L(R.target.value),required:!0}),X&&s.jsx("div",{className:"form-msg form-msg--err",children:X}),s.jsx("button",{className:"btn-primary",type:"submit",disabled:ne,children:ne?"Creating…":"Create Role"})]})]}),r==="archives"&&s.jsxs("div",{className:"admin-section",children:[s.jsx("h2",{children:"Mounted Archives"}),s.jsx("div",{className:"admin-list",children:e.map(R=>s.jsxs("div",{className:"admin-archive",children:[s.jsx("strong",{children:R.label}),s.jsx("div",{className:"muted",children:R.archive_path})]},R.id))})]})]}):s.jsxs("section",{id:"admin-view",className:"view admin-view is-active",children:[s.jsx("h1",{children:"Admin"}),s.jsx("p",{className:"muted",children:"You need admin privileges to access this panel."}),s.jsx("h2",{children:"Mounted Archives"}),s.jsx("div",{className:"admin-list",children:e.map(R=>s.jsxs("div",{className:"admin-archive",children:[s.jsx("strong",{children:R.label}),s.jsx("div",{className:"muted",children:R.archive_path})]},R.id))})]})}function hf({node:e,onPick:t}){var n;return s.jsxs("li",{children:[s.jsx("button",{className:"tag-picker-node-btn",title:e.tag.full_path,onClick:()=>t(e.tag),children:e.tag.slug}),((n=e.children)==null?void 0:n.length)>0&&s.jsx("div",{className:"tag-children",children:s.jsx("ul",{className:"tag-tree-list",children:e.children.map(r=>s.jsx(hf,{node:r,onPick:t},r.tag.tag_uid))})})]})}function Fu({title:e,tagNodes:t,excludeUid:n,onPick:r,onCancel:l}){c.useEffect(()=>{function o(u){u.key==="Escape"&&(u.preventDefault(),u.stopPropagation(),l())}return document.addEventListener("keydown",o),()=>document.removeEventListener("keydown",o)},[l]);function i(o){return o.filter(u=>u.tag.tag_uid!==n).map(u=>({...u,children:i(u.children)}))}const a=n?i(t):t;return s.jsx("div",{className:"tag-picker-backdrop",onClick:o=>{o.target===o.currentTarget&&l()},children:s.jsxs("div",{className:"tag-picker-modal",role:"dialog","aria-modal":"true",children:[s.jsxs("div",{className:"tag-picker-header",children:[s.jsx("span",{className:"tag-picker-title",children:e}),s.jsx("button",{className:"tag-picker-close",onClick:l,title:"Cancel","aria-label":"Cancel",children:"×"})]}),s.jsxs("div",{className:"tag-picker-body",children:[s.jsx("button",{className:"tag-picker-root-btn",onClick:()=>r(null),title:"Place at root level (no parent)",children:"↑ Root tag (no parent)"}),a.length>0?s.jsx("ul",{className:"tag-tree-list tag-picker-tree",children:a.map(o=>s.jsx(hf,{node:o,onPick:r},o.tag.tag_uid))}):s.jsx("p",{className:"tag-picker-empty",children:"No other tags available."})]})]})})}function mf({parentPath:e,archiveId:t,onDone:n,onCancel:r}){const[l,i]=c.useState(""),a=c.useRef(!1);async function o(){const u=l.trim();if(!u){r();return}const d=e?`${e}/${u}`:`/${u}`;try{await om(t,d),n()}catch(y){alert(y.message||"Create failed"),r()}}return s.jsx("input",{className:"tag-rename-input",autoFocus:!0,placeholder:"tag name",value:l,onChange:u=>i(u.target.value),onKeyDown:u=>{u.key==="Enter"&&u.currentTarget.blur(),u.key==="Escape"&&(a.current=!0,u.currentTarget.blur())},onBlur:()=>{a.current?(a.current=!1,r()):o()}})}function gf({node:e,archiveId:t,tagFilter:n,onTagFilterSet:r,onViewChange:l,onTagRenamed:i,onTagDeleted:a,onTagsRefresh:o,humanizeTags:u,moveSelectMode:d,onMoveSourceSelect:y,pendingCreateParentUid:g,onCreateDone:m,onCreateCancel:x}){var X;const w=n===e.tag.full_path,[S,_]=c.useState(!1),[p,f]=c.useState(""),h=c.useRef(!1);function k(){if(S)return;if(d){y(e);return}const Q=w?null:e.tag.full_path;r(Q),l("archive")}function E(Q){Q.stopPropagation(),!d&&(f(e.tag.slug),_(!0))}async function P(){const Q=p.trim();if(!Q||Q===e.tag.slug){_(!1);return}try{const ne=await im(t,e.tag.tag_uid,Q);i(e.tag.full_path,ne.full_path),o()}catch{}finally{_(!1)}}async function N(Q){var q;Q.stopPropagation();const ne=((q=e.children)==null?void 0:q.length)>0?`Delete tag "${e.tag.full_path}" and all its child tags? This cannot be undone.`:`Delete tag "${e.tag.full_path}"? This cannot be undone.`;if(window.confirm(ne))try{await am(t,e.tag.tag_uid),a(e.tag.full_path),o()}catch{}}const v={archiveId:t,tagFilter:n,onTagFilterSet:r,onViewChange:l,onTagRenamed:i,onTagDeleted:a,onTagsRefresh:o,humanizeTags:u,moveSelectMode:d,onMoveSourceSelect:y,pendingCreateParentUid:g,onCreateDone:m,onCreateCancel:x},b=g===e.tag.tag_uid,L=((X=e.children)==null?void 0:X.length)>0;return s.jsxs("li",{children:[s.jsxs("div",{className:`tag-node-row${d?" tag-node-row--move-select":""}`,children:[S?s.jsx("input",{className:"tag-rename-input",autoFocus:!0,value:p,onChange:Q=>f(Q.target.value),onKeyDown:Q=>{Q.key==="Enter"&&Q.currentTarget.blur(),Q.key==="Escape"&&(h.current=!0,Q.currentTarget.blur())},onBlur:()=>{h.current?(h.current=!1,_(!1)):P()}}):s.jsxs("button",{className:`tag-node-btn${w?" is-active":""}${d?" tag-node-btn--move-select":""}`,title:d?`Select "${e.tag.full_path}" to move`:e.tag.full_path,onClick:k,onDoubleClick:d?void 0:E,children:[s.jsx("span",{className:"tag-node-label",children:u?e.tag.name:e.tag.slug}),s.jsx("span",{className:"tag-node-count",children:e.children.length===0?`(${e.entry_count})`:`(${e.entry_count}) (${e.subtree_count} Total)`}),!d&&s.jsx("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:Q=>{Q.stopPropagation(),E(Q)},children:s.jsx("path",{d:"M11.5 2.5a1.5 1.5 0 0 1 2 2L5 13l-3 1 1-3 8.5-8.5z"})})]}),!S&&!d&&s.jsx("button",{className:"remove tag-node-delete",title:`Delete "${e.tag.full_path}"`,onClick:N,"aria-label":`Delete "${e.tag.full_path}"`,children:"×"})]}),(L||b)&&s.jsx("div",{className:"tag-children",children:s.jsxs("ul",{className:"tag-tree-list",children:[e.children.map(Q=>s.jsx(gf,{node:Q,...v},Q.tag.tag_uid)),b&&s.jsx("li",{children:s.jsx(mf,{parentPath:e.tag.full_path,archiveId:t,onDone:m,onCancel:x})})]})})]})}function fg({archiveId:e,tagNodes:t,tagFilter:n,onTagFilterSet:r,onViewChange:l,onTagRenamed:i,onTagDeleted:a,onTagsRefresh:o,humanizeTags:u}){const[d,y]=c.useState(null),[g,m]=c.useState(null);function x(){y("select-source"),m(null)}function w(){y(null),m(null)}c.useEffect(()=>{if(d!=="select-source")return;function ne(q){q.key==="Escape"&&(q.preventDefault(),q.stopPropagation(),w())}return document.addEventListener("keydown",ne),()=>document.removeEventListener("keydown",ne)},[d]);function S(ne){m(ne),y("select-dest")}async function _(ne){const q=g.tag.full_path,fe=g.tag.tag_uid,Le=(ne==null?void 0:ne.tag_uid)??null;w();try{const ve=await um(e,fe,Le);i(q,ve.full_path),o()}catch(ve){alert(ve.message||"Move failed")}}const[p,f]=c.useState(null),[h,k]=c.useState(void 0);function E(){f("select-parent"),k(void 0)}function P(){f(null),k(void 0)}function N(ne){k(ne??null),f("input")}function v(){f(null),k(void 0),o()}const b=d==="select-source",L=p==="input"?h?h.tag_uid:"__root__":void 0,X={archiveId:e,tagFilter:n,onTagFilterSet:r,onViewChange:l,onTagRenamed:i,onTagDeleted:a,onTagsRefresh:o,humanizeTags:u,moveSelectMode:b,onMoveSourceSelect:S,pendingCreateParentUid:L,onCreateDone:v,onCreateCancel:P},Q=L==="__root__";return s.jsxs("section",{id:"tags-view",className:"view is-active",children:[s.jsxs("div",{className:"tag-tree",children:[s.jsx("div",{className:"tag-tree-header",children:b?s.jsxs(s.Fragment,{children:[s.jsx("span",{className:"tag-tree-title tag-tree-title--move",children:"Select a tag to move"}),s.jsx("button",{className:"tag-tree-action-btn tag-tree-action-btn--cancel",onClick:w,children:"Cancel"})]}):s.jsxs(s.Fragment,{children:[s.jsx("span",{className:"tag-tree-title",children:"Tags"}),n&&s.jsxs("span",{className:"tag-tree-active",title:n,children:["Filtering: ",n]}),s.jsxs("div",{className:"tag-tree-actions",children:[s.jsx("button",{className:"tag-tree-action-btn",onClick:E,title:"Create a new tag",disabled:!!p||!!d,children:"+ New"}),s.jsx("button",{className:"tag-tree-action-btn",onClick:x,title:"Move a tag to a different parent",disabled:!!p||!!d||t.length===0,children:"Move"})]})]})}),t.length===0&&!Q?s.jsx("p",{className:"muted",style:{padding:"8px 0"},children:"No tags yet."}):s.jsxs("ul",{className:"tag-tree-list",children:[t.map(ne=>s.jsx(gf,{node:ne,...X},ne.tag.tag_uid)),Q&&s.jsx("li",{children:s.jsx(mf,{parentPath:null,archiveId:e,onDone:v,onCancel:P})})]})]}),p==="select-parent"&&s.jsx(Fu,{title:"Create tag under…",tagNodes:t,excludeUid:null,onPick:N,onCancel:P}),d==="select-dest"&&g&&s.jsx(Fu,{title:`Move "${g.tag.slug}" under…`,tagNodes:t,excludeUid:g.tag.tag_uid,onPick:_,onCancel:w})]})}const Fr=[{value:0,label:"Private"},{value:2,label:"Users only"},{value:3,label:"Public"}],pg=e=>{var t;return((t=Fr.find(n=>n.value===e))==null?void 0:t.label)??String(e)};function hg({archiveId:e}){const[t,n]=c.useState([]),[r,l]=c.useState(!1),[i,a]=c.useState(null),[o,u]=c.useState(null),[d,y]=c.useState(null),[g,m]=c.useState(!1),[x,w]=c.useState(null),[S,_]=c.useState(""),[p,f]=c.useState(""),[h,k]=c.useState(2),[E,P]=c.useState(!1),[N,v]=c.useState(!0),[b,L]=c.useState(null),[X,Q]=c.useState(""),[ne,q]=c.useState(2),[fe,Le]=c.useState(!1),[ve,I]=c.useState(null),[R,K]=c.useState(!1),[ee,se]=c.useState(""),ie=c.useRef(null),Y=t.find($=>$.collection_uid===o)??null,Ne=(Y==null?void 0:Y.slug)==="_default_",pe=c.useCallback(async()=>{if(e){l(!0),a(null);try{const $=await wo(e);n($)}catch($){a($.message)}finally{l(!1)}}},[e]),U=c.useCallback(async $=>{if(!$){y(null);return}m(!0),w(null);try{const Z=await Tm(e,$);y(Z)}catch(Z){w(Z.message)}finally{m(!1)}},[e]);c.useEffect(()=>{pe()},[pe]),c.useEffect(()=>{U(o)},[o,U]),c.useEffect(()=>{R&&ie.current&&ie.current.focus()},[R]),c.useEffect(()=>{d!=null&&q(d.default_visibility_bits)},[d]);async function te($){$.preventDefault();const Z=S.trim(),Ae=p.trim();if(!(!Z||!Ae)){P(!0),L(null);try{const Me=await bm(e,Z,Ae,h,N);_(""),f(""),k(2),v(!0),await pe(),u(Me.collection_uid)}catch(Me){L(Me.message)}finally{P(!1)}}}async function re(){const $=ee.trim();if(!$||!Y){K(!1);return}try{await Ci(e,Y.collection_uid,{name:$}),await pe(),y(Z=>Z&&{...Z,name:$})}catch(Z){a(Z.message)}finally{K(!1)}}async function Ue($){if(Y)try{await Ci(e,Y.collection_uid,{default_visibility_bits:$}),await pe(),y(Z=>Z&&{...Z,default_visibility_bits:$})}catch(Z){a(Z.message)}}async function Lt($){if(Y)try{await Ci(e,Y.collection_uid,{requires_auth:$}),await pe(),y(Z=>Z&&{...Z,requires_auth:$})}catch(Z){a(Z.message)}}async function yt(){if(Y&&window.confirm(`Delete collection "${Y.name}"? Entries will not be deleted.`))try{await Rm(e,Y.collection_uid),u(null),y(null),await pe()}catch($){a($.message)}}async function Ie($){$.preventDefault();const Z=X.trim();if(!(!Z||!Y)){Le(!0),I(null);try{await Sa(e,Y.collection_uid,Z,ne),Q(""),await U(Y.collection_uid)}catch(Ae){I(Ae.message)}finally{Le(!1)}}}async function st($){if(Y)try{await Pm(e,Y.collection_uid,$),await U(Y.collection_uid)}catch(Z){w(Z.message)}}async function Ze($,Z){if(Y)try{await Lm(e,Y.collection_uid,$,Z),y(Ae=>Ae&&{...Ae,entries:Ae.entries.map(Me=>Me.entry_uid===$?{...Me,collection_visibility_bits:Z}:Me)})}catch(Ae){w(Ae.message)}}return e?s.jsxs("div",{className:"collections-view",children:[s.jsx("h2",{className:"collections-heading",children:"Collections"}),r&&s.jsx("div",{className:"muted",children:"Loading…"}),i&&s.jsxs("div",{className:"collections-error",children:[i," ",s.jsx("button",{onClick:()=>a(null),className:"coll-dismiss",children:"×"})]}),s.jsxs("div",{className:"collections-layout",children:[s.jsxs("div",{className:"collections-sidebar",children:[t.map($=>s.jsxs("button",{className:`coll-sidebar-row${o===$.collection_uid?" is-active":""}`,onClick:()=>u($.collection_uid),children:[s.jsx("span",{className:"coll-row-name",children:$.name}),s.jsx("span",{className:"coll-row-meta",children:pg($.default_visibility_bits)})]},$.collection_uid)),t.length===0&&!r&&s.jsx("div",{className:"muted",style:{padding:"8px 12px"},children:"No collections yet."})]}),Y?s.jsxs("div",{className:"coll-detail",children:[s.jsxs("div",{className:"coll-detail-header",children:[R?s.jsx("input",{ref:ie,className:"coll-rename-input",value:ee,onChange:$=>se($.target.value),onBlur:re,onKeyDown:$=>{$.key==="Enter"&&re(),$.key==="Escape"&&K(!1)}}):s.jsxs("h3",{className:`coll-detail-name${Ne?"":" coll-detail-name--editable"}`,title:Ne?void 0:"Click to rename",onClick:()=>{Ne||(se(Y.name),K(!0))},children:[(d==null?void 0:d.name)??Y.name,!Ne&&s.jsx("span",{className:"coll-edit-hint",children:" ✎"})]}),!Ne&&s.jsx("button",{className:"coll-delete-btn",onClick:yt,title:"Delete collection",children:"Delete"})]}),s.jsxs("div",{className:"coll-detail-vis",children:[s.jsx("span",{className:"coll-vis-label",children:"Entries' default visibility"}),s.jsx("select",{className:"coll-vis-select",value:(d==null?void 0:d.default_visibility_bits)??Y.default_visibility_bits,onChange:$=>Ue(Number($.target.value)),children:Fr.map($=>s.jsx("option",{value:$.value,children:$.label},$.value))})]}),s.jsxs("div",{className:"coll-detail-vis",children:[s.jsx("span",{className:"coll-vis-label",children:"Collection access"}),s.jsxs("label",{className:"coll-auth-label",children:[s.jsx("input",{type:"checkbox",checked:!!((d==null?void 0:d.requires_auth)??Y.requires_auth),onChange:$=>Lt($.target.checked)})," Require authentication to view"]})]}),s.jsxs("div",{className:"coll-entries-section",children:[s.jsx("div",{className:"coll-section-heading",children:"Entries"}),g&&s.jsx("div",{className:"muted",children:"Loading…"}),x&&s.jsx("div",{className:"collections-error",children:x}),!g&&d&&(d.entries.length===0?s.jsx("div",{className:"muted",children:"No entries in this collection."}):s.jsx("ul",{className:"coll-entries-list",children:d.entries.map($=>s.jsxs("li",{className:"coll-entry-row",children:[s.jsxs("div",{className:"coll-entry-info",children:[s.jsx("span",{className:"coll-entry-title",children:$.title||$.entry_uid}),s.jsx("span",{className:"coll-entry-kind muted",children:$.source_kind})]}),s.jsxs("div",{className:"coll-entry-actions",children:[s.jsx("select",{className:"coll-entry-vis-select",value:$.collection_visibility_bits,onChange:Z=>Ze($.entry_uid,Number(Z.target.value)),children:Fr.map(Z=>s.jsx("option",{value:Z.value,children:Z.label},Z.value))}),!Ne&&s.jsx("button",{className:"coll-entry-remove",onClick:()=>st($.entry_uid),title:"Remove from collection",children:"×"})]})]},$.entry_uid))}))]}),!Ne&&s.jsxs("form",{className:"coll-add-entry-form",onSubmit:Ie,children:[s.jsx("div",{className:"coll-section-heading",children:"Add entry"}),s.jsxs("div",{className:"coll-add-entry-row",children:[s.jsx("input",{className:"coll-add-entry-input",type:"text",value:X,onChange:$=>Q($.target.value),placeholder:"entry_uid",required:!0}),s.jsx("select",{className:"coll-vis-select",value:ne,onChange:$=>q(Number($.target.value)),children:Fr.map($=>s.jsx("option",{value:$.value,children:$.label},$.value))}),s.jsx("button",{className:"coll-add-btn",type:"submit",disabled:fe,children:fe?"…":"Add"})]}),ve&&s.jsx("div",{className:"collections-error",style:{marginTop:4},children:ve})]})]}):s.jsx("div",{className:"coll-detail coll-detail--empty",children:s.jsx("div",{className:"muted",children:"Select a collection to view details."})})]}),s.jsxs("details",{className:"coll-create-details",children:[s.jsx("summary",{children:"+ Create collection"}),s.jsxs("form",{className:"coll-create-form",onSubmit:te,children:[s.jsxs("div",{className:"form-field",children:[s.jsx("label",{className:"form-label",htmlFor:"coll-name",children:"Name"}),s.jsx("input",{className:"capture-input",id:"coll-name",type:"text",value:S,onChange:$=>{_($.target.value),p||f($.target.value.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,""))},placeholder:"My Collection",required:!0})]}),s.jsxs("div",{className:"form-field",children:[s.jsx("label",{className:"form-label",htmlFor:"coll-slug",children:"Slug"}),s.jsx("input",{className:"capture-input",id:"coll-slug",type:"text",value:p,onChange:$=>f($.target.value),placeholder:"my-collection",required:!0})]}),s.jsxs("div",{className:"form-field",children:[s.jsx("label",{className:"form-label",htmlFor:"coll-vis",children:"Entries' default visibility"}),s.jsx("select",{className:"capture-input",id:"coll-vis",style:{height:42},value:h,onChange:$=>k(Number($.target.value)),children:Fr.map($=>s.jsx("option",{value:$.value,children:$.label},$.value))})]}),s.jsx("div",{className:"form-field",children:s.jsxs("label",{className:"form-label",children:[s.jsx("input",{type:"checkbox",checked:N,onChange:$=>v($.target.checked)})," Require authentication to view"]})}),b&&s.jsx("div",{className:"collections-error",children:b}),s.jsx("button",{className:"btn-primary",type:"submit",disabled:E,children:E?"Creating…":"Create collection"})]})]})]}):s.jsx("div",{className:"view-placeholder",children:"Select an archive."})}const mg=4,gg=8;function yg({tab:e,onTabChange:t,archiveId:n}){const{currentUser:r,setCurrentUser:l}=c.useContext(Hs)??{},i=r&&(r.role_bits&mg)!==0,a=!!r&&(r.role_bits&gg)!==0,o=["profile","tokens",...i?["instance","cookies","extensions","storage"]:[]],u={profile:"Profile",tokens:"API Tokens",instance:"Instance",cookies:"Cookies",extensions:"Extensions",storage:"Storage"};return s.jsxs("section",{className:"admin-view",children:[s.jsx("h1",{children:"Settings"}),s.jsx("div",{className:"view-tabs",children:o.map(d=>s.jsx("button",{className:`view-tab${e===d?" is-active":""}`,onClick:()=>t(d),children:u[d]},d))}),e==="profile"&&s.jsx(vg,{currentUser:r,setCurrentUser:l}),e==="tokens"&&s.jsx(xg,{}),e==="instance"&&i&&s.jsx(wg,{isOwner:a,setCurrentUser:l}),e==="cookies"&&i&&s.jsx(jg,{}),e==="extensions"&&i&&s.jsx(Sg,{}),e==="storage"&&i&&s.jsx(kg,{archiveId:n})]})}function vg({currentUser:e,setCurrentUser:t}){const[n,r]=c.useState((e==null?void 0:e.display_name)??""),[l,i]=c.useState(!1),[a,o]=c.useState(null),[u,d]=c.useState(""),[y,g]=c.useState(""),[m,x]=c.useState(""),[w,S]=c.useState(!1),[_,p]=c.useState(null);async function f(k){k.preventDefault(),i(!0),o(null);try{await vm(n),t(E=>({...E,display_name:n||null})),o({ok:!0,text:"Saved."})}catch(E){o({ok:!1,text:E.message})}finally{i(!1)}}async function h(k){if(k.preventDefault(),y!==m){p({ok:!1,text:"Passwords do not match."});return}S(!0),p(null);try{await wm(u,y),d(""),g(""),x(""),p({ok:!0,text:"Password changed."})}catch(E){p({ok:!1,text:E.message})}finally{S(!1)}}return s.jsxs("div",{style:{maxWidth:440},children:[s.jsxs("div",{className:"form-section",children:[s.jsx("h2",{children:"Display Name"}),s.jsxs("form",{onSubmit:f,children:[s.jsxs("div",{className:"form-field",children:[s.jsx("label",{className:"form-label",htmlFor:"display-name",children:"Name shown in the UI"}),s.jsx("input",{className:"field-input",id:"display-name",placeholder:(e==null?void 0:e.username)??"",value:n,onChange:k=>r(k.target.value)})]}),a&&s.jsx("div",{className:`form-msg form-msg--${a.ok?"ok":"err"}`,children:a.text}),s.jsx("button",{className:"btn-primary",type:"submit",disabled:l,children:l?"Saving…":"Save"})]})]}),s.jsxs("div",{className:"form-section",children:[s.jsx("h2",{children:"Display Preferences"}),s.jsxs("label",{className:"checkbox-row",children:[s.jsx("input",{type:"checkbox",checked:(e==null?void 0:e.humanize_slugs)??!1,onChange:async k=>{const E=k.target.checked;try{await xm({humanize_slugs:E}),t(P=>({...P,humanize_slugs:E}))}catch{}}}),s.jsx("span",{className:"form-label",style:{margin:0},children:"Humanize tag display"})]}),s.jsx("p",{className:"muted",style:{fontSize:13,margin:"4px 0 0"},children:'When on, tag paths show as "X / Articles" instead of "x/articles".'})]}),s.jsxs("div",{className:"form-section",children:[s.jsx("h2",{children:"Change Password"}),s.jsxs("form",{onSubmit:h,children:[s.jsxs("div",{className:"form-field",children:[s.jsx("label",{className:"form-label",htmlFor:"cur-pw",children:"Current password"}),s.jsx("input",{className:"field-input",id:"cur-pw",type:"password",value:u,onChange:k=>d(k.target.value),required:!0})]}),s.jsxs("div",{className:"form-field",children:[s.jsx("label",{className:"form-label",htmlFor:"new-pw",children:"New password"}),s.jsx("input",{className:"field-input",id:"new-pw",type:"password",value:y,onChange:k=>g(k.target.value),required:!0})]}),s.jsxs("div",{className:"form-field",children:[s.jsx("label",{className:"form-label",htmlFor:"confirm-pw",children:"Confirm new password"}),s.jsx("input",{className:"field-input",id:"confirm-pw",type:"password",value:m,onChange:k=>x(k.target.value),required:!0})]}),_&&s.jsx("div",{className:`form-msg form-msg--${_.ok?"ok":"err"}`,children:_.text}),s.jsx("button",{className:"btn-primary",type:"submit",disabled:w,children:w?"Changing…":"Change Password"})]})]})]})}function xg(){const[e,t]=c.useState([]),[n,r]=c.useState(!0),[l,i]=c.useState(null),[a,o]=c.useState(""),[u,d]=c.useState(!1),[y,g]=c.useState(null),m=c.useCallback(async()=>{r(!0),i(null);try{t(await km())}catch(S){i(S.message)}finally{r(!1)}},[]);c.useEffect(()=>{m()},[m]);async function x(S){if(S.preventDefault(),!!a.trim()){d(!0);try{const _=await jm(a.trim());g(_),o(""),m()}catch(_){i(_.message)}finally{d(!1)}}}async function w(S){try{await Sm(S),t(_=>_.filter(p=>p.token_uid!==S))}catch(_){i(_.message)}}return s.jsx("div",{style:{maxWidth:600},children:s.jsxs("div",{className:"form-section",children:[s.jsx("h2",{children:"API Tokens"}),y&&s.jsxs("div",{className:"token-banner",children:[s.jsx("strong",{children:"Token created."})," Copy it now — it won't be shown again.",s.jsx("code",{children:y.raw_token}),s.jsx("button",{className:"token-dismiss",onClick:()=>g(null),children:"Dismiss"})]}),s.jsxs("form",{className:"token-create-row",onSubmit:x,children:[s.jsx("input",{className:"field-input field-input--flex",placeholder:"Token name",value:a,onChange:S=>o(S.target.value),required:!0}),s.jsx("button",{className:"btn-primary",type:"submit",disabled:u,children:u?"Creating…":"Create token"})]}),l&&s.jsx("div",{className:"form-msg form-msg--err",children:l}),n?s.jsx("div",{className:"muted",children:"Loading…"}):s.jsxs("div",{children:[e.length===0&&s.jsx("div",{className:"muted",children:"No tokens yet."}),e.map(S=>s.jsxs("div",{className:"token-row",children:[s.jsxs("div",{className:"token-row-info",children:[s.jsx("strong",{children:S.name}),s.jsxs("div",{className:"muted",children:["Created ",S.created_at.slice(0,10),S.last_used_at&&` · Last used ${S.last_used_at.slice(0,10)}`]})]}),s.jsx("button",{className:"btn-danger",style:{fontSize:12,padding:"4px 10px"},onClick:()=>w(S.token_uid),children:"Revoke"})]},S.token_uid))]})]})})}function wg({isOwner:e,setCurrentUser:t}){const[n,r]=c.useState(null),[l,i]=c.useState(!0),[a,o]=c.useState(null),[u,d]=c.useState(!1),[y,g]=c.useState(null),[m,x]=c.useState([]),[w,S]=c.useState(12),[_,p]=c.useState(!1),[f,h]=c.useState(null);c.useEffect(()=>{(async()=>{try{const[N,v]=await Promise.all([xo(),df()]);r(N),x(v.filter(b=>b.bit_position>0)),S(N.reorder_children_role_bits??12)}catch(N){o(N.message)}finally{i(!1)}})()},[]);function k(N,v){S(b=>v?(b|N)>>>0:(b&~N)>>>0)}async function E(N){N.preventDefault(),p(!0),h(null);try{await Jr({reorder_children_role_bits:w}),r(b=>({...b,reorder_children_role_bits:w}));const v=await cf();v&&(t==null||t(v)),h({ok:!0,text:"Saved."})}catch(v){h({ok:!1,text:v.message})}finally{p(!1)}}async function P(N){N.preventDefault(),d(!0),g(null);try{const{reorder_children_role_bits:v,...b}=n;await Jr(b),g({ok:!0,text:"Saved."})}catch(v){g({ok:!1,text:v.message})}finally{d(!1)}}return l?s.jsx("div",{className:"muted",children:"Loading…"}):a?s.jsx("div",{className:"form-msg form-msg--err",children:a}):n?s.jsxs("div",{style:{maxWidth:440},children:[s.jsxs("div",{className:"form-section",children:[s.jsx("h2",{children:"Instance Settings"}),s.jsxs("form",{onSubmit:P,children:[[["public_index_enabled","Public index (unauthenticated browsing)"],["public_entry_content_enabled","Public entry content"],["open_registration_enabled","Open registration"]].map(([N,v])=>s.jsxs("label",{className:"checkbox-row",children:[s.jsx("input",{type:"checkbox",checked:!!n[N],onChange:b=>r(L=>({...L,[N]:b.target.checked}))}),v]},N)),s.jsxs("div",{className:"form-field",style:{marginTop:4},children:[s.jsx("label",{className:"form-label",children:"Default entry visibility"}),s.jsxs("select",{className:"field-input",value:n.default_entry_visibility,onChange:N=>r(v=>({...v,default_entry_visibility:Number(N.target.value)})),children:[s.jsx("option",{value:0,children:"Private"}),s.jsx("option",{value:2,children:"Unlisted"}),s.jsx("option",{value:3,children:"Public"})]})]}),y&&s.jsx("div",{className:`form-msg form-msg--${y.ok?"ok":"err"}`,children:y.text}),s.jsx("button",{className:"btn-primary",type:"submit",disabled:u,children:u?"Saving…":"Save Settings"})]})]}),s.jsxs("div",{className:"form-section",children:[s.jsx("h2",{children:"Permissions"}),s.jsxs("form",{onSubmit:E,children:[s.jsx("label",{className:"form-label",children:"Reorder child entries"}),m.map(N=>{const v=1<>>0;return s.jsxs("label",{className:"checkbox-row",children:[s.jsx("input",{type:"checkbox",disabled:!e||_,checked:(w&v)!==0,onChange:b=>k(v,b.target.checked)}),N.name,!N.is_builtin&&s.jsx("span",{className:"muted",children:" (custom)"})]},N.role_uid)}),s.jsx("p",{className:"form-hint",children:"Roles are cumulative: every signed-in account also has User, and owners also have Admin. Checking User lets every signed-in account reorder."}),!e&&s.jsx("p",{className:"form-hint",children:"Only the owner can change this."}),f&&s.jsx("div",{className:`form-msg form-msg--${f.ok?"ok":"err"}`,children:f.text}),e&&s.jsx("button",{className:"btn-primary",type:"submit",disabled:_,children:_?"Saving…":"Save Permissions"})]})]})]}):null}function bi(e){if(e===0)return"0 B";const t=["B","KB","MB","GB","TB"],n=Math.floor(Math.log(e)/Math.log(1024));return`${(e/Math.pow(1024,n)).toFixed(n===0?0:1)} ${t[n]}`}function kg({archiveId:e}){const[t,n]=c.useState("idle"),[r,l]=c.useState(null),[i,a]=c.useState(null),[o,u]=c.useState(null);function d(){n("idle"),l(null),a(null),u(null)}async function y(){n("scanning"),u(null),l(null);try{const x=await zm(e);l(x),n("scanned")}catch(x){u(x.message),n("error")}}async function g(){n("deleting"),u(null);try{const x=await Im(e);a(x),n("done")}catch(x){u(x.message),n("error")}}const m=r&&r.deletable_files===0&&r.orphaned_blob_rows===0;return s.jsx("div",{style:{maxWidth:440},children:s.jsxs("div",{className:"form-section",children:[s.jsx("h2",{children:"Orphan Cleanup"}),s.jsxs("p",{className:"muted",style:{marginBottom:16},children:["Scan for blob files and database records that are no longer referenced by any archive entry and safely delete them."," ",s.jsx("strong",{children:"Cleanup is blocked while captures are running."})]}),!e&&s.jsx("div",{className:"muted",children:"No archive selected."}),e&&t==="idle"&&s.jsx("button",{className:"btn-ghost",onClick:y,children:"Scan for orphaned blobs"}),t==="scanning"&&s.jsx("div",{className:"muted",children:"Scanning…"}),t==="scanned"&&r&&m&&s.jsxs(s.Fragment,{children:[s.jsx("div",{className:"form-msg form-msg--ok",children:"Archive is clean — nothing to remove."}),s.jsx("button",{className:"btn-ghost",style:{marginTop:10},onClick:d,children:"Done"})]}),t==="scanned"&&r&&!m&&s.jsxs("div",{children:[s.jsxs("div",{style:{marginBottom:14,lineHeight:1.6},children:["Found ",s.jsx("strong",{children:r.deletable_files})," unreferenced file",r.deletable_files!==1?"s":""," ","and ",s.jsx("strong",{children:r.orphaned_blob_rows})," orphaned DB record",r.orphaned_blob_rows!==1?"s":""," ","— ",s.jsx("strong",{children:bi(r.total_bytes)})," recoverable."]}),s.jsxs("div",{style:{display:"flex",gap:8},children:[s.jsxs("button",{className:"btn-danger",onClick:g,children:["Delete (",bi(r.total_bytes),")"]}),s.jsx("button",{className:"btn-ghost",onClick:d,children:"Cancel"})]})]}),t==="deleting"&&s.jsx("div",{className:"muted",children:"Deleting…"}),t==="done"&&i&&s.jsxs("div",{children:[s.jsxs("div",{className:"form-msg form-msg--ok",children:["Freed ",s.jsx("strong",{children:bi(i.freed_bytes)})," ","— removed ",i.deleted_files," file",i.deleted_files!==1?"s":""," ","and ",i.deleted_blob_rows," DB record",i.deleted_blob_rows!==1?"s":"","."]}),i.errors&&i.errors.length>0&&s.jsxs("div",{className:"form-msg form-msg--err",style:{marginTop:6},children:[i.errors.length," file",i.errors.length!==1?"s":""," could not be deleted."]}),s.jsx("button",{className:"btn-ghost",style:{marginTop:10},onClick:d,children:"Scan again"})]}),t==="error"&&s.jsxs("div",{children:[s.jsx("div",{className:"form-msg form-msg--err",children:o}),s.jsx("button",{className:"btn-ghost",style:{marginTop:10},onClick:d,children:"Try again"})]})]})})}function jg(){const[e,t]=c.useState(null),[n,r]=c.useState(!0),[l,i]=c.useState(null),[a,o]=c.useState("global"),[u,d]=c.useState(""),[y,g]=c.useState("{}"),[m,x]=c.useState(null),[w,S]=c.useState(!1),[_,p]=c.useState({});c.useEffect(()=>{f()},[]);async function f(){r(!0),i(null);try{t(await Om())}catch(v){i(v.message)}finally{r(!1)}}async function h(v){v.preventDefault();try{JSON.parse(y)}catch{x({ok:!1,text:'cookies must be valid JSON, e.g. {"session": "abc"}'});return}S(!0),x(null);try{await Am(a==="global"?null:u.trim(),a,y),d(""),g("{}"),o("global"),x({ok:!0,text:"Rule added."}),await f()}catch(b){x({ok:!1,text:b.message})}finally{S(!1)}}async function k(v){try{await Fm(v),await f()}catch(b){i(b.message)}}function E(v){p(b=>({...b,[v.rule_uid]:{cookiesInput:v.cookies_json,saving:!1,msg:null}}))}function P(v){p(b=>{const L={...b};return delete L[v],L})}async function N(v){const b=_[v.rule_uid];try{JSON.parse(b.cookiesInput)}catch{p(L=>({...L,[v.rule_uid]:{...L[v.rule_uid],msg:{ok:!1,text:"Invalid JSON"}}}));return}p(L=>({...L,[v.rule_uid]:{...L[v.rule_uid],saving:!0,msg:null}}));try{await Mm(v.rule_uid,{cookies_json:b.cookiesInput}),P(v.rule_uid),await f()}catch(L){p(X=>({...X,[v.rule_uid]:{...X[v.rule_uid],saving:!1,msg:{ok:!1,text:L.message}}}))}}return n?s.jsx("div",{className:"muted",children:"Loading…"}):l?s.jsx("div",{className:"form-msg form-msg--err",children:l}):s.jsx("div",{style:{maxWidth:560},children:s.jsxs("div",{className:"form-section",children:[s.jsx("h2",{children:"Cookie Rules"}),s.jsx("p",{className:"muted",style:{marginBottom:12},children:"Cookies are injected into every capture network request (yt-dlp, HTTP downloads, web-page snapshots). Global rules apply to all URLs; wildcard and regex rules apply only to matching URLs."}),e&&e.length>0?s.jsxs("table",{className:"data-table",style:{width:"100%",marginBottom:16},children:[s.jsx("thead",{children:s.jsxs("tr",{children:[s.jsx("th",{children:"Pattern"}),s.jsx("th",{children:"Cookies"}),s.jsx("th",{style:{width:100},children:"Actions"})]})}),s.jsx("tbody",{children:e.map(v=>{const b=_[v.rule_uid],L=v.url_pattern?s.jsxs(s.Fragment,{children:[s.jsxs("span",{className:"muted",children:[v.pattern_kind,":"]})," ",s.jsx("code",{children:v.url_pattern})]}):s.jsx("span",{className:"muted",children:"global (all URLs)"});return s.jsxs("tr",{children:[s.jsx("td",{children:L}),s.jsx("td",{children:b?s.jsxs(s.Fragment,{children:[s.jsx("textarea",{className:"field-input",style:{fontFamily:"monospace",fontSize:12,width:"100%",minHeight:60},value:b.cookiesInput,onChange:X=>p(Q=>({...Q,[v.rule_uid]:{...Q[v.rule_uid],cookiesInput:X.target.value}}))}),b.msg&&s.jsx("div",{className:`form-msg form-msg--${b.msg.ok?"ok":"err"}`,children:b.msg.text}),s.jsxs("div",{style:{display:"flex",gap:8,marginTop:4},children:[s.jsx("button",{className:"btn-primary",style:{fontSize:12,padding:"2px 8px"},disabled:b.saving,onClick:()=>N(v),children:b.saving?"Saving…":"Save"}),s.jsx("button",{className:"btn-secondary",style:{fontSize:12,padding:"2px 8px"},onClick:()=>P(v.rule_uid),children:"Cancel"})]})]}):s.jsx("code",{style:{fontSize:12,wordBreak:"break-all"},children:v.cookies_json})}),s.jsx("td",{children:!b&&s.jsxs("div",{style:{display:"flex",gap:6},children:[s.jsx("button",{className:"btn-secondary",style:{fontSize:12,padding:"2px 8px"},onClick:()=>E(v),children:"Edit"}),s.jsx("button",{className:"btn-danger",style:{fontSize:12,padding:"2px 8px"},onClick:()=>k(v.rule_uid),children:"Del"})]})})]},v.rule_uid)})})]}):s.jsx("p",{className:"muted",style:{marginBottom:16},children:"No cookie rules defined."}),s.jsx("h3",{style:{marginBottom:8},children:"Add Rule"}),s.jsxs("form",{onSubmit:h,children:[s.jsxs("div",{className:"form-field",children:[s.jsx("label",{className:"form-label",children:"Pattern type"}),s.jsxs("select",{className:"field-input",value:a,onChange:v=>o(v.target.value),children:[s.jsx("option",{value:"global",children:"Global (all URLs)"}),s.jsx("option",{value:"wildcard",children:"Wildcard (e.g. *.youtube.com)"}),s.jsx("option",{value:"regex",children:"Regex (matched against full URL)"})]})]}),a!=="global"&&s.jsxs("div",{className:"form-field",children:[s.jsx("label",{className:"form-label",children:a==="wildcard"?"URL/hostname pattern":"Regex pattern"}),s.jsx("input",{className:"field-input",type:"text",value:u,onChange:v=>d(v.target.value),placeholder:a==="wildcard"?"*.youtube.com or https://example.com/*":".*\\.youtube\\.com.*",required:!0})]}),s.jsxs("div",{className:"form-field",children:[s.jsx("label",{className:"form-label",children:"Cookies (JSON object)"}),s.jsx("textarea",{className:"field-input",style:{fontFamily:"monospace",fontSize:13,minHeight:70},value:y,onChange:v=>g(v.target.value),placeholder:'{"SESSION": "abc123", "token": "xyz"}',required:!0})]}),m&&s.jsx("div",{className:`form-msg form-msg--${m.ok?"ok":"err"}`,children:m.text}),s.jsx("button",{className:"btn-primary",type:"submit",disabled:w,children:w?"Adding…":"Add Rule"})]})]})})}function Sg(){const[e,t]=c.useState(null),[n,r]=c.useState(!0),[l,i]=c.useState(!1),[a,o]=c.useState(null);c.useEffect(()=>{(async()=>{try{t(await xo())}catch(_){o({ok:!1,text:_.message})}finally{r(!1)}})()},[]);async function u(_){i(!0),o(null);try{await Jr({ublock_enabled:_}),t(p=>({...p,ublock_enabled:_})),o({ok:!0,text:"Saved."})}catch(p){o({ok:!1,text:p.message})}finally{i(!1)}}async function d(_){i(!0),o(null);try{await Jr({cookie_ext_enabled:_}),t(p=>({...p,cookie_ext_enabled:_})),o({ok:!0,text:"Saved."})}catch(p){o({ok:!1,text:p.message})}finally{i(!1)}}async function y(_){i(!0),o(null);try{await Jr({modal_closer_enabled:_}),t(p=>({...p,modal_closer_enabled:_})),o({ok:!0,text:"Saved."})}catch(p){o({ok:!1,text:p.message})}finally{i(!1)}}if(n)return s.jsx("div",{className:"muted",children:"Loading\\u2026"});const g=(e==null?void 0:e.ublock_ext_available)??!1,m=(e==null?void 0:e.ublock_enabled)??!0,x=(e==null?void 0:e.cookie_ext_available)??!1,w=(e==null?void 0:e.cookie_ext_enabled)??!0,S=(e==null?void 0:e.modal_closer_enabled)??!0;return s.jsx("div",{children:s.jsxs("div",{className:"form-section",children:[s.jsx("h2",{children:"Extensions"}),s.jsx("p",{className:"form-hint",style:{marginBottom:20},children:"Extensions run inside the browser during WebPage captures and can block ads, accept cookie banners, and more. Changes take effect on the next capture."}),s.jsxs("div",{className:"ext-grid",children:[s.jsx("div",{className:"ext-card",children:s.jsxs("div",{className:"ext-card-header",children:[s.jsxs("div",{className:"ext-card-info",children:[s.jsx("span",{className:"ext-card-name",children:"uBlock Origin Lite"}),s.jsx("span",{className:"ext-card-desc",children:"Blocks ads, trackers, and other page clutter during archiving via Chrome’s declarativeNetRequest API (Manifest V3)."}),!g&&s.jsxs("span",{className:"ext-card-hint",children:["Not configured — set ",s.jsx("code",{children:"ARCHIVR_UBLOCK_EXT"})," to the unpacked extension directory to enable."]})]}),s.jsx("button",{type:"button",role:"switch","aria-checked":m,className:`ext-toggle${m?" ext-toggle--on":""}`,onClick:()=>u(!m),disabled:l,"aria-label":"Toggle uBlock Origin Lite",children:s.jsx("span",{className:"ext-toggle-knob"})})]})}),s.jsx("div",{className:"ext-card",children:s.jsxs("div",{className:"ext-card-header",children:[s.jsxs("div",{className:"ext-card-info",children:[s.jsx("span",{className:"ext-card-name",children:"I Still Don’t Care About Cookies"}),s.jsx("span",{className:"ext-card-desc",children:"Dismiss cookie consent banners during archiving."}),!x&&s.jsxs("span",{className:"ext-card-hint",children:["Not configured — set ",s.jsx("code",{children:"ARCHIVR_COOKIE_EXT"})," to the unpacked extension directory to enable."]})]}),s.jsx("button",{type:"button",role:"switch","aria-checked":w,className:`ext-toggle${w?" ext-toggle--on":""}`,onClick:()=>d(!w),disabled:l,"aria-label":"Toggle I Still Don't Care About Cookies",children:s.jsx("span",{className:"ext-toggle-knob"})})]})}),s.jsx("div",{className:"ext-card",children:s.jsxs("div",{className:"ext-card-header",children:[s.jsxs("div",{className:"ext-card-info",children:[s.jsx("span",{className:"ext-card-name",children:"Modal & Dialog Closer"}),s.jsx("span",{className:"ext-card-desc",children:"Auto-dismiss cookie banners, consent overlays, and other modal dialogs before a WebPage capture is taken. Implemented as an injected browser script; no external extension required."})]}),s.jsx("button",{type:"button",role:"switch","aria-checked":S,className:`ext-toggle${S?" ext-toggle--on":""}`,onClick:()=>y(!S),disabled:l,"aria-label":"Toggle Modal and Dialog Closer",children:s.jsx("span",{className:"ext-toggle-knob"})})]})})]}),a&&s.jsx("div",{className:`form-msg form-msg--${a.ok?"ok":"err"}`,children:a.text})]})})}const Bu={0:"Private",1:"Public",2:"Users only",3:"Public"},es=[{value:"anthropic_http",label:"Anthropic API"},{value:"openai_compatible",label:"OpenAI-compatible API"},{value:"claude_cli",label:"Claude CLI"},{value:"codex_cli",label:"Codex CLI"}],_g=Object.fromEntries(es.map(e=>[e.value,e.label])),Uu="archivr:summary:provider",Ng=1500,yf="This entry can’t be summarized yet.",vf="It doesn’t contain archived text that a summary provider can read. Summaries currently support text notes, web pages, X posts and threads, and X Articles. Video, audio, and image-only entries need a transcript or text source.",Hu=`${yf} + +${vf}`;function Cg(e){if(!e)return null;try{const t=JSON.parse(e);if(t&&typeof t=="object")return{tldr:typeof t.tldr=="string"?t.tldr:"",summary:typeof t.summary=="string"?t.summary:"",tags:Array.isArray(t.tags)?t.tags.filter(n=>typeof n=="string"):[]}}catch{}return{tldr:"",summary:e,tags:[]}}const Eg=()=>s.jsx("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:s.jsx("path",{d:"M7 17 17 7M9 7h8v8"})});function bg({archiveId:e,selectedEntry:t,selectedUids:n,selectedEntries:r,detail:l,onTagFilterSet:i,tagNodes:a,onTagsRefresh:o,onEntryTitleChange:u,onEntryDeleted:d,onBulkDeleted:y,humanizeTags:g,onDetailRefresh:m,onOpenPreview:x,onPlay:w,isPublicSession:S}){var Ee,We,je;const[_,p]=c.useState([]),[f,h]=c.useState(""),[k,E]=c.useState([]),[P,N]=c.useState(""),v=c.useRef(0),b=c.useRef(!1),[L,X]=c.useState(!1),[Q,ne]=c.useState(""),[q,fe]=c.useState("idle"),[Le,ve]=c.useState(""),I=c.useRef(null),[R,K]=c.useState(!1);c.useEffect(()=>{K(!1)},[(Ee=l==null?void 0:l.summary)==null?void 0:Ee.entry_uid]);const[ee,se]=c.useState(null),[ie,Y]=c.useState(null),[Ne,pe]=c.useState(""),[U,te]=c.useState(!1),[re,Ue]=c.useState(()=>{try{return sessionStorage.getItem(Uu)||es[0].value}catch{return es[0].value}}),[Lt,yt]=c.useState(!1),Ie=c.useRef(null),st=c.useRef(null),Ze=c.useRef(null),$=c.useRef(null),Z=e&&(t!=null&&t.entry_uid)?`${e}:${t.entry_uid}`:null;$.current=Z;const Ae=(n==null?void 0:n.size)>=2,[Me,Rt]=c.useState(""),[zt,It]=c.useState("idle"),[et,Xt]=c.useState(""),[$t,an]=c.useState([]),[He,j]=c.useState(""),[B,A]=c.useState("idle"),[D,F]=c.useState(""),[H,ke]=c.useState("idle"),[he,Re]=c.useState(""),[ye,Fe]=c.useState("idle"),[ce,it]=c.useState("");c.useEffect(()=>{const T=++v.current;if(I.current&&(clearInterval(I.current),I.current=null),fe("idle"),ve(""),!t||!e){p([]),E([]);return}if(S){p([]),E([]);return}X(!1),ne(""),b.current=!1,p([]),Promise.all([Fl(e,t.entry_uid),Ou(e,t.entry_uid)]).then(([J,ae])=>{T===v.current&&(p(J),E(ae))}).catch(()=>{})},[t,e,S]),c.useEffect(()=>()=>{clearInterval(I.current)},[]),c.useLayoutEffect(()=>{var J,ae,me;clearInterval(Ie.current),Ie.current=null,(J=st.current)==null||J.abort(),st.current=null,(ae=Ze.current)==null||ae.abort(),Ze.current=null;const T=(t==null?void 0:t.entry_uid)!=null&&((me=l==null?void 0:l.summary)==null?void 0:me.entry_uid)===(t==null?void 0:t.entry_uid);se(T?l.latest_summary??null:null),Y(T?l.summary_attempt??null:null),pe(""),te(!1),yt(!1)},[e,t==null?void 0:t.entry_uid,(We=l==null?void 0:l.summary)==null?void 0:We.entry_uid]);const dt=ie==null?void 0:ie.status;c.useEffect(()=>{var on;if(clearInterval(Ie.current),Ie.current=null,dt!=="pending"&&dt!=="running"||!e||!((on=l==null?void 0:l.summary)!=null&&on.entry_uid))return;const T=l.summary.entry_uid,J=`${e}:${T}`;if(Z!==J)return;const ae=new AbortController;st.current=ae;const $e=setInterval(async()=>{var xe;try{const un=await Yh(e,T,{signal:ae.signal});if(ae.signal.aborted||$.current!==J)return;se(un.summary??null),Y(un.attempt??null);const Ys=(xe=un.attempt)==null?void 0:xe.status;Ys!=="pending"&&Ys!=="running"&&(clearInterval($e),Ie.current===$e&&(Ie.current=null),te(!1),Ys==="completed"&&$.current===J&&(m==null||m()))}catch{if(ae.signal.aborted||$.current!==J)return}},Ng);return Ie.current=$e,()=>{clearInterval($e),Ie.current===$e&&(Ie.current=null),ae.abort(),st.current===ae&&(st.current=null)}},[dt,e,t==null?void 0:t.entry_uid,(je=l==null?void 0:l.summary)==null?void 0:je.entry_uid,Z]),c.useEffect(()=>()=>{var T,J;clearInterval(Ie.current),(T=st.current)==null||T.abort(),(J=Ze.current)==null||J.abort()},[]);async function Nr(T=!1){var $e,on;if(!e||!(($e=l==null?void 0:l.summary)!=null&&$e.entry_uid)||U)return;const J=l.summary.entry_uid,ae=`${e}:${J}`;if($.current!==ae)return;const me=new AbortController;(on=Ze.current)==null||on.abort(),Ze.current=me,te(!0),pe("");try{const xe=await Zh(e,J,{provider:re,force:T,includeImages:Lt,signal:me.signal});if(me.signal.aborted||$.current!==ae)return;xe.status==="completed"?(se(xe),Y(null),te(!1),$.current===ae&&(m==null||m())):Y({...xe??{},status:"pending"})}catch(xe){if(me.signal.aborted||$.current!==ae)return;pe(xe.message||"Summary request failed"),te(!1)}finally{Ze.current===me&&(Ze.current=null)}}function Ws(T){Ue(T),T==="claude_cli"&&yt(!1);try{sessionStorage.setItem(Uu,T)}catch{}}c.useEffect(()=>{if(!e){an([]);return}wo(e).then(an).catch(()=>an([]))},[e]),c.useEffect(()=>{Rt(""),It("idle"),Xt(""),j(""),A("idle"),F(""),ke("idle"),Re(""),Fe("idle"),it("")},[n]);async function Vs(){const T=n.size;if(!window.confirm(`Delete ${T} entr${T===1?"y":"ies"}? This cannot be undone.`))return;ke("running");const J=new Set;for(const ae of n)try{await Du(e,ae),J.add(ae)}catch{}ke("idle"),y==null||y(J)}async function kl(){const T=Me.trim();if(T){It("running"),Xt("");try{for(const J of n)await $u(e,J,T);Rt(""),It("done"),o==null||o(),setTimeout(()=>It("idle"),1800)}catch(J){Xt(J.message),It("error")}}}async function Ks(){if(!He)return;A("running"),F("");const T=[],J=$t.find(ae=>ae.collection_uid===He);for(const ae of n)try{await Sa(e,He,ae,(J==null?void 0:J.default_visibility_bits)??2)}catch{T.push(ae)}T.length>0?(F(`Failed for ${T.length} entr${T.length===1?"y":"ies"}.`),A("error")):(A("done"),setTimeout(()=>A("idle"),1800))}async function Qs(){if(!he||!t)return;Fe("running"),it("");const T=$t.find(J=>J.collection_uid===he);try{await Sa(e,he,t.entry_uid,(T==null?void 0:T.default_visibility_bits)??2),Fe("done"),Re("");const J=await Ou(e,t.entry_uid);E(J),setTimeout(()=>Fe("idle"),1800)}catch(J){it(J.message),Fe("error")}}async function Kn(){const T=Q.trim()||null;try{await lm(e,t.entry_uid,T),u==null||u(t.entry_uid,T)}catch{}finally{X(!1)}}async function Qn(){const T=f.trim();if(!(!T||!t))try{await $u(e,t.entry_uid,T),h(""),N("");const J=await Fl(e,t.entry_uid);p(J),o()}catch(J){N(J.message)}}async function Pn(T){try{await sm(e,t.entry_uid,T);const J=await Fl(e,t.entry_uid);p(J),o()}catch{}}async function jl(){if(!(!t||!e)&&window.confirm("Delete this entry? This cannot be undone."))try{await Du(e,t.entry_uid),d==null||d(t.entry_uid)}catch{}}async function qs(){if(!t||!e||q==="running")return;const T=v.current,J=t.entry_uid;fe("running"),ve("");try{const{job_uid:ae}=await $m(e,J);if(v.current!==T)return;I.current=setInterval(async()=>{try{const me=await uf(e,ae);if(me.status==="completed"){if(clearInterval(I.current),I.current=null,v.current!==T)return;fe("done");const $e=await Fl(e,J);if(v.current!==T)return;p($e),m==null||m()}else if(me.status==="failed"){if(clearInterval(I.current),I.current=null,v.current!==T)return;fe("error"),ve(me.error_text||"Re-archive failed.")}}catch{if(clearInterval(I.current),I.current=null,v.current!==T)return;fe("error"),ve("Network error while polling.")}},500)}catch(ae){if(v.current!==T)return;fe("error"),ve(ae.message||"Failed to start re-archive.")}}const Xs=l?[["Added",ko(l.summary.archived_at)],["Source",l.summary.source_kind],["Type",l.summary.entity_kind],["Visibility",Bu[l.summary.visibility]??l.summary.visibility],["Root",l.structured_root_relpath]]:[],Js=new Set(["mp3","ogg","m4a","opus","wav","flac","aac"]),Gs=new Set(["mp4","webm","mov","mkv","avi","m4v","ogv","pdf","html","htm","md","markdown","txt","jpg","jpeg","png","gif","webp","avif","svg","bmp"]),C=l?l.artifacts.findIndex(T=>T.artifact_role==="primary_media"):-1,z=C>=0?l.artifacts[C]:null,M=z?z.relpath.split(".").pop().toLowerCase():"",W=z&&Js.has(M),G=C>=0&&t?`/api/archives/${e}/entries/${t.entry_uid}/artifacts/${C}`:null,ue=l&&!W&&(l.summary.entity_kind==="tweet"||l.summary.entity_kind==="tweet_thread"||z&&Gs.has(M));return s.jsxs("aside",{className:"context-rail",children:[s.jsx("div",{className:"rail-eyebrow",children:"Context"}),Ae?S?s.jsxs("p",{className:"bulk-count",children:[s.jsx("span",{className:"bulk-count-num",children:n.size})," entries selected"]}):s.jsxs("div",{className:"bulk-panel",children:[s.jsxs("p",{className:"bulk-count",children:[s.jsx("span",{className:"bulk-count-num",children:n.size})," entries selected"]}),s.jsxs("div",{className:"rail-section",children:[s.jsx("div",{className:"rail-section-heading",children:"Assign tag"}),et&&s.jsx("p",{className:"form-msg form-msg--err",style:{margin:"0 0 8px"},children:et}),s.jsxs("div",{className:"tag-input-wrap",children:[s.jsx("span",{className:"hash",children:"/"}),s.jsx("input",{className:"tag-input",type:"text",placeholder:"science/cs",autoComplete:"off",value:Me,onChange:T=>Rt(T.target.value),onKeyDown:T=>{T.key==="Enter"&&kl()}}),s.jsx("button",{className:"tag-add-btn",onClick:kl,disabled:zt==="running"||!Me.trim(),children:zt==="running"?"…":zt==="done"?"✓":"Add"})]})]}),$t.length>0&&s.jsxs("div",{className:"rail-section",children:[s.jsx("div",{className:"rail-section-heading",children:"Add to collection"}),s.jsxs("div",{className:"bulk-coll-row",children:[s.jsxs("select",{className:"bulk-coll-select",value:He,onChange:T=>j(T.target.value),children:[s.jsx("option",{value:"",children:"Pick a collection…"}),$t.filter(T=>T.slug!=="_default_").map(T=>s.jsx("option",{value:T.collection_uid,children:T.name},T.collection_uid))]}),s.jsx("button",{className:"tag-add-btn",onClick:Ks,disabled:!He||B==="running",children:B==="running"?"…":B==="done"?"✓":B==="error"?"!":"Add"})]}),D&&s.jsx("p",{className:"form-msg form-msg--err",style:{margin:"6px 0 0"},children:D})]}),s.jsx("div",{className:"rail-delete-zone",children:s.jsx("button",{className:"rail-delete-btn",onClick:Vs,disabled:H==="running",children:H==="running"?"Deleting…":`Delete ${n.size} entr${n.size===1?"y":"ies"}`})})]}):t?l?s.jsxs(s.Fragment,{children:[S?s.jsx("h2",{className:"rail-title",children:xt(l.summary.title)||xt(l.summary.entry_uid)}):L?s.jsx("input",{className:"rail-title-input",autoFocus:!0,value:Q,onChange:T=>ne(T.target.value),onKeyDown:T=>{T.key==="Enter"&&T.currentTarget.blur(),T.key==="Escape"&&(b.current=!0,T.currentTarget.blur())},onBlur:()=>{b.current?X(!1):Kn(),b.current=!1}}):s.jsxs("h2",{className:"rail-title rail-title--editable",title:"Click to rename",onClick:()=>{ne(l.summary.title??""),X(!0)},children:[xt(l.summary.title)||xt(l.summary.entry_uid),s.jsx("svg",{className:"edit-icon",viewBox:"0 0 16 16",fill:"none",stroke:"currentColor",strokeWidth:"1.5",strokeLinecap:"round",strokeLinejoin:"round","aria-hidden":"true",children:s.jsx("path",{d:"M11.5 2.5a1.5 1.5 0 0 1 2 2L5 13l-3 1 1-3 8.5-8.5z"})})]}),l.summary.original_url&&s.jsxs("a",{className:"url-tile",href:l.summary.original_url,target:"_blank",rel:"noopener noreferrer",children:[s.jsx("span",{className:"ico",dangerouslySetInnerHTML:{__html:Us(l.summary.source_kind)}}),s.jsx("span",{className:"u-text",children:l.summary.original_url}),s.jsx("span",{className:"ext",children:s.jsx(Eg,{})})]}),W&&w&&s.jsx("button",{className:"rail-preview-btn",onClick:()=>w(G,t),children:"▶ Play"}),ue&&x&&s.jsx("button",{className:"rail-preview-btn",onClick:x,children:"Preview"}),(()=>{const T=(ee==null?void 0:ee.status)==="completed"?Cg(ee.summary_text):null,J=(ie==null?void 0:ie.status)==="pending"||(ie==null?void 0:ie.status)==="running",ae=(ie==null?void 0:ie.status)==="failed"&&ie.error_text===Hu||Ne===Hu;return S&&!T?null:s.jsxs("div",{className:"rail-section rail-summary",children:[s.jsx("div",{className:"rail-section-heading",children:"Summary"}),T&&s.jsxs("div",{className:"rail-summary-body",children:[T.tldr&&s.jsx("p",{className:"rail-summary-tldr",children:T.tldr}),T.summary&&s.jsx("p",{className:"rail-summary-text",children:T.summary}),T.tags.length>0&&s.jsx("div",{className:"rail-summary-tags",children:T.tags.map(me=>s.jsx("span",{className:"rail-summary-tag",children:me},me))}),s.jsxs("p",{className:"rail-summary-provider",children:[_g[ee.provider_kind]||ee.provider_kind,ee.resolved_model||ee.provider_model?` · ${ee.resolved_model||ee.provider_model}`:""]})]}),J&&s.jsxs("p",{className:"rail-summary-status",children:[s.jsx("span",{className:"rail-summary-spinner","aria-hidden":"true"}),"Generating…"]}),ae&&!S&&s.jsxs("div",{className:"rail-summary-info",role:"status",children:[s.jsx("p",{className:"rail-summary-info__heading",children:yf}),s.jsx("p",{className:"rail-summary-info__detail",children:vf})]}),(ie==null?void 0:ie.status)==="failed"&&ie.error_text&&!ae&&!S&&s.jsx("p",{className:"form-msg form-msg--err rail-summary-error",children:ie.error_text}),Ne&&!ae&&s.jsx("p",{className:"form-msg form-msg--err rail-summary-error",children:Ne}),!S&&!J&&s.jsxs("div",{className:"rail-summary-controls",children:[s.jsx("select",{className:"rail-summary-select",value:re,onChange:me=>Ws(me.target.value),"aria-label":"Summary provider",children:es.map(me=>s.jsx("option",{value:me.value,children:me.label},me.value))}),s.jsxs("div",{className:`rail-summary-image-option${re==="claude_cli"?" rail-summary-image-option--disabled":""}`,children:[s.jsxs("label",{className:"rail-summary-image-option__label",children:[s.jsx("input",{type:"checkbox",checked:Lt,disabled:re==="claude_cli",onChange:me=>yt(me.target.checked)}),"Include attached images"]}),s.jsx("p",{className:"rail-summary-image-option__note",children:re==="claude_cli"?"Claude CLI cannot attach local images. Choose an HTTP provider or Codex CLI.":"Selected archived images are sent to the chosen provider. Up to 4 supported images (5 MiB each, 12 MiB total) can be attached; unsupported or oversized artifacts are skipped."})]}),s.jsx("button",{className:"rail-rearchive-btn",onClick:()=>Nr(!!T),disabled:U,children:U?"…":T?"Regenerate":"Generate"})]})]})})(),s.jsx("div",{className:"meta-list",children:Xs.filter(([,T])=>T!=null&&T!=="").map(([T,J])=>s.jsxs("div",{className:"meta-item",children:[s.jsx("span",{className:"meta-k",children:T}),s.jsx("span",{className:`meta-v${T==="Root"?" mono":""}`,children:xt(J)})]},T))}),l.artifacts.length>0&&(()=>{const T=l.artifacts.map((xe,un)=>({...xe,_idx:un})),J=T.filter(xe=>xe.artifact_role==="font"),ae=T.filter(xe=>xe.artifact_role!=="font"),me=J.reduce((xe,un)=>xe+(un.byte_size||0),0),$e=l.summary.entry_uid,on=xe=>s.jsx("li",{children:s.jsxs("a",{href:`/api/archives/${e}/entries/${$e}/artifacts/${xe._idx}`,target:"_blank",rel:"noopener noreferrer",className:"artifact-link",children:[s.jsx("span",{className:"artifact-name",children:xe.artifact_role==="font"?xe.relpath.split("/").pop():xe.artifact_role.replace(/_/g," ")}),s.jsx("span",{className:"artifact-size",children:xe.byte_size!=null?ml(xe.byte_size):"—"})]})},xe._idx);return s.jsxs("div",{className:"rail-section",children:[s.jsxs("div",{className:"rail-section-heading",children:["Artifacts ",s.jsx("span",{className:"num",children:l.artifacts.length})]}),s.jsxs("ul",{className:"artifact-list",children:[ae.map(on),J.length>0&&s.jsxs("li",{className:"artifact-group",children:[s.jsxs("button",{type:"button",className:"artifact-group-header artifact-link","aria-expanded":R,onClick:()=>K(xe=>!xe),children:[s.jsxs("span",{className:"artifact-name",children:[s.jsx("span",{"aria-hidden":"true",className:`artifact-group-chevron${R?" open":""}`,children:"›"}),` fonts (${J.length})`]}),s.jsx("span",{className:"artifact-size",children:ml(me)})]}),R&&s.jsx("ul",{className:"artifact-list artifact-group-body",children:J.map(on)})]})]})]})})()]}):s.jsx("p",{className:"tags-empty",children:"Loading\\u2026"}):s.jsx("p",{className:"tags-empty",children:"Select an entry."}),t&&!Ae&&!S&&s.jsxs(s.Fragment,{children:[s.jsxs("div",{className:"rail-section",children:[s.jsx("div",{className:"rail-section-heading",children:"Tags"}),_.length===0?s.jsx("p",{className:"tags-empty",children:"No tags yet."}):s.jsx("div",{className:"tags-wrap",children:_.map(T=>s.jsxs("span",{className:"tag-pill",title:T.full_path,children:[g?pf(T.full_path):T.full_path,s.jsx("button",{className:"remove",title:`Remove tag ${T.full_path}`,onClick:()=>Pn(T.tag_uid),children:"×"})]},T.tag_uid))}),P&&s.jsx("p",{className:"form-msg form-msg--err",style:{margin:"0 0 8px"},children:P}),s.jsxs("div",{className:"tag-input-wrap",children:[s.jsx("span",{className:"hash",children:"/"}),s.jsx("input",{className:"tag-input",type:"text",placeholder:"science/cs",autoComplete:"off",value:f,onChange:T=>h(T.target.value),onKeyDown:T=>{T.key==="Enter"&&Qn()}}),s.jsx("button",{className:"tag-add-btn",onClick:Qn,children:"Add"})]})]}),(k.length>0||$t.filter(T=>T.slug!=="_default_").length>0)&&s.jsxs("div",{className:"rail-section",children:[s.jsx("div",{className:"rail-section-heading",children:"Collections"}),k.map(T=>s.jsxs("div",{className:"coll-row",children:[s.jsx("span",{className:"coll-name",children:T.name}),s.jsx("span",{className:"vis-badge",children:Bu[T.visibility_bits]??`bits:${T.visibility_bits}`})]},T.collection_uid)),$t.filter(T=>T.slug!=="_default_").length>0&&s.jsxs("div",{className:"bulk-coll-row",style:{marginTop:8},children:[s.jsxs("select",{className:"bulk-coll-select",value:he,onChange:T=>Re(T.target.value),children:[s.jsx("option",{value:"",children:"Add to collection…"}),$t.filter(T=>T.slug!=="_default_").map(T=>s.jsx("option",{value:T.collection_uid,children:T.name},T.collection_uid))]}),s.jsx("button",{className:"tag-add-btn",onClick:Qs,disabled:!he||ye==="running",children:ye==="running"?"…":ye==="done"?"✓":ye==="error"?"!":"Add"})]}),ce&&s.jsx("p",{className:"form-msg form-msg--err",style:{margin:"4px 0 0"},children:ce})]}),l&&(l.summary.entity_kind==="tweet"||l.summary.entity_kind==="tweet_thread")&&s.jsxs("div",{className:"rail-section",children:[s.jsx("div",{className:"rail-section-heading",children:"Actions"}),s.jsx("button",{className:"rail-rearchive-btn",onClick:qs,disabled:q==="running",children:q==="running"?"Re-archiving…":"Re-archive"}),q==="done"&&s.jsx("p",{className:"form-msg form-msg--ok",style:{marginTop:"6px"},children:"Re-archived successfully."}),q==="error"&&s.jsx("p",{className:"form-msg form-msg--err",style:{marginTop:"6px"},children:Le})]}),s.jsx("div",{className:"rail-delete-zone",children:s.jsx("button",{className:"rail-delete-btn",onClick:jl,children:"Delete entry"})})]})]})}const Tg=/\/api\/archives\/([^/]+)\/entries\/([^/]+)\/artifacts\/(\d+)/;function Pg(){return s.jsxs("svg",{width:"18",height:"18",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round","aria-hidden":"true",children:[s.jsx("path",{d:"M5 17H3a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h18a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-2"}),s.jsx("polygon",{points:"12 15 17 21 7 21 12 15"})]})}function Lg({src:e,contentType:t="video/mp4"}){const n=c.useRef(null),[r,l]=c.useState(null),[i,a]=c.useState(!1),[o,u]=c.useState(!1);c.useEffect(()=>{if(!e){l(null);return}const g=e.match(Tg);if(!g){l(e);return}let m=!1;return l(null),Dm(g[1],g[2],parseInt(g[3],10)).then(({url:x})=>{m||l(x)}).catch(()=>{m||l(e)}),()=>{m=!0}},[e]),c.useEffect(()=>{n.current&&n.current.load()},[r]),c.useEffect(()=>{u(typeof HTMLVideoElement<"u"&&"webkitShowPlaybackTargetPicker"in HTMLVideoElement.prototype)},[]),c.useEffect(()=>{var m;const g=x=>{if(x)try{cast.framework.CastContext.getInstance().setOptions({receiverApplicationId:chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID,autoJoinPolicy:chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED}),a(!0)}catch{}};if((m=window.cast)!=null&&m.framework)g(!0);else if(window.__onGCastApiAvailable=g,!document.querySelector('script[src*="cast_sender"]')){const x=document.createElement("script");x.src="https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1",document.head.appendChild(x)}},[]),c.useEffect(()=>{if(!i||!r)return;const g=cast.framework.CastContext.getInstance(),{CastContextEventType:m,SessionState:x}=cast.framework,w=_=>{if(!_)return;const p=new chrome.cast.media.MediaInfo(window.location.origin+r,t);_.loadMedia(new chrome.cast.media.LoadRequest(p)).catch(()=>{})};w(g.getCurrentSession());const S=_=>{(_.sessionState===x.SESSION_STARTED||_.sessionState===x.SESSION_RESUMED)&&w(g.getCurrentSession())};return g.addEventListener(m.SESSION_STATE_CHANGED,S),()=>g.removeEventListener(m.SESSION_STATE_CHANGED,S)},[i,r,t]);const d=()=>{var g,m;(m=(g=n.current)==null?void 0:g.webkitShowPlaybackTargetPicker)==null||m.call(g)},y=i||o;return s.jsx("div",{className:"preview-video-wrap",style:{position:"relative"},children:e?s.jsxs(s.Fragment,{children:[r?s.jsxs("video",{ref:n,controls:!0,autoPlay:!1,playsInline:!0,"x-webkit-airplay":"allow",style:{width:"100%",maxHeight:"100%",display:"block"},children:[s.jsx("source",{src:r}),"Your browser does not support the video element."]}):s.jsx("div",{className:"video-tv-loading","aria-label":"Loading video…"}),y&&r&&s.jsxs("div",{className:"video-tv-controls",children:[o&&s.jsx("button",{className:"video-tv-btn",title:"AirPlay to device","aria-label":"AirPlay to device",onClick:d,children:s.jsx(Pg,{})}),i&&s.jsx("google-cast-launcher",{className:"video-tv-btn",title:"Cast to TV"})]})]}):s.jsx("span",{style:{color:"var(--muted)",fontFamily:"var(--sans)",fontSize:"0.9rem"},children:"No video available"})})}function Wu({src:e,type:t,title:n,originalUrl:r}){const l=r||e,i=n||null;return s.jsxs("div",{className:"preview-iframe-wrap",style:{flex:1,minHeight:0,display:"flex",flexDirection:"column"},children:[s.jsxs("div",{className:"preview-iframe-toolbar",style:{display:"flex",flexDirection:"column",gap:"2px",padding:"6px 12px",borderBottom:"1px solid var(--line-soft)",background:"var(--paper-2)",flexShrink:0},children:[i&&s.jsx("span",{style:{fontSize:"0.85rem",fontWeight:600,color:"var(--ink)",fontFamily:"var(--sans)",overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"},children:i}),s.jsxs("div",{style:{display:"flex",alignItems:"center",gap:"8px"},children:[s.jsx("span",{style:{flex:1,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap",fontSize:"0.78rem",color:"var(--muted)",fontFamily:"var(--sans)"},children:l}),s.jsx("a",{href:e,target:"_blank",rel:"noreferrer noopener",style:{fontSize:"0.78rem",color:"var(--accent)",textDecoration:"none",whiteSpace:"nowrap",fontFamily:"var(--sans)",flexShrink:0},children:t==="pdf"?"Open PDF ↗":"Open in new tab ↗"})]})]}),s.jsx("iframe",{src:e,sandbox:"allow-same-origin allow-popups",allow:"autoplay 'none'",referrerPolicy:"no-referrer",style:{flex:1,border:"none",width:"100%",minHeight:0},title:i||(t==="pdf"?"PDF preview":"Page preview")})]})}function Rg({src:e,alt:t}){return s.jsx("div",{className:"preview-image-wrap",style:{display:"flex",alignItems:"center",justifyContent:"center",width:"100%",height:"100%",background:"var(--paper-2)",overflow:"hidden"},children:s.jsx("a",{href:e,target:"_blank",rel:"noreferrer noopener",style:{display:"contents"},children:s.jsx("img",{src:e,alt:t||"",style:{objectFit:"contain",maxHeight:"100%",maxWidth:"100%",display:"block",cursor:"pointer"}})})})}function xf(e){return e?new Intl.DateTimeFormat("en-US",{month:"short",day:"numeric",year:"numeric"}).format(new Date(e*1e3)):""}function Vu(e){return!e||!e.includes("&")?e:e.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,'"').replace(/'/g,"'").replace(/'/g,"'")}const le={card:{background:"var(--paper)",border:"1px solid var(--line)",borderRadius:"12px",overflow:"hidden",maxWidth:"560px",margin:"0 auto",fontFamily:'var(--sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif)'},threadOuter:{border:"1px solid var(--line)",borderRadius:"12px",overflow:"hidden",maxWidth:"560px",margin:"0 auto",background:"var(--paper)",fontFamily:'var(--sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif)'},tweetRow:{display:"flex",gap:"10px",padding:"10px 12px"},tweetRowThread:{display:"flex",gap:"10px",padding:"10px 12px 0"},leftCol:{display:"flex",flexDirection:"column",alignItems:"center",flexShrink:0,width:"36px"},rightCol:{flex:1,minWidth:0,paddingBottom:"8px"},avatar:{width:"36px",height:"36px",borderRadius:"50%",objectFit:"cover",flexShrink:0,display:"block"},avatarPh:{width:"36px",height:"36px",borderRadius:"50%",background:"var(--line)",flexShrink:0},threadLine:{flex:1,width:"2px",background:"var(--line-soft, var(--line))",margin:"4px 0",minHeight:"12px",borderRadius:"1px"},authorRow:{display:"flex",alignItems:"baseline",gap:"4px",flexWrap:"wrap",marginBottom:"4px",lineHeight:"1.3"},authorName:{fontWeight:"700",fontSize:"14px",color:"var(--ink)"},authorHandle:{fontSize:"13px",color:"var(--muted)"},datePart:{fontSize:"13px",color:"var(--muted)"},tweetText:{fontSize:"14px",lineHeight:"1.5",color:"var(--ink)",whiteSpace:"pre-line",marginBottom:"8px",wordBreak:"break-word"},stats:{display:"flex",gap:"12px",fontSize:"13px",color:"var(--muted)"},link:{color:"var(--accent)",textDecoration:"none"},loading:{padding:"24px 16px",textAlign:"center",color:"var(--muted)",fontSize:"14px"},error:{padding:"24px 16px",textAlign:"center",color:"var(--alert)",fontSize:"14px"},mediaGrid:{marginBottom:"8px",borderRadius:"10px",overflow:"hidden",border:"1px solid var(--line)"},mediaImg:{display:"block",width:"100%",objectFit:"cover",maxHeight:"260px"},mediaVideo:{display:"block",width:"100%",maxHeight:"260px",background:"#000"},article:{maxWidth:"560px",margin:"0 auto",border:"1px solid var(--line)",borderRadius:"12px",overflow:"hidden",background:"var(--paper)",fontFamily:'var(--sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif)'},aCover:{width:"100%",display:"block",objectFit:"cover",maxHeight:"280px"},aMeta:{padding:"10px 14px 0"},aTweetTitle:{fontSize:"20px",fontWeight:"800",letterSpacing:"-0.3px",color:"var(--ink)",lineHeight:"1.3",marginBottom:"8px"},aAuthorRow:{display:"flex",alignItems:"center",gap:"8px",marginBottom:"8px"},aAvatar:{width:"36px",height:"36px",borderRadius:"50%",objectFit:"cover",flexShrink:0,display:"block"},aAvatarPh:{width:"36px",height:"36px",borderRadius:"50%",background:"var(--line)",flexShrink:0},aAuthorName:{fontSize:"14px",fontWeight:"700",color:"var(--ink)",lineHeight:"1.3"},aAuthorSub:{fontSize:"13px",color:"var(--muted)",lineHeight:"1.3"},aDivider:{border:"none",borderTop:"1px solid var(--line)",margin:"0"},aBody:{padding:"4px 14px 16px"},bH1:{fontSize:"22px",fontWeight:"800",letterSpacing:"-0.4px",color:"var(--ink)",lineHeight:"1.25",margin:"16px 0 6px"},bH2:{fontSize:"18px",fontWeight:"700",letterSpacing:"-0.2px",color:"var(--ink)",lineHeight:"1.3",margin:"14px 0 4px"},bP:{fontSize:"15px",color:"var(--ink)",lineHeight:"1.65",marginBottom:"12px",marginTop:"0"},bSpacer:{height:"4px",display:"block"},bQuote:{borderLeft:"3px solid var(--line)",padding:"2px 12px",margin:"12px 0",color:"var(--muted)",fontSize:"15px",lineHeight:"1.6"},bHr:{border:"none",borderTop:"1px solid var(--line)",margin:"14px 0"},bImg:{width:"100%",display:"block",borderRadius:"8px",margin:"12px 0"},bUl:{margin:"8px 0 12px",paddingLeft:"24px"},bOl:{margin:"8px 0 12px",paddingLeft:"24px"},bLi:{fontSize:"15px",color:"var(--ink)",lineHeight:"1.6",marginBottom:"4px"},bTweet:{display:"flex",alignItems:"center",gap:"10px",border:"1px solid var(--line)",borderRadius:"10px",padding:"10px 14px",margin:"12px 0",color:"var(--muted)",fontSize:"14px",textDecoration:"none"},bMdPre:{borderRadius:"8px",margin:"10px 0",overflow:"auto",background:"var(--paper-3, var(--field))",padding:"12px 14px"},bMdCode:{fontFamily:"ui-monospace, 'Cascadia Code', 'Fira Code', Menlo, Consolas, monospace",fontSize:"12px",lineHeight:"1.6",color:"var(--ink)",background:"transparent",display:"block"},iCode:{fontFamily:"ui-monospace, 'Cascadia Code', Menlo, Consolas, monospace",fontSize:"0.875em",background:"var(--paper-3, var(--field))",padding:"1px 5px",borderRadius:"4px",color:"var(--ink)"},qtBadge:{fontSize:"11px",color:"var(--muted)",display:"inline-flex",alignItems:"center",gap:"2px",marginLeft:"4px",letterSpacing:"0.03em",flexShrink:0},lightboxBackdrop:{position:"fixed",inset:0,zIndex:500,background:"rgba(0,0,0,0.92)",display:"flex",alignItems:"center",justifyContent:"center",padding:"20px"},lightboxContent:{display:"flex",flexDirection:"column",alignItems:"center",gap:"10px",maxWidth:"90vw",maxHeight:"90vh"},lightboxToolbar:{display:"flex",alignItems:"center",gap:"10px",alignSelf:"flex-end"},lightboxImg:{maxWidth:"88vw",maxHeight:"80vh",objectFit:"contain",borderRadius:"6px",display:"block"},lightboxNav:{display:"flex",gap:"12px",alignItems:"center"},lightboxNavBtn:{background:"rgba(255,255,255,0.18)",border:"none",color:"#fff",fontSize:"20px",cursor:"pointer",borderRadius:"50%",width:"36px",height:"36px",display:"flex",alignItems:"center",justifyContent:"center",lineHeight:1,flexShrink:0},lightboxBtn:{background:"rgba(255,255,255,0.18)",border:"none",color:"#fff",fontSize:"16px",cursor:"pointer",borderRadius:"50%",width:"30px",height:"30px",display:"flex",alignItems:"center",justifyContent:"center",lineHeight:1,flexShrink:0},lightboxLink:{color:"rgba(255,255,255,0.7)",textDecoration:"none",fontSize:"14px"},lightboxCounter:{color:"rgba(255,255,255,0.6)",fontSize:"13px"}},Ve={bg:"#000000",border:"#2f3336",text:"#e7e9ea",dim:"#71767b",accent:"#1d9bf0",codeBg:"#16181c"},Ti={article:{background:Ve.bg,color:Ve.text,minHeight:"100%",fontFamily:'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'},articleInner:{maxWidth:"598px",margin:"0 auto",paddingBottom:"80px"},aCover:{width:"100%",display:"block",objectFit:"cover",maxHeight:"420px"},aMeta:{padding:"20px 16px 0"},aTweetTitle:{fontSize:"34px",fontWeight:"800",letterSpacing:"-0.5px",color:Ve.text,lineHeight:"44px",marginBottom:"16px"},aAuthorRow:{display:"flex",alignItems:"center",gap:"12px",marginBottom:"16px"},aAvatar:{width:"40px",height:"40px",borderRadius:"50%",objectFit:"cover",flexShrink:0,display:"block"},aAvatarPh:{width:"40px",height:"40px",borderRadius:"50%",background:Ve.border,flexShrink:0},aAuthorName:{fontSize:"15px",fontWeight:"700",color:Ve.text,lineHeight:"1.3"},aAuthorSub:{fontSize:"15px",color:Ve.dim,lineHeight:"1.3"},aDivider:{border:"none",borderTop:`1px solid ${Ve.border}`,margin:"0"},aBody:{padding:"4px 16px 0"},bH1:{fontSize:"26px",fontWeight:"800",letterSpacing:"-0.4px",color:Ve.text,lineHeight:"36px",margin:"24px 0 10px"},bH2:{fontSize:"26px",fontWeight:"800",letterSpacing:"-0.4px",color:Ve.text,lineHeight:"36px",margin:"24px 0 10px"},bP:{fontSize:"17px",color:Ve.text,lineHeight:"1.5",marginBottom:"16px",marginTop:"0"},bSpacer:{height:"6px",display:"block"},bQuote:{borderLeft:`3px solid ${Ve.border}`,padding:"2px 14px",margin:"14px 0",color:Ve.dim,fontSize:"17px",lineHeight:"1.5"},bHr:{border:"none",borderTop:`1px solid ${Ve.border}`,margin:"28px 0"},bImg:{width:"100%",display:"block",borderRadius:"12px",margin:"16px 0"},bUl:{margin:"10px 0 16px",paddingLeft:"28px"},bOl:{margin:"10px 0 16px",paddingLeft:"28px"},bLi:{fontSize:"17px",color:Ve.text,lineHeight:"1.5",marginBottom:"6px"},bTweet:{display:"flex",alignItems:"center",gap:"12px",border:`1px solid ${Ve.border}`,borderRadius:"12px",padding:"14px 16px",margin:"14px 0",color:Ve.dim,fontSize:"15px",textDecoration:"none"},bMdPre:{borderRadius:"12px",margin:"12px 0",overflow:"auto",background:"#1e2029"},bMdCode:{fontFamily:"ui-monospace, 'Cascadia Code', 'Fira Code', Menlo, Consolas, monospace",fontSize:"13px",lineHeight:"1.65",padding:"18px 20px",display:"block",color:Ve.text,background:"transparent"},iCode:{fontFamily:"ui-monospace, 'Cascadia Code', Menlo, Consolas, monospace",fontSize:"0.875em",background:Ve.codeBg,padding:"2px 6px",borderRadius:"4px",color:"#e6edf3"},link:{color:Ve.accent,textDecoration:"none"}};function zg(e,t,n){const r={};return n&&n.forEach((l,i)=>{l.relpath&&(r[l.relpath]=`/api/archives/${e}/entries/${t}/artifacts/${i}`)}),r}function hr(e,t,n){return e&&n[e]?n[e]:t||null}function wf(){return s.jsx("svg",{width:"20",height:"20",viewBox:"0 0 24 24",fill:"currentColor","aria-hidden":"true",style:{flexShrink:0,color:"var(--ink)"},children:s.jsx("path",{d:"M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.748l7.73-8.835L1.254 2.25H8.08l4.259 5.63L18.244 2.25zm-1.161 17.52h1.833L7.084 4.126H5.117L17.083 19.77z"})})}function jo(e,t,...n){var r;if(e.fromIndex!=null&&e.toIndex!=null)return{s:e.fromIndex,e:e.toIndex};if(((r=e.indices)==null?void 0:r.length)===2)return{s:e.indices[0],e:e.indices[1]};if(t)for(const l of n){if(!l)continue;const i=t.indexOf(l);if(i!==-1)return{s:i,e:i+l.length}}return null}function kf(e,t){const n=e.expanded_url||e.url||e.text||"",r=e.display_url||e.expanded_url||e.url||e.text||"",l=jo(e,t,e.url,e.text,e.display_url,e.expanded_url);return!l||!n?null:{...l,kind:"url",href:n,display:r}}function jf(e,t){const n=e.screen_name||e.name||e.text||"",r=n?`@${n}`:null,l=jo(e,t,r);return!l||!n?null:{...l,kind:"mention",screen_name:n}}const Ku=/https?:\/\/[^\s<>"'\])]+/g,Ig=/[.,;:!?)()]+$/;function Ns(e,t){const n=[];let r=0,l;for(Ku.lastIndex=0;(l=Ku.exec(e))!==null;){l.index>r&&n.push(e.slice(r,l.index));let i=l[0].replace(Ig,"");n.push(s.jsx("a",{href:i,target:"_blank",rel:"noopener noreferrer",style:t,children:i},l.index));const a=l[0].slice(i.length);a&&n.push(a),r=l.index+l[0].length}return r===0?e:(rkf(o,e)).filter(Boolean),...(t.user_mentions||[]).map(o=>jf(o,e)).filter(Boolean)];if(r.length===0&&n.length===0)return Ns(Vu(e),le.link);const l=new Set([0,e.length]);for(const o of r)o.s>=0&&o.s<=e.length&&l.add(o.s),o.e>=0&&o.e<=e.length&&l.add(o.e);for(const[o,u]of n)o>=0&&o<=e.length&&l.add(o),u>=0&&u<=e.length&&l.add(u);const i=(o,u)=>n.some(([d,y])=>d<=o&&y>=u),a=[...l].sort((o,u)=>o-u);return a.slice(0,-1).map((o,u)=>{const d=a[u+1];if(i(o,d))return null;const y=e.slice(o,d),g=r.filter(w=>w.s<=o&&w.e>=d),m=g.find(w=>w.kind==="url");if(m)return s.jsx("a",{href:m.href,target:"_blank",rel:"noopener noreferrer",style:le.link,children:m.display||y},u);const x=g.find(w=>w.kind==="mention");return x?s.jsx("a",{href:`https://x.com/${x.screen_name}`,target:"_blank",rel:"noopener noreferrer",style:le.link,children:y},u):s.jsx("span",{children:Ns(Vu(y),le.link)},u)}).filter(o=>o!==null)}function Dg(e,t,n,r,l=le){if(!e)return null;t=t||[],n=n||[],r=r||[];const i=[];for(const u of t)u.length>0&&i.push({s:u.offset,e:u.offset+u.length,kind:"style",style:u.style});for(const u of n){const d=kf(u,e);d&&i.push(d)}for(const u of r){const d=jf(u,e);d&&i.push(d)}if(i.length===0)return Ns(e,l.link);const a=new Set([0,e.length]);for(const u of i)u.s>=0&&u.s<=e.length&&a.add(u.s),u.e>=0&&u.e<=e.length&&a.add(u.e);const o=[...a].sort((u,d)=>u-d);return o.slice(0,-1).map((u,d)=>{const y=o[d+1],g=i.filter(_=>_.s<=u&&_.e>=y),m=e.slice(u,y);let x;if(m.includes(` +`)){const _=m.split(` +`);x=_.flatMap((p,f)=>f<_.length-1?[p,s.jsx("br",{},f)]:[p])}else x=m;g.some(_=>_.kind==="style"&&_.style==="Code")&&(x=s.jsx("code",{style:l.iCode,children:x})),g.some(_=>_.kind==="style"&&_.style==="Bold")&&(x=s.jsx("strong",{children:x})),g.some(_=>_.kind==="style"&&_.style==="Italic")&&(x=s.jsx("em",{children:x})),g.some(_=>_.kind==="style"&&_.style==="Underline")&&(x=s.jsx("u",{children:x})),g.some(_=>_.kind==="style"&&_.style==="Strikethrough")&&(x=s.jsx("s",{children:x}));const w=g.find(_=>_.kind==="url");if(w){const _=/^https?:\/\/t\.co\//i.test(x);x=s.jsx("a",{href:w.href,target:"_blank",rel:"noopener noreferrer",style:l.link,children:_?w.display:x})}const S=g.find(_=>_.kind==="mention");return S&&(x=s.jsx("a",{href:`https://x.com/${S.screen_name}`,target:"_blank",rel:"noopener noreferrer",style:l.link,children:x})),s.jsx("span",{children:typeof x=="string"?Ns(x,l.link):x},d)})}function Og(e,t,n){const r=(n==null?void 0:n.st)||le,l=e.resolved_entities||[];return l.length===0?null:l.map((i,a)=>{var o;switch(i.type){case"divider":return s.jsx("hr",{style:r.bHr},a);case"media":{const u=hr(i.local_path,i.url,t);return u?s.jsx("a",{href:u,target:"_blank",rel:"noopener noreferrer",style:{display:"block",cursor:"zoom-in"},onClick:d=>{var y;!d.metaKey&&!d.ctrlKey&&(d.preventDefault(),(y=n==null?void 0:n.onImgClick)==null||y.call(n,u))},children:s.jsx("img",{src:u,style:r.bImg,loading:"lazy",alt:""})},a):null}case"tweet":return i.tweet_id?s.jsxs("a",{href:`https://x.com/i/status/${i.tweet_id}`,target:"_blank",rel:"noopener noreferrer",style:r.bTweet,children:[s.jsx(wf,{}),"View post on X"]},a):null;case"link":return i.url?s.jsx("p",{style:r.bP,children:s.jsx("a",{href:i.url,target:"_blank",rel:"noopener noreferrer",style:r.link,children:i.url})},a):null;case"markdown":{const u=i.markdown??((o=i.data)==null?void 0:o.markdown)??"";return s.jsx("pre",{style:r.bMdPre,children:s.jsx("code",{style:r.bMdCode,children:u})},a)}case"emoji":return i.url?s.jsx("img",{src:i.url,alt:"",style:{height:"1.2em",verticalAlign:"middle",margin:"0 1px"}},a):null;default:return null}})}function Pi(e,t,n,r){const l=(r==null?void 0:r.st)||le,i=e.type||"",a=e.text||"",o=e.inline_style_ranges||[],u=e.data||{},d=Dg(a,o,u.urls||[],u.mentions||[],l);switch(i){case"header-one":return s.jsx("h1",{style:l.bH1,children:d},t);case"header-two":{const y=a.match(/(?:x\.com|twitter\.com)\/i\/status\/(\d+)/);return y?s.jsxs("a",{href:`https://x.com/i/status/${y[1]}`,target:"_blank",rel:"noopener noreferrer",style:l.bTweet,children:[s.jsx(wf,{}),"View post on X"]},t):s.jsx("h2",{style:l.bH2,children:d},t)}case"unstyled":return a.trim()?s.jsx("p",{style:l.bP,children:d},t):s.jsx("span",{style:l.bSpacer},t);case"blockquote":return s.jsx("blockquote",{style:l.bQuote,children:d},t);case"unordered-list-item":case"ordered-list-item":return s.jsx("li",{style:l.bLi,children:d},t);case"atomic":return s.jsx("span",{children:Og(e,n,r)},t);default:return a?s.jsx("p",{style:l.bP,children:d},t):null}}function Ag(e,t,n){const r=(n==null?void 0:n.st)||le,l=[];let i=0;for(;i{r&&(l==null||l(!0))},[r,l]);const o=r?Ti:le,u=e.cover_media||{},d=e.author||t||{},y=e.first_published_at_secs?xf(e.first_published_at_secs):"",m=[d.screen_name?`@${d.screen_name}`:"",y].filter(Boolean).join(" · "),x=hr(u.local_path,u.url,n),w=hr(d.avatar_local_path,d.avatar_url,n),S=s.jsxs(s.Fragment,{children:[i&&s.jsx(Sf,{items:[{src:i,alt:""}],startIndex:0,onClose:()=>a(null)}),x&&s.jsx("a",{href:x,target:"_blank",rel:"noopener noreferrer",style:{display:"block",cursor:"zoom-in"},onClick:_=>{!_.metaKey&&!_.ctrlKey&&(_.preventDefault(),a(x))},children:s.jsx("img",{src:x,style:o.aCover,alt:"Article cover"})}),s.jsxs("div",{style:o.aMeta,children:[e.title&&s.jsx("div",{style:o.aTweetTitle,children:e.title}),s.jsxs("div",{style:o.aAuthorRow,children:[w?s.jsx("img",{src:w,style:o.aAvatar,alt:d.name||""}):s.jsx("div",{style:o.aAvatarPh}),s.jsxs("div",{children:[s.jsx("div",{style:o.aAuthorName,children:d.name||d.screen_name||"Unknown"}),m&&s.jsx("div",{style:o.aAuthorSub,children:m})]})]})]}),s.jsx("hr",{style:o.aDivider}),s.jsx("div",{style:o.aBody,children:Ag(e.blocks||[],n,{onImgClick:a,st:o})})]});return r?s.jsx("div",{style:Ti.article,children:s.jsx("div",{style:Ti.articleInner,children:S})}):s.jsx("div",{style:le.article,children:S})}function Fg({photos:e,onOpen:t}){const n=e.length;if(n===0)return null;if(n===1)return s.jsx("a",{href:e[0].src,target:"_blank",rel:"noopener noreferrer",style:{display:"block"},onClick:l=>{!l.metaKey&&!l.ctrlKey&&(l.preventDefault(),t(0))},children:s.jsx("img",{src:e[0].src,alt:e[0].alt||"",style:le.mediaImg,loading:"lazy"})});const r=n<=2?"180px":"140px";return s.jsx("div",{style:{display:"grid",gridTemplateColumns:"1fr 1fr",gridTemplateRows:n===2?r:`${r} ${r}`,gap:"2px"},children:e.map((l,i)=>s.jsx("a",{href:l.src,target:"_blank",rel:"noopener noreferrer",style:{display:"block",overflow:"hidden",gridRow:n===3&&i===0?"span 2":void 0},onClick:a=>{!a.metaKey&&!a.ctrlKey&&(a.preventDefault(),t(i))},children:s.jsx("img",{src:l.src,alt:l.alt||"",loading:"lazy",style:{width:"100%",height:"100%",objectFit:"cover",display:"block"}})},i))})}function Sf({items:e,startIndex:t,onClose:n}){const[r,l]=c.useState(t);c.useEffect(()=>{const a=o=>{(o.key==="Escape"||o.key==="ArrowLeft"||o.key==="ArrowRight")&&(o.stopPropagation(),o.preventDefault()),o.key==="Escape"&&n(),o.key==="ArrowRight"&&l(u=>Math.min(u+1,e.length-1)),o.key==="ArrowLeft"&&l(u=>Math.max(u-1,0))};return document.addEventListener("keydown",a),()=>document.removeEventListener("keydown",a)},[n,e.length]);const i=e[r];return s.jsx("div",{style:le.lightboxBackdrop,onClick:n,children:s.jsxs("div",{style:le.lightboxContent,onClick:a=>a.stopPropagation(),children:[s.jsxs("div",{style:le.lightboxToolbar,children:[e.length>1&&s.jsxs("span",{style:le.lightboxCounter,children:[r+1," / ",e.length]}),s.jsx("a",{href:i.src,target:"_blank",rel:"noopener noreferrer",style:le.lightboxLink,title:"Open in new tab",children:"↗"}),s.jsx("button",{style:le.lightboxBtn,onClick:n,"aria-label":"Close",children:"×"})]}),s.jsx("img",{src:i.src,alt:i.alt||"",style:le.lightboxImg}),e.length>1&&s.jsxs("div",{style:le.lightboxNav,children:[s.jsx("button",{style:le.lightboxNavBtn,onClick:()=>l(a=>Math.max(a-1,0)),disabled:r===0,children:"‹"}),s.jsx("button",{style:le.lightboxNavBtn,onClick:()=>l(a=>Math.min(a+1,e.length-1)),disabled:r===e.length-1,children:"›"})]})]})})}function Qu({tweet:e,isInThread:t,isLast:n,artifactMap:r}){var f,h;const[l,i]=c.useState(null),a=e.author||{},o=e.created_at_secs?xf(e.created_at_secs):"",u=e.entities||{},d=hr(a.avatar_local_path,a.avatar_url,r),y=t&&!n,g=e.is_quote_status===!0,m=t?le.tweetRowThread:le.tweetRow,x=e.full_text||"",w=((h=(f=e.extended_entities)==null?void 0:f.media)!=null&&h.length?e.extended_entities.media:u.media)||[],S=[],_=[];for(const k of w)if(k.type==="photo"){const E=hr(k.local_path,k.media_url_https,r);E&&S.push({kind:"photo",src:E,alt:k.alt_text||""})}else if(k.type==="video"||k.type==="animated_gif"){const E=k.local_path&&r[k.local_path]||(()=>{var N,v;return(v=(((N=k.video_info)==null?void 0:N.variants)||[]).filter(b=>b.content_type==="video/mp4").sort((b,L)=>(L.bitrate||0)-(b.bitrate||0))[0])==null?void 0:v.url})();E&&_.push({kind:k.type==="animated_gif"?"gif":"video",src:E})}const p=[];for(const k of w){if(!k.url||!(k.type==="photo"?S.some(N=>N.src===hr(k.local_path,k.media_url_https,r)):_.length>0))continue;const P=jo(k,x,k.url);P&&p.push([P.s,P.e])}return s.jsxs(s.Fragment,{children:[l!==null&&s.jsx(Sf,{items:S,startIndex:l,onClose:()=>i(null)}),s.jsxs("div",{style:m,children:[s.jsxs("div",{style:le.leftCol,children:[d?s.jsx("img",{src:d,style:le.avatar,alt:a.name||""}):s.jsx("div",{style:le.avatarPh}),y&&s.jsx("div",{style:le.threadLine})]}),s.jsxs("div",{style:le.rightCol,children:[s.jsxs("div",{style:le.authorRow,children:[s.jsx("span",{style:le.authorName,children:a.name||a.screen_name||"Unknown"}),a.screen_name&&s.jsxs("span",{style:le.authorHandle,children:["@",a.screen_name]}),o&&s.jsxs("span",{style:le.datePart,children:["· ",o]}),g&&s.jsx("span",{style:le.qtBadge,title:"Quote tweet",children:"↻ QT"})]}),s.jsx("div",{style:le.tweetText,children:$g(x,u,p)}),S.length>0&&s.jsx("div",{style:le.mediaGrid,children:s.jsx(Fg,{photos:S,onOpen:k=>i(k)})}),_.map((k,E)=>s.jsx("div",{style:le.mediaGrid,children:s.jsx("video",{src:k.src,style:le.mediaVideo,controls:!0,loop:k.kind==="gif",muted:k.kind==="gif",autoPlay:k.kind==="gif"})},E)),(e.retweet_count>0||e.favorite_count>0)&&s.jsxs("div",{style:le.stats,children:[e.favorite_count>0&&s.jsxs("span",{children:["❤️ ",e.favorite_count.toLocaleString()]}),e.retweet_count>0&&s.jsxs("span",{children:["🔁 ",e.retweet_count.toLocaleString()]})]})]})]})]})}function Bg({archiveId:e,entryUid:t,artifacts:n,entityKind:r,fullPage:l,onXArticle:i}){const[a,o]=c.useState(!0),[u,d]=c.useState(null),[y,g]=c.useState([]);if(c.useEffect(()=>{if(o(!0),d(null),g([]),!n||!e||!t){o(!1);return}const w=n.map((_,p)=>({..._,index:p})).filter(_=>_.artifact_role==="raw_tweet_json");if(w.length===0){d("No tweet data found."),o(!1);return}let S=!1;return nm(e,t,w.map(_=>_.index)).then(async _=>{if(S)return;const p=/https?:\/\/t\.co\/[A-Za-z0-9]+/g,f=N=>{var b;const v=N.full_text||"";return(((b=N.entities)==null?void 0:b.urls)||[]).map(L=>{var X;if(L.fromIndex!=null&&L.toIndex!=null)return[L.fromIndex,L.toIndex];if(((X=L.indices)==null?void 0:X.length)===2)return[L.indices[0],L.indices[1]];if(L.url){const Q=v.indexOf(L.url);if(Q!==-1)return[Q,Q+L.url.length]}return null}).filter(Boolean)},h=(N,v,b)=>N.some(([L,X])=>L<=v&&X>=b),k=new Set;for(const N of _){const v=N.full_text||"",b=f(N);let L;for(p.lastIndex=0;(L=p.exec(v))!==null;)h(b,L.index,L.index+L[0].length)||k.add(L[0])}const E=k.size>0?await rm([...k]).catch(()=>({})):{},P=_.map(N=>{var Q;const v=N.full_text||"",b=f(N),L=[];let X;for(p.lastIndex=0;(X=p.exec(v))!==null;){const ne=X[0],q=E[ne];q&&q!==ne&&!h(b,X.index,X.index+ne.length)&&L.push({url:ne,expanded_url:q,display_url:(()=>{try{const fe=new URL(q);return fe.hostname+(fe.pathname.length>1?"/…":"")}catch{return q}})(),fromIndex:X.index,toIndex:X.index+ne.length})}return L.length===0?N:{...N,entities:{...N.entities||{},urls:[...((Q=N.entities)==null?void 0:Q.urls)||[],...L]}}});S||g(P)}).catch(_=>{S||d(_.message||"Failed to load tweet.")}).finally(()=>{S||o(!1)}),()=>{S=!0}},[e,t,n]),a)return s.jsx("div",{style:le.loading,children:"Loading…"});if(u)return s.jsxs("div",{style:le.error,children:["Error: ",u]});if(y.length===0)return null;const m=zg(e,t,n);if(r==="tweet_thread")return s.jsx("div",{style:le.threadOuter,children:y.map((w,S)=>s.jsx(Qu,{tweet:w,isInThread:!0,isLast:S===y.length-1,artifactMap:m},w.id||S))});const x=y[0];return x.is_article&&x.article?s.jsx(Mg,{article:x.article,tweetAuthor:x.author,artifactMap:m,fullPage:l,onXArticle:i}):s.jsx("div",{style:le.card,children:s.jsx(Qu,{tweet:x,isInThread:!1,isLast:!0,artifactMap:m})})}function Ug({src:e,mime:t,title:n}){const[r,l]=c.useState(null),[i,a]=c.useState(null);return c.useEffect(()=>{const o=new AbortController;return l(null),a(null),em(e,{signal:o.signal}).then(l).catch(u=>{u.name!=="AbortError"&&a(u.message||String(u))}),()=>o.abort()},[e]),i?s.jsxs("div",{className:"text-preview text-preview--error",children:["Failed to load text: ",i]}):r===null?s.jsx("div",{className:"text-preview text-preview--loading",children:"Loading…"}):s.jsxs("div",{className:"text-preview",children:[n&&s.jsx("h1",{className:"text-preview__title",children:n}),s.jsx("pre",{className:"text-preview__body",children:r}),t&&s.jsx("div",{className:"text-preview__mime",children:t})]})}const Hg=new Set(["mp4","webm","mov","mkv","avi","m4v","ogv"]),Wg=new Set(["mp3","ogg","m4a","opus","wav","flac","aac"]),Vg=new Set(["jpg","jpeg","png","gif","webp","avif","svg","bmp"]),Kg=new Set(["md","markdown","txt"]),Qg={mp4:"video/mp4",webm:"video/webm",mov:"video/quicktime",mkv:"video/x-matroska",avi:"video/x-msvideo",m4v:"video/mp4",ogv:"video/ogg"};function _f({archiveId:e,entry:t,detail:n,fullPage:r,onXArticle:l}){if(!t)return s.jsx("div",{className:"preview-panel preview-panel--empty",children:s.jsx("span",{style:{color:"var(--muted)",fontFamily:"var(--sans)",fontSize:"0.9rem"},children:"Select an entry to preview"})});if(!n)return s.jsx("div",{className:"preview-panel preview-panel--loading",children:s.jsx("span",{style:{color:"var(--muted)",fontFamily:"var(--sans)",fontSize:"0.9rem"},children:"Loading…"})});const{summary:i,artifacts:a}=n,o=i.entry_uid,u=i.entity_kind;if(u==="tweet"||u==="tweet_thread"){const x=s.jsx(Bg,{archiveId:e,entryUid:o,artifacts:a,entityKind:u,fullPage:r,onXArticle:l});return r?x:s.jsx("div",{className:"preview-tweet-wrap",children:x})}const d=a.findIndex(x=>x.artifact_role==="primary_media");if(d===-1)return s.jsxs("div",{className:"preview-panel preview-panel--no-preview",children:[s.jsx("span",{style:{color:"var(--muted)",fontFamily:"var(--sans)",fontSize:"0.9rem"},children:"No preview available"}),a.length>0&&s.jsx("ul",{style:{marginTop:"12px",paddingLeft:"20px",fontSize:"0.8rem",color:"var(--muted-2)",fontFamily:"var(--sans)"},children:a.map((x,w)=>s.jsxs("li",{children:[x.artifact_role,": ",x.relpath]},w))})]});const y=a[d],g=`/api/archives/${e}/entries/${o}/artifacts/${d}`,m=y.relpath.split(".").pop().toLowerCase();return Hg.has(m)?s.jsx("div",{className:"preview-panel",children:s.jsx(Lg,{src:g,contentType:Qg[m]||"video/mp4"})}):Wg.has(m)?s.jsxs("div",{className:"preview-panel preview-panel--audio",style:{display:"flex",flexDirection:"column",alignItems:"center",justifyContent:"center",gap:"12px",padding:"24px",fontFamily:"var(--sans)"},children:[s.jsx("span",{style:{fontSize:"2rem"},children:"🎵"}),s.jsx("span",{style:{color:"var(--ink)",fontSize:"0.95rem",fontWeight:600},children:i.title||o}),s.jsx("audio",{src:g,controls:!0,style:{marginTop:"8px",width:"100%",maxWidth:"400px"}})]}):m==="pdf"?s.jsx("div",{className:"preview-panel",style:{flex:1,minHeight:0},children:s.jsx(Wu,{src:g,type:"pdf",title:i.title,originalUrl:i.original_url})}):m==="html"||m==="htm"?s.jsx("div",{className:"preview-panel",style:{flex:1,minHeight:0},children:s.jsx(Wu,{src:g,type:"page",title:i.title,originalUrl:i.original_url})}):Kg.has(m)?s.jsx("div",{className:"preview-panel",style:{flex:1,minHeight:0,overflow:"auto"},children:s.jsx(Ug,{src:g,mime:y.mime_type,title:i.title})}):Vg.has(m)?s.jsx("div",{className:"preview-panel",style:{height:"100%"},children:s.jsx(Rg,{src:g,alt:i.title||"Image"})}):s.jsxs("div",{className:"preview-panel preview-panel--no-preview",children:[s.jsx("span",{style:{color:"var(--muted)",fontFamily:"var(--sans)",fontSize:"0.9rem"},children:"No preview available"}),s.jsx("ul",{style:{marginTop:"12px",paddingLeft:"20px",fontSize:"0.8rem",color:"var(--muted-2)",fontFamily:"var(--sans)"},children:a.map((x,w)=>s.jsxs("li",{children:[x.artifact_role,": ",x.relpath]},w))})]})}function qg({archiveId:e,entry:t,detail:n,onClose:r}){var l,i;return c.useEffect(()=>{const a=o=>{o.key==="Escape"&&r()};return window.addEventListener("keydown",a),()=>window.removeEventListener("keydown",a)},[r]),s.jsx("div",{className:"preview-modal-backdrop",onClick:r,children:s.jsxs("div",{className:`preview-modal${((l=n==null?void 0:n.summary)==null?void 0:l.entity_kind)==="tweet"||((i=n==null?void 0:n.summary)==null?void 0:i.entity_kind)==="tweet_thread"?"":" preview-modal--full"}`,onClick:a=>a.stopPropagation(),children:[s.jsxs("div",{className:"preview-modal-header",children:[s.jsx("span",{className:"preview-modal-title",children:(t==null?void 0:t.title)||(t==null?void 0:t.entry_uid)||"Preview"}),s.jsx("a",{className:"preview-modal-newtab",href:`/preview/${e}/${t==null?void 0:t.entry_uid}`,target:"_blank",rel:"noopener noreferrer",title:"Open in new tab",children:"↗"}),s.jsx("button",{className:"preview-modal-close",onClick:r,"aria-label":"Close preview",children:"×"})]}),s.jsx("div",{className:"preview-modal-body",children:s.jsx(_f,{archiveId:e,entry:t,detail:n})})]})})}function qu(e){if(!isFinite(e)||isNaN(e))return"--:--";const t=Math.floor(e/60),n=Math.floor(e%60);return`${t}:${String(n).padStart(2,"0")}`}function Xg({entry:e,src:t,archiveId:n,onClose:r}){const l=c.useRef(null),[i,a]=c.useState(!1),[o,u]=c.useState(0),[d,y]=c.useState(NaN),[g,m]=c.useState(1);c.useEffect(()=>{!l.current||!t||(l.current.load(),u(0),y(NaN),a(!1))},[t]),c.useEffect(()=>{l.current&&(l.current.volume=g)},[g]);function x(){const f=l.current;f&&(i?f.pause():f.play().catch(()=>{}))}function w(f){const h=l.current;if(!h||!isFinite(d))return;const k=Number(f.target.value);h.currentTime=k,u(k)}function S(f){m(Number(f.target.value))}const _=(e==null?void 0:e.title)||(e==null?void 0:e.entry_uid)||"Unknown",p=(e==null?void 0:e.source_kind)||"other";return s.jsxs(s.Fragment,{children:[s.jsx("audio",{ref:l,src:t||void 0,preload:"auto",onPlay:()=>a(!0),onPause:()=>a(!1),onEnded:()=>a(!1),onTimeUpdate:()=>{var f;return u(((f=l.current)==null?void 0:f.currentTime)??0)},onLoadedMetadata:()=>{var f;return y(((f=l.current)==null?void 0:f.duration)??NaN)},onDurationChange:()=>{var f;return y(((f=l.current)==null?void 0:f.duration)??NaN)},style:{display:"none"}}),s.jsxs("div",{className:"audio-bar",style:{position:"fixed",bottom:0,left:0,right:0,zIndex:100,background:"var(--paper-3)",borderTop:"1px solid var(--line)",display:"flex",alignItems:"center",gap:"16px",padding:"0 16px",height:"56px",fontFamily:"var(--sans)"},children:[s.jsxs("div",{className:"audio-bar-info",style:{display:"flex",alignItems:"center",gap:"8px",minWidth:0,flex:"0 1 220px",overflow:"hidden"},children:[s.jsx("span",{className:"source-icon",style:{flexShrink:0,width:"18px",height:"18px",display:"flex",alignItems:"center"},dangerouslySetInnerHTML:{__html:Us(p)}}),s.jsx("span",{style:{overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap",fontSize:"0.85rem",color:"var(--ink)"},title:_,children:_})]}),s.jsxs("div",{className:"audio-bar-controls",style:{display:"flex",alignItems:"center",gap:"10px",flex:"1 1 0",minWidth:0},children:[s.jsx("button",{onClick:x,"aria-label":i?"Pause":"Play",style:{background:"none",border:"none",cursor:"pointer",padding:"4px",color:"var(--ink)",flexShrink:0,fontSize:"1.2rem",lineHeight:1},children:i?"⏸":"▶"}),s.jsx("span",{style:{fontSize:"0.75rem",color:"var(--muted)",whiteSpace:"nowrap",flexShrink:0},children:qu(o)}),s.jsx("input",{type:"range",min:0,max:isFinite(d)?d:0,step:.1,value:isFinite(o)?o:0,onChange:w,"aria-label":"Seek",style:{flex:1,minWidth:0,accentColor:"var(--accent)"}}),s.jsx("span",{style:{fontSize:"0.75rem",color:"var(--muted)",whiteSpace:"nowrap",flexShrink:0},children:qu(d)})]}),s.jsxs("div",{className:"audio-bar-right",style:{display:"flex",alignItems:"center",gap:"8px",flex:"0 1 160px"},children:[s.jsx("span",{style:{fontSize:"0.85rem",color:"var(--muted)",flexShrink:0},children:"🔊"}),s.jsx("input",{type:"range",min:0,max:1,step:.01,value:g,onChange:S,"aria-label":"Volume",style:{width:"80px",accentColor:"var(--accent)"}}),s.jsx("button",{onClick:r,"aria-label":"Close audio player",style:{background:"none",border:"none",cursor:"pointer",padding:"4px",color:"var(--muted)",fontSize:"1rem",lineHeight:1,marginLeft:"4px"},children:"✕"})]})]})]})}function Jg({archiveId:e,entryUid:t}){var m,x;const[n,r]=c.useState(null),[l,i]=c.useState(!0),[a,o]=c.useState(null),[u,d]=c.useState(!1);c.useEffect(()=>{if(!u)return;const w=document.documentElement,S=document.body,_=w.style.colorScheme,p=S.style.background;return w.style.colorScheme="dark",S.style.background="#000",()=>{w.style.colorScheme=_,S.style.background=p}},[u]),c.useEffect(()=>{d(!1),Mr(e,t).then(w=>{r(w),i(!1)}).catch(w=>{o((w==null?void 0:w.message)||"Failed to load entry"),i(!1)})},[e,t]);const y=((m=n==null?void 0:n.summary)==null?void 0:m.title)||t,g=(x=n==null?void 0:n.summary)==null?void 0:x.original_url;return s.jsxs("div",{style:{minHeight:"100vh",display:"flex",flexDirection:"column",background:u?"#000":"var(--paper)",fontFamily:"var(--sans)"},children:[s.jsxs("div",{style:{display:"flex",alignItems:"center",gap:"10px",padding:u?"8px 12px":"10px 16px",borderBottom:u?"none":"1px solid var(--line)",flexShrink:0,position:u?"sticky":"relative",top:0,zIndex:20,background:u?"rgba(0,0,0,0.82)":"var(--paper-2)",backdropFilter:u?"blur(12px)":"none",WebkitBackdropFilter:u?"blur(12px)":"none"},children:[s.jsx("a",{href:"/",style:{color:u?"#1d9bf0":"var(--accent)",textDecoration:"none",fontSize:"13px",flexShrink:0},children:"← Archive"}),s.jsx("span",{style:{flex:1,fontSize:"14px",fontWeight:600,color:u?"#e7e9ea":"var(--ink)",overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"},children:y}),g&&s.jsx("a",{href:g,target:"_blank",rel:"noopener noreferrer",style:{color:u?"#71767b":"var(--muted)",textDecoration:"none",fontSize:"13px",flexShrink:0},children:"Original ↗"})]}),s.jsxs("div",{style:u?{flex:1}:{flex:1,minHeight:0,overflow:"auto",display:"flex",flexDirection:"column"},children:[l&&s.jsx("div",{style:{padding:"48px",textAlign:"center",color:"var(--muted)",fontSize:"14px"},children:"Loading…"}),a&&s.jsx("div",{style:{padding:"48px",textAlign:"center",color:"var(--alert)",fontSize:"14px"},children:a}),n&&s.jsx(_f,{archiveId:e,entry:n.summary,detail:n,fullPage:!0,onXArticle:d})]})]})}const Gg=7e3;function Yg({toasts:e,onDismiss:t,onIgnoreUblock:n}){return e.length?s.jsx("div",{className:"toast-stack",role:"log","aria-live":"polite","aria-label":"Notifications",children:e.map(r=>s.jsx(ey,{toast:r,onDismiss:t,onIgnoreUblock:n},r.id))}):null}function Zg(e){if(!e)return null;if(e.length<=52)return e;try{const{hostname:t,pathname:n,search:r}=new URL(e),l=n.split("/").filter(Boolean),i=(l[l.length-1]??"")+r,a=i?`${t}/…/${i}`:t;return a.length<=56?a:a.slice(0,53)+"…"}catch{return"…"+e.slice(-51)}}function ey({toast:e,onDismiss:t,onIgnoreUblock:n}){const[r,l]=c.useState(!1),i=e.type==="warning",a=e.type==="success";c.useEffect(()=>{if(r)return;const u=setTimeout(()=>t(e.id),Gg);return()=>clearTimeout(u)},[r,e.id,t]);const o=Zg(e.locator);return a?s.jsx("div",{className:"toast toast--success",role:"alert","aria-atomic":"true",children:s.jsxs("div",{className:"toast-top",children:[s.jsx("span",{className:"toast-icon","aria-hidden":"true",children:"✓"}),s.jsxs("div",{className:"toast-body",children:[s.jsx("span",{className:"toast-headline",children:e.headline||"Archived"}),o&&s.jsx("span",{className:"toast-locator",title:e.locator,children:o})]}),s.jsx("div",{className:"toast-btns",children:s.jsx("button",{type:"button",className:"toast-dismiss",onClick:()=>t(e.id),"aria-label":"Dismiss",children:"×"})})]})}):i?s.jsxs("div",{className:"toast toast--warning",role:"alert","aria-atomic":"true",children:[s.jsxs("div",{className:"toast-top",children:[s.jsx("span",{className:"toast-icon","aria-hidden":"true",children:"⚠"}),s.jsxs("div",{className:"toast-body",children:[s.jsx("span",{className:"toast-headline",children:e.headline||"Archived with warnings"}),o&&s.jsx("span",{className:"toast-locator",title:e.locator,children:o})]}),s.jsxs("div",{className:"toast-btns",children:[e.text&&s.jsx("button",{type:"button",className:"toast-view-btn",onClick:()=>l(u=>!u),"aria-expanded":r,children:r?"Hide":"Details"}),e.locator&&s.jsx("button",{type:"button",className:"toast-view-btn toast-ignore-btn",onClick:()=>{n==null||n(),t(e.id)},children:"Ignore"}),s.jsx("button",{type:"button",className:"toast-dismiss",onClick:()=>t(e.id),"aria-label":"Dismiss",children:"×"})]})]}),r&&s.jsx("p",{className:"toast-warning-detail",children:e.text||"The page was captured but one or more browser extensions were unavailable (ad-blocking or cookie-consent). Check ARCHIVR_UBLOCK_EXT and ARCHIVR_COOKIE_EXT in your server config."})]}):s.jsxs("div",{className:"toast toast--error",role:"alert","aria-atomic":"true",children:[s.jsxs("div",{className:"toast-top",children:[s.jsx("span",{className:"toast-icon","aria-hidden":"true",children:"✕"}),s.jsxs("div",{className:"toast-body",children:[s.jsx("span",{className:"toast-headline",children:e.headline||"Capture failed"}),o&&s.jsx("span",{className:"toast-locator",title:e.locator,children:o})]}),s.jsxs("div",{className:"toast-btns",children:[e.text&&s.jsx("button",{type:"button",className:"toast-view-btn",onClick:()=>l(u=>!u),children:r?"Hide":"View error"}),s.jsx("button",{type:"button",className:"toast-dismiss",onClick:()=>t(e.id),"aria-label":"Dismiss",children:"×"})]})]}),r&&e.text&&s.jsx("pre",{className:"toast-error-detail",children:e.text})]})}const Hs=c.createContext(null),ty=["archive","tags","collections","runs","admin","settings"],ny=["profile","tokens","instance","cookies","extensions","storage"],Ir=(()=>{const e=window.location.pathname.match(/^\/preview\/([^/]+)\/([^/]+)/);return e?{archiveId:e[1],entryUid:e[2]}:null})();function Wt(){const e=window.location.pathname.split("/").filter(Boolean),t=ty.includes(e[0])?e[0]:"archive",n=t==="settings"&&ny.includes(e[1])?e[1]:"profile",r=new URLSearchParams(window.location.search),l=r.get("q")??"",i=t==="archive"?r.get("tag")??null:null,a=t==="archive"?r.get("entry")??null:null,o=t==="archive"?r.get("collection")??null:null,u=o==="main"?null:o,d=r.get("archive")??null;return{view:t,settingsTab:n,q:l,tag:i,entry:a,collection:u,archive:d}}function ry(e,t){return e==="archive"?"/":e==="settings"?`/settings/${t}`:`/${e}`}function ly(){const[e,t]=c.useState("loading"),[n,r]=c.useState(null);c.useEffect(()=>{(async()=>{var M,W,G;if(await hm()){t("setup");return}const z=await cf();if(!z){const{collection:ue,archive:Ee}=Wt();try{const We=await Ru(),je=Ee?((M=We.find(T=>T.id===Ee))==null?void 0:M.id)??((W=We[0])==null?void 0:W.id):(G=We[0])==null?void 0:G.id;if(je){await zu(je,ue),i(We),o(je),d(ue),t("authenticated");return}}catch{}t("login");return}r(z),t("authenticated")})()},[]),c.useEffect(()=>{const C=()=>{n&&(r(null),t("login"))};return window.addEventListener("auth:expired",C),()=>window.removeEventListener("auth:expired",C)},[n]),c.useEffect(()=>{const C=()=>{const{view:z,settingsTab:M,q:W,tag:G,entry:ue,collection:Ee}=Wt();X(z),ne(M),fe(W),b(G),h(ue),E(null),N(ue?new Set([ue]):new Set),d(Ee)};return window.addEventListener("popstate",C),()=>window.removeEventListener("popstate",C)},[]);const[l,i]=c.useState([]),[a,o]=c.useState(null),[u,d]=c.useState(()=>Wt().collection),[y,g]=c.useState([]),[m,x]=c.useState([]),[w,S]=c.useState(()=>new Set),[_,p]=c.useState(()=>new Map),[f,h]=c.useState(()=>Wt().entry),[k,E]=c.useState(null),[P,N]=c.useState(()=>{const C=Wt().entry;return C?new Set([C]):new Set}),[v,b]=c.useState(()=>Wt().tag),[L,X]=c.useState(()=>Wt().view),[Q,ne]=c.useState(()=>Wt().settingsTab),[q,fe]=c.useState(()=>Wt().q),[Le,ve]=c.useState(""),[I,R]=c.useState(!1),[K,ee]=c.useState([]),[se,ie]=c.useState([]),[Y,Ne]=c.useState(()=>sessionStorage.getItem("captureDialogOpen")==="true"),[pe,U]=c.useState([]),te=c.useRef(0),[re,Ue]=c.useState(()=>{try{const C=JSON.parse(sessionStorage.getItem("pendingCaptures")||"[]");if(Array.isArray(C))return C}catch{}return[]});c.useEffect(()=>{sessionStorage.setItem("pendingCaptures",JSON.stringify(re))},[re]);const[Lt,yt]=c.useState(()=>sessionStorage.getItem("ublockWarningIgnored")==="true"),[Ie,st]=c.useState(null),Ze=c.useRef(0),$=c.useRef(null),Z=c.useRef(!1),Ae=c.useRef(!0),Me=c.useRef(null),Rt=c.useRef(0),zt=c.useRef(new Map),It=(n==null?void 0:n.humanize_slugs)??!1;c.useEffect(()=>{const C=++Ze.current;st(null),!(!k||!a)&&Mr(a,k.entry_uid).then(z=>{C===Ze.current&&st(z)}).catch(()=>{})},[k,a]),c.useEffect(()=>{sessionStorage.setItem("captureDialogOpen",Y)},[Y]);const et=c.useCallback(async(C,z,M,W)=>{if(C){R(!0);try{let G;z||M?G=await Gh(C,z,M,W):G=await zu(C,W),x(G),N(ue=>{if(ue.size<2)return ue;const Ee=new Set(G.map(je=>je.entry_uid)),We=new Set([...ue].filter(je=>Ee.has(je)));return We.size===ue.size?ue:We}),ve(G.length===0?"No results":`${G.length} result${G.length===1?"":"s"}`)}catch{x([]),ve("Search failed. Try again.")}finally{R(!1)}}},[]);c.useEffect(()=>{e==="authenticated"&&(a||Ru().then(C=>{if(i(C),C.length>0){const{archive:z}=Wt(),M=z?C.find(W=>W.id===z):null;o((M==null?void 0:M.id)??C[0].id)}}))},[e,a]),c.useEffect(()=>{if(!a)return;const C=!n;if(Ae.current){Ae.current=!1;const M=[];C||(M.push(Ni(a).then(ee)),M.push(Bl(a).then(ie))),M.length&&Promise.all(M);return}b(null),E(null),h(null),N(new Set),d(null);const z=[et(a,"",null,null)];C||(z.push(Ni(a).then(ee)),z.push(Bl(a).then(ie))),Promise.all(z)},[a,n]),c.useEffect(()=>{if(a===null)return;const C=setTimeout(()=>{et(a,q,v,u)},300);return()=>clearTimeout(C)},[q,a,u]),c.useEffect(()=>{a!==null&&(v!==null&&X("archive"),et(a,q,v,u))},[v,a,u]);const Xt=c.useCallback(C=>{o(C),d(null);const z=new URLSearchParams(window.location.search);z.set("archive",C),z.delete("collection");const M=z.toString();history.replaceState(null,"",window.location.pathname+(M?"?"+M:""))},[]);c.useEffect(()=>{if(!a){g([]);return}wo(a).then(g).catch(()=>g([]))},[a]);const $t=c.useCallback(C=>{if(!n){const W=C?y.find(G=>G.collection_uid===C):y.find(G=>G.slug==="_default_");if(W!=null&&W.requires_auth){t("login");return}}d(C),E(null),h(null),N(new Set),et(a,q,v,C);const z=new URLSearchParams(window.location.search);C?(z.set("collection",C),z.set("archive",a)):(z.delete("collection"),a&&z.set("archive",a));const M=z.toString();history.replaceState(null,"",window.location.pathname+(M?"?"+M:""))},[a,q,v,n,y,et]),an=c.useCallback(C=>{X(C),C==="tags"&&a&&Bl(a).then(ie)},[a]);c.useEffect(()=>{if(Ir)return;const C=ry(L,Q);window.location.pathname!==C&&history.pushState(null,"",C+window.location.search)},[L,Q]);const He=c.useCallback(C=>{h(C?C.entry_uid:null),E(C)},[]),j=c.useCallback((C,z)=>{if(zt.current.set(C.entry_uid,C),++Rt.current,z.shiftKey&&Me.current!==null){z.preventDefault();const M=[...document.querySelectorAll("#entries-body [data-entry-uid]")],W=M.findIndex(je=>je.dataset.entryUid===Me.current),G=M.findIndex(je=>je.dataset.entryUid===C.entry_uid);if(W===-1||G===-1){Me.current=C.entry_uid,N(new Set([C.entry_uid])),He(C);return}const ue=Math.min(W,G),Ee=Math.max(W,G),We=new Set(M.slice(ue,Ee+1).map(je=>je.dataset.entryUid));N(We),We.size===1&&He(C)}else if(z.ctrlKey||z.metaKey){Me.current=C.entry_uid;const M=new Set(P);if(M.has(C.entry_uid)?M.delete(C.entry_uid):M.add(C.entry_uid),N(M),M.size===0)He(null);else if(M.size===1){const[W]=M,G=zt.current.get(W)??m.find(ue=>ue.entry_uid===W)??null;if(G)He(G);else{const ue=++Rt.current;Mr(a,W).then(Ee=>{ue===Rt.current&&(Ee!=null&&Ee.summary)&&He(Ee.summary)}).catch(()=>{})}}else He(null)}else Me.current=C.entry_uid,N(new Set([C.entry_uid])),He(C)},[m,P,He,a]),B=c.useCallback(C=>{b(C)},[]),A=c.useCallback(()=>{b(null)},[]),D=c.useCallback(()=>{a&&Bl(a).then(ie)},[a]),F=c.useCallback((C,z)=>{v===C?b(z):v!=null&&v.startsWith(C+"/")&&b(z+v.slice(C.length))},[v]),H=c.useCallback(C=>{(v===C||v!=null&&v.startsWith(C+"/"))&&b(null)},[v]),ke=c.useCallback((C,z)=>{p(W=>new Map(W).set(C,z));const M=zt.current.get(C);M&&zt.current.set(C,{...M,title:z}),x(W=>W.map(G=>G.entry_uid===C?{...G,title:z}:G)),E(W=>W&&W.entry_uid===C?{...W,title:z}:W),st(W=>W&&W.summary.entry_uid===C?{...W,summary:{...W.summary,title:z}}:W)},[]),he=c.useCallback(()=>{if(!a||!k)return;const C=++Ze.current;Mr(a,k.entry_uid).then(z=>{C===Ze.current&&st(z)}).catch(()=>{})},[a,k]),Re=c.useCallback(C=>{const z=m.some(M=>M.entry_uid===C);S(M=>{const W=new Set(M);return W.add(C),W}),x(M=>M.filter(W=>W.entry_uid!==C)),E(M=>(M==null?void 0:M.entry_uid)===C?null:M),h(M=>M===C?null:M),N(M=>{const W=new Set(M);return W.delete(C),W}),z||et(a,q,v,u)},[m,a,q,v,u,et]),ye=c.useCallback(C=>{const z=new Set(m.map(W=>W.entry_uid)),M=[...C].some(W=>!z.has(W));S(W=>{const G=new Set(W);return C.forEach(ue=>G.add(ue)),G}),x(W=>W.filter(G=>!C.has(G.entry_uid))),N(new Set),E(null),h(null),M&&et(a,q,v,u)},[m,a,q,v,u,et]);c.useEffect(()=>{if(P.size>=2)h(null),E(null);else if(P.size===1){const[C]=P;h(C)}else h(null),E(null)},[P]);const Fe=c.useMemo(()=>m.filter(C=>P.has(C.entry_uid)),[m,P]);c.useEffect(()=>{if(!f||k)return;const C=m.find(z=>z.entry_uid===f);C&&E(C)},[m,f,k]),c.useEffect(()=>{if(Ir)return;const C=new URLSearchParams(window.location.search).get("archive"),z=new URLSearchParams;q&&z.set("q",q),L==="archive"&&v&&z.set("tag",v),L==="archive"&&f&&z.set("entry",f),L==="archive"&&u?(z.set("collection",u),a&&z.set("archive",a)):C&&a&&z.set("archive",a);const M=z.toString(),W=window.location.pathname+(M?"?"+M:"");window.location.pathname+window.location.search!==W&&history.replaceState(null,"",W)},[q,v,f,u,a,L]),c.useEffect(()=>{const C=z=>{var ue,Ee,We,je;const M=z.key==="/"&&!z.metaKey&&!z.ctrlKey&&!z.altKey,W=(z.metaKey||z.ctrlKey)&&z.key==="k";if(!M&&!W)return;const G=(ue=document.activeElement)==null?void 0:ue.tagName;M&&(G==="INPUT"||G==="TEXTAREA"||G==="SELECT")||M&&((Ee=document.activeElement)!=null&&Ee.isContentEditable)||(z.preventDefault(),L==="archive"?((We=$.current)==null||We.focus(),(je=$.current)==null||je.select()):(Z.current=!0,X("archive")))};return document.addEventListener("keydown",C),()=>document.removeEventListener("keydown",C)},[L]),c.useEffect(()=>{const C=z=>{var ae,me;if(z.metaKey||z.ctrlKey||z.altKey||z.key!=="j"&&z.key!=="k")return;const M=(ae=document.activeElement)==null?void 0:ae.tagName;if(M==="INPUT"||M==="TEXTAREA"||M==="SELECT"||(me=document.activeElement)!=null&&me.isContentEditable)return;const W=[...document.querySelectorAll("#entries-body [data-entry-uid]")];if(W.length===0)return;const[G]=P.size===1?P:[null],ue=G?W.findIndex($e=>$e.dataset.entryUid===G):-1,Ee=z.key==="j"?Math.min(ue+1,W.length-1):Math.max(ue-1,0);if(Ee===ue&&ue!==-1)return;const We=W[Ee<0?0:Ee],je=We.dataset.entryUid;Me.current=je;const T=++Rt.current;N(new Set([je]));const J=zt.current.get(je)??m.find($e=>$e.entry_uid===je)??null;J?He(J):Mr(a,je).then($e=>{T===Rt.current&&($e!=null&&$e.summary)&&He($e.summary)}).catch(()=>{}),We.scrollIntoView({block:"nearest"}),z.preventDefault()};return document.addEventListener("keydown",C),()=>document.removeEventListener("keydown",C)},[P,m,He,a]),c.useEffect(()=>{L==="archive"&&Z.current&&(Z.current=!1,requestAnimationFrame(()=>{var C,z;(C=$.current)==null||C.focus(),(z=$.current)==null||z.select()}))},[L]);const ce=c.useCallback(()=>{Ne(!0)},[]),it=c.useCallback(()=>{Ne(!1)},[]),dt=c.useCallback(()=>{if(a)return Promise.allSettled([et(a,q,v,u),Ni(a).then(ee)])},[a,q,v,u,et]),Nr=c.useCallback((C,z,M="error",W=null)=>{if(M==="warning"&&Lt&&z)return;const G=++te.current;U(ue=>[...ue,{id:G,text:C,locator:z,type:M,headline:W}])},[Lt]),Ws=c.useCallback(C=>{U(z=>z.filter(M=>M.id!==C))},[]),Vs=c.useCallback(C=>{Nr(C,null,"error","Reorder failed")},[Nr]),kl=c.useCallback(()=>{sessionStorage.setItem("ublockWarningIgnored","true"),yt(!0),U(C=>C.filter(z=>!(z.type==="warning"&&z.locator)))},[]),Ks=c.useCallback(C=>{Ue(z=>[...z,C])},[]),Qs=c.useCallback(C=>{Ue(z=>z.filter(M=>M.id!==C))},[]),[Kn,Qn]=c.useState(null),[Pn,jl]=c.useState(null),qs=c.useCallback(()=>{k&&Qn(k.entry_uid)},[k]),Xs=c.useCallback(()=>Qn(null),[]),Js=c.useCallback((C,z)=>{jl({src:C,entry:z})},[]),Gs=c.useCallback(()=>jl(null),[]);return c.useEffect(()=>{Qn(null)},[k]),c.useEffect(()=>{const C=z=>{var W,G,ue;if(z.key!=="Escape"||Y||Kn)return;const M=(W=document.activeElement)==null?void 0:W.tagName;M==="INPUT"||M==="TEXTAREA"||M==="SELECT"||P.size>0&&(z.preventDefault(),N(new Set),(ue=(G=document.activeElement)==null?void 0:G.blur)==null||ue.call(G))};return window.addEventListener("keydown",C),()=>window.removeEventListener("keydown",C)},[Y,Kn,P]),c.useEffect(()=>(document.body.classList.toggle("has-audio-bar",!!Pn),()=>document.body.classList.remove("has-audio-bar")),[Pn]),Ir?s.jsx(Jg,{archiveId:Ir.archiveId,entryUid:Ir.entryUid}):e==="loading"?s.jsx("div",{className:"auth-loading",children:"Loading\\u2026"}):e==="setup"?s.jsx(Wm,{onComplete:()=>t("login")}):e==="login"?s.jsx(Hm,{onLogin:C=>{r(C),t("authenticated")}}):s.jsx(Hs.Provider,{value:{currentUser:n,setCurrentUser:r},children:s.jsxs(s.Fragment,{children:[s.jsx(Vm,{archives:l,archiveId:a,onArchiveChange:Xt,view:L,onViewChange:an,onCaptureClick:ce,collections:y,selectedCollectionUid:u,onCollectionChange:$t,isPublicSession:!n,onSignInClick:()=>t("login")}),s.jsxs("main",{className:"app-shell",children:[s.jsxs("div",{className:"workspace",children:[L==="archive"&&s.jsxs("div",{className:"toolbar",children:[s.jsxs("div",{className:"search-field",children:[s.jsx("span",{className:"ico","aria-hidden":"true",children:s.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",children:[s.jsx("circle",{cx:"11",cy:"11",r:"7"}),s.jsx("line",{x1:"16.5",y1:"16.5",x2:"21",y2:"21"})]})}),s.jsx("input",{ref:$,className:"search-input",type:"search","aria-label":"Search archive","aria-busy":I,placeholder:"Search titles, URLs, types, tags…",value:q,onChange:C=>fe(C.target.value)}),s.jsx("span",{className:"kbd",children:"⌘K"})]}),s.jsxs("span",{className:"result-count",children:[Le&&s.jsxs(s.Fragment,{children:[s.jsx("b",{children:Le.split(" ")[0]})," ",Le.split(" ").slice(1).join(" ")]}),v&&s.jsxs("button",{className:"tag-filter-badge",onClick:A,children:["× ",It?pf(v):v]})]})]}),L==="archive"&&s.jsx(ig,{entries:m,selectedUids:P,onRowClick:j,archiveId:a,pendingCaptures:re,deletedUids:w,renamedTitles:_,isPublicSession:!n,canReorder:!!(n!=null&&n.can_reorder_children),onChildReorderError:Vs}),L==="runs"&&s.jsx(ug,{runs:K}),L==="admin"&&s.jsx(dg,{archives:l}),L==="tags"&&s.jsx(fg,{archiveId:a,tagNodes:se,tagFilter:v,onTagFilterSet:B,onViewChange:an,onTagRenamed:F,onTagDeleted:H,onTagsRefresh:D,humanizeTags:It}),L==="collections"&&s.jsx(hg,{archiveId:a}),L==="settings"&&s.jsx(yg,{tab:Q,onTabChange:ne,archiveId:a})]}),s.jsx(bg,{archiveId:a,selectedEntry:k,selectedUids:P,selectedEntries:Fe,detail:Ie,onTagFilterSet:B,tagNodes:se,onTagsRefresh:D,onEntryTitleChange:ke,onEntryDeleted:Re,onBulkDeleted:ye,humanizeTags:It,onDetailRefresh:he,onOpenPreview:qs,onPlay:Js,isPublicSession:!n})]}),Kn&&k&&k.entry_uid===Kn&&s.jsx(qg,{archiveId:a,entry:k,detail:Ie,onClose:Xs}),Pn&&s.jsx(Xg,{entry:Pn.entry,src:Pn.src,archiveId:a,onClose:Gs}),s.jsx(Xm,{open:Y,archiveId:a,onClose:it,onCaptured:dt,onToast:Nr,activeJobs:re,onJobStarted:Ks,onJobSettled:Qs}),s.jsx(Yg,{toasts:pe,onDismiss:Ws,onIgnoreUblock:kl})]})})}of(document.getElementById("root")).render(s.jsx(c.StrictMode,{children:s.jsx(ly,{})})); diff --git a/crates/archivr-server/static/assets/index-CQKWq7vB.css b/crates/archivr-server/static/assets/index-CQKWq7vB.css new file mode 100644 index 0000000..448f473 --- /dev/null +++ b/crates/archivr-server/static/assets/index-CQKWq7vB.css @@ -0,0 +1 @@ +@import"https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@500;600&family=Spectral:wght@500;600&display=swap";:root{color-scheme:light;--ink: #20251f;--muted: #6b6f66;--muted-2: #8a8d83;--paper: #f5f0e7;--paper-2: #e9e1d2;--paper-3: #fffaf0;--line: #d2c6b5;--line-soft: #e5dccd;--accent: #8d3f30;--accent-2: #b78342;--link: #245f72;--top: #141d18;--field: #fffdf7;--r: 3px;--r2: 6px;--r3: 10px;--sans: "Helvetica Neue", Helvetica, Arial, sans-serif;--serif: "Spectral", Georgia, "Times New Roman", serif;--display: "Cormorant Garamond", Georgia, serif}*{box-sizing:border-box}body{margin:0;min-height:100vh;background:var(--paper);color:var(--ink);font-family:var(--sans);font-size:14px;-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}button,input,select{font:inherit}.workspace,.context-rail{scrollbar-width:thin;scrollbar-color:var(--line) transparent}.workspace::-webkit-scrollbar,.context-rail::-webkit-scrollbar{width:11px;height:11px}.workspace::-webkit-scrollbar-track,.context-rail::-webkit-scrollbar-track{background:transparent}.workspace::-webkit-scrollbar-thumb,.context-rail::-webkit-scrollbar-thumb{background:var(--line);border-radius:999px;border:3px solid transparent;background-clip:padding-box}.workspace::-webkit-scrollbar-thumb:hover,.context-rail::-webkit-scrollbar-thumb:hover{background:var(--muted-2);background-clip:padding-box;border:3px solid transparent}.topbar{height:58px;display:grid;grid-template-columns:auto auto 1fr auto auto;align-items:center;gap:26px;padding:0 22px;background:var(--top);color:#efe6d6;border-bottom:3px solid var(--accent)}.brand{font-family:var(--display);font-weight:600;font-size:30px;line-height:1;letter-spacing:.01em;color:#f4ead8}.switcher{position:relative;display:inline-flex;align-items:center}.switcher select{-moz-appearance:none;appearance:none;-webkit-appearance:none;border:1px solid rgba(247,238,223,.2);background:#f7eedf0f;color:#f1e8d8;font-family:var(--sans);font-weight:550;font-size:13.5px;letter-spacing:.04em;padding:8px 32px 8px 15px;cursor:pointer;border-radius:6px;transition:background .15s ease,border-color .15s ease}.switcher select:hover{background:#f7eedf1c;border-color:#f7eedf57}.switcher:after{content:"";position:absolute;right:15px;top:50%;width:6px;height:6px;margin-top:-5px;border-right:1.2px solid #b6ab98;border-bottom:1.2px solid #b6ab98;transform:rotate(45deg);pointer-events:none}.switchers{display:flex;gap:8px;align-items:center;min-width:0}.nav{display:flex;gap:22px;justify-content:flex-end;min-width:0}.nav-link{border:0;background:transparent;color:#cdc1ad;cursor:pointer;padding:6px 0;font-size:12.5px;font-weight:600;letter-spacing:.1em;text-transform:uppercase;position:relative}.nav-link:hover,.nav-link.is-active{color:#f4ead8}.nav-link.is-active:after{content:"";position:absolute;left:0;right:0;bottom:-2px;height:1px;background:var(--accent-2)}.capture-button{display:inline-flex;align-items:center;gap:8px;position:relative;overflow:hidden;border:0;background:linear-gradient(180deg,color-mix(in srgb,var(--accent) 88%,#fff) 0%,var(--accent) 55%);color:#f7eddd;padding:10px 18px;cursor:pointer;font-size:12px;font-weight:600;letter-spacing:.15em;text-transform:uppercase;border-radius:var(--r);box-shadow:inset 0 1px #fff5e629,0 1px 2px #141d184d;transition:filter .18s ease,box-shadow .18s ease}.capture-button:hover{filter:brightness(.93);box-shadow:inset 0 1px #fff5e61f,0 2px 6px #141d1857}.capture-button:before{content:"";position:absolute;top:0;bottom:0;left:0;width:45%;background:linear-gradient(100deg,transparent 0%,rgba(255,246,232,.45) 50%,transparent 100%);transform:translate(-180%) skew(-18deg);pointer-events:none}@media (prefers-reduced-motion: no-preference){.capture-button:hover:before{animation:cap-sheen .7s ease}}@keyframes cap-sheen{0%{transform:translate(-180%) skew(-18deg)}to{transform:translate(340%) skew(-18deg)}}.app-shell{height:calc(100vh - 58px);display:grid;grid-template-columns:minmax(0,1fr) 340px}.workspace{min-width:0;overflow:auto;display:flex;flex-direction:column}.view{display:none}.view.is-active{display:block}.toolbar{position:sticky;top:0;z-index:3;display:flex;align-items:center;gap:16px;padding:12px 22px;background:color-mix(in srgb,var(--paper) 88%,white);border-bottom:1px solid var(--line-soft)}.search-field{position:relative;flex:1 1 auto;min-width:0;display:flex;align-items:center;height:42px;background:var(--field);border:1px solid var(--line);border-radius:20px;transition:border-color .15s ease,box-shadow .15s ease}.search-field:focus-within{border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 14%,transparent)}.search-field .ico{display:grid;place-items:center;width:46px;height:100%;color:var(--muted-2);flex-shrink:0}.search-field .ico svg{width:16px;height:16px}.search-input{flex:1;min-width:0;height:100%;border:0;background:transparent;color:var(--ink);padding:0 6px 0 0;font-size:14px;letter-spacing:.01em;outline:none}.search-input::placeholder{color:var(--muted-2)}.search-field .kbd{margin-right:12px;display:inline-flex;align-items:center;padding:3px 7px;font-size:11px;color:var(--muted);background:var(--paper-2);border:1px solid var(--line-soft);border-radius:var(--r);flex-shrink:0}.result-count{flex-shrink:0;font-size:13px;color:var(--muted);letter-spacing:.02em;white-space:nowrap}.result-count b{color:var(--ink);font-weight:600;font-variant-numeric:tabular-nums}.tag-filter-badge{display:inline-flex;align-items:center;gap:4px;background:var(--accent);color:#fff;border:0;border-radius:var(--r);font-size:12px;padding:2px 8px;cursor:pointer;margin-left:8px}.tag-filter-badge:hover{opacity:.85}.entry-table{width:100%;font-size:12.5px}.entry-header-row{display:flex;align-items:stretch;position:sticky;top:67px;z-index:2;background:color-mix(in srgb,var(--paper) 92%,white);border-bottom:1px solid var(--line-soft);color:var(--muted);font-size:10.5px;text-transform:uppercase;letter-spacing:.08em}.entry-header-row>div{padding:9px 10px;flex-shrink:0;font-weight:600;display:flex;align-items:center}#entries-body>div{display:flex;align-items:center;cursor:default;border-bottom:1px solid var(--line-soft)}#entries-body>div>div{padding:7px 10px;flex-shrink:0;overflow:hidden}#entries-body>div:not(.entry-row-outer):nth-child(2n){background:#f2ede5}#entries-body>div:not(.entry-row-outer):nth-child(odd){background:var(--paper-3)}#entries-body>.entry-row-outer--light{background:var(--paper-3)}#entries-body>.entry-row-outer--dark{background:#f2ede5}#entries-body>div.is-selected{background:#eee2d2;box-shadow:inset 0 0 0 2px var(--accent)}#entries-body>div.is-multi-selected{background:#eee2d2;box-shadow:inset 0 0 0 2px var(--accent)}.col-added{width:162px;color:var(--muted)}.col-title{flex:1 1 0;min-width:0;overflow:hidden;display:flex;align-items:center;gap:.42em}.col-type{width:116px}.col-size{width:100px;display:flex;flex-direction:column;justify-content:center;gap:1px}.size-total{font-variant-numeric:tabular-nums}.size-cached-pct{font-size:10px;color:var(--muted);opacity:.8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.col-url{flex:0 0 30%;min-width:0;overflow:hidden}.entry-header-row .col-added,#entries-body>div .col-added{padding-left:22px}.entry-header-row>div:last-child,#entries-body>div>div:last-child{padding-right:22px}.entry-title{color:var(--link);font-weight:700;min-width:0}.entry-header-row>div{padding:9px 10px;flex-shrink:0;font-weight:600}.source-icon{display:flex;align-items:center;justify-content:center;width:1.05em;height:1.05em;flex-shrink:0}.source-icon>*{width:100%;height:100%;display:flex;align-items:center;justify-content:center}.source-icon svg{width:100%;height:100%}.url-cell{color:#555b55;white-space:nowrap;text-overflow:ellipsis;word-break:break-all}#entries-body .url-cell:hover{overflow:visible;white-space:normal}.type-pill{display:inline-block;padding:2px 6px;background:#d8e3df;color:#275a5f;border:1px solid #bfd0ca;border-radius:var(--r)}.col-check{display:none;width:40px;flex-shrink:0;align-items:center;justify-content:center;padding:0!important;cursor:pointer}.row-checkbox{-webkit-appearance:none;-moz-appearance:none;appearance:none;padding:0;border:1.5px solid var(--line);cursor:pointer;display:block;width:17px;height:17px;border-radius:4px;background:var(--paper-3);transition:border-color .15s ease,background .15s ease;position:relative;flex-shrink:0}.row-checkbox.is-checked{background:var(--accent);border-color:var(--accent)}.row-checkbox.is-checked:after{content:"";position:absolute;left:4px;top:2px;width:5px;height:8px;border:2px solid #fff;border-top:none;border-left:none;transform:rotate(45deg)}@media (pointer: coarse){.col-check{display:flex}.entry-header-row .col-added,#entries-body>div .col-added{padding-left:10px}}#runs-view .entry-table{border-collapse:collapse;table-layout:auto}#runs-view .entry-table th{position:sticky;top:0;z-index:1;text-align:left;padding:10px;background:color-mix(in srgb,var(--paper) 92%,white);color:var(--muted);border-bottom:1px solid var(--line-soft);font-size:10.5px;text-transform:uppercase;letter-spacing:.08em}#runs-view .entry-table td{padding:10px;border-bottom:1px solid var(--line-soft);vertical-align:top}#runs-view .entry-table tr:nth-child(2n) td{background:#f2ede5}#runs-view .entry-table tr:nth-child(odd) td{background:var(--paper-3)}.context-rail{border-left:1px solid var(--line);background:var(--paper);padding:20px 20px 32px;overflow:auto}.rail-eyebrow{font-size:10.5px;font-weight:600;letter-spacing:.16em;text-transform:uppercase;color:var(--muted-2);margin-bottom:16px}.rail-title{display:block;font-family:var(--sans);font-weight:700;font-size:15.5px;line-height:1.4;color:var(--ink);margin:0 0 16px;word-break:break-word}.url-tile{display:flex;align-items:center;gap:9px;padding:10px 12px;background:var(--paper-3);border:1px solid var(--line);border-radius:var(--r3);margin-bottom:20px;text-decoration:none}.url-tile:hover{background:var(--field);border-color:var(--accent)}.url-tile .ico{color:var(--ink);flex-shrink:0;display:grid;place-items:center}.url-tile .ico svg{width:15px;height:15px}.url-tile .u-text{min-width:0;flex:1;font-size:12.5px;color:var(--link);font-family:ui-monospace,SF Mono,Menlo,monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.url-tile .ext{color:var(--muted-2);flex-shrink:0}.url-tile .ext svg{width:13px;height:13px;display:block}.meta-list{margin-bottom:24px;border-top:1px solid var(--line-soft)}.meta-item{display:grid;grid-template-columns:92px 1fr;gap:14px;align-items:baseline;padding:9px 0;border-bottom:1px solid var(--line-soft)}.meta-k{font-size:10.5px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2)}.meta-v{font-size:13.5px;color:var(--ink);word-break:break-word;line-height:1.4}.meta-v.mono{font-family:ui-monospace,SF Mono,Menlo,monospace;font-size:12px;color:var(--muted)}.rail-section{margin-bottom:24px}.rail-section-heading{display:flex;align-items:baseline;gap:8px;font-size:10.5px;font-weight:600;text-transform:uppercase;letter-spacing:.12em;color:var(--muted-2);margin-bottom:9px;padding-bottom:8px;border-bottom:1px solid var(--line)}.rail-section-heading .num{color:var(--muted-2);font-weight:600}.artifact-list{list-style:none;margin:0;padding:0}.artifact-link{display:flex;align-items:baseline;justify-content:space-between;gap:12px;padding:7px 2px;border-bottom:1px solid var(--line-soft);text-decoration:none}.artifact-link:last-child{border-bottom:0}.artifact-name{font-size:13px;color:var(--link);min-width:0;word-break:break-word}.artifact-link:hover .artifact-name{text-decoration:underline}.artifact-size{font-size:11.5px;color:var(--muted-2);flex-shrink:0;font-variant-numeric:tabular-nums}.artifact-group{list-style:none}.artifact-group-header{width:100%;background:none;border:none;border-bottom:1px solid var(--line-soft);cursor:pointer;text-align:left;color:inherit;font:inherit}.artifact-group-header:hover .artifact-name{text-decoration:underline}.artifact-group-chevron{display:inline-block;font-style:normal;transition:transform .15s ease;transform:rotate(0);transform-origin:45% 50%;margin-right:3px}.artifact-group-chevron.open{transform:rotate(90deg)}.artifact-group-body{padding-left:14px}.tags-wrap{display:flex;flex-wrap:wrap;gap:7px;margin:0 0 12px}.tag-pill{display:inline-flex;align-items:center;gap:7px;background:var(--paper-2);border:1px solid var(--line);border-radius:var(--r);font-size:12px;padding:4px 7px 4px 10px;color:var(--ink)}.tag-pill .remove{border:0;background:transparent;cursor:pointer;color:var(--muted-2);width:15px;height:15px;display:grid;place-items:center;font-size:13px;line-height:1}.tag-pill .remove:hover{color:var(--accent)}.tags-empty{font-size:13px;color:var(--muted);margin:0 0 11px}.tag-input-wrap{display:flex;align-items:center;gap:8px;background:var(--field);border:1px solid var(--line);border-radius:var(--r2);padding:2px 2px 2px 11px;transition:border-color .15s ease,box-shadow .15s ease}.tag-input-wrap:focus-within{border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 12%,transparent)}.tag-input-wrap .hash{color:var(--muted-2);font-size:13px}.tag-input{flex:1;min-width:0;border:0;background:transparent;color:var(--ink);font-size:13px;padding:6px 0;outline:none;font-family:ui-monospace,SF Mono,Menlo,monospace}.tag-add-btn{border:0;background:var(--ink);color:var(--paper-3);padding:6px 13px;font-size:11.5px;letter-spacing:.06em;text-transform:uppercase;border-radius:var(--r);cursor:pointer;white-space:nowrap}.tag-add-btn:hover{background:#2c332b}.coll-row{display:flex;align-items:center;justify-content:space-between;gap:12px;overflow:hidden;padding:9px 13px;background:var(--field);border:1px solid var(--line);border-radius:var(--r2);margin-bottom:6px;transition:border-color .15s ease}.coll-row:hover{border-color:var(--accent)}.coll-name{font-size:13px;color:var(--ink);font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}.vis-badge{display:inline-flex;align-items:center;gap:6px;font-size:11px;color:var(--muted);background:var(--paper-2);border:1px solid var(--line-soft);border-radius:999px;padding:3px 10px}.vis-badge:before{content:"";width:6px;height:6px;border-radius:50%;background:var(--accent-2)}.bulk-panel{padding-top:2px}.bulk-count{font-size:13px;color:var(--muted);margin-bottom:22px}.bulk-count-num{font-size:22px;font-weight:700;color:var(--ink);line-height:1;margin-right:4px}.bulk-coll-row{display:flex;align-items:center;gap:8px}.bulk-coll-select{flex:1;min-width:0;height:32px;padding:0 8px;border:1px solid var(--line);border-radius:var(--r);background:var(--field);color:var(--ink);font-size:13px;cursor:pointer;outline:none;transition:border-color .15s ease}.bulk-coll-select:focus{border-color:var(--accent)}.tag-add-btn:disabled{opacity:.45;cursor:default}.rail-delete-zone{margin-top:24px;padding-top:20px;border-top:1px solid var(--line-soft)}.rail-delete-btn{width:100%;padding:8px 14px;background:none;border:1px solid color-mix(in srgb,var(--accent) 45%,transparent);color:var(--accent);border-radius:var(--r);cursor:pointer;font-size:12.5px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;transition:background .15s ease,border-color .15s ease}.rail-delete-btn:hover{background:color-mix(in srgb,var(--accent) 8%,transparent);border-color:var(--accent)}.rail-rearchive-btn{width:100%;padding:6px 12px;background:var(--surface-2, #2a2a2a);color:var(--text, #e0e0e0);border:1px solid var(--border, #444);border-radius:4px;cursor:pointer;font-size:13px}.rail-rearchive-btn:hover:not(:disabled){background:var(--surface-3, #333)}.rail-rearchive-btn:disabled{opacity:.6;cursor:not-allowed}.capture-dialog{border:1px solid var(--line);background:var(--paper);padding:0;width:min(540px,calc(100vw - 32px));border-radius:var(--r3);box-shadow:0 20px 60px #141d1847}.capture-dialog::backdrop{background:#141d1873}.capture-dialog-inner{padding:28px}.capture-dialog-title{margin:0;font-size:22px;font-family:var(--display);font-weight:600}.capture-label{display:block;font-size:11px;font-weight:600;margin-bottom:6px;color:var(--muted-2);text-transform:uppercase;letter-spacing:.06em}.capture-input{width:100%;height:44px;border:1px solid var(--line);background:var(--field);color:var(--ink);padding:0 14px;font-size:15px;margin-bottom:10px;border-radius:var(--r2);outline:none;transition:border-color .15s ease,box-shadow .15s ease}.capture-input:focus{border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 14%,transparent)}.form-field .capture-input{margin-bottom:0}.capture-error{color:var(--accent);font-size:13px;margin-bottom:10px;min-height:18px}.capture-actions{display:flex;flex-direction:column;gap:8px;margin-top:18px}.capture-submit{border:0;background:var(--ink);color:var(--paper);padding:13px 20px;border-radius:var(--r2);cursor:pointer;font-weight:600;font-size:15px;width:100%;min-width:220px;letter-spacing:.01em}.capture-submit:hover{opacity:.85}.capture-submit:disabled{opacity:.45;cursor:default}.capture-cancel{border:0;background:none;color:var(--muted);padding:7px 18px;border-radius:var(--r);cursor:pointer;font-size:13px;text-align:center;width:100%}.capture-cancel:hover{color:var(--ink);background:var(--paper-2)}.capture-advanced{margin-top:12px}.capture-advanced-toggle{display:flex;align-items:center;gap:5px;background:none;border:0;cursor:pointer;color:var(--muted);font-size:12.5px;padding:4px 0;border-radius:var(--r)}.capture-advanced-toggle:hover{color:var(--ink)}.capture-chevron{width:14px;height:14px;flex-shrink:0;transition:transform .18s ease}.capture-chevron--open{transform:rotate(180deg)}.capture-advanced-panel{margin-top:8px;padding:10px 12px;border:1px solid var(--line);border-radius:var(--r2);background:var(--paper-2)}.capture-ext-row{display:flex;align-items:center;justify-content:space-between;gap:12px;cursor:default}.capture-ext-label{flex:1;min-width:0}.capture-ext-name{display:block;font-size:13px;font-weight:600;color:var(--ink)}.capture-ext-desc{display:block;font-size:11.5px;color:var(--muted);margin-top:1px}.capture-dialog-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:20px}.capture-dialog-close{flex-shrink:0;background:none;border:0;cursor:pointer;color:var(--muted);width:28px;height:28px;display:grid;place-items:center;border-radius:var(--r);padding:0}.capture-dialog-close svg{width:14px;height:14px}.capture-dialog-close:hover{background:var(--paper-2);color:var(--ink)}.capture-rows{display:flex;flex-direction:column;gap:8px;margin-bottom:8px}.capture-row{display:flex;flex-direction:column;gap:3px}.capture-row-main{display:flex;align-items:center;gap:8px}.capture-row-main .capture-input{margin-bottom:0}.capture-quality{flex-shrink:0;height:32px;padding:0 6px;border:1px solid var(--line);border-radius:var(--r);background:var(--paper);color:var(--ink);font-size:12px;cursor:pointer;outline:none;transition:border-color .15s}.capture-quality:hover{border-color:var(--accent-2)}.capture-quality:focus{border-color:var(--accent)}.capture-quality:disabled{opacity:.5;cursor:default}.capture-quality-probing{flex-shrink:0;font-size:12px;color:var(--muted);padding:0 4px;letter-spacing:.05em}.capture-quality-hint{flex-shrink:0;font-size:12px;color:var(--muted);font-style:italic}.capture-quality-hint--error{color:#c05000;font-style:normal}.cap-dot{flex-shrink:0;width:20px;height:20px;border-radius:50%;display:grid;place-items:center;font-size:10px;font-weight:700;line-height:1}.cap-dot--idle{background:var(--paper-2)}.cap-dot--running{background:#fdefd8;color:#8a4f10}.cap-dot--ok{background:#d8eddf;color:#235c35}.cap-dot--err{background:#f5ddd8;color:#8d3f30}.cap-spinner{display:block;width:10px;height:10px;border:2px solid currentColor;border-top-color:transparent;border-radius:50%;animation:cap-spin .7s linear infinite}@keyframes cap-spin{to{transform:rotate(360deg)}}.capture-row-action{flex-shrink:0;background:none;border:0;cursor:pointer;color:var(--muted);width:28px;height:28px;display:grid;place-items:center;border-radius:var(--r);padding:0}.capture-row-action svg{width:12px;height:12px}.capture-row-action:hover{color:var(--accent);background:var(--paper-2)}.capture-row-remove:hover{color:var(--accent)}.capture-row-error{margin:0;padding-left:28px;font-size:12px;color:var(--accent);line-height:1.45}.capture-text-row>.capture-row-main{align-items:flex-start}.capture-text-icon{flex-shrink:0;width:20px;height:44px;display:grid;place-items:center;color:var(--muted)}.capture-text-inputs{flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:8px}.capture-text-input{width:100%;border:1px solid var(--line);background:var(--field);color:var(--ink);border-radius:var(--r2);outline:none;transition:border-color .15s ease,box-shadow .15s ease;font-family:inherit}.capture-text-input:focus{border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 14%,transparent)}.capture-text-title{height:44px;padding:0 14px;font-size:15px}.capture-text-body{min-height:140px;padding:10px 14px;font-size:14px;line-height:1.55;resize:vertical}.capture-text-body::placeholder,.capture-text-title::placeholder{color:var(--muted)}.capture-text-footer{display:flex;align-items:center;gap:8px;justify-content:flex-end}.capture-text-mime{flex-shrink:0;height:28px;padding:0 26px 0 10px;border:1px solid var(--line);border-radius:var(--r);background:var(--paper);color:var(--ink);font-size:12px;cursor:pointer;outline:none;transition:border-color .15s;-moz-appearance:none;appearance:none;-webkit-appearance:none;background-image:url("data:image/svg+xml;utf8,");background-repeat:no-repeat;background-position:right 8px center}.capture-text-mime:hover{border-color:var(--accent-2)}.capture-text-mime:focus{border-color:var(--accent)}.capture-text-row .capture-row-action{margin-top:8px}.capture-add-row{display:flex;align-items:center;gap:7px;width:100%;background:none;border:1px dashed var(--line);border-radius:var(--r);color:var(--muted);font-size:13px;cursor:pointer;padding:7px 12px;margin-top:4px;margin-bottom:18px;transition:border-color .15s,color .15s,background .15s}.capture-add-row svg{width:13px;height:13px;flex-shrink:0}.capture-add-row:hover{border-color:var(--accent-2);color:var(--ink);background:var(--paper-2)}.capture-add-row-group{display:flex;gap:8px;margin-top:4px;margin-bottom:18px}.capture-add-row-group .capture-add-row{margin-top:0;margin-bottom:0;flex:1}.capture-dialog--dragover .capture-dialog-inner{outline:2px dashed var(--accent);outline-offset:-6px;border-radius:var(--r3)}.capture-file-icon{flex-shrink:0;color:var(--muted);display:flex;align-items:center}.capture-file-name{flex:1;min-width:0;font-size:13.5px;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.capture-file-name--error{color:var(--accent)}.capture-file-progress-wrap{flex-shrink:0;display:flex;align-items:center;gap:6px}.capture-file-progress-bar{width:80px;height:4px;border-radius:2px;background:var(--paper-2);overflow:hidden;border:1px solid var(--line)}.capture-file-progress-fill{display:block;height:100%;background:var(--ink);border-radius:2px;transition:width .1s ease}.capture-file-progress-pct{font-size:11px;color:var(--muted);min-width:28px;text-align:right}.capture-file-badge{flex-shrink:0;width:18px;height:18px;border-radius:50%;display:grid;place-items:center;font-size:11px;font-weight:700;line-height:1}.capture-file-badge--done{background:#d8eddf;color:#235c35}.capture-file-badge--error{background:#f5ddd8;color:#8d3f30}.toast-stack{position:fixed;bottom:24px;right:24px;z-index:9000;display:flex;flex-direction:column;gap:10px;width:340px;max-width:calc(100vw - 32px);pointer-events:none}.toast{pointer-events:auto;background:var(--paper);border:1px solid var(--line);border-radius:var(--r2);box-shadow:0 6px 24px #141d182e,0 1px 4px #141d181a;overflow:hidden;animation:toast-slide-in .22s cubic-bezier(.22,.68,0,1.2)}@keyframes toast-slide-in{0%{opacity:0;transform:translateY(10px) scale(.97)}to{opacity:1;transform:translateY(0) scale(1)}}.toast--error{border-left:3px solid var(--accent)}.toast--warning{border-left:3px solid #e8a000}.toast--success{border-left:3px solid #22a855}.toast-top{display:flex;align-items:flex-start;gap:10px;padding:12px 12px 12px 14px}.toast-icon{flex-shrink:0;font-size:13px;font-weight:700;color:var(--accent);margin-top:2px}.toast--success .toast-icon{color:#22a855}.toast--warning .toast-icon{color:#e8a000}.toast-body{flex:1;min-width:0}.toast-headline{display:block;font-size:13.5px;font-weight:600;color:var(--ink);line-height:1.3}.toast-locator{display:block;font-size:12px;color:var(--muted);margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.toast-btns{display:flex;align-items:center;gap:6px;flex-shrink:0}.toast-view-btn{background:none;border:1px solid var(--line);color:var(--link);font-size:12px;cursor:pointer;padding:3px 9px;border-radius:var(--r);white-space:nowrap}.toast-view-btn:hover{background:var(--paper-2);border-color:var(--link)}.toast-dismiss{background:none;border:0;cursor:pointer;color:var(--muted);font-size:20px;line-height:1;width:24px;height:24px;display:grid;place-items:center;border-radius:var(--r);padding:0}.toast-dismiss:hover{color:var(--ink);background:var(--paper-2)}.toast-error-detail{margin:0;padding:10px 14px 12px;font-family:ui-monospace,SF Mono,Menlo,monospace;font-size:11.5px;line-height:1.55;color:var(--muted);background:var(--paper-2);border-top:1px solid var(--line);white-space:pre-wrap;word-break:break-word;max-height:200px;overflow-y:auto}.toast-warning-detail{margin:0;padding:10px 14px 12px;font-size:12px;line-height:1.55;color:var(--muted);background:var(--paper-2);border-top:1px solid var(--line);white-space:pre-wrap;word-break:break-word}.toast-ignore-btn{color:var(--muted)}.ext-toggle{flex-shrink:0;position:relative;width:44px;height:24px;border-radius:12px;background:var(--line);border:0;cursor:pointer;transition:background .18s ease;padding:0}.ext-toggle--sm{width:36px;height:20px;border-radius:10px}.ext-toggle--on{background:var(--ink)}.ext-toggle:disabled{opacity:.4;cursor:default}.ext-toggle-knob{position:absolute;top:3px;left:3px;width:18px;height:18px;border-radius:50%;background:var(--paper);transition:transform .18s ease;display:block;box-shadow:0 1px 3px #0003}.ext-toggle--sm .ext-toggle-knob{width:14px;height:14px;top:3px;left:3px}.ext-toggle--on .ext-toggle-knob{transform:translate(20px)}.ext-toggle--sm.ext-toggle--on .ext-toggle-knob{transform:translate(16px)}.ext-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:12px}.ext-card{border:1px solid var(--line);border-radius:var(--r2);padding:16px 18px;background:var(--paper)}.ext-card-header{display:flex;align-items:flex-start;justify-content:space-between;gap:16px}.ext-card-info{flex:1;min-width:0}.ext-card-name{display:block;font-weight:600;font-size:14px;margin-bottom:4px}.ext-card-desc{display:block;font-size:13px;color:var(--muted);line-height:1.5}.ext-card-hint{display:block;font-size:12px;color:#e8a000;margin-top:6px}.ext-card-hint code{font-size:11.5px}.form-hint{font-size:13px;color:var(--muted);line-height:1.55;margin:0}.muted{color:var(--muted)}.admin-view{padding:22px}.admin-list{display:grid;gap:10px;max-width:780px}.admin-archive{border:1px solid var(--line);background:var(--paper-3);padding:12px;border-radius:var(--r2)}.tag-tree{padding:20px 22px;max-width:320px}.tag-tree-header{display:flex;align-items:baseline;gap:10px;margin-bottom:14px;border-bottom:1px solid var(--line-soft);padding-bottom:10px}.tag-tree-title{font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.07em}.tag-tree-active{font-size:12px;color:var(--muted-2);font-style:italic}.tag-tree-list{list-style:none;margin:0;padding:0}.tag-children{padding-left:16px}.tag-node-btn{border:0;background:transparent;color:var(--link);cursor:pointer;padding:3px 0;text-align:left;font-size:13px;display:block;width:100%}.tag-node-btn:hover{text-decoration:underline}.tag-node-btn.is-active{font-weight:600;color:var(--ink)}.tag-node-row{display:flex;align-items:center;gap:4px}.tag-node-row .tag-node-btn{flex:1 1 0;min-width:0;display:flex;align-items:center;gap:4px;width:auto}.tag-node-btn .edit-icon{width:.75em;height:.75em;flex-shrink:0;vertical-align:-.1em;opacity:.35}.tag-node-btn:hover .edit-icon{opacity:.65}.tag-node-delete{flex-shrink:0;border:0;background:transparent;color:var(--muted-2);cursor:pointer;padding:0 3px;font-size:14px;line-height:1;opacity:0}.tag-node-row:hover .tag-node-delete{opacity:1}.tag-node-delete:hover{color:var(--accent)}.tag-rename-input{font-size:13px;flex:1 1 0;min-width:0;padding:2px 5px;border:1px solid var(--accent);border-radius:var(--r);background:var(--field);color:var(--ink);outline:none}.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}.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 .1s,color .1s}.tag-tree-action-btn:hover:not(:disabled){background:var(--paper-2);color:var(--ink)}.tag-tree-action-btn:disabled{opacity:.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)}.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-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:200;background:#00000073;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 #0000003d;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-view{padding:24px}.collections-heading{margin:0 0 16px;font-size:22px;font-family:var(--display);font-weight:600}.collections-error{color:var(--accent);font-size:13px;margin-bottom:8px}.coll-dismiss{background:none;border:none;cursor:pointer;color:var(--accent);font-size:16px;line-height:1;padding:0 4px}.collections-layout{display:grid;grid-template-columns:220px 1fr;gap:0;border:1px solid var(--line);border-radius:var(--r2);min-height:340px}.collections-sidebar{border-right:1px solid var(--line);overflow-y:auto}.coll-sidebar-row{display:flex;flex-direction:column;width:100%;padding:10px 14px;border:none;border-bottom:1px solid var(--line-soft);background:none;cursor:pointer;text-align:left;gap:2px}.coll-sidebar-row:hover{background:var(--paper-2)}.coll-sidebar-row.is-active{background:var(--paper-2);border-left:3px solid var(--accent)}.coll-row-name{font-size:14px;font-weight:600;color:var(--ink)}.coll-row-meta{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.04em}.coll-detail{padding:20px 24px;display:flex;flex-direction:column;gap:16px}.coll-detail--empty{justify-content:center;align-items:center}.coll-detail-header{display:flex;align-items:center;gap:12px}.coll-detail-name{margin:0;font-size:18px;font-family:var(--display);font-weight:600;flex:1}.coll-detail-name--editable{cursor:pointer}.coll-detail-name--editable:hover{color:var(--link)}.coll-edit-hint{font-size:14px;opacity:.5;margin-left:4px}.coll-rename-input{flex:1;font-size:16px;font-family:var(--display);border:1px solid var(--line);padding:4px 8px;background:var(--field);color:var(--ink);border-radius:var(--r)}.coll-delete-btn{border:1px solid var(--accent);background:none;color:var(--accent);padding:5px 12px;font-size:13px;cursor:pointer;border-radius:var(--r)}.coll-delete-btn:hover{background:var(--accent);color:var(--paper)}.coll-detail-vis{display:flex;align-items:center;gap:10px}.coll-vis-label{font-size:13px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;white-space:nowrap}.coll-vis-select{border:1px solid var(--line);background:var(--paper-3);color:var(--ink);padding:4px 8px;font-size:13px;border-radius:var(--r)}.coll-section-heading{font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin-bottom:8px}.coll-entries-section{flex:1}.coll-entries-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:2px}.coll-entry-row{display:flex;align-items:center;gap:10px;padding:7px 0;border-bottom:1px solid var(--line-soft)}.coll-entry-info{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}.coll-entry-title{font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.coll-entry-kind{font-size:11px;text-transform:uppercase;letter-spacing:.04em}.coll-entry-actions{display:flex;align-items:center;gap:6px;flex-shrink:0}.coll-entry-vis-select{border:1px solid var(--line);background:var(--paper-3);color:var(--ink);padding:3px 6px;font-size:12px;border-radius:var(--r)}.coll-entry-remove{border:none;background:none;cursor:pointer;color:var(--muted);font-size:18px;line-height:1;padding:0 4px}.coll-entry-remove:hover{color:var(--accent)}.coll-add-entry-form{border-top:1px solid var(--line-soft);padding-top:12px}.coll-add-entry-row{display:flex;gap:8px;align-items:center}.coll-add-entry-input{flex:1;border:1px solid var(--line);background:var(--paper-3);color:var(--ink);padding:6px 10px;font-size:13px;min-width:0;border-radius:var(--r)}.coll-add-btn{border:none;background:var(--ink);color:var(--paper);padding:6px 14px;font-size:13px;font-weight:600;cursor:pointer;white-space:nowrap;border-radius:var(--r)}.coll-add-btn:hover{opacity:.85}.coll-add-btn:disabled{opacity:.45;cursor:default}.rail-public-summary{padding:4px 0}.rail-public-summary .rail-title{margin:0 0 10px}.auth-loading{min-height:100vh;display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:15px;background:var(--paper)}.user-menu{display:flex;align-items:center;gap:14px;padding-left:6px;flex-shrink:0}.user-name{font-size:12.5px;color:var(--muted-2);letter-spacing:.01em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:140px}.logout-btn{border:1px solid rgba(255,255,255,.18);background:transparent;color:#c8bfb0;font-size:12px;padding:4px 11px;border-radius:var(--r);cursor:pointer;white-space:nowrap;transition:background .15s,color .15s}.logout-btn:hover{background:#ffffff1a;color:var(--paper)}.logout-btn:disabled{opacity:.5;cursor:default}.login-page,.setup-page{min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;background:var(--paper)}.login-card,.setup-card{width:100%;max-width:360px;padding:40px 36px 44px;background:var(--paper-3);border:1px solid var(--line);border-radius:var(--r3);box-shadow:0 2px 20px #141d1812}.login-brand,.setup-brand{font-family:var(--display);font-size:32px;font-weight:600;color:var(--ink);letter-spacing:-.01em;margin:0 0 6px}.login-tagline,.setup-tagline{font-size:13px;color:var(--muted);margin:0 0 28px}.login-field,.setup-field{display:flex;flex-direction:column;gap:5px;margin-bottom:14px}.login-label,.setup-label{font-size:11.5px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.06em}.login-input,.setup-input{width:100%;border:1px solid var(--line);background:var(--field);color:var(--ink);padding:9px 11px;font-size:14px;border-radius:var(--r2);outline:none;transition:border-color .15s}.login-input:focus,.setup-input:focus{border-color:var(--accent)}.login-error,.setup-error{font-size:13px;color:var(--accent);margin:4px 0 8px}.login-submit,.setup-submit{width:100%;margin-top:8px;border:none;background:var(--top);color:var(--paper);font-size:14px;font-weight:600;padding:11px 16px;border-radius:var(--r2);cursor:pointer;letter-spacing:.02em;transition:opacity .15s}.login-submit:hover,.setup-submit:hover{opacity:.85}.login-submit:disabled,.setup-submit:disabled{opacity:.45;cursor:default}.view-tabs{display:flex;gap:2px;margin-bottom:22px;border-bottom:1px solid var(--line-soft);padding-bottom:0}.view-tab{border:none;background:none;color:var(--muted);font-size:13px;font-weight:600;padding:7px 14px;cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px;letter-spacing:.02em;transition:color .12s;text-transform:uppercase;letter-spacing:.06em;font-size:11.5px}.view-tab:hover{color:var(--ink)}.view-tab.is-active{color:var(--ink);border-bottom-color:var(--accent)}.form-section{margin-bottom:32px}.form-section h2{font-size:14px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin:0 0 14px}.form-row{display:flex;gap:8px;margin-bottom:10px}.form-field{display:flex;flex-direction:column;gap:5px;margin-bottom:12px}.form-label{font-size:11.5px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.05em}.field-input{border:1px solid var(--line);background:var(--field);color:var(--ink);padding:8px 10px;font-size:13.5px;border-radius:var(--r);outline:none;transition:border-color .15s;min-width:0}.field-input:focus{border-color:var(--accent)}.field-input--flex{flex:1}.form-msg{font-size:13px;margin:6px 0}.form-msg--ok{color:var(--link)}.form-msg--err{color:var(--accent)}.btn-primary{border:none;background:var(--top);color:var(--paper);font-size:13px;font-weight:600;padding:8px 16px;border-radius:var(--r);cursor:pointer;white-space:nowrap}.btn-primary:hover{opacity:.85}.btn-primary:disabled{opacity:.45;cursor:default}.btn-ghost{border:1px solid var(--line);background:none;color:var(--ink);font-size:13px;padding:7px 14px;border-radius:var(--r);cursor:pointer}.btn-ghost:hover{background:var(--paper-2)}.btn-danger{border:1px solid var(--accent);background:none;color:var(--accent);font-size:13px;padding:7px 14px;border-radius:var(--r);cursor:pointer}.btn-danger:hover{background:var(--accent);color:var(--paper)}.admin-view h1{font-family:var(--display);font-weight:600;font-size:26px;margin:0 0 20px}.admin-table{width:100%;border-collapse:collapse;font-size:13px;margin-bottom:28px}.admin-table th{text-align:left;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);padding:8px 10px;border-bottom:1px solid var(--line);background:var(--paper-2)}.admin-table th:first-child{padding-left:16px}.admin-table td{padding:9px 10px;border-bottom:1px solid var(--line-soft);color:var(--ink);vertical-align:middle}.admin-table td:first-child{padding-left:16px}.admin-table tr:hover td{background:var(--paper-2)}.admin-row-disabled td{opacity:.45}.admin-section{margin-bottom:36px;max-width:860px}.admin-section h2{font-size:14px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin:0 0 14px}.admin-section h3{font-size:13px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin:22px 0 10px}.admin-form{display:flex;flex-direction:column;gap:8px;max-width:480px}.admin-input{border:1px solid var(--line);background:var(--field);color:var(--ink);padding:8px 10px;font-size:13.5px;border-radius:var(--r);outline:none;transition:border-color .15s}.admin-input:focus{border-color:var(--accent)}.admin-action-btn{border:1px solid var(--line);background:none;color:var(--ink);font-size:12px;padding:4px 10px;border-radius:var(--r);cursor:pointer;white-space:nowrap}.admin-action-btn:hover{border-color:var(--accent);color:var(--accent)}.status-badge{display:inline-block;padding:2px 8px;border-radius:var(--r);font-size:11.5px;font-weight:600;text-transform:uppercase;letter-spacing:.04em}.status-active{background:#d8eddf;color:#235c35;border:1px solid #b4d9be}.status-disabled{background:#ede8e0;color:var(--muted);border:1px solid var(--line)}.run-status{display:inline-block;padding:2px 8px;border-radius:var(--r);font-size:11.5px;font-weight:600;text-transform:uppercase;letter-spacing:.04em}.run-status--completed{background:#d8eddf;color:#235c35;border:1px solid #b4d9be}.run-status--failed{background:#f5ddd8;color:#8d3f30;border:1px solid #e0b8b0}.run-status--in-progress{background:#fdefd8;color:#8a4f10;border:1px solid #f0cfa0}.run-row--failed{cursor:pointer}.run-row--failed:hover td{background:#fdf0ed!important}.run-expand-hint{margin-left:6px;font-size:10px;color:var(--accent);vertical-align:middle}.run-error-row td{padding:0!important;background:var(--paper-2)!important}.run-error-detail{margin:0;padding:10px 16px 12px;font-family:ui-monospace,SF Mono,Menlo,monospace;font-size:11.5px;line-height:1.55;color:var(--muted);white-space:pre-wrap;word-break:break-word;max-height:220px;overflow-y:auto}.token-banner{background:#d8eddf;border:1px solid #b4d9be;padding:12px 14px;margin-bottom:16px;font-size:13px;border-radius:var(--r2)}.token-banner code{word-break:break-all;display:block;margin-top:6px;padding:6px 8px;background:var(--field);border:1px solid var(--line);border-radius:var(--r);font-size:12px;font-family:ui-monospace,SF Mono,Menlo,monospace}.token-dismiss{margin-top:8px;font-size:12px;border:1px solid var(--line);background:none;cursor:pointer;padding:3px 8px;border-radius:var(--r);color:var(--muted)}.token-dismiss:hover{background:var(--paper-2)}.token-row{display:flex;align-items:center;justify-content:space-between;border:1px solid var(--line);background:var(--paper-3);padding:10px 12px;border-radius:var(--r);margin-bottom:6px}.token-row-info strong{font-size:14px;display:block}.token-row-info .muted{font-size:12px;margin-top:2px}.token-create-row{display:flex;gap:8px;margin-bottom:16px}.checkbox-row{display:flex;align-items:center;gap:10px;margin-bottom:12px;cursor:pointer;font-size:14px}.checkbox-row input[type=checkbox]{width:15px;height:15px;accent-color:var(--accent)}.coll-create-details{margin-top:16px}.coll-create-details summary{font-weight:600;cursor:pointer;font-size:13px;color:var(--link);list-style:none;display:flex;align-items:center;gap:6px;padding:8px 12px;border:1px solid var(--line-soft);border-radius:var(--r2);background:var(--paper-3);width:fit-content}.coll-create-details summary:hover{background:var(--paper-2)}.coll-create-details[open] summary{border-bottom-left-radius:0;border-bottom-right-radius:0}.coll-create-form{border:1px solid var(--line-soft);border-top:none;padding:14px;border-radius:0 0 var(--r2) var(--r2);background:var(--paper-3);display:flex;flex-direction:column;gap:10px;max-width:440px}@media (max-width: 900px){.topbar{grid-template-columns:1fr auto;height:auto;min-height:58px;padding:12px}.switcher,.nav{grid-column:1 / -1}.nav{justify-content:flex-start;overflow-x:auto}.app-shell{height:auto;grid-template-columns:1fr}.context-rail{border-left:0;border-top:1px solid var(--line)}.entry-table{min-width:860px}}.rail-title--editable{cursor:pointer}.rail-title--editable:hover{opacity:.75}.rail-title--editable .edit-icon{display:inline-block;width:.75em;height:.75em;margin-left:.35em;vertical-align:middle;opacity:0;transition:opacity .1s;flex-shrink:0}.rail-title--editable:hover .edit-icon{opacity:.5}.rail-title-input{width:100%;font:inherit;font-size:inherit;font-weight:600;background:transparent;border:none;border-bottom:1px solid currentColor;color:inherit;padding:0;margin:0 0 8px;outline:none}.preview-panel{flex:1;display:flex;flex-direction:column;min-height:0}.preview-panel-loading,.preview-panel-empty{flex:1;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:8px;color:var(--muted-2);font-size:14px;text-align:center;padding:24px}.preview-video-wrap{flex:1;display:flex;align-items:center;justify-content:center;background:#0d0d0b;min-height:200px}.preview-video-wrap video{max-width:100%;max-height:100%;width:100%;display:block}.video-tv-controls{position:absolute;top:10px;right:10px;display:flex;gap:6px;z-index:10}.video-tv-btn{display:flex;align-items:center;justify-content:center;width:34px;height:34px;padding:0;border:none;border-radius:6px;background:#00000080;color:#ffffffd1;cursor:pointer;backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);transition:background .15s,color .15s}.video-tv-btn:hover{background:#000000b8;color:#fff}google-cast-launcher.video-tv-btn{color:#ffffffd1}.video-tv-loading{width:100%;min-height:200px;background:#0d0d0b}.preview-iframe-wrap{flex:1;display:flex;flex-direction:column;min-height:0}.preview-iframe-toolbar{display:flex;flex-direction:column;gap:2px;padding:6px 12px;background:var(--paper-2);border-bottom:1px solid var(--line);flex-shrink:0;text-transform:none;letter-spacing:normal}.preview-iframe-toolbar span{font-size:11px;color:var(--muted-2)}.preview-iframe-toolbar a{margin-left:auto;color:var(--link);font-size:12px;text-decoration:none;padding:3px 8px;border:1px solid var(--line);border-radius:var(--r2);background:var(--field);transition:border-color .12s}.preview-iframe-toolbar a:hover{border-color:var(--accent);text-decoration:none}.preview-iframe-wrap iframe{flex:1;width:100%;border:none;background:#fff;min-height:0}.preview-image-wrap{flex:1;display:flex;align-items:center;justify-content:center;padding:20px;background:var(--paper-2)}.preview-image-wrap img{max-width:100%;max-height:100%;object-fit:contain;border-radius:var(--r3);cursor:zoom-in;display:block;box-shadow:0 4px 24px #0000001a}.preview-tweet-wrap{flex:1;min-height:0;overflow-y:auto;padding:12px 16px;background:var(--paper-2);scrollbar-width:thin;scrollbar-color:var(--line) transparent}.preview-tweet-wrap::-webkit-scrollbar{width:8px}.preview-tweet-wrap::-webkit-scrollbar-track{background:transparent}.preview-tweet-wrap::-webkit-scrollbar-thumb{background:var(--line);border-radius:999px;border:2px solid transparent;background-clip:padding-box}.preview-tweet-wrap::-webkit-scrollbar-thumb:hover{background:var(--muted-2);background-clip:padding-box;border:2px solid transparent}.tw-card{border:1px solid var(--line);border-radius:12px;padding:16px;background:var(--paper);max-width:560px;margin:0 auto 12px}.tw-thread{max-width:560px;margin:0 auto}.tw-thread-item{display:flex;gap:0;margin-bottom:0}.tw-thread-connector{display:flex;flex-direction:column;align-items:center;flex-shrink:0;width:52px;padding-top:2px}.tw-thread-avatar-col{display:flex;flex-direction:column;align-items:center;flex-shrink:0}.tw-thread-line{width:2px;background:var(--line);flex:1;min-height:16px;margin:4px 0}.tw-thread-body{flex:1;min-width:0;padding:0 0 16px 12px}.tw-thread-body:last-child{padding-bottom:0}.tw-avatar{width:40px;height:40px;border-radius:50%;object-fit:cover;flex-shrink:0;display:block}.tw-avatar-ph{width:40px;height:40px;border-radius:50%;background:var(--paper-2);border:1px solid var(--line);flex-shrink:0}.tw-header{display:flex;align-items:baseline;gap:6px;flex-wrap:wrap;margin-bottom:6px}.tw-author-name{font-size:14.5px;font-weight:700;color:var(--ink)}.tw-author-handle{font-size:13px;color:var(--muted-2)}.tw-dot{color:var(--muted-2);font-size:13px}.tw-date{font-size:13px;color:var(--muted-2)}.tw-text{font-size:15px;line-height:1.65;color:var(--ink);white-space:pre-line;word-break:break-word;margin-bottom:10px}.tw-text a{color:var(--link);text-decoration:none}.tw-text a:hover{text-decoration:underline}.tw-stats{display:flex;gap:20px;color:var(--muted-2);font-size:13px;margin-top:10px;padding-top:10px;border-top:1px solid var(--line-soft)}.tw-stats span{display:flex;align-items:center;gap:4px}.tw-article{max-width:598px;margin:0 auto}.tw-article-cover{width:100%;display:block;object-fit:cover;max-height:380px}.tw-article-meta{padding:20px 16px 0}.tw-article-title{font-size:22px;font-weight:800;letter-spacing:-.3px;color:var(--ink);line-height:1.3;margin-bottom:14px}.tw-article-author-row{display:flex;align-items:center;gap:12px;margin-bottom:14px}.tw-article-author-name{font-size:14px;font-weight:700;color:var(--ink)}.tw-article-author-sub{font-size:13px;color:var(--muted-2)}.tw-article-divider{border:none;border-top:1px solid var(--line);margin:0}.tw-article-body{padding:8px 16px 60px}.tw-b-h1{font-size:24px;font-weight:800;letter-spacing:-.3px;color:var(--ink);line-height:1.25;margin:22px 0 10px}.tw-b-h2{font-size:19px;font-weight:700;letter-spacing:-.15px;color:var(--ink);line-height:1.3;margin:20px 0 8px}.tw-b-p{font-size:16px;color:var(--ink);line-height:1.72;margin-bottom:14px}.tw-b-p:empty{display:none}.tw-b-spacer{height:6px;display:block}.tw-b-blockquote{border-left:3px solid var(--line);padding:2px 14px;margin:14px 0;color:var(--muted);font-size:16px;line-height:1.65}.tw-b-hr{border:none;border-top:1px solid var(--line);margin:24px 0}.tw-b-img{width:100%;display:block;border-radius:10px;margin:14px 0}.tw-b-ul,.tw-b-ol{margin:10px 0 14px;padding-left:26px}.tw-b-li{font-size:16px;color:var(--ink);line-height:1.65;margin-bottom:6px}.tw-b-code{font-family:ui-monospace,SF Mono,Menlo,monospace;font-size:.875em;background:var(--paper-2);padding:2px 5px;border-radius:4px;color:var(--ink)}.tw-b-pre{background:var(--paper-2);border:1px solid var(--line);border-radius:8px;padding:14px 16px;overflow-x:auto;margin:12px 0}.tw-b-pre code{font-family:ui-monospace,SF Mono,Menlo,monospace;font-size:13px;line-height:1.6}.tw-b-tweet-link{display:flex;align-items:center;gap:10px;border:1px solid var(--line);border-radius:10px;padding:12px 14px;margin:12px 0;color:var(--muted);font-size:14px;text-decoration:none;background:var(--paper-3);transition:background .12s}.tw-b-tweet-link:hover{background:var(--field)}.tw-b-tweet-link svg{flex-shrink:0;color:var(--ink)}.audio-bar{position:fixed;bottom:0;left:0;right:0;height:72px;background:var(--paper);border-top:1px solid var(--line);display:flex;align-items:center;gap:20px;padding:0 24px;z-index:100;box-shadow:0 -2px 16px #00000014}.audio-bar-info{display:flex;align-items:center;gap:10px;min-width:0;flex:0 0 200px}.audio-bar-icon{flex-shrink:0;width:20px;height:20px;display:flex;align-items:center;justify-content:center}.audio-bar-icon svg{width:20px;height:20px}.audio-bar-title{font-size:13px;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;font-weight:500}.audio-bar-controls{flex:1;display:flex;align-items:center;gap:12px;min-width:0}.audio-bar-play-btn{width:38px;height:38px;border-radius:50%;background:var(--accent);color:var(--paper);border:none;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:background .12s,transform .1s}.audio-bar-play-btn:hover{background:var(--accent-2)}.audio-bar-play-btn:active{transform:scale(.93)}.audio-bar-play-btn svg{width:15px;height:15px}.audio-bar-seek{flex:1;min-width:0;display:flex;align-items:center;gap:8px}.audio-bar-seek input[type=range]{flex:1;accent-color:var(--accent);height:4px;cursor:pointer;min-width:0}.audio-bar-time{font-size:11.5px;color:var(--muted-2);white-space:nowrap;min-width:80px;text-align:right;font-variant-numeric:tabular-nums}.audio-bar-right{flex:0 0 auto;display:flex;align-items:center;gap:10px}.audio-bar-volume{display:flex;align-items:center;gap:6px}.audio-bar-volume svg{width:14px;height:14px;color:var(--muted-2);flex-shrink:0}.audio-bar-volume input[type=range]{width:72px;accent-color:var(--accent);cursor:pointer}.audio-bar-close{width:28px;height:28px;border-radius:50%;border:none;background:transparent;color:var(--muted-2);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:18px;padding:0;line-height:1;transition:background .12s,color .12s}.audio-bar-close:hover{background:var(--field);color:var(--ink)}.preview-modal-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:200;background:#0000008c;display:flex;align-items:center;justify-content:center;padding:20px}.preview-modal{background:var(--paper);border-radius:12px;box-shadow:0 24px 80px #00000047;width:90vw;max-width:1100px;max-height:88vh;display:flex;flex-direction:column;overflow:hidden}.preview-modal--full{height:88vh}.preview-modal--full .preview-modal-body{max-height:none}.preview-modal-header{display:flex;align-items:center;padding:14px 18px;border-bottom:1px solid var(--line);flex-shrink:0;gap:12px}.preview-modal-title{flex:1;min-width:0;font-size:14px;font-weight:600;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.preview-modal-close{width:30px;height:30px;border-radius:50%;border:none;background:transparent;color:var(--muted-2);font-size:20px;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:background .12s,color .12s}.preview-modal-close:hover{background:var(--field);color:var(--ink)}.preview-modal-body{flex:1;min-height:0;overflow:hidden;display:flex;flex-direction:column;max-height:calc(88vh - 52px)}.rail-preview-btn{display:block;width:100%;padding:8px 12px;margin-bottom:16px;background:var(--accent);color:var(--paper);border:none;border-radius:var(--r3);font-size:13px;font-weight:600;cursor:pointer;text-align:center;transition:background .12s}.rail-preview-btn:hover{background:var(--accent-2)}.preview-modal-newtab{display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:var(--r2);color:var(--muted-2);text-decoration:none;font-size:16px;flex-shrink:0;transition:background .12s,color .12s}.preview-modal-newtab:hover{background:var(--field);color:var(--ink)}body.has-audio-bar{padding-bottom:56px}.in-progress-entry-row{box-sizing:border-box;min-width:0;padding:7px 22px;gap:9px;color:var(--muted);white-space:nowrap}.in-progress-entry-row__spinner{width:16px;height:16px;border-width:2px;color:var(--accent);flex-shrink:0}.in-progress-entry-row__locator{min-width:0;overflow:hidden;text-overflow:ellipsis;font-family:ui-monospace,SF Mono,Menlo,monospace;font-size:11.5px}.in-progress-entry-row__status{display:inline-flex;align-items:center;gap:5px;flex-shrink:0;color:var(--muted-2);font-size:11.5px}.in-progress-entry-row__kind{font-size:10.5px;opacity:.85}@media (pointer: coarse){.in-progress-entry-row{padding-inline:10px}}#entries-body>.entry-row-outer{display:block;border-bottom:none}#entries-body>.entry-row-outer>.entry-row-main{display:flex;align-items:center;cursor:default;border-bottom:1px solid var(--line-soft);padding:0;flex-shrink:unset;overflow:visible}#entries-body>.entry-row-outer>.entry-row-main>div{padding:7px 10px;flex-shrink:0;overflow:hidden}#entries-body>.entry-row-outer>.entry-row-main>div:last-child{padding-right:22px}#entries-body>.entry-row-outer>.entry-row-main .col-added{padding-left:22px}#entries-body>.entry-row-outer.is-selected{background:unset;outline:none;box-shadow:none}#entries-body>.entry-row-outer.is-multi-selected{background:unset;outline:none;box-shadow:none}#entries-body>.entry-row-outer.is-selected>.entry-row-main{background:#eee2d2;box-shadow:inset 0 0 0 2px var(--accent)}#entries-body>.entry-row-outer.is-multi-selected>.entry-row-main{background:#eee2d2;box-shadow:inset 0 0 0 2px var(--accent)}#entries-body>.entry-row-outer .url-cell:hover{overflow:visible;white-space:normal}#entries-body>.entry-row-outer>.child-entries{display:block;padding:0;flex-shrink:unset;overflow:visible;border-left:2px solid var(--line-soft);margin-left:32px}.child-entry-row{display:flex;align-items:center;cursor:default;border-bottom:1px solid var(--line-soft);opacity:.88}.child-entry-row:hover{opacity:1}.child-entry-row:last-child{border-bottom:none}.child-entry-row--light{background:#fafaf8}.child-entry-row--dark{background:#f2f0ec}.child-entry-row.is-selected,.child-entry-row.is-multi-selected{background:#eee2d2;box-shadow:inset 0 0 0 2px var(--accent);opacity:1}.child-entry-row>div{padding:6px 10px;flex-shrink:0;overflow:hidden}.child-entry-row .col-added{padding-left:22px}.child-entry-row>div:last-child{padding-right:22px}.child-reorder-controls{display:inline-flex;align-items:center;gap:4px;flex-shrink:0;transition:opacity .15s}.child-drag-handle{display:none;cursor:grab;padding:0 3px;font-size:.8em;letter-spacing:-2px;-webkit-user-select:none;user-select:none;color:var(--muted)}.child-drag-handle:active{cursor:grabbing}.child-move-btn{width:28px;height:28px;padding:0;border:none;background:none;color:inherit;font-size:.9em;line-height:1;cursor:pointer}.child-move-btn:disabled{opacity:.3;cursor:default}.child-move-btn:focus{outline:none}.child-move-btn:focus-visible{outline:2px solid var(--accent);outline-offset:1px;border-radius:2px}@media (hover: hover) and (pointer: fine) and (min-width: 641px){.child-reorder-controls{gap:1px;opacity:.3}.child-entry-row:hover .child-reorder-controls,.child-entry-row:focus-within .child-reorder-controls{opacity:1}.child-drag-handle{display:inline}.child-move-btn{display:none}}.child-entry-row.is-dragging{opacity:.4}.child-entry-row.is-drop-before{box-shadow:inset 0 2px 0 var(--accent)}.child-entry-row.is-drop-after{box-shadow:inset 0 -2px 0 var(--accent)}.child-entries[aria-busy=true] .child-entry-row{cursor:progress}.entry-expand-btn{display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;flex-shrink:0;padding:0;margin-right:2px;background:none;border:none;cursor:pointer;opacity:.45;color:inherit;transition:opacity .15s}.entry-expand-btn:focus{outline:none}.entry-expand-btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:2px}.entry-expand-btn:before{content:"";display:block;width:0;height:0;border-top:4px solid transparent;border-bottom:4px solid transparent;border-left:6px solid currentColor;transition:transform .15s}.entry-expand-btn:hover{opacity:1}.entry-expand-btn.is-expanded:before{transform:rotate(90deg)}.child-count-badge{display:inline-block;margin-left:5px;padding:0 5px;font-size:.72em;font-weight:600;background:color-mix(in srgb,var(--line-soft) 60%,transparent);border-radius:10px;opacity:.75;vertical-align:middle;line-height:1.6}.child-entries-loading{padding:8px 12px;font-size:.85em;opacity:.55}.capture-playlist-toggle{display:inline-flex;align-items:center;gap:4px;font-size:.75rem;color:var(--muted);background:none;border:none;cursor:pointer;padding:2px 6px}.capture-playlist-toggle:hover{color:var(--text)}.capture-playlist-toggle--left{flex-shrink:0;padding:2px 4px;font-size:.7rem}.capture-conflict-badge{font-size:.7rem;color:#c07000;background:#fff3cd;border:1px solid #e0a000;border-radius:4px;padding:1px 6px;margin-left:6px}.capture-playlist-items{border-top:1px solid var(--line-soft);max-height:320px;overflow-y:auto;scrollbar-width:thin;scrollbar-color:var(--line) transparent}.capture-playlist-item{display:flex;align-items:center;gap:8px;padding:5px 10px 5px 14px;font-size:.8rem;border-bottom:1px solid var(--line-soft);border-left:3px solid transparent}.capture-playlist-item:last-of-type{border-bottom:none}.capture-playlist-item--conflict{border-left-color:#c07000;background:color-mix(in srgb,#c07000 6%,transparent)}.capture-playlist-item-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text);opacity:.9}.capture-item-quality{font-size:.73rem;padding:2px 4px;border:1px solid var(--line);border-radius:3px;background:var(--paper-3);color:var(--text);flex-shrink:0}.capture-playlist-conflict-badge{font-size:.68rem;font-weight:600;color:#c07000;white-space:nowrap;flex-shrink:0}.capture-playlist-item-remove{flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;padding:0;border:none;background:none;color:var(--muted);cursor:pointer;border-radius:3px;opacity:.3;transition:opacity .1s,color .1s}.capture-playlist-item:hover .capture-playlist-item-remove,.capture-playlist-item-remove:focus-visible{opacity:1}.capture-playlist-item-remove:hover{color:#c04000;opacity:1}@media (pointer: coarse){.capture-playlist-item-remove{opacity:1}}.capture-playlist-item-remove{font-size:.75rem;line-height:1}.capture-sync-row{display:flex;align-items:center;gap:6px;padding:6px 12px;font-size:.78rem;color:var(--muted);border-top:1px solid var(--line-soft);cursor:pointer}.capture-sync-row input[type=checkbox]{cursor:pointer}.rail-summary-body{margin-bottom:10px}.rail-summary-tldr{margin:0 0 8px;font-size:13.5px;font-weight:600;color:var(--ink);line-height:1.45}.rail-summary-text{margin:0 0 8px;font-size:13px;color:var(--ink);line-height:1.55}.rail-summary-tags{display:flex;flex-wrap:wrap;gap:5px;margin-bottom:8px}.rail-summary-tag{font-size:11px;padding:2px 7px;border:1px solid var(--line);border-radius:999px;color:var(--muted)}.rail-summary-provider{margin:0;font-size:11px;color:var(--muted-2);letter-spacing:.02em}.rail-summary-status{display:flex;align-items:center;gap:7px;margin:0 0 8px;font-size:12.5px;color:var(--muted)}.rail-summary-info{margin:0 0 8px;padding:8px;color:var(--muted);background:var(--paper-2);border:1px solid var(--line-soft);border-radius:4px}.rail-summary-info__heading{margin:0 0 4px;color:var(--ink);font-size:12.5px;font-weight:600}.rail-summary-info__detail{margin:0;font-size:12px;line-height:1.45}.rail-summary-error{margin:0 0 8px}.rail-summary-spinner{width:11px;height:11px;border:1.5px solid var(--line);border-top-color:var(--muted);border-radius:50%;animation:rail-summary-spin .7s linear infinite;flex-shrink:0}@keyframes rail-summary-spin{to{transform:rotate(360deg)}}@media (prefers-reduced-motion: reduce){.rail-summary-spinner{animation:none}}.rail-summary-controls{display:flex;flex-direction:column;gap:6px}.rail-summary-select{width:100%;padding:5px 8px;font-size:12.5px;color:var(--ink);background:var(--paper);border:1px solid var(--line);border-radius:4px}.rail-summary-image-option{padding:7px 8px;color:var(--muted);background:var(--paper);border:1px solid var(--line);border-radius:4px}.rail-summary-image-option__label{display:flex;align-items:center;gap:6px;color:var(--ink);font-size:12.5px;cursor:pointer}.rail-summary-image-option__label input[type=checkbox]{margin:0;accent-color:var(--accent);cursor:pointer}.rail-summary-image-option__label input[type=checkbox]:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.rail-summary-image-option__note{margin:5px 0 0;font-size:11px;line-height:1.4}.rail-summary-image-option--disabled{color:var(--muted-2);background:var(--paper)}.rail-summary-image-option--disabled .rail-summary-image-option__label{color:var(--muted-2);cursor:not-allowed}.rail-summary-image-option--disabled .rail-summary-image-option__label input[type=checkbox]{cursor:not-allowed}.text-preview{padding:32px 40px 48px;max-width:780px;margin:0 auto;font-family:var(--sans);color:var(--ink);overflow:auto;height:100%;box-sizing:border-box}.text-preview--loading,.text-preview--error{padding:24px;color:var(--muted);font-size:13px}.text-preview--error{color:var(--accent)}.text-preview__title{font-family:var(--serif, var(--sans));font-size:22px;font-weight:600;margin:0 0 20px;line-height:1.25;color:var(--ink)}.text-preview__body{white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word;font-family:ui-monospace,SF Mono,Menlo,Consolas,monospace;font-size:13.5px;line-height:1.65;color:var(--ink);background:var(--paper);border:1px solid var(--line);border-radius:var(--r2);padding:16px 18px;margin:0}.text-preview__mime{margin-top:12px;font-size:11px;color:var(--muted);letter-spacing:.04em;text-align:right} diff --git a/crates/archivr-server/static/assets/index-DicH9MNh.css b/crates/archivr-server/static/assets/index-DicH9MNh.css deleted file mode 100644 index 8ca8c49..0000000 --- a/crates/archivr-server/static/assets/index-DicH9MNh.css +++ /dev/null @@ -1 +0,0 @@ -@import"https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@500;600&family=Spectral:wght@500;600&display=swap";:root{color-scheme:light;--ink: #20251f;--muted: #6b6f66;--muted-2: #8a8d83;--paper: #f5f0e7;--paper-2: #e9e1d2;--paper-3: #fffaf0;--line: #d2c6b5;--line-soft: #e5dccd;--accent: #8d3f30;--accent-2: #b78342;--link: #245f72;--top: #141d18;--field: #fffdf7;--r: 3px;--r2: 6px;--r3: 10px;--sans: "Helvetica Neue", Helvetica, Arial, sans-serif;--serif: "Spectral", Georgia, "Times New Roman", serif;--display: "Cormorant Garamond", Georgia, serif}*{box-sizing:border-box}body{margin:0;min-height:100vh;background:var(--paper);color:var(--ink);font-family:var(--sans);font-size:14px;-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}button,input,select{font:inherit}.workspace,.context-rail{scrollbar-width:thin;scrollbar-color:var(--line) transparent}.workspace::-webkit-scrollbar,.context-rail::-webkit-scrollbar{width:11px;height:11px}.workspace::-webkit-scrollbar-track,.context-rail::-webkit-scrollbar-track{background:transparent}.workspace::-webkit-scrollbar-thumb,.context-rail::-webkit-scrollbar-thumb{background:var(--line);border-radius:999px;border:3px solid transparent;background-clip:padding-box}.workspace::-webkit-scrollbar-thumb:hover,.context-rail::-webkit-scrollbar-thumb:hover{background:var(--muted-2);background-clip:padding-box;border:3px solid transparent}.topbar{height:58px;display:grid;grid-template-columns:auto auto 1fr auto auto;align-items:center;gap:26px;padding:0 22px;background:var(--top);color:#efe6d6;border-bottom:3px solid var(--accent)}.brand{font-family:var(--display);font-weight:600;font-size:30px;line-height:1;letter-spacing:.01em;color:#f4ead8}.switcher{position:relative;display:inline-flex;align-items:center}.switcher select{-moz-appearance:none;appearance:none;-webkit-appearance:none;border:1px solid rgba(247,238,223,.2);background:#f7eedf0f;color:#f1e8d8;font-family:var(--sans);font-weight:550;font-size:13.5px;letter-spacing:.04em;padding:8px 32px 8px 15px;cursor:pointer;border-radius:6px;transition:background .15s ease,border-color .15s ease}.switcher select:hover{background:#f7eedf1c;border-color:#f7eedf57}.switcher:after{content:"";position:absolute;right:15px;top:50%;width:6px;height:6px;margin-top:-5px;border-right:1.2px solid #b6ab98;border-bottom:1.2px solid #b6ab98;transform:rotate(45deg);pointer-events:none}.switchers{display:flex;gap:8px;align-items:center;min-width:0}.nav{display:flex;gap:22px;justify-content:flex-end;min-width:0}.nav-link{border:0;background:transparent;color:#cdc1ad;cursor:pointer;padding:6px 0;font-size:12.5px;font-weight:600;letter-spacing:.1em;text-transform:uppercase;position:relative}.nav-link:hover,.nav-link.is-active{color:#f4ead8}.nav-link.is-active:after{content:"";position:absolute;left:0;right:0;bottom:-2px;height:1px;background:var(--accent-2)}.capture-button{display:inline-flex;align-items:center;gap:8px;position:relative;overflow:hidden;border:0;background:linear-gradient(180deg,color-mix(in srgb,var(--accent) 88%,#fff) 0%,var(--accent) 55%);color:#f7eddd;padding:10px 18px;cursor:pointer;font-size:12px;font-weight:600;letter-spacing:.15em;text-transform:uppercase;border-radius:var(--r);box-shadow:inset 0 1px #fff5e629,0 1px 2px #141d184d;transition:filter .18s ease,box-shadow .18s ease}.capture-button:hover{filter:brightness(.93);box-shadow:inset 0 1px #fff5e61f,0 2px 6px #141d1857}.capture-button:before{content:"";position:absolute;top:0;bottom:0;left:0;width:45%;background:linear-gradient(100deg,transparent 0%,rgba(255,246,232,.45) 50%,transparent 100%);transform:translate(-180%) skew(-18deg);pointer-events:none}@media (prefers-reduced-motion: no-preference){.capture-button:hover:before{animation:cap-sheen .7s ease}}@keyframes cap-sheen{0%{transform:translate(-180%) skew(-18deg)}to{transform:translate(340%) skew(-18deg)}}.app-shell{height:calc(100vh - 58px);display:grid;grid-template-columns:minmax(0,1fr) 340px}.workspace{min-width:0;overflow:auto;display:flex;flex-direction:column}.view{display:none}.view.is-active{display:block}.toolbar{position:sticky;top:0;z-index:3;display:flex;align-items:center;gap:16px;padding:12px 22px;background:color-mix(in srgb,var(--paper) 88%,white);border-bottom:1px solid var(--line-soft)}.search-field{position:relative;flex:1 1 auto;min-width:0;display:flex;align-items:center;height:42px;background:var(--field);border:1px solid var(--line);border-radius:20px;transition:border-color .15s ease,box-shadow .15s ease}.search-field:focus-within{border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 14%,transparent)}.search-field .ico{display:grid;place-items:center;width:46px;height:100%;color:var(--muted-2);flex-shrink:0}.search-field .ico svg{width:16px;height:16px}.search-input{flex:1;min-width:0;height:100%;border:0;background:transparent;color:var(--ink);padding:0 6px 0 0;font-size:14px;letter-spacing:.01em;outline:none}.search-input::placeholder{color:var(--muted-2)}.search-field .kbd{margin-right:12px;display:inline-flex;align-items:center;padding:3px 7px;font-size:11px;color:var(--muted);background:var(--paper-2);border:1px solid var(--line-soft);border-radius:var(--r);flex-shrink:0}.result-count{flex-shrink:0;font-size:13px;color:var(--muted);letter-spacing:.02em;white-space:nowrap}.result-count b{color:var(--ink);font-weight:600;font-variant-numeric:tabular-nums}.tag-filter-badge{display:inline-flex;align-items:center;gap:4px;background:var(--accent);color:#fff;border:0;border-radius:var(--r);font-size:12px;padding:2px 8px;cursor:pointer;margin-left:8px}.tag-filter-badge:hover{opacity:.85}.entry-table{width:100%;font-size:12.5px}.entry-header-row{display:flex;align-items:stretch;position:sticky;top:67px;z-index:2;background:color-mix(in srgb,var(--paper) 92%,white);border-bottom:1px solid var(--line-soft);color:var(--muted);font-size:10.5px;text-transform:uppercase;letter-spacing:.08em}.entry-header-row>div{padding:9px 10px;flex-shrink:0;font-weight:600;display:flex;align-items:center}#entries-body>div{display:flex;align-items:center;cursor:default;border-bottom:1px solid var(--line-soft)}#entries-body>div>div{padding:7px 10px;flex-shrink:0;overflow:hidden}#entries-body>div:not(.entry-row-outer):nth-child(2n){background:#f2ede5}#entries-body>div:not(.entry-row-outer):nth-child(odd){background:var(--paper-3)}#entries-body>.entry-row-outer--light{background:var(--paper-3)}#entries-body>.entry-row-outer--dark{background:#f2ede5}#entries-body>div.is-selected{background:#eee2d2;box-shadow:inset 0 0 0 2px var(--accent)}#entries-body>div.is-multi-selected{background:#eee2d2;box-shadow:inset 0 0 0 2px var(--accent)}.col-added{width:162px;color:var(--muted)}.col-title{flex:1 1 0;min-width:0;overflow:hidden;display:flex;align-items:center;gap:.42em}.col-type{width:116px}.col-size{width:100px;display:flex;flex-direction:column;justify-content:center;gap:1px}.size-total{font-variant-numeric:tabular-nums}.size-cached-pct{font-size:10px;color:var(--muted);opacity:.8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.col-url{flex:0 0 30%;min-width:0;overflow:hidden}.entry-header-row .col-added,#entries-body>div .col-added{padding-left:22px}.entry-header-row>div:last-child,#entries-body>div>div:last-child{padding-right:22px}.entry-title{color:var(--link);font-weight:700;min-width:0}.entry-header-row>div{padding:9px 10px;flex-shrink:0;font-weight:600}.source-icon{display:flex;align-items:center;justify-content:center;width:1.05em;height:1.05em;flex-shrink:0}.source-icon>*{width:100%;height:100%;display:flex;align-items:center;justify-content:center}.source-icon svg{width:100%;height:100%}.url-cell{color:#555b55;white-space:nowrap;text-overflow:ellipsis;word-break:break-all}#entries-body .url-cell:hover{overflow:visible;white-space:normal}.type-pill{display:inline-block;padding:2px 6px;background:#d8e3df;color:#275a5f;border:1px solid #bfd0ca;border-radius:var(--r)}.col-check{display:none;width:40px;flex-shrink:0;align-items:center;justify-content:center;padding:0!important;cursor:pointer}.row-checkbox{-webkit-appearance:none;-moz-appearance:none;appearance:none;padding:0;border:1.5px solid var(--line);cursor:pointer;display:block;width:17px;height:17px;border-radius:4px;background:var(--paper-3);transition:border-color .15s ease,background .15s ease;position:relative;flex-shrink:0}.row-checkbox.is-checked{background:var(--accent);border-color:var(--accent)}.row-checkbox.is-checked:after{content:"";position:absolute;left:4px;top:2px;width:5px;height:8px;border:2px solid #fff;border-top:none;border-left:none;transform:rotate(45deg)}@media (pointer: coarse){.col-check{display:flex}.entry-header-row .col-added,#entries-body>div .col-added{padding-left:10px}}#runs-view .entry-table{border-collapse:collapse;table-layout:auto}#runs-view .entry-table th{position:sticky;top:0;z-index:1;text-align:left;padding:10px;background:color-mix(in srgb,var(--paper) 92%,white);color:var(--muted);border-bottom:1px solid var(--line-soft);font-size:10.5px;text-transform:uppercase;letter-spacing:.08em}#runs-view .entry-table td{padding:10px;border-bottom:1px solid var(--line-soft);vertical-align:top}#runs-view .entry-table tr:nth-child(2n) td{background:#f2ede5}#runs-view .entry-table tr:nth-child(odd) td{background:var(--paper-3)}.context-rail{border-left:1px solid var(--line);background:var(--paper);padding:20px 20px 32px;overflow:auto}.rail-eyebrow{font-size:10.5px;font-weight:600;letter-spacing:.16em;text-transform:uppercase;color:var(--muted-2);margin-bottom:16px}.rail-title{display:block;font-family:var(--sans);font-weight:700;font-size:15.5px;line-height:1.4;color:var(--ink);margin:0 0 16px;word-break:break-word}.url-tile{display:flex;align-items:center;gap:9px;padding:10px 12px;background:var(--paper-3);border:1px solid var(--line);border-radius:var(--r3);margin-bottom:20px;text-decoration:none}.url-tile:hover{background:var(--field);border-color:var(--accent)}.url-tile .ico{color:var(--ink);flex-shrink:0;display:grid;place-items:center}.url-tile .ico svg{width:15px;height:15px}.url-tile .u-text{min-width:0;flex:1;font-size:12.5px;color:var(--link);font-family:ui-monospace,SF Mono,Menlo,monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.url-tile .ext{color:var(--muted-2);flex-shrink:0}.url-tile .ext svg{width:13px;height:13px;display:block}.meta-list{margin-bottom:24px;border-top:1px solid var(--line-soft)}.meta-item{display:grid;grid-template-columns:92px 1fr;gap:14px;align-items:baseline;padding:9px 0;border-bottom:1px solid var(--line-soft)}.meta-k{font-size:10.5px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2)}.meta-v{font-size:13.5px;color:var(--ink);word-break:break-word;line-height:1.4}.meta-v.mono{font-family:ui-monospace,SF Mono,Menlo,monospace;font-size:12px;color:var(--muted)}.rail-section{margin-bottom:24px}.rail-section-heading{display:flex;align-items:baseline;gap:8px;font-size:10.5px;font-weight:600;text-transform:uppercase;letter-spacing:.12em;color:var(--muted-2);margin-bottom:9px;padding-bottom:8px;border-bottom:1px solid var(--line)}.rail-section-heading .num{color:var(--muted-2);font-weight:600}.artifact-list{list-style:none;margin:0;padding:0}.artifact-link{display:flex;align-items:baseline;justify-content:space-between;gap:12px;padding:7px 2px;border-bottom:1px solid var(--line-soft);text-decoration:none}.artifact-link:last-child{border-bottom:0}.artifact-name{font-size:13px;color:var(--link);min-width:0;word-break:break-word}.artifact-link:hover .artifact-name{text-decoration:underline}.artifact-size{font-size:11.5px;color:var(--muted-2);flex-shrink:0;font-variant-numeric:tabular-nums}.artifact-group{list-style:none}.artifact-group-header{width:100%;background:none;border:none;border-bottom:1px solid var(--line-soft);cursor:pointer;text-align:left;color:inherit;font:inherit}.artifact-group-header:hover .artifact-name{text-decoration:underline}.artifact-group-chevron{display:inline-block;font-style:normal;transition:transform .15s ease;transform:rotate(0);transform-origin:45% 50%;margin-right:3px}.artifact-group-chevron.open{transform:rotate(90deg)}.artifact-group-body{padding-left:14px}.tags-wrap{display:flex;flex-wrap:wrap;gap:7px;margin:0 0 12px}.tag-pill{display:inline-flex;align-items:center;gap:7px;background:var(--paper-2);border:1px solid var(--line);border-radius:var(--r);font-size:12px;padding:4px 7px 4px 10px;color:var(--ink)}.tag-pill .remove{border:0;background:transparent;cursor:pointer;color:var(--muted-2);width:15px;height:15px;display:grid;place-items:center;font-size:13px;line-height:1}.tag-pill .remove:hover{color:var(--accent)}.tags-empty{font-size:13px;color:var(--muted);margin:0 0 11px}.tag-input-wrap{display:flex;align-items:center;gap:8px;background:var(--field);border:1px solid var(--line);border-radius:var(--r2);padding:2px 2px 2px 11px;transition:border-color .15s ease,box-shadow .15s ease}.tag-input-wrap:focus-within{border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 12%,transparent)}.tag-input-wrap .hash{color:var(--muted-2);font-size:13px}.tag-input{flex:1;min-width:0;border:0;background:transparent;color:var(--ink);font-size:13px;padding:6px 0;outline:none;font-family:ui-monospace,SF Mono,Menlo,monospace}.tag-add-btn{border:0;background:var(--ink);color:var(--paper-3);padding:6px 13px;font-size:11.5px;letter-spacing:.06em;text-transform:uppercase;border-radius:var(--r);cursor:pointer;white-space:nowrap}.tag-add-btn:hover{background:#2c332b}.coll-row{display:flex;align-items:center;justify-content:space-between;gap:12px;overflow:hidden;padding:9px 13px;background:var(--field);border:1px solid var(--line);border-radius:var(--r2);margin-bottom:6px;transition:border-color .15s ease}.coll-row:hover{border-color:var(--accent)}.coll-name{font-size:13px;color:var(--ink);font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}.vis-badge{display:inline-flex;align-items:center;gap:6px;font-size:11px;color:var(--muted);background:var(--paper-2);border:1px solid var(--line-soft);border-radius:999px;padding:3px 10px}.vis-badge:before{content:"";width:6px;height:6px;border-radius:50%;background:var(--accent-2)}.bulk-panel{padding-top:2px}.bulk-count{font-size:13px;color:var(--muted);margin-bottom:22px}.bulk-count-num{font-size:22px;font-weight:700;color:var(--ink);line-height:1;margin-right:4px}.bulk-coll-row{display:flex;align-items:center;gap:8px}.bulk-coll-select{flex:1;min-width:0;height:32px;padding:0 8px;border:1px solid var(--line);border-radius:var(--r);background:var(--field);color:var(--ink);font-size:13px;cursor:pointer;outline:none;transition:border-color .15s ease}.bulk-coll-select:focus{border-color:var(--accent)}.tag-add-btn:disabled{opacity:.45;cursor:default}.rail-delete-zone{margin-top:24px;padding-top:20px;border-top:1px solid var(--line-soft)}.rail-delete-btn{width:100%;padding:8px 14px;background:none;border:1px solid color-mix(in srgb,var(--accent) 45%,transparent);color:var(--accent);border-radius:var(--r);cursor:pointer;font-size:12.5px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;transition:background .15s ease,border-color .15s ease}.rail-delete-btn:hover{background:color-mix(in srgb,var(--accent) 8%,transparent);border-color:var(--accent)}.rail-rearchive-btn{width:100%;padding:6px 12px;background:var(--surface-2, #2a2a2a);color:var(--text, #e0e0e0);border:1px solid var(--border, #444);border-radius:4px;cursor:pointer;font-size:13px}.rail-rearchive-btn:hover:not(:disabled){background:var(--surface-3, #333)}.rail-rearchive-btn:disabled{opacity:.6;cursor:not-allowed}.capture-dialog{border:1px solid var(--line);background:var(--paper);padding:0;width:min(540px,calc(100vw - 32px));border-radius:var(--r3);box-shadow:0 20px 60px #141d1847}.capture-dialog::backdrop{background:#141d1873}.capture-dialog-inner{padding:28px}.capture-dialog-title{margin:0;font-size:22px;font-family:var(--display);font-weight:600}.capture-label{display:block;font-size:11px;font-weight:600;margin-bottom:6px;color:var(--muted-2);text-transform:uppercase;letter-spacing:.06em}.capture-input{width:100%;height:44px;border:1px solid var(--line);background:var(--field);color:var(--ink);padding:0 14px;font-size:15px;margin-bottom:10px;border-radius:var(--r2);outline:none;transition:border-color .15s ease,box-shadow .15s ease}.capture-input:focus{border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 14%,transparent)}.form-field .capture-input{margin-bottom:0}.capture-error{color:var(--accent);font-size:13px;margin-bottom:10px;min-height:18px}.capture-actions{display:flex;flex-direction:column;gap:8px;margin-top:18px}.capture-submit{border:0;background:var(--ink);color:var(--paper);padding:13px 20px;border-radius:var(--r2);cursor:pointer;font-weight:600;font-size:15px;width:100%;min-width:220px;letter-spacing:.01em}.capture-submit:hover{opacity:.85}.capture-submit:disabled{opacity:.45;cursor:default}.capture-cancel{border:0;background:none;color:var(--muted);padding:7px 18px;border-radius:var(--r);cursor:pointer;font-size:13px;text-align:center;width:100%}.capture-cancel:hover{color:var(--ink);background:var(--paper-2)}.capture-advanced{margin-top:12px}.capture-advanced-toggle{display:flex;align-items:center;gap:5px;background:none;border:0;cursor:pointer;color:var(--muted);font-size:12.5px;padding:4px 0;border-radius:var(--r)}.capture-advanced-toggle:hover{color:var(--ink)}.capture-chevron{width:14px;height:14px;flex-shrink:0;transition:transform .18s ease}.capture-chevron--open{transform:rotate(180deg)}.capture-advanced-panel{margin-top:8px;padding:10px 12px;border:1px solid var(--line);border-radius:var(--r2);background:var(--paper-2)}.capture-ext-row{display:flex;align-items:center;justify-content:space-between;gap:12px;cursor:default}.capture-ext-label{flex:1;min-width:0}.capture-ext-name{display:block;font-size:13px;font-weight:600;color:var(--ink)}.capture-ext-desc{display:block;font-size:11.5px;color:var(--muted);margin-top:1px}.capture-dialog-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:20px}.capture-dialog-close{flex-shrink:0;background:none;border:0;cursor:pointer;color:var(--muted);width:28px;height:28px;display:grid;place-items:center;border-radius:var(--r);padding:0}.capture-dialog-close svg{width:14px;height:14px}.capture-dialog-close:hover{background:var(--paper-2);color:var(--ink)}.capture-rows{display:flex;flex-direction:column;gap:8px;margin-bottom:8px}.capture-row{display:flex;flex-direction:column;gap:3px}.capture-row-main{display:flex;align-items:center;gap:8px}.capture-row-main .capture-input{margin-bottom:0}.capture-quality{flex-shrink:0;height:32px;padding:0 6px;border:1px solid var(--line);border-radius:var(--r);background:var(--paper);color:var(--ink);font-size:12px;cursor:pointer;outline:none;transition:border-color .15s}.capture-quality:hover{border-color:var(--accent-2)}.capture-quality:focus{border-color:var(--accent)}.capture-quality:disabled{opacity:.5;cursor:default}.capture-quality-probing{flex-shrink:0;font-size:12px;color:var(--muted);padding:0 4px;letter-spacing:.05em}.capture-quality-hint{flex-shrink:0;font-size:12px;color:var(--muted);font-style:italic}.capture-quality-hint--error{color:#c05000;font-style:normal}.cap-dot{flex-shrink:0;width:20px;height:20px;border-radius:50%;display:grid;place-items:center;font-size:10px;font-weight:700;line-height:1}.cap-dot--idle{background:var(--paper-2)}.cap-dot--running{background:#fdefd8;color:#8a4f10}.cap-dot--ok{background:#d8eddf;color:#235c35}.cap-dot--err{background:#f5ddd8;color:#8d3f30}.cap-spinner{display:block;width:10px;height:10px;border:2px solid currentColor;border-top-color:transparent;border-radius:50%;animation:cap-spin .7s linear infinite}@keyframes cap-spin{to{transform:rotate(360deg)}}.capture-row-action{flex-shrink:0;background:none;border:0;cursor:pointer;color:var(--muted);width:28px;height:28px;display:grid;place-items:center;border-radius:var(--r);padding:0}.capture-row-action svg{width:12px;height:12px}.capture-row-action:hover{color:var(--accent);background:var(--paper-2)}.capture-row-remove:hover{color:var(--accent)}.capture-row-error{margin:0;padding-left:28px;font-size:12px;color:var(--accent);line-height:1.45}.capture-add-row{display:flex;align-items:center;gap:7px;width:100%;background:none;border:1px dashed var(--line);border-radius:var(--r);color:var(--muted);font-size:13px;cursor:pointer;padding:7px 12px;margin-top:4px;margin-bottom:18px;transition:border-color .15s,color .15s,background .15s}.capture-add-row svg{width:13px;height:13px;flex-shrink:0}.capture-add-row:hover{border-color:var(--accent-2);color:var(--ink);background:var(--paper-2)}.capture-add-row-group{display:flex;gap:8px;margin-top:4px;margin-bottom:18px}.capture-add-row-group .capture-add-row{margin-top:0;margin-bottom:0;flex:1}.capture-dialog--dragover .capture-dialog-inner{outline:2px dashed var(--accent);outline-offset:-6px;border-radius:var(--r3)}.capture-file-icon{flex-shrink:0;color:var(--muted);display:flex;align-items:center}.capture-file-name{flex:1;min-width:0;font-size:13.5px;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.capture-file-name--error{color:var(--accent)}.capture-file-progress-wrap{flex-shrink:0;display:flex;align-items:center;gap:6px}.capture-file-progress-bar{width:80px;height:4px;border-radius:2px;background:var(--paper-2);overflow:hidden;border:1px solid var(--line)}.capture-file-progress-fill{display:block;height:100%;background:var(--ink);border-radius:2px;transition:width .1s ease}.capture-file-progress-pct{font-size:11px;color:var(--muted);min-width:28px;text-align:right}.capture-file-badge{flex-shrink:0;width:18px;height:18px;border-radius:50%;display:grid;place-items:center;font-size:11px;font-weight:700;line-height:1}.capture-file-badge--done{background:#d8eddf;color:#235c35}.capture-file-badge--error{background:#f5ddd8;color:#8d3f30}.toast-stack{position:fixed;bottom:24px;right:24px;z-index:9000;display:flex;flex-direction:column;gap:10px;width:340px;max-width:calc(100vw - 32px);pointer-events:none}.toast{pointer-events:auto;background:var(--paper);border:1px solid var(--line);border-radius:var(--r2);box-shadow:0 6px 24px #141d182e,0 1px 4px #141d181a;overflow:hidden;animation:toast-slide-in .22s cubic-bezier(.22,.68,0,1.2)}@keyframes toast-slide-in{0%{opacity:0;transform:translateY(10px) scale(.97)}to{opacity:1;transform:translateY(0) scale(1)}}.toast--error{border-left:3px solid var(--accent)}.toast--warning{border-left:3px solid #e8a000}.toast--success{border-left:3px solid #22a855}.toast-top{display:flex;align-items:flex-start;gap:10px;padding:12px 12px 12px 14px}.toast-icon{flex-shrink:0;font-size:13px;font-weight:700;color:var(--accent);margin-top:2px}.toast--success .toast-icon{color:#22a855}.toast--warning .toast-icon{color:#e8a000}.toast-body{flex:1;min-width:0}.toast-headline{display:block;font-size:13.5px;font-weight:600;color:var(--ink);line-height:1.3}.toast-locator{display:block;font-size:12px;color:var(--muted);margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.toast-btns{display:flex;align-items:center;gap:6px;flex-shrink:0}.toast-view-btn{background:none;border:1px solid var(--line);color:var(--link);font-size:12px;cursor:pointer;padding:3px 9px;border-radius:var(--r);white-space:nowrap}.toast-view-btn:hover{background:var(--paper-2);border-color:var(--link)}.toast-dismiss{background:none;border:0;cursor:pointer;color:var(--muted);font-size:20px;line-height:1;width:24px;height:24px;display:grid;place-items:center;border-radius:var(--r);padding:0}.toast-dismiss:hover{color:var(--ink);background:var(--paper-2)}.toast-error-detail{margin:0;padding:10px 14px 12px;font-family:ui-monospace,SF Mono,Menlo,monospace;font-size:11.5px;line-height:1.55;color:var(--muted);background:var(--paper-2);border-top:1px solid var(--line);white-space:pre-wrap;word-break:break-word;max-height:200px;overflow-y:auto}.toast-warning-detail{margin:0;padding:10px 14px 12px;font-size:12px;line-height:1.55;color:var(--muted);background:var(--paper-2);border-top:1px solid var(--line);white-space:pre-wrap;word-break:break-word}.toast-ignore-btn{color:var(--muted)}.ext-toggle{flex-shrink:0;position:relative;width:44px;height:24px;border-radius:12px;background:var(--line);border:0;cursor:pointer;transition:background .18s ease;padding:0}.ext-toggle--sm{width:36px;height:20px;border-radius:10px}.ext-toggle--on{background:var(--ink)}.ext-toggle:disabled{opacity:.4;cursor:default}.ext-toggle-knob{position:absolute;top:3px;left:3px;width:18px;height:18px;border-radius:50%;background:var(--paper);transition:transform .18s ease;display:block;box-shadow:0 1px 3px #0003}.ext-toggle--sm .ext-toggle-knob{width:14px;height:14px;top:3px;left:3px}.ext-toggle--on .ext-toggle-knob{transform:translate(20px)}.ext-toggle--sm.ext-toggle--on .ext-toggle-knob{transform:translate(16px)}.ext-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:12px}.ext-card{border:1px solid var(--line);border-radius:var(--r2);padding:16px 18px;background:var(--paper)}.ext-card-header{display:flex;align-items:flex-start;justify-content:space-between;gap:16px}.ext-card-info{flex:1;min-width:0}.ext-card-name{display:block;font-weight:600;font-size:14px;margin-bottom:4px}.ext-card-desc{display:block;font-size:13px;color:var(--muted);line-height:1.5}.ext-card-hint{display:block;font-size:12px;color:#e8a000;margin-top:6px}.ext-card-hint code{font-size:11.5px}.form-hint{font-size:13px;color:var(--muted);line-height:1.55;margin:0}.muted{color:var(--muted)}.admin-view{padding:22px}.admin-list{display:grid;gap:10px;max-width:780px}.admin-archive{border:1px solid var(--line);background:var(--paper-3);padding:12px;border-radius:var(--r2)}.tag-tree{padding:20px 22px;max-width:320px}.tag-tree-header{display:flex;align-items:baseline;gap:10px;margin-bottom:14px;border-bottom:1px solid var(--line-soft);padding-bottom:10px}.tag-tree-title{font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.07em}.tag-tree-active{font-size:12px;color:var(--muted-2);font-style:italic}.tag-tree-list{list-style:none;margin:0;padding:0}.tag-children{padding-left:16px}.tag-node-btn{border:0;background:transparent;color:var(--link);cursor:pointer;padding:3px 0;text-align:left;font-size:13px;display:block;width:100%}.tag-node-btn:hover{text-decoration:underline}.tag-node-btn.is-active{font-weight:600;color:var(--ink)}.tag-node-row{display:flex;align-items:center;gap:4px}.tag-node-row .tag-node-btn{flex:1 1 0;min-width:0;display:flex;align-items:center;gap:4px;width:auto}.tag-node-btn .edit-icon{width:.75em;height:.75em;flex-shrink:0;vertical-align:-.1em;opacity:.35}.tag-node-btn:hover .edit-icon{opacity:.65}.tag-node-delete{flex-shrink:0;border:0;background:transparent;color:var(--muted-2);cursor:pointer;padding:0 3px;font-size:14px;line-height:1;opacity:0}.tag-node-row:hover .tag-node-delete{opacity:1}.tag-node-delete:hover{color:var(--accent)}.tag-rename-input{font-size:13px;flex:1 1 0;min-width:0;padding:2px 5px;border:1px solid var(--accent);border-radius:var(--r);background:var(--field);color:var(--ink);outline:none}.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}.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 .1s,color .1s}.tag-tree-action-btn:hover:not(:disabled){background:var(--paper-2);color:var(--ink)}.tag-tree-action-btn:disabled{opacity:.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)}.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-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:200;background:#00000073;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 #0000003d;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-view{padding:24px}.collections-heading{margin:0 0 16px;font-size:22px;font-family:var(--display);font-weight:600}.collections-error{color:var(--accent);font-size:13px;margin-bottom:8px}.coll-dismiss{background:none;border:none;cursor:pointer;color:var(--accent);font-size:16px;line-height:1;padding:0 4px}.collections-layout{display:grid;grid-template-columns:220px 1fr;gap:0;border:1px solid var(--line);border-radius:var(--r2);min-height:340px}.collections-sidebar{border-right:1px solid var(--line);overflow-y:auto}.coll-sidebar-row{display:flex;flex-direction:column;width:100%;padding:10px 14px;border:none;border-bottom:1px solid var(--line-soft);background:none;cursor:pointer;text-align:left;gap:2px}.coll-sidebar-row:hover{background:var(--paper-2)}.coll-sidebar-row.is-active{background:var(--paper-2);border-left:3px solid var(--accent)}.coll-row-name{font-size:14px;font-weight:600;color:var(--ink)}.coll-row-meta{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.04em}.coll-detail{padding:20px 24px;display:flex;flex-direction:column;gap:16px}.coll-detail--empty{justify-content:center;align-items:center}.coll-detail-header{display:flex;align-items:center;gap:12px}.coll-detail-name{margin:0;font-size:18px;font-family:var(--display);font-weight:600;flex:1}.coll-detail-name--editable{cursor:pointer}.coll-detail-name--editable:hover{color:var(--link)}.coll-edit-hint{font-size:14px;opacity:.5;margin-left:4px}.coll-rename-input{flex:1;font-size:16px;font-family:var(--display);border:1px solid var(--line);padding:4px 8px;background:var(--field);color:var(--ink);border-radius:var(--r)}.coll-delete-btn{border:1px solid var(--accent);background:none;color:var(--accent);padding:5px 12px;font-size:13px;cursor:pointer;border-radius:var(--r)}.coll-delete-btn:hover{background:var(--accent);color:var(--paper)}.coll-detail-vis{display:flex;align-items:center;gap:10px}.coll-vis-label{font-size:13px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;white-space:nowrap}.coll-vis-select{border:1px solid var(--line);background:var(--paper-3);color:var(--ink);padding:4px 8px;font-size:13px;border-radius:var(--r)}.coll-section-heading{font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin-bottom:8px}.coll-entries-section{flex:1}.coll-entries-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:2px}.coll-entry-row{display:flex;align-items:center;gap:10px;padding:7px 0;border-bottom:1px solid var(--line-soft)}.coll-entry-info{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}.coll-entry-title{font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.coll-entry-kind{font-size:11px;text-transform:uppercase;letter-spacing:.04em}.coll-entry-actions{display:flex;align-items:center;gap:6px;flex-shrink:0}.coll-entry-vis-select{border:1px solid var(--line);background:var(--paper-3);color:var(--ink);padding:3px 6px;font-size:12px;border-radius:var(--r)}.coll-entry-remove{border:none;background:none;cursor:pointer;color:var(--muted);font-size:18px;line-height:1;padding:0 4px}.coll-entry-remove:hover{color:var(--accent)}.coll-add-entry-form{border-top:1px solid var(--line-soft);padding-top:12px}.coll-add-entry-row{display:flex;gap:8px;align-items:center}.coll-add-entry-input{flex:1;border:1px solid var(--line);background:var(--paper-3);color:var(--ink);padding:6px 10px;font-size:13px;min-width:0;border-radius:var(--r)}.coll-add-btn{border:none;background:var(--ink);color:var(--paper);padding:6px 14px;font-size:13px;font-weight:600;cursor:pointer;white-space:nowrap;border-radius:var(--r)}.coll-add-btn:hover{opacity:.85}.coll-add-btn:disabled{opacity:.45;cursor:default}.rail-public-summary{padding:4px 0}.rail-public-summary .rail-title{margin:0 0 10px}.auth-loading{min-height:100vh;display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:15px;background:var(--paper)}.user-menu{display:flex;align-items:center;gap:14px;padding-left:6px;flex-shrink:0}.user-name{font-size:12.5px;color:var(--muted-2);letter-spacing:.01em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:140px}.logout-btn{border:1px solid rgba(255,255,255,.18);background:transparent;color:#c8bfb0;font-size:12px;padding:4px 11px;border-radius:var(--r);cursor:pointer;white-space:nowrap;transition:background .15s,color .15s}.logout-btn:hover{background:#ffffff1a;color:var(--paper)}.logout-btn:disabled{opacity:.5;cursor:default}.login-page,.setup-page{min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;background:var(--paper)}.login-card,.setup-card{width:100%;max-width:360px;padding:40px 36px 44px;background:var(--paper-3);border:1px solid var(--line);border-radius:var(--r3);box-shadow:0 2px 20px #141d1812}.login-brand,.setup-brand{font-family:var(--display);font-size:32px;font-weight:600;color:var(--ink);letter-spacing:-.01em;margin:0 0 6px}.login-tagline,.setup-tagline{font-size:13px;color:var(--muted);margin:0 0 28px}.login-field,.setup-field{display:flex;flex-direction:column;gap:5px;margin-bottom:14px}.login-label,.setup-label{font-size:11.5px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.06em}.login-input,.setup-input{width:100%;border:1px solid var(--line);background:var(--field);color:var(--ink);padding:9px 11px;font-size:14px;border-radius:var(--r2);outline:none;transition:border-color .15s}.login-input:focus,.setup-input:focus{border-color:var(--accent)}.login-error,.setup-error{font-size:13px;color:var(--accent);margin:4px 0 8px}.login-submit,.setup-submit{width:100%;margin-top:8px;border:none;background:var(--top);color:var(--paper);font-size:14px;font-weight:600;padding:11px 16px;border-radius:var(--r2);cursor:pointer;letter-spacing:.02em;transition:opacity .15s}.login-submit:hover,.setup-submit:hover{opacity:.85}.login-submit:disabled,.setup-submit:disabled{opacity:.45;cursor:default}.view-tabs{display:flex;gap:2px;margin-bottom:22px;border-bottom:1px solid var(--line-soft);padding-bottom:0}.view-tab{border:none;background:none;color:var(--muted);font-size:13px;font-weight:600;padding:7px 14px;cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px;letter-spacing:.02em;transition:color .12s;text-transform:uppercase;letter-spacing:.06em;font-size:11.5px}.view-tab:hover{color:var(--ink)}.view-tab.is-active{color:var(--ink);border-bottom-color:var(--accent)}.form-section{margin-bottom:32px}.form-section h2{font-size:14px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin:0 0 14px}.form-row{display:flex;gap:8px;margin-bottom:10px}.form-field{display:flex;flex-direction:column;gap:5px;margin-bottom:12px}.form-label{font-size:11.5px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.05em}.field-input{border:1px solid var(--line);background:var(--field);color:var(--ink);padding:8px 10px;font-size:13.5px;border-radius:var(--r);outline:none;transition:border-color .15s;min-width:0}.field-input:focus{border-color:var(--accent)}.field-input--flex{flex:1}.form-msg{font-size:13px;margin:6px 0}.form-msg--ok{color:var(--link)}.form-msg--err{color:var(--accent)}.btn-primary{border:none;background:var(--top);color:var(--paper);font-size:13px;font-weight:600;padding:8px 16px;border-radius:var(--r);cursor:pointer;white-space:nowrap}.btn-primary:hover{opacity:.85}.btn-primary:disabled{opacity:.45;cursor:default}.btn-ghost{border:1px solid var(--line);background:none;color:var(--ink);font-size:13px;padding:7px 14px;border-radius:var(--r);cursor:pointer}.btn-ghost:hover{background:var(--paper-2)}.btn-danger{border:1px solid var(--accent);background:none;color:var(--accent);font-size:13px;padding:7px 14px;border-radius:var(--r);cursor:pointer}.btn-danger:hover{background:var(--accent);color:var(--paper)}.admin-view h1{font-family:var(--display);font-weight:600;font-size:26px;margin:0 0 20px}.admin-table{width:100%;border-collapse:collapse;font-size:13px;margin-bottom:28px}.admin-table th{text-align:left;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);padding:8px 10px;border-bottom:1px solid var(--line);background:var(--paper-2)}.admin-table th:first-child{padding-left:16px}.admin-table td{padding:9px 10px;border-bottom:1px solid var(--line-soft);color:var(--ink);vertical-align:middle}.admin-table td:first-child{padding-left:16px}.admin-table tr:hover td{background:var(--paper-2)}.admin-row-disabled td{opacity:.45}.admin-section{margin-bottom:36px;max-width:860px}.admin-section h2{font-size:14px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin:0 0 14px}.admin-section h3{font-size:13px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin:22px 0 10px}.admin-form{display:flex;flex-direction:column;gap:8px;max-width:480px}.admin-input{border:1px solid var(--line);background:var(--field);color:var(--ink);padding:8px 10px;font-size:13.5px;border-radius:var(--r);outline:none;transition:border-color .15s}.admin-input:focus{border-color:var(--accent)}.admin-action-btn{border:1px solid var(--line);background:none;color:var(--ink);font-size:12px;padding:4px 10px;border-radius:var(--r);cursor:pointer;white-space:nowrap}.admin-action-btn:hover{border-color:var(--accent);color:var(--accent)}.status-badge{display:inline-block;padding:2px 8px;border-radius:var(--r);font-size:11.5px;font-weight:600;text-transform:uppercase;letter-spacing:.04em}.status-active{background:#d8eddf;color:#235c35;border:1px solid #b4d9be}.status-disabled{background:#ede8e0;color:var(--muted);border:1px solid var(--line)}.run-status{display:inline-block;padding:2px 8px;border-radius:var(--r);font-size:11.5px;font-weight:600;text-transform:uppercase;letter-spacing:.04em}.run-status--completed{background:#d8eddf;color:#235c35;border:1px solid #b4d9be}.run-status--failed{background:#f5ddd8;color:#8d3f30;border:1px solid #e0b8b0}.run-status--in-progress{background:#fdefd8;color:#8a4f10;border:1px solid #f0cfa0}.run-row--failed{cursor:pointer}.run-row--failed:hover td{background:#fdf0ed!important}.run-expand-hint{margin-left:6px;font-size:10px;color:var(--accent);vertical-align:middle}.run-error-row td{padding:0!important;background:var(--paper-2)!important}.run-error-detail{margin:0;padding:10px 16px 12px;font-family:ui-monospace,SF Mono,Menlo,monospace;font-size:11.5px;line-height:1.55;color:var(--muted);white-space:pre-wrap;word-break:break-word;max-height:220px;overflow-y:auto}.token-banner{background:#d8eddf;border:1px solid #b4d9be;padding:12px 14px;margin-bottom:16px;font-size:13px;border-radius:var(--r2)}.token-banner code{word-break:break-all;display:block;margin-top:6px;padding:6px 8px;background:var(--field);border:1px solid var(--line);border-radius:var(--r);font-size:12px;font-family:ui-monospace,SF Mono,Menlo,monospace}.token-dismiss{margin-top:8px;font-size:12px;border:1px solid var(--line);background:none;cursor:pointer;padding:3px 8px;border-radius:var(--r);color:var(--muted)}.token-dismiss:hover{background:var(--paper-2)}.token-row{display:flex;align-items:center;justify-content:space-between;border:1px solid var(--line);background:var(--paper-3);padding:10px 12px;border-radius:var(--r);margin-bottom:6px}.token-row-info strong{font-size:14px;display:block}.token-row-info .muted{font-size:12px;margin-top:2px}.token-create-row{display:flex;gap:8px;margin-bottom:16px}.checkbox-row{display:flex;align-items:center;gap:10px;margin-bottom:12px;cursor:pointer;font-size:14px}.checkbox-row input[type=checkbox]{width:15px;height:15px;accent-color:var(--accent)}.coll-create-details{margin-top:16px}.coll-create-details summary{font-weight:600;cursor:pointer;font-size:13px;color:var(--link);list-style:none;display:flex;align-items:center;gap:6px;padding:8px 12px;border:1px solid var(--line-soft);border-radius:var(--r2);background:var(--paper-3);width:fit-content}.coll-create-details summary:hover{background:var(--paper-2)}.coll-create-details[open] summary{border-bottom-left-radius:0;border-bottom-right-radius:0}.coll-create-form{border:1px solid var(--line-soft);border-top:none;padding:14px;border-radius:0 0 var(--r2) var(--r2);background:var(--paper-3);display:flex;flex-direction:column;gap:10px;max-width:440px}@media (max-width: 900px){.topbar{grid-template-columns:1fr auto;height:auto;min-height:58px;padding:12px}.switcher,.nav{grid-column:1 / -1}.nav{justify-content:flex-start;overflow-x:auto}.app-shell{height:auto;grid-template-columns:1fr}.context-rail{border-left:0;border-top:1px solid var(--line)}.entry-table{min-width:860px}}.rail-title--editable{cursor:pointer}.rail-title--editable:hover{opacity:.75}.rail-title--editable .edit-icon{display:inline-block;width:.75em;height:.75em;margin-left:.35em;vertical-align:middle;opacity:0;transition:opacity .1s;flex-shrink:0}.rail-title--editable:hover .edit-icon{opacity:.5}.rail-title-input{width:100%;font:inherit;font-size:inherit;font-weight:600;background:transparent;border:none;border-bottom:1px solid currentColor;color:inherit;padding:0;margin:0 0 8px;outline:none}.preview-panel{flex:1;display:flex;flex-direction:column;min-height:0}.preview-panel-loading,.preview-panel-empty{flex:1;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:8px;color:var(--muted-2);font-size:14px;text-align:center;padding:24px}.preview-video-wrap{flex:1;display:flex;align-items:center;justify-content:center;background:#0d0d0b;min-height:200px}.preview-video-wrap video{max-width:100%;max-height:100%;width:100%;display:block}.video-tv-controls{position:absolute;top:10px;right:10px;display:flex;gap:6px;z-index:10}.video-tv-btn{display:flex;align-items:center;justify-content:center;width:34px;height:34px;padding:0;border:none;border-radius:6px;background:#00000080;color:#ffffffd1;cursor:pointer;backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);transition:background .15s,color .15s}.video-tv-btn:hover{background:#000000b8;color:#fff}google-cast-launcher.video-tv-btn{color:#ffffffd1}.video-tv-loading{width:100%;min-height:200px;background:#0d0d0b}.preview-iframe-wrap{flex:1;display:flex;flex-direction:column;min-height:0}.preview-iframe-toolbar{display:flex;flex-direction:column;gap:2px;padding:6px 12px;background:var(--paper-2);border-bottom:1px solid var(--line);flex-shrink:0;text-transform:none;letter-spacing:normal}.preview-iframe-toolbar span{font-size:11px;color:var(--muted-2)}.preview-iframe-toolbar a{margin-left:auto;color:var(--link);font-size:12px;text-decoration:none;padding:3px 8px;border:1px solid var(--line);border-radius:var(--r2);background:var(--field);transition:border-color .12s}.preview-iframe-toolbar a:hover{border-color:var(--accent);text-decoration:none}.preview-iframe-wrap iframe{flex:1;width:100%;border:none;background:#fff;min-height:0}.preview-image-wrap{flex:1;display:flex;align-items:center;justify-content:center;padding:20px;background:var(--paper-2)}.preview-image-wrap img{max-width:100%;max-height:100%;object-fit:contain;border-radius:var(--r3);cursor:zoom-in;display:block;box-shadow:0 4px 24px #0000001a}.preview-tweet-wrap{flex:1;min-height:0;overflow-y:auto;padding:12px 16px;background:var(--paper-2);scrollbar-width:thin;scrollbar-color:var(--line) transparent}.preview-tweet-wrap::-webkit-scrollbar{width:8px}.preview-tweet-wrap::-webkit-scrollbar-track{background:transparent}.preview-tweet-wrap::-webkit-scrollbar-thumb{background:var(--line);border-radius:999px;border:2px solid transparent;background-clip:padding-box}.preview-tweet-wrap::-webkit-scrollbar-thumb:hover{background:var(--muted-2);background-clip:padding-box;border:2px solid transparent}.tw-card{border:1px solid var(--line);border-radius:12px;padding:16px;background:var(--paper);max-width:560px;margin:0 auto 12px}.tw-thread{max-width:560px;margin:0 auto}.tw-thread-item{display:flex;gap:0;margin-bottom:0}.tw-thread-connector{display:flex;flex-direction:column;align-items:center;flex-shrink:0;width:52px;padding-top:2px}.tw-thread-avatar-col{display:flex;flex-direction:column;align-items:center;flex-shrink:0}.tw-thread-line{width:2px;background:var(--line);flex:1;min-height:16px;margin:4px 0}.tw-thread-body{flex:1;min-width:0;padding:0 0 16px 12px}.tw-thread-body:last-child{padding-bottom:0}.tw-avatar{width:40px;height:40px;border-radius:50%;object-fit:cover;flex-shrink:0;display:block}.tw-avatar-ph{width:40px;height:40px;border-radius:50%;background:var(--paper-2);border:1px solid var(--line);flex-shrink:0}.tw-header{display:flex;align-items:baseline;gap:6px;flex-wrap:wrap;margin-bottom:6px}.tw-author-name{font-size:14.5px;font-weight:700;color:var(--ink)}.tw-author-handle{font-size:13px;color:var(--muted-2)}.tw-dot{color:var(--muted-2);font-size:13px}.tw-date{font-size:13px;color:var(--muted-2)}.tw-text{font-size:15px;line-height:1.65;color:var(--ink);white-space:pre-line;word-break:break-word;margin-bottom:10px}.tw-text a{color:var(--link);text-decoration:none}.tw-text a:hover{text-decoration:underline}.tw-stats{display:flex;gap:20px;color:var(--muted-2);font-size:13px;margin-top:10px;padding-top:10px;border-top:1px solid var(--line-soft)}.tw-stats span{display:flex;align-items:center;gap:4px}.tw-article{max-width:598px;margin:0 auto}.tw-article-cover{width:100%;display:block;object-fit:cover;max-height:380px}.tw-article-meta{padding:20px 16px 0}.tw-article-title{font-size:22px;font-weight:800;letter-spacing:-.3px;color:var(--ink);line-height:1.3;margin-bottom:14px}.tw-article-author-row{display:flex;align-items:center;gap:12px;margin-bottom:14px}.tw-article-author-name{font-size:14px;font-weight:700;color:var(--ink)}.tw-article-author-sub{font-size:13px;color:var(--muted-2)}.tw-article-divider{border:none;border-top:1px solid var(--line);margin:0}.tw-article-body{padding:8px 16px 60px}.tw-b-h1{font-size:24px;font-weight:800;letter-spacing:-.3px;color:var(--ink);line-height:1.25;margin:22px 0 10px}.tw-b-h2{font-size:19px;font-weight:700;letter-spacing:-.15px;color:var(--ink);line-height:1.3;margin:20px 0 8px}.tw-b-p{font-size:16px;color:var(--ink);line-height:1.72;margin-bottom:14px}.tw-b-p:empty{display:none}.tw-b-spacer{height:6px;display:block}.tw-b-blockquote{border-left:3px solid var(--line);padding:2px 14px;margin:14px 0;color:var(--muted);font-size:16px;line-height:1.65}.tw-b-hr{border:none;border-top:1px solid var(--line);margin:24px 0}.tw-b-img{width:100%;display:block;border-radius:10px;margin:14px 0}.tw-b-ul,.tw-b-ol{margin:10px 0 14px;padding-left:26px}.tw-b-li{font-size:16px;color:var(--ink);line-height:1.65;margin-bottom:6px}.tw-b-code{font-family:ui-monospace,SF Mono,Menlo,monospace;font-size:.875em;background:var(--paper-2);padding:2px 5px;border-radius:4px;color:var(--ink)}.tw-b-pre{background:var(--paper-2);border:1px solid var(--line);border-radius:8px;padding:14px 16px;overflow-x:auto;margin:12px 0}.tw-b-pre code{font-family:ui-monospace,SF Mono,Menlo,monospace;font-size:13px;line-height:1.6}.tw-b-tweet-link{display:flex;align-items:center;gap:10px;border:1px solid var(--line);border-radius:10px;padding:12px 14px;margin:12px 0;color:var(--muted);font-size:14px;text-decoration:none;background:var(--paper-3);transition:background .12s}.tw-b-tweet-link:hover{background:var(--field)}.tw-b-tweet-link svg{flex-shrink:0;color:var(--ink)}.audio-bar{position:fixed;bottom:0;left:0;right:0;height:72px;background:var(--paper);border-top:1px solid var(--line);display:flex;align-items:center;gap:20px;padding:0 24px;z-index:100;box-shadow:0 -2px 16px #00000014}.audio-bar-info{display:flex;align-items:center;gap:10px;min-width:0;flex:0 0 200px}.audio-bar-icon{flex-shrink:0;width:20px;height:20px;display:flex;align-items:center;justify-content:center}.audio-bar-icon svg{width:20px;height:20px}.audio-bar-title{font-size:13px;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;font-weight:500}.audio-bar-controls{flex:1;display:flex;align-items:center;gap:12px;min-width:0}.audio-bar-play-btn{width:38px;height:38px;border-radius:50%;background:var(--accent);color:var(--paper);border:none;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:background .12s,transform .1s}.audio-bar-play-btn:hover{background:var(--accent-2)}.audio-bar-play-btn:active{transform:scale(.93)}.audio-bar-play-btn svg{width:15px;height:15px}.audio-bar-seek{flex:1;min-width:0;display:flex;align-items:center;gap:8px}.audio-bar-seek input[type=range]{flex:1;accent-color:var(--accent);height:4px;cursor:pointer;min-width:0}.audio-bar-time{font-size:11.5px;color:var(--muted-2);white-space:nowrap;min-width:80px;text-align:right;font-variant-numeric:tabular-nums}.audio-bar-right{flex:0 0 auto;display:flex;align-items:center;gap:10px}.audio-bar-volume{display:flex;align-items:center;gap:6px}.audio-bar-volume svg{width:14px;height:14px;color:var(--muted-2);flex-shrink:0}.audio-bar-volume input[type=range]{width:72px;accent-color:var(--accent);cursor:pointer}.audio-bar-close{width:28px;height:28px;border-radius:50%;border:none;background:transparent;color:var(--muted-2);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:18px;padding:0;line-height:1;transition:background .12s,color .12s}.audio-bar-close:hover{background:var(--field);color:var(--ink)}.preview-modal-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:200;background:#0000008c;display:flex;align-items:center;justify-content:center;padding:20px}.preview-modal{background:var(--paper);border-radius:12px;box-shadow:0 24px 80px #00000047;width:90vw;max-width:1100px;max-height:88vh;display:flex;flex-direction:column;overflow:hidden}.preview-modal--full{height:88vh}.preview-modal--full .preview-modal-body{max-height:none}.preview-modal-header{display:flex;align-items:center;padding:14px 18px;border-bottom:1px solid var(--line);flex-shrink:0;gap:12px}.preview-modal-title{flex:1;min-width:0;font-size:14px;font-weight:600;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.preview-modal-close{width:30px;height:30px;border-radius:50%;border:none;background:transparent;color:var(--muted-2);font-size:20px;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:background .12s,color .12s}.preview-modal-close:hover{background:var(--field);color:var(--ink)}.preview-modal-body{flex:1;min-height:0;overflow:hidden;display:flex;flex-direction:column;max-height:calc(88vh - 52px)}.rail-preview-btn{display:block;width:100%;padding:8px 12px;margin-bottom:16px;background:var(--accent);color:var(--paper);border:none;border-radius:var(--r3);font-size:13px;font-weight:600;cursor:pointer;text-align:center;transition:background .12s}.rail-preview-btn:hover{background:var(--accent-2)}.preview-modal-newtab{display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:var(--r2);color:var(--muted-2);text-decoration:none;font-size:16px;flex-shrink:0;transition:background .12s,color .12s}.preview-modal-newtab:hover{background:var(--field);color:var(--ink)}body.has-audio-bar{padding-bottom:56px}@keyframes skeleton-shimmer{0%{background-position:200% center}to{background-position:-200% center}}.skeleton-cell{display:inline-block;border-radius:3px;background:linear-gradient(90deg,var(--paper-2) 25%,var(--line-soft) 50%,var(--paper-2) 75%);background-size:200% 100%;animation:skeleton-shimmer 1.8s ease-in-out infinite}.skeleton-row{display:flex;align-items:center;background:var(--paper-3)}.skeleton-row>div{padding:7px 10px;flex-shrink:0;overflow:hidden}.skeleton-row .col-added{padding-left:22px}.skeleton-row>div:last-child{padding-right:22px}@media (pointer: coarse){.skeleton-row .col-added{padding-left:10px}}#entries-body>.entry-row-outer{display:block;border-bottom:none}#entries-body>.entry-row-outer>.entry-row-main{display:flex;align-items:center;cursor:default;border-bottom:1px solid var(--line-soft);padding:0;flex-shrink:unset;overflow:visible}#entries-body>.entry-row-outer>.entry-row-main>div{padding:7px 10px;flex-shrink:0;overflow:hidden}#entries-body>.entry-row-outer>.entry-row-main>div:last-child{padding-right:22px}#entries-body>.entry-row-outer>.entry-row-main .col-added{padding-left:22px}#entries-body>.entry-row-outer.is-selected{background:unset;outline:none;box-shadow:none}#entries-body>.entry-row-outer.is-multi-selected{background:unset;outline:none;box-shadow:none}#entries-body>.entry-row-outer.is-selected>.entry-row-main{background:#eee2d2;box-shadow:inset 0 0 0 2px var(--accent)}#entries-body>.entry-row-outer.is-multi-selected>.entry-row-main{background:#eee2d2;box-shadow:inset 0 0 0 2px var(--accent)}#entries-body>.entry-row-outer .url-cell:hover{overflow:visible;white-space:normal}#entries-body>.entry-row-outer>.child-entries{display:block;padding:0;flex-shrink:unset;overflow:visible;border-left:2px solid var(--line-soft);margin-left:32px}.child-entry-row{display:flex;align-items:center;cursor:default;border-bottom:1px solid var(--line-soft);opacity:.88}.child-entry-row:hover{opacity:1}.child-entry-row:last-child{border-bottom:none}.child-entry-row--light{background:#fafaf8}.child-entry-row--dark{background:#f2f0ec}.child-entry-row.is-selected,.child-entry-row.is-multi-selected{background:#eee2d2;box-shadow:inset 0 0 0 2px var(--accent);opacity:1}.child-entry-row>div{padding:6px 10px;flex-shrink:0;overflow:hidden}.child-entry-row .col-added{padding-left:22px}.child-entry-row>div:last-child{padding-right:22px}.entry-expand-btn{display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;flex-shrink:0;padding:0;margin-right:2px;background:none;border:none;cursor:pointer;opacity:.45;color:inherit;transition:opacity .15s}.entry-expand-btn:focus{outline:none}.entry-expand-btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:2px}.entry-expand-btn:before{content:"";display:block;width:0;height:0;border-top:4px solid transparent;border-bottom:4px solid transparent;border-left:6px solid currentColor;transition:transform .15s}.entry-expand-btn:hover{opacity:1}.entry-expand-btn.is-expanded:before{transform:rotate(90deg)}.child-count-badge{display:inline-block;margin-left:5px;padding:0 5px;font-size:.72em;font-weight:600;background:color-mix(in srgb,var(--line-soft) 60%,transparent);border-radius:10px;opacity:.75;vertical-align:middle;line-height:1.6}.child-entries-loading{padding:8px 12px;font-size:.85em;opacity:.55}.capture-playlist-toggle{display:inline-flex;align-items:center;gap:4px;font-size:.75rem;color:var(--muted);background:none;border:none;cursor:pointer;padding:2px 6px}.capture-playlist-toggle:hover{color:var(--text)}.capture-playlist-toggle--left{flex-shrink:0;padding:2px 4px;font-size:.7rem}.capture-conflict-badge{font-size:.7rem;color:#c07000;background:#fff3cd;border:1px solid #e0a000;border-radius:4px;padding:1px 6px;margin-left:6px}.capture-playlist-items{border-top:1px solid var(--line-soft);max-height:320px;overflow-y:auto;scrollbar-width:thin;scrollbar-color:var(--line) transparent}.capture-playlist-item{display:flex;align-items:center;gap:8px;padding:5px 10px 5px 14px;font-size:.8rem;border-bottom:1px solid var(--line-soft);border-left:3px solid transparent}.capture-playlist-item:last-of-type{border-bottom:none}.capture-playlist-item--conflict{border-left-color:#c07000;background:color-mix(in srgb,#c07000 6%,transparent)}.capture-playlist-item-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text);opacity:.9}.capture-item-quality{font-size:.73rem;padding:2px 4px;border:1px solid var(--line);border-radius:3px;background:var(--paper-3);color:var(--text);flex-shrink:0}.capture-playlist-conflict-badge{font-size:.68rem;font-weight:600;color:#c07000;white-space:nowrap;flex-shrink:0}.capture-playlist-item-remove{flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;padding:0;border:none;background:none;color:var(--muted);cursor:pointer;border-radius:3px;opacity:.3;transition:opacity .1s,color .1s}.capture-playlist-item:hover .capture-playlist-item-remove,.capture-playlist-item-remove:focus-visible{opacity:1}.capture-playlist-item-remove:hover{color:#c04000;opacity:1}@media (pointer: coarse){.capture-playlist-item-remove{opacity:1}}.capture-playlist-item-remove{font-size:.75rem;line-height:1}.capture-sync-row{display:flex;align-items:center;gap:6px;padding:6px 12px;font-size:.78rem;color:var(--muted);border-top:1px solid var(--line-soft);cursor:pointer}.capture-sync-row input[type=checkbox]{cursor:pointer} diff --git a/crates/archivr-server/static/index.html b/crates/archivr-server/static/index.html index 210c92a..a8b7f0e 100644 --- a/crates/archivr-server/static/index.html +++ b/crates/archivr-server/static/index.html @@ -6,8 +6,8 @@ Archivr - - + +
diff --git a/docker-compose.yml b/docker-compose.yml index d38ed2a..017cfb9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,12 @@ services: # Uncomment and set this to enable Twitter/X archiving. # The file must be accessible inside the container (e.g. in the config volume). # ARCHIVR_TWITTER_CREDENTIALS_FILE: /config/twitter-cookies.txt + # Optional local transcription for YouTube summaries without subtitles. + # Engines are not bundled: install them in a derived image or mount them. + # ARCHIVR_TRANSCRIBE_ENGINES: "whisper,phonon2" + # ARCHIVR_WHISPER_CLI: /models/bin/whisper-cli + # ARCHIVR_WHISPER_MODEL: /models/ggml-large-v3-turbo.bin + # ARCHIVR_PHONON2_CLI: /opt/transcribe/bin/fermion volumes: # Mount a directory containing archivr-server.toml as read-only config. # Copy docker/config.example.toml to ./config/archivr-server.toml to start. @@ -18,6 +24,8 @@ services: # Persistent volume for the auth database and archive directories. # The paths inside must match archive_path values in your TOML config. - archivr-data:/data + # Optional read-only model directory for local transcription engines. + # - ./models:/models:ro volumes: archivr-data: diff --git a/docs/README.md b/docs/README.md index 4866c2b..37233f0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -25,9 +25,15 @@ Archivr is a self-hosted tool for capturing and preserving digital content — Y - [Supported Inputs](#supported-inputs) - [YouTube playlists and channels](#youtube-playlists-and-channels) - [Video quality and audio-only](#video-quality-and-audio-only) + - [YouTube subtitles](#youtube-subtitles) + - [Text notes](#text-notes) - [Configuration](#configuration) - [TOML config file](#toml-config-file) - [Environment variables](#environment-variables) + - [LLM providers](#llm-providers) +- [Keeping yt-dlp and its JS runtime fresh](#keeping-yt-dlp-and-its-js-runtime-fresh) + - [JavaScript runtime (Deno)](#javascript-runtime-deno) + - [Troubleshooting](#troubleshooting) - [Deployment](#deployment) - [Security](#security) - [NixOS](#hosting-on-nixos) @@ -37,14 +43,17 @@ Archivr is a self-hosted tool for capturing and preserving digital content — Y ## Features -- **Social media** — YouTube (videos, shorts, playlists, channels with sync mode), X/Twitter (tweet and thread JSON + media downloads), Instagram, TikTok, Facebook, Reddit, Snapchat via yt-dlp +- **Social media** — YouTube (videos, shorts, playlists, channels with sync mode; subtitles saved with each video by default), X/Twitter (tweet and thread JSON + media downloads), Instagram, TikTok, Facebook, Reddit, Snapchat via yt-dlp - **Web pages** — full self-contained HTML snapshots via SingleFile + Chromium; optional Freedium mirror for paywalled articles; reader mode - **Local files** — import any file from disk by `file://` path - **Deduplication** — SHA3-256 content-addressed blob store shared across all captures; identical files are stored once -- **Tags and search** — hierarchical tag tree, full-text search, filterable entry list +- **Tags and search** — hierarchical tag tree, full-text search (including the latest completed summary and its generated JSON tags), filterable entry list - **Multiple archives** — the server mounts any number of separate archives from a single TOML config -- **Role-based auth** — Guest / User / Admin / Owner roles; session cookies and API tokens; Argon2 passwords +- **Role-based auth** — Guest / User / Admin / Owner roles; session cookies and API tokens; Argon2 passwords; the Owner can choose which roles (including custom ones) may reorder child entries - **Quality selection** — choose video quality or audio-only per capture; a live metadata probe populates the selector before download +- **LLM summaries** — regenerable per-entry summary via the Anthropic HTTP API, an OpenAI-compatible HTTP API, a local `claude` CLI, or a local `codex` CLI; triggered manually from the entry rail, never automatically on capture; text-only by default, with an explicit `Include attached images` option; YouTube videos are summarized from their subtitles +- **Text notes** — capture a plain-text or Markdown note with a title and no URL; the byte-preserving note is stored as a normal deduplicated blob and opens in the usual entry-rail preview +- **In-progress capture indicator** — running captures appear as a compact spinner row in the entries list until they finish, replacing the earlier grey skeleton block ## Quick Start @@ -134,12 +143,28 @@ A separate auth database (`archivr-auth.sqlite`, path set in TOML) holds users, | Snapchat | Direct URL · `snapchat:ID` | | Arbitrary URL / web page | Any `https://` URL | +X Articles are titled `
— @handle` from the article's own title instead of the tweet text (usually a +bare `t.co` link). Article entries archived before this change are retitled on the next `archivr-server` start, but only +while their title still equals the old auto-generated bare-link title; a renamed entry is left alone. Titles have no +"edited" flag, so an entry you renamed back to exactly that old title is retitled too. CLI-only installs never run this +pass — it runs at server startup only. + +X threads keep `Thread by @handle` at capture. **Generate title** in the entry rail (thread entries, user role and up) +asks the selected Summary provider's cheap title model for a short topic and saves `Thread about — @handle` as +the entry title (`POST /api/archives/:id/entries/:uid/thread-title`, body `{"provider": ""}`); rename it like any +title. With ≥2 entries selected and at least one X thread, the bulk panel's **Generate titles** does the same for each +selected thread (non-threads skipped; uses the Summary provider selected in the rail), two at a time, with progress and +an `X updated, Y failed` summary; changing the selection stops it picking up further entries. Models are listed under +[LLM providers](#llm-providers). + ### YouTube playlists and channels Capturing a playlist or channel creates a **container entry** with each video archived as a child beneath it. Before downloading, the UI probes each video for available quality options — set quality per-video or apply one to the whole batch. Individual videos can be excluded with the remove button. **Sync mode:** when re-archiving a playlist or channel, enable sync mode in the capture dialog to skip videos that are already in the archive. Only new videos are downloaded; the existing container is reused. +**Reordering:** if your role is allowed (by default Admin and Owner), expand a container on the main page and drag a video by its handle to change its position. On touch screens and phone-sized windows, where dragging isn't available, ↑/↓ buttons appear instead. Alt+↑/↓ on a selected row works everywhere. The order is saved to the archive. Videos added later by sync mode appear at the end. The Owner chooses which roles, including custom roles, may reorder under **Settings → Instance → Permissions**. + ### Video quality and audio-only When capturing a yt-dlp-backed source through the web UI, a metadata probe runs first and populates the quality selector with heights actually available in that video: @@ -161,6 +186,105 @@ The `POST /api/archives/:id/captures` endpoint accepts an optional `quality` fie `"audio"` selects the most efficient native audio track without re-encoding (Opus/WebM preferred, then AAC/M4A). Omitting `quality` or passing `"best"` downloads at the highest available quality. +### YouTube subtitles + +YouTube video captures save subtitles next to the video by default: single videos, shorts, and every video archived +from a YouTube playlist or channel, at any quality including audio-only. Subtitles apply to YouTube videos only — +YouTube Music, Spotify, X, TikTok, and the other yt-dlp sources are downloaded without them. + +At most two tracks are saved, chosen from the metadata yt-dlp already fetches for the capture: + +- English, plus the video's original language when that isn't English. +- Manual (uploader-provided) tracks are preferred. If the original language has no manual track, its auto-generated + track is used; auto-generated English is used only when nothing else was found. +- If the metadata probe fails, yt-dlp is asked for `en` and any `-orig` (original-language) track instead. + +Tracks are requested as VTT, with SRT accepted; nothing is converted, and other subtitle formats are dropped. Each file +goes through the usual SHA3-256 dedup into `store/raw/` and is recorded as a `subtitle` artifact of the entry, along +with its language, whether it was manual or auto-generated, and its format. + +Subtitle failures never fail a capture. Subtitles are requested in the same yt-dlp call as the media with +`--ignore-errors`, so a missing track or a rate-limited caption request only logs a warning. If that call still fails, +the media is retried once without subtitles. + +Subtitles are on unless you turn them off for a capture: + +- **Web UI:** the **Download subtitles** toggle in the capture dialog. +- **API:** `"download_subtitles": false` in the `POST /api/archives/:id/captures` body. Omitting the field means `true`. +- **CLI:** `archivr archive --no-subtitles `. + +Videos captured without subtitles can still be summarized; see [LLM providers](#llm-providers) and +[Local transcription](#local-transcription-optional). + +#### Local transcription (optional) + +When a YouTube video has no subtitles at summary time, Archivr can transcribe its audio on the server. The order is +fixed and transcription never runs if an earlier step yields usable subtitles: + +1. archived subtitles; +2. subtitles fetched from the original video (subtitles only, no media); +3. local transcription with the engine chosen in the Summary panel; +4. the no-subtitles error (or a transcription-specific error if step 3 ran and failed). + +The feature is off until `ARCHIVR_TRANSCRIBE_ENGINES` lists at least one configured engine (env vars in +[Local transcription env](#local-transcription)). Then the Summary panel shows a second selector on YouTube videos — +**No local transcription** (default) or an enabled engine — remembered for the browser session. The API field is +`"transcribe_engine": ""` in the summary POST body; an unknown or unconfigured engine is a 400. Enabled engines +are listed by `GET /api/summary/transcription-engines`. + +| Engine (`kind`) | Languages | Runs on | Install | +|---|---|---|---| +| Whisper (`whisper`) | ~99 (`*.en` models English only) | CPU, Metal, CUDA/Vulkan | whisper.cpp `whisper-cli` + a ggml model (default backend), or a faster-whisper wrapper (`script` backend) | +| NVIDIA Parakeet (`parakeet`) | v2 English; v3 25 European | NVIDIA GPU (NeMo), Apple silicon (parakeet-mlx), CPU (ONNX) | your own wrapper script; weights CC-BY-4.0 | +| Fermion Phonon-2 (`phonon2`) | **English only** (hard-coded) | Apple silicon (MLX), x86-64/Arm CPU, CUDA | `pip install fermion-research` + platform runtime; weights CC-BY-4.0, CLI licence unknown | + +Setup examples: + +```sh +# whisper.cpp +nix shell nixpkgs#whisper-cpp +curl -LO https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo.bin +export ARCHIVR_TRANSCRIBE_ENGINES=whisper ARCHIVR_WHISPER_MODEL=$PWD/ggml-large-v3-turbo.bin + +# Parakeet via parakeet-mlx (Apple silicon), wrapper from spec Appendix A.3 +export ARCHIVR_TRANSCRIBE_ENGINES=parakeet ARCHIVR_PARAKEET_CLI=/opt/transcribe/parakeet.py + +# Phonon-2 (vendor package; Apple silicon shown) +python3 -m venv /opt/transcribe && /opt/transcribe/bin/pip install fermion-research mlx mlx-audio mlx-lm soundfile scipy zstandard +export ARCHIVR_TRANSCRIBE_ENGINES=phonon2 ARCHIVR_PHONON2_CLI=/opt/transcribe/bin/fermion +``` + +**Script contract** (Whisper `script` backend and Parakeet). Archivr runs +`