mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-22 03:05:32 +02:00
Compare commits
16 commits
b56c969624
...
b3f1932b62
| Author | SHA1 | Date | |
|---|---|---|---|
| b3f1932b62 | |||
| 17d116655a | |||
| 2d7a4f1766 | |||
| 10c41ef84f | |||
| 5ea0c396b8 | |||
| e3face0a1b | |||
| 8d0352c2c4 | |||
| b70ab4945e | |||
| 9c4f05b013 | |||
| 5803f2119b | |||
| 83f909585f | |||
| db953de67a | |||
| fa2968a21a | |||
| dcf9e127bb | |||
| 9337a822ad | |||
| 44c17eee3d |
17 changed files with 3743 additions and 1287 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -2,8 +2,9 @@
|
||||||
|
|
||||||
!.gitignore
|
!.gitignore
|
||||||
|
|
||||||
!docs
|
!docs/
|
||||||
!docs/**
|
!docs/LICENSE
|
||||||
|
!docs/README*
|
||||||
|
|
||||||
!crates
|
!crates
|
||||||
!crates/**
|
!crates/**
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,13 @@
|
||||||
|
|
||||||
This document explains the current project shape after the workspace refactor.
|
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
|
## The Big Model
|
||||||
|
|
||||||
Archivr is now a Rust workspace with three crates:
|
Archivr is now a Rust workspace with three crates:
|
||||||
|
|
@ -57,6 +64,40 @@ label = "Personal"
|
||||||
archive_path = "/path/to/archive/.archivr"
|
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
|
## Write Data Flow
|
||||||
|
|
||||||
When archiving something through the CLI:
|
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.
|
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.
|
Admin is a mounted-archives view, not a management system.
|
||||||
|
|
|
||||||
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -124,6 +124,7 @@ dependencies = [
|
||||||
"archivr-core",
|
"archivr-core",
|
||||||
"axum",
|
"axum",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
"toml",
|
"toml",
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -25,6 +25,7 @@ pub struct EntrySummary {
|
||||||
pub original_url: Option<String>,
|
pub original_url: Option<String>,
|
||||||
pub artifact_count: i64,
|
pub artifact_count: i64,
|
||||||
pub total_artifact_bytes: i64,
|
pub total_artifact_bytes: i64,
|
||||||
|
pub parent_entry_uid: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
|
||||||
|
|
@ -57,6 +58,20 @@ pub struct RunSummary {
|
||||||
pub error_summary: Option<String>,
|
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>> {
|
pub fn find_archive_path_from(start: &Path) -> Result<Option<PathBuf>> {
|
||||||
let mut dir = start.to_path_buf();
|
let mut dir = start.to_path_buf();
|
||||||
loop {
|
loop {
|
||||||
|
|
@ -167,7 +182,8 @@ pub fn list_root_entries(conn: &rusqlite::Connection) -> Result<Vec<EntrySummary
|
||||||
e.visibility,
|
e.visibility,
|
||||||
si.canonical_url,
|
si.canonical_url,
|
||||||
COUNT(ea.id) AS artifact_count,
|
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
|
FROM archived_entries e
|
||||||
JOIN source_identities si ON si.id = e.source_identity_id
|
JOIN source_identities si ON si.id = e.source_identity_id
|
||||||
LEFT JOIN entry_artifacts ea ON ea.entry_id = e.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)?,
|
original_url: row.get(6)?,
|
||||||
artifact_count: row.get(7)?,
|
artifact_count: row.get(7)?,
|
||||||
total_artifact_bytes: row.get(8)?,
|
total_artifact_bytes: row.get(8)?,
|
||||||
|
parent_entry_uid: row.get(9)?,
|
||||||
})
|
})
|
||||||
})?
|
})?
|
||||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||||
|
|
@ -278,6 +295,384 @@ pub fn list_runs(conn: &rusqlite::Connection) -> Result<Vec<RunSummary>> {
|
||||||
Ok(runs)
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -435,4 +830,367 @@ mod tests {
|
||||||
assert_eq!(runs.len(), 1);
|
assert_eq!(runs.len(), 1);
|
||||||
assert_eq!(runs[0].status, "completed");
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1261
crates/archivr-core/src/capture.rs
Normal file
1261
crates/archivr-core/src/capture.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -63,6 +63,16 @@ pub struct NewArtifact {
|
||||||
pub metadata_json: Option<String>,
|
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 {
|
pub fn database_path(archive_path: &Path) -> PathBuf {
|
||||||
archive_path.join(DATABASE_FILE_NAME)
|
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_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_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_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(())
|
Ok(())
|
||||||
|
|
@ -488,6 +499,104 @@ pub fn add_entry_artifact(conn: &Connection, artifact: &NewArtifact) -> Result<i
|
||||||
Ok(conn.last_insert_rowid())
|
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)]
|
#[cfg(test)]
|
||||||
pub fn set_public_settings(
|
pub fn set_public_settings(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
|
|
@ -535,7 +644,6 @@ pub fn main_archive_entry_count(conn: &Connection) -> Result<i64> {
|
||||||
Ok(count)
|
Ok(count)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
pub fn create_tag_path(conn: &Connection, full_path: &str) -> Result<i64> {
|
pub fn create_tag_path(conn: &Connection, full_path: &str) -> Result<i64> {
|
||||||
let segments = normalized_tag_segments(full_path)?;
|
let segments = normalized_tag_segments(full_path)?;
|
||||||
let mut parent_tag_id = None;
|
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)
|
Ok(current_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
pub fn assign_entry_to_tag(conn: &Connection, entry_id: i64, tag_id: i64) -> Result<()> {
|
pub fn assign_entry_to_tag(conn: &Connection, entry_id: i64, tag_id: i64) -> Result<()> {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR IGNORE INTO entry_tag_assignments (entry_id, tag_id)
|
"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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
pub fn entry_count_for_tag_path(conn: &Connection, full_path: &str) -> Result<i64> {
|
pub fn entry_count_for_tag_path(conn: &Connection, full_path: &str) -> Result<i64> {
|
||||||
let count = conn.query_row(
|
let count = conn.query_row(
|
||||||
"WITH RECURSIVE descendants(id) AS (
|
"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>> {
|
fn normalized_tag_segments(full_path: &str) -> Result<Vec<&str>> {
|
||||||
let segments = full_path
|
let segments = full_path
|
||||||
.trim()
|
.trim()
|
||||||
|
|
@ -669,7 +774,6 @@ fn normalized_tag_segments(full_path: &str) -> Result<Vec<&str>> {
|
||||||
Ok(segments)
|
Ok(segments)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
fn humanize_slug(slug: &str) -> String {
|
fn humanize_slug(slug: &str) -> String {
|
||||||
slug.split('-')
|
slug.split('-')
|
||||||
.map(|part| {
|
.map(|part| {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
pub mod capture;
|
||||||
pub mod archive;
|
pub mod archive;
|
||||||
pub mod database;
|
pub mod database;
|
||||||
pub mod downloader;
|
pub mod downloader;
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,10 @@ axum.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
toml.workspace = true
|
toml.workspace = true
|
||||||
|
tower.workspace = true
|
||||||
tower-http.workspace = true
|
tower-http.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile.workspace = true
|
tempfile.workspace = true
|
||||||
tower.workspace = true
|
tower.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
mod registry;
|
mod registry;
|
||||||
mod routes;
|
mod routes;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::{Context, Result};
|
||||||
use std::{net::SocketAddr, path::PathBuf};
|
use std::{net::SocketAddr, path::PathBuf};
|
||||||
|
|
||||||
|
const DEFAULT_BIND: &str = "127.0.0.1:8080";
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
let config_path = std::env::args()
|
let config_path = std::env::args()
|
||||||
|
|
@ -11,8 +13,27 @@ async fn main() -> Result<()> {
|
||||||
.map(PathBuf::from)
|
.map(PathBuf::from)
|
||||||
.unwrap_or_else(|| PathBuf::from("archivr-server.toml"));
|
.unwrap_or_else(|| PathBuf::from("archivr-server.toml"));
|
||||||
let registry = registry::load_registry(&config_path)?;
|
let registry = registry::load_registry(&config_path)?;
|
||||||
let app = routes::app(registry);
|
let app = routes::app(registry.clone());
|
||||||
let addr = SocketAddr::from(([127, 0, 0, 1], 8080));
|
|
||||||
|
// 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?;
|
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||||
println!("archivr-server listening on http://{addr}");
|
println!("archivr-server listening on http://{addr}");
|
||||||
axum::serve(listener, app).await?;
|
axum::serve(listener, app).await?;
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,12 @@ pub struct MountedArchive {
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
|
||||||
pub struct ServerRegistry {
|
pub struct ServerRegistry {
|
||||||
|
#[serde(default)]
|
||||||
pub archives: Vec<MountedArchive>,
|
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> {
|
pub fn load_registry(path: &Path) -> Result<ServerRegistry> {
|
||||||
|
|
@ -78,6 +83,7 @@ mod tests {
|
||||||
label: "Personal".to_string(),
|
label: "Personal".to_string(),
|
||||||
archive_path: archive_path.clone(),
|
archive_path: archive_path.clone(),
|
||||||
}],
|
}],
|
||||||
|
bind: None,
|
||||||
};
|
};
|
||||||
let path = temp.path().join("server.toml");
|
let path = temp.path().join("server.toml");
|
||||||
save_registry(&path, ®istry).unwrap();
|
save_registry(&path, ®istry).unwrap();
|
||||||
|
|
@ -102,10 +108,36 @@ mod tests {
|
||||||
archive_path: PathBuf::from("/tmp/b/.archivr"),
|
archive_path: PathBuf::from("/tmp/b/.archivr"),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
bind: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let err = validate_registry(®istry).unwrap_err().to_string();
|
let err = validate_registry(®istry).unwrap_err().to_string();
|
||||||
|
|
||||||
assert!(err.contains("duplicate archive id"));
|
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(®istry).is_ok());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 std::{path::PathBuf, sync::Arc};
|
||||||
|
|
||||||
use archivr_core::{archive, database};
|
use archivr_core::{archive, capture, database};
|
||||||
use axum::{
|
use axum::{
|
||||||
Json, Router,
|
Json, Router,
|
||||||
extract::{Path, State},
|
extract::{Path, Query, Request, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
routing::get,
|
routing::{delete, get, post},
|
||||||
};
|
};
|
||||||
use tower_http::services::{ServeDir, ServeFile};
|
use tower_http::services::{ServeDir, ServeFile};
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
use crate::registry::{MountedArchive, ServerRegistry};
|
use crate::registry::{MountedArchive, ServerRegistry};
|
||||||
|
|
||||||
|
|
@ -17,6 +42,12 @@ pub struct AppState {
|
||||||
registry: Arc<ServerRegistry>,
|
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 {
|
pub fn app(registry: ServerRegistry) -> Router {
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
registry: Arc::new(registry),
|
registry: Arc::new(registry),
|
||||||
|
|
@ -27,11 +58,26 @@ pub fn app(registry: ServerRegistry) -> Router {
|
||||||
.route("/health", get(|| async { "ok" }))
|
.route("/health", get(|| async { "ok" }))
|
||||||
.route("/api/archives", get(list_archives))
|
.route("/api/archives", get(list_archives))
|
||||||
.route("/api/archives/:archive_id/entries", get(list_entries))
|
.route("/api/archives/:archive_id/entries", get(list_entries))
|
||||||
|
.route("/api/archives/:archive_id/entries/search", get(search_entries_handler))
|
||||||
.route(
|
.route(
|
||||||
"/api/archives/:archive_id/entries/:entry_uid",
|
"/api/archives/:archive_id/entries/:entry_uid",
|
||||||
get(entry_detail),
|
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/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))
|
.nest_service("/assets", ServeDir::new(&static_dir))
|
||||||
.fallback_service(ServeFile::new(static_dir.join("index.html")))
|
.fallback_service(ServeFile::new(static_dir.join("index.html")))
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
|
|
@ -56,6 +102,22 @@ async fn list_entries(
|
||||||
Ok(Json(archive::list_root_entries(&conn)?))
|
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(
|
async fn entry_detail(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path((archive_id, entry_uid)): Path<(String, String)>,
|
Path((archive_id, entry_uid)): Path<(String, String)>,
|
||||||
|
|
@ -76,6 +138,125 @@ async fn list_runs(
|
||||||
Ok(Json(archive::list_runs(&conn)?))
|
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>(
|
fn mounted_archive<'a>(
|
||||||
state: &'a AppState,
|
state: &'a AppState,
|
||||||
archive_id: &str,
|
archive_id: &str,
|
||||||
|
|
@ -101,6 +282,13 @@ impl ApiError {
|
||||||
message: message.to_string(),
|
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
|
impl<E> From<E> for ApiError
|
||||||
|
|
@ -137,6 +325,7 @@ mod tests {
|
||||||
label: "Personal".to_string(),
|
label: "Personal".to_string(),
|
||||||
archive_path: std::path::PathBuf::from("/tmp/personal/.archivr"),
|
archive_path: std::path::PathBuf::from("/tmp/personal/.archivr"),
|
||||||
}],
|
}],
|
||||||
|
bind: None,
|
||||||
};
|
};
|
||||||
let response = app(registry)
|
let response = app(registry)
|
||||||
.oneshot(
|
.oneshot(
|
||||||
|
|
@ -165,4 +354,627 @@ mod tests {
|
||||||
|
|
||||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,11 @@ const state = {
|
||||||
archives: [],
|
archives: [],
|
||||||
archiveId: null,
|
archiveId: null,
|
||||||
entries: [],
|
entries: [],
|
||||||
filteredEntries: [],
|
|
||||||
selectedEntryUid: null,
|
selectedEntryUid: null,
|
||||||
|
selectedEntry: null,
|
||||||
|
tagFilter: null,
|
||||||
};
|
};
|
||||||
|
let selectSeq = 0;
|
||||||
|
|
||||||
const archiveSwitcher = document.querySelector("#archive-switcher");
|
const archiveSwitcher = document.querySelector("#archive-switcher");
|
||||||
const entriesBody = document.querySelector("#entries-body");
|
const entriesBody = document.querySelector("#entries-body");
|
||||||
|
|
@ -14,6 +16,17 @@ const navButtons = document.querySelectorAll(".nav-link");
|
||||||
const searchInput = document.querySelector("#search");
|
const searchInput = document.querySelector("#search");
|
||||||
const resultCount = document.querySelector("#result-count");
|
const resultCount = document.querySelector("#result-count");
|
||||||
const adminArchives = document.querySelector("#admin-archives");
|
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) {
|
function formatBytes(bytes) {
|
||||||
if (!bytes) return "0 B";
|
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() {
|
function renderEntries() {
|
||||||
entriesBody.innerHTML = "";
|
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");
|
const row = document.createElement("tr");
|
||||||
row.tabIndex = 0;
|
row.tabIndex = 0;
|
||||||
row.dataset.entryUid = entry.entry_uid;
|
row.dataset.entryUid = entry.entry_uid;
|
||||||
|
|
@ -132,30 +138,139 @@ function renderEntries() {
|
||||||
|
|
||||||
function renderContextDetail(detail) {
|
function renderContextDetail(detail) {
|
||||||
contextBody.innerHTML = "";
|
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],
|
["Type", detail.summary.entity_kind],
|
||||||
["Visibility", detail.summary.visibility],
|
["Visibility", detail.summary.visibility],
|
||||||
["Artifacts", detail.artifacts.length],
|
|
||||||
["Structured root", detail.structured_root_relpath],
|
["Structured root", detail.structured_root_relpath],
|
||||||
];
|
];
|
||||||
|
for (const [label, value] of metaFields) {
|
||||||
for (const [label, value] of items) {
|
|
||||||
const item = document.createElement("div");
|
const item = document.createElement("div");
|
||||||
item.className = "rail-item";
|
item.className = "rail-item";
|
||||||
item.textContent = `${label}: ${valueText(value)}`;
|
const labelEl = document.createElement("span");
|
||||||
contextBody.append(item);
|
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) {
|
async function selectEntry(entry) {
|
||||||
|
const seq = ++selectSeq;
|
||||||
state.selectedEntryUid = entry.entry_uid;
|
state.selectedEntryUid = entry.entry_uid;
|
||||||
|
state.selectedEntry = entry;
|
||||||
renderEntries();
|
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);
|
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() {
|
async function loadRuns() {
|
||||||
|
|
@ -172,12 +287,68 @@ async function loadRuns() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadEntries() {
|
async function loadEntries(q = "") {
|
||||||
state.entries = await getJson(`/api/archives/${state.archiveId}/entries`);
|
const trimmed = q.trim();
|
||||||
state.selectedEntryUid = null;
|
const params = new URLSearchParams();
|
||||||
applyEntryFilter();
|
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();
|
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() {
|
async function loadArchives() {
|
||||||
|
|
@ -187,32 +358,118 @@ async function loadArchives() {
|
||||||
if (state.archiveId) {
|
if (state.archiveId) {
|
||||||
await loadEntries();
|
await loadEntries();
|
||||||
await loadRuns();
|
await loadRuns();
|
||||||
|
loadTagTree();
|
||||||
} else {
|
} else {
|
||||||
contextBody.textContent = "No archives mounted.";
|
contextBody.textContent = "No archives mounted.";
|
||||||
resultCount.textContent = "0 entries";
|
resultCount.textContent = "0 entries";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function debounce(fn, ms) {
|
||||||
|
let timer;
|
||||||
|
return (...args) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => fn(...args), ms);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
archiveSwitcher.addEventListener("change", async () => {
|
archiveSwitcher.addEventListener("change", async () => {
|
||||||
|
state.tagFilter = null;
|
||||||
|
state.selectedEntry = null;
|
||||||
|
state.selectedEntryUid = null;
|
||||||
|
entryTagsEl.hidden = true;
|
||||||
|
assignTagForm.hidden = true;
|
||||||
state.archiveId = archiveSwitcher.value;
|
state.archiveId = archiveSwitcher.value;
|
||||||
await loadEntries();
|
await loadEntries();
|
||||||
await loadRuns();
|
await loadRuns();
|
||||||
|
loadTagTree();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const debouncedSearch = debounce((q) => {
|
||||||
|
if (state.archiveId) loadEntries(q);
|
||||||
|
}, 300);
|
||||||
|
|
||||||
searchInput.addEventListener("input", () => {
|
searchInput.addEventListener("input", () => {
|
||||||
applyEntryFilter();
|
debouncedSearch(searchInput.value);
|
||||||
renderEntries();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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) => {
|
navButtons.forEach((button) => {
|
||||||
button.addEventListener("click", () => {
|
button.addEventListener("click", () => {
|
||||||
navButtons.forEach((candidate) => candidate.classList.remove("is-active"));
|
switchView(button.dataset.view);
|
||||||
document.querySelectorAll(".view").forEach((view) => view.classList.remove("is-active"));
|
if (button.dataset.view === "tags") loadTagTree();
|
||||||
button.classList.add("is-active");
|
|
||||||
document.querySelector(`#${button.dataset.view}-view`).classList.add("is-active");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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) => {
|
loadArchives().catch((error) => {
|
||||||
contextBody.textContent = `Failed to load archives: ${error.message}`;
|
contextBody.textContent = `Failed to load archives: ${error.message}`;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
<button class="nav-link is-active" data-view="archive">Archive</button>
|
<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="runs">Runs</button>
|
||||||
<button class="nav-link" data-view="admin">Admin</button>
|
<button class="nav-link" data-view="admin">Admin</button>
|
||||||
|
<button class="nav-link" data-view="tags">Tags</button>
|
||||||
</nav>
|
</nav>
|
||||||
<button class="capture-button">+ Capture</button>
|
<button class="capture-button">+ Capture</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
@ -66,14 +67,37 @@
|
||||||
<h1>Mounted Archives</h1>
|
<h1>Mounted Archives</h1>
|
||||||
<div id="admin-archives" class="admin-list"></div>
|
<div id="admin-archives" class="admin-list"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section id="tags-view" class="view">
|
||||||
|
<div id="tag-tree" class="tag-tree"></div>
|
||||||
|
</section>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<aside class="context-rail">
|
<aside class="context-rail">
|
||||||
<div class="rail-title">Context</div>
|
<div class="rail-title">Context</div>
|
||||||
<div id="context-body" class="rail-body">Select an entry.</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>
|
</aside>
|
||||||
</main>
|
</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>
|
<script type="module" src="/assets/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -312,3 +312,277 @@ select {
|
||||||
min-width: 860px;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,79 @@ This project aims to provide a reliable solution for archiving important data fr
|
||||||
- Direct platform URLs
|
- Direct platform URLs
|
||||||
- Platform shorthand inputs such as `tweet:...`, `yt:...`, or `instagram:...`
|
- 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
|
### Supported Platforms
|
||||||
|
|
||||||
- Local files: `file:///absolute/path/to/file.ext`
|
- Local files: `file:///absolute/path/to/file.ext`
|
||||||
|
|
|
||||||
17
flake.nix
17
flake.nix
|
|
@ -129,18 +129,31 @@
|
||||||
pname = "archivr-server-wrapped";
|
pname = "archivr-server-wrapped";
|
||||||
inherit version;
|
inherit version;
|
||||||
nativeBuildInputs = [ pkgs.makeWrapper ];
|
nativeBuildInputs = [ pkgs.makeWrapper ];
|
||||||
|
buildInputs = [ tweetPython ];
|
||||||
phases = [ "installPhase" ];
|
phases = [ "installPhase" ];
|
||||||
installPhase = ''
|
installPhase = ''
|
||||||
mkdir -p $out/bin $out/libexec/archivr-server $out/share/archivr-server/static
|
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 ${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/
|
cp -r ${./crates/archivr-server/static}/* $out/share/archivr-server/static/
|
||||||
makeWrapper $out/libexec/archivr-server/archivr-server $out/bin/archivr-server \
|
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
|
in
|
||||||
{
|
{
|
||||||
default = archivr;
|
default = archivr-all;
|
||||||
|
archivr-all = archivr-all;
|
||||||
archivr = archivr;
|
archivr = archivr;
|
||||||
archivr-cli = archivr;
|
archivr-cli = archivr;
|
||||||
archivr-cli-unwrapped = archivr_cli_unwrapped;
|
archivr-cli-unwrapped = archivr_cli_unwrapped;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue