From 325358bc0f8988fb290f125df3c43f0faa143da6 Mon Sep 17 00:00:00 2001 From: TheGeneralist <180094941+thegeneralist01@users.noreply.github.com> Date: Thu, 4 Jun 2026 16:51:59 +0200 Subject: [PATCH] docs: remove private superpowers plans --- .../plans/2026-06-01-web-ui-foundation.md | 2159 ----------------- .../specs/2026-06-01-web-ui-design.md | 235 -- 2 files changed, 2394 deletions(-) delete mode 100644 docs/superpowers/plans/2026-06-01-web-ui-foundation.md delete mode 100644 docs/superpowers/specs/2026-06-01-web-ui-design.md diff --git a/docs/superpowers/plans/2026-06-01-web-ui-foundation.md b/docs/superpowers/plans/2026-06-01-web-ui-foundation.md deleted file mode 100644 index d2cf223..0000000 --- a/docs/superpowers/plans/2026-06-01-web-ui-foundation.md +++ /dev/null @@ -1,2159 +0,0 @@ -# Archivr Web UI Foundation Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Refactor Archivr into a workspace with reusable core archive logic, rename taxonomy concepts to hierarchical tags, and add the first multi-archive web server/UI foundation. - -**Architecture:** Split the current single binary into `archivr-core`, `archivr-cli`, and `archivr-server`. Keep every mounted archive self-contained with its own `.archivr/archivr.sqlite`, and give the server a separate registry config for mounting many archives. The first UI renders the approved dense archive table, a contextual right rail, a runs view, and a secondary capture button. - -**Tech Stack:** Rust 2024 workspace, `rusqlite` for archive DBs, `axum`/`tokio` for HTTP, `tower-http` for static assets, vanilla HTML/CSS/JS for the first web UI, `serde`/`toml` for server registry config. - ---- - -## Scope And Sequencing - -This plan intentionally separates the foundational refactor from the first web UI slice: - -1. Create a workspace while keeping CLI behavior intact. -2. Extract reusable archive logic into `archivr-core`. -3. Rename taxonomy schema/API/tests to hierarchical tags. -4. Add archive query APIs needed by the server. -5. Add `archivr-server` with a multi-archive registry. -6. Add server API routes. -7. Add the first static web UI. -8. Verify with unit tests, API smoke tests, and browser inspection. - -Do not implement full OP search, production auth/session handling, browser capture flow, public publishing UI, full tag management UI, or final row-click/open behavior in this plan. - -## File Structure - -Create this workspace structure: - -```text -Cargo.toml -Cargo.lock -crates/ - archivr-core/ - Cargo.toml - src/ - lib.rs - archive.rs - database.rs - hash.rs - twitter.rs - downloader/ - mod.rs - local.rs - store.rs - tweets.rs - ytdlp.rs - archivr-cli/ - Cargo.toml - src/ - main.rs - archivr-server/ - Cargo.toml - src/ - main.rs - registry.rs - routes.rs - static/ - index.html - styles.css - app.js -docs/ - superpowers/ - specs/ - 2026-06-01-web-ui-design.md - plans/ - 2026-06-01-web-ui-foundation.md -``` - -Responsibilities: - -- `archivr-core/src/archive.rs`: archive directory discovery/opening, archive metadata, store path reading, entry/run query APIs. -- `archivr-core/src/database.rs`: SQLite schema and domain operations, including hierarchical tags. -- `archivr-core/src/downloader/*`: existing archive download/store behavior. -- `archivr-cli/src/main.rs`: command-line parsing and terminal behavior only. -- `archivr-server/src/registry.rs`: server-owned mounted archive registry config. -- `archivr-server/src/routes.rs`: API and static route construction. -- `archivr-server/static/*`: first browser UI. - -## Task 1: Convert To A Workspace With CLI Crate - -**Files:** -- Modify: `Cargo.toml` -- Create: `crates/archivr-cli/Cargo.toml` -- Move: `src/main.rs` -> `crates/archivr-cli/src/main.rs` -- Move: `src/database.rs` -> `crates/archivr-cli/src/database.rs` -- Move: `src/hash.rs` -> `crates/archivr-cli/src/hash.rs` -- Move: `src/twitter.rs` -> `crates/archivr-cli/src/twitter.rs` -- Move: `src/downloader/` -> `crates/archivr-cli/src/downloader/` - -- [ ] **Step 1: Record the current baseline** - -Run: - -```bash -cargo test -``` - -Expected: all current tests pass. - -- [ ] **Step 2: Replace root manifest with a workspace manifest** - -Replace `Cargo.toml` with: - -```toml -[workspace] -members = [ - "crates/archivr-cli", -] -resolver = "2" - -[workspace.package] -version = "0.1.0" -edition = "2024" - -[workspace.dependencies] -anyhow = "1.0.100" -chrono = "0.4.42" -clap = { version = "4.5.48", features = ["derive"] } -hex = "0.4.3" -regex = "1.12.2" -rusqlite = { version = "0.32.1", features = ["bundled"] } -serde_json = "1.0.132" -sha3 = "0.10.8" -uuid = { version = "1.18.1", features = ["v4"] } -``` - -- [ ] **Step 3: Create the CLI crate manifest** - -Create `crates/archivr-cli/Cargo.toml`: - -```toml -[package] -name = "archivr-cli" -version.workspace = true -edition.workspace = true - -[[bin]] -name = "archivr" -path = "src/main.rs" - -[dependencies] -anyhow.workspace = true -chrono.workspace = true -clap.workspace = true -hex.workspace = true -regex.workspace = true -rusqlite.workspace = true -serde_json.workspace = true -sha3.workspace = true -uuid.workspace = true -``` - -- [ ] **Step 4: Move current source files into the CLI crate** - -Run: - -```bash -mkdir -p crates/archivr-cli/src -git mv src/main.rs crates/archivr-cli/src/main.rs -git mv src/database.rs crates/archivr-cli/src/database.rs -git mv src/hash.rs crates/archivr-cli/src/hash.rs -git mv src/twitter.rs crates/archivr-cli/src/twitter.rs -git mv src/downloader crates/archivr-cli/src/downloader -``` - -- [ ] **Step 5: Verify the CLI crate still builds and tests** - -Run: - -```bash -cargo test -p archivr-cli -``` - -Expected: all moved tests pass. - -- [ ] **Step 6: Commit the workspace conversion** - -```bash -git add Cargo.toml Cargo.lock crates/archivr-cli -git commit -m "chore: move cli into workspace crate" -``` - -## Task 2: Extract Core Crate - -**Files:** -- Modify: `Cargo.toml` -- Create: `crates/archivr-core/Cargo.toml` -- Create: `crates/archivr-core/src/lib.rs` -- Move: `crates/archivr-cli/src/database.rs` -> `crates/archivr-core/src/database.rs` -- Move: `crates/archivr-cli/src/hash.rs` -> `crates/archivr-core/src/hash.rs` -- Move: `crates/archivr-cli/src/twitter.rs` -> `crates/archivr-core/src/twitter.rs` -- Move: `crates/archivr-cli/src/downloader/` -> `crates/archivr-core/src/downloader/` -- Modify: `crates/archivr-cli/src/main.rs` - -- [ ] **Step 1: Add `archivr-core` to the workspace** - -Update root `Cargo.toml`: - -```toml -[workspace] -members = [ - "crates/archivr-core", - "crates/archivr-cli", -] -resolver = "2" -``` - -Keep the existing `[workspace.package]` and `[workspace.dependencies]` sections unchanged. - -- [ ] **Step 2: Create the core crate manifest** - -Create `crates/archivr-core/Cargo.toml`: - -```toml -[package] -name = "archivr-core" -version.workspace = true -edition.workspace = true - -[dependencies] -anyhow.workspace = true -chrono.workspace = true -hex.workspace = true -regex.workspace = true -rusqlite.workspace = true -serde_json.workspace = true -sha3.workspace = true -uuid.workspace = true -``` - -- [ ] **Step 3: Add the core crate as a CLI dependency** - -Modify `crates/archivr-cli/Cargo.toml`: - -```toml -[dependencies] -archivr-core = { path = "../archivr-core" } -anyhow.workspace = true -chrono.workspace = true -clap.workspace = true -regex.workspace = true -rusqlite.workspace = true -serde_json.workspace = true -``` - -Remove `hex`, `sha3`, and `uuid` from `archivr-cli` dependencies after moving the modules that use them into core. - -- [ ] **Step 4: Move reusable modules into core** - -Run: - -```bash -mkdir -p crates/archivr-core/src -git mv crates/archivr-cli/src/database.rs crates/archivr-core/src/database.rs -git mv crates/archivr-cli/src/hash.rs crates/archivr-core/src/hash.rs -git mv crates/archivr-cli/src/twitter.rs crates/archivr-core/src/twitter.rs -git mv crates/archivr-cli/src/downloader crates/archivr-core/src/downloader -``` - -- [ ] **Step 5: Create `archivr-core` module exports** - -Create `crates/archivr-core/src/lib.rs`: - -```rust -pub mod database; -pub mod downloader; -pub mod hash; -pub mod twitter; -``` - -- [ ] **Step 6: Update core module imports** - -In `crates/archivr-core/src/downloader/tweets.rs`, replace: - -```rust -use crate::twitter::parse_tweet_id; - -use super::store; -``` - -with: - -```rust -use crate::{downloader::store, twitter::parse_tweet_id}; -``` - -Confirm `crates/archivr-core/src/downloader/local.rs`, `store.rs`, and `ytdlp.rs` still refer to `crate::hash::hash_file`. - -- [ ] **Step 7: Update CLI imports** - -In `crates/archivr-cli/src/main.rs`, remove: - -```rust -mod database; -mod downloader; -mod hash; -mod twitter; -``` - -Add: - -```rust -use archivr_core::{ - database, downloader, - twitter::parse_tweet_id, -}; -``` - -Remove the old line: - -```rust -use crate::twitter::parse_tweet_id; -``` - -- [ ] **Step 8: Verify core and CLI tests** - -Run: - -```bash -cargo test -p archivr-core -cargo test -p archivr-cli -``` - -Expected: core database/downloader/hash/twitter tests pass under `archivr-core`; CLI source-detection and initialization tests pass under `archivr-cli`. - -- [ ] **Step 9: Commit the core extraction** - -```bash -git add Cargo.toml Cargo.lock crates/archivr-core crates/archivr-cli -git commit -m "refactor: extract archive core crate" -``` - -## Task 3: Add Archive Opening APIs To Core - -**Files:** -- Create: `crates/archivr-core/src/archive.rs` -- Modify: `crates/archivr-core/src/lib.rs` -- Modify: `crates/archivr-cli/src/main.rs` - -- [ ] **Step 1: Write core archive API tests** - -Create `crates/archivr-core/src/archive.rs` with tests first: - -```rust -use anyhow::{Context, Result, bail}; -use std::{ - env, fs, - path::{Path, PathBuf}, -}; - -use crate::database; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ArchivePaths { - pub archive_path: PathBuf, - pub store_path: PathBuf, - pub name: String, -} - -#[cfg(test)] -mod tests { - use super::*; - use std::time::{SystemTime, UNIX_EPOCH}; - - fn unique_path(prefix: &str) -> PathBuf { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - env::temp_dir().join(format!("{prefix}-{nanos}-{}", std::process::id())) - } - - #[test] - fn find_archive_path_walks_up_to_dot_archivr() { - let root = unique_path("archivr-core-find"); - let nested = root.join("a").join("b"); - fs::create_dir_all(root.join(".archivr")).unwrap(); - fs::create_dir_all(&nested).unwrap(); - - let found = find_archive_path_from(&nested).unwrap().unwrap(); - - assert_eq!(found, root.join(".archivr")); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn read_archive_paths_returns_name_and_store_path() { - let root = unique_path("archivr-core-open"); - let archive_path = root.join(".archivr"); - let store_path = root.join("store"); - fs::create_dir_all(&archive_path).unwrap(); - fs::create_dir_all(&store_path).unwrap(); - fs::write(archive_path.join("name"), "Personal").unwrap(); - fs::write(archive_path.join("store_path"), store_path.display().to_string()).unwrap(); - - let paths = read_archive_paths(&archive_path).unwrap(); - - assert_eq!(paths.archive_path, archive_path); - assert_eq!(paths.store_path, store_path); - assert_eq!(paths.name, "Personal"); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn initialize_archive_creates_database_store_and_metadata() { - let root = unique_path("archivr-core-init"); - let archive_parent = root.join("archive"); - let store_path = root.join("store"); - - let paths = initialize_archive(&archive_parent, &store_path, "Personal", false).unwrap(); - - assert_eq!(paths.archive_path, archive_parent.join(".archivr")); - assert!(paths.archive_path.join(database::DATABASE_FILE_NAME).is_file()); - assert!(paths.store_path.join("raw").is_dir()); - assert!(paths.store_path.join("raw_tweets").is_dir()); - assert!(paths.store_path.join("structured").is_dir()); - assert!(paths.store_path.join("temp").is_dir()); - let _ = fs::remove_dir_all(root); - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: - -```bash -cargo test -p archivr-core archive:: -``` - -Expected: FAIL because `find_archive_path_from`, `read_archive_paths`, and `initialize_archive` are not defined. - -- [ ] **Step 3: Implement archive APIs** - -Add this implementation above the test module in `crates/archivr-core/src/archive.rs`: - -```rust -pub fn find_archive_path_from(start: &Path) -> Result> { - let mut dir = start.to_path_buf(); - loop { - let candidate = dir.join(".archivr"); - if candidate.is_dir() { - return Ok(Some(candidate)); - } - if !dir.pop() { - return Ok(None); - } - } -} - -pub fn find_archive_path() -> Result> { - let cwd = env::current_dir().context("failed to read current working directory")?; - find_archive_path_from(&cwd) -} - -pub fn read_archive_paths(archive_path: &Path) -> Result { - if !archive_path.is_dir() { - bail!("archive path does not exist: {}", archive_path.display()); - } - - let name = fs::read_to_string(archive_path.join("name")) - .with_context(|| format!("failed to read archive name in {}", archive_path.display()))? - .trim() - .to_string(); - let store_path = fs::read_to_string(archive_path.join("store_path")) - .with_context(|| format!("failed to read store path in {}", archive_path.display()))?; - - Ok(ArchivePaths { - archive_path: archive_path.to_path_buf(), - store_path: PathBuf::from(store_path.trim()), - name, - }) -} - -pub fn initialize_archive( - archive_parent: &Path, - store_path: &Path, - archive_name: &str, - force_with_info_removal: bool, -) -> Result { - let archive_path = archive_parent.join(".archivr"); - - if archive_path.exists() { - if !archive_path.is_dir() { - bail!("Archive path exists and is not a directory: {}", archive_path.display()); - } - - if force_with_info_removal { - fs::remove_dir_all(&archive_path)?; - } else if fs::read_dir(&archive_path)?.next().is_some() { - bail!( - "Archive already exists at {} and is not empty. Use --force-with-info-removal to reinitialize.", - archive_path.display() - ); - } - } - - if store_path.exists() && !force_with_info_removal { - bail!("Store path already exists at {}", store_path.display()); - } - - fs::create_dir_all(&archive_path)?; - fs::create_dir_all(store_path)?; - fs::write(archive_path.join("name"), archive_name)?; - let canonical_store_path = store_path - .canonicalize() - .with_context(|| format!("failed to canonicalize {}", store_path.display()))?; - fs::write( - archive_path.join("store_path"), - canonical_store_path - .to_str() - .context("store path is not valid UTF-8")?, - )?; - - initialize_store_directories(&canonical_store_path)?; - let conn = database::open_or_initialize(&archive_path)?; - let _ = database::ensure_default_user(&conn)?; - - Ok(ArchivePaths { - archive_path, - store_path: canonical_store_path, - name: archive_name.to_string(), - }) -} - -pub fn initialize_store_directories(store_path: &Path) -> Result<()> { - fs::create_dir_all(store_path.join("raw"))?; - fs::create_dir_all(store_path.join("raw_tweets"))?; - fs::create_dir_all(store_path.join("structured"))?; - fs::create_dir_all(store_path.join("temp"))?; - Ok(()) -} -``` - -- [ ] **Step 4: Export the archive module** - -Modify `crates/archivr-core/src/lib.rs`: - -```rust -pub mod archive; -pub mod database; -pub mod downloader; -pub mod hash; -pub mod twitter; -``` - -- [ ] **Step 5: Update CLI to call core archive APIs** - -In `crates/archivr-cli/src/main.rs`, add `archive` to the import: - -```rust -use archivr_core::{ - archive, database, downloader, - twitter::parse_tweet_id, -}; -``` - -Replace the local `get_archive_path` function with calls to `archive::find_archive_path()`. - -Replace the local `initialize_store_directories` function with `archive::initialize_store_directories` or remove it if only `archive::initialize_archive` is used. - -In the `Init` command branch, replace the manual `.archivr` creation block with: - -```rust -let archive_parent = Path::new(&archive_path_string); -let store_path = if Path::new(&store_path_string).is_relative() { - env::current_dir() - .context("failed to read current working directory")? - .join(store_path_string) -} else { - Path::new(store_path_string).to_path_buf() -}; - -let paths = archive::initialize_archive( - archive_parent, - &store_path, - archive_name, - force_with_info_removal, -)?; - -println!("Initialized empty archive in {}", paths.archive_path.display()); -``` - -- [ ] **Step 6: Verify tests** - -Run: - -```bash -cargo test -p archivr-core archive:: -cargo test -p archivr-cli -``` - -Expected: all tests pass. - -- [ ] **Step 7: Commit archive API extraction** - -```bash -git add crates/archivr-core crates/archivr-cli -git commit -m "refactor: add core archive opening APIs" -``` - -## Task 4: Rename Taxonomy To Hierarchical Tags - -**Files:** -- Modify: `crates/archivr-core/src/database.rs` - -- [ ] **Step 1: Rename test names first** - -In `crates/archivr-core/src/database.rs`, rename: - -```rust -fn taxonomy_assignments_are_discoverable_through_ancestors() -``` - -to: - -```rust -fn hierarchical_tag_assignments_are_discoverable_through_ancestors() -``` - -Update the test body to call the new API names: - -```rust -let tag_id = create_tag_path(&conn, "/sciences/computer-science/compilers").unwrap(); -assign_entry_to_tag(&conn, entry.id, tag_id).unwrap(); - -assert_eq!( - entry_count_for_tag_path(&conn, "/sciences/computer-science/compilers").unwrap(), - 1 -); -assert_eq!( - entry_count_for_tag_path(&conn, "/sciences/computer-science").unwrap(), - 1 -); -assert_eq!(entry_count_for_tag_path(&conn, "/sciences").unwrap(), 1); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: - -```bash -cargo test -p archivr-core hierarchical_tag_assignments_are_discoverable_through_ancestors -``` - -Expected: FAIL because tag function names do not exist yet. - -- [ ] **Step 3: Rename schema tables and indexes** - -In `initialize_schema`, replace the taxonomy table block with: - -```sql - CREATE TABLE IF NOT EXISTS tags ( - id INTEGER PRIMARY KEY, - tag_uid TEXT NOT NULL UNIQUE, - parent_tag_id INTEGER REFERENCES tags(id), - name TEXT NOT NULL, - slug TEXT NOT NULL, - full_path TEXT NOT NULL UNIQUE - ); - - CREATE TABLE IF NOT EXISTS entry_tag_assignments ( - entry_id INTEGER NOT NULL REFERENCES archived_entries(id) ON DELETE CASCADE, - tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE, - PRIMARY KEY (entry_id, tag_id) - ); -``` - -Replace: - -```sql - CREATE INDEX IF NOT EXISTS idx_taxonomy_nodes_parent_id ON taxonomy_nodes(parent_id); -``` - -with: - -```sql - CREATE INDEX IF NOT EXISTS idx_tags_parent_tag_id ON tags(parent_tag_id); -``` - -- [ ] **Step 4: Rename helper functions** - -Replace `create_taxonomy_path` with: - -```rust -#[cfg(test)] -pub fn create_tag_path(conn: &Connection, full_path: &str) -> Result { - let segments = normalized_tag_segments(full_path)?; - let mut parent_tag_id = None; - let mut current_path = String::new(); - let mut current_id = 0; - - for segment in segments { - current_path.push('/'); - current_path.push_str(segment); - - if let Some(id) = conn - .query_row( - "SELECT id FROM tags WHERE full_path = ?1", - [current_path.as_str()], - |row| row.get(0), - ) - .optional()? - { - current_id = id; - parent_tag_id = Some(id); - continue; - } - - conn.execute( - "INSERT INTO tags (tag_uid, parent_tag_id, name, slug, full_path) - VALUES (?1, ?2, ?3, ?4, ?5)", - params![ - public_id("tag"), - parent_tag_id, - humanize_slug(segment), - segment, - current_path - ], - )?; - current_id = conn.last_insert_rowid(); - parent_tag_id = Some(current_id); - } - - Ok(current_id) -} -``` - -Replace `assign_entry_to_taxonomy` with: - -```rust -#[cfg(test)] -pub fn assign_entry_to_tag(conn: &Connection, entry_id: i64, tag_id: i64) -> Result<()> { - conn.execute( - "INSERT OR IGNORE INTO entry_tag_assignments (entry_id, tag_id) - VALUES (?1, ?2)", - params![entry_id, tag_id], - )?; - Ok(()) -} -``` - -Replace `entry_count_for_taxonomy_path` with: - -```rust -#[cfg(test)] -pub fn entry_count_for_tag_path(conn: &Connection, full_path: &str) -> Result { - let count = conn.query_row( - "WITH RECURSIVE descendants(id) AS ( - SELECT id FROM tags WHERE full_path = ?1 - UNION ALL - SELECT child.id - FROM tags child - JOIN descendants parent ON child.parent_tag_id = parent.id - ) - SELECT COUNT(DISTINCT eta.entry_id) - FROM entry_tag_assignments eta - JOIN descendants d ON eta.tag_id = d.id", - [full_path], - |row| row.get(0), - )?; - Ok(count) -} -``` - -Rename `normalized_taxonomy_segments` to `normalized_tag_segments`. - -- [ ] **Step 5: Verify there are no taxonomy identifiers left** - -Run: - -```bash -rg "taxonomy|Taxonomy|category|Category" crates/archivr-core/src/database.rs -``` - -Expected: no output. - -- [ ] **Step 6: Run core tests** - -Run: - -```bash -cargo test -p archivr-core -``` - -Expected: all core tests pass. - -- [ ] **Step 7: Commit the tag rename** - -```bash -git add crates/archivr-core/src/database.rs -git commit -m "refactor: rename taxonomy model to tags" -``` - -## Task 5: Add Core Entry And Run Query APIs - -**Files:** -- Modify: `crates/archivr-core/src/archive.rs` -- Modify: `crates/archivr-core/src/database.rs` - -- [ ] **Step 1: Add query result types** - -In `crates/archivr-core/src/archive.rs`, add: - -```rust -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct EntrySummary { - pub entry_uid: String, - pub archived_at: String, - pub source_kind: String, - pub entity_kind: String, - pub title: Option, - pub visibility: String, - pub original_url: Option, - pub artifact_count: i64, - pub total_artifact_bytes: i64, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct EntryDetail { - pub summary: EntrySummary, - pub structured_root_relpath: String, - pub source_metadata_json: String, - pub display_metadata_json: Option, - pub artifacts: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct EntryArtifactSummary { - pub artifact_role: String, - pub storage_area: String, - pub relpath: String, - pub byte_size: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RunSummary { - pub run_uid: String, - pub started_at: String, - pub finished_at: Option, - pub status: String, - pub requested_count: i64, - pub discovered_count: i64, - pub completed_count: i64, - pub failed_count: i64, - pub error_summary: Option, -} -``` - -- [ ] **Step 2: Add failing query tests** - -In `crates/archivr-core/src/archive.rs`, add tests that initialize an in-memory DB, create a user/run/source/entry/artifact with existing database helpers, then call: - -```rust -let entries = list_root_entries(&conn).unwrap(); -assert_eq!(entries.len(), 1); -assert_eq!(entries[0].title.as_deref(), Some("Saved Article")); -assert_eq!(entries[0].artifact_count, 1); - -let detail = get_entry_detail(&conn, &entries[0].entry_uid).unwrap().unwrap(); -assert_eq!(detail.artifacts.len(), 1); -assert_eq!(detail.artifacts[0].artifact_role, "primary_media"); - -let runs = list_runs(&conn).unwrap(); -assert_eq!(runs.len(), 1); -assert_eq!(runs[0].status, "completed"); -``` - -Use existing helpers `database::ensure_default_user`, `create_archive_run`, `upsert_source_identity`, `create_archived_entry`, `add_entry_artifact`, `complete_archive_run_item`, and `finish_archive_run`. - -- [ ] **Step 3: Run tests to verify they fail** - -Run: - -```bash -cargo test -p archivr-core archive::tests::list_root_entries -``` - -Expected: FAIL because query functions are not defined. - -- [ ] **Step 4: Implement `list_root_entries`** - -Add: - -```rust -pub fn list_root_entries(conn: &rusqlite::Connection) -> Result> { - let mut stmt = conn.prepare( - "SELECT - e.entry_uid, - e.archived_at, - e.source_kind, - e.entity_kind, - e.title, - e.visibility, - si.canonical_url, - COUNT(ea.id) AS artifact_count, - COALESCE(SUM(b.byte_size), 0) AS total_artifact_bytes - FROM archived_entries e - JOIN source_identities si ON si.id = e.source_identity_id - LEFT JOIN entry_artifacts ea ON ea.entry_id = e.id - LEFT JOIN blobs b ON b.id = ea.blob_id - WHERE e.parent_entry_id IS NULL - GROUP BY e.id - ORDER BY e.archived_at DESC, e.id DESC", - )?; - - let entries = stmt - .query_map([], |row| { - Ok(EntrySummary { - entry_uid: row.get(0)?, - archived_at: row.get(1)?, - source_kind: row.get(2)?, - entity_kind: row.get(3)?, - title: row.get(4)?, - visibility: row.get(5)?, - original_url: row.get(6)?, - artifact_count: row.get(7)?, - total_artifact_bytes: row.get(8)?, - }) - })? - .collect::>>()?; - - Ok(entries) -} -``` - -- [ ] **Step 5: Implement `get_entry_detail`** - -Add: - -```rust -pub fn get_entry_detail( - conn: &rusqlite::Connection, - entry_uid: &str, -) -> Result> { - let Some((entry_id, structured_root_relpath, source_metadata_json, display_metadata_json)) = - conn.query_row( - "SELECT id, structured_root_relpath, source_metadata_json, display_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)?, - row.get::<_, Option>(3)?, - )) - }, - ) - .optional()? - else { - return Ok(None); - }; - - let summary = list_root_entries(conn)? - .into_iter() - .find(|entry| entry.entry_uid == entry_uid) - .context("entry disappeared while loading detail")?; - - let mut stmt = conn.prepare( - "SELECT ea.artifact_role, ea.storage_area, ea.relpath, b.byte_size - FROM entry_artifacts ea - LEFT JOIN blobs b ON b.id = ea.blob_id - WHERE ea.entry_id = ?1 - ORDER BY ea.id ASC", - )?; - let artifacts = stmt - .query_map([entry_id], |row| { - Ok(EntryArtifactSummary { - artifact_role: row.get(0)?, - storage_area: row.get(1)?, - relpath: row.get(2)?, - byte_size: row.get(3)?, - }) - })? - .collect::>>()?; - - Ok(Some(EntryDetail { - summary, - structured_root_relpath, - source_metadata_json, - display_metadata_json, - artifacts, - })) -} -``` - -- [ ] **Step 6: Implement `list_runs`** - -Add: - -```rust -pub fn list_runs(conn: &rusqlite::Connection) -> Result> { - let mut stmt = conn.prepare( - "SELECT run_uid, started_at, finished_at, status, requested_count, - discovered_count, completed_count, failed_count, error_summary - FROM archive_runs - ORDER BY started_at DESC, id DESC", - )?; - let runs = stmt - .query_map([], |row| { - Ok(RunSummary { - run_uid: row.get(0)?, - started_at: row.get(1)?, - finished_at: row.get(2)?, - status: row.get(3)?, - requested_count: row.get(4)?, - discovered_count: row.get(5)?, - completed_count: row.get(6)?, - failed_count: row.get(7)?, - error_summary: row.get(8)?, - }) - })? - .collect::>>()?; - - Ok(runs) -} -``` - -- [ ] **Step 7: Verify query tests** - -Run: - -```bash -cargo test -p archivr-core archive:: -``` - -Expected: all archive query tests pass. - -- [ ] **Step 8: Commit query APIs** - -```bash -git add crates/archivr-core/src/archive.rs -git commit -m "feat: add archive query APIs" -``` - -## Task 6: Add Server Crate And Registry - -**Files:** -- Modify: `Cargo.toml` -- Create: `crates/archivr-server/Cargo.toml` -- Create: `crates/archivr-server/src/main.rs` -- Create: `crates/archivr-server/src/registry.rs` - -- [ ] **Step 1: Add server dependencies to workspace** - -In root `Cargo.toml`, add to `[workspace.dependencies]`: - -```toml -axum = "0.7.9" -serde = { version = "1.0.228", features = ["derive"] } -tempfile = "3.13.0" -tokio = { version = "1.41.1", features = ["macros", "rt-multi-thread", "net"] } -toml = "0.8.19" -tower = "0.5.1" -tower-http = { version = "0.6.2", features = ["fs", "trace"] } -``` - -- [ ] **Step 2: Add server crate to workspace** - -Update root `Cargo.toml` workspace members: - -```toml -members = [ - "crates/archivr-core", - "crates/archivr-cli", - "crates/archivr-server", -] -``` - -- [ ] **Step 3: Create server manifest** - -Create `crates/archivr-server/Cargo.toml`: - -```toml -[package] -name = "archivr-server" -version.workspace = true -edition.workspace = true - -[dependencies] -anyhow.workspace = true -archivr-core = { path = "../archivr-core" } -axum.workspace = true -serde.workspace = true -tokio.workspace = true -toml.workspace = true -tower-http.workspace = true - -[dev-dependencies] -tempfile.workspace = true -``` - -- [ ] **Step 4: Write registry tests** - -Create `crates/archivr-server/src/registry.rs`: - -```rust -use anyhow::{Context, Result, bail}; -use serde::{Deserialize, Serialize}; -use std::{fs, path::{Path, PathBuf}}; - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct MountedArchive { - pub id: String, - pub label: String, - pub archive_path: PathBuf, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] -pub struct ServerRegistry { - pub archives: Vec, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn registry_round_trips_archives_from_toml() { - let temp = tempfile::tempdir().unwrap(); - let archive_path = temp.path().join("personal").join(".archivr"); - fs::create_dir_all(&archive_path).unwrap(); - fs::write(archive_path.join("name"), "Personal").unwrap(); - fs::write(archive_path.join("store_path"), temp.path().join("store").display().to_string()).unwrap(); - - let registry = ServerRegistry { - archives: vec![MountedArchive { - id: "personal".to_string(), - label: "Personal".to_string(), - archive_path: archive_path.clone(), - }], - }; - let path = temp.path().join("server.toml"); - save_registry(&path, ®istry).unwrap(); - - let loaded = load_registry(&path).unwrap(); - - assert_eq!(loaded, registry); - } - - #[test] - fn registry_rejects_duplicate_archive_ids() { - let registry = ServerRegistry { - archives: vec![ - MountedArchive { - id: "personal".to_string(), - label: "Personal".to_string(), - archive_path: PathBuf::from("/tmp/a/.archivr"), - }, - MountedArchive { - id: "personal".to_string(), - label: "Duplicate".to_string(), - archive_path: PathBuf::from("/tmp/b/.archivr"), - }, - ], - }; - - let err = validate_registry(®istry).unwrap_err().to_string(); - - assert!(err.contains("duplicate archive id")); - } -} -``` - -- [ ] **Step 5: Run registry tests to verify failure** - -Run: - -```bash -cargo test -p archivr-server registry:: -``` - -Expected: FAIL because `save_registry`, `load_registry`, and `validate_registry` are not defined. - -- [ ] **Step 6: Implement registry functions** - -Add above the test module in `registry.rs`: - -```rust -pub fn load_registry(path: &Path) -> Result { - let contents = fs::read_to_string(path) - .with_context(|| format!("failed to read server registry {}", path.display()))?; - let registry = toml::from_str::(&contents) - .with_context(|| format!("failed to parse server registry {}", path.display()))?; - validate_registry(®istry)?; - Ok(registry) -} - -pub fn save_registry(path: &Path, registry: &ServerRegistry) -> Result<()> { - validate_registry(registry)?; - let contents = toml::to_string_pretty(registry)?; - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - fs::write(path, contents)?; - Ok(()) -} - -pub fn validate_registry(registry: &ServerRegistry) -> Result<()> { - let mut ids = std::collections::HashSet::new(); - for archive in ®istry.archives { - if archive.id.trim().is_empty() { - bail!("archive id must not be empty"); - } - if !ids.insert(archive.id.as_str()) { - bail!("duplicate archive id: {}", archive.id); - } - if !archive.archive_path.ends_with(".archivr") { - bail!( - "mounted archive path must point at a .archivr directory: {}", - archive.archive_path.display() - ); - } - } - Ok(()) -} -``` - -- [ ] **Step 7: Add minimal server main** - -Create `crates/archivr-server/src/main.rs`: - -```rust -mod registry; -mod routes; - -use anyhow::Result; -use std::{net::SocketAddr, path::PathBuf}; - -#[tokio::main] -async fn main() -> Result<()> { - let config_path = std::env::args() - .nth(1) - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from("archivr-server.toml")); - let registry = registry::load_registry(&config_path)?; - let app = routes::app(registry); - let addr = SocketAddr::from(([127, 0, 0, 1], 8080)); - let listener = tokio::net::TcpListener::bind(addr).await?; - println!("archivr-server listening on http://{addr}"); - axum::serve(listener, app).await?; - Ok(()) -} -``` - -Create temporary `crates/archivr-server/src/routes.rs` so the crate compiles: - -```rust -use axum::{routing::get, Router}; - -use crate::registry::ServerRegistry; - -pub fn app(_registry: ServerRegistry) -> Router { - Router::new().route("/health", get(|| async { "ok" })) -} -``` - -- [ ] **Step 8: Verify server crate** - -Run: - -```bash -cargo test -p archivr-server -``` - -Expected: registry tests pass. - -- [ ] **Step 9: Commit server registry** - -```bash -git add Cargo.toml Cargo.lock crates/archivr-server -git commit -m "feat: add web server registry" -``` - -## Task 7: Add Server API Routes - -**Files:** -- Modify: `crates/archivr-server/src/routes.rs` -- Modify: `crates/archivr-server/Cargo.toml` - -- [ ] **Step 1: Add JSON serialization support** - -Add to `crates/archivr-core/Cargo.toml`: - -```toml -serde.workspace = true -``` - -Derive `serde::Serialize` for these core query types in `crates/archivr-core/src/archive.rs`: - -```rust -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] -pub struct EntrySummary { - pub entry_uid: String, - pub archived_at: String, - pub source_kind: String, - pub entity_kind: String, - pub title: Option, - pub visibility: String, - pub original_url: Option, - pub artifact_count: i64, - pub total_artifact_bytes: i64, -} - -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] -pub struct EntryDetail { - pub summary: EntrySummary, - pub structured_root_relpath: String, - pub source_metadata_json: String, - pub display_metadata_json: Option, - pub artifacts: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] -pub struct EntryArtifactSummary { - pub artifact_role: String, - pub storage_area: String, - pub relpath: String, - pub byte_size: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] -pub struct RunSummary { - pub run_uid: String, - pub started_at: String, - pub finished_at: Option, - pub status: String, - pub requested_count: i64, - pub discovered_count: i64, - pub completed_count: i64, - pub failed_count: i64, - pub error_summary: Option, -} -``` - -- [ ] **Step 2: Write API route tests** - -In `crates/archivr-server/src/routes.rs`, add tests using `tower::ServiceExt`: - -```rust -#[cfg(test)] -mod tests { - use super::*; - use axum::body::Body; - use axum::http::{Request, StatusCode}; - use tower::ServiceExt; - - #[tokio::test] - async fn archives_endpoint_lists_mounted_archives() { - let registry = ServerRegistry { - archives: vec![MountedArchive { - id: "personal".to_string(), - label: "Personal".to_string(), - archive_path: std::path::PathBuf::from("/tmp/personal/.archivr"), - }], - }; - let response = app(registry) - .oneshot(Request::builder().uri("/api/archives").body(Body::empty()).unwrap()) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::OK); - } - - #[tokio::test] - async fn missing_archive_returns_404() { - let response = app(ServerRegistry::default()) - .oneshot(Request::builder().uri("/api/archives/missing/entries").body(Body::empty()).unwrap()) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::NOT_FOUND); - } -} -``` - -- [ ] **Step 3: Run tests to verify failure** - -Run: - -```bash -cargo test -p archivr-server routes:: -``` - -Expected: FAIL until API routes exist. - -- [ ] **Step 4: Implement API route state and handlers** - -Replace `crates/archivr-server/src/routes.rs` with: - -```rust -use std::sync::Arc; - -use archivr_core::{archive, database}; -use axum::{ - extract::{Path, State}, - http::StatusCode, - response::{IntoResponse, Response}, - routing::get, - Json, Router, -}; - -use crate::registry::{MountedArchive, ServerRegistry}; - -#[derive(Clone)] -pub struct AppState { - registry: Arc, -} - -pub fn app(registry: ServerRegistry) -> Router { - let state = AppState { - registry: Arc::new(registry), - }; - - Router::new() - .route("/health", get(|| async { "ok" })) - .route("/api/archives", get(list_archives)) - .route("/api/archives/:archive_id/entries", get(list_entries)) - .route("/api/archives/:archive_id/entries/:entry_uid", get(entry_detail)) - .route("/api/archives/:archive_id/runs", get(list_runs)) - .with_state(state) -} - -async fn list_archives(State(state): State) -> Json> { - Json(state.registry.archives.clone()) -} - -async fn list_entries( - State(state): State, - Path(archive_id): Path, -) -> Result>, ApiError> { - let mounted = mounted_archive(&state, &archive_id)?; - let conn = database::open_or_initialize(&mounted.archive_path)?; - Ok(Json(archive::list_root_entries(&conn)?)) -} - -async fn entry_detail( - State(state): State, - 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)?; - let detail = archive::get_entry_detail(&conn, &entry_uid)? - .ok_or(ApiError::not_found("entry not found"))?; - Ok(Json(detail)) -} - -async fn list_runs( - State(state): State, - Path(archive_id): Path, -) -> Result>, ApiError> { - let mounted = mounted_archive(&state, &archive_id)?; - let conn = database::open_or_initialize(&mounted.archive_path)?; - Ok(Json(archive::list_runs(&conn)?)) -} - -fn mounted_archive<'a>(state: &'a AppState, archive_id: &str) -> Result<&'a MountedArchive, ApiError> { - state - .registry - .archives - .iter() - .find(|archive| archive.id == archive_id) - .ok_or(ApiError::not_found("archive not found")) -} - -#[derive(Debug)] -pub struct ApiError { - status: StatusCode, - message: String, -} - -impl ApiError { - fn not_found(message: &str) -> Self { - Self { - status: StatusCode::NOT_FOUND, - message: message.to_string(), - } - } -} - -impl From for ApiError -where - E: Into, -{ - fn from(error: E) -> Self { - let error = error.into(); - Self { - status: StatusCode::INTERNAL_SERVER_ERROR, - message: format!("{error:#}"), - } - } -} - -impl IntoResponse for ApiError { - fn into_response(self) -> Response { - (self.status, self.message).into_response() - } -} -``` - -- [ ] **Step 5: Add server dev dependency for route tests** - -In `crates/archivr-server/Cargo.toml`, add: - -```toml -[dev-dependencies] -tempfile.workspace = true -tower.workspace = true -``` - -- [ ] **Step 6: Verify route tests** - -Run: - -```bash -cargo test -p archivr-server -``` - -Expected: route tests pass. - -- [ ] **Step 7: Commit API routes** - -```bash -git add Cargo.toml Cargo.lock crates/archivr-core crates/archivr-server -git commit -m "feat: expose archive server APIs" -``` - -## Task 8: Add Static Web UI - -**Files:** -- Modify: `crates/archivr-server/src/routes.rs` -- Create: `crates/archivr-server/static/index.html` -- Create: `crates/archivr-server/static/styles.css` -- Create: `crates/archivr-server/static/app.js` - -- [ ] **Step 1: Serve static assets** - -In `crates/archivr-server/src/routes.rs`, import: - -```rust -use tower_http::services::{ServeDir, ServeFile}; -``` - -Update `app`: - -```rust -pub fn app(registry: ServerRegistry) -> Router { - let state = AppState { - registry: Arc::new(registry), - }; - - let static_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("static"); - - Router::new() - .route("/health", get(|| async { "ok" })) - .route("/api/archives", get(list_archives)) - .route("/api/archives/:archive_id/entries", get(list_entries)) - .route("/api/archives/:archive_id/entries/:entry_uid", get(entry_detail)) - .route("/api/archives/:archive_id/runs", get(list_runs)) - .nest_service("/assets", ServeDir::new(&static_dir)) - .fallback_service(ServeFile::new(static_dir.join("index.html"))) - .with_state(state) -} -``` - -- [ ] **Step 2: Create HTML shell** - -Create `crates/archivr-server/static/index.html`: - -```html - - - - - - Archivr - - - -
-
Archivr
- - - -
- -
-
-
- -
Advanced search later
-
- -
- - - - - - - - - - - -
AddedTitleTypeSizeOriginal URL
-
- -
- - - - - - - - - - - -
StartedStatusRequestedCompletedFailed
-
- -
-
- Mounted archive configuration will live here. -
-
-
- - -
- - - - -``` - -- [ ] **Step 3: Create CSS** - -Create `crates/archivr-server/static/styles.css`: - -```css -:root { - color-scheme: light; - --ink: #1f2924; - --muted: #62665f; - --paper: #f7f3ea; - --paper-2: #eee8dc; - --paper-3: #fffdf8; - --line: #d4cabb; - --line-soft: #e3ddd3; - --accent: #8d3f30; - --link: #245f72; -} - -* { - box-sizing: border-box; -} - -body { - margin: 0; - min-height: 100vh; - background: var(--paper); - color: var(--ink); - font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; -} - -.topbar { - height: 56px; - display: grid; - grid-template-columns: auto minmax(160px, 240px) 1fr auto; - align-items: center; - gap: 18px; - padding: 0 18px; - background: #151f1b; - color: #f5eee1; - border-bottom: 3px solid var(--accent); -} - -.brand { - font-family: Georgia, "Times New Roman", serif; - font-size: 28px; -} - -.archive-switcher, -.capture-button, -.nav-link { - font: inherit; -} - -.archive-switcher { - width: 100%; - border: 1px solid rgba(245, 238, 225, 0.35); - background: #202c27; - color: #f5eee1; - padding: 7px 9px; -} - -.nav { - display: flex; - gap: 14px; - justify-content: flex-end; -} - -.nav-link { - border: 0; - background: transparent; - color: #d8d0c3; - cursor: pointer; - padding: 8px 0; -} - -.nav-link.is-active { - color: #fffaf0; - border-bottom: 1px solid #fffaf0; -} - -.capture-button { - border: 0; - background: #f5eee1; - color: #151f1b; - padding: 9px 12px; - cursor: pointer; -} - -.app-shell { - height: calc(100vh - 56px); - display: grid; - grid-template-columns: minmax(0, 1fr) 320px; -} - -.workspace { - min-width: 0; - overflow: auto; -} - -.search-row { - min-height: 76px; - display: grid; - grid-template-columns: minmax(0, 1fr) 210px; - gap: 18px; - align-items: center; - padding: 14px 16px; - background: var(--paper-2); - border-bottom: 1px solid var(--line); -} - -.search-input { - width: 100%; - height: 46px; - border: 2px solid var(--ink); - background: var(--paper-3); - color: var(--ink); - padding: 0 14px; - font: inherit; - font-size: 16px; -} - -.search-note { - color: var(--muted); - font-size: 13px; -} - -.view { - display: none; -} - -.view.is-active { - display: block; -} - -.entry-table { - width: 100%; - border-collapse: collapse; - table-layout: fixed; - font-size: 13px; -} - -.entry-table th { - position: sticky; - top: 0; - z-index: 1; - text-align: left; - padding: 10px; - background: #e1dacd; - color: #5d625e; - border-bottom: 1px solid #d2cabe; - font-size: 11px; - text-transform: uppercase; -} - -.entry-table td { - padding: 11px 10px; - border-bottom: 1px solid var(--line-soft); - vertical-align: top; -} - -.entry-table tr:nth-child(even) td { - background: #f5f1e8; -} - -.entry-table tr:nth-child(odd) td { - background: var(--paper-3); -} - -.entry-table tr.is-selected td { - background: #efe8dc; - border-top: 2px solid var(--accent); - border-bottom: 2px solid var(--accent); -} - -.entry-title { - color: var(--link); - font-weight: 750; -} - -.url-cell { - color: #555; - word-break: break-all; -} - -.context-rail { - border-left: 1px solid var(--line); - background: #f0ebe2; - padding: 18px; - overflow: auto; -} - -.rail-title { - color: var(--accent); - font-weight: 800; - margin-bottom: 10px; -} - -.rail-body { - color: var(--muted); - font-size: 14px; - line-height: 1.6; -} - -.admin-note { - padding: 24px; - color: var(--muted); -} - -@media (max-width: 900px) { - .topbar { - grid-template-columns: 1fr auto; - height: auto; - min-height: 56px; - padding: 12px; - } - - .archive-switcher, - .nav { - grid-column: 1 / -1; - } - - .app-shell { - height: auto; - grid-template-columns: 1fr; - } - - .context-rail { - border-left: 0; - border-top: 1px solid var(--line); - } -} -``` - -- [ ] **Step 4: Create JavaScript** - -Create `crates/archivr-server/static/app.js`: - -```js -const state = { - archives: [], - archiveId: null, - entries: [], -}; - -const archiveSwitcher = document.querySelector("#archive-switcher"); -const entriesBody = document.querySelector("#entries-body"); -const runsBody = document.querySelector("#runs-body"); -const contextBody = document.querySelector("#context-body"); -const navButtons = document.querySelectorAll(".nav-link"); - -function formatBytes(bytes) { - if (!bytes) return "0 B"; - const units = ["B", "KB", "MB", "GB"]; - let size = bytes; - let unit = 0; - while (size >= 1024 && unit < units.length - 1) { - size /= 1024; - unit += 1; - } - return `${size.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`; -} - -function text(value) { - return value ?? ""; -} - -async function getJson(url) { - const response = await fetch(url); - if (!response.ok) { - throw new Error(`${response.status} ${response.statusText}`); - } - return response.json(); -} - -function renderArchives() { - archiveSwitcher.innerHTML = ""; - for (const archive of state.archives) { - const option = document.createElement("option"); - option.value = archive.id; - option.textContent = archive.label; - archiveSwitcher.append(option); - } - archiveSwitcher.value = state.archiveId ?? ""; -} - -function renderEntries() { - entriesBody.innerHTML = ""; - for (const entry of state.entries) { - const row = document.createElement("tr"); - row.tabIndex = 0; - row.innerHTML = ` - ${text(entry.archived_at)} - ${text(entry.title) || text(entry.entry_uid)} - ${text(entry.entity_kind)} - ${formatBytes(entry.total_artifact_bytes)} - ${text(entry.original_url)} - `; - row.addEventListener("click", () => selectEntry(entry, row)); - entriesBody.append(row); - } -} - -async function selectEntry(entry, row) { - document.querySelectorAll(".entry-table tr.is-selected").forEach((selected) => { - selected.classList.remove("is-selected"); - }); - row.classList.add("is-selected"); - - const detail = await getJson(`/api/archives/${state.archiveId}/entries/${entry.entry_uid}`); - contextBody.innerHTML = ` -
${text(detail.summary.title) || text(detail.summary.entry_uid)}
-
Type: ${text(detail.summary.entity_kind)}
-
Visibility: ${text(detail.summary.visibility)}
-
Artifacts: ${detail.artifacts.length}
-
Structured root: ${text(detail.structured_root_relpath)}
- `; -} - -async function loadRuns() { - const runs = await getJson(`/api/archives/${state.archiveId}/runs`); - runsBody.innerHTML = ""; - for (const run of runs) { - const row = document.createElement("tr"); - row.innerHTML = ` - ${text(run.started_at)} - ${text(run.status)} - ${run.requested_count} - ${run.completed_count} - ${run.failed_count} - `; - runsBody.append(row); - } -} - -async function loadEntries() { - state.entries = await getJson(`/api/archives/${state.archiveId}/entries`); - renderEntries(); - contextBody.textContent = "Select an entry to inspect metadata and artifacts."; -} - -async function loadArchives() { - state.archives = await getJson("/api/archives"); - state.archiveId = state.archives[0]?.id ?? null; - renderArchives(); - if (state.archiveId) { - await loadEntries(); - await loadRuns(); - } else { - contextBody.textContent = "No archives are mounted."; - } -} - -archiveSwitcher.addEventListener("change", async () => { - state.archiveId = archiveSwitcher.value; - await loadEntries(); - await loadRuns(); -}); - -navButtons.forEach((button) => { - button.addEventListener("click", () => { - navButtons.forEach((candidate) => candidate.classList.remove("is-active")); - document.querySelectorAll(".view").forEach((view) => view.classList.remove("is-active")); - button.classList.add("is-active"); - document.querySelector(`#${button.dataset.view}-view`).classList.add("is-active"); - }); -}); - -loadArchives().catch((error) => { - contextBody.textContent = `Failed to load archives: ${error.message}`; -}); -``` - -- [ ] **Step 5: Verify static route** - -Run: - -```bash -cargo test -p archivr-server -``` - -Expected: all server tests still pass. - -- [ ] **Step 6: Commit static UI** - -```bash -git add crates/archivr-server -git commit -m "feat: add archive table web UI" -``` - -## Task 9: End-To-End Smoke Verification - -**Files:** -- No required source changes unless verification finds a bug. - -- [ ] **Step 1: Run all Rust tests** - -Run: - -```bash -cargo test -``` - -Expected: all workspace tests pass. - -- [ ] **Step 2: Create a temporary archive** - -Run: - -```bash -tmp=$(mktemp -d) -cargo run -p archivr-cli --bin archivr -- init "$tmp/archive" "$tmp/store" --name Smoke -printf 'archive me' > "$tmp/source.txt" -cd "$tmp/archive" -cargo run --manifest-path /Users/thegeneralist/.codex/worktrees/0dd8/archivr/crates/archivr-cli/Cargo.toml --bin archivr -- archive "file://$tmp/source.txt" -``` - -Expected: archive initializes and one local file entry is recorded. - -- [ ] **Step 3: Create server config for the temporary archive** - -Run: - -```bash -cat > "$tmp/server.toml" < /path/to/personal/.archivr - family -> /path/to/family/.archivr - research -> /path/to/research/.archivr -``` - -This means Archivr should not merge all archive entries into one global archive database. Instead, the server chooses a mounted archive, opens that archive's own DB, and queries entries/runs/artifacts from there. - -The web UI should include an archive switcher once more than one archive is mounted. - -## Workspace Architecture - -Move toward a Rust workspace: - -```text -crates/ - archivr-core/ - archivr-cli/ - archivr-server/ -``` - -`archivr-core` owns reusable archive behavior: - -- archive directory discovery/opening -- archive DB schema and access -- store path handling -- entry, artifact, blob, source identity, run, user, setting, and tag domain operations -- archive-scoped query APIs used by CLI and server - -`archivr-cli` owns command-line UX: - -- argument parsing -- terminal output -- process exit behavior -- invoking `archivr-core` operations - -`archivr-server` owns web-server behavior: - -- mounted archive registry/config -- archive switcher data -- HTTP routes -- static frontend assets or frontend build integration -- local development server behavior -- request routing to the selected archive - -The server state must stay clearly separate from each mounted archive's DB. - -## Navigation - -Use a small top navigation model: - -```text -[Archive Switcher] Archive Runs Admin + Capture -``` - -`Archive` is the main entry table. - -`Runs` is archive job history, failures, and progress. - -`Admin` is for mounted archive configuration, instance/server settings, and later user management. - -`+ Capture` is a button, not a top-level workspace. It can open a capture dialog or page later. - -Do not add `Public` as a top-level nav item in the first design. Public/private/unlisted are entry visibility states inside an archive. Public archive publishing UI is deferred. - -Do not add top-level `Tags` navigation yet. Tags should be queryable/searchable and appear in entry context, but the first UI does not need a separate tag management workspace. - -## Tags - -Use the product term `tags` or `hierarchical tags`. - -The current `taxonomy` naming should be renamed before UI work builds on it: - -```text -taxonomy_nodes -> tags -entry_taxonomy_assignments -> entry_tag_assignments -``` - -Proposed tag columns: - -```text -tags.id -tags.tag_uid -tags.parent_tag_id -tags.name -tags.slug -tags.full_path -``` - -Examples: - -```text -/sciences/computer-science/compilers -/family/recipes/georgian -/research/ai/evals -``` - -Avoid user-facing words such as taxonomy and categories. Internally, code and schema should also move to tag terminology while the project is still early. - -## Data Flow - -The UI talks to `archivr-server`. The server resolves the selected mounted archive, then calls `archivr-core` against that archive's DB and store path. - -Initial API shape: - -```text -GET /api/archives -GET /api/archives/:archive_id/entries -GET /api/archives/:archive_id/entries/:entry_uid -GET /api/archives/:archive_id/runs -``` - -The main page loads entries for the selected archive, renders the dense table, and fills the contextual right rail from the selected entry. - -Search initially filters or queries entries through the server. The search internals can become more powerful later without changing the high-level UI shape. - -## First Implementation Scope - -Must have: - -- Convert repo toward a Rust workspace. -- Extract reusable archive DB/store/domain logic into `archivr-core`. -- Keep existing CLI behavior working through `archivr-cli` or an equivalent transitional CLI crate. -- Add `archivr-server` as a separate crate. -- Server can register or mount multiple archive directories. -- Web UI can switch archives. -- Main page shows the selected archive's entries in the approved dense table layout. -- Right rail is contextual and can show selected entry metadata/artifacts. -- Runs page shows archive run history. -- Rename taxonomy schema/code language to hierarchical tags. - -Should not have yet: - -- Full OP search implementation. -- Full browser capture workflow. -- Full production authentication/session/permission model. -- Public archive publishing UI. -- Final row-click/open behavior. -- Full tag management UI. - -Authentication is deferred except for whatever minimal local-only guard is needed to run the first web UI safely. The production auth/session/permission model needs a separate design pass covering login, sessions, roles, public/private/unlisted access, and multi-archive permissions. - -## Testing - -Core tests: - -- Move existing archive DB tests into `archivr-core`. -- Test creating hierarchical tag paths. -- Test assigning an entry to the deepest tag. -- Test ancestor tag queries still find assigned entries. -- Test opening an existing archive path returns its DB, store path, and name. - -Server tests: - -- Mount registry can add/list archives. -- Invalid archive paths are rejected. -- Entry list endpoint queries the selected archive. -- Two mounted archives with separate DBs do not leak entries into each other. - -UI/smoke tests: - -- Server starts. -- Archive table page loads. -- Archive switcher changes selected archive. -- Selecting an entry can populate the right context rail when that behavior is implemented. -- Runs page loads. - -Before claiming UI work complete, verify the table in a browser at desktop and mobile widths for density, legibility, overflow, and right-rail behavior. - -## Open Decisions - -- Exact row-click/open behavior. -- Exact search grammar and advanced search interactions. -- Browser capture workflow. -- Public archive publishing UI. -- Production authentication/session/permission model. -- Final visual polish. - -## Risks - -- The workspace refactor will touch many files, so implementation should be staged carefully. -- Renaming taxonomy to tags should happen before UI work to avoid building on the wrong vocabulary. -- Server registry state must stay clearly separate from archive DB state. -- The table can easily feel old if the implementation copies ArchiveBox styling too closely instead of using the approved archive-ledger direction.