From ab054f6256f97e9c5e596b977f6d185193daf638 Mon Sep 17 00:00:00 2001 From: TheGeneralist <180094941+thegeneralist01@users.noreply.github.com> Date: Fri, 26 Jun 2026 16:48:22 +0200 Subject: [PATCH 1/7] =?UTF-8?q?feat(collections):=20schema=20=E2=80=94=20c?= =?UTF-8?q?ollections=20+=20collection=5Fentries=20tables?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/archivr-core/src/database.rs | 212 ++++++++++++++++++++++++++++ 1 file changed, 212 insertions(+) diff --git a/crates/archivr-core/src/database.rs b/crates/archivr-core/src/database.rs index 87a1fd0..cce1d75 100644 --- a/crates/archivr-core/src/database.rs +++ b/crates/archivr-core/src/database.rs @@ -130,6 +130,16 @@ pub struct RoleRecord { pub is_builtin: bool, } +#[derive(Debug, Clone, serde::Serialize)] +pub struct CollectionRecord { + pub id: i64, + pub collection_uid: String, + pub name: String, + pub slug: String, + pub default_visibility_bits: u32, + pub created_at: String, +} + pub fn database_path(archive_path: &Path) -> PathBuf { archive_path.join(DATABASE_FILE_NAME) } @@ -293,6 +303,43 @@ pub fn initialize_schema(conn: &Connection) -> Result<()> { 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); + + CREATE TABLE IF NOT EXISTS collections ( + id INTEGER PRIMARY KEY, + collection_uid TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + default_visibility_bits INTEGER NOT NULL DEFAULT 2, + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS collection_entries ( + collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, + entry_id INTEGER NOT NULL REFERENCES archived_entries(id) ON DELETE CASCADE, + visibility_bits INTEGER NOT NULL DEFAULT 2, + added_at TEXT NOT NULL, + PRIMARY KEY (collection_id, entry_id) + ); + + CREATE INDEX IF NOT EXISTS idx_collection_entries_entry_id ON collection_entries(entry_id); + CREATE INDEX IF NOT EXISTS idx_collection_entries_collection_id ON collection_entries(collection_id); + + -- Seed default collection (idempotent) + INSERT OR IGNORE INTO collections (collection_uid, name, slug, default_visibility_bits, created_at) + VALUES ('coll_default', 'All Entries', '_default_', 2, datetime('now')); + + -- Migrate existing entries to default collection (idempotent) + INSERT OR IGNORE INTO collection_entries (collection_id, entry_id, visibility_bits, added_at) + SELECT + (SELECT id FROM collections WHERE slug = '_default_'), + ae.id, + CASE ae.visibility + WHEN 'public' THEN 3 + WHEN 'unlisted' THEN 2 + ELSE 0 + END, + ae.archived_at + FROM archived_entries ae; "#, )?; Ok(()) @@ -1146,6 +1193,11 @@ pub fn create_archived_entry(conn: &Connection, entry: &NewEntry) -> Result Result<()> { Ok(()) } +/// Maps legacy visibility strings to collection_entries.visibility_bits. +/// 'public'→3 (guest|user), 'unlisted'→2 (user only), 'private'→0 (nobody). +pub fn visibility_to_bits(visibility: &str) -> u32 { + match visibility { + "public" => 3, + "unlisted" => 2, + _ => 0, + } +} + +/// Returns the id of the '_default_' collection, creating it if absent. +pub fn ensure_default_collection(conn: &Connection) -> Result { + let now = now_timestamp(); + conn.execute( + "INSERT OR IGNORE INTO collections (collection_uid, name, slug, default_visibility_bits, created_at) \ + VALUES ('coll_default', 'All Entries', '_default_', 2, ?1)", + [&now], + )?; + let id: i64 = conn.query_row( + "SELECT id FROM collections WHERE slug = '_default_'", + [], + |row| row.get(0), + )?; + Ok(id) +} + +/// Creates a new collection. Returns the created record. +pub fn create_collection( + conn: &Connection, + name: &str, + slug: &str, + default_visibility_bits: u32, +) -> Result { + if slug.is_empty() || slug.starts_with('_') { + anyhow::bail!("collection slug must be non-empty and not start with underscore"); + } + let collection_uid = public_id("coll"); + let now = now_timestamp(); + conn.execute( + "INSERT INTO collections (collection_uid, name, slug, default_visibility_bits, created_at) \ + VALUES (?1, ?2, ?3, ?4, ?5)", + params![collection_uid, name, slug, default_visibility_bits as i64, now], + )?; + let id = conn.last_insert_rowid(); + Ok(CollectionRecord { + id, + collection_uid, + name: name.to_string(), + slug: slug.to_string(), + default_visibility_bits, + created_at: now, + }) +} + +/// Lists all collections ordered by creation date. +pub fn list_collections(conn: &Connection) -> Result> { + let mut stmt = conn.prepare( + "SELECT id, collection_uid, name, slug, default_visibility_bits, created_at \ + FROM collections ORDER BY created_at ASC", + )?; + stmt.query_map([], |row| { + Ok(CollectionRecord { + id: row.get(0)?, + collection_uid: row.get(1)?, + name: row.get(2)?, + slug: row.get(3)?, + default_visibility_bits: row.get::<_, i64>(4)? as u32, + created_at: row.get(5)?, + }) + })? + .collect::>() + .map_err(Into::into) +} + +/// Returns a collection by its uid, or None if not found. +pub fn get_collection_by_uid( + conn: &Connection, + uid: &str, +) -> Result> { + conn.query_row( + "SELECT id, collection_uid, name, slug, default_visibility_bits, created_at \ + FROM collections WHERE collection_uid = ?1", + [uid], + |row| { + Ok(CollectionRecord { + id: row.get(0)?, + collection_uid: row.get(1)?, + name: row.get(2)?, + slug: row.get(3)?, + default_visibility_bits: row.get::<_, i64>(4)? as u32, + created_at: row.get(5)?, + }) + }, + ) + .optional() + .map_err(Into::into) +} + +/// Adds an entry to a collection with given visibility_bits. Idempotent (INSERT OR IGNORE). +pub fn add_entry_to_collection( + conn: &Connection, + collection_id: i64, + entry_id: i64, + visibility_bits: u32, +) -> Result<()> { + let now = now_timestamp(); + conn.execute( + "INSERT OR IGNORE INTO collection_entries (collection_id, entry_id, visibility_bits, added_at) \ + VALUES (?1, ?2, ?3, ?4)", + params![collection_id, entry_id, visibility_bits as i64, now], + )?; + Ok(()) +} + +/// Updates the visibility_bits of an entry in a collection. Returns true if updated. +pub fn update_collection_entry_visibility( + conn: &Connection, + collection_id: i64, + entry_id: i64, + visibility_bits: u32, +) -> Result { + let n = conn.execute( + "UPDATE collection_entries SET visibility_bits = ?1 \ + WHERE collection_id = ?2 AND entry_id = ?3", + params![visibility_bits as i64, collection_id, entry_id], + )?; + Ok(n > 0) +} + +/// Removes an entry from a collection. Returns true if removed. +pub fn remove_entry_from_collection( + conn: &Connection, + collection_id: i64, + entry_id: i64, +) -> Result { + let n = conn.execute( + "DELETE FROM collection_entries WHERE collection_id = ?1 AND entry_id = ?2", + params![collection_id, entry_id], + )?; + Ok(n > 0) +} + +/// Returns (collection_id, collection_uid, visibility_bits) for all collections containing an entry. +pub fn get_entry_collection_memberships( + conn: &Connection, + entry_id: i64, +) -> Result> { + let mut stmt = conn.prepare( + "SELECT ce.collection_id, c.collection_uid, ce.visibility_bits \ + FROM collection_entries ce \ + JOIN collections c ON c.id = ce.collection_id \ + WHERE ce.entry_id = ?1", + )?; + stmt.query_map([entry_id], |row| { + Ok((row.get(0)?, row.get(1)?, row.get::<_, i64>(2)? as u32)) + })? + .collect::>() + .map_err(Into::into) +} + fn run_id_for_item(conn: &Connection, item_id: i64) -> Result { let run_id = conn.query_row( "SELECT run_id FROM archive_run_items WHERE id = ?1", From 02cab149fa3014ab928d0b3fecc9deb02a08b527 Mon Sep 17 00:00:00 2001 From: TheGeneralist <180094941+thegeneralist01@users.noreply.github.com> Date: Fri, 26 Jun 2026 16:51:48 +0200 Subject: [PATCH 2/7] feat(collections): visibility-filtered entry queries + collection helpers --- crates/archivr-core/src/archive.rs | 85 ++++++++++++++++++++++++++++-- 1 file changed, 82 insertions(+), 3 deletions(-) diff --git a/crates/archivr-core/src/archive.rs b/crates/archivr-core/src/archive.rs index 23eeb33..89235bc 100644 --- a/crates/archivr-core/src/archive.rs +++ b/crates/archivr-core/src/archive.rs @@ -85,6 +85,15 @@ pub struct TagNode { pub children: Vec, } +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub struct CollectionSummary { + pub collection_uid: String, + pub name: String, + pub slug: String, + pub default_visibility_bits: u32, + pub created_at: String, +} + pub fn find_archive_path_from(start: &Path) -> Result> { let mut dir = start.to_path_buf(); loop { @@ -184,7 +193,7 @@ pub fn initialize_store_directories(store_path: &Path) -> Result<()> { Ok(()) } -pub fn list_root_entries(conn: &rusqlite::Connection) -> Result> { +pub fn list_root_entries(conn: &rusqlite::Connection, caller_bits: u32) -> Result> { let mut stmt = conn.prepare( "SELECT e.entry_uid, @@ -203,12 +212,20 @@ pub fn list_root_entries(conn: &rusqlite::Connection) -> Result Result> { + let records = database::list_collections(conn)?; + Ok(records + .into_iter() + .map(|r| CollectionSummary { + collection_uid: r.collection_uid, + name: r.name, + slug: r.slug, + default_visibility_bits: r.default_visibility_bits, + created_at: r.created_at, + }) + .collect()) +} + +/// Represents an entry's membership in a collection with its visibility bits. +#[derive(Debug, Clone, serde::Serialize)] +pub struct EntryCollectionMembership { + pub collection_uid: String, + pub visibility_bits: u32, +} + +/// Returns collection memberships for the given entry_uid. +/// Returns Ok(None) if the entry_uid does not exist. +pub fn get_entry_collections( + 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 memberships = database::get_entry_collection_memberships(conn, entry_id)?; + Ok(Some( + memberships + .into_iter() + .map(|(_, uid, bits)| EntryCollectionMembership { + collection_uid: uid, + visibility_bits: bits, + }) + .collect(), + )) +} + /// 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`). @@ -368,6 +435,9 @@ pub struct SearchEntriesQuery { pub before: Option, /// Tag full_path filter; includes all entries (root + child) matching the tag subtree pub tag: Option, + /// Role bits of the caller for visibility filtering. Admins (bits 4/8) bypass all filters. + /// Pass `u32::MAX` internally to bypass all visibility. Pass 0 for unauthenticated guests only. + pub caller_bits: u32, } /// Parses a raw search string into a [`SearchEntriesQuery`]. @@ -498,6 +568,15 @@ pub fn search_entries( params.push(b.clone()); } + // Visibility filter + let n = params.len() + 1; + sql.push_str(&format!( + " AND (CAST(?{n} AS INTEGER) & 12 != 0 \ + OR EXISTS (SELECT 1 FROM collection_entries ce \ + WHERE ce.entry_id = e.id AND ce.visibility_bits & CAST(?{n} AS INTEGER) != 0))" + )); + params.push(query.caller_bits.to_string()); + sql.push_str(" GROUP BY e.id ORDER BY e.archived_at DESC, e.id DESC"); let mut stmt = conn.prepare(&sql)?; From 24c5321f6e9b929ac9bf5731bd68381096a413ca Mon Sep 17 00:00:00 2001 From: TheGeneralist <180094941+thegeneralist01@users.noreply.github.com> Date: Fri, 26 Jun 2026 17:00:19 +0200 Subject: [PATCH 3/7] feat(collections): collection CRUD + entry-visibility routes --- crates/archivr-core/src/archive.rs | 61 ++++++++- crates/archivr-server/Cargo.toml | 1 + crates/archivr-server/src/routes.rs | 201 +++++++++++++++++++++++++++- 3 files changed, 257 insertions(+), 6 deletions(-) diff --git a/crates/archivr-core/src/archive.rs b/crates/archivr-core/src/archive.rs index 89235bc..0961830 100644 --- a/crates/archivr-core/src/archive.rs +++ b/crates/archivr-core/src/archive.rs @@ -392,6 +392,45 @@ pub fn get_entry_collections( )) } +/// Returns entries belonging to a collection, filtered by caller_bits visibility. +/// Caller with admin/owner bits (4|8) sees all entries regardless of visibility_bits. +pub fn list_entries_for_collection( + conn: &rusqlite::Connection, + collection_id: i64, + caller_bits: u32, +) -> Result> { + let sql = format!( + "{} {} \ + JOIN collection_entries ce ON ce.entry_id = e.id \ + WHERE ce.collection_id = ?1 \ + AND (CAST(?2 AS INTEGER) & 12 != 0 \ + OR (ce.visibility_bits & CAST(?2 AS INTEGER)) != 0) \ + GROUP BY e.id \ + ORDER BY e.archived_at DESC, e.id DESC", + ENTRY_SELECT_COLS, + ENTRY_FROM_JOINS, + ); + let mut stmt = conn.prepare(&sql)?; + let entries = stmt + .query_map([collection_id, caller_bits as i64], |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)?, + has_favicon: row.get::<_, i64>(10)? != 0, + }) + })? + .collect::>>()?; + Ok(entries) +} + /// 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`). @@ -417,7 +456,7 @@ pub fn resolve_artifact_path( Ok(canonical_artifact) } -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone)] pub struct SearchEntriesQuery { /// Free-text term: LIKE-matched against title, canonical_url, entry_uid, source_kind, entity_kind, visibility pub q: Option, @@ -440,6 +479,22 @@ pub struct SearchEntriesQuery { pub caller_bits: u32, } +impl Default for SearchEntriesQuery { + fn default() -> Self { + Self { + q: None, + source_kind: None, + entity_kind: None, + url: None, + title: None, + after: None, + before: None, + tag: None, + caller_bits: u32::MAX, + } + } +} + /// Parses a raw search string into a [`SearchEntriesQuery`]. /// /// Recognized prefixes: `source:`, `type:`, `url:`, `title:`, `after:`, `before:`, `tag:`. @@ -928,7 +983,7 @@ mod tests { database::complete_archive_run_item(&conn, item.id, entry.id).unwrap(); database::finish_archive_run(&conn, run.id).unwrap(); - let entries = list_root_entries(&conn).unwrap(); + let entries = list_root_entries(&conn, u32::MAX).unwrap(); assert_eq!(entries.len(), 1); assert_eq!(entries[0].title.as_deref(), Some("Saved Article")); assert_eq!(entries[0].artifact_count, 1); @@ -1099,7 +1154,7 @@ mod tests { #[test] fn search_empty_query_returns_all_root_entries() { let conn = make_test_db_with_entries(); - let all = list_root_entries(&conn).unwrap(); + let all = list_root_entries(&conn, u32::MAX).unwrap(); let searched = search_entries(&conn, &SearchEntriesQuery::default()).unwrap(); assert_eq!(all.len(), searched.len()); } diff --git a/crates/archivr-server/Cargo.toml b/crates/archivr-server/Cargo.toml index 371950a..b647f08 100644 --- a/crates/archivr-server/Cargo.toml +++ b/crates/archivr-server/Cargo.toml @@ -18,6 +18,7 @@ axum-extra.workspace = true chrono.workspace = true base64.workspace = true serde_json.workspace = true +rusqlite.workspace = true [dev-dependencies] tempfile.workspace = true diff --git a/crates/archivr-server/src/routes.rs b/crates/archivr-server/src/routes.rs index ba4f571..b158656 100644 --- a/crates/archivr-server/src/routes.rs +++ b/crates/archivr-server/src/routes.rs @@ -26,15 +26,16 @@ use axum::{ http::StatusCode, middleware::Next, response::{IntoResponse, Response}, - routing::{delete, get, post}, + routing::{delete, get, patch, post}, }; use tower_http::services::{ServeDir, ServeFile}; use tower::ServiceExt; use crate::registry::{MountedArchive, ServerRegistry}; use crate::auth; -pub use crate::auth::{AuthUser, ROLE_ADMIN, ROLE_OWNER, ROLE_USER}; +pub use crate::auth::{AuthUser, ROLE_ADMIN, ROLE_GUEST, ROLE_OWNER, ROLE_USER}; use axum_extra::extract::CookieJar; +use rusqlite::OptionalExtension; #[derive(Clone)] pub struct AppState { @@ -127,6 +128,17 @@ pub fn app(registry: ServerRegistry, auth_db_path: std::path::PathBuf) -> Router .route("/api/admin/users/:uid/roles", axum::routing::post(admin_assign_role)) .route("/api/admin/users/:uid/roles/:role_slug", axum::routing::delete(admin_remove_role)) .route("/api/admin/roles", get(admin_list_roles).post(admin_create_role)) + .route("/api/archives/:archive_id/collections", + get(list_collections_handler).post(create_collection_handler)) + .route("/api/archives/:archive_id/collections/:coll_uid", + get(get_collection_handler)) + .route("/api/archives/:archive_id/collections/:coll_uid/entries", + post(add_entry_to_collection_handler)) + .route("/api/archives/:archive_id/collections/:coll_uid/entries/:entry_uid", + delete(remove_entry_from_collection_handler) + .patch(update_entry_visibility_handler)) + .route("/api/archives/:archive_id/entries/:entry_uid/collections", + get(list_entry_collections_handler)) .nest_service("/assets", ServeDir::new(static_dir.join("assets"))) .fallback_service(ServeFile::new(static_dir.join("index.html"))) .layer(axum::middleware::from_fn_with_state(state.clone(), setup_guard)) @@ -145,15 +157,18 @@ async fn list_archives(State(state): State) -> Json, + auth: AuthUser, Path(archive_id): Path, ) -> Result>, ApiError> { let mounted = mounted_archive(&state, &archive_id)?; let conn = database::open_or_initialize(&mounted.archive_path)?; - Ok(Json(archive::list_root_entries(&conn)?)) + let caller_bits = auth_to_caller_bits(&auth); + Ok(Json(archive::list_root_entries(&conn, caller_bits)?)) } async fn search_entries_handler( State(state): State, + auth: AuthUser, Path(archive_id): Path, Query(params): Query, ) -> Result>, ApiError> { @@ -165,6 +180,7 @@ async fn search_entries_handler( if let Some(tag) = params.tag { search_query.tag = Some(tag); } + search_query.caller_bits = auth_to_caller_bits(&auth); Ok(Json(archive::search_entries(&conn, &search_query)?)) } @@ -278,6 +294,28 @@ struct AssignTagBody { tag_path: String, } +#[derive(Debug, serde::Deserialize)] +struct CreateCollectionBody { + name: String, + slug: String, + #[serde(default = "default_user_visibility")] + default_visibility_bits: u32, +} + +fn default_user_visibility() -> u32 { 2 } + +#[derive(Debug, serde::Deserialize)] +struct AddEntryBody { + entry_uid: String, + #[serde(default = "default_user_visibility")] + visibility_bits: u32, +} + +#[derive(Debug, serde::Deserialize)] +struct UpdateVisibilityBody { + visibility_bits: u32, +} + async fn list_tags( State(state): State, Path(archive_id): Path, @@ -698,6 +736,13 @@ async fn admin_create_role( Ok((StatusCode::CREATED, Json(role))) } +fn auth_to_caller_bits(auth: &AuthUser) -> u32 { + match auth { + AuthUser::Authenticated { role_bits, .. } => *role_bits, + AuthUser::Guest => ROLE_GUEST, + } +} + fn mounted_archive<'a>( state: &'a AppState, archive_id: &str, @@ -767,6 +812,156 @@ impl IntoResponse for ApiError { } } +// ── Collection handlers ──────────────────────────────────────────────────────── + +async fn list_collections_handler( + State(state): State, + Path(archive_id): Path, +) -> Result>, ApiError> { + let mounted = mounted_archive(&state, &archive_id)?; + let conn = database::open_or_initialize(&mounted.archive_path)?; + Ok(Json(archive::list_collections(&conn)?)) +} + +async fn create_collection_handler( + State(state): State, + auth: AuthUser, + Path(archive_id): Path, + Json(body): Json, +) -> Result<(StatusCode, Json), ApiError> { + auth.require_role(ROLE_USER)?; + if body.name.trim().is_empty() { + return Err(ApiError::bad_request("collection name must not be empty")); + } + if body.slug.trim().is_empty() || body.slug.starts_with('_') { + return Err(ApiError::bad_request("collection slug must not be empty or start with underscore")); + } + let mounted = mounted_archive(&state, &archive_id)?; + let conn = database::open_or_initialize(&mounted.archive_path)?; + let record = database::create_collection(&conn, &body.name, &body.slug, body.default_visibility_bits) + .map_err(|e| ApiError::bad_request(&format!("{e:#}")))?; + Ok((StatusCode::CREATED, Json(archive::CollectionSummary { + collection_uid: record.collection_uid, + name: record.name, + slug: record.slug, + default_visibility_bits: record.default_visibility_bits, + created_at: record.created_at, + }))) +} + +async fn get_collection_handler( + State(state): State, + auth: AuthUser, + Path((archive_id, coll_uid)): Path<(String, String)>, +) -> Result, ApiError> { + let mounted = mounted_archive(&state, &archive_id)?; + let conn = database::open_or_initialize(&mounted.archive_path)?; + let record = database::get_collection_by_uid(&conn, &coll_uid)? + .ok_or(ApiError::not_found("collection not found"))?; + let caller_bits = auth_to_caller_bits(&auth); + let entries = archive::list_entries_for_collection(&conn, record.id, caller_bits)?; + Ok(Json(serde_json::json!({ + "collection_uid": record.collection_uid, + "name": record.name, + "slug": record.slug, + "default_visibility_bits": record.default_visibility_bits, + "created_at": record.created_at, + "entries": entries, + }))) +} + +async fn add_entry_to_collection_handler( + State(state): State, + auth: AuthUser, + Path((archive_id, coll_uid)): Path<(String, String)>, + Json(body): Json, +) -> Result { + auth.require_role(ROLE_USER)?; + let mounted = mounted_archive(&state, &archive_id)?; + let conn = database::open_or_initialize(&mounted.archive_path)?; + let coll = database::get_collection_by_uid(&conn, &coll_uid)? + .ok_or(ApiError::not_found("collection not found"))?; + if coll.slug == "_default_" { + return Err(ApiError::bad_request("cannot manually add entries to the default collection")); + } + let entry_id: i64 = conn + .query_row( + "SELECT id FROM archived_entries WHERE entry_uid = ?1", + [body.entry_uid.as_str()], + |row| row.get(0), + ) + .optional()? + .ok_or(ApiError::not_found("entry not found"))?; + database::add_entry_to_collection(&conn, coll.id, entry_id, body.visibility_bits)?; + Ok(StatusCode::NO_CONTENT) +} + +async fn remove_entry_from_collection_handler( + State(state): State, + auth: AuthUser, + Path((archive_id, coll_uid, entry_uid)): Path<(String, String, String)>, +) -> Result { + auth.require_role(ROLE_USER)?; + let mounted = mounted_archive(&state, &archive_id)?; + let conn = database::open_or_initialize(&mounted.archive_path)?; + let coll = database::get_collection_by_uid(&conn, &coll_uid)? + .ok_or(ApiError::not_found("collection not found"))?; + if coll.slug == "_default_" { + return Err(ApiError::bad_request("cannot manually remove entries from the default collection")); + } + let entry_id: i64 = conn + .query_row( + "SELECT id FROM archived_entries WHERE entry_uid = ?1", + [entry_uid.as_str()], + |row| row.get(0), + ) + .optional()? + .ok_or(ApiError::not_found("entry not found"))?; + if database::remove_entry_from_collection(&conn, coll.id, entry_id)? { + Ok(StatusCode::NO_CONTENT) + } else { + Err(ApiError::not_found("entry not in collection")) + } +} + +async fn update_entry_visibility_handler( + State(state): State, + auth: AuthUser, + Path((archive_id, coll_uid, entry_uid)): Path<(String, String, String)>, + Json(body): Json, +) -> Result { + auth.require_role(ROLE_USER)?; + let mounted = mounted_archive(&state, &archive_id)?; + let conn = database::open_or_initialize(&mounted.archive_path)?; + let coll = database::get_collection_by_uid(&conn, &coll_uid)? + .ok_or(ApiError::not_found("collection not found"))?; + let entry_id: i64 = conn + .query_row( + "SELECT id FROM archived_entries WHERE entry_uid = ?1", + [entry_uid.as_str()], + |row| row.get(0), + ) + .optional()? + .ok_or(ApiError::not_found("entry not found"))?; + if database::update_collection_entry_visibility(&conn, coll.id, entry_id, body.visibility_bits)? { + Ok(StatusCode::NO_CONTENT) + } else { + Err(ApiError::not_found("entry not in collection")) + } +} + +async fn list_entry_collections_handler( + State(state): State, + Path((archive_id, entry_uid)): Path<(String, String)>, +) -> Result>, ApiError> { + let mounted = mounted_archive(&state, &archive_id)?; + let conn = database::open_or_initialize(&mounted.archive_path)?; + match archive::get_entry_collections(&conn, &entry_uid)? { + Some(memberships) => Ok(Json(memberships)), + None => Err(ApiError::not_found("entry not found")), + } +} + #[cfg(test)] mod tests { use super::*; From dd56fc8c700b298aaf5ca7b75bab8d4fd0732d4c Mon Sep 17 00:00:00 2001 From: TheGeneralist <180094941+thegeneralist01@users.noreply.github.com> Date: Fri, 26 Jun 2026 17:01:43 +0200 Subject: [PATCH 4/7] test(collections): update tag-search test to send auth (private entries require auth) --- crates/archivr-server/src/routes.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/archivr-server/src/routes.rs b/crates/archivr-server/src/routes.rs index b158656..f5044fa 100644 --- a/crates/archivr-server/src/routes.rs +++ b/crates/archivr-server/src/routes.rs @@ -1531,11 +1531,12 @@ mod tests { .unwrap(); assert_eq!(assign_resp.status(), StatusCode::CREATED, "assign tag should return 201"); - // Search with ?tag=/science — entry should appear + // Search with ?tag=/science — entry should appear (requires auth since entry is private) let response = app(registry.clone(), auth_path.clone()) .oneshot( Request::builder() .uri("/api/archives/test/entries/search?tag=%2Fscience") + .header("cookie", &session_cookie) .body(Body::empty()) .unwrap(), ) @@ -1555,6 +1556,7 @@ mod tests { .oneshot( Request::builder() .uri("/api/archives/test/entries/search?tag=%2Fart") + .header("cookie", &session_cookie) .body(Body::empty()) .unwrap(), ) From 763cb8e17f54c5ada0d1376622949097dc9118f9 Mon Sep 17 00:00:00 2001 From: TheGeneralist <180094941+thegeneralist01@users.noreply.github.com> Date: Fri, 26 Jun 2026 17:04:49 +0200 Subject: [PATCH 5/7] =?UTF-8?q?feat(collections):=20frontend=20=E2=80=94?= =?UTF-8?q?=20CollectionsView,=20nav,=20visibility=20in=20ContextRail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.jsx | 4 + frontend/src/api.js | 61 ++++++++ frontend/src/components/CollectionsView.jsx | 165 ++++++++++++++++++++ frontend/src/components/ContextRail.jsx | 21 ++- frontend/src/components/Topbar.jsx | 2 +- 5 files changed, 250 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/CollectionsView.jsx diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index f6f119b..f8f01ab 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -9,6 +9,7 @@ import EntriesView from './components/EntriesView' import RunsView from './components/RunsView' import AdminView from './components/AdminView' import TagsView from './components/TagsView' +import CollectionsView from './components/CollectionsView' import ContextRail from './components/ContextRail' export const AuthContext = createContext(null); @@ -213,6 +214,9 @@ export default function App() { onViewChange={handleViewChange} /> )} + {view === 'collections' && ( + + )} ({ error: res.statusText })); + throw new Error(err.error || res.statusText); + } + return res.json(); +} + +export async function getCollection(archiveId, collUid) { + return getJson(`/api/archives/${archiveId}/collections/${collUid}`); +} + +export async function addEntryToCollection(archiveId, collUid, entryUid, visibilityBits = 2) { + const res = await fetch(`/api/archives/${archiveId}/collections/${collUid}/entries`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ entry_uid: entryUid, visibility_bits: visibilityBits }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })); + throw new Error(err.error || res.statusText); + } +} + +export async function removeEntryFromCollection(archiveId, collUid, entryUid) { + const res = await fetch(`/api/archives/${archiveId}/collections/${collUid}/entries/${entryUid}`, { + method: 'DELETE', + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })); + throw new Error(err.error || res.statusText); + } +} + +export async function updateEntryVisibility(archiveId, collUid, entryUid, visibilityBits) { + const res = await fetch(`/api/archives/${archiveId}/collections/${collUid}/entries/${entryUid}`, { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ visibility_bits: visibilityBits }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })); + throw new Error(err.error || res.statusText); + } +} + +export async function listEntryCollections(archiveId, entryUid) { + return getJson(`/api/archives/${archiveId}/entries/${entryUid}/collections`); +} + // ── 401 interceptor ─────────────────────────────────────────────────────────── const _origFetch = window.fetch; window.fetch = async (...args) => { diff --git a/frontend/src/components/CollectionsView.jsx b/frontend/src/components/CollectionsView.jsx new file mode 100644 index 0000000..bd542e7 --- /dev/null +++ b/frontend/src/components/CollectionsView.jsx @@ -0,0 +1,165 @@ +import { useState, useEffect, useCallback } from 'react' +import { listCollections, createCollection, getCollection } from '../api.js' + +const VIS_LABELS = { 0: 'Private', 1: 'Public', 2: 'Users only', 3: 'Public' } + +export default function CollectionsView({ archiveId }) { + const [collections, setCollections] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [selectedColl, setSelectedColl] = useState(null) + const [collDetail, setCollDetail] = useState(null) + const [detailLoading, setDetailLoading] = useState(false) + + // Create form + const [newName, setNewName] = useState('') + const [newSlug, setNewSlug] = useState('') + const [newVis, setNewVis] = useState(2) + const [creating, setCreating] = useState(false) + const [createError, setCreateError] = useState(null) + + const refresh = useCallback(async () => { + if (!archiveId) return + setLoading(true) + setError(null) + try { + const cols = await listCollections(archiveId) + setCollections(cols) + } catch (e) { + setError(e.message) + } finally { + setLoading(false) + } + }, [archiveId]) + + useEffect(() => { refresh() }, [refresh]) + + useEffect(() => { + if (!selectedColl) { setCollDetail(null); return } + setDetailLoading(true) + getCollection(archiveId, selectedColl.collection_uid) + .then(d => setCollDetail(d)) + .catch(e => setError(e.message)) + .finally(() => setDetailLoading(false)) + }, [selectedColl, archiveId]) + + async function handleCreate(e) { + e.preventDefault() + const name = newName.trim() + const slug = newSlug.trim() + if (!name || !slug) return + setCreating(true) + setCreateError(null) + try { + await createCollection(archiveId, name, slug, newVis) + setNewName('') + setNewSlug('') + setNewVis(2) + await refresh() + } catch (err) { + setCreateError(err.message) + } finally { + setCreating(false) + } + } + + if (!archiveId) return
Select an archive.
+ + return ( +
+

Collections

+ + {loading &&
Loading…
} + {error &&
{error}
} + +
+
+ {collections.map(c => ( +
setSelectedColl(c)} + > + {c.name} + + {VIS_LABELS[c.default_visibility_bits] ?? c.default_visibility_bits} + +
+ ))} + {collections.length === 0 && !loading && ( +
No collections yet.
+ )} +
+ + {selectedColl && ( +
+

{selectedColl.name}

+
+ Default visibility: {VIS_LABELS[selectedColl.default_visibility_bits] ?? selectedColl.default_visibility_bits} +
+ {detailLoading ? ( +
Loading entries…
+ ) : collDetail ? ( + collDetail.entries.length === 0 ? ( +
No entries visible to you in this collection.
+ ) : ( +
    + {collDetail.entries.map(entry => ( +
  • + + {entry.title || entry.entry_uid} + + + {entry.source_kind} + +
  • + ))} +
+ ) + ) : null} +
+ )} +
+ +
+ Create collection +
+ + + + {createError &&
{createError}
} + +
+
+
+ ) +} diff --git a/frontend/src/components/ContextRail.jsx b/frontend/src/components/ContextRail.jsx index ff497fc..b94c6a5 100644 --- a/frontend/src/components/ContextRail.jsx +++ b/frontend/src/components/ContextRail.jsx @@ -1,11 +1,12 @@ import { useState, useEffect, useRef } from 'react' -import { fetchEntryDetail, fetchEntryTags, assignTag, removeTag } from '../api' +import { fetchEntryDetail, fetchEntryTags, assignTag, removeTag, listEntryCollections } from '../api' import { formatTimestamp, formatBytes, valueText } from '../utils' export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet, tagNodes, onTagsRefresh }) { const [detail, setDetail] = useState(null) const [tags, setTags] = useState([]) const [assignInput, setAssignInput] = useState('') + const [entryCollections, setEntryCollections] = useState([]) const [assignError, setAssignError] = useState('') const selectSeqRef = useRef(0) @@ -13,6 +14,7 @@ export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet, if (!selectedEntry || !archiveId) { setDetail(null) setTags([]) + setEntryCollections([]) return } const seq = ++selectSeqRef.current @@ -21,10 +23,12 @@ export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet, Promise.all([ fetchEntryDetail(archiveId, selectedEntry.entry_uid), fetchEntryTags(archiveId, selectedEntry.entry_uid), - ]).then(([det, tgs]) => { + listEntryCollections(archiveId, selectedEntry.entry_uid), + ]).then(([det, tgs, ecs]) => { if (seq !== selectSeqRef.current) return setDetail(det) setTags(tgs) + setEntryCollections(ecs) }).catch(() => {}) }, [selectedEntry, archiveId]) @@ -156,6 +160,19 @@ export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet, )} + {selectedEntry && entryCollections.length > 0 && ( +
+
Collections
+ {entryCollections.map(c => ( +
+ {c.collection_uid}:{' '} + + {{ 0: 'Private', 1: 'Public', 2: 'Users only', 3: 'Public' }[c.visibility_bits] ?? `bits:${c.visibility_bits}`} + +
+ ))} +
+ )} )} diff --git a/frontend/src/components/Topbar.jsx b/frontend/src/components/Topbar.jsx index fb55ea7..bf6ced4 100644 --- a/frontend/src/components/Topbar.jsx +++ b/frontend/src/components/Topbar.jsx @@ -21,7 +21,7 @@ export default function Topbar({ archives, archiveId, onArchiveChange, view, onV {archives.map(a => )}