1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-21 18:55:36 +02:00

Compare commits

...

16 commits

Author SHA1 Message Date
b3f1932b62
chore: update gitignore and flake.nix 2026-06-23 20:33:41 +02:00
17d116655a
docs: remove completed planning docs and stale superpowers plan
- Delete NEXT.md (all six tracks implemented)
- Delete docs/superpowers/plans/track1 plan (track complete)
- Trim ARCHIVR-MENTAL-MODEL.md Key Documents table to only living files
2026-06-23 17:24:24 +02:00
2d7a4f1766
feat(server): configurable bind address with loopback default and non-loopback warning
- Add optional `bind` field to ServerRegistry (TOML + ARCHIVR_BIND env var)
- Default bind address remains 127.0.0.1:8080; non-loopback prints a warning
- Add route security classification comment block (READ/ADMIN/WRITE/STATIC)
- Add Security and Deployment section to docs/README.md
- Replace vague auth note in ARCHIVR-MENTAL-MODEL.md with concrete model description
- Add three registry tests covering bind field round-trip and defaults
2026-06-23 17:24:24 +02:00
10c41ef84f
feat: browser capture button 2026-06-22 22:47:51 +02:00
5ea0c396b8
fix: expand yt:/youtube: shorthands to real URLs before yt-dlp
expand_shorthand_to_url handled instagram:/facebook:/tiktok: etc. but
not yt:/youtube:. yt-dlp doesn't know the yt: scheme, so shorthands
like yt:video/ID failed with 'Unsupported url scheme'.

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

Add tests pinning all new expansions.
2026-06-22 22:42:41 +02:00
e3face0a1b
fix: bundle tweet scraper in archivr-server Nix wrapper
archivr_server now includes the Python scraper and sets
ARCHIVR_TWEET_PYTHON and ARCHIVR_TWEET_SCRAPER, matching what the
archivr CLI wrapper already does. Without this, browser capture of
tweets failed with 'No such file or directory' because the server
resolved the scraper relative to cwd.
2026-06-22 22:37:58 +02:00
8d0352c2c4
feat: browser capture button (Track 4)
- Add archivr-core/src/capture.rs: Source enum, all capture helpers,
  fail_run (replaces process::exit fail_archive_and_exit), and
  perform_capture() as the public entry point
- Slim archivr-cli/src/main.rs to 103 lines: thin adapter over
  archivr_core::capture::perform_capture
- Move all source-classification and tweet-entry tests from CLI to
  archivr-core/src/capture.rs (they test core logic, belong in core)
- Add POST /api/archives/:archive_id/captures route in archivr-server:
  validates non-empty locator (400), resolves archive (404), delegates
  to perform_capture, returns {run_uid, status}
- Add capture dialog to browser UI: dialog markup in index.html,
  showModal/submit/cancel/loading/error wiring in app.js, dialog
  styles in styles.css
- 77 tests pass, clean build (0 warnings)
2026-06-22 22:18:44 +02:00
b70ab4945e
feat: hierarchical tags 2026-06-22 14:48:47 +02:00
9c4f05b013
fix(server): remove unused post import from routes.rs 2026-06-22 14:48:47 +02:00
5803f2119b
feat(server+ui): tag management API routes and browser UI
- 5 tag routes (list tree, create, assign, remove, search with tag=)
- Tags nav, tag tree view, tag filter badge
- Entry tag pills with remove, assign-tag form in context rail
2026-06-22 14:48:47 +02:00
83f909585f
feat: server-side search with structured prefix query language 2026-06-22 14:48:46 +02:00
db953de67a
feat(core): hierarchical tag API
- Tag/TagNode types, create_tag, list_tag_tree, assign_entry_tag,
  remove_entry_tag, entries_for_tag
- Tag-filtered search returns child entries (no root restriction)
- parent_entry_uid added to EntrySummary
- 8 new tests
2026-06-22 14:48:46 +02:00
fa2968a21a
fix(server): remove unused Body import; keep bonus happy-path artifact test 2026-06-22 14:48:46 +02:00
dcf9e127bb
feat: entry detail view and artifact file serving 2026-06-22 14:48:46 +02:00
9337a822ad
docs: add next work handoff 2026-06-21 20:17:51 +02:00
44c17eee3d
docs: document runtime entrypoints 2026-06-21 19:40:56 +02:00
17 changed files with 3743 additions and 1287 deletions

5
.gitignore vendored
View file

