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

feat: hierarchical tags

This commit is contained in:
TheGeneralist 2026-06-22 14:45:10 +02:00
commit b70ab4945e
Signed by: thegeneralist01
SSH key fingerprint: SHA256:pp9qddbCNmVNoSjevdvQvM5z0DHN7LTa8qBMbcMq/R4
8 changed files with 1238 additions and 34 deletions

1
Cargo.lock generated
View file

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

View file

@ -25,6 +25,7 @@ pub struct EntrySummary {
pub original_url: Option<String>,
pub artifact_count: i64,
pub total_artifact_bytes: i64,
pub parent_entry_uid: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
@ -57,6 +58,20 @@ pub struct RunSummary {
pub error_summary: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct Tag {
pub tag_uid: String,
pub name: String,
pub slug: String,
pub full_path: String,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct TagNode {
pub tag: Tag,
pub children: Vec<TagNode>,
}
pub fn find_archive_path_from(start: &Path) -> Result<Option<PathBuf>> {
let mut dir = start.to_path_buf();
loop {
@ -167,7 +182,8 @@ pub fn list_root_entries(conn: &rusqlite::Connection) -> Result<Vec<EntrySummary
e.visibility,
si.canonical_url,
COUNT(ea.id) AS artifact_count,
COALESCE(SUM(b.byte_size), 0) AS total_artifact_bytes
COALESCE(SUM(b.byte_size), 0) AS total_artifact_bytes,
NULL AS parent_entry_uid
FROM archived_entries e
JOIN source_identities si ON si.id = e.source_identity_id
LEFT JOIN entry_artifacts ea ON ea.entry_id = e.id
@ -189,6 +205,7 @@ pub fn list_root_entries(conn: &rusqlite::Connection) -> Result<Vec<EntrySummary
original_url: row.get(6)?,
artifact_count: row.get(7)?,
total_artifact_bytes: row.get(8)?,
parent_entry_uid: row.get(9)?,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
@ -319,11 +336,13 @@ pub struct SearchEntriesQuery {
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:`.
/// 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<SearchEntriesQuery, String> {
"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<SearchEntriesQuery, String> {
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<Vec<EntrySummary>> {
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<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());
@ -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::<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<_>>>()?;
@ -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<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);
}
}

View file

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

View file

@ -16,3 +16,4 @@ tower-http.workspace = true
[dev-dependencies]
tempfile.workspace = true
tower.workspace = true
serde_json.workspace = true

View file

@ -6,7 +6,7 @@ use axum::{
extract::{Path, Query, Request, State},
http::StatusCode,
response::{IntoResponse, Response},
routing::get,
routing::{delete, get},
};
use tower_http::services::{ServeDir, ServeFile};
use tower::ServiceExt;
@ -21,6 +21,7 @@ pub struct AppState {
#[derive(Debug, serde::Deserialize, Default)]
pub struct EntrySearchParams {
pub q: Option<String>,
pub tag: Option<String>,
}
pub fn app(registry: ServerRegistry) -> Router {
@ -43,6 +44,15 @@ pub fn app(registry: ServerRegistry) -> Router {
get(serve_artifact),
)
.route("/api/archives/:archive_id/runs", get(list_runs))
.route("/api/archives/:archive_id/tags", get(list_tags).post(create_tag_handler))
.route(
"/api/archives/:archive_id/entries/:entry_uid/tags",
get(list_entry_tags).post(assign_entry_tag_handler),
)
.route(
"/api/archives/:archive_id/entries/:entry_uid/tags/:tag_uid",
delete(remove_entry_tag_handler),
)
.nest_service("/assets", ServeDir::new(&static_dir))
.fallback_service(ServeFile::new(static_dir.join("index.html")))
.with_state(state)
@ -75,12 +85,11 @@ async fn search_entries_handler(
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 search_query = archive::parse_search_query(raw)
.map_err(|prefix| ApiError::bad_request(&format!("unknown search prefix: {prefix}")));
let search_query = match search_query {
Ok(q) => q,
Err(e) => return Err(e),
};
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)?))
}
@ -128,6 +137,80 @@ async fn serve_artifact(
.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"))
}
}
fn mounted_archive<'a>(
state: &'a AppState,
archive_id: &str,
@ -463,4 +546,350 @@ mod tests {
.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(),
}],
};
(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);
}
}

View file

@ -3,7 +3,10 @@ const state = {
archiveId: null,
entries: [],
selectedEntryUid: null,
selectedEntry: null,
tagFilter: null,
};
let selectSeq = 0;
const archiveSwitcher = document.querySelector("#archive-switcher");
const entriesBody = document.querySelector("#entries-body");
@ -13,6 +16,11 @@ const navButtons = document.querySelectorAll(".nav-link");
const searchInput = document.querySelector("#search");
const resultCount = document.querySelector("#result-count");
const adminArchives = document.querySelector("#admin-archives");
const tagTree = document.querySelector("#tag-tree");
const entryTagsEl = document.querySelector("#entry-tags");
const assignTagForm = document.querySelector("#assign-tag-form");
const assignTagInput = document.querySelector("#assign-tag-input");
const assignTagBtn = document.querySelector("#assign-tag-btn");
function formatBytes(bytes) {
if (!bytes) return "0 B";
@ -78,6 +86,16 @@ function renderEntries() {
} 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.entries) {
const row = document.createElement("tr");
@ -194,11 +212,59 @@ function renderContextDetail(detail) {
}
}
function renderEntryTags(tags, entryUid) {
entryTagsEl.innerHTML = "";
if (!tags.length) {
entryTagsEl.textContent = "No tags.";
return;
}
for (const tag of tags) {
const pill = document.createElement("span");
pill.className = "tag-pill";
pill.textContent = tag.name;
pill.title = tag.full_path;
const removeBtn = document.createElement("button");
removeBtn.className = "remove-tag";
removeBtn.textContent = "×";
removeBtn.title = `Remove tag ${tag.full_path}`;
removeBtn.addEventListener("click", async () => {
const resp = await fetch(
`/api/archives/${state.archiveId}/entries/${entryUid}/tags/${tag.tag_uid}`,
{ method: "DELETE" }
);
if (!resp.ok) {
removeBtn.title = `Remove failed (${resp.status})`;
return;
}
const updated = await getJson(
`/api/archives/${state.archiveId}/entries/${entryUid}/tags`
);
renderEntryTags(updated, entryUid);
loadTagTree();
});
pill.appendChild(removeBtn);
entryTagsEl.appendChild(pill);
}
}
async function selectEntry(entry) {
const seq = ++selectSeq;
state.selectedEntryUid = entry.entry_uid;
state.selectedEntry = entry;
renderEntries();
const detail = await getJson(`/api/archives/${state.archiveId}/entries/${entry.entry_uid}`);
const detail = await getJson(
`/api/archives/${state.archiveId}/entries/${entry.entry_uid}`
);
if (seq !== selectSeq) return;
renderContextDetail(detail);
entryTagsEl.hidden = false;
assignTagForm.hidden = false;
entryTagsEl.innerHTML = "";
const tags = await getJson(
`/api/archives/${state.archiveId}/entries/${entry.entry_uid}/tags`
);
if (seq !== selectSeq) return;
renderEntryTags(tags, entry.entry_uid);
}
async function loadRuns() {
@ -217,9 +283,13 @@ async function loadRuns() {
async function loadEntries(q = "") {
const trimmed = q.trim();
const url = trimmed
? `/api/archives/${state.archiveId}/entries/search?q=${encodeURIComponent(trimmed)}`
: `/api/archives/${state.archiveId}/entries`;
const params = new URLSearchParams();
if (trimmed) params.set("q", trimmed);
if (state.tagFilter) params.set("tag", state.tagFilter);
const url =
trimmed || state.tagFilter
? `/api/archives/${state.archiveId}/entries/search?${params}`
: `/api/archives/${state.archiveId}/entries`;
searchInput.setAttribute("aria-busy", "true");
try {
state.entries = await getJson(url);
@ -232,6 +302,49 @@ async function loadEntries(q = "") {
renderEntries();
}
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() {
state.archives = await getJson("/api/archives");
state.archiveId = state.archives[0]?.id ?? null;
@ -239,6 +352,7 @@ async function loadArchives() {
if (state.archiveId) {
await loadEntries();
await loadRuns();
loadTagTree();
} else {
contextBody.textContent = "No archives mounted.";
resultCount.textContent = "0 entries";
@ -254,9 +368,15 @@ function debounce(fn, ms) {
}
archiveSwitcher.addEventListener("change", async () => {
state.tagFilter = null;
state.selectedEntry = null;
state.selectedEntryUid = null;
entryTagsEl.hidden = true;
assignTagForm.hidden = true;
state.archiveId = archiveSwitcher.value;
await loadEntries();
await loadRuns();
loadTagTree();
});
const debouncedSearch = debounce((q) => {
@ -267,15 +387,44 @@ searchInput.addEventListener("input", () => {
debouncedSearch(searchInput.value);
});
function switchView(name) {
navButtons.forEach(b => b.classList.toggle("is-active", b.dataset.view === name));
document.querySelectorAll(".view").forEach(v => v.classList.remove("is-active"));
document.querySelector(`#${name}-view`)?.classList.add("is-active");
}
navButtons.forEach((button) => {
button.addEventListener("click", () => {
navButtons.forEach((candidate) => candidate.classList.remove("is-active"));
document.querySelectorAll(".view").forEach((view) => view.classList.remove("is-active"));
button.classList.add("is-active");
document.querySelector(`#${button.dataset.view}-view`).classList.add("is-active");
switchView(button.dataset.view);
if (button.dataset.view === "tags") loadTagTree();
});
});
assignTagBtn.addEventListener("click", async () => {
const path = assignTagInput.value.trim();
if (!path || !state.selectedEntry) return;
const resp = await fetch(
`/api/archives/${state.archiveId}/entries/${state.selectedEntry.entry_uid}/tags`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tag_path: path }),
}
);
if (resp.ok) {
assignTagInput.setCustomValidity("");
assignTagInput.value = "";
const tags = await getJson(
`/api/archives/${state.archiveId}/entries/${state.selectedEntry.entry_uid}/tags`
);
renderEntryTags(tags, state.selectedEntry.entry_uid);
loadTagTree();
} else {
assignTagInput.setCustomValidity(`Failed to add tag (${resp.status})`);
assignTagInput.reportValidity();
}
});
loadArchives().catch((error) => {
contextBody.textContent = `Failed to load archives: ${error.message}`;
});

View file

@ -14,6 +14,7 @@
<button class="nav-link is-active" data-view="archive">Archive</button>
<button class="nav-link" data-view="runs">Runs</button>
<button class="nav-link" data-view="admin">Admin</button>
<button class="nav-link" data-view="tags">Tags</button>
</nav>
<button class="capture-button">+ Capture</button>
</header>
@ -66,11 +67,20 @@
<h1>Mounted Archives</h1>
<div id="admin-archives" class="admin-list"></div>
</section>
<section id="tags-view" class="view">
<div id="tag-tree" class="tag-tree"></div>
</section>
</section>
<aside class="context-rail">
<div class="rail-title">Context</div>
<div id="context-body" class="rail-body">Select an entry.</div>
<div id="entry-tags" class="entry-tags" hidden></div>
<div id="assign-tag-form" class="assign-tag-form" hidden>
<input id="assign-tag-input" class="assign-tag-input" type="text" placeholder="/science/cs" autocomplete="off">
<button id="assign-tag-btn" class="assign-tag-btn">Add tag</button>
</div>
</aside>
</main>

View file

@ -374,3 +374,125 @@ select {
.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;
}