From db953de67ad6443959653bb3833bb73f18315f8a Mon Sep 17 00:00:00 2001 From: TheGeneralist <180094941+thegeneralist01@users.noreply.github.com> Date: Mon, 22 Jun 2026 11:48:58 +0200 Subject: [PATCH] feat(core): hierarchical tag API - Tag/TagNode types, create_tag, list_tag_tree, assign_entry_tag, remove_entry_tag, entries_for_tag - Tag-filtered search returns child entries (no root restriction) - parent_entry_uid added to EntrySummary - 8 new tests --- crates/archivr-core/src/archive.rs | 416 +++++++++++++++++++++++++++- crates/archivr-core/src/database.rs | 114 +++++++- 2 files changed, 511 insertions(+), 19 deletions(-) diff --git a/crates/archivr-core/src/archive.rs b/crates/archivr-core/src/archive.rs index 7a5f18e..be14c24 100644 --- a/crates/archivr-core/src/archive.rs +++ b/crates/archivr-core/src/archive.rs @@ -25,6 +25,7 @@ pub struct EntrySummary { pub original_url: Option, pub artifact_count: i64, pub total_artifact_bytes: i64, + pub parent_entry_uid: Option, } #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] @@ -57,6 +58,20 @@ pub struct RunSummary { pub error_summary: Option, } +#[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, +} + pub fn find_archive_path_from(start: &Path) -> Result> { let mut dir = start.to_path_buf(); loop { @@ -167,7 +182,8 @@ pub fn list_root_entries(conn: &rusqlite::Connection) -> Result Result>>()?; @@ -319,11 +336,13 @@ pub struct SearchEntriesQuery { pub after: Option, /// e.archived_at < before (exclusive, ISO 8601) pub before: Option, + /// Tag full_path filter; includes all entries (root + child) matching the tag subtree + pub tag: Option, } /// Parses a raw search string into a [`SearchEntriesQuery`]. /// -/// Recognized prefixes: `source:`, `type:`, `url:`, `title:`, `after:`, `before:`. +/// 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 @@ -346,6 +365,7 @@ pub fn parse_search_query(raw: &str) -> Result { "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 { @@ -359,25 +379,52 @@ pub fn parse_search_query(raw: &str) -> Result { 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`. /// -/// Returns root entries only (same scope as [`list_root_entries`]). +/// 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> { - let mut sql = String::from( - "SELECT e.entry_uid, e.archived_at, e.source_kind, e.entity_kind, e.title, \ - e.visibility, si.canonical_url, COUNT(ea.id) AS artifact_count, \ - COALESCE(SUM(b.byte_size), 0) AS total_artifact_bytes \ - FROM archived_entries e \ - JOIN source_identities si ON si.id = e.source_identity_id \ - LEFT JOIN entry_artifacts ea ON ea.entry_id = e.id \ - LEFT JOIN blobs b ON b.id = ea.blob_id \ - WHERE e.parent_entry_id IS NULL", - ); - let mut params: Vec = 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()); @@ -435,6 +482,191 @@ pub fn search_entries( original_url: row.get(6)?, artifact_count: row.get(7)?, total_artifact_bytes: row.get(8)?, + parent_entry_uid: row.get(9)?, + }) + })? + .collect::>>()?; + Ok(entries) +} + +fn tag_by_id(conn: &rusqlite::Connection, id: i64) -> Result { + 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 { + 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> { + use std::collections::HashMap; + + let records = database::list_all_tags(conn)?; + + let mut by_parent: HashMap, Vec> = HashMap::new(); + for record in records { + by_parent.entry(record.parent_tag_id).or_default().push(record); + } + + fn build_nodes( + parent_id: Option, + by_parent: &HashMap, Vec>, + ) -> Vec { + 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>> { + 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> { + 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 { + 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> { + 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::>>()?; @@ -683,6 +915,13 @@ mod tests { 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 { @@ -805,4 +1044,153 @@ mod tests { }).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, + root_entry_id: Option, + 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); + } } diff --git a/crates/archivr-core/src/database.rs b/crates/archivr-core/src/database.rs index 5ec3f74..d207c35 100644 --- a/crates/archivr-core/src/database.rs +++ b/crates/archivr-core/src/database.rs @@ -63,6 +63,16 @@ pub struct NewArtifact { pub metadata_json: Option, } +#[derive(Debug, Clone)] +pub struct TagRecord { + pub id: i64, + pub tag_uid: String, + pub parent_tag_id: Option, + pub name: String, + pub slug: String, + pub full_path: String, +} + pub fn database_path(archive_path: &Path) -> PathBuf { archive_path.join(DATABASE_FILE_NAME) } @@ -214,6 +224,7 @@ pub fn initialize_schema(conn: &Connection) -> Result<()> { CREATE INDEX IF NOT EXISTS idx_entry_artifacts_entry_id ON entry_artifacts(entry_id); CREATE INDEX IF NOT EXISTS idx_entry_artifacts_blob_id ON entry_artifacts(blob_id); CREATE INDEX IF NOT EXISTS idx_tags_parent_tag_id ON tags(parent_tag_id); + CREATE INDEX IF NOT EXISTS idx_entry_tag_assignments_tag_id ON entry_tag_assignments(tag_id); "#, )?; Ok(()) @@ -488,6 +499,104 @@ pub fn add_entry_artifact(conn: &Connection, artifact: &NewArtifact) -> Result 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> { + 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::, _>>() + .context("failed to list tags")?; + Ok(records) +} + +pub fn list_tags_for_entry(conn: &Connection, entry_id: i64) -> Result> { + 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::, _>>() + .context("failed to list tags for entry")?; + Ok(records) +} + +pub fn get_tag_by_uid(conn: &Connection, tag_uid: &str) -> Result> { + 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> { + conn.query_row( + "SELECT id, tag_uid, parent_tag_id, name, slug, full_path + FROM tags WHERE full_path = ?1", + [full_path], + |row| { + Ok(TagRecord { + id: row.get(0)?, + tag_uid: row.get(1)?, + parent_tag_id: row.get(2)?, + name: row.get(3)?, + slug: row.get(4)?, + full_path: row.get(5)?, + }) + }, + ) + .optional() + .context("failed to get tag by path") +} + #[cfg(test)] pub fn set_public_settings( conn: &Connection, @@ -535,7 +644,6 @@ pub fn main_archive_entry_count(conn: &Connection) -> Result { Ok(count) } -#[cfg(test)] pub fn create_tag_path(conn: &Connection, full_path: &str) -> Result { let segments = normalized_tag_segments(full_path)?; let mut parent_tag_id = None; @@ -577,7 +685,6 @@ pub fn create_tag_path(conn: &Connection, full_path: &str) -> Result { Ok(current_id) } -#[cfg(test)] pub fn assign_entry_to_tag(conn: &Connection, entry_id: i64, tag_id: i64) -> Result<()> { conn.execute( "INSERT OR IGNORE INTO entry_tag_assignments (entry_id, tag_id) @@ -587,7 +694,6 @@ pub fn assign_entry_to_tag(conn: &Connection, entry_id: i64, tag_id: i64) -> Res Ok(()) } -#[cfg(test)] pub fn entry_count_for_tag_path(conn: &Connection, full_path: &str) -> Result { let count = conn.query_row( "WITH RECURSIVE descendants(id) AS ( @@ -653,7 +759,6 @@ fn validate_visibility(visibility: &str) -> Result<()> { } } -#[cfg(test)] fn normalized_tag_segments(full_path: &str) -> Result> { let segments = full_path .trim() @@ -669,7 +774,6 @@ fn normalized_tag_segments(full_path: &str) -> Result> { Ok(segments) } -#[cfg(test)] fn humanize_slug(slug: &str) -> String { slug.split('-') .map(|part| {