@ -2,8 +2,9 @@
!.gitignore
!docs
!docs/**
!docs/
!docs/LICENSE
!docs/README*
!crates
!crates/**

View file

@ -2,6 +2,13 @@
This document explains the current project shape after the workspace refactor.
## Key Documents
| Document | Role |
|---|---|
| `ARCHIVR-MENTAL-MODEL.md` | **This file.** Current architecture, data flows, and where to edit. |
| `docs/README.md` | User-facing docs: how to run the tool, supported inputs, environment variables. |
## The Big Model
Archivr is now a Rust workspace with three crates:
@ -57,6 +64,40 @@ label = "Personal"
archive_path = "/path/to/archive/.archivr"
```
## How To Run It
There are two user-facing binaries:
| Binary | Purpose |
|---|---|
| `archivr` | CLI for initializing archives and capturing material into one archive |
| `archivr-server` | Web server for browsing one or more existing archives |
The CLI writes archive data:
```sh
nix run .#archivr -- init ./my-archive --name "My Archive"
nix run .#archivr -- archive file:///absolute/path/to/file.pdf
```
The server reads archive data:
```sh
nix run .#archivr-server -- ./archivr-server.toml
```
If no config path is passed, the server reads `./archivr-server.toml`.
The config is a server registry, not archive data:
```toml
[[archives]]
id = "personal"
label = "Personal"
archive_path = "/absolute/path/to/my-archive/.archivr"
```
The packaged Nix server wrapper sets `ARCHIVR_STATIC_DIR` so the server can find the installed web UI assets. Source-tree runs do not need that variable because they fall back to `crates/archivr-server/static`.
## Write Data Flow
When archiving something through the CLI:
@ -130,6 +171,6 @@ The web server reads archive data and serves the UI. It does not yet implement c
Search is currently simple client-side filtering.
Auth is not a production model yet.
**Auth and session model:** The server binds to `127.0.0.1` by default and has no authentication middleware. This is intentional — Archivr is a local tool. The bind address is configurable via the TOML `bind` field or `ARCHIVR_BIND` env var; a non-loopback address triggers a startup warning. Route families are classified (READ / ADMIN / WRITE / STATIC) in `crates/archivr-server/src/routes.rs` as the decision record for when middleware is eventually added. See the "Security and Deployment" section in `docs/README.md`.
Admin is a mounted-archives view, not a management system.

1
Cargo.lock generated
View file

@ -124,6 +124,7 @@ dependencies = [
"archivr-core",
"axum",
"serde",
"serde_json",
"tempfile",
"tokio",
"toml",

File diff suppressed because it is too large Load diff

View file

@ -25,6 +25,7 @@ pub struct EntrySummary {
pub original_url: Option<String>,
pub artifact_count: i64,
pub total_artifact_bytes: i64,
pub parent_entry_uid: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
@ -57,6 +58,20 @@ pub struct RunSummary {
pub error_summary: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct Tag {
pub tag_uid: String,
pub name: String,
pub slug: String,
pub full_path: String,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct TagNode {
pub tag: Tag,
pub children: Vec<TagNode>,
}
pub fn find_archive_path_from(start: &Path) -> Result<Option<PathBuf>> {
let mut dir = start.to_path_buf();
loop {
@ -167,7 +182,8 @@ pub fn list_root_entries(conn: &rusqlite::Connection) -> Result<Vec<EntrySummary
e.visibility,
si.canonical_url,
COUNT(ea.id) AS artifact_count,
COALESCE(SUM(b.byte_size), 0) AS total_artifact_bytes
COALESCE(SUM(b.byte_size), 0) AS total_artifact_bytes,
NULL AS parent_entry_uid
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
@ -189,6 +205,7 @@ pub fn list_root_entries(conn: &rusqlite::Connection) -> Result<Vec<EntrySummary
original_url: row.get(6)?,
artifact_count: row.get(7)?,
total_artifact_bytes: row.get(8)?,
parent_entry_uid: row.get(9)?,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
@ -278,6 +295,384 @@ pub fn list_runs(conn: &rusqlite::Connection) -> Result<Vec<RunSummary>> {
Ok(runs)
}
/// Resolves an artifact to its absolute on-disk path under `store_path`.
///
/// `artifact.relpath` is a store-relative path (e.g. `raw/a/b/abc.pdf`).
/// The returned path is canonicalized. Returns an error if the resolved path
/// escapes `store_path` (path traversal protection) or if the file does not exist.
pub fn resolve_artifact_path(
store_path: &Path,
artifact: &EntryArtifactSummary,
) -> Result<PathBuf> {
let joined = store_path.join(&artifact.relpath);
let canonical_store = store_path
.canonicalize()
.with_context(|| format!("failed to canonicalize store path: {}", store_path.display()))?;
let canonical_artifact = joined
.canonicalize()
.with_context(|| format!("artifact path does not exist: {}", joined.display()))?;
if !canonical_artifact.starts_with(&canonical_store) {
bail!(
"artifact path escapes store: {}",
canonical_artifact.display()
);
}
Ok(canonical_artifact)
}
#[derive(Debug, Clone, Default)]
pub struct SearchEntriesQuery {
/// Free-text term: LIKE-matched against title, canonical_url, entry_uid, source_kind, entity_kind, visibility
pub q: Option<String>,
/// Exact match on e.source_kind
pub source_kind: Option<String>,
/// Exact match on e.entity_kind
pub entity_kind: Option<String>,
/// LIKE-matched against si.canonical_url
pub url: Option<String>,
/// LIKE-matched against e.title
pub title: Option<String>,
/// e.archived_at >= after (inclusive, ISO 8601)
pub after: Option<String>,
/// e.archived_at < before (exclusive, ISO 8601)
pub before: Option<String>,
/// Tag full_path filter; includes all entries (root + child) matching the tag subtree
pub tag: Option<String>,
}
/// Parses a raw search string into a [`SearchEntriesQuery`].
///
/// Recognized prefixes: `source:`, `type:`, `url:`, `title:`, `after:`, `before:`, `tag:`.
/// Tokens with an unrecognized `prefix:` return `Err(prefix)`.
/// Remaining non-prefix tokens are joined as the free-text `q`.
/// Quoted values (`title:"resume templates"`) are supported for single-word values
/// after the colon; leading/trailing double quotes are stripped.
pub fn parse_search_query(raw: &str) -> Result<SearchEntriesQuery, String> {
let mut query = SearchEntriesQuery::default();
let mut free_text_tokens: Vec<&str> = Vec::new();
for token in raw.split_whitespace() {
if let Some(colon_pos) = token.find(':') {
let prefix = &token[..colon_pos];
let value_raw = &token[colon_pos + 1..];
// Strip surrounding double quotes if present
let value = value_raw.trim_matches('"').to_string();
match prefix {
"source" => query.source_kind = Some(value),
"type" => query.entity_kind = Some(value),
"url" => query.url = Some(value),
"title" => query.title = Some(value),
"after" => query.after = Some(value),
"before" => query.before = Some(value),
"tag" => query.tag = Some(value),
other => return Err(other.to_string()),
}
} else {
free_text_tokens.push(token);
}
}
let q = free_text_tokens.join(" ");
query.q = if q.is_empty() { None } else { Some(q) };
Ok(query)
}
const ENTRY_SELECT_COLS: &str =
"SELECT e.entry_uid, e.archived_at, e.source_kind, e.entity_kind, e.title, \
e.visibility, si.canonical_url, COUNT(ea.id) AS artifact_count, \
COALESCE(SUM(b.byte_size), 0) AS total_artifact_bytes, \
parent.entry_uid AS parent_entry_uid";
const ENTRY_FROM_JOINS: &str =
"FROM archived_entries e \
JOIN source_identities si ON si.id = e.source_identity_id \
LEFT JOIN entry_artifacts ea ON ea.entry_id = e.id \
LEFT JOIN blobs b ON b.id = ea.blob_id \
LEFT JOIN archived_entries parent ON parent.id = e.parent_entry_id";
/// Searches archived entries matching all non-`None` fields in `query`.
///
/// Without a tag filter, returns root entries only (same scope as [`list_root_entries`]).
/// With a tag filter, returns ALL entries (root and child) assigned to that tag subtree.
pub fn search_entries(
conn: &rusqlite::Connection,
query: &SearchEntriesQuery,
) -> Result<Vec<EntrySummary>> {
let mut params: Vec<String> = Vec::new();
let mut sql;
if let Some(tag_path) = &query.tag {
params.push(tag_path.clone());
sql = format!(
"WITH RECURSIVE descendants(id) AS (\
SELECT id FROM tags WHERE full_path = ?1 \
UNION ALL \
SELECT child.id FROM tags child \
JOIN descendants d ON child.parent_tag_id = d.id\
) {} {} \
JOIN entry_tag_assignments eta ON eta.entry_id = e.id \
JOIN descendants d ON eta.tag_id = d.id \
WHERE 1=1",
ENTRY_SELECT_COLS,
ENTRY_FROM_JOINS,
);
} else {
sql = format!(
"{} {} WHERE e.parent_entry_id IS NULL",
ENTRY_SELECT_COLS,
ENTRY_FROM_JOINS,
);
}
if let Some(q) = &query.q {
let term = format!("%{}%", q.to_lowercase());
let n = params.len() + 1;
sql.push_str(&format!(
" AND (LOWER(e.title) LIKE ?{n} OR LOWER(si.canonical_url) LIKE ?{n} \
OR LOWER(e.entry_uid) LIKE ?{n} OR LOWER(e.source_kind) LIKE ?{n} \
OR LOWER(e.entity_kind) LIKE ?{n} OR LOWER(e.visibility) LIKE ?{n})"
));
params.push(term);
}
if let Some(sk) = &query.source_kind {
let n = params.len() + 1;
sql.push_str(&format!(" AND e.source_kind = ?{n}"));
params.push(sk.clone());
}
if let Some(ek) = &query.entity_kind {
let n = params.len() + 1;
sql.push_str(&format!(" AND e.entity_kind = ?{n}"));
params.push(ek.clone());
}
if let Some(u) = &query.url {
let n = params.len() + 1;
sql.push_str(&format!(" AND LOWER(si.canonical_url) LIKE ?{n}"));
params.push(format!("%{}%", u.to_lowercase()));
}
if let Some(t) = &query.title {
let n = params.len() + 1;
sql.push_str(&format!(" AND LOWER(e.title) LIKE ?{n}"));
params.push(format!("%{}%", t.to_lowercase()));
}
if let Some(a) = &query.after {
let n = params.len() + 1;
sql.push_str(&format!(" AND e.archived_at >= ?{n}"));
params.push(a.clone());
}
if let Some(b) = &query.before {
let n = params.len() + 1;
sql.push_str(&format!(" AND e.archived_at < ?{n}"));
params.push(b.clone());
}
sql.push_str(" GROUP BY e.id ORDER BY e.archived_at DESC, e.id DESC");
let mut stmt = conn.prepare(&sql)?;
let entries = stmt
.query_map(rusqlite::params_from_iter(params.iter()), |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)?,
parent_entry_uid: row.get(9)?,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(entries)
}
fn tag_by_id(conn: &rusqlite::Connection, id: i64) -> Result<Tag> {
conn.query_row(
"SELECT tag_uid, name, slug, full_path FROM tags WHERE id = ?1",
[id],
|row| {
Ok(Tag {
tag_uid: row.get(0)?,
name: row.get(1)?,
slug: row.get(2)?,
full_path: row.get(3)?,
})
},
)
.context("tag not found by id")
}
/// Creates all tag path segments (idempotent) and returns the leaf `Tag`.
pub fn create_tag(conn: &rusqlite::Connection, full_path: &str) -> Result<Tag> {
let id = database::create_tag_path(conn, full_path)?;
tag_by_id(conn, id)
}
/// Returns the full tag tree with root nodes at the top level and children nested.
pub fn list_tag_tree(conn: &rusqlite::Connection) -> Result<Vec<TagNode>> {
use std::collections::HashMap;
let records = database::list_all_tags(conn)?;
let mut by_parent: HashMap<Option<i64>, Vec<database::TagRecord>> = HashMap::new();
for record in records {
by_parent.entry(record.parent_tag_id).or_default().push(record);
}
fn build_nodes(
parent_id: Option<i64>,
by_parent: &HashMap<Option<i64>, Vec<database::TagRecord>>,
) -> Vec<TagNode> {
let Some(children) = by_parent.get(&parent_id) else {
return Vec::new();
};
children
.iter()
.map(|r| TagNode {
tag: Tag {
tag_uid: r.tag_uid.clone(),
name: r.name.clone(),
slug: r.slug.clone(),
full_path: r.full_path.clone(),
},
children: build_nodes(Some(r.id), by_parent),
})
.collect()
}
Ok(build_nodes(None, &by_parent))
}
/// Returns the tags assigned to an entry.
///
/// Returns `Ok(None)` if the entry_uid does not exist (caller maps to 404).
/// Returns `Ok(Some([]))` if the entry exists but has no tags.
pub fn get_entry_tags(
conn: &rusqlite::Connection,
entry_uid: &str,
) -> Result<Option<Vec<Tag>>> {
let Some(entry_id) = conn
.query_row(
"SELECT id FROM archived_entries WHERE entry_uid = ?1",
[entry_uid],
|row| row.get::<_, i64>(0),
)
.optional()?
else {
return Ok(None);
};
let records = database::list_tags_for_entry(conn, entry_id)?;
Ok(Some(
records
.into_iter()
.map(|r| Tag {
tag_uid: r.tag_uid,
name: r.name,
slug: r.slug,
full_path: r.full_path,
})
.collect(),
))
}
/// Assigns a tag (by full path, creating it if needed) to an entry.
///
/// Returns `Ok(None)` if the entry_uid does not exist.
/// Returns `Ok(Some(tag))` on success.
pub fn assign_entry_tag(
conn: &rusqlite::Connection,
entry_uid: &str,
tag_full_path: &str,
) -> Result<Option<Tag>> {
let Some(entry_id) = conn
.query_row(
"SELECT id FROM archived_entries WHERE entry_uid = ?1",
[entry_uid],
|row| row.get::<_, i64>(0),
)
.optional()?
else {
return Ok(None);
};
let tag_id = database::create_tag_path(conn, tag_full_path)?;
database::assign_entry_to_tag(conn, entry_id, tag_id)?;
Ok(Some(tag_by_id(conn, tag_id)?))
}
/// Removes a tag assignment from an entry.
///
/// Returns `Ok(false)` if either the entry_uid or tag_uid is not found.
/// Returns `Ok(true)` on success (even if no row was deleted, i.e. assignment didn't exist).
pub fn remove_entry_tag(
conn: &rusqlite::Connection,
entry_uid: &str,
tag_uid: &str,
) -> Result<bool> {
let Some(entry_id) = conn
.query_row(
"SELECT id FROM archived_entries WHERE entry_uid = ?1",
[entry_uid],
|row| row.get::<_, i64>(0),
)
.optional()?
else {
return Ok(false);
};
let Some(tag_record) = database::get_tag_by_uid(conn, tag_uid)? else {
return Ok(false);
};
database::remove_entry_tag_assignment(conn, entry_id, tag_record.id)?;
Ok(true)
}
/// Returns all entries (root and child) assigned to any tag in the subtree rooted at `tag_full_path`.
pub fn entries_for_tag(
conn: &rusqlite::Connection,
tag_full_path: &str,
) -> Result<Vec<EntrySummary>> {
let mut stmt = conn.prepare(
"WITH RECURSIVE descendants(id) AS (
SELECT id FROM tags WHERE full_path = ?1
UNION ALL
SELECT child.id FROM tags child
JOIN descendants d ON child.parent_tag_id = d.id
)
SELECT e.entry_uid, e.archived_at, e.source_kind, e.entity_kind, e.title,
e.visibility, si.canonical_url, COUNT(ea.id) AS artifact_count,
COALESCE(SUM(b.byte_size), 0) AS total_artifact_bytes,
parent.entry_uid AS parent_entry_uid
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
LEFT JOIN archived_entries parent ON parent.id = e.parent_entry_id
JOIN entry_tag_assignments eta ON eta.entry_id = e.id
JOIN descendants d ON eta.tag_id = d.id
GROUP BY e.id
ORDER BY e.archived_at DESC, e.id DESC",
)?;
let entries = stmt
.query_map([tag_full_path], |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)?,
parent_entry_uid: row.get(9)?,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(entries)
}
#[cfg(test)]
mod tests {
use super::*;
@ -435,4 +830,367 @@ mod tests {
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].status, "completed");
}
#[test]
fn resolve_artifact_path_returns_absolute_path_within_store() {
let root = unique_path("archivr-resolve-artifact");
let store_path = root.join("store");
fs::create_dir_all(store_path.join("raw/a/b")).unwrap();
let artifact_file = store_path.join("raw/a/b/abc.pdf");
fs::write(&artifact_file, b"data").unwrap();
let artifact = EntryArtifactSummary {
artifact_role: "primary".to_string(),
storage_area: "raw".to_string(),
relpath: "raw/a/b/abc.pdf".to_string(),
byte_size: Some(4),
};
let resolved = resolve_artifact_path(&store_path, &artifact).unwrap();
assert_eq!(resolved, artifact_file.canonicalize().unwrap());
let _ = fs::remove_dir_all(&root);
}
#[test]
fn resolve_artifact_path_rejects_traversal() {
let root = unique_path("archivr-resolve-traversal");
let store_path = root.join("store");
fs::create_dir_all(&store_path).unwrap();
let artifact = EntryArtifactSummary {
artifact_role: "primary".to_string(),
storage_area: "raw".to_string(),
relpath: "../escaped.txt".to_string(),
byte_size: None,
};
assert!(resolve_artifact_path(&store_path, &artifact).is_err());
let _ = fs::remove_dir_all(&root);
}
// ---- parse_search_query tests ----
#[test]
fn parse_empty_query_returns_default() {
let q = parse_search_query("").unwrap();
assert!(q.q.is_none());
assert!(q.source_kind.is_none());
assert!(q.entity_kind.is_none());
}
#[test]
fn parse_plain_text_sets_q() {
let q = parse_search_query("polymarket").unwrap();
assert_eq!(q.q.as_deref(), Some("polymarket"));
}
#[test]
fn parse_prefix_source_sets_source_kind() {
let q = parse_search_query("source:x").unwrap();
assert_eq!(q.source_kind.as_deref(), Some("x"));
assert!(q.q.is_none());
}
#[test]
fn parse_prefix_type_sets_entity_kind() {
let q = parse_search_query("type:tweet").unwrap();
assert_eq!(q.entity_kind.as_deref(), Some("tweet"));
}
#[test]
fn parse_mixed_plain_and_prefix() {
let q = parse_search_query("polymarket type:tweet").unwrap();
assert_eq!(q.q.as_deref(), Some("polymarket"));
assert_eq!(q.entity_kind.as_deref(), Some("tweet"));
}
#[test]
fn parse_unknown_prefix_returns_err() {
let result = parse_search_query("foo:bar");
assert!(result.is_err());
assert_eq!(result.unwrap_err(), "foo");
}
#[test]
fn parse_after_before_dates() {
let q = parse_search_query("after:2026-01-01 before:2026-04-01").unwrap();
assert_eq!(q.after.as_deref(), Some("2026-01-01"));
assert_eq!(q.before.as_deref(), Some("2026-04-01"));
}
#[test]
fn parse_search_query_tag_prefix() {
let q = parse_search_query("tag:/science/cs").unwrap();
assert_eq!(q.tag.as_deref(), Some("/science/cs"));
assert_eq!(q.q, None);
}
// ---- search_entries tests ----
fn make_test_db_with_entries() -> rusqlite::Connection {
let conn = rusqlite::Connection::open_in_memory().unwrap();
database::initialize_schema(&conn).unwrap();
let user_id = database::ensure_default_user(&conn).unwrap();
let run = database::create_archive_run(&conn, user_id, 2).unwrap();
// Entry 1: tweet by source x
let si1 = database::upsert_source_identity(
&conn, "x", "tweet", Some("t-1"),
Some("https://x.com/user/status/1"),
"https://x.com/user/status/1",
).unwrap();
database::create_archived_entry(
&conn,
&database::NewEntry {
source_identity_id: si1,
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".to_string(),
title: Some("Polymarket tweet".to_string()),
visibility: "private".to_string(),
representation_kind: "json".to_string(),
source_metadata_json: "{}".to_string(),
display_metadata_json: None,
},
).unwrap();
// Entry 2: web page
let si2 = database::upsert_source_identity(
&conn, "web", "page", Some("page-1"),
Some("https://medium.com/article"),
"https://medium.com/article",
).unwrap();
database::create_archived_entry(
&conn,
&database::NewEntry {
source_identity_id: si2,
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("Resume Templates".to_string()),
visibility: "private".to_string(),
representation_kind: "html".to_string(),
source_metadata_json: "{}".to_string(),
display_metadata_json: None,
},
).unwrap();
conn
}
#[test]
fn search_empty_query_returns_all_root_entries() {
let conn = make_test_db_with_entries();
let all = list_root_entries(&conn).unwrap();
let searched = search_entries(&conn, &SearchEntriesQuery::default()).unwrap();
assert_eq!(all.len(), searched.len());
}
#[test]
fn search_q_filters_on_title() {
let conn = make_test_db_with_entries();
let results = search_entries(&conn, &SearchEntriesQuery {
q: Some("polymarket".to_string()),
..Default::default()
}).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].entity_kind, "tweet");
}
#[test]
fn search_entity_kind_exact_match() {
let conn = make_test_db_with_entries();
let results = search_entries(&conn, &SearchEntriesQuery {
entity_kind: Some("page".to_string()),
..Default::default()
}).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].source_kind, "web");
}
#[test]
fn search_url_like_filter() {
let conn = make_test_db_with_entries();
let results = search_entries(&conn, &SearchEntriesQuery {
url: Some("medium.com".to_string()),
..Default::default()
}).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].title.as_deref(), Some("Resume Templates"));
}
#[test]
fn search_no_match_returns_empty() {
let conn = make_test_db_with_entries();
let results = search_entries(&conn, &SearchEntriesQuery {
q: Some("zzznonexistent".to_string()),
..Default::default()
}).unwrap();
assert!(results.is_empty());
}
#[test]
fn search_multiple_filters_compound() {
let conn = make_test_db_with_entries();
let results = search_entries(&conn, &SearchEntriesQuery {
source_kind: Some("x".to_string()),
entity_kind: Some("tweet".to_string()),
..Default::default()
}).unwrap();
assert_eq!(results.len(), 1);
}
// ---- tag API tests ----
fn make_tag_test_db() -> (rusqlite::Connection, i64, i64) {
let conn = rusqlite::Connection::open_in_memory().unwrap();
database::initialize_schema(&conn).unwrap();
let user_id = database::ensure_default_user(&conn).unwrap();
let run = database::create_archive_run(&conn, user_id, 2).unwrap();
(conn, user_id, run.id)
}
fn make_entry_in_db(
conn: &rusqlite::Connection,
user_id: i64,
run_id: i64,
parent_entry_id: Option<i64>,
root_entry_id: Option<i64>,
title: &str,
url: &str,
) -> database::ArchivedEntry {
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,
root_entry_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()
}
#[test]
fn tag_tree_roots_and_children() {
let (conn, _, _) = make_tag_test_db();
create_tag(&conn, "/science/cs").unwrap();
create_tag(&conn, "/art").unwrap();
let tree = list_tag_tree(&conn).unwrap();
assert_eq!(tree.len(), 2, "expected two root nodes");
let science = tree.iter().find(|n| n.tag.slug == "science").expect("science root missing");
assert_eq!(science.children.len(), 1, "science should have one child");
assert_eq!(science.children[0].tag.slug, "cs");
let art = tree.iter().find(|n| n.tag.slug == "art").expect("art root missing");
assert!(art.children.is_empty(), "art should have no children");
}
#[test]
fn assign_entry_tag_is_idempotent() {
let (conn, user_id, run_id) = make_tag_test_db();
let entry = make_entry_in_db(&conn, user_id, run_id, None, None, "Test", "https://example.com/t1");
assign_entry_tag(&conn, &entry.entry_uid, "/science").unwrap();
assign_entry_tag(&conn, &entry.entry_uid, "/science").unwrap();
let tags = get_entry_tags(&conn, &entry.entry_uid).unwrap().unwrap();
assert_eq!(tags.len(), 1, "idempotent assign should yield exactly one tag");
}
#[test]
fn remove_entry_tag_clears_assignment() {
let (conn, user_id, run_id) = make_tag_test_db();
let entry = make_entry_in_db(&conn, user_id, run_id, None, None, "Test", "https://example.com/t2");
let tag = assign_entry_tag(&conn, &entry.entry_uid, "/science").unwrap().unwrap();
remove_entry_tag(&conn, &entry.entry_uid, &tag.tag_uid).unwrap();
let tags = get_entry_tags(&conn, &entry.entry_uid).unwrap().unwrap();
assert!(tags.is_empty(), "tag should be removed");
}
#[test]
fn entries_for_tag_includes_descendants() {
let (conn, user_id, run_id) = make_tag_test_db();
let entry = make_entry_in_db(&conn, user_id, run_id, None, None, "Compilers Paper", "https://example.com/c1");
assign_entry_tag(&conn, &entry.entry_uid, "/science/cs/compilers").unwrap();
let results = entries_for_tag(&conn, "/science").unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].entry_uid, entry.entry_uid);
}
#[test]
fn entries_for_tag_includes_child_entries() {
let (conn, user_id, run_id) = make_tag_test_db();
let parent = make_entry_in_db(&conn, user_id, run_id, None, None, "Playlist", "https://example.com/pl");
let child = make_entry_in_db(
&conn, user_id, run_id,
Some(parent.id), Some(parent.id),
"Video 1", "https://example.com/pl/v1",
);
assign_entry_tag(&conn, &child.entry_uid, "/science").unwrap();
let results = entries_for_tag(&conn, "/science").unwrap();
assert_eq!(results.len(), 1, "only the tagged child should appear");
assert_eq!(results[0].entry_uid, child.entry_uid);
assert_eq!(results[0].parent_entry_uid.as_deref(), Some(parent.entry_uid.as_str()));
}
#[test]
fn search_with_tag_filter_works() {
let (conn, user_id, run_id) = make_tag_test_db();
let entry = make_entry_in_db(&conn, user_id, run_id, None, None, "Science Article", "https://example.com/s1");
assign_entry_tag(&conn, &entry.entry_uid, "/science").unwrap();
let results = search_entries(&conn, &SearchEntriesQuery {
tag: Some("/science".to_string()),
..Default::default()
}).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].entry_uid, entry.entry_uid);
let empty = search_entries(&conn, &SearchEntriesQuery {
tag: Some("/art".to_string()),
..Default::default()
}).unwrap();
assert!(empty.is_empty(), "no entries under /art");
}
#[test]
fn search_without_tag_returns_roots_only() {
let (conn, user_id, run_id) = make_tag_test_db();
let parent = make_entry_in_db(&conn, user_id, run_id, None, None, "Parent", "https://example.com/par");
let _child = make_entry_in_db(
&conn, user_id, run_id,
Some(parent.id), Some(parent.id),
"Child", "https://example.com/par/c",
);
let results = search_entries(&conn, &SearchEntriesQuery::default()).unwrap();
assert_eq!(results.len(), 1, "plain search should return root entries only");
assert_eq!(results[0].entry_uid, parent.entry_uid);
}
}

File diff suppressed because it is too large Load diff

View file

@ -63,6 +63,16 @@ pub struct NewArtifact {
pub metadata_json: Option<String>,
}
#[derive(Debug, Clone)]
pub struct TagRecord {
pub id: i64,
pub tag_uid: String,
pub parent_tag_id: Option<i64>,
pub name: String,
pub slug: String,
pub full_path: String,
}
pub fn database_path(archive_path: &Path) -> PathBuf {
archive_path.join(DATABASE_FILE_NAME)
}
@ -214,6 +224,7 @@ pub fn initialize_schema(conn: &Connection) -> Result<()> {
CREATE INDEX IF NOT EXISTS idx_entry_artifacts_entry_id ON entry_artifacts(entry_id);
CREATE INDEX IF NOT EXISTS idx_entry_artifacts_blob_id ON entry_artifacts(blob_id);
CREATE INDEX IF NOT EXISTS idx_tags_parent_tag_id ON tags(parent_tag_id);
CREATE INDEX IF NOT EXISTS idx_entry_tag_assignments_tag_id ON entry_tag_assignments(tag_id);
"#,
)?;
Ok(())
@ -488,6 +499,104 @@ pub fn add_entry_artifact(conn: &Connection, artifact: &NewArtifact) -> Result<i
Ok(conn.last_insert_rowid())
}
pub fn remove_entry_tag_assignment(
conn: &Connection,
entry_id: i64,
tag_id: i64,
) -> Result<()> {
conn.execute(
"DELETE FROM entry_tag_assignments WHERE entry_id = ?1 AND tag_id = ?2",
params![entry_id, tag_id],
)?;
Ok(())
}
pub fn list_all_tags(conn: &Connection) -> Result<Vec<TagRecord>> {
let mut stmt = conn.prepare(
"SELECT id, tag_uid, parent_tag_id, name, slug, full_path
FROM tags
ORDER BY full_path",
)?;
let records = stmt
.query_map([], |row| {
Ok(TagRecord {
id: row.get(0)?,
tag_uid: row.get(1)?,
parent_tag_id: row.get(2)?,
name: row.get(3)?,
slug: row.get(4)?,
full_path: row.get(5)?,
})
})?
.collect::<Result<Vec<_>, _>>()
.context("failed to list tags")?;
Ok(records)
}
pub fn list_tags_for_entry(conn: &Connection, entry_id: i64) -> Result<Vec<TagRecord>> {
let mut stmt = conn.prepare(
"SELECT t.id, t.tag_uid, t.parent_tag_id, t.name, t.slug, t.full_path
FROM tags t
JOIN entry_tag_assignments eta ON eta.tag_id = t.id
WHERE eta.entry_id = ?1
ORDER BY t.full_path",
)?;
let records = stmt
.query_map([entry_id], |row| {
Ok(TagRecord {
id: row.get(0)?,
tag_uid: row.get(1)?,
parent_tag_id: row.get(2)?,
name: row.get(3)?,
slug: row.get(4)?,
full_path: row.get(5)?,
})
})?
.collect::<Result<Vec<_>, _>>()
.context("failed to list tags for entry")?;
Ok(records)
}
pub fn get_tag_by_uid(conn: &Connection, tag_uid: &str) -> Result<Option<TagRecord>> {
conn.query_row(
"SELECT id, tag_uid, parent_tag_id, name, slug, full_path
FROM tags WHERE tag_uid = ?1",
[tag_uid],
|row| {
Ok(TagRecord {
id: row.get(0)?,
tag_uid: row.get(1)?,
parent_tag_id: row.get(2)?,
name: row.get(3)?,
slug: row.get(4)?,
full_path: row.get(5)?,
})
},
)
.optional()
.context("failed to get tag by uid")
}
pub fn get_tag_by_path(conn: &Connection, full_path: &str) -> Result<Option<TagRecord>> {
conn.query_row(
"SELECT id, tag_uid, parent_tag_id, name, slug, full_path
FROM tags WHERE full_path = ?1",
[full_path],
|row| {
Ok(TagRecord {
id: row.get(0)?,
tag_uid: row.get(1)?,
parent_tag_id: row.get(2)?,
name: row.get(3)?,
slug: row.get(4)?,
full_path: row.get(5)?,
})
},
)
.optional()
.context("failed to get tag by path")
}
#[cfg(test)]
pub fn set_public_settings(
conn: &Connection,
@ -535,7 +644,6 @@ pub fn main_archive_entry_count(conn: &Connection) -> Result<i64> {
Ok(count)
}
#[cfg(test)]
pub fn create_tag_path(conn: &Connection, full_path: &str) -> Result<i64> {
let segments = normalized_tag_segments(full_path)?;
let mut parent_tag_id = None;
@ -577,7 +685,6 @@ pub fn create_tag_path(conn: &Connection, full_path: &str) -> Result<i64> {
Ok(current_id)
}
#[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)
@ -587,7 +694,6 @@ pub fn assign_entry_to_tag(conn: &Connection, entry_id: i64, tag_id: i64) -> Res
Ok(())
}
#[cfg(test)]
pub fn entry_count_for_tag_path(conn: &Connection, full_path: &str) -> Result<i64> {
let count = conn.query_row(
"WITH RECURSIVE descendants(id) AS (
@ -653,7 +759,6 @@ fn validate_visibility(visibility: &str) -> Result<()> {
}
}
#[cfg(test)]
fn normalized_tag_segments(full_path: &str) -> Result<Vec<&str>> {
let segments = full_path
.trim()
@ -669,7 +774,6 @@ fn normalized_tag_segments(full_path: &str) -> Result<Vec<&str>> {
Ok(segments)
}
#[cfg(test)]
fn humanize_slug(slug: &str) -> String {
slug.split('-')
.map(|part| {

View file

@ -1,3 +1,4 @@
pub mod capture;
pub mod archive;
pub mod database;
pub mod downloader;

View file

@ -10,8 +10,10 @@ axum.workspace = true
serde.workspace = true
tokio.workspace = true
toml.workspace = true
tower.workspace = true
tower-http.workspace = true
[dev-dependencies]
tempfile.workspace = true
tower.workspace = true
serde_json.workspace = true

View file

@ -1,9 +1,11 @@
mod registry;
mod routes;
use anyhow::Result;
use anyhow::{Context, Result};
use std::{net::SocketAddr, path::PathBuf};
const DEFAULT_BIND: &str = "127.0.0.1:8080";
#[tokio::main]
async fn main() -> Result<()> {
let config_path = std::env::args()
@ -11,8 +13,27 @@ async fn main() -> Result<()> {
.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 app = routes::app(registry.clone());
// Bind address priority: ARCHIVR_BIND env var > TOML bind field > default loopback.
let bind_str = std::env::var("ARCHIVR_BIND")
.ok()
.or_else(|| registry.bind.clone())
.unwrap_or_else(|| DEFAULT_BIND.to_string());
let addr: SocketAddr = bind_str
.parse()
.with_context(|| format!("invalid bind address: {bind_str}"))?;
// Warn when the server is reachable beyond localhost — it has no authentication.
if !addr.ip().is_loopback() {
eprintln!(
"warn: archivr-server is bound to {addr} — \
this server has no authentication. \
Only expose it on a trusted network."
);
}
let listener = tokio::net::TcpListener::bind(addr).await?;
println!("archivr-server listening on http://{addr}");
axum::serve(listener, app).await?;

View file

@ -14,7 +14,12 @@ pub struct MountedArchive {
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
pub struct ServerRegistry {
#[serde(default)]
pub archives: Vec<MountedArchive>,
/// Optional bind address for the server. Defaults to `127.0.0.1:8080`.
/// Set this to `0.0.0.0:8080` only on trusted networks — the server has no authentication.
#[serde(default)]
pub bind: Option<String>,
}
pub fn load_registry(path: &Path) -> Result<ServerRegistry> {
@ -78,6 +83,7 @@ mod tests {
label: "Personal".to_string(),
archive_path: archive_path.clone(),
}],
bind: None,
};
let path = temp.path().join("server.toml");
save_registry(&path, &registry).unwrap();
@ -102,10 +108,36 @@ mod tests {
archive_path: PathBuf::from("/tmp/b/.archivr"),
},
],
bind: None,
};
let err = validate_registry(&registry).unwrap_err().to_string();
assert!(err.contains("duplicate archive id"));
}
#[test]
fn registry_bind_field_round_trips() {
let toml = r#"bind = "127.0.0.1:9090""#;
let registry: ServerRegistry = toml::from_str(toml).unwrap();
assert_eq!(registry.bind.as_deref(), Some("127.0.0.1:9090"));
assert!(registry.archives.is_empty());
}
#[test]
fn registry_bind_field_defaults_to_none_when_absent() {
let toml = r#""#;
let registry: ServerRegistry = toml::from_str(toml).unwrap();
assert!(registry.bind.is_none());
}
#[test]
fn registry_bind_field_does_not_affect_archive_validation() {
let registry = ServerRegistry {
archives: vec![],
bind: Some("0.0.0.0:8080".to_string()),
};
// validate_registry does not reject non-loopback bind — that's main's concern.
assert!(validate_registry(&registry).is_ok());
}
}

View file

@ -1,14 +1,39 @@
// ── Security Boundary ────────────────────────────────────────────────────────
// All routes are currently trusted-local: no authentication or authorization
// middleware is applied. The server is designed to bind on 127.0.0.1 only.
//
// Route classification (for when middleware is added later):
//
// STATIC — safe to expose publicly: GET / and static /assets/*
// READ — safe to expose read-only: GET /health
// GET /api/archives
// GET /api/archives/:id/entries
// GET /api/archives/:id/entries/search
// GET /api/archives/:id/entries/:uid
// GET /api/archives/:id/entries/:uid/artifacts/:idx
// GET /api/archives/:id/runs
// GET /api/archives/:id/tags
// ADMIN — requires auth if ever public: GET /api/admin/archives
// WRITE — requires auth if ever public: POST /api/archives/:id/captures
// POST /api/archives/:id/tags
// PUT /api/archives/:id/tags/:tag_id
// DELETE /api/archives/:id/tags/:tag_id
//
// Do not add middleware here until the auth model is chosen. See docs/README.md.
// ─────────────────────────────────────────────────────────────────────────────
use std::{path::PathBuf, sync::Arc};
use archivr_core::{archive, database};
use archivr_core::{archive, capture, database};
use axum::{
Json, Router,
extract::{Path, State},
extract::{Path, Query, Request, State},
http::StatusCode,
response::{IntoResponse, Response},
routing::get,
routing::{delete, get, post},
};
use tower_http::services::{ServeDir, ServeFile};
use tower::ServiceExt;
use crate::registry::{MountedArchive, ServerRegistry};
@ -17,6 +42,12 @@ pub struct AppState {
registry: Arc<ServerRegistry>,
}
#[derive(Debug, serde::Deserialize, Default)]
pub struct EntrySearchParams {
pub q: Option<String>,
pub tag: Option<String>,
}
pub fn app(registry: ServerRegistry) -> Router {
let state = AppState {
registry: Arc::new(registry),
@ -27,11 +58,26 @@ pub fn app(registry: ServerRegistry) -> Router {
.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/search", get(search_entries_handler))
.route(
"/api/archives/:archive_id/entries/:entry_uid",
get(entry_detail),
)
.route(
"/api/archives/:archive_id/entries/:entry_uid/artifacts/:artifact_index",
get(serve_artifact),
)
.route("/api/archives/:archive_id/runs", get(list_runs))
.route("/api/archives/:archive_id/captures", post(capture_handler))
.route("/api/archives/:archive_id/tags", get(list_tags).post(create_tag_handler))
.route(
"/api/archives/:archive_id/entries/:entry_uid/tags",
get(list_entry_tags).post(assign_entry_tag_handler),
)
.route(
"/api/archives/:archive_id/entries/:entry_uid/tags/:tag_uid",
delete(remove_entry_tag_handler),
)
.nest_service("/assets", ServeDir::new(&static_dir))
.fallback_service(ServeFile::new(static_dir.join("index.html")))
.with_state(state)
@ -56,6 +102,22 @@ async fn list_entries(
Ok(Json(archive::list_root_entries(&conn)?))
}
async fn search_entries_handler(
State(state): State<AppState>,
Path(archive_id): Path<String>,
Query(params): Query<EntrySearchParams>,
) -> Result<Json<Vec<archive::EntrySummary>>, ApiError> {
let mounted = mounted_archive(&state, &archive_id)?;
let conn = database::open_or_initialize(&mounted.archive_path)?;
let raw = params.q.as_deref().unwrap_or("");
let mut search_query = archive::parse_search_query(raw)
.map_err(|prefix| ApiError::bad_request(&format!("unknown search prefix: {prefix}")))?;
if let Some(tag) = params.tag {
search_query.tag = Some(tag);
}
Ok(Json(archive::search_entries(&conn, &search_query)?))
}
async fn entry_detail(
State(state): State<AppState>,
Path((archive_id, entry_uid)): Path<(String, String)>,
@ -76,6 +138,125 @@ async fn list_runs(
Ok(Json(archive::list_runs(&conn)?))
}
async fn serve_artifact(
State(state): State<AppState>,
Path((archive_id, entry_uid, artifact_index)): Path<(String, String, usize)>,
req: Request,
) -> Result<Response, ApiError> {
let mounted = mounted_archive(&state, &archive_id)?;
let paths = archive::read_archive_paths(&mounted.archive_path)?;
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"))?;
let artifact = detail
.artifacts
.get(artifact_index)
.ok_or(ApiError::not_found("artifact index out of range"))?;
let file_path = archive::resolve_artifact_path(&paths.store_path, artifact)?;
// ServeFile streams the file, handles Range requests (video seeking),
// sets Content-Type/ETag/Last-Modified. Error type is Infallible.
Ok(ServeFile::new(&file_path)
.oneshot(req)
.await
.unwrap()
.into_response())
}
#[derive(Debug, serde::Deserialize)]
struct CreateTagBody {
path: String,
}
#[derive(Debug, serde::Deserialize)]
struct AssignTagBody {
tag_path: String,
}
async fn list_tags(
State(state): State<AppState>,
Path(archive_id): Path<String>,
) -> Result<Json<Vec<archive::TagNode>>, ApiError> {
let mounted = mounted_archive(&state, &archive_id)?;
let conn = database::open_or_initialize(&mounted.archive_path)?;
Ok(Json(archive::list_tag_tree(&conn)?))
}
async fn create_tag_handler(
State(state): State<AppState>,
Path(archive_id): Path<String>,
Json(body): Json<CreateTagBody>,
) -> Result<(StatusCode, Json<archive::Tag>), ApiError> {
if body.path.trim().is_empty() {
return Err(ApiError::bad_request("tag path must not be empty"));
}
let mounted = mounted_archive(&state, &archive_id)?;
let conn = database::open_or_initialize(&mounted.archive_path)?;
let tag = archive::create_tag(&conn, &body.path)?;
Ok((StatusCode::CREATED, Json(tag)))
}
async fn list_entry_tags(
State(state): State<AppState>,
Path((archive_id, entry_uid)): Path<(String, String)>,
) -> Result<Json<Vec<archive::Tag>>, ApiError> {
let mounted = mounted_archive(&state, &archive_id)?;
let conn = database::open_or_initialize(&mounted.archive_path)?;
match archive::get_entry_tags(&conn, &entry_uid)? {
Some(tags) => Ok(Json(tags)),
None => Err(ApiError::not_found("entry not found")),
}
}
async fn assign_entry_tag_handler(
State(state): State<AppState>,
Path((archive_id, entry_uid)): Path<(String, String)>,
Json(body): Json<AssignTagBody>,
) -> Result<(StatusCode, Json<archive::Tag>), ApiError> {
if body.tag_path.trim().is_empty() {
return Err(ApiError::bad_request("tag_path must not be empty"));
}
let mounted = mounted_archive(&state, &archive_id)?;
let conn = database::open_or_initialize(&mounted.archive_path)?;
match archive::assign_entry_tag(&conn, &entry_uid, &body.tag_path)? {
Some(tag) => Ok((StatusCode::CREATED, Json(tag))),
None => Err(ApiError::not_found("entry not found")),
}
}
async fn remove_entry_tag_handler(
State(state): State<AppState>,
Path((archive_id, entry_uid, tag_uid)): Path<(String, String, String)>,
) -> Result<StatusCode, ApiError> {
let mounted = mounted_archive(&state, &archive_id)?;
let conn = database::open_or_initialize(&mounted.archive_path)?;
if archive::remove_entry_tag(&conn, &entry_uid, &tag_uid)? {
Ok(StatusCode::NO_CONTENT)
} else {
Err(ApiError::not_found("entry or tag not found"))
}
}
#[derive(Debug, serde::Deserialize)]
struct CaptureBody {
locator: String,
}
async fn capture_handler(
State(state): State<AppState>,
Path(archive_id): Path<String>,
Json(body): Json<CaptureBody>,
) -> Result<Json<capture::CaptureResult>, ApiError> {
if body.locator.trim().is_empty() {
return Err(ApiError::bad_request("locator must not be empty"));
}
let mounted = mounted_archive(&state, &archive_id)?;
let archive_paths = archive::read_archive_paths(&mounted.archive_path)
.map_err(ApiError::from)?;
let result = capture::perform_capture(&archive_paths, &body.locator)
.map_err(ApiError::from)?;
Ok(Json(result))
}
fn mounted_archive<'a>(
state: &'a AppState,
archive_id: &str,
@ -101,6 +282,13 @@ impl ApiError {
message: message.to_string(),
}
}
fn bad_request(message: &str) -> Self {
Self {
status: StatusCode::BAD_REQUEST,
message: message.to_string(),
}
}
}
impl<E> From<E> for ApiError
@ -137,6 +325,7 @@ mod tests {
label: "Personal".to_string(),
archive_path: std::path::PathBuf::from("/tmp/personal/.archivr"),
}],
bind: None,
};
let response = app(registry)
.oneshot(
@ -165,4 +354,627 @@ mod tests {
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn artifact_missing_archive_returns_404() {
let response = app(ServerRegistry::default())
.oneshot(
Request::builder()
.uri("/api/archives/nope/entries/entry_abc/artifacts/0")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn artifact_missing_entry_returns_404() {
let dir = tempfile::tempdir().unwrap();
archivr_core::archive::initialize_archive(
dir.path(),
&dir.path().join("store"),
"test",
false,
)
.unwrap();
let archive_path = dir.path().join(".archivr");
let registry = ServerRegistry {
archives: vec![MountedArchive {
id: "test".to_string(),
label: "Test".to_string(),
archive_path,
}],
bind: None,
};
let response = app(registry)
.oneshot(
Request::builder()
.uri("/api/archives/test/entries/entry_doesnotexist/artifacts/0")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn artifact_out_of_range_index_returns_404() {
let dir = tempfile::tempdir().unwrap();
archivr_core::archive::initialize_archive(
dir.path(),
&dir.path().join("store"),
"test",
false,
)
.unwrap();
let archive_path = dir.path().join(".archivr");
let registry = ServerRegistry {
archives: vec![MountedArchive {
id: "test".to_string(),
label: "Test".to_string(),
archive_path,
}],
bind: None,
};
let response = app(registry)
.oneshot(
Request::builder()
.uri("/api/archives/test/entries/entry_doesnotexist/artifacts/99")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn artifact_serves_file_with_ok_status() {
// Initialize archive (creates .archivr dir, store dirs, and db)
let dir = tempfile::tempdir().unwrap();
let store_path = dir.path().join("store");
let paths =
archivr_core::archive::initialize_archive(dir.path(), &store_path, "test", false)
.unwrap();
// Write artifact file to the store
let artifact_relpath = "raw/a/b/test.html";
let artifact_dir = store_path.join("raw").join("a").join("b");
std::fs::create_dir_all(&artifact_dir).unwrap();
std::fs::write(artifact_dir.join("test.html"), b"<html>hello</html>").unwrap();
// Populate the database with user, source identity, run, entry, blob, artifact
let conn = database::open_or_initialize(&paths.archive_path).unwrap();
let user_id = database::ensure_default_user(&conn).unwrap();
let source_identity_id = database::upsert_source_identity(
&conn,
"web",
"page",
Some("test-page"),
Some("https://example.com/page"),
"https://example.com/page",
)
.unwrap();
let run = database::create_archive_run(&conn, user_id, 1).unwrap();
let entry = database::create_archived_entry(
&conn,
&database::NewEntry {
source_identity_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("Test Page".to_string()),
visibility: "private".to_string(),
representation_kind: "html".to_string(),
source_metadata_json: r#"{"source":"test"}"#.to_string(),
display_metadata_json: None,
},
)
.unwrap();
let blob_id = database::upsert_blob(
&conn,
&database::BlobRecord {
sha256: "abc123testblob".to_string(),
byte_size: 18,
mime_type: Some("text/html".to_string()),
extension: Some("html".to_string()),
raw_relpath: artifact_relpath.to_string(),
},
)
.unwrap();
database::add_entry_artifact(
&conn,
&database::NewArtifact {
entry_id: entry.id,
artifact_role: "primary_media".to_string(),
storage_area: "raw".to_string(),
relpath: artifact_relpath.to_string(),
blob_id: Some(blob_id),
logical_path: None,
metadata_json: None,
},
)
.unwrap();
drop(conn); // release before the HTTP handler opens the same db file
let registry = ServerRegistry {
archives: vec![MountedArchive {
id: "test".to_string(),
label: "Test".to_string(),
archive_path: paths.archive_path.clone(),
}],
bind: None,
};
let uri = format!(
"/api/archives/test/entries/{}/artifacts/0",
entry.entry_uid
);
let response = app(registry)
.oneshot(Request::builder().uri(&uri).body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn search_missing_archive_returns_404() {
let response = app(ServerRegistry::default())
.oneshot(
Request::builder()
.uri("/api/archives/nope/entries/search?q=anything")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn search_empty_q_returns_ok() {
let dir = tempfile::tempdir().unwrap();
archivr_core::archive::initialize_archive(
dir.path(),
&dir.path().join("store"),
"test",
false,
)
.unwrap();
let archive_path = dir.path().join(".archivr");
let registry = ServerRegistry {
archives: vec![MountedArchive {
id: "test".to_string(),
label: "Test".to_string(),
archive_path,
}],
bind: None,
};
let response = app(registry)
.oneshot(
Request::builder()
.uri("/api/archives/test/entries/search")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn search_unknown_prefix_returns_400() {
let dir = tempfile::tempdir().unwrap();
archivr_core::archive::initialize_archive(
dir.path(),
&dir.path().join("store"),
"test",
false,
)
.unwrap();
let archive_path = dir.path().join(".archivr");
let registry = ServerRegistry {
archives: vec![MountedArchive {
id: "test".to_string(),
label: "Test".to_string(),
archive_path,
}],
bind: None,
};
let response = app(registry)
.oneshot(
Request::builder()
.uri("/api/archives/test/entries/search?q=unknownprefix%3Aval")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
// ---- helpers ----
fn make_test_registry(dir: &tempfile::TempDir) -> (ServerRegistry, std::path::PathBuf) {
let paths = archivr_core::archive::initialize_archive(
dir.path(),
&dir.path().join("store"),
"test",
false,
)
.unwrap();
let registry = ServerRegistry {
archives: vec![MountedArchive {
id: "test".to_string(),
label: "Test".to_string(),
archive_path: paths.archive_path.clone(),
}],
bind: None,
};
(registry, paths.archive_path)
}
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();
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/test"),
"https://example.com/test",
)
.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: "web".to_string(),
entity_kind: "page".to_string(),
title: Some("Test Entry".to_string()),
visibility: "private".to_string(),
representation_kind: "html".to_string(),
source_metadata_json: "{}".to_string(),
display_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
.unwrap();
serde_json::from_slice(&bytes).unwrap()
}
fn json_body(payload: &serde_json::Value) -> Body {
Body::from(serde_json::to_vec(payload).unwrap())
}
// ---- tag route tests ----
#[tokio::test]
async fn test_list_tags_unknown_archive() {
let response = app(ServerRegistry::default())
.oneshot(
Request::builder()
.uri("/api/archives/ghost/tags")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_create_tag_unknown_archive() {
let response = app(ServerRegistry::default())
.oneshot(
Request::builder()
.method("POST")
.uri("/api/archives/ghost/tags")
.header("content-type", "application/json")
.body(json_body(&serde_json::json!({"path": "/science"})))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_create_tag_empty_path() {
let dir = tempfile::tempdir().unwrap();
let (registry, _) = make_test_registry(&dir);
let response = app(registry)
.oneshot(
Request::builder()
.method("POST")
.uri("/api/archives/test/tags")
.header("content-type", "application/json")
.body(json_body(&serde_json::json!({"path": ""})))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_tag_round_trip() {
let dir = tempfile::tempdir().unwrap();
let (registry, _) = make_test_registry(&dir);
let create_response = app(registry.clone())
.oneshot(
Request::builder()
.method("POST")
.uri("/api/archives/test/tags")
.header("content-type", "application/json")
.body(json_body(&serde_json::json!({"path": "/science"})))
.unwrap(),
)
.await
.unwrap();
assert_eq!(create_response.status(), StatusCode::CREATED);
let list_response = app(registry.clone())
.oneshot(
Request::builder()
.uri("/api/archives/test/tags")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(list_response.status(), StatusCode::OK);
let tree = body_json(list_response).await;
let slugs: Vec<&str> = tree
.as_array()
.unwrap()
.iter()
.map(|n| n["tag"]["slug"].as_str().unwrap())
.collect();
assert!(slugs.contains(&"science"), "expected 'science' in tag tree, got {slugs:?}");
}
#[tokio::test]
async fn test_entry_tag_assign_and_remove() {
let dir = tempfile::tempdir().unwrap();
let (registry, archive_path) = make_test_registry(&dir);
let entry = make_test_entry(&archive_path);
let entry_uid = entry.entry_uid.clone();
let entry_tags_uri = format!("/api/archives/test/entries/{entry_uid}/tags");
// Assign tag
let assign_response = app(registry.clone())
.oneshot(
Request::builder()
.method("POST")
.uri(&entry_tags_uri)
.header("content-type", "application/json")
.body(json_body(&serde_json::json!({"tag_path": "/science"})))
.unwrap(),
)
.await
.unwrap();
assert_eq!(assign_response.status(), StatusCode::CREATED);
let assigned_tag = body_json(assign_response).await;
let tag_uid = assigned_tag["tag_uid"].as_str().unwrap().to_string();
// List entry tags — should contain the assigned tag
let list_response = app(registry.clone())
.oneshot(
Request::builder()
.uri(&entry_tags_uri)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(list_response.status(), StatusCode::OK);
let tags = body_json(list_response).await;
assert_eq!(tags.as_array().unwrap().len(), 1);
// Remove tag
let delete_uri = format!("{entry_tags_uri}/{tag_uid}");
let delete_response = app(registry.clone())
.oneshot(
Request::builder()
.method("DELETE")
.uri(&delete_uri)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(delete_response.status(), StatusCode::NO_CONTENT);
// List entry tags again — should be empty
let list2_response = app(registry.clone())
.oneshot(
Request::builder()
.uri(&entry_tags_uri)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(list2_response.status(), StatusCode::OK);
let tags2 = body_json(list2_response).await;
assert!(tags2.as_array().unwrap().is_empty(), "tags should be empty after removal");
}
#[tokio::test]
async fn test_search_with_tag_param() {
let dir = tempfile::tempdir().unwrap();
let (registry, archive_path) = make_test_registry(&dir);
let entry = make_test_entry(&archive_path);
let entry_uid = entry.entry_uid.clone();
// Assign /science tag to entry
let assign_resp = app(registry.clone())
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/api/archives/test/entries/{entry_uid}/tags"))
.header("content-type", "application/json")
.body(json_body(&serde_json::json!({"tag_path": "/science"})))
.unwrap(),
)
.await
.unwrap();
assert_eq!(assign_resp.status(), StatusCode::CREATED, "assign tag should return 201");
// Search with ?tag=/science — entry should appear
let response = app(registry.clone())
.oneshot(
Request::builder()
.uri("/api/archives/test/entries/search?tag=%2Fscience")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let results = body_json(response).await;
assert_eq!(
results.as_array().unwrap().len(),
1,
"expected 1 result for /science tag, got {}",
results.as_array().unwrap().len()
);
// Search with ?tag=/art — should return empty
let response2 = app(registry.clone())
.oneshot(
Request::builder()
.uri("/api/archives/test/entries/search?tag=%2Fart")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response2.status(), StatusCode::OK);
let results2 = body_json(response2).await;
assert!(
results2.as_array().unwrap().is_empty(),
"expected 0 results for /art tag"
);
}
#[tokio::test]
async fn test_list_entry_tags_unknown_entry() {
let dir = tempfile::tempdir().unwrap();
let (registry, _) = make_test_registry(&dir);
let response = app(registry)
.oneshot(
Request::builder()
.uri("/api/archives/test/entries/ghost_uid/tags")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_assign_entry_tag_unknown_entry() {
let dir = tempfile::tempdir().unwrap();
let (registry, _) = make_test_registry(&dir);
let response = app(registry)
.oneshot(
Request::builder()
.method("POST")
.uri("/api/archives/test/entries/ghost_uid/tags")
.header("content-type", "application/json")
.body(json_body(&serde_json::json!({"tag_path": "/science"})))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_assign_entry_tag_empty_tag_path() {
let dir = tempfile::tempdir().unwrap();
let (registry, archive_path) = make_test_registry(&dir);
let entry = make_test_entry(&archive_path);
let entry_uid = entry.entry_uid.clone();
let response = app(registry)
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/api/archives/test/entries/{entry_uid}/tags"))
.header("content-type", "application/json")
.body(json_body(&serde_json::json!({"tag_path": ""})))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_remove_entry_tag_unknown_entry() {
let dir = tempfile::tempdir().unwrap();
let (registry, _) = make_test_registry(&dir);
let response = app(registry)
.oneshot(
Request::builder()
.method("DELETE")
.uri("/api/archives/test/entries/ghost_uid/tags/ghost_tag_uid")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn capture_rejects_empty_locator() {
let response = app(ServerRegistry::default())
.oneshot(
Request::builder()
.method("POST")
.uri("/api/archives/test/captures")
.header("content-type", "application/json")
.body(Body::from(r#"{"locator":""}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn capture_rejects_unknown_archive() {
let response = app(ServerRegistry::default())
.oneshot(
Request::builder()
.method("POST")
.uri("/api/archives/nonexistent/captures")
.header("content-type", "application/json")
.body(Body::from(r#"{"locator":"tweet:1234567890"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
}

View file

@ -2,9 +2,11 @@ const state = {
archives: [],
archiveId: null,
entries: [],
filteredEntries: [],
selectedEntryUid: null,
selectedEntry: null,
tagFilter: null,
};
let selectSeq = 0;
const archiveSwitcher = document.querySelector("#archive-switcher");
const entriesBody = document.querySelector("#entries-body");
@ -14,6 +16,17 @@ const navButtons = document.querySelectorAll(".nav-link");
const searchInput = document.querySelector("#search");
const resultCount = document.querySelector("#result-count");
const adminArchives = document.querySelector("#admin-archives");
const tagTree = document.querySelector("#tag-tree");
const entryTagsEl = document.querySelector("#entry-tags");
const assignTagForm = document.querySelector("#assign-tag-form");
const assignTagInput = document.querySelector("#assign-tag-input");
const assignTagBtn = document.querySelector("#assign-tag-btn");
const captureButton = document.querySelector('.capture-button');
const captureDialog = document.querySelector('#capture-dialog');
const captureLocatorInput = document.querySelector('#capture-locator');
const captureSubmitBtn = document.querySelector('#capture-submit-btn');
const captureCancelBtn = document.querySelector('#capture-cancel-btn');
const captureError = document.querySelector('#capture-error');
function formatBytes(bytes) {
if (!bytes) return "0 B";
@ -71,33 +84,26 @@ function renderArchives() {
}
}
function applyEntryFilter() {
const query = searchInput.value.trim().toLowerCase();
if (!query) {
state.filteredEntries = state.entries;
return;
}
state.filteredEntries = state.entries.filter((entry) => {
const haystack = [
entry.title,
entry.original_url,
entry.entry_uid,
entry.source_kind,
entry.entity_kind,
entry.visibility,
]
.filter(Boolean)
.join(" ")
.toLowerCase();
return haystack.includes(query);
});
}
function renderEntries() {
entriesBody.innerHTML = "";
resultCount.textContent = `${state.filteredEntries.length} entries`;
if (state.entries.length === 0 && searchInput.value.trim()) {
resultCount.textContent = "No results.";
} else {
resultCount.textContent = `${state.entries.length} entries`;
}
if (state.tagFilter) {
const badge = document.createElement("button");
badge.className = "tag-filter-badge";
badge.textContent = `× ${state.tagFilter}`;
badge.addEventListener("click", () => {
state.tagFilter = null;
if (state.archiveId) loadEntries(searchInput.value);
});
resultCount.appendChild(badge);
}
for (const entry of state.filteredEntries) {
for (const entry of state.entries) {
const row = document.createElement("tr");
row.tabIndex = 0;
row.dataset.entryUid = entry.entry_uid;
@ -132,30 +138,139 @@ function renderEntries() {
function renderContextDetail(detail) {
contextBody.innerHTML = "";
const title = document.createElement("strong");
title.textContent = valueText(detail.summary.title) || valueText(detail.summary.entry_uid);
contextBody.append(title);
const items = [
// Title
const titleEl = document.createElement("strong");
titleEl.className = "rail-entry-title";
titleEl.textContent =
valueText(detail.summary.title) || valueText(detail.summary.entry_uid);
contextBody.append(titleEl);
// Metadata section
const metaSection = document.createElement("div");
metaSection.className = "rail-section";
if (detail.summary.original_url) {
const urlRow = document.createElement("div");
urlRow.className = "rail-item";
const urlLabel = document.createElement("span");
urlLabel.className = "rail-label";
urlLabel.textContent = "Original URL";
const urlLink = document.createElement("a");
urlLink.href = detail.summary.original_url;
urlLink.target = "_blank";
urlLink.rel = "noopener noreferrer";
urlLink.className = "rail-url-link";
urlLink.textContent = detail.summary.original_url;
urlRow.append(urlLabel, document.createTextNode(": "), urlLink);
metaSection.append(urlRow);
}
const metaFields = [
["Added", detail.summary.archived_at],
["Source", detail.summary.source_kind],
["Type", detail.summary.entity_kind],
["Visibility", detail.summary.visibility],
["Artifacts", detail.artifacts.length],
["Structured root", detail.structured_root_relpath],
];
for (const [label, value] of items) {
for (const [label, value] of metaFields) {
const item = document.createElement("div");
item.className = "rail-item";
item.textContent = `${label}: ${valueText(value)}`;
contextBody.append(item);
const labelEl = document.createElement("span");
labelEl.className = "rail-label";
labelEl.textContent = label;
item.append(labelEl, document.createTextNode(`: ${valueText(value)}`));
metaSection.append(item);
}
contextBody.append(metaSection);
// Artifacts section
if (detail.artifacts.length > 0) {
const artifactsSection = document.createElement("div");
artifactsSection.className = "rail-section";
const artifactsHeading = document.createElement("div");
artifactsHeading.className = "rail-section-heading";
artifactsHeading.textContent = `Artifacts (${detail.artifacts.length})`;
artifactsSection.append(artifactsHeading);
const list = document.createElement("ul");
list.className = "artifact-list";
detail.artifacts.forEach((artifact, index) => {
const li = document.createElement("li");
const a = document.createElement("a");
a.href = `/api/archives/${state.archiveId}/entries/${detail.summary.entry_uid}/artifacts/${index}`;
a.target = "_blank";
a.rel = "noopener noreferrer";
a.className = "artifact-link";
const roleName = artifact.artifact_role.replace(/_/g, " ");
const size =
artifact.byte_size != null ? ` (${formatBytes(artifact.byte_size)})` : "";
a.textContent = `${roleName}${size}`;
li.append(a);
list.append(li);
});
artifactsSection.append(list);
contextBody.append(artifactsSection);
} else {
const noArtifacts = document.createElement("div");
noArtifacts.className = "rail-item muted";
noArtifacts.textContent = "No artifacts.";
contextBody.append(noArtifacts);
}
}
function renderEntryTags(tags, entryUid) {
entryTagsEl.innerHTML = "";
if (!tags.length) {
entryTagsEl.textContent = "No tags.";
return;
}
for (const tag of tags) {
const pill = document.createElement("span");
pill.className = "tag-pill";
pill.textContent = tag.name;
pill.title = tag.full_path;
const removeBtn = document.createElement("button");
removeBtn.className = "remove-tag";
removeBtn.textContent = "×";
removeBtn.title = `Remove tag ${tag.full_path}`;
removeBtn.addEventListener("click", async () => {
const resp = await fetch(
`/api/archives/${state.archiveId}/entries/${entryUid}/tags/${tag.tag_uid}`,
{ method: "DELETE" }
);
if (!resp.ok) {
removeBtn.title = `Remove failed (${resp.status})`;
return;
}
const updated = await getJson(
`/api/archives/${state.archiveId}/entries/${entryUid}/tags`
);
renderEntryTags(updated, entryUid);
loadTagTree();
});
pill.appendChild(removeBtn);
entryTagsEl.appendChild(pill);
}
}
async function selectEntry(entry) {
const seq = ++selectSeq;
state.selectedEntryUid = entry.entry_uid;
state.selectedEntry = entry;
renderEntries();
const detail = await getJson(`/api/archives/${state.archiveId}/entries/${entry.entry_uid}`);
const detail = await getJson(
`/api/archives/${state.archiveId}/entries/${entry.entry_uid}`
);
if (seq !== selectSeq) return;
renderContextDetail(detail);
entryTagsEl.hidden = false;
assignTagForm.hidden = false;
entryTagsEl.innerHTML = "";
const tags = await getJson(
`/api/archives/${state.archiveId}/entries/${entry.entry_uid}/tags`
);
if (seq !== selectSeq) return;
renderEntryTags(tags, entry.entry_uid);
}
async function loadRuns() {
@ -172,12 +287,68 @@ async function loadRuns() {
}
}
async function loadEntries() {
state.entries = await getJson(`/api/archives/${state.archiveId}/entries`);
state.selectedEntryUid = null;
applyEntryFilter();
async function loadEntries(q = "") {
const trimmed = q.trim();
const params = new URLSearchParams();
if (trimmed) params.set("q", trimmed);
if (state.tagFilter) params.set("tag", state.tagFilter);
const url =
trimmed || state.tagFilter
? `/api/archives/${state.archiveId}/entries/search?${params}`
: `/api/archives/${state.archiveId}/entries`;
searchInput.setAttribute("aria-busy", "true");
try {
state.entries = await getJson(url);
} catch (err) {
resultCount.textContent = "Search failed. Try again.";
state.entries = [];
} finally {
searchInput.removeAttribute("aria-busy");
}
renderEntries();
contextBody.textContent = "Select an entry.";
}
async function loadTagTree() {
if (!state.archiveId) return;
const nodes = await getJson(`/api/archives/${state.archiveId}/tags`);
tagTree.innerHTML = "";
renderTagTree(nodes, tagTree);
}
function renderTagTree(nodes, container) {
if (!nodes.length) {
container.textContent = "No tags yet.";
return;
}
const ul = document.createElement("ul");
ul.className = "tag-tree-list";
for (const node of nodes) {
const li = document.createElement("li");
const btn = document.createElement("button");
btn.className = "tag-node-btn";
if (state.tagFilter === node.tag.full_path) btn.classList.add("is-active");
btn.textContent = node.tag.name;
btn.title = node.tag.full_path;
btn.addEventListener("click", () => {
if (state.tagFilter === node.tag.full_path) {
state.tagFilter = null;
} else {
state.tagFilter = node.tag.full_path;
}
// Switch to archive view and reload
switchView("archive");
if (state.archiveId) loadEntries(searchInput.value);
});
li.appendChild(btn);
if (node.children?.length) {
const childContainer = document.createElement("div");
childContainer.className = "tag-children";
renderTagTree(node.children, childContainer);
li.appendChild(childContainer);
}
ul.appendChild(li);
}
container.appendChild(ul);
}
async function loadArchives() {
@ -187,32 +358,118 @@ async function loadArchives() {
if (state.archiveId) {
await loadEntries();
await loadRuns();
loadTagTree();
} else {
contextBody.textContent = "No archives mounted.";
resultCount.textContent = "0 entries";
}
}
function debounce(fn, ms) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), ms);
};
}
archiveSwitcher.addEventListener("change", async () => {
state.tagFilter = null;
state.selectedEntry = null;
state.selectedEntryUid = null;
entryTagsEl.hidden = true;
assignTagForm.hidden = true;
state.archiveId = archiveSwitcher.value;
await loadEntries();
await loadRuns();
loadTagTree();
});
const debouncedSearch = debounce((q) => {
if (state.archiveId) loadEntries(q);
}, 300);
searchInput.addEventListener("input", () => {
applyEntryFilter();
renderEntries();
debouncedSearch(searchInput.value);
});
function switchView(name) {
navButtons.forEach(b => b.classList.toggle("is-active", b.dataset.view === name));
document.querySelectorAll(".view").forEach(v => v.classList.remove("is-active"));
document.querySelector(`#${name}-view`)?.classList.add("is-active");
}
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");
switchView(button.dataset.view);
if (button.dataset.view === "tags") loadTagTree();
});
});
assignTagBtn.addEventListener("click", async () => {
const path = assignTagInput.value.trim();
if (!path || !state.selectedEntry) return;
const resp = await fetch(
`/api/archives/${state.archiveId}/entries/${state.selectedEntry.entry_uid}/tags`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tag_path: path }),
}
);
if (resp.ok) {
assignTagInput.setCustomValidity("");
assignTagInput.value = "";
const tags = await getJson(
`/api/archives/${state.archiveId}/entries/${state.selectedEntry.entry_uid}/tags`
);
renderEntryTags(tags, state.selectedEntry.entry_uid);
loadTagTree();
} else {
assignTagInput.setCustomValidity(`Failed to add tag (${resp.status})`);
assignTagInput.reportValidity();
}
});
captureButton.addEventListener('click', () => {
captureLocatorInput.value = '';
captureError.hidden = true;
captureDialog.showModal();
});
captureCancelBtn.addEventListener('click', () => captureDialog.close());
captureSubmitBtn.addEventListener('click', async () => {
const locator = captureLocatorInput.value.trim();
if (!locator) {
captureError.textContent = 'Enter a locator.';
captureError.hidden = false;
return;
}
captureSubmitBtn.disabled = true;
captureSubmitBtn.textContent = 'Capturing\u2026';
captureError.hidden = true;
try {
const res = await fetch(`/api/archives/${state.archiveId}/captures`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ locator }),
});
if (!res.ok) {
const msg = await res.text();
throw new Error(msg || `HTTP ${res.status}`);
}
captureDialog.close();
await Promise.all([loadEntries(searchInput.value), loadRuns()]);
} catch (e) {
captureError.textContent = e.message;
captureError.hidden = false;
} finally {
captureSubmitBtn.disabled = false;
captureSubmitBtn.textContent = 'Capture';
}
});
loadArchives().catch((error) => {
contextBody.textContent = `Failed to load archives: ${error.message}`;
});

View file

@ -14,6 +14,7 @@
<button class="nav-link is-active" data-view="archive">Archive</button>
<button class="nav-link" data-view="runs">Runs</button>
<button class="nav-link" data-view="admin">Admin</button>
<button class="nav-link" data-view="tags">Tags</button>
</nav>
<button class="capture-button">+ Capture</button>
</header>
@ -66,14 +67,37 @@
<h1>Mounted Archives</h1>
<div id="admin-archives" class="admin-list"></div>
</section>
<section id="tags-view" class="view">
<div id="tag-tree" class="tag-tree"></div>
</section>
</section>
<aside class="context-rail">
<div class="rail-title">Context</div>
<div id="context-body" class="rail-body">Select an entry.</div>
<div id="entry-tags" class="entry-tags" hidden></div>
<div id="assign-tag-form" class="assign-tag-form" hidden>
<input id="assign-tag-input" class="assign-tag-input" type="text" placeholder="/science/cs" autocomplete="off">
<button id="assign-tag-btn" class="assign-tag-btn">Add tag</button>
</div>
</aside>
</main>
<dialog id="capture-dialog" class="capture-dialog">
<div class="capture-dialog-inner">
<h2 class="capture-dialog-title">Capture</h2>
<label for="capture-locator" class="capture-label">Locator</label>
<input id="capture-locator" class="capture-input" type="text"
placeholder="tweet:1234567890 or https://..." autocomplete="off">
<div id="capture-error" class="capture-error" hidden></div>
<div class="capture-actions">
<button type="button" id="capture-cancel-btn" class="capture-cancel">Cancel</button>
<button type="button" id="capture-submit-btn" class="capture-submit">Capture</button>
</div>
</div>
</dialog>
<script type="module" src="/assets/app.js"></script>
</body>
</html>

View file

@ -312,3 +312,277 @@ select {
min-width: 860px;
}
}
.rail-entry-title {
display: block;
font-size: 15px;
font-weight: 700;
color: var(--ink);
margin-bottom: 12px;
line-height: 1.4;
}
.rail-section {
margin-bottom: 18px;
}
.rail-section-heading {
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--accent);
margin-bottom: 6px;
}
.rail-label {
font-weight: 600;
color: var(--ink);
}
.rail-url-link {
color: var(--accent);
word-break: break-all;
font-size: 13px;
}
.artifact-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.artifact-link {
display: block;
padding: 6px 8px;
background: var(--paper-3);
border: 1px solid var(--line);
color: var(--accent);
text-decoration: none;
font-size: 13px;
border-radius: 3px;
}
.artifact-link:hover {
background: var(--line);
text-decoration: underline;
}
/* Search loading state */
.search-input[aria-busy="true"] {
cursor: progress;
}
/* Tag tree */
.tag-tree {
padding: 12px;
}
.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 pills in context rail */
.entry-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 8px 0;
}
.tag-pill {
display: inline-flex;
align-items: center;
gap: 4px;
background: var(--paper-2);
border: 1px solid var(--line);
border-radius: 3px;
font-size: 12px;
padding: 2px 6px;
color: var(--ink);
}
.remove-tag {
border: 0;
background: none;
cursor: pointer;
color: var(--muted);
padding: 0;
font-size: 14px;
line-height: 1;
}
.remove-tag:hover {
color: var(--accent);
}
/* Assign tag form */
.assign-tag-form {
display: flex;
gap: 6px;
padding: 6px 0 8px;
border-top: 1px solid var(--line-soft);
margin-top: 4px;
}
.assign-tag-input {
flex: 1;
min-width: 0;
border: 1px solid var(--line);
background: var(--paper-3);
color: var(--ink);
padding: 4px 7px;
font-size: 12px;
}
.assign-tag-btn {
border: 1px solid var(--line);
background: var(--paper-2);
color: var(--ink);
cursor: pointer;
padding: 4px 10px;
font-size: 12px;
white-space: nowrap;
}
.assign-tag-btn:hover {
background: var(--paper);
}
/* Tag filter badge in search row */
.tag-filter-badge {
display: inline-flex;
align-items: center;
gap: 4px;
background: var(--accent);
color: #fff;
border: 0;
border-radius: 3px;
font-size: 12px;
padding: 2px 8px;
cursor: pointer;
margin-left: 8px;
}
.tag-filter-badge:hover {
opacity: 0.85;
}
/* Capture dialog */
.capture-dialog {
border: 2px solid var(--ink);
background: var(--paper);
padding: 0;
min-width: 360px;
max-width: 520px;
box-shadow: 4px 4px 0 var(--ink);
}
.capture-dialog::backdrop {
background: rgba(0, 0, 0, 0.45);
}
.capture-dialog-inner {
padding: 28px;
}
.capture-dialog-title {
margin: 0 0 16px;
font-size: 18px;
font-family: Georgia, "Times New Roman", serif;
}
.capture-label {
display: block;
font-size: 13px;
font-weight: 600;
margin-bottom: 6px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.capture-input {
width: 100%;
height: 42px;
border: 2px solid var(--ink);
background: var(--paper-3);
color: var(--ink);
padding: 0 12px;
font-size: 15px;
margin-bottom: 10px;
outline-offset: 3px;
}
.capture-error {
color: var(--accent);
font-size: 13px;
margin-bottom: 10px;
min-height: 18px;
}
.capture-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 16px;
}
.capture-cancel {
border: 1px solid var(--line);
background: none;
color: var(--ink);
padding: 8px 18px;
cursor: pointer;
}
.capture-cancel:hover {
background: var(--paper-2);
}
.capture-submit {
border: 0;
background: var(--ink);
color: var(--paper);
padding: 8px 20px;
cursor: pointer;
font-weight: 600;
}
.capture-submit:hover {
opacity: 0.85;
}
.capture-submit:disabled {
opacity: 0.45;
cursor: default;
}

