diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 88d5b52..5ee006e 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -605,11 +605,13 @@ dependencies = [ "aws-sdk-s3", "axum", "axum-extra", + "base64 0.21.7", "bytes", "chrono", "diesel", "diesel_migrations", "dotenv", + "futures-util", "hex", "http-body-util", "hyper 1.7.0", @@ -619,6 +621,7 @@ dependencies = [ "once_cell", "pdfium-render", "percent-encoding", + "quick-xml", "rand 0.8.5", "reqwest", "serde", @@ -628,7 +631,7 @@ dependencies = [ "thiserror 1.0.69", "tokio", "tower 0.4.13", - "tower-http 0.5.2", + "tower-http", "tracing", "tracing-subscriber", "uuid", @@ -1269,6 +1272,23 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.31" @@ -1288,9 +1308,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ "futures-core", + "futures-io", + "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "pin-utils", + "slab", ] [[package]] @@ -2354,6 +2379,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "quick-xml" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d3a6e5838b60e0e8fa7a43f22ade549a37d61f8bdbe636d0d7816191de969c2" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.9" @@ -2547,6 +2581,7 @@ dependencies = [ "base64 0.22.1", "bytes", "futures-core", + "futures-util", "http 1.3.1", "http-body 1.0.1", "http-body-util", @@ -2566,12 +2601,14 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls 0.26.4", + "tokio-util", "tower 0.5.2", - "tower-http 0.6.6", + "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", "webpki-roots", ] @@ -3320,23 +3357,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "tower-http" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" -dependencies = [ - "bitflags", - "bytes", - "http 1.3.1", - "http-body 1.0.1", - "http-body-util", - "pin-project-lite", - "tower-layer", - "tower-service", - "tracing", -] - [[package]] name = "tower-http" version = "0.6.6" @@ -3353,6 +3373,7 @@ dependencies = [ "tower 0.5.2", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -3642,6 +3663,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "web-sys" version = "0.3.81" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index f395f8d..5812992 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -8,7 +8,7 @@ edition = "2021" axum = { version = "0.7", features = ["multipart"] } tokio = { version = "1", features = ["full"] } tower = { version = "0.4", features = ["make", "util"] } -tower-http = { version = "0.5", features = ["cors", "trace"] } +tower-http = { version = "0.6", features = ["cors", "trace"] } axum-extra = { version = "0.9", features = ["typed-header"] } # Database @@ -38,8 +38,11 @@ image = { version = "0.25", default-features = false, features = ["png", "jpeg"] pdfium-render = "0.8" mime_guess = "2.0" tempfile = "3.10" -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } percent-encoding = "2.3" +base64 = "0.21" +quick-xml = "0.32" +futures-util = "0.3" # Error handling thiserror = "1.0" diff --git a/backend/Dockerfile b/backend/Dockerfile index 1aeced2..92d9c1a 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -20,7 +20,7 @@ COPY migrations ./migrations COPY tests ./tests COPY diesel.toml ./ -RUN cargo build --release --bin backend --bin worker +RUN cargo build --release --bin backend --bin worker --bin webdav RUN cargo install diesel_cli --no-default-features --features postgres FROM debian:trixie-slim AS runtime @@ -52,6 +52,7 @@ RUN apt-get update \ COPY --from=builder /app/target/release/backend /usr/local/bin/papercrate-backend COPY --from=builder /app/target/release/worker /usr/local/bin/papercrate-worker +COPY --from=builder /app/target/release/webdav /usr/local/bin/papercrate-webdav COPY --from=builder /usr/local/cargo/bin/diesel /usr/local/bin/diesel COPY migrations ./migrations COPY diesel.toml ./ diff --git a/backend/src/bin/webdav.rs b/backend/src/bin/webdav.rs new file mode 100644 index 0000000..0240b45 --- /dev/null +++ b/backend/src/bin/webdav.rs @@ -0,0 +1,48 @@ +use std::net::SocketAddr; +use std::sync::Arc; + +use tokio::net::TcpListener; +use tower::make::Shared; +use tracing_subscriber::EnvFilter; + +use backend::auth::jwt::JwtService; +use backend::config::AppConfig; +use backend::db; +use backend::routes::webdav; +use backend::s3::build_client; +use backend::state::AppState; +use backend::storage::S3Storage; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + dotenv::dotenv().ok(); + init_tracing(); + + let config = AppConfig::from_env()?; + let pool = db::init_pool(&config.database_url)?; + let s3_client = build_client(&config).await?; + let storage = Arc::new(S3Storage::new(s3_client, config.s3_bucket.clone())); + let jwt = JwtService::from_config(&config)?; + + let state = AppState::new(pool, config, storage, jwt); + let listen_addr: SocketAddr = { + let config = state.config.clone(); + format!("{}:{}", config.webdav_host, config.webdav_port).parse()? + }; + let router = webdav::create_router().with_state(state); + + let listener = TcpListener::bind(listen_addr).await?; + tracing::info!("listening for WebDAV on {}", listen_addr); + + axum::serve(listener, Shared::new(router)).await?; + Ok(()) +} + +fn init_tracing() { + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); + tracing_subscriber::fmt() + .with_env_filter(filter) + .with_target(false) + .compact() + .init(); +} diff --git a/backend/src/config.rs b/backend/src/config.rs index 3cfd708..6da9d74 100644 --- a/backend/src/config.rs +++ b/backend/src/config.rs @@ -7,6 +7,8 @@ pub struct AppConfig { pub database_url: String, pub server_host: String, pub server_port: u16, + pub webdav_host: String, + pub webdav_port: u16, pub jwt_secret: String, pub jwt_issuer: String, pub jwt_audience: String, @@ -34,6 +36,11 @@ impl AppConfig { .unwrap_or_else(|_| "3000".to_string()) .parse() .context("SERVER_PORT must be a valid u16")?; + let webdav_host = env::var("WEBDAV_HOST").unwrap_or_else(|_| server_host.clone()); + let webdav_port = env::var("WEBDAV_PORT") + .unwrap_or_else(|_| "3001".to_string()) + .parse() + .context("WEBDAV_PORT must be a valid u16")?; let jwt_secret = env::var("JWT_SECRET").context("JWT_SECRET must be set")?; let jwt_issuer = env::var("JWT_ISSUER").unwrap_or_else(|_| "papercrate".to_string()); let jwt_audience = @@ -69,6 +76,8 @@ impl AppConfig { database_url, server_host, server_port, + webdav_host, + webdav_port, jwt_secret, jwt_issuer, jwt_audience, diff --git a/backend/src/routes/documents.rs b/backend/src/routes/documents.rs index f93e4d6..ea1d239 100644 --- a/backend/src/routes/documents.rs +++ b/backend/src/routes/documents.rs @@ -205,6 +205,12 @@ pub struct BulkTagResponse { pub removed: usize, } +#[derive(Serialize)] +pub struct BulkCorrespondentResponse { + pub assigned: usize, + pub removed: usize, +} + #[derive(Deserialize)] pub struct CorrespondentAssignmentInput { pub correspondent_id: Uuid, @@ -218,6 +224,69 @@ pub struct AssignCorrespondentsRequest { pub replace: bool, } +#[derive(Deserialize, Copy, Clone, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum BulkCorrespondentAction { + Add, + Remove, +} + +fn default_bulk_correspondent_action() -> BulkCorrespondentAction { + BulkCorrespondentAction::Add +} + +#[derive(Deserialize)] +pub struct BulkCorrespondentsRequest { + pub document_ids: Vec, + pub assignments: Vec, + #[serde(default = "default_bulk_correspondent_action")] + pub action: BulkCorrespondentAction, +} + +fn normalize_correspondent_assignments( + assignments: &[CorrespondentAssignmentInput], +) -> AppResult<(Vec<(Uuid, String)>, Vec, Vec)> { + let mut unique_pairs: HashSet<(Uuid, String)> = HashSet::new(); + let mut normalized_pairs: Vec<(Uuid, String)> = Vec::new(); + let mut role_set: HashSet = HashSet::new(); + let mut correspondent_ids: HashSet = HashSet::new(); + + for assignment in assignments { + let role = normalize_role(&assignment.role); + if role.is_empty() { + return Err(AppError::bad_request("role must not be empty")); + } + if !is_valid_correspondent_role(&role) { + return Err(AppError::bad_request(format!( + "invalid correspondent role '{role}'. Allowed roles: {}", + CORRESPONDENT_ROLES.join(", ") + ))); + } + + if !unique_pairs.insert((assignment.correspondent_id, role.clone())) { + continue; + } + + normalized_pairs.push((assignment.correspondent_id, role.clone())); + role_set.insert(role); + correspondent_ids.insert(assignment.correspondent_id); + } + + if normalized_pairs.is_empty() { + return Err(AppError::bad_request( + "assignments must contain at least one unique correspondent/role pair", + )); + } + + let mut correspondents_vec: Vec = correspondent_ids.into_iter().collect(); + correspondents_vec.sort(); + + let mut roles_vec: Vec = role_set.into_iter().collect(); + roles_vec.sort(); + + Ok((normalized_pairs, correspondents_vec, roles_vec)) +} + #[derive(Deserialize)] pub struct CorrespondentRoleQuery { pub role: String, @@ -850,40 +919,8 @@ pub async fn assign_correspondents( return Err(AppError::bad_request("assignments must not be empty")); } - let mut unique_pairs: HashSet<(Uuid, String)> = HashSet::new(); - let mut normalized_pairs: Vec<(Uuid, String)> = Vec::new(); - let mut role_set: HashSet = HashSet::new(); - let mut correspondent_ids: HashSet = HashSet::new(); - - for assignment in &payload.assignments { - let role = normalize_role(&assignment.role); - if role.is_empty() { - return Err(AppError::bad_request("role must not be empty")); - } - if !is_valid_correspondent_role(&role) { - return Err(AppError::bad_request(format!( - "invalid correspondent role '{role}'. Allowed roles: {}", - CORRESPONDENT_ROLES.join(", ") - ))); - } - - if !unique_pairs.insert((assignment.correspondent_id, role.clone())) { - continue; - } - - normalized_pairs.push((assignment.correspondent_id, role.clone())); - role_set.insert(role); - correspondent_ids.insert(assignment.correspondent_id); - } - - if normalized_pairs.is_empty() { - return Err(AppError::bad_request( - "assignments must contain at least one unique correspondent/role pair", - )); - } - - let correspondents_vec: Vec = correspondent_ids.into_iter().collect(); - let roles_vec: Vec = role_set.into_iter().collect(); + let (normalized_pairs, correspondents_vec, roles_vec) = + normalize_correspondent_assignments(&payload.assignments)?; let replace = payload.replace; let user_id = user.user_id; @@ -950,6 +987,137 @@ pub async fn assign_correspondents( Ok(StatusCode::NO_CONTENT) } +pub async fn bulk_assign_correspondents( + State(state): State, + user: AuthenticatedUser, + Json(payload): Json, +) -> AppResult<(StatusCode, Json)> { + if payload.document_ids.is_empty() { + return Err(AppError::bad_request("document_ids must not be empty")); + } + if payload.assignments.is_empty() { + return Err(AppError::bad_request("assignments must not be empty")); + } + + let mut document_ids = payload.document_ids; + document_ids.sort(); + document_ids.dedup(); + + let (normalized_pairs, correspondents_vec, roles_vec) = + normalize_correspondent_assignments(&payload.assignments)?; + let action = payload.action; + let user_id = user.user_id; + + let mut conn = state.db()?; + let (assigned, removed) = conn.transaction::<(usize, usize), AppError, _>(|conn| { + let docs: Vec<(Uuid, Option)> = documents::table + .filter(documents::id.eq_any(&document_ids)) + .select((documents::id, documents::deleted_at)) + .load(conn)?; + + if docs.len() != document_ids.len() { + return Err(AppError::bad_request( + "one or more documents do not exist or are inaccessible", + )); + } + + if docs.iter().any(|(_, deleted)| deleted.is_some()) { + return Err(AppError::bad_request( + "cannot assign correspondents to deleted documents", + )); + } + + if !correspondents_vec.is_empty() { + let existing: Vec = correspondents::table + .filter(correspondents::id.eq_any(&correspondents_vec)) + .load(conn)?; + if existing.len() != correspondents_vec.len() { + return Err(AppError::bad_request( + "one or more correspondents do not exist", + )); + } + } + + match action { + BulkCorrespondentAction::Add => { + let mut removed = 0; + if !roles_vec.is_empty() { + removed = diesel::delete( + document_correspondents::table + .filter(document_correspondents::document_id.eq_any(&document_ids)) + .filter(document_correspondents::role.eq_any(&roles_vec)), + ) + .execute(conn)?; + } + + let mut new_rows = Vec::with_capacity(document_ids.len() * normalized_pairs.len()); + for doc_id in &document_ids { + for (correspondent_id, role) in &normalized_pairs { + new_rows.push(NewDocumentCorrespondent { + document_id: *doc_id, + correspondent_id: *correspondent_id, + role: role.clone(), + assigned_by: Some(user_id), + }); + } + } + + let assigned = if new_rows.is_empty() { + 0 + } else { + diesel::insert_into(document_correspondents::table) + .values(&new_rows) + .on_conflict_do_nothing() + .execute(conn)? + }; + + if assigned > 0 || removed > 0 { + diesel::update(documents::table.filter(documents::id.eq_any(&document_ids))) + .set(documents::updated_at.eq(Utc::now().naive_utc())) + .execute(conn)?; + } + + Ok((assigned, removed)) + } + BulkCorrespondentAction::Remove => { + let mut removed = 0; + if !normalized_pairs.is_empty() { + let mut grouped: HashMap> = HashMap::new(); + for (correspondent_id, role) in &normalized_pairs { + grouped + .entry(role.clone()) + .or_default() + .push(*correspondent_id); + } + + for (role, ids) in grouped { + removed += diesel::delete( + document_correspondents::table + .filter(document_correspondents::document_id.eq_any(&document_ids)) + .filter(document_correspondents::role.eq(role.as_str())) + .filter(document_correspondents::correspondent_id.eq_any(&ids)), + ) + .execute(conn)?; + } + } + + if removed > 0 { + diesel::update(documents::table.filter(documents::id.eq_any(&document_ids))) + .set(documents::updated_at.eq(Utc::now().naive_utc())) + .execute(conn)?; + } + + Ok((0, removed)) + } + } + })?; + + Ok(( + StatusCode::OK, + Json(BulkCorrespondentResponse { assigned, removed }), + )) +} + pub async fn remove_correspondent( State(state): State, Path((document_id, correspondent_id)): Path<(Uuid, Uuid)>, diff --git a/backend/src/routes/folders.rs b/backend/src/routes/folders.rs index 9692c60..836b225 100644 --- a/backend/src/routes/folders.rs +++ b/backend/src/routes/folders.rs @@ -11,7 +11,7 @@ use std::collections::{HashMap, HashSet}; use uuid::Uuid; use crate::models::{Document, Folder, NewFolder}; -use crate::schema::{document_tags, documents, folders}; +use crate::schema::{document_correspondents, document_tags, documents, folders}; use crate::state::AppState; use crate::{ auth::AuthenticatedUser, @@ -60,6 +60,7 @@ pub struct FolderContentsResponse { pub struct DocumentSearchQuery { pub query: Option, pub tags: Option, + pub correspondents: Option, } #[derive(Serialize)] @@ -353,6 +354,55 @@ pub async fn search_documents( } } + if let Some(correspondents_param) = params + .correspondents + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + { + let correspondent_ids: Result, _> = correspondents_param + .split(',') + .map(|s| Uuid::parse_str(s.trim())) + .collect(); + + if let Ok(ids) = correspondent_ids { + if !ids.is_empty() { + let mut doc_id_set: Option> = None; + for correspondent_id in &ids { + let docs_for_correspondent: Vec = document_correspondents::table + .filter(document_correspondents::correspondent_id.eq(*correspondent_id)) + .select(document_correspondents::document_id) + .load(&mut conn)?; + + let docs_set: HashSet = docs_for_correspondent.into_iter().collect(); + doc_id_set = Some(match doc_id_set { + Some(existing) => existing.intersection(&docs_set).cloned().collect(), + None => docs_set, + }); + + if let Some(ref set) = doc_id_set { + if set.is_empty() { + break; + } + } + } + + let matching_doc_ids: HashSet = doc_id_set.unwrap_or_default(); + + if matching_doc_ids.is_empty() { + return Ok(Json(vec![])); + } + + let new_filter = match &filter_ids { + Some(existing) => existing.intersection(&matching_doc_ids).copied().collect(), + None => matching_doc_ids.clone(), + }; + + filter_ids = Some(new_filter); + } + } + } + if let Some(ref set) = filter_ids { if set.is_empty() { return Ok(Json(vec![])); diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index 7d6fac0..6b1f539 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -15,6 +15,7 @@ pub mod documents; pub mod folders; pub mod health; pub mod tags; +pub mod webdav; pub fn create_router(state: AppState) -> Router<()> { let cors = if let Some(origins) = state.config.cors_allowed_origin.as_ref() { @@ -59,6 +60,10 @@ pub fn create_router(state: AppState) -> Router<()> { .route("/reanalyze", post(documents::reanalyze_all_documents)) .route("/bulk/move", post(documents::bulk_move_documents)) .route("/bulk/tags", post(documents::bulk_update_tags)) + .route( + "/bulk/correspondents", + post(documents::bulk_assign_correspondents), + ) .route( "/bulk/reanalyze", post(documents::reanalyze_selected_documents), @@ -125,9 +130,9 @@ pub fn create_router(state: AppState) -> Router<()> { Router::new() .merge(download_routes) + .merge(protected_routes) .nest("/api/auth", auth_routes) .route("/api/health", get(health::health_check)) - .merge(protected_routes) .with_state(state) .layer(cors) .layer(DefaultBodyLimit::max(1024 * 1024 * 512)) diff --git a/backend/src/routes/webdav/mod.rs b/backend/src/routes/webdav/mod.rs new file mode 100644 index 0000000..fb128e0 --- /dev/null +++ b/backend/src/routes/webdav/mod.rs @@ -0,0 +1,808 @@ +use std::time::Duration; + +use axum::body::Body; +use axum::extract::State; +use axum::http::{header, HeaderMap, Method, StatusCode}; +use axum::response::Response; +use axum::Router; +use base64::engine::general_purpose::STANDARD as BASE64; +use base64::Engine; +use diesel::prelude::*; +use diesel::PgConnection; +use futures_util::StreamExt; +use percent_encoding::{percent_decode_str, utf8_percent_encode, NON_ALPHANUMERIC}; +use quick_xml::events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event}; +use quick_xml::Writer; +use uuid::Uuid; + +use crate::auth::password; +use crate::error::{AppError, AppResult}; +use crate::models::{Document, DocumentVersion, Folder, User}; +use crate::schema::{ + document_versions::dsl as document_versions_dsl, documents::dsl as documents_dsl, + folders::dsl as folders_dsl, users::dsl as users_dsl, +}; +use crate::state::AppState; + +const REALM: &str = "Papercrate WebDAV"; +const DOWNLOAD_URL_TTL_SECONDS: u64 = 300; + +#[derive(Clone, Debug)] +struct WebDavUser { + _user_id: Uuid, + _username: String, +} + +pub fn create_router() -> Router { + Router::new().fallback(webdav_entrypoint) +} + +async fn webdav_entrypoint( + State(state): State, + req: axum::http::Request, +) -> Result { + let method = req.method().clone(); + let headers = req.headers().clone(); + let path = req.uri().path().trim_start_matches('/').to_string(); + + tracing::debug!(method = %method, %path, "webdav entrypoint" ); + + match method { + ref m if m == Method::OPTIONS => Ok(handle_options()), + ref m if m == Method::GET => handle_get_or_head(&state, &path, headers, Method::GET).await, + ref m if m == Method::HEAD => { + handle_get_or_head(&state, &path, headers, Method::HEAD).await + } + _ => { + if method.as_str() == "PROPFIND" { + handle_propfind(&state, &path, headers).await + } else { + Ok(method_not_allowed()) + } + } + } +} + +async fn handle_propfind( + state: &AppState, + path: &str, + headers: HeaderMap, +) -> Result { + let _user = match authenticate(state, &headers)? { + Some(user) => user, + None => return Ok(unauthorized_response()), + }; + + let depth = match parse_depth(&headers) { + Ok(value) => value, + Err(response) => return Ok(response), + }; + + let segments = parse_segments(path)?; + let resolution = match resolve_path(state, &segments)? { + Some(resolved) => resolved, + None => return Ok(not_found_response()), + }; + + let resources = match resolution { + ResolvedPath::Root => { + let contents = fetch_folder_contents(state, None)?; + build_resources_for_folder(None, &[], &contents, depth) + } + ResolvedPath::Folder { folder, chain } => { + let contents = fetch_folder_contents(state, Some(folder.id))?; + build_resources_for_folder(Some(&folder), &chain, &contents, depth) + } + ResolvedPath::Document { + document, + version, + chain, + } => build_resources_for_document(&chain, &document, &version), + }; + + let body = render_multistatus(&resources) + .map_err(|err| AppError::internal(format!("failed to render WebDAV response: {err}")))?; + + let response = Response::builder() + .status(multi_status()) + .header(header::CONTENT_TYPE, "application/xml; charset=utf-8") + .body(Body::from(body)) + .expect("valid response"); + + Ok(response) +} + +async fn handle_get_or_head( + state: &AppState, + path: &str, + headers: HeaderMap, + method: Method, +) -> Result { + let _user = match authenticate(state, &headers)? { + Some(user) => user, + None => return Ok(unauthorized_response()), + }; + + let segments = parse_segments(path)?; + let resolution = match resolve_path(state, &segments)? { + Some(resolved) => resolved, + None => return Ok(not_found_response()), + }; + + let (document, version, chain) = match resolution { + ResolvedPath::Document { + document, + version, + chain, + } => (document, version, chain), + _ => return Ok(method_not_allowed()), + }; + + stream_document(state, &document, &version, &chain, headers, method).await +} + +fn handle_options() -> Response { + Response::builder() + .status(StatusCode::OK) + .header("DAV", "1,2") + .header(header::ALLOW, "OPTIONS, PROPFIND, GET, HEAD") + .header("Accept-Ranges", "bytes") + .body(Body::empty()) + .expect("valid OPTIONS response") +} + +fn method_not_allowed() -> Response { + Response::builder() + .status(StatusCode::METHOD_NOT_ALLOWED) + .body(Body::empty()) + .expect("valid response") +} + +fn not_found_response() -> Response { + Response::builder() + .status(StatusCode::NOT_FOUND) + .body(Body::empty()) + .expect("valid response") +} + +fn unauthorized_response() -> Response { + Response::builder() + .status(StatusCode::UNAUTHORIZED) + .header( + header::WWW_AUTHENTICATE, + format!("Basic realm=\"{REALM}\", charset=\"UTF-8\""), + ) + .body(Body::empty()) + .expect("valid response") +} + +fn multi_status() -> StatusCode { + StatusCode::from_u16(207).expect("valid multi-status") +} + +fn parse_depth(headers: &HeaderMap) -> Result { + match headers.get("Depth") { + None => Ok(1), + Some(value) => match value.to_str() { + Ok("0") => Ok(0), + Ok("1") => Ok(1), + Ok("infinity") => Err(Response::builder() + .status(StatusCode::FORBIDDEN) + .body(Body::empty()) + .expect("valid response")), + _ => Err(Response::builder() + .status(StatusCode::BAD_REQUEST) + .body(Body::empty()) + .expect("valid response")), + }, + } +} + +fn parse_segments(path: &str) -> AppResult> { + if path.trim_matches('/').is_empty() { + return Ok(vec![]); + } + + let segments = path + .split('/') + .filter(|segment| !segment.is_empty()) + .map(|segment| { + percent_decode_str(segment) + .decode_utf8() + .map(|cow| cow.into_owned()) + .map_err(|_| AppError::bad_request("invalid UTF-8 in path")) + }) + .collect::, _>>()?; + + Ok(segments) +} + +fn fetch_folder_contents( + state: &AppState, + folder_id: Option, +) -> AppResult { + let mut conn = state.db()?; + + let folder = match folder_id { + Some(id) => Some(folders_dsl::folders.find(id).first::(&mut conn)?), + None => None, + }; + + let subfolders: Vec = match folder_id { + Some(id) => folders_dsl::folders + .filter(folders_dsl::parent_id.eq(Some(id))) + .order(folders_dsl::name.asc()) + .load(&mut conn)?, + None => folders_dsl::folders + .filter(folders_dsl::parent_id.is_null()) + .order(folders_dsl::name.asc()) + .load(&mut conn)?, + }; + + let mut docs_query = documents_dsl::documents + .filter(documents_dsl::deleted_at.is_null()) + .into_boxed(); + + docs_query = match folder_id { + Some(id) => docs_query.filter(documents_dsl::folder_id.eq(Some(id))), + None => docs_query.filter(documents_dsl::folder_id.is_null()), + }; + + let documents: Vec = docs_query + .order(documents_dsl::uploaded_at.desc()) + .load(&mut conn)?; + + let version_ids: Vec = documents.iter().map(|doc| doc.current_version_id).collect(); + let versions: Vec = if version_ids.is_empty() { + Vec::new() + } else { + document_versions_dsl::document_versions + .filter(document_versions_dsl::id.eq_any(&version_ids)) + .load(&mut conn)? + }; + + let mut version_map = versions + .into_iter() + .map(|version| (version.id, version)) + .collect::>(); + + let mut entries = Vec::with_capacity(documents.len()); + for document in documents { + if let Some(version) = version_map.remove(&document.current_version_id) { + entries.push(DocumentEntry { document, version }); + } + } + + Ok(WebDavFolderContents { + _folder: folder, + subfolders, + documents: entries, + }) +} + +async fn stream_document( + state: &AppState, + document: &Document, + version: &DocumentVersion, + _chain: &[String], + headers: HeaderMap, + method: Method, +) -> Result { + let range_header = headers.get(header::RANGE).cloned(); + + let url = state + .storage + .presign_get_object( + &version.s3_key, + Duration::from_secs(DOWNLOAD_URL_TTL_SECONDS), + ) + .await + .map_err(|err| AppError::internal(format!("failed to presign document download: {err}")))?; + + let client = reqwest::Client::new(); + let mut request = client.request(method.clone(), url.clone()); + + if let Some(range) = range_header.clone() { + request = request.header(header::RANGE, range.clone()); + } + + let upstream = request + .send() + .await + .map_err(|err| AppError::internal(format!("failed to fetch document stream: {err}")))?; + + let status = + StatusCode::from_u16(upstream.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY); + + if !(status.is_success() || status == StatusCode::PARTIAL_CONTENT) { + return Err(AppError::internal(format!( + "upstream download returned status {status}" + ))); + } + + let mut builder = Response::builder().status(status); + + if let Some(content_type) = upstream.headers().get(header::CONTENT_TYPE) { + builder = builder.header(header::CONTENT_TYPE, content_type); + } else if let Some(ref typ) = document.content_type { + builder = builder.header(header::CONTENT_TYPE, typ); + } + + if let Some(content_length) = upstream.headers().get(header::CONTENT_LENGTH) { + builder = builder.header(header::CONTENT_LENGTH, content_length); + } + + if let Some(range) = upstream.headers().get(header::CONTENT_RANGE) { + builder = builder.header(header::CONTENT_RANGE, range); + } + + builder = builder.header("Accept-Ranges", "bytes"); + + if let Some(disposition) = content_disposition(&document.filename) { + builder = builder.header(header::CONTENT_DISPOSITION, disposition); + } + + builder = builder.header(header::ETAG, format!("\"{}\"", version.id)); + + if method == Method::HEAD { + return builder + .body(Body::empty()) + .map_err(|err| AppError::internal(format!("failed to build response: {err}"))); + } + + let stream = upstream + .bytes_stream() + .map(|chunk| chunk.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err))); + let body = Body::from_stream(stream); + + builder + .body(body) + .map_err(|err| AppError::internal(format!("failed to build response: {err}"))) +} + +fn authenticate(state: &AppState, headers: &HeaderMap) -> Result, AppError> { + tracing::debug!("webdav authenticate invoked"); + let authorization = match headers.get(header::AUTHORIZATION) { + Some(value) => match value.to_str() { + Ok(header) if header.starts_with("Basic ") => { + tracing::debug!("authorization header present"); + &header[6..] + } + Ok(other) => { + tracing::warn!(header = %other, "non-basic authorization header"); + return Ok(None); + } + Err(err) => { + tracing::warn!(error = %err, "invalid authorization header"); + return Ok(None); + } + }, + None => { + tracing::debug!("no authorization header"); + return Ok(None); + } + }; + + let decoded = match BASE64.decode(authorization) { + Ok(bytes) => bytes, + Err(err) => { + tracing::warn!(error = %err, "failed to decode basic credentials"); + return Ok(None); + } + }; + + let credential_str = match String::from_utf8(decoded) { + Ok(value) => value, + Err(err) => { + tracing::warn!(error = %err, "invalid utf-8 basic credentials"); + return Ok(None); + } + }; + + let (username, password) = match credential_str.split_once(':') { + Some((username, password)) if !username.is_empty() => (username, password), + _ => return Ok(None), + }; + + tracing::debug!(%username, "attempting webdav login"); + let mut conn = state.db()?; + + let user: User = match users_dsl::users + .filter(users_dsl::username.eq(username)) + .first(&mut conn) + { + Ok(user) => user, + Err(diesel::result::Error::NotFound) => { + tracing::warn!(%username, "webdav user not found"); + return Ok(None); + } + Err(err) => return Err(AppError::from(err)), + }; + + let valid = password::verify_password(password, &user.password_hash) + .map_err(|_| AppError::internal("failed to verify password"))?; + + if !valid { + tracing::warn!(%username, "webdav password invalid"); + return Ok(None); + } + + tracing::debug!(%username, "webdav login success"); + Ok(Some(WebDavUser { + _user_id: user.id, + _username: user.username, + })) +} + +fn build_resources_for_folder( + folder: Option<&Folder>, + chain: &[String], + contents: &WebDavFolderContents, + depth: u8, +) -> Vec { + let mut resources = Vec::new(); + + let display_name = folder + .map(|folder| folder.name.clone()) + .unwrap_or_else(|| "/".to_string()); + + let href = build_href(chain, true); + let last_modified = folder.map(|folder| format_http_date(folder.updated_at)); + + resources.push(DavResource { + href, + display_name, + is_collection: true, + content_length: None, + content_type: None, + last_modified, + }); + + if depth == 0 { + return resources; + } + + for subfolder in &contents.subfolders { + let mut child_chain = chain.to_vec(); + child_chain.push(subfolder.name.clone()); + resources.push(DavResource { + href: build_href(&child_chain, true), + display_name: subfolder.name.clone(), + is_collection: true, + content_length: None, + content_type: None, + last_modified: Some(format_http_date(subfolder.updated_at)), + }); + } + + for entry in &contents.documents { + let mut child_chain = chain.to_vec(); + child_chain.push(entry.document.filename.clone()); + resources.push(document_to_resource( + &child_chain, + &entry.document, + &entry.version, + )); + } + + resources +} + +fn build_resources_for_document( + chain: &[String], + document: &Document, + version: &DocumentVersion, +) -> Vec { + vec![document_to_resource(chain, document, version)] +} + +fn document_to_resource( + chain: &[String], + document: &Document, + version: &DocumentVersion, +) -> DavResource { + let href = build_href(chain, false); + + DavResource { + href, + display_name: document.title.clone(), + is_collection: false, + content_length: Some(version.size_bytes), + content_type: document.content_type.clone(), + last_modified: Some(format_http_date(document.updated_at)), + } +} + +fn build_href(names: &[String], is_collection: bool) -> String { + if names.is_empty() { + return "/".to_string(); + } + + let encoded = names + .iter() + .map(|name| utf8_percent_encode(name, NON_ALPHANUMERIC).to_string()) + .collect::>(); + + let mut path = format!("/{}", encoded.join("/")); + if is_collection && !path.ends_with('/') { + path.push('/'); + } + path +} + +fn render_multistatus(resources: &[DavResource]) -> Result, quick_xml::Error> { + let mut writer = Writer::new(Vec::new()); + writer.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?; + + let mut multistatus = BytesStart::new("D:multistatus"); + multistatus.push_attribute(("xmlns:D", "DAV:")); + writer.write_event(Event::Start(multistatus))?; + + for resource in resources { + writer.write_event(Event::Start(BytesStart::new("D:response")))?; + + writer.write_event(Event::Start(BytesStart::new("D:href")))?; + writer.write_event(Event::Text(BytesText::new(&resource.href)))?; + writer.write_event(Event::End(BytesEnd::new("D:href")))?; + + writer.write_event(Event::Start(BytesStart::new("D:propstat")))?; + writer.write_event(Event::Start(BytesStart::new("D:prop")))?; + + writer.write_event(Event::Start(BytesStart::new("D:displayname")))?; + writer.write_event(Event::Text(BytesText::new(&resource.display_name)))?; + writer.write_event(Event::End(BytesEnd::new("D:displayname")))?; + + writer.write_event(Event::Start(BytesStart::new("D:resourcetype")))?; + if resource.is_collection { + writer.write_event(Event::Empty(BytesStart::new("D:collection")))?; + } + writer.write_event(Event::End(BytesEnd::new("D:resourcetype")))?; + + if let Some(length) = resource.content_length { + writer.write_event(Event::Start(BytesStart::new("D:getcontentlength")))?; + writer.write_event(Event::Text(BytesText::new(&length.to_string())))?; + writer.write_event(Event::End(BytesEnd::new("D:getcontentlength")))?; + } + + if let Some(content_type) = &resource.content_type { + writer.write_event(Event::Start(BytesStart::new("D:getcontenttype")))?; + writer.write_event(Event::Text(BytesText::new(content_type)))?; + writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?; + } + + if let Some(last_modified) = &resource.last_modified { + writer.write_event(Event::Start(BytesStart::new("D:getlastmodified")))?; + writer.write_event(Event::Text(BytesText::new(last_modified)))?; + writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?; + } + + writer.write_event(Event::End(BytesEnd::new("D:prop")))?; + + writer.write_event(Event::Start(BytesStart::new("D:status")))?; + writer.write_event(Event::Text(BytesText::new("HTTP/1.1 200 OK")))?; + writer.write_event(Event::End(BytesEnd::new("D:status")))?; + + writer.write_event(Event::End(BytesEnd::new("D:propstat")))?; + writer.write_event(Event::End(BytesEnd::new("D:response")))?; + } + + writer.write_event(Event::End(BytesEnd::new("D:multistatus")))?; + Ok(writer.into_inner()) +} + +fn format_http_date(value: chrono::NaiveDateTime) -> String { + let datetime = chrono::DateTime::::from_naive_utc_and_offset(value, chrono::Utc); + datetime.format("%a, %d %b %Y %H:%M:%S GMT").to_string() +} + +fn content_disposition(filename: &str) -> Option { + if filename.is_empty() { + return None; + } + + let sanitized: String = filename + .chars() + .map(|ch| match ch { + '"' | '\\' => '_', + _ => ch, + }) + .collect(); + + let encoded = + percent_encoding::utf8_percent_encode(&sanitized, percent_encoding::NON_ALPHANUMERIC); + Some(format!( + "inline; filename=\"{}\"; filename*=UTF-8''{}", + sanitized, encoded + )) +} + +struct WebDavFolderContents { + _folder: Option, + subfolders: Vec, + documents: Vec, +} + +struct DocumentEntry { + document: Document, + version: DocumentVersion, +} + +struct DavResource { + href: String, + display_name: String, + is_collection: bool, + content_length: Option, + content_type: Option, + last_modified: Option, +} + +enum ResolvedPath { + Root, + Folder { + folder: Folder, + chain: Vec, + }, + Document { + document: Document, + version: DocumentVersion, + chain: Vec, + }, +} + +fn resolve_path(state: &AppState, segments: &[String]) -> AppResult> { + if segments.is_empty() { + return Ok(Some(ResolvedPath::Root)); + } + + let mut conn = state.db()?; + let mut parent_id: Option = None; + let mut chain: Vec = Vec::new(); + let mut current_folder: Option = None; + + for (index, segment) in segments.iter().enumerate() { + let is_last = index == segments.len() - 1; + + match find_folder_by_name(&mut conn, parent_id, segment)? { + Some(folder) => { + if is_last { + chain.push(folder.name.clone()); + return Ok(Some(ResolvedPath::Folder { folder, chain })); + } + + parent_id = Some(folder.id); + chain.push(folder.name.clone()); + current_folder = Some(folder); + continue; + } + None => {} + } + + if is_last { + if let Some((document, version)) = + find_document_by_filename(&mut conn, parent_id, segment)? + { + chain.push(document.filename.clone()); + return Ok(Some(ResolvedPath::Document { + document, + version, + chain, + })); + } + } + + if let Ok(uuid) = Uuid::parse_str(segment) { + if let Some(folder) = folders_dsl::folders + .find(uuid) + .first::(&mut conn) + .optional()? + { + if folder.parent_id != parent_id { + return Ok(None); + } + if !is_last { + parent_id = Some(folder.id); + chain.push(folder.name.clone()); + current_folder = Some(folder); + continue; + } else { + chain.push(folder.name.clone()); + return Ok(Some(ResolvedPath::Folder { folder, chain })); + } + } + + if let Some((document, version)) = find_document_by_id(&mut conn, uuid)? { + if document.folder_id != parent_id { + return Ok(None); + } + chain.push(document.filename.clone()); + return Ok(Some(ResolvedPath::Document { + document, + version, + chain, + })); + } + } + + if is_last { + if let Some((document, version)) = + find_document_by_filename(&mut conn, parent_id, segment)? + { + chain.push(document.filename.clone()); + return Ok(Some(ResolvedPath::Document { + document, + version, + chain, + })); + } + } + + return Ok(None); + } + + Ok(current_folder.map(|folder| ResolvedPath::Folder { folder, chain })) +} + +fn find_folder_by_name( + conn: &mut PgConnection, + parent_id: Option, + name: &str, +) -> AppResult> { + let result = match parent_id { + Some(parent) => folders_dsl::folders + .filter(folders_dsl::parent_id.eq(Some(parent))) + .filter(folders_dsl::name.eq(name)) + .first::(conn) + .optional()?, + None => folders_dsl::folders + .filter(folders_dsl::parent_id.is_null()) + .filter(folders_dsl::name.eq(name)) + .first::(conn) + .optional()?, + }; + + Ok(result) +} + +fn find_document_by_filename( + conn: &mut PgConnection, + parent_id: Option, + filename: &str, +) -> AppResult> { + let mut query = documents_dsl::documents + .filter(documents_dsl::deleted_at.is_null()) + .filter(documents_dsl::filename.eq(filename)) + .into_boxed(); + + query = match parent_id { + Some(parent) => query.filter(documents_dsl::folder_id.eq(Some(parent))), + None => query.filter(documents_dsl::folder_id.is_null()), + }; + + if let Some(document) = query.first::(conn).optional()? { + let version = document_versions_dsl::document_versions + .find(document.current_version_id) + .first::(conn)?; + return Ok(Some((document, version))); + } + + Ok(None) +} + +fn find_document_by_id( + conn: &mut PgConnection, + document_id: Uuid, +) -> AppResult> { + if let Some(document) = documents_dsl::documents + .filter(documents_dsl::deleted_at.is_null()) + .find(document_id) + .first::(conn) + .optional()? + { + let version = document_versions_dsl::document_versions + .find(document.current_version_id) + .first::(conn)?; + return Ok(Some((document, version))); + } + + Ok(None) +} diff --git a/backend/tests/documents_flow.rs b/backend/tests/documents_flow.rs index f366617..7a3ad5a 100644 --- a/backend/tests/documents_flow.rs +++ b/backend/tests/documents_flow.rs @@ -20,6 +20,8 @@ struct DocumentInfo { issued_at: Option, tags: Vec, #[serde(default)] + correspondents: Vec, + #[serde(default)] current_version: Option, } @@ -75,6 +77,23 @@ struct TagSummary { label: String, } +#[derive(Deserialize)] +struct DocumentCorrespondentInfo { + name: String, + role: String, +} + +#[derive(Deserialize)] +struct CorrespondentSummary { + id: Uuid, +} + +#[derive(Deserialize)] +struct BulkCorrespondentResult { + assigned: usize, + removed: usize, +} + #[derive(Deserialize)] struct AnalyzeJobPayload { document_id: Uuid, @@ -157,12 +176,12 @@ async fn upload_and_list_document() -> Result<()> { assert_eq!(detail.document.deleted_at, None); assert!(detail.document.issued_at.is_none()); assert!(detail.document.tags.is_empty()); - assert!(current_version.download_path.starts_with("/download/")); let current_version = detail .document .current_version .as_ref() .expect("current version detail"); + assert!(current_version.download_path.starts_with("/download/")); assert_eq!(current_version.version_number, 1); assert_eq!(current_version.size_bytes, file_bytes.len() as i64); assert!(current_version.assets.is_empty()); @@ -616,6 +635,234 @@ async fn bulk_update_tags_for_selection() -> Result<()> { Ok(()) } +#[tokio::test] +async fn bulk_assign_correspondents_to_selection() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "bulkcorresp"; + app.insert_user("corra", password, "admin").await?; + let token = app.login_token("corra", password).await?; + + let first = app + .upload_document( + "/api/documents", + "letter-one.txt", + "text/plain", + b"letter one", + None, + &token, + ) + .await?; + assert_eq!(first.status(), StatusCode::CREATED); + let first_body = body_to_vec(first.into_body()).await?; + let first_detail: DocumentDetail = serde_json::from_slice(&first_body)?; + + let second = app + .upload_document( + "/api/documents", + "letter-two.txt", + "text/plain", + b"letter two", + None, + &token, + ) + .await?; + assert_eq!(second.status(), StatusCode::CREATED); + let second_body = body_to_vec(second.into_body()).await?; + let second_detail: DocumentDetail = serde_json::from_slice(&second_body)?; + + let sender = app + .post_json( + "/api/correspondents", + &serde_json::json!({ "name": "Acme Corp" }), + Some(&token), + ) + .await?; + assert_eq!(sender.status(), StatusCode::OK); + let sender_body = body_to_vec(sender.into_body()).await?; + let sender_summary: CorrespondentSummary = serde_json::from_slice(&sender_body)?; + + let receiver = app + .post_json( + "/api/correspondents", + &serde_json::json!({ "name": "Bank Ltd" }), + Some(&token), + ) + .await?; + assert_eq!(receiver.status(), StatusCode::OK); + let receiver_body = body_to_vec(receiver.into_body()).await?; + let receiver_summary: CorrespondentSummary = serde_json::from_slice(&receiver_body)?; + + let assign_payload = serde_json::json!({ + "document_ids": [ + first_detail.document.id, + second_detail.document.id + ], + "assignments": [ + { + "correspondent_id": sender_summary.id, + "role": "sender" + }, + { + "correspondent_id": receiver_summary.id, + "role": "receiver" + } + ] + }); + + let assign_resp = app + .post_json( + "/api/documents/bulk/correspondents", + &assign_payload, + Some(&token), + ) + .await?; + assert_eq!(assign_resp.status(), StatusCode::OK); + let assign_body = body_to_vec(assign_resp.into_body()).await?; + let assign_result: BulkCorrespondentResult = serde_json::from_slice(&assign_body)?; + assert_eq!(assign_result.assigned, 4); + assert_eq!(assign_result.removed, 0); + + for doc_id in [first_detail.document.id, second_detail.document.id] { + let refreshed = app + .get(&format!("/api/documents/{doc_id}"), Some(&token)) + .await?; + assert_eq!(refreshed.status(), StatusCode::OK); + let refreshed_body = body_to_vec(refreshed.into_body()).await?; + let detail: DocumentDetail = serde_json::from_slice(&refreshed_body)?; + assert_eq!(detail.document.correspondents.len(), 2); + assert!(detail + .document + .correspondents + .iter() + .any(|entry| entry.role == "sender" && entry.name == "Acme Corp")); + assert!(detail + .document + .correspondents + .iter() + .any(|entry| entry.role == "receiver" && entry.name == "Bank Ltd")); + } + + let duplicate_resp = app + .post_json( + "/api/documents/bulk/correspondents", + &assign_payload, + Some(&token), + ) + .await?; + assert_eq!(duplicate_resp.status(), StatusCode::OK); + let duplicate_body = body_to_vec(duplicate_resp.into_body()).await?; + let duplicate_result: BulkCorrespondentResult = serde_json::from_slice(&duplicate_body)?; + assert_eq!(duplicate_result.assigned, 0); + assert_eq!(duplicate_result.removed, 0); + + let replacement = app + .post_json( + "/api/correspondents", + &serde_json::json!({ "name": "Charlie" }), + Some(&token), + ) + .await?; + assert_eq!(replacement.status(), StatusCode::OK); + let replacement_body = body_to_vec(replacement.into_body()).await?; + let replacement_summary: CorrespondentSummary = serde_json::from_slice(&replacement_body)?; + + let replace_payload = serde_json::json!({ + "document_ids": [ + first_detail.document.id, + second_detail.document.id + ], + "assignments": [ + { + "correspondent_id": replacement_summary.id, + "role": "sender" + } + ] + }); + + let replace_resp = app + .post_json( + "/api/documents/bulk/correspondents", + &replace_payload, + Some(&token), + ) + .await?; + assert_eq!(replace_resp.status(), StatusCode::OK); + let replace_body = body_to_vec(replace_resp.into_body()).await?; + let replace_result: BulkCorrespondentResult = serde_json::from_slice(&replace_body)?; + assert_eq!(replace_result.assigned, 2); + assert_eq!(replace_result.removed, 2); + + for doc_id in [first_detail.document.id, second_detail.document.id] { + let refreshed = app + .get(&format!("/api/documents/{doc_id}"), Some(&token)) + .await?; + let refreshed_body = body_to_vec(refreshed.into_body()).await?; + let detail: DocumentDetail = serde_json::from_slice(&refreshed_body)?; + assert_eq!(detail.document.correspondents.len(), 2); + assert!(detail + .document + .correspondents + .iter() + .any(|entry| entry.role == "sender" && entry.name == "Charlie")); + assert!(detail + .document + .correspondents + .iter() + .any(|entry| entry.role == "receiver" && entry.name == "Bank Ltd")); + } + + let remove_payload = serde_json::json!({ + "document_ids": [ + first_detail.document.id, + second_detail.document.id + ], + "assignments": [ + { + "correspondent_id": receiver_summary.id, + "role": "receiver" + } + ], + "action": "remove" + }); + + let remove_resp = app + .post_json( + "/api/documents/bulk/correspondents", + &remove_payload, + Some(&token), + ) + .await?; + assert_eq!(remove_resp.status(), StatusCode::OK); + let remove_body = body_to_vec(remove_resp.into_body()).await?; + let remove_result: BulkCorrespondentResult = serde_json::from_slice(&remove_body)?; + assert_eq!(remove_result.assigned, 0); + assert_eq!(remove_result.removed, 2); + + for doc_id in [first_detail.document.id, second_detail.document.id] { + let refreshed = app + .get(&format!("/api/documents/{doc_id}"), Some(&token)) + .await?; + let refreshed_body = body_to_vec(refreshed.into_body()).await?; + let detail: DocumentDetail = serde_json::from_slice(&refreshed_body)?; + assert_eq!(detail.document.correspondents.len(), 1); + assert!(detail + .document + .correspondents + .iter() + .any(|entry| entry.role == "sender" && entry.name == "Charlie")); + assert!(!detail + .document + .correspondents + .iter() + .any(|entry| entry.role == "receiver")); + } + + app.cleanup().await?; + Ok(()) +} + #[tokio::test] async fn bulk_reanalyze_selected_documents() -> Result<()> { let _lock = acquire_db_lock().await; diff --git a/docs/api.txt b/docs/api.txt index 8783660..3e59e81 100644 --- a/docs/api.txt +++ b/docs/api.txt @@ -21,6 +21,7 @@ Documents - POST /api/documents/reanalyze - Queue re-analysis for every non-deleted document. - POST /api/documents/bulk/move - Move multiple documents to a target folder. - POST /api/documents/bulk/tags - Add or remove tags across multiple documents. +- POST /api/documents/bulk/correspondents - Bulk correspondent actions. Default `action=add` replaces existing assignments for the provided roles before adding the supplied correspondents; `action=remove` drops the specified correspondent/role pairs. - POST /api/documents/bulk/reanalyze - Queue re-analysis jobs for selected documents. - GET /api/documents/:id - Retrieve metadata and current version details for a document. - PATCH /api/documents/:id - Update document metadata (currently title). diff --git a/frontend/src/detail/DetailPanel.jsx b/frontend/src/detail/DetailPanel.jsx index 8e033de..07d2260 100644 --- a/frontend/src/detail/DetailPanel.jsx +++ b/frontend/src/detail/DetailPanel.jsx @@ -7,6 +7,178 @@ import { CORRESPONDENT_ROLES } from '../constants/correspondents'; const MAX_PREVIEW_STACK_ITEMS = 15; +const normalizeRole = (role) => (role || '').toLowerCase(); + +const formatRoleLabel = (role) => { + const normalized = normalizeRole(role); + if (!normalized) return 'Other'; + return normalized.charAt(0).toUpperCase() + normalized.slice(1); +}; + +const compareCorrespondents = (a, b) => { + const indexA = CORRESPONDENT_ROLES.indexOf(a.role); + const indexB = CORRESPONDENT_ROLES.indexOf(b.role); + const rankedA = indexA === -1 ? Number.MAX_SAFE_INTEGER : indexA; + const rankedB = indexB === -1 ? Number.MAX_SAFE_INTEGER : indexB; + if (rankedA !== rankedB) { + return rankedA - rankedB; + } + return (a.name || '').localeCompare(b.name || ''); +}; + +const sortCorrespondents = (entries = []) => + entries + .map((entry) => ({ + id: entry.id, + name: entry.name || '', + role: normalizeRole(entry.role), + })) + .sort(compareCorrespondents); + +const CorrespondentPills = ({ entries = [], onRemove, showCount = false }) => ( +
+ {entries.length ? ( + entries.map((entry) => ( + + + {formatRoleLabel(entry.role)} + + {entry.name} + {showCount && entry.count ? ` (${entry.count})` : ''} + + + {onRemove ? ( + + ) : null} + + )) + ) : ( + No correspondents yet. + )} +
+); + +const TagSection = ({ + title, + tags = [], + onRemove, + onAdd, + emptyMessage = 'No tags yet.', + addPlaceholder = 'Add or create tag', + addButtonLabel = 'Add', + datalistId, + datalistOptions = [], + className, +}) => ( +
+
{title}
+
+ {tags.length ? ( + tags.map((tag) => { + const key = tag.id ?? tag.label; + const style = getTagColorStyle(tag.color); + return ( + + {tag.label}{' '} + {onRemove ? ( + + ) : null} + + ); + }) + ) : ( + {emptyMessage} + )} +
+ {onAdd ? ( +
{ + event.preventDefault(); + const input = event.currentTarget.elements.tag; + const value = input.value.trim(); + if (!value) return; + onAdd({ value, input }); + }} + > + + + {datalistId ? ( + + {datalistOptions.map((option) => ( + + ) : null} +
+ ) : null} +
+); + +const CorrespondentSection = ({ + title, + entries = [], + onRemove, + onAdd, + showCount = false, + addPlaceholder = 'Add or create correspondent', + addButtonLabel = 'Add', + datalistId, + datalistOptions = [], + className, +}) => ( +
+
{title}
+ + {onAdd ? ( +
{ + event.preventDefault(); + const form = event.currentTarget; + const nameInput = form.elements.correspondent; + const roleSelect = form.elements.role; + const value = nameInput.value.trim(); + const role = roleSelect.value; + if (!value) return; + onAdd({ name: value, role, input: nameInput }); + form.reset(); + }} + > + + + + {datalistId ? ( + + {datalistOptions.map((name) => ( + + ) : null} +
+ ) : null} +
+); + const computeStackAngle = (docId, index) => { if (index === 0) return 0; let hash = 0; @@ -95,7 +267,6 @@ const DetailPanel = ({ selectedDocuments = [], tags = [], tagLookupById = new Map(), - tagLookupByLabel = new Map(), onTagAdd, onTagRemove, onRegenerateThumbnails, @@ -103,10 +274,9 @@ const DetailPanel = ({ onOpenPreview, onBulkTagAdd, onBulkTagRemove, - onBulkMove, onBulkReanalyze, - folderOptions = [], - defaultMoveTarget = 'root', + onBulkCorrespondentAdd, + onBulkCorrespondentRemove, onPromoteSelection, activePreviewId = null, onUpdateTitle = async () => false, @@ -257,20 +427,25 @@ const DetailPanel = ({ }); }, [stackDocuments, ensureAssetUrl, getDocumentAsset]); - const commonTags = useMemo(() => { - if (selectedCount < 2) return []; - const tagSets = selectedDocuments.map((doc) => new Set((doc.tags || []).map((tag) => tag.label))); - if (!tagSets.length) return []; - const intersection = new Set(tagSets[0]); - tagSets.slice(1).forEach((set) => { - [...intersection].forEach((label) => { - if (!set.has(label)) { - intersection.delete(label); + const bulkTagUnion = useMemo(() => { + if (!selectedDocuments.length) return []; + const tagMap = new Map(); + selectedDocuments.forEach((doc) => { + (doc.tags || []).forEach((tag) => { + const label = (tag?.label || '').trim(); + if (!label) return; + if (!tagMap.has(label)) { + const fallback = tagLookupById.get(tag.id) || {}; + tagMap.set(label, { + id: tag.id, + label, + color: tag.color || fallback.color, + }); } }); }); - return [...intersection]; - }, [selectedDocuments, selectedCount]); + return [...tagMap.values()].sort((a, b) => a.label.localeCompare(b.label)); + }, [selectedDocuments, tagLookupById]); const stackTotalSizeBytes = useMemo(() => { if (!stackPreviews.length) return 0; @@ -302,6 +477,87 @@ const DetailPanel = ({ }); }, [availableCorrespondents]); + const singleCorrespondents = useMemo(() => { + if (!singleDoc) return []; + return sortCorrespondents(singleDoc.correspondents || []); + }, [singleDoc]); + + const bulkCorrespondents = useMemo(() => { + if (selectedDocuments.length <= 1) { + const doc = selectedDocuments[0]; + return doc ? sortCorrespondents(doc.correspondents || []) : []; + } + + const map = new Map(); + selectedDocuments.forEach((doc) => { + if (!doc?.id) return; + (doc.correspondents || []).forEach((entry) => { + if (!entry?.id) return; + const normalizedRole = normalizeRole(entry.role); + const key = `${entry.id}:${normalizedRole}`; + if (!map.has(key)) { + map.set(key, { + id: entry.id, + name: entry.name || '', + role: normalizedRole, + documentIds: new Set(), + }); + } + map.get(key).documentIds.add(doc.id); + }); + }); + + return [...map.values()] + .map((entry) => ({ + id: entry.id, + name: entry.name, + role: entry.role, + documentIds: [...entry.documentIds], + count: entry.documentIds.size, + })) + .sort(compareCorrespondents); + }, [selectedDocuments]); + + const handleBulkCorrespondentRemove = useCallback( + (entry) => { + if (!entry?.id) return; + const normalizedRole = normalizeRole(entry.role); + if (onBulkCorrespondentRemove) { + return onBulkCorrespondentRemove({ + assignments: [ + { + correspondent_id: entry.id, + role: normalizedRole, + }, + ], + documentIds: entry.documentIds, + }); + } + + if (!onCorrespondentRemove) return; + const targets = entry.documentIds && entry.documentIds.length + ? entry.documentIds + : selectedDocuments + .filter((doc) => + (doc.correspondents || []).some( + (item) => item.id === entry.id && normalizeRole(item.role) === normalizedRole, + ), + ) + .map((doc) => doc.id); + + return Promise.all( + targets.map((documentId) => + onCorrespondentRemove({ + documentId, + correspondentId: entry.id, + role: normalizedRole, + }), + ), + ).catch(() => {}); + }, + [bulkCorrespondents, onBulkCorrespondentRemove, onCorrespondentRemove, selectedDocuments], + ); + const renderSingle = () => { if (!singleDoc) { return

Select a document to view metadata, tags and actions.

; @@ -320,21 +576,6 @@ const DetailPanel = ({ const tagsForDoc = Array.isArray(singleDoc.tags) ? singleDoc.tags : []; const metadata = singleDoc.metadata && Object.keys(singleDoc.metadata).length > 0 ? singleDoc.metadata : null; - const docCorrespondents = Array.isArray(singleDoc.correspondents) - ? singleDoc.correspondents - : []; - const sortedCorrespondents = docCorrespondents.slice().sort((a, b) => { - const roleA = (a.role || '').toLowerCase(); - const roleB = (b.role || '').toLowerCase(); - const indexA = CORRESPONDENT_ROLES.indexOf(roleA); - const indexB = CORRESPONDENT_ROLES.indexOf(roleB); - if (indexA !== indexB) { - return (indexA === -1 ? Number.MAX_SAFE_INTEGER : indexA) - - (indexB === -1 ? Number.MAX_SAFE_INTEGER : indexB); - } - return (a.name || '').localeCompare(b.name || ''); - }); - return ( <>
@@ -448,119 +689,39 @@ const DetailPanel = ({ Re-run analysis
-
-
Tags
-
- {tagsForDoc.length ? ( - tagsForDoc.map((tag) => { - const colorSource = tag?.color || tagLookupById.get(tag.id)?.color; - const style = getTagColorStyle(colorSource); - return ( - - {tag.label}{' '} - - - ); - }) - ) : ( - No tags yet. - )} -
-
{ - event.preventDefault(); - const input = event.currentTarget.elements.tag; - const value = input.value.trim(); - if (!value) return; - onTagAdd(singleDoc, value, input); - }} - > - - - - {tags.map((tag) => ( - -
-
-
-
Correspondents
-
- {sortedCorrespondents.length ? ( - sortedCorrespondents.map((entry) => { - const roleLabel = entry.role - ? entry.role.charAt(0).toUpperCase() + entry.role.slice(1) - : 'Other'; - return ( - - - {roleLabel} - {entry.name} - - - - ); - }) - ) : ( - No correspondents yet. - )} -
-
{ - event.preventDefault(); - const form = event.currentTarget; - const nameInput = form.elements.correspondent; - const roleSelect = form.elements.role; - const value = nameInput.value.trim(); - const role = roleSelect.value; - if (!value) return; - onCorrespondentAdd?.({ - document: singleDoc, - name: value, - role, - input: nameInput, - }); - form.reset(); - }} - > - - - - - {correspondentOptions.map((name) => ( - -
-
+ ({ + id: tag.id, + label: tag.label, + color: tag.color || tagLookupById.get(tag.id)?.color, + }))} + onRemove={(tag) => onTagRemove(singleDoc.id, tag.id)} + onAdd={({ value, input }) => onTagAdd(singleDoc, value, input)} + datalistId="tag-catalog-single" + datalistOptions={tags} + /> + + onCorrespondentRemove?.({ + documentId: singleDoc.id, + correspondentId: entry.id, + role: entry.role, + }) + } + onAdd={({ name, role, input }) => + onCorrespondentAdd?.({ + document: singleDoc, + name, + role, + input, + }) + } + datalistId="correspondent-catalog-single" + datalistOptions={correspondentOptions} + /> {metadata && (
Metadata
@@ -593,64 +754,32 @@ const DetailPanel = ({
Total size (stack): {sizeLabel}
-
- Common tags:{' '} - {commonTags.length ? commonTags.join(', ') : 'None'} -
-
-
- Bulk tag operations -
{ - event.preventDefault(); - const input = event.currentTarget.elements.tag; - const value = input.value.trim(); - if (!value) return; - onBulkTagAdd?.({ label: value, input }); - }} - > - - -
-
{ - event.preventDefault(); - const input = event.currentTarget.elements.tag; - const value = input.value.trim(); - if (!value) return; - onBulkTagRemove?.({ label: value, input }); - }} - > - - -
-
- - {tags.map((tag) => ( - -
- -
+ onBulkTagRemove?.({ label: tag.label })} + onAdd={({ value, input }) => onBulkTagAdd?.({ label: value, input })} + addPlaceholder="Add tag to selection" + addButtonLabel="Add tag" + datalistId="tag-catalog-bulk" + datalistOptions={tags} + className="bulk-tags" + /> + + onBulkCorrespondentAdd?.({ name, role, input }) + } + addPlaceholder="Add correspondent to selection" + datalistId="correspondent-catalog-bulk" + datalistOptions={correspondentOptions} + showCount + className="bulk-correspondents" + /> - ); - }) - ) : ( - No tags yet - )} - -
- {hasFilters && ( - - )} -
- -); - const DocumentThumbnailImage = ({ document, ensureAssetUrl, getDocumentAsset, alt }) => { const url = useMemo( () => @@ -103,6 +49,8 @@ const DocumentsTable = ({ onFolderDragEnd, draggedFolderId, onFolderDelete, + selectedFolderIds = [], + onFolderRowClick, onDocumentRowClick, onDocumentOpen, selectedDocumentIds, @@ -114,7 +62,6 @@ const DocumentsTable = ({ onDocumentDelete, onFolderRename, onDocumentRename, - filterBar, tagLookupById, onDocumentListFocus, onDocumentListKeyDown, @@ -131,6 +78,10 @@ const DocumentsTable = ({ () => new Set(selectedDocumentIds), [selectedDocumentIds], ); + const selectedFolderSet = useMemo( + () => new Set(selectedFolderIds || []), + [selectedFolderIds], + ); const draggingSet = useMemo( () => new Set(draggingDocumentIds || []), [draggingDocumentIds], @@ -235,7 +186,6 @@ const DocumentsTable = ({
-
{filterBar}
{ const canDragFolder = folder.id !== 'root'; const isDraggingFolder = draggedFolderId === folder.id; + const isSelectedFolder = selectedFolderSet.has(folder.id); return ( { - onFolderSelect(folder.id); + onClick={(event) => { + onFolderRowClick?.(folder.id, event); + const shouldNavigate = + !event.defaultPrevented && + !event.metaKey && + !event.ctrlKey && + !event.shiftKey; + if (shouldNavigate) { + onFolderSelect(folder.id); + } if (scrollRef.current) { scrollRef.current.focus({ preventScroll: true }); } onFocusedRowChange?.(`folder:${folder.id}`); }} + onDoubleClick={(event) => { + event.preventDefault(); + onFolderSelect(folder.id); + }} onDragOver={(event) => onFolderDragOver(event, folder.id)} onDragLeave={onFolderDragLeave} onDrop={(event) => onFolderDrop(event, folder.id)} @@ -413,36 +376,36 @@ const DocumentsTable = ({ {(doc.tags || []).length > 0 && (
{(doc.tags || []).map((tag) => { - const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color; - const style = getTagColorStyle(colorSource); - return ( - { - event.stopPropagation(); - if (onTagClick) { - onTagClick(tag.id); - } - }} - role="button" - tabIndex={0} - onKeyDown={(event) => { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); + const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color; + const style = getTagColorStyle(colorSource); + return ( + { event.stopPropagation(); if (onTagClick) { onTagClick(tag.id); } - } - }} - > - {tag.label} - - ); - })} + }} + role="button" + tabIndex={0} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + event.stopPropagation(); + if (onTagClick) { + onTagClick(tag.id); + } + } + }} + > + {tag.label} + + ); + })}
)}
@@ -504,4 +467,4 @@ const DocumentsTable = ({ }; export default DocumentsTable; -export { FilterBar, DocumentThumbnailImage }; +export { DocumentThumbnailImage }; diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx index 18ea814..4782b74 100644 --- a/frontend/src/index.jsx +++ b/frontend/src/index.jsx @@ -31,7 +31,7 @@ import { DownloadIcon } from './ui/icons'; import TagManager from './tag_manager'; import { formatFileSize } from './utils/format'; import Sidebar from './sidebar/Sidebar'; -import DocumentsTable, { FilterBar } from './documents/DocumentsTable'; +import DocumentsTable from './documents/DocumentsTable'; const runtimeApiBase = typeof window !== 'undefined' && window.__PAPERCRATE_API_BASE_URL @@ -140,8 +140,33 @@ const useAppDispatch = () => { const DEFAULT_FOLDER_NAME = 'All Documents'; +const ROW_KEY_SEPARATOR = ':'; +const DOCUMENT_ROW_PREFIX = 'document'; +const FOLDER_ROW_PREFIX = 'folder'; + const resolveApiPath = (path = '') => (API_ROOT ? `${API_ROOT}${path}` : path); +const makeRowKey = (type, id) => + id ? `${type}${ROW_KEY_SEPARATOR}${id}` : `${type}${ROW_KEY_SEPARATOR}`; + +const getRowType = (key) => (typeof key === 'string' ? key.split(ROW_KEY_SEPARATOR, 1)[0] : ''); + +const getRowId = (key) => { + if (typeof key !== 'string') return ''; + const separatorIndex = key.indexOf(ROW_KEY_SEPARATOR); + if (separatorIndex === -1) return key; + return key.slice(separatorIndex + 1); +}; + +const isDocumentRowKey = (key) => getRowType(key) === DOCUMENT_ROW_PREFIX; +const isFolderRowKey = (key) => getRowType(key) === FOLDER_ROW_PREFIX; + +const resolveDocumentRowKey = (documentId) => + documentId ? makeRowKey(DOCUMENT_ROW_PREFIX, documentId) : null; + +const resolveFolderRowKey = (folderId) => + folderId ? makeRowKey(FOLDER_ROW_PREFIX, folderId) : null; + const hasFiles = (event) => Array.from(event.dataTransfer?.types || []).includes('Files'); @@ -337,12 +362,14 @@ const AppLayout = () => { const [currentSubfolders, setCurrentSubfolders] = useState([]); const [documents, setDocuments] = useState([]); const [workspaceMode, setWorkspaceMode] = useState('table'); - const initialSelection = routeDocumentId ? [routeDocumentId] : []; - const [selectedDocumentIds, setSelectedDocumentIds] = useState(initialSelection); - const [selectionOrder, setSelectionOrder] = useState(initialSelection); + const initialRowSelection = routeDocumentId + ? [resolveDocumentRowKey(routeDocumentId)] + : []; + const [selectedRowKeys, setSelectedRowKeys] = useState(initialRowSelection); + const [selectionOrder, setSelectionOrder] = useState(initialRowSelection); const [focusedDocumentId, setFocusedDocumentId] = useState(routeDocumentId); const [focusedRowKey, setFocusedRowKey] = useState(() => - routeDocumentId ? `document:${routeDocumentId}` : null, + routeDocumentId ? resolveDocumentRowKey(routeDocumentId) : null, ); const tokenRef = useRef(token); const refreshPromiseRef = useRef(null); @@ -369,6 +396,30 @@ const AppLayout = () => { const [correspondents, setCorrespondents] = useState([]); const [searchQuery, setSearchQuery] = useState(''); const [activeTagFilters, setActiveTagFilters] = useState([]); + const [activeCorrespondentFilters, setActiveCorrespondentFilters] = useState([]); + const toggleTagFilter = useCallback((tagId) => { + if (!tagId) return; + setActiveTagFilters((previous) => + previous.includes(tagId) + ? previous.filter((id) => id !== tagId) + : previous.concat([tagId]), + ); + }, []); + + const toggleCorrespondentFilter = useCallback((correspondentId) => { + setActiveCorrespondentFilters((previous) => { + if (!correspondentId) { + return []; + } + return previous.includes(correspondentId) ? [] : [correspondentId]; + }); + }, []); + + const clearFilters = useCallback(() => { + setSearchQuery(''); + setActiveTagFilters([]); + setActiveCorrespondentFilters([]); + }, []); const [draggedDocumentIds, setDraggedDocumentIds] = useState([]); const [draggedFolderId, setDraggedFolderId] = useState(null); const [dropOverlayState, setDropOverlayState] = useState({ @@ -465,8 +516,26 @@ const AppLayout = () => { const selectionInitializedRef = useRef(false); const dragCounterRef = useRef(0); const prefetchedFoldersRef = useRef(new Set(['root'])); - const selectionAnchorRef = useRef(routeDocumentId); - const selectionOrderRef = useRef(initialSelection); + const selectionAnchorRef = useRef(initialRowSelection[initialRowSelection.length - 1] || null); + const selectionOrderRef = useRef(initialRowSelection); + + const selectedDocumentIds = useMemo( + () => + selectedRowKeys + .filter(isDocumentRowKey) + .map((key) => getRowId(key)) + .filter(Boolean), + [selectedRowKeys], + ); + + const selectedFolderIds = useMemo( + () => + selectedRowKeys + .filter(isFolderRowKey) + .map((key) => getRowId(key)) + .filter(Boolean), + [selectedRowKeys], + ); const resetWorkspaceState = useCallback(() => { const rootNode = createRootNode(); @@ -476,7 +545,7 @@ const AppLayout = () => { setCurrentFolder(null); setCurrentSubfolders([]); setDocuments([]); - setSelectedDocumentIds([]); + setSelectedRowKeys([]); setSelectionOrder([]); selectionOrderRef.current = []; setFocusedDocumentId(null); @@ -512,16 +581,6 @@ const AppLayout = () => { return map; }, [tags]); - const tagLookupByLabel = useMemo(() => { - const map = new Map(); - tags.forEach((tag) => { - if (tag?.label) { - map.set(tag.label.toLowerCase(), tag); - } - }); - return map; - }, [tags]); - const correspondentLookupByName = useMemo(() => { const map = new Map(); correspondents.forEach((correspondent) => { @@ -532,10 +591,10 @@ const AppLayout = () => { return map; }, [correspondents]); - const updateSelectionOrder = useCallback((nextSelection, interactedIds = []) => { + const updateSelectionOrder = useCallback((nextSelection, interactedKeys = []) => { const nextSet = new Set(nextSelection); const previousOrder = selectionOrderRef.current.filter((id) => nextSet.has(id)); - const interacted = (interactedIds || []).filter((id, index, array) => array.indexOf(id) === index); + const interacted = (interactedKeys || []).filter((id, index, array) => array.indexOf(id) === index); const base = previousOrder.filter((id) => !interacted.includes(id)); const result = [...base]; @@ -663,8 +722,11 @@ const AppLayout = () => { }, [selectedFolder, currentFolder]); const isFilterActive = useMemo( - () => searchQuery.trim().length > 0 || activeTagFilters.length > 0, - [searchQuery, activeTagFilters], + () => + searchQuery.trim().length > 0 || + activeTagFilters.length > 0 || + activeCorrespondentFilters.length > 0, + [searchQuery, activeTagFilters, activeCorrespondentFilters], ); const applySelectedFolder = useCallback( @@ -677,40 +739,53 @@ const AppLayout = () => { setDocuments(docs); setCurrentFolder(folderInfo); - const availableIds = new Set(docs.map((doc) => doc.id)); - let nextSelection = []; + const availableDocKeys = docs + .map((doc) => resolveDocumentRowKey(doc.id)) + .filter(Boolean); + const availableDocKeySet = new Set(availableDocKeys); + + let nextDocKeys = []; + let mergedSelection = []; + + setSelectedRowKeys((previous) => { + const previousFolderKeys = previous.filter(isFolderRowKey); + const previousDocKeys = previous.filter(isDocumentRowKey); - setSelectedDocumentIds((previous) => { if (selectionInitializedRef.current) { - nextSelection = previous.filter((id) => availableIds.has(id)); - return nextSelection; + nextDocKeys = previousDocKeys.filter((key) => availableDocKeySet.has(key)); + } else { + const filtered = previousDocKeys.filter((key) => availableDocKeySet.has(key)); + if (filtered.length) { + nextDocKeys = filtered; + } else if (availableDocKeys.length) { + nextDocKeys = [availableDocKeys[0]]; + } else { + nextDocKeys = []; + } } - const filtered = previous.filter((id) => availableIds.has(id)); - if (filtered.length) { - nextSelection = filtered; - return filtered; - } - if (docs.length) { - nextSelection = [docs[0].id]; - return nextSelection; - } - nextSelection = []; - return []; + + mergedSelection = [...previousFolderKeys, ...nextDocKeys]; + return mergedSelection; }); - let nextFocus = focusedDocumentId && availableIds.has(focusedDocumentId) - ? focusedDocumentId - : null; - if (!nextFocus && nextSelection.length) { - nextFocus = nextSelection[nextSelection.length - 1]; - } + const nextFocus = (() => { + const currentFocusedKey = resolveDocumentRowKey(focusedDocumentId); + if (currentFocusedKey && availableDocKeySet.has(currentFocusedKey)) { + return focusedDocumentId; + } + if (nextDocKeys.length) { + const lastDocKey = nextDocKeys[nextDocKeys.length - 1]; + return getRowId(lastDocKey) || null; + } + return null; + })(); setFocusedDocumentId(nextFocus); - selectionAnchorRef.current = nextSelection.length - ? nextSelection[nextSelection.length - 1] + selectionAnchorRef.current = nextDocKeys.length + ? nextDocKeys[nextDocKeys.length - 1] : null; - selectionOrderRef.current = nextSelection; - setSelectionOrder(nextSelection); + selectionOrderRef.current = mergedSelection; + setSelectionOrder(mergedSelection); return nextFocus; }, @@ -729,6 +804,31 @@ const AppLayout = () => { [visibleDocuments], ); + const visibleDocumentKeys = useMemo( + () => visibleDocumentIds.map((id) => resolveDocumentRowKey(id)).filter(Boolean), + [visibleDocumentIds], + ); + + const visibleFolderKeys = useMemo( + () => + showingSearchResults + ? [] + : currentSubfolders + .map((folder) => resolveFolderRowKey(folder.id)) + .filter(Boolean), + [showingSearchResults, currentSubfolders], + ); + + const visibleRowKeys = useMemo( + () => [...visibleFolderKeys, ...visibleDocumentKeys], + [visibleFolderKeys, visibleDocumentKeys], + ); + + const visibleRowKeySet = useMemo( + () => new Set(visibleRowKeys), + [visibleRowKeys], + ); + const documentLookup = useMemo(() => { const map = new Map(); const push = (items) => { @@ -747,12 +847,25 @@ const AppLayout = () => { }, [documents, searchResults]); const applySelection = useCallback( - (ids, { anchor, interactedIds = [] } = {}) => { - const visibleSet = new Set(visibleDocumentIds); + (rowKeys, { anchor, interactedKeys = [] } = {}) => { const unique = []; - ids.forEach((id) => { - if (visibleSet.has(id) && !unique.includes(id)) { - unique.push(id); + + (rowKeys || []).forEach((key) => { + if (!key) return; + const canonicalKey = visibleRowKeySet.has(key) + ? key + : isDocumentRowKey(key) + ? resolveDocumentRowKey(getRowId(key)) + : isFolderRowKey(key) + ? resolveFolderRowKey(getRowId(key)) + : null; + + if (!canonicalKey || !visibleRowKeySet.has(canonicalKey)) { + return; + } + + if (!unique.includes(canonicalKey)) { + unique.push(canonicalKey); } }); @@ -761,41 +874,52 @@ const AppLayout = () => { resolvedAnchor = null; } - const nextFocus = - (focusedDocumentId && unique.includes(focusedDocumentId) && focusedDocumentId) || - resolvedAnchor || - (unique.length ? unique[unique.length - 1] : null); + setSelectedRowKeys(unique); + updateSelectionOrder(unique, interactedKeys); - setSelectedDocumentIds(unique); - setFocusedDocumentId(nextFocus); - updateSelectionOrder(unique, interactedIds); + const nextFocusedDocumentId = (() => { + if (focusedDocumentId) { + const focusKey = resolveDocumentRowKey(focusedDocumentId); + if (focusKey && unique.includes(focusKey)) { + return focusedDocumentId; + } + } + + if (resolvedAnchor && isDocumentRowKey(resolvedAnchor)) { + return getRowId(resolvedAnchor) || null; + } + + const lastDocKey = [...unique].reverse().find(isDocumentRowKey); + return lastDocKey ? getRowId(lastDocKey) || null : null; + })(); + + setFocusedDocumentId(nextFocusedDocumentId); if (resolvedAnchor) { selectionAnchorRef.current = resolvedAnchor; - } else if (unique.length === 0) { + } else if (!unique.length) { selectionAnchorRef.current = null; - } else if ( - !selectionAnchorRef.current || - !unique.includes(selectionAnchorRef.current) - ) { + } else if (!selectionAnchorRef.current || !unique.includes(selectionAnchorRef.current)) { selectionAnchorRef.current = unique[unique.length - 1]; } - return { selection: unique, focus: nextFocus }; + return { selection: unique, focusKey: selectionAnchorRef.current }; }, - [visibleDocumentIds, focusedDocumentId, updateSelectionOrder], + [visibleRowKeySet, focusedDocumentId, updateSelectionOrder], ); const promoteSelectionOrder = useCallback( (docId) => { if (!docId) return; - if (!selectedDocumentIds.includes(docId)) return; - updateSelectionOrder(selectedDocumentIds, [docId]); - selectionAnchorRef.current = docId; + const rowKey = resolveDocumentRowKey(docId); + if (!rowKey) return; + if (!selectedRowKeys.includes(rowKey)) return; + updateSelectionOrder(selectedRowKeys, [rowKey]); + selectionAnchorRef.current = rowKey; setFocusedDocumentId(docId); setActivePreviewId(docId); }, - [selectedDocumentIds, updateSelectionOrder], + [selectedRowKeys, updateSelectionOrder], ); const visibleSelectedCount = useMemo( @@ -854,83 +978,21 @@ const AppLayout = () => { return map; }, [folderOptions]); - const handleDocumentRowClick = useCallback( - (documentId, event) => { - const { shiftKey, metaKey, ctrlKey } = event; - const additive = metaKey || ctrlKey; - if (shiftKey) { - event.preventDefault(); - } - if (!visibleDocumentIds.includes(documentId)) { - return; - } - - let anchor = selectionAnchorRef.current; - if (!anchor && shiftKey && selectedDocumentIds.length) { - anchor = selectedDocumentIds[selectedDocumentIds.length - 1]; - } - if (!anchor) { - anchor = documentId; - } - let nextIds; - let interactedIds = []; - - if (shiftKey && anchor) { - const anchorIndex = visibleDocumentIds.indexOf(anchor); - const targetIndex = visibleDocumentIds.indexOf(documentId); - if (anchorIndex !== -1 && targetIndex !== -1) { - const [start, end] = - anchorIndex <= targetIndex - ? [anchorIndex, targetIndex] - : [targetIndex, anchorIndex]; - const range = visibleDocumentIds.slice(start, end + 1); - nextIds = range; - - const ordered = anchorIndex <= targetIndex ? range : [...range].reverse(); - const previousSet = new Set(selectedDocumentIds); - interactedIds = ordered.filter( - (id) => id === documentId || !previousSet.has(id), - ); - if (!interactedIds.includes(documentId)) { - interactedIds.push(documentId); - } - } else { - nextIds = [documentId]; - interactedIds = [documentId]; - } - } else if (additive) { - if (selectedDocumentIds.includes(documentId)) { - nextIds = selectedDocumentIds.filter((id) => id !== documentId); - interactedIds = []; - } else { - nextIds = [...selectedDocumentIds, documentId]; - interactedIds = [documentId]; - } - anchor = documentId; - } else { - nextIds = [documentId]; - interactedIds = [documentId]; - anchor = documentId; - } - - applySelection(nextIds, { anchor, interactedIds }); - }, - [ - visibleDocumentIds, - selectedDocumentIds, - applySelection, - ], - ); - const navigableRows = useMemo(() => { const entries = []; if (!showingSearchResults) { currentSubfolders.forEach((folder) => { - entries.push({ key: `folder:${folder.id}`, type: 'folder', id: folder.id }); + const key = resolveFolderRowKey(folder.id); + if (key) { + entries.push({ key, type: 'folder', id: folder.id }); + } }); } visibleDocuments.forEach((doc) => { - entries.push({ key: `document:${doc.id}`, type: 'document', id: doc.id }); + const key = resolveDocumentRowKey(doc.id); + if (key) { + entries.push({ key, type: 'document', id: doc.id }); + } }); return entries; }, [showingSearchResults, currentSubfolders, visibleDocuments]); @@ -940,6 +1002,94 @@ const AppLayout = () => { [navigableRows], ); + const handleRowSelection = useCallback( + (rowKey, event) => { + if (!rowKey || !visibleRowKeySet.has(rowKey)) { + return; + } + + setFocusedRowKey(rowKey); + + const shiftKey = Boolean(event?.shiftKey); + const metaKey = Boolean(event?.metaKey); + const ctrlKey = Boolean(event?.ctrlKey); + const additive = metaKey || ctrlKey; + + if (shiftKey) { + event?.preventDefault?.(); + } + + let anchorKey = selectionAnchorRef.current; + if (!anchorKey && shiftKey && selectedRowKeys.length) { + anchorKey = selectedRowKeys[selectedRowKeys.length - 1]; + } + if (!anchorKey) { + anchorKey = rowKey; + } + + let nextKeys = []; + let interactedKeys = []; + + if (shiftKey && anchorKey) { + const anchorIndex = navigableRowKeys.indexOf(anchorKey); + const targetIndex = navigableRowKeys.indexOf(rowKey); + if (anchorIndex !== -1 && targetIndex !== -1) { + const [start, end] = + anchorIndex <= targetIndex + ? [anchorIndex, targetIndex] + : [targetIndex, anchorIndex]; + const range = navigableRowKeys.slice(start, end + 1); + nextKeys = range; + + const previousSet = new Set(selectedRowKeys); + interactedKeys = range.filter( + (key) => key === rowKey || !previousSet.has(key), + ); + if (!interactedKeys.includes(rowKey)) { + interactedKeys.push(rowKey); + } + } else { + nextKeys = [rowKey]; + interactedKeys = [rowKey]; + } + } else if (additive) { + if (selectedRowKeys.includes(rowKey)) { + nextKeys = selectedRowKeys.filter((key) => key !== rowKey); + interactedKeys = []; + } else { + nextKeys = [...selectedRowKeys, rowKey]; + interactedKeys = [rowKey]; + } + anchorKey = rowKey; + } else { + nextKeys = [rowKey]; + interactedKeys = [rowKey]; + anchorKey = rowKey; + } + + applySelection(nextKeys, { anchor: anchorKey, interactedKeys }); + }, + [applySelection, navigableRowKeys, selectedRowKeys, setFocusedRowKey, visibleRowKeySet], + ); + + const handleDocumentRowClick = useCallback( + (documentId, event) => { + const rowKey = resolveDocumentRowKey(documentId); + if (!rowKey) return; + handleRowSelection(rowKey, event); + }, + [handleRowSelection], + ); + + const handleFolderRowClick = useCallback( + (folderId, event) => { + const rowKey = resolveFolderRowKey(folderId); + if (!rowKey) return; + handleRowSelection(rowKey, event); + }, + [handleRowSelection], + ); + const prevFocusedDocIdRef = useRef(focusedDocumentId); useEffect(() => { const previous = prevFocusedDocIdRef.current; @@ -948,9 +1098,9 @@ const AppLayout = () => { } prevFocusedDocIdRef.current = focusedDocumentId; if (focusedDocumentId) { - setFocusedRowKey(`document:${focusedDocumentId}`); + setFocusedRowKey(resolveDocumentRowKey(focusedDocumentId)); } else { - setFocusedRowKey((current) => (current?.startsWith('folder:') ? current : null)); + setFocusedRowKey((current) => (isFolderRowKey(current) ? current : null)); } }, [focusedDocumentId]); @@ -961,7 +1111,7 @@ const AppLayout = () => { if (navigableRowKeys.includes(focusedRowKey)) { return; } - const docKey = focusedDocumentId ? `document:${focusedDocumentId}` : null; + const docKey = focusedDocumentId ? resolveDocumentRowKey(focusedDocumentId) : null; if (docKey && navigableRowKeys.includes(docKey)) { setFocusedRowKey(docKey); return; @@ -1176,28 +1326,6 @@ const AppLayout = () => { [api, folderNodes, ensureFolderData, selectedFolder, setSelectedFolder, setFolderNodes, notifyApiError], ); - const handleFolderDragStart = useCallback( - (event, folderId) => { - if (folderId === 'root') { - return; - } - event.stopPropagation(); - setDraggedFolderId(folderId); - event.dataTransfer.effectAllowed = 'move'; - try { - event.dataTransfer.setData('application/x-papercrate-folder', folderId); - } catch ( - // eslint-disable-next-line no-empty - error - ) {} - }, - [setDraggedFolderId], - ); - - const handleFolderDragEnd = useCallback(() => { - setDraggedFolderId(null); - }, [setDraggedFolderId]); - const refreshTags = useCallback(async () => { try { const { data } = await api.get('/tags'); @@ -1384,7 +1512,10 @@ const AppLayout = () => { ], ); - async function handleDocumentCorrespondentAttach({ documentId, correspondentId, role }) { + async function handleDocumentCorrespondentAttach( + { documentId, correspondentId, role }, + { notify = true, refresh = true } = {}, + ) { if (!documentId || !correspondentId || !role) { throw new Error('Missing document, correspondent or role.'); } @@ -1393,8 +1524,13 @@ const AppLayout = () => { assignments: [{ correspondent_id: correspondentId, role }], replace: false, }); - await refreshCurrentFolder(); - setStatusMessage('Correspondent assigned.', 'success'); + if (refresh) { + await refreshCurrentFolder(); + } + if (notify) { + setStatusMessage('Correspondent assigned.', 'success'); + } + return true; } catch (error) { const message = error.response?.data?.error || 'Failed to assign correspondent.'; notifyApiError(error, message); @@ -1402,7 +1538,10 @@ const AppLayout = () => { } } - async function handleCorrespondentRemove({ documentId, correspondentId, role }) { + async function handleCorrespondentRemove( + { documentId, correspondentId, role }, + { notify = true, refresh = true } = {}, + ) { if (!documentId || !correspondentId || !role) { throw new Error('Missing document, correspondent or role.'); } @@ -1410,8 +1549,13 @@ const AppLayout = () => { await api.delete(`/documents/${documentId}/correspondents/${correspondentId}`, { params: { role }, }); - await refreshCurrentFolder(); - setStatusMessage('Correspondent removed.', 'success'); + if (refresh) { + await refreshCurrentFolder(); + } + if (notify) { + setStatusMessage('Correspondent removed.', 'success'); + } + return true; } catch (error) { const message = error.response?.data?.error || 'Failed to remove correspondent.'; notifyApiError(error, message); @@ -1657,6 +1801,125 @@ const AppLayout = () => { } }, [selectedFolder, ensureFolderData, applySelectedFolder, notifyApiError]); + const handleBulkCorrespondentAdd = useCallback( + async ({ name, role, input }) => { + const trimmed = (name || '').trim(); + if (!trimmed) { + setStatusMessage('Correspondent name is required.', 'error'); + return; + } + if (!selectedDocumentIds.length) { + setStatusMessage('Select documents before assigning correspondents.', 'error'); + return; + } + const normalizedRole = (role || '').trim().toLowerCase(); + if (!CORRESPONDENT_ROLES.includes(normalizedRole)) { + setStatusMessage('Select a valid correspondent role.', 'error'); + return; + } + + let target = correspondentLookupByName.get(trimmed.toLowerCase()) || null; + if (!target) { + try { + target = await handleCorrespondentCreate({ name: trimmed }); + } catch (error) { + return; + } + } + + if (!target?.id) { + setStatusMessage('Unable to resolve correspondent.', 'error'); + return; + } + + const response = await api.post('/documents/bulk/correspondents', { + document_ids: selectedDocumentIds, + assignments: [ + { + correspondent_id: target.id, + role: normalizedRole, + }, + ], + action: 'add', + }); + + const { assigned = 0, removed = 0 } = response.data || {}; + + await refreshCurrentFolder(); + const assignedSuffix = assigned === 1 ? '' : 's'; + if (removed > 0) { + const removedSuffix = removed === 1 ? '' : 's'; + setStatusMessage( + `Correspondent assigned (${assigned}) and replaced ${removed} link${removedSuffix}.`, + 'success', + ); + } else { + setStatusMessage( + `Correspondent assigned to ${assigned} document${assignedSuffix}.`, + 'success', + ); + } + + if (input) { + input.value = ''; + } + }, + [ + correspondentLookupByName, + handleCorrespondentCreate, + api, + refreshCurrentFolder, + selectedDocumentIds, + setStatusMessage, + ], + ); + + const handleBulkCorrespondentRemove = useCallback( + async ({ assignments = [], documentIds }) => { + if (!assignments.length) { + setStatusMessage('Select a correspondent to remove.', 'error'); + return; + } + + const targets = Array.isArray(documentIds) && documentIds.length + ? documentIds + : selectedDocumentIds; + + if (!targets.length) { + setStatusMessage('Select documents before removing correspondents.', 'error'); + return; + } + + const normalizedAssignments = assignments.map((entry) => ({ + correspondent_id: entry.correspondent_id, + role: (entry.role || '').toLowerCase(), + })); + + const response = await api.post('/documents/bulk/correspondents', { + document_ids: targets, + assignments: normalizedAssignments, + action: 'remove', + }); + + const { assigned = 0, removed = 0 } = response.data || {}; + await refreshCurrentFolder(); + + if (removed > 0) { + const removedSuffix = removed === 1 ? '' : 's'; + setStatusMessage( + `Correspondent removed from ${removed} link${removedSuffix}.`, + 'success', + ); + } else if (assigned > 0) { + const assignedSuffix = assigned === 1 ? '' : 's'; + setStatusMessage(`Correspondent updated ${assigned} link${assignedSuffix}.`, 'info'); + } else { + setStatusMessage('No correspondents changed.', 'info'); + } + }, + [api, refreshCurrentFolder, selectedDocumentIds, setStatusMessage], + ); + useEffect(() => { if (appStatus !== 'authenticated') { return; @@ -2110,56 +2373,96 @@ const AppLayout = () => { useEffect(() => destroyDragPreview, [destroyDragPreview]); const createDragPreview = useCallback( - (documentsForPreview) => { + ({ documents = [], folders = [] } = {}) => { destroyDragPreview(); - const docs = (documentsForPreview || []).filter(Boolean); - if (!docs.length) { + + const docEntries = (documents || []).filter(Boolean); + const folderEntries = (folders || []).filter(Boolean); + const totalCount = docEntries.length + folderEntries.length; + if (!totalCount) { return null; } const maxVisible = 4; const size = 64; const canvasSize = Math.round(size * 1.6); - const visibleDocs = docs.slice(0, maxVisible); + + const visibleItems = []; + docEntries.slice(0, maxVisible).forEach((doc) => { + visibleItems.push({ type: 'document', payload: doc }); + }); + + if (visibleItems.length < maxVisible) { + folderEntries + .slice(0, maxVisible - visibleItems.length) + .forEach((folderId) => visibleItems.push({ type: 'folder', payload: folderId })); + } const wrapper = document.createElement('div'); wrapper.className = 'document-drag-preview'; wrapper.style.setProperty('--drag-preview-size', `${canvasSize}px`); - visibleDocs.forEach((doc, index) => { + visibleItems.forEach((item, index) => { const layer = document.createElement('div'); layer.className = 'document-drag-preview__thumb'; const rotationMagnitude = Math.random() * 8 + 2; // 2..10 degrees const rotation = (index % 2 === 0 ? 1 : -1) * rotationMagnitude; layer.style.setProperty('--rotation-deg', `${rotation}deg`); - const rowEl = doc?.id ? document.getElementById(`document-row-${doc.id}`) : null; - const thumbnailEl = rowEl?.querySelector('.document-thumbnail'); - const placeholderEl = rowEl?.querySelector('.thumb-placeholder'); + if (item.type === 'document') { + const doc = item.payload; + const rowEl = doc?.id ? document.getElementById(`document-row-${doc.id}`) : null; + const thumbnailEl = rowEl?.querySelector('.document-thumbnail'); + const placeholderEl = rowEl?.querySelector('.thumb-placeholder'); - let content = null; - if (thumbnailEl instanceof HTMLImageElement) { - content = thumbnailEl.cloneNode(true); - content.draggable = false; - content.style.pointerEvents = 'none'; - } else if (placeholderEl instanceof HTMLElement) { - content = placeholderEl.cloneNode(true); - content.style.pointerEvents = 'none'; - } + let content = null; + if (thumbnailEl instanceof HTMLImageElement) { + content = thumbnailEl.cloneNode(true); + content.draggable = false; + content.style.pointerEvents = 'none'; + } else if (placeholderEl instanceof HTMLElement) { + content = placeholderEl.cloneNode(true); + content.style.pointerEvents = 'none'; + } - if (content) { - layer.appendChild(content); + if (content) { + layer.appendChild(content); + } else { + layer.textContent = 'DOC'; + } } else { - layer.textContent = 'DOC'; + const folderId = item.payload; + const rowEl = folderId ? document.getElementById(`folder-row-${folderId}`) : null; + const iconEl = rowEl?.querySelector('.thumb-icon'); + + let content = null; + if (iconEl instanceof HTMLElement) { + content = iconEl.cloneNode(true); + content.classList.add('document-drag-preview__folder-thumb'); + const svg = content.querySelector('svg'); + if (svg) { + svg.setAttribute('width', '48'); + svg.setAttribute('height', '48'); + } + } + + if (!content) { + content = document.createElement('div'); + content.className = 'document-drag-preview__folder-placeholder'; + content.textContent = 'Folder'; + } + + layer.appendChild(content); } wrapper.appendChild(layer); }); - if (docs.length > 1) { + if (totalCount > 1) { const badge = document.createElement('div'); badge.className = 'document-drag-preview__count'; - badge.textContent = docs.length > maxVisible ? `+${docs.length - maxVisible}` : `${docs.length}`; + badge.textContent = + totalCount > maxVisible ? `+${totalCount - maxVisible}` : `${totalCount}`; wrapper.appendChild(badge); } @@ -2177,28 +2480,47 @@ const AppLayout = () => { return; } + const documentKey = resolveDocumentRowKey(documentId); + if (!documentKey) { + return; + } + const isAlreadySelected = selectedDocumentIds.includes(documentId); const selection = isAlreadySelected ? [...selectedDocumentIds] : [documentId]; + const folderSelection = selectedFolderIds.length ? [...selectedFolderIds] : []; if (!isAlreadySelected) { - applySelection([documentId], { - anchor: documentId, - interactedIds: [documentId], + applySelection([documentKey], { + anchor: documentKey, + interactedKeys: [documentKey], }); } - const previewDocs = selection - .map((id) => documentLookup.get(id) || null) - .filter(Boolean); - const previewNode = createDragPreview(previewDocs); + const previewDocs = selection.map((id) => documentLookup.get(id) || null).filter(Boolean); + const previewNode = createDragPreview({ + documents: previewDocs, + folders: folderSelection, + }); setDraggedDocumentIds(selection); + if (folderSelection.length) { + setDraggedFolderId(folderSelection[0] || null); + } event.dataTransfer.effectAllowed = 'move'; try { event.dataTransfer.setData( 'application/x-papercrate-doc-list', JSON.stringify(selection), ); + if (folderSelection.length) { + event.dataTransfer.setData( + 'application/x-papercrate-folder-list', + JSON.stringify(folderSelection), + ); + if (folderSelection.length === 1) { + event.dataTransfer.setData('application/x-papercrate-folder', folderSelection[0]); + } + } } catch ( // eslint-disable-next-line no-empty error @@ -2210,7 +2532,14 @@ const AppLayout = () => { } event.currentTarget.classList.add('dragging'); }, - [selectedDocumentIds, applySelection, documentLookup, createDragPreview], + [ + selectedDocumentIds, + selectedFolderIds, + applySelection, + documentLookup, + createDragPreview, + setDraggedFolderId, + ], ); const handleDocumentDragEnd = useCallback( @@ -2218,8 +2547,94 @@ const AppLayout = () => { setDraggedDocumentIds([]); event.currentTarget.classList.remove('dragging'); destroyDragPreview(); + setDraggedFolderId(null); }, - [destroyDragPreview], + [destroyDragPreview, setDraggedFolderId], + ); + + const handleFolderDragStart = useCallback( + (event, folderId) => { + if (folderId === 'root') { + return; + } + event.stopPropagation(); + const folderKey = resolveFolderRowKey(folderId); + const isAlreadySelected = folderKey ? selectedRowKeys.includes(folderKey) : false; + + let effectiveFolderSelection = selectedFolderIds; + let effectiveDocumentSelection = selectedDocumentIds; + + if (!isAlreadySelected && folderKey) { + effectiveFolderSelection = [folderId]; + effectiveDocumentSelection = []; + handleRowSelection(folderKey, { preventDefault: () => {} }); + } + + const uniqueFolders = effectiveFolderSelection.length + ? Array.from(new Set(effectiveFolderSelection.filter(Boolean))) + : [folderId]; + + setDraggedFolderId(folderId); + if (effectiveDocumentSelection.length) { + setDraggedDocumentIds(effectiveDocumentSelection); + } + + event.dataTransfer.effectAllowed = 'move'; + try { + event.dataTransfer.setData( + 'application/x-papercrate-folder-list', + JSON.stringify(uniqueFolders), + ); + if (uniqueFolders.length === 1) { + event.dataTransfer.setData('application/x-papercrate-folder', uniqueFolders[0]); + } + if (effectiveDocumentSelection.length) { + event.dataTransfer.setData( + 'application/x-papercrate-doc-list', + JSON.stringify(effectiveDocumentSelection), + ); + } + } catch ( + // eslint-disable-next-line no-empty + error + ) {} + + const previewDocs = effectiveDocumentSelection + .map((id) => documentLookup.get(id) || null) + .filter(Boolean); + const previewNode = createDragPreview({ + documents: previewDocs, + folders: uniqueFolders, + }); + if (previewNode) { + const width = previewNode.offsetWidth || 96; + const height = previewNode.offsetHeight || 96; + event.dataTransfer.setDragImage(previewNode, width / 2, height / 2); + } + event.currentTarget?.classList.add('dragging'); + }, + [ + selectedRowKeys, + selectedFolderIds, + selectedDocumentIds, + setDraggedFolderId, + setDraggedDocumentIds, + handleRowSelection, + documentLookup, + createDragPreview, + ], + ); + + const handleFolderDragEnd = useCallback( + (event) => { + if (event?.currentTarget) { + event.currentTarget.classList.remove('dragging'); + } + setDraggedFolderId(null); + setDraggedDocumentIds([]); + destroyDragPreview(); + }, + [setDraggedFolderId, setDraggedDocumentIds, destroyDragPreview], ); const ensurePreviewUrl = useCallback( @@ -2459,24 +2874,34 @@ const AppLayout = () => { ], ); + const normalizeDocumentId = (value) => { + if (!value) return null; + if (typeof value === 'object' && value.id) { + return value.id; + } + return value; + }; + const moveDocumentsToFolder = useCallback( async (documentIds, targetFolderId) => { - const unique = Array.from(new Set(documentIds || [])).filter(Boolean); - if (!unique.length) return; + const uniqueIds = Array.from( + new Set((documentIds || []).map((value) => normalizeDocumentId(value)).filter(Boolean)), + ); + if (!uniqueIds.length) return; const target = targetFolderId === 'root' ? null : targetFolderId; setLoading(true); try { - if (unique.length === 1) { - await api.patch(`/documents/${unique[0].id}/folder`, { folder_id: target }); + if (uniqueIds.length === 1) { + await api.patch(`/documents/${uniqueIds[0]}/folder`, { folder_id: target }); } else { await api.post('/documents/bulk/move', { - document_ids: unique, + document_ids: uniqueIds, folder_id: target, }); } - const count = unique.length; + const count = uniqueIds.length; const suffix = count === 1 ? '' : 's'; const targetLabel = target === null @@ -2509,18 +2934,6 @@ const AppLayout = () => { ], ); - const handleBulkMoveFromDetail = useCallback( - async ({ target }) => { - const destination = target === 'root' ? 'root' : target; - if (!selectedDocumentIds.length) { - setStatusMessage('Select documents before moving.', 'error'); - return; - } - await moveDocumentsToFolder(selectedDocumentIds, destination); - }, - [selectedDocumentIds, moveDocumentsToFolder, setStatusMessage], - ); - const handleThumbnailRegeneration = useCallback( async (documentId) => { if (!token) { @@ -2608,11 +3021,10 @@ const AppLayout = () => { } let resolvedKey = null; - for (let index = selectedDocumentIds.length - 1; index >= 0; index -= 1) { - const candidate = selectedDocumentIds[index]; - const rowKey = `document:${candidate}`; - if (navigableRowKeys.includes(rowKey)) { - resolvedKey = rowKey; + for (let index = selectedRowKeys.length - 1; index >= 0; index -= 1) { + const candidate = selectedRowKeys[index]; + if (navigableRowKeys.includes(candidate)) { + resolvedKey = candidate; break; } } @@ -2627,16 +3039,13 @@ const AppLayout = () => { setFocusedRowKey(resolvedKey); - if (resolvedKey.startsWith('document:')) { - const docId = resolvedKey.slice('document:'.length); - if (!selectedDocumentIds.includes(docId)) { - applySelection([docId], { anchor: docId, interactedIds: [docId] }); - } + if (!selectedRowKeys.includes(resolvedKey)) { + applySelection([resolvedKey], { anchor: resolvedKey, interactedKeys: [resolvedKey] }); } }, [ focusedRowKey, navigableRowKeys, - selectedDocumentIds, + selectedRowKeys, navigableRows, applySelection, ]); @@ -2661,18 +3070,17 @@ const AppLayout = () => { : null; if (!activeKey) { - for (let index = selectedDocumentIds.length - 1; index >= 0; index -= 1) { - const candidate = selectedDocumentIds[index]; - const rowKey = `document:${candidate}`; - if (navigableRowKeys.includes(rowKey)) { - activeKey = rowKey; + for (let index = selectedRowKeys.length - 1; index >= 0; index -= 1) { + const candidate = selectedRowKeys[index]; + if (navigableRowKeys.includes(candidate)) { + activeKey = candidate; break; } } } if (!activeKey) { - activeKey = navigableRows[0].key; + activeKey = navigableRowKeys[0]; setFocusedRowKey(activeKey); } @@ -2683,12 +3091,10 @@ const AppLayout = () => { if (!row) { return; } + handleRowSelection(row.key, event); if (row.type === 'folder') { - setFocusedRowKey(`folder:${row.id}`); selectFolder(row.id); - } else { - setFocusedRowKey(`document:${row.id}`); - applySelection([row.id], { anchor: row.id, interactedIds: [row.id] }); + } else if (row.type === 'document') { openDocumentPreview(row.id); } return; @@ -2721,50 +3127,19 @@ const AppLayout = () => { setFocusedRowKey(targetRow.key); - if (targetRow.type === 'folder') { - return; - } - - const targetId = targetRow.id; - if (!targetId) { - return; - } - - if (shiftKey) { - let anchorId = selectionAnchorRef.current; - if (!anchorId || !visibleDocumentIds.includes(anchorId)) { - if (focusedDocumentId && visibleDocumentIds.includes(focusedDocumentId)) { - anchorId = focusedDocumentId; - } else { - anchorId = targetId; - } - } - - const anchorIndex = visibleDocumentIds.indexOf(anchorId); - const targetIndex = visibleDocumentIds.indexOf(targetId); - if (anchorIndex !== -1 && targetIndex !== -1) { - const start = Math.min(anchorIndex, targetIndex); - const end = Math.max(anchorIndex, targetIndex); - const range = visibleDocumentIds.slice(start, end + 1); - applySelection(range, { anchor: anchorId, interactedIds: [targetId] }); - } else { - applySelection([targetId], { anchor: targetId, interactedIds: [targetId] }); - } - } else { - applySelection([targetId], { anchor: targetId, interactedIds: [targetId] }); - } + handleRowSelection(targetRow.key, { + shiftKey, + preventDefault: () => {}, + }); }, [ navigableRows, navigableRowKeys, focusedRowKey, - selectedDocumentIds, + selectedRowKeys, + handleRowSelection, selectFolder, openDocumentPreview, - visibleDocumentIds, - focusedDocumentId, - applySelection, - selectionAnchorRef, ], ); @@ -3267,6 +3642,9 @@ const AppLayout = () => { if (activeTagFilters.length) { params.tags = activeTagFilters.join(','); } + if (activeCorrespondentFilters.length) { + params.correspondents = activeCorrespondentFilters.join(','); + } const folderIdentifier = selectedFolder === 'root' ? 'root' : selectedFolder; const { data } = await api.get(`/folders/${folderIdentifier}/documents`, { params, @@ -3277,33 +3655,47 @@ const AppLayout = () => { setSearchResults(results); if (!results.length) { - setSelectedDocumentIds([]); + setSelectedRowKeys([]); setFocusedDocumentId(null); + selectionOrderRef.current = []; + setSelectionOrder([]); selectionAnchorRef.current = null; return; } - const resultIds = results.map((doc) => doc.id); - let targetId = null; - setSelectedDocumentIds((previous) => { - const filtered = previous.filter((id) => resultIds.includes(id)); + const resultKeys = results + .map((doc) => resolveDocumentRowKey(doc.id)) + .filter(Boolean); + + let targetKey = null; + let nextSelectionKeys = []; + + setSelectedRowKeys((previous) => { + const previousDocKeys = previous.filter(isDocumentRowKey); + const filtered = previousDocKeys.filter((key) => resultKeys.includes(key)); if (filtered.length) { - targetId = filtered[filtered.length - 1]; + targetKey = filtered[filtered.length - 1]; + nextSelectionKeys = filtered; return filtered; } - targetId = resultIds[0]; - return [targetId]; + targetKey = resultKeys[0] || null; + nextSelectionKeys = targetKey ? [targetKey] : []; + return nextSelectionKeys; }); + selectionOrderRef.current = nextSelectionKeys; + setSelectionOrder(nextSelectionKeys); + + const targetDocId = targetKey ? getRowId(targetKey) : null; + setFocusedDocumentId((previous) => { - if (previous && resultIds.includes(previous)) { - targetId = previous; + if (previous && resultKeys.includes(resolveDocumentRowKey(previous))) { return previous; } - return targetId; + return targetDocId; }); - selectionAnchorRef.current = targetId; + selectionAnchorRef.current = targetKey; // rely on hydrated search results; assets refresh on demand } catch (error) { @@ -3329,6 +3721,7 @@ const AppLayout = () => { isFilterActive, searchQuery, activeTagFilters, + activeCorrespondentFilters, selectedFolder, notifyApiError, assetManager, @@ -3481,29 +3874,55 @@ const AppLayout = () => { event.stopPropagation(); event.currentTarget.classList.remove('is-drop-target'); - let folderSourceId = draggedFolderId; - if (!folderSourceId) { - try { - if (event.dataTransfer.types?.includes('application/x-papercrate-folder')) { - folderSourceId = event.dataTransfer.getData('application/x-papercrate-folder'); + let folderIds = []; + try { + const rawFolderList = event.dataTransfer.getData('application/x-papercrate-folder-list'); + if (rawFolderList) { + const parsed = JSON.parse(rawFolderList); + if (Array.isArray(parsed)) { + folderIds = parsed.filter(Boolean); } - } catch ( - // eslint-disable-next-line no-empty - error - ) {} + } + } catch ( + // eslint-disable-next-line no-empty + error + ) {} + + if (!folderIds.length) { + let folderSourceId = draggedFolderId; + if (!folderSourceId) { + try { + if (event.dataTransfer.types?.includes('application/x-papercrate-folder')) { + folderSourceId = event.dataTransfer.getData('application/x-papercrate-folder'); + } + } catch ( + // eslint-disable-next-line no-empty + error + ) {} + } + + if (folderSourceId) { + folderIds = [folderSourceId]; + } } - if (folderSourceId) { + folderIds = Array.from(new Set(folderIds.filter(Boolean))); + + if (folderIds.length) { setDraggedFolderId(null); - if (isInvalidFolderDrop(folderSourceId, folderId)) { + const invalidMove = folderIds.some((sourceId) => isInvalidFolderDrop(sourceId, folderId)); + if (invalidMove) { setStatusMessage( 'Cannot move a folder into itself or one of its descendants.', 'error', ); return; } - await moveFolder(folderSourceId, folderId); - return; + + for (const sourceId of folderIds) { + // eslint-disable-next-line no-await-in-loop + await moveFolder(sourceId, folderId); + } } if (hasFiles(event)) { @@ -3541,6 +3960,8 @@ const AppLayout = () => { docIds = draggedDocumentIds; } + docIds = Array.from(new Set(docIds)); + if (!docIds.length || folderId === selectedFolder) { return; } @@ -3572,33 +3993,6 @@ const AppLayout = () => { }, }; - const filterBar = ( - - setActiveTagFilters((prev) => - prev.includes(tagId) - ? prev.filter((id) => id !== tagId) - : prev.concat([tagId]), - ) - } - onClear={() => { - setSearchQuery(''); - setActiveTagFilters([]); - }} - hasFilters={isFilterActive} - /> - ); - - const defaultMoveTarget = useMemo(() => { - return folderOptions.some((option) => option.id === selectedFolder) - ? selectedFolder - : 'root'; - }, [folderOptions, selectedFolder]); - const selectedDocument = useMemo(() => { if (!focusedDocumentId) { return null; @@ -3661,8 +4055,15 @@ const AppLayout = () => { previewInflightRef.current.delete(documentId); if (selectedDocumentIds.includes(documentId)) { - const remaining = selectedDocumentIds.filter((id) => id !== documentId); - applySelection(remaining, { anchor: null, interactedIds: [documentId] }); + const remainingRowKeys = selectedDocumentIds + .filter((id) => id !== documentId) + .map((id) => resolveDocumentRowKey(id)) + .filter(Boolean); + const removedKey = resolveDocumentRowKey(documentId); + applySelection(remainingRowKeys, { + anchor: null, + interactedKeys: removedKey ? [removedKey] : [], + }); } if (previewDocumentId === documentId) { @@ -3705,8 +4106,12 @@ const AppLayout = () => { } }; - selectionOrder.forEach((id) => { - const doc = documentLookup.get(id) || null; + selectionOrder.forEach((key) => { + if (!isDocumentRowKey(key)) { + return; + } + const docId = getRowId(key); + const doc = documentLookup.get(docId) || null; pushDoc(doc); }); @@ -3836,8 +4241,12 @@ const AppLayout = () => { draggedFolderId, onShowTags: () => navigate('/tags'), tags, + activeTagIds: activeTagFilters, + onToggleTagFilter: toggleTagFilter, onShowCorrespondents: () => navigate('/correspondents'), correspondents, + activeCorrespondentIds: activeCorrespondentFilters, + onToggleCorrespondentFilter: toggleCorrespondentFilter, }; const resolveThumbnailUrlForDoc = useCallback( @@ -3871,18 +4280,19 @@ const AppLayout = () => { onFolderDragEnd: handleFolderDragEnd, draggedFolderId, onFolderDelete: handleFolderDelete, + onFolderRowClick: handleFolderRowClick, onFolderRename: handleFolderRename, onDocumentRowClick: handleDocumentRowClick, onDocumentOpen: openDocumentPreview, onDocumentDelete: handleDocumentDelete, onDocumentRename: handleDocumentTitleUpdate, selectedDocumentIds, + selectedFolderIds, focusedDocumentId, focusedRowKey, draggingDocumentIds: draggedDocumentIds, onDocumentDragStart: handleDocumentDragStart, onDocumentDragEnd: handleDocumentDragEnd, - filterBar, tagLookupById, onDocumentListFocus: handleDocumentListFocus, onDocumentListKeyDown: handleDocumentListKeyDown, @@ -3893,19 +4303,13 @@ const AppLayout = () => { doc?.current_version?.download_path ? resolveApiPath(doc.current_version.download_path) : null, - onTagClick: (tagId) => { - if (!tagId) return; - setActiveTagFilters((previous) => - previous.includes(tagId) ? previous : previous.concat([tagId]), - ); - }, + onTagClick: toggleTagFilter, }; const detailPanelProps = { selectedDocuments: orderedSelectedDocuments, tags, tagLookupById, - tagLookupByLabel, onTagAdd: handleTagAdd, onTagRemove: handleTagRemove, onRegenerateThumbnails: handleThumbnailRegeneration, @@ -3913,10 +4317,9 @@ const AppLayout = () => { onOpenPreview: openDocumentPreview, onBulkTagAdd: handleBulkTagAddFromDetail, onBulkTagRemove: handleBulkTagRemoveFromDetail, - onBulkMove: handleBulkMoveFromDetail, onBulkReanalyze: handleBulkSelectionReanalyze, - folderOptions, - defaultMoveTarget, + onBulkCorrespondentAdd: handleBulkCorrespondentAdd, + onBulkCorrespondentRemove: handleBulkCorrespondentRemove, onPromoteSelection: promoteSelectionOrder, activePreviewId, onUpdateTitle: handleDocumentTitleUpdate, @@ -4067,22 +4470,42 @@ const AppLayout = () => { : 'Drag files here to upload.'}
- {status && ( -
- +
+ setSearchQuery(event.target.value)} + placeholder="Search documents" + aria-label="Search documents" + /> + {isFilterActive && ( + + )} +
+
+ {status && ( +
+ +
+ )} +
+ +
- )} -
- -
diff --git a/frontend/src/sidebar/Sidebar.jsx b/frontend/src/sidebar/Sidebar.jsx index e802b6e..22a44e6 100644 --- a/frontend/src/sidebar/Sidebar.jsx +++ b/frontend/src/sidebar/Sidebar.jsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { useMatch } from 'react-router-dom'; import { ChevronIcon, @@ -8,6 +8,7 @@ import { CorrespondentIcon, EditIcon, } from '../ui/icons'; +import { getTagColorStyle } from '../utils/colors'; const FolderNode = ({ node, @@ -138,8 +139,12 @@ const Sidebar = ({ draggedFolderId, onShowTags, tags = [], + activeTagIds = [], + onToggleTagFilter, onShowCorrespondents, correspondents = [], + activeCorrespondentIds = [], + onToggleCorrespondentFilter, }) => { const handleShowTags = onShowTags || (() => {}); const tagsRouteMatch = useMatch('/tags'); @@ -147,6 +152,19 @@ const Sidebar = ({ const handleShowCorrespondents = onShowCorrespondents || (() => {}); const correspondentsRouteMatch = useMatch('/correspondents'); const isCorrespondentsRoute = Boolean(correspondentsRouteMatch); + const sortedCorrespondents = useMemo( + () => + [...correspondents].sort((a, b) => + (a?.name || '').localeCompare(b?.name || '', undefined, { sensitivity: 'base' }), + ), + [correspondents], + ); + const activeCorrespondentSet = useMemo( + () => new Set(activeCorrespondentIds || []), + [activeCorrespondentIds], + ); + const handleToggleTag = onToggleTagFilter || (() => {}); + const activeTagSet = new Set(activeTagIds); const renderNodes = useCallback( (ids, depth) => @@ -221,6 +239,30 @@ const Sidebar = ({ All tags +
+ {tags.length ? ( + tags.map((tag) => { + const isActive = activeTagSet.has(tag.id); + const style = getTagColorStyle(tag.color); + const className = `sidebar-tag-pill${isActive ? ' active' : ''}`; + return ( + + ); + }) + ) : ( + No tags yet + )} +
@@ -231,10 +273,14 @@ const Sidebar = ({ role="button" tabIndex={0} className={`sidebar-item${isCorrespondentsRoute ? ' active' : ''}`} - onClick={handleShowCorrespondents} + onClick={() => { + onToggleCorrespondentFilter?.(null); + handleShowCorrespondents(); + }} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); + onToggleCorrespondentFilter?.(null); handleShowCorrespondents(); } }} @@ -242,6 +288,39 @@ const Sidebar = ({ All correspondents +
    + {sortedCorrespondents.length ? ( + sortedCorrespondents.map((correspondent) => { + const isActive = activeCorrespondentSet.has(correspondent.id); + const className = `sidebar-correspondent-item${isActive ? ' active' : ''}`; + const label = correspondent.name || 'Unnamed'; + const handleSelect = () => { + const nextId = isActive ? null : correspondent.id; + onToggleCorrespondentFilter?.(nextId); + }; + return ( +
  • + { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleSelect(); + } + }} + > + {label} + +
  • + ); + }) + ) : ( +
  • No correspondents yet
  • + )} +
); diff --git a/frontend/src/styles.css b/frontend/src/styles.css index ad68ac3..94a2192 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -221,14 +221,16 @@ button.icon-button.ghost:hover:not([disabled]) { .app-bar__main { display: flex; + flex-wrap: wrap; align-items: center; - justify-content: space-between; + gap: 1rem; } .app-bar__meta { display: flex; flex-direction: column; gap: 0.2rem; + flex: 0 0 auto; } .app-bar h1 { @@ -257,6 +259,52 @@ button.icon-button.ghost:hover:not([disabled]) { margin: 0; } +.app-bar__search { + display: flex; + align-items: center; + gap: 0.5rem; + flex: 1 1 320px; + max-width: 420px; + min-width: 240px; + margin: 0 auto; +} + +.app-bar__search input[type='search'] { + flex: 1; + min-width: 180px; + padding: 0.4rem 0.6rem; + border-radius: 4px; + border: 1px solid var(--border); + background: var(--surface-subtle); + color: var(--fg); +} + +.app-bar__search input[type='search']:focus { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +.app-bar__search-clear { + border: none; + background: transparent; + color: var(--accent); + font-weight: 600; + font-size: 0.85rem; + cursor: pointer; + padding: 0.2rem 0.3rem; +} + +.app-bar__search-clear:hover { + text-decoration: underline; +} + +.app-bar__right { + display: flex; + align-items: center; + gap: 0.75rem; + margin-left: auto; +} + .app-main { flex: 1; display: grid; @@ -363,6 +411,34 @@ button.icon-button.ghost:hover:not([disabled]) { pointer-events: none; } +.document-drag-preview__folder-thumb { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; +} + +.document-drag-preview__folder-thumb svg { + width: 48px; + height: 48px; + color: var(--accent-strong, var(--accent)); +} + +.document-drag-preview__folder-placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: rgba(255, 255, 255, 0.9); +} + .document-drag-preview__count { position: absolute; bottom: 4px; @@ -852,6 +928,80 @@ button.icon-button.ghost:hover:not([disabled]) { color: currentColor; } +.sidebar-tag-cloud { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + padding: 0.25rem 0 0.1rem; +} + +.sidebar-tag-pill { + border: 1px solid var(--sidebar-pill-border, rgba(15, 23, 42, 0.08)); + border-radius: 999px; + padding: 0.25rem 0.6rem; + font-size: 0.75rem; + font-weight: 500; + line-height: 1; + cursor: pointer; + background: var(--surface-soft); + color: var(--fg); + transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06); +} + +.sidebar-tag-pill:hover { + transform: none; + box-shadow: 0 2px 6px rgba(15, 23, 42, 0.16); + border-color: var(--accent-soft, rgba(59, 130, 246, 0.35)); +} + +.sidebar-tag-pill:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.sidebar-tag-pill.active { + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent); +} + +.sidebar-correspondent-list { + list-style: none; + margin: 0.4rem 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.sidebar-correspondent-item { + display: block; + width: 100%; + background: transparent; + border-radius: 4px; + padding: 0.35rem 0.5rem; + text-align: left; + color: var(--fg); + cursor: pointer; + font-size: 0.85rem; + transition: background 0.15s ease, box-shadow 0.15s ease; +} + +.sidebar-correspondent-item:hover { + background: var(--sidebar-hover-bg); + box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08); +} + +.sidebar-correspondent-item:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.sidebar-correspondent-item.active { + background: var(--accent-soft, rgba(59, 130, 246, 0.18)); + box-shadow: 0 0 0 1px var(--accent-soft, rgba(59, 130, 246, 0.24)); +} + .documents-panel { padding: 1.25rem 1.25rem 0 1.25rem; @@ -893,7 +1043,6 @@ button.icon-button.ghost:hover:not([disabled]) { } .documents-panel .documents-scroll { - flex: 1; overflow-y: auto; border: none; background: transparent; @@ -1064,32 +1213,6 @@ button.icon-button.ghost:hover:not([disabled]) { min-width: 180px; } -.tag-filters { - display: flex; - flex-wrap: wrap; - gap: 0.32rem; -} - -.tag-filter { - border: 1px solid var(--border); - background: var(--surface-subtle); - color: var(--fg); - padding: 0.25rem 0.6rem; - border-radius: 2px; - cursor: pointer; - font-size: 0.85rem; - transition: transform 0.12s ease, box-shadow 0.12s ease, filter 0.12s ease; -} - -.tag-filter:hover { - filter: brightness(0.97); -} - -.tag-filter.active { - box-shadow: 0 0 0 1px currentColor inset; - transform: translateY(-1px); -} - .filter-actions { display: flex; gap: 0.32rem; diff --git a/frontend/src/tag_manager.js b/frontend/src/tag_manager.js new file mode 100644 index 0000000..beb430c --- /dev/null +++ b/frontend/src/tag_manager.js @@ -0,0 +1,27 @@ +import { generateRandomTagColor } from './utils/colors'; + +class TagManager { + constructor({ colorGenerator = generateRandomTagColor } = {}) { + this.colorGenerator = typeof colorGenerator === 'function' ? colorGenerator : generateRandomTagColor; + } + + normalizeLabel(label) { + if (typeof label !== 'string') { + return ''; + } + return label.trim(); + } + + buildPayload({ label, color } = {}) { + const normalizedLabel = this.normalizeLabel(label); + if (!normalizedLabel) { + throw new Error('Tag label is required.'); + } + const payload = { label: normalizedLabel }; + const trimmedColor = typeof color === 'string' && color.trim().length ? color.trim() : null; + payload.color = trimmedColor || this.colorGenerator(); + return payload; + } +} + +export default TagManager; diff --git a/k8s/papercrate/templates/_helpers.tpl b/k8s/papercrate/templates/_helpers.tpl index 969b618..4e6c01b 100644 --- a/k8s/papercrate/templates/_helpers.tpl +++ b/k8s/papercrate/templates/_helpers.tpl @@ -46,6 +46,10 @@ app.kubernetes.io/instance: {{ .Release.Name }} {{- include "papercrate.image" (dict "Values" .Values "image" .Values.backend.image) -}} {{- end -}} +{{- define "papercrate.webdav.image" -}} +{{- include "papercrate.image" (dict "Values" .Values "image" .Values.webdav.image) -}} +{{- end -}} + {{- define "papercrate.frontend.image" -}} {{- include "papercrate.image" (dict "Values" .Values "image" .Values.frontend.image) -}} {{- end -}} diff --git a/k8s/papercrate/templates/webdav-deployment.yaml b/k8s/papercrate/templates/webdav-deployment.yaml new file mode 100644 index 0000000..7930b9a --- /dev/null +++ b/k8s/papercrate/templates/webdav-deployment.yaml @@ -0,0 +1,91 @@ +{{- if .Values.webdav.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "papercrate.fullname" . }}-webdav + labels: + {{- include "papercrate.labels" . | nindent 4 }} + app.kubernetes.io/component: webdav +spec: + replicas: {{ .Values.webdav.replicaCount }} + selector: + matchLabels: + {{- include "papercrate.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: webdav + template: + metadata: + labels: + {{- include "papercrate.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: webdav + spec: + {{- if or .Values.global.imagePullSecrets .Values.imagePullSecrets }} + imagePullSecrets: + {{- range .Values.global.imagePullSecrets }} + - name: {{ . }} + {{- end }} + {{- range .Values.imagePullSecrets }} + - name: {{ . }} + {{- end }} + {{- end }} + containers: + - name: webdav + image: {{ include "papercrate.webdav.image" . }}:{{ include "papercrate.image.tag" (dict "image" .Values.webdav.image "Chart" .Chart) }} + imagePullPolicy: {{ .Values.webdav.image.pullPolicy }} + command: + - /usr/local/bin/papercrate-webdav + ports: + - name: http + containerPort: {{ .Values.webdav.service.port }} + protocol: TCP + env: + - name: SERVER_HOST + value: 0.0.0.0 + - name: SERVER_PORT + value: {{ .Values.webdav.service.port | quote }} + - name: WEBDAV_HOST + value: 0.0.0.0 + - name: WEBDAV_PORT + value: {{ .Values.webdav.service.port | quote }} + {{- with .Values.global.s3.endpoint }} + - name: AWS_ENDPOINT_URL + value: {{ . | quote }} + {{- end }} + {{- with .Values.global.s3.region }} + - name: AWS_REGION + value: {{ . | quote }} + {{- end }} + {{- with .Values.global.s3.bucket }} + - name: S3_BUCKET + value: {{ . | quote }} + {{- end }} + {{- range .Values.webdav.env.extra }} + - name: {{ .name }} + value: {{ .value | quote }} + {{- end }} + envFrom: + - secretRef: + name: {{ if .Values.webdav.env.existingSecret }}{{ .Values.webdav.env.existingSecret }}{{ else }}{{ include "papercrate.fullname" . }}-backend-env{{ end }} + {{- with .Values.global.s3.credentialsSecret }} + - secretRef: + name: {{ . }} + {{- end }} + {{- range .Values.webdav.envFrom }} + - {{ toYaml . | nindent 12 | trim }} + {{- end }} + {{- with .Values.webdav.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.webdav.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.webdav.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.webdav.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} +{{- end }} diff --git a/k8s/papercrate/templates/webdav-service.yaml b/k8s/papercrate/templates/webdav-service.yaml new file mode 100644 index 0000000..b1a32d0 --- /dev/null +++ b/k8s/papercrate/templates/webdav-service.yaml @@ -0,0 +1,19 @@ +{{- if .Values.webdav.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "papercrate.fullname" . }}-webdav + labels: + {{- include "papercrate.labels" . | nindent 4 }} + app.kubernetes.io/component: webdav +spec: + type: {{ .Values.webdav.service.type }} + ports: + - name: http + port: {{ .Values.webdav.service.port }} + targetPort: http + protocol: TCP + selector: + {{- include "papercrate.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: webdav +{{- end }} diff --git a/k8s/papercrate/values.yaml b/k8s/papercrate/values.yaml index 7d99c5e..3c798a8 100644 --- a/k8s/papercrate/values.yaml +++ b/k8s/papercrate/values.yaml @@ -28,6 +28,25 @@ backend: tolerations: [] affinity: {} +webdav: + enabled: true + replicaCount: 1 + image: + repository: registry.example.com/papercrate-backend + tag: "" + pullPolicy: IfNotPresent + service: + type: ClusterIP + port: 3001 + env: + existingSecret: "" + extra: [] + envFrom: [] + resources: {} + nodeSelector: {} + tolerations: [] + affinity: {} + worker: replicaCount: 1 enabled: true