View file

@ -56,6 +56,79 @@ This project aims to provide a reliable solution for archiving important data fr
- Direct platform URLs
- Platform shorthand inputs such as `tweet:...`, `yt:...`, or `instagram:...`
## Running Archivr
Archivr currently ships as two binaries:
- `archivr`
- The CLI for creating and writing to one archive.
- Use this for `init` and `archive`.
- `archivr-server`
- The web server for reading one or more existing archives through the browser UI.
- Use this after archives already exist.
With Nix, run the CLI with:
```sh
nix run .#archivr -- init ./my-archive --name "My Archive"
nix run .#archivr -- archive file:///absolute/path/to/file.pdf
```
Run the web server with:
```sh
nix run .#archivr-server -- ./archivr-server.toml
```
The server expects a TOML registry file. If no path is passed, it reads `./archivr-server.toml`.
Example:
```toml
[[archives]]
id = "personal"
label = "Personal"
archive_path = "/absolute/path/to/my-archive/.archivr"
```
Then open:
```text
http://127.0.0.1:8080
```
When installed through Nix, `archivr-server` is wrapped so it can find the static web UI assets automatically. The wrapper sets `ARCHIVR_STATIC_DIR` to the installed static asset directory. Running from source with `cargo run -p archivr-server` falls back to `crates/archivr-server/static`.
### Security and Deployment
`archivr-server` is a **local-only tool by default**. It binds to `127.0.0.1:8080` and has no authentication or access control. Do not expose it to a public network or a shared LAN without understanding the risks.
**Changing the bind address**
You can set the bind address in your TOML config:
```toml
# Optional. Default: 127.0.0.1:8080
# Only change this if you know what you are doing — the server has no authentication.
bind = "127.0.0.1:9090"
```
Or override it with the `ARCHIVR_BIND` environment variable:
```sh
ARCHIVR_BIND=127.0.0.1:9090 nix run .#archivr-server -- ./archivr-server.toml
```
If the server is started with a non-loopback address (e.g. `0.0.0.0`), it prints a warning to stderr:
```text
warn: archivr-server is bound to 0.0.0.0:8080 — this server has no authentication. Only expose it on a trusted network.
```
**When will auth be added?**
Auth and session handling will be designed when remote or public hosting becomes a real requirement. Until then, keep the server on loopback. See `crates/archivr-server/src/routes.rs` for the route classification that will guide where middleware is applied.
### Supported Platforms
- Local files: `file:///absolute/path/to/file.ext`

View file

@ -129,18 +129,31 @@
pname = "archivr-server-wrapped";
inherit version;
nativeBuildInputs = [ pkgs.makeWrapper ];
buildInputs = [ tweetPython ];
phases = [ "installPhase" ];
installPhase = ''
mkdir -p $out/bin $out/libexec/archivr-server $out/share/archivr-server/static
cp ${archivr_server_unwrapped}/bin/archivr-server $out/libexec/archivr-server/archivr-server
cp ${./vendor/twitter/scrape_user_tweet_contents.py} $out/libexec/archivr-server/scrape_user_tweet_contents.py
chmod +x $out/libexec/archivr-server/scrape_user_tweet_contents.py
cp -r ${./crates/archivr-server/static}/* $out/share/archivr-server/static/
makeWrapper $out/libexec/archivr-server/archivr-server $out/bin/archivr-server \
--set ARCHIVR_STATIC_DIR $out/share/archivr-server/static
--set ARCHIVR_STATIC_DIR $out/share/archivr-server/static \
--set ARCHIVR_TWEET_PYTHON ${tweetPython}/bin/python3 \
--set ARCHIVR_TWEET_SCRAPER $out/libexec/archivr-server/scrape_user_tweet_contents.py
'';
};
archivr-all = pkgs.symlinkJoin {
name = "archivr-all";
paths = [
archivr
archivr_server
];
};
in
{
default = archivr;
default = archivr-all;
archivr-all = archivr-all;
archivr = archivr;
archivr-cli = archivr;
archivr-cli-unwrapped = archivr_cli_unwrapped;