From 41a9e8f76ceae56d7fb96cd26e15276980f2eee8 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Sat, 11 Oct 2025 11:09:38 +0200 Subject: [PATCH] preview-stack --- .../down.sql | 2 + .../up.sql | 2 + .../down.sql | 2 + .../202503090002_add_name_to_documents/up.sql | 11 + .../down.sql | 2 + .../202503090003_rename_name_to_title/up.sql | 2 + backend/src/models.rs | 4 + backend/src/routes/documents.rs | 253 ++- backend/src/routes/mod.rs | 6 + backend/src/schema.rs | 3 + backend/tests/documents_flow.rs | 391 ++++- frontend/src/index.jsx | 1537 ++++++++++++++--- frontend/src/styles.css | 160 +- 13 files changed, 2088 insertions(+), 287 deletions(-) create mode 100644 backend/migrations/202503090001_add_issued_at_to_documents/down.sql create mode 100644 backend/migrations/202503090001_add_issued_at_to_documents/up.sql create mode 100644 backend/migrations/202503090002_add_name_to_documents/down.sql create mode 100644 backend/migrations/202503090002_add_name_to_documents/up.sql create mode 100644 backend/migrations/202503090003_rename_name_to_title/down.sql create mode 100644 backend/migrations/202503090003_rename_name_to_title/up.sql diff --git a/backend/migrations/202503090001_add_issued_at_to_documents/down.sql b/backend/migrations/202503090001_add_issued_at_to_documents/down.sql new file mode 100644 index 0000000..b576330 --- /dev/null +++ b/backend/migrations/202503090001_add_issued_at_to_documents/down.sql @@ -0,0 +1,2 @@ +ALTER TABLE documents + DROP COLUMN issued_at; diff --git a/backend/migrations/202503090001_add_issued_at_to_documents/up.sql b/backend/migrations/202503090001_add_issued_at_to_documents/up.sql new file mode 100644 index 0000000..544e5b1 --- /dev/null +++ b/backend/migrations/202503090001_add_issued_at_to_documents/up.sql @@ -0,0 +1,2 @@ +ALTER TABLE documents + ADD COLUMN issued_at TIMESTAMPTZ; diff --git a/backend/migrations/202503090002_add_name_to_documents/down.sql b/backend/migrations/202503090002_add_name_to_documents/down.sql new file mode 100644 index 0000000..430458f --- /dev/null +++ b/backend/migrations/202503090002_add_name_to_documents/down.sql @@ -0,0 +1,2 @@ +ALTER TABLE documents + DROP COLUMN name; diff --git a/backend/migrations/202503090002_add_name_to_documents/up.sql b/backend/migrations/202503090002_add_name_to_documents/up.sql new file mode 100644 index 0000000..b2bda83 --- /dev/null +++ b/backend/migrations/202503090002_add_name_to_documents/up.sql @@ -0,0 +1,11 @@ +ALTER TABLE documents + ADD COLUMN name VARCHAR(255); + +UPDATE documents +SET name = CASE + WHEN filename ~ '\\.[^./]+$' THEN regexp_replace(filename, '\\.[^./]+$', '') + ELSE filename +END; + +ALTER TABLE documents + ALTER COLUMN name SET NOT NULL; diff --git a/backend/migrations/202503090003_rename_name_to_title/down.sql b/backend/migrations/202503090003_rename_name_to_title/down.sql new file mode 100644 index 0000000..1470159 --- /dev/null +++ b/backend/migrations/202503090003_rename_name_to_title/down.sql @@ -0,0 +1,2 @@ +ALTER TABLE documents + RENAME COLUMN title TO name; diff --git a/backend/migrations/202503090003_rename_name_to_title/up.sql b/backend/migrations/202503090003_rename_name_to_title/up.sql new file mode 100644 index 0000000..60487bf --- /dev/null +++ b/backend/migrations/202503090003_rename_name_to_title/up.sql @@ -0,0 +1,2 @@ +ALTER TABLE documents + RENAME COLUMN name TO title; diff --git a/backend/src/models.rs b/backend/src/models.rs index bef2eb0..b9d2a34 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -58,6 +58,8 @@ pub struct Document { pub updated_at: NaiveDateTime, pub deleted_at: Option, pub metadata: serde_json::Value, + pub issued_at: Option, + pub title: String, } #[derive(Debug, Insertable)] @@ -70,6 +72,8 @@ pub struct NewDocument { pub folder_id: Option, pub current_version: i32, pub metadata: serde_json::Value, + pub issued_at: Option, + pub title: String, } #[derive(Debug, Clone, Queryable, Identifiable, Associations)] diff --git a/backend/src/routes/documents.rs b/backend/src/routes/documents.rs index 69086d5..8de69e3 100644 --- a/backend/src/routes/documents.rs +++ b/backend/src/routes/documents.rs @@ -1,4 +1,4 @@ -use std::{collections::HashMap, time::Duration}; +use std::{collections::HashMap, path::Path as FsPath, time::Duration}; use axum::extract::{Json, Multipart, Path, Query, State}; use axum::http::StatusCode; @@ -57,6 +57,7 @@ impl From for TagResponse { pub struct DocumentResponse { pub id: Uuid, pub filename: String, + pub title: String, pub original_name: String, pub content_type: Option, pub folder_id: Option, @@ -64,6 +65,7 @@ pub struct DocumentResponse { pub uploaded_at: String, pub updated_at: String, pub deleted_at: Option, + pub issued_at: Option, pub metadata: Value, pub tags: Vec, pub thumbnail: Option, @@ -112,6 +114,48 @@ pub struct BulkReanalyzeResponse { pub queued: usize, } +#[derive(Deserialize)] +pub struct BulkMoveRequest { + pub document_ids: Vec, + pub folder_id: Option, +} + +#[derive(Serialize)] +pub struct BulkMoveResponse { + pub updated: usize, +} + +#[derive(Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BulkTagAction { + Add, + Remove, +} + +#[derive(Deserialize)] +pub struct BulkTagRequest { + pub document_ids: Vec, + pub tag_ids: Vec, + pub action: BulkTagAction, +} + +#[derive(Serialize)] +pub struct BulkTagResponse { + pub added: usize, + pub removed: usize, +} + +#[derive(Deserialize)] +pub struct BulkReanalyzeSelectionRequest { + pub document_ids: Vec, + #[serde(default = "default_true")] + pub force: bool, +} + +fn default_true() -> bool { + true +} + struct UploadRequest { bytes: Vec, original_name: String, @@ -379,6 +423,57 @@ pub async fn reanalyze_all_documents( Ok((StatusCode::ACCEPTED, Json(BulkReanalyzeResponse { queued }))) } +pub async fn reanalyze_selected_documents( + State(state): State, + Json(payload): Json, +) -> AppResult<(StatusCode, Json)> { + let BulkReanalyzeSelectionRequest { + mut document_ids, + force, + } = payload; + + if document_ids.is_empty() { + return Err(AppError::bad_request("document_ids must not be empty")); + } + + document_ids.sort(); + document_ids.dedup(); + + let mut conn = state.db()?; + + let targets: Vec<(Uuid, Uuid)> = document_versions::table + .inner_join(documents::table.on(document_versions::document_id.eq(documents::id))) + .filter(documents::id.eq_any(&document_ids)) + .filter(documents::deleted_at.is_null()) + .filter(document_versions::version_number.eq(documents::current_version)) + .select((documents::id, document_versions::id)) + .load(&mut conn)?; + + if targets.len() != document_ids.len() { + return Err(AppError::bad_request( + "one or more documents do not exist or are inaccessible", + )); + } + + let mut queued = 0usize; + for (document_id, version_id) in targets { + enqueue_job( + &mut conn, + JOB_ANALYZE_DOCUMENT, + json!({ + "document_id": document_id, + "document_version_id": version_id, + "force": force, + }), + None, + ) + .map_err(|err| AppError::internal(format!("failed to enqueue analyze job: {err}")))?; + queued += 1; + } + + Ok((StatusCode::ACCEPTED, Json(BulkReanalyzeResponse { queued }))) +} + pub async fn list_document_assets( State(state): State, Path(document_id): Path, @@ -468,6 +563,54 @@ pub async fn move_document( Ok(StatusCode::NO_CONTENT) } +pub async fn bulk_move_documents( + State(state): State, + Json(payload): Json, +) -> AppResult<(StatusCode, Json)> { + let BulkMoveRequest { + mut document_ids, + folder_id, + } = payload; + + if document_ids.is_empty() { + return Err(AppError::bad_request("document_ids must not be empty")); + } + + document_ids.sort(); + document_ids.dedup(); + + if let Some(target_folder) = folder_id { + ensure_folder_exists(&state, target_folder)?; + } + + let mut conn = state.db()?; + + let existing: Vec<(Uuid, Option)> = documents::table + .filter(documents::id.eq_any(&document_ids)) + .select((documents::id, documents::deleted_at)) + .load(&mut conn)?; + + if existing.len() != document_ids.len() { + return Err(AppError::bad_request( + "one or more documents do not exist or are inaccessible", + )); + } + + if existing.iter().any(|(_, deleted)| deleted.is_some()) { + return Err(AppError::bad_request("cannot move deleted documents")); + } + + let now = Utc::now().naive_utc(); + let updated = diesel::update(documents::table.filter(documents::id.eq_any(&document_ids))) + .set(( + documents::folder_id.eq(folder_id), + documents::updated_at.eq(now), + )) + .execute(&mut conn)?; + + Ok((StatusCode::OK, Json(BulkMoveResponse { updated }))) +} + pub async fn assign_tags( State(state): State, Path(document_id): Path, @@ -511,6 +654,94 @@ pub async fn assign_tags( Ok(StatusCode::NO_CONTENT) } +pub async fn bulk_update_tags( + State(state): State, + user: AuthenticatedUser, + Json(payload): Json, +) -> AppResult<(StatusCode, Json)> { + let BulkTagRequest { + mut document_ids, + mut tag_ids, + action, + } = payload; + + if document_ids.is_empty() { + return Err(AppError::bad_request("document_ids must not be empty")); + } + if tag_ids.is_empty() { + return Err(AppError::bad_request("tag_ids must not be empty")); + } + + document_ids.sort(); + document_ids.dedup(); + tag_ids.sort(); + tag_ids.dedup(); + + let mut conn = state.db()?; + + let docs: Vec<(Uuid, Option)> = documents::table + .filter(documents::id.eq_any(&document_ids)) + .select((documents::id, documents::deleted_at)) + .load(&mut 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 or remove tags from deleted documents", + )); + } + + let existing_tags: Vec = tags::table + .filter(tags::id.eq_any(&tag_ids)) + .load(&mut conn)?; + if existing_tags.len() != tag_ids.len() { + return Err(AppError::bad_request("one or more tags do not exist")); + } + + let response = match action { + BulkTagAction::Add => { + let mut inserts = Vec::with_capacity(document_ids.len() * tag_ids.len()); + for doc_id in &document_ids { + for tag_id in &tag_ids { + inserts.push(NewDocumentTag { + document_id: *doc_id, + tag_id: *tag_id, + assigned_by: Some(user.user_id), + }); + } + } + + let added = if inserts.is_empty() { + 0 + } else { + diesel::insert_into(document_tags::table) + .values(&inserts) + .on_conflict_do_nothing() + .execute(&mut conn)? + }; + + BulkTagResponse { added, removed: 0 } + } + BulkTagAction::Remove => { + let removed = diesel::delete( + document_tags::table + .filter(document_tags::document_id.eq_any(&document_ids)) + .filter(document_tags::tag_id.eq_any(&tag_ids)), + ) + .execute(&mut conn)?; + + BulkTagResponse { added: 0, removed } + } + }; + + Ok((StatusCode::OK, Json(response))) +} + pub async fn remove_tag( State(state): State, Path((document_id, tag_id)): Path<(Uuid, Uuid)>, @@ -627,6 +858,8 @@ async fn process_upload(state: &AppState, request: UploadRequest) -> AppResult DocumentAssetResponse } } +fn derive_document_title(original: &str) -> String { + let trimmed = original.trim(); + if trimmed.is_empty() { + return "Document".to_string(); + } + + let stem = FsPath::new(trimmed) + .file_stem() + .and_then(|s| s.to_str()) + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + + stem.unwrap_or_else(|| trimmed.to_string()) +} + async fn load_asset_responses( state: &AppState, version_id: Uuid, diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index b9bc610..26e2c46 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -31,6 +31,12 @@ pub fn create_router(state: AppState) -> Router<()> { get(documents::list_documents).post(documents::upload_document), ) .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/reanalyze", + post(documents::reanalyze_selected_documents), + ) .route( "/:id", get(documents::get_document).delete(documents::delete_document), diff --git a/backend/src/schema.rs b/backend/src/schema.rs index 2e0be20..889762b 100644 --- a/backend/src/schema.rs +++ b/backend/src/schema.rs @@ -53,6 +53,9 @@ diesel::table! { updated_at -> Timestamptz, deleted_at -> Nullable, metadata -> Jsonb, + issued_at -> Nullable, + #[max_length = 255] + title -> Varchar, } } diff --git a/backend/tests/documents_flow.rs b/backend/tests/documents_flow.rs index a26807f..d107df1 100644 --- a/backend/tests/documents_flow.rs +++ b/backend/tests/documents_flow.rs @@ -3,8 +3,7 @@ mod common; use anyhow::Result; use axum::http::StatusCode; use common::{acquire_db_lock, body_to_vec, TestApp}; -use serde::Deserialize; -use serde_json::Value; +use serde::{Deserialize, Serialize}; use uuid::Uuid; #[derive(Deserialize)] @@ -17,10 +16,12 @@ struct DocumentDetail { #[derive(Deserialize)] struct DocumentInfo { id: Uuid, + title: String, original_name: String, current_version: i32, deleted_at: Option, - tags: Vec, + issued_at: Option, + tags: Vec, } #[derive(Deserialize)] @@ -30,6 +31,7 @@ struct DocumentVersion { size_bytes: i64, } +#[allow(dead_code)] #[derive(Deserialize)] struct DocumentAssetInfo { id: Uuid, @@ -53,6 +55,22 @@ struct BulkReanalyze { queued: usize, } +#[derive(Deserialize)] +struct BulkMoveResult { + updated: usize, +} + +#[derive(Deserialize)] +struct BulkTagResult { + added: usize, + removed: usize, +} + +#[derive(Deserialize)] +struct TagSummary { + label: String, +} + #[derive(Deserialize)] struct AnalyzeJobPayload { document_id: Uuid, @@ -61,6 +79,51 @@ struct AnalyzeJobPayload { force: bool, } +#[derive(Deserialize)] +struct FolderResponse { + folder: FolderInfo, +} + +#[derive(Deserialize)] +struct FolderInfo { + id: Uuid, +} + +#[derive(Deserialize)] +struct FolderContents { + documents: Vec, +} + +#[derive(Deserialize)] +struct TagResponse { + id: Uuid, +} + +#[derive(Serialize)] +struct BulkMoveRequest<'a> { + document_ids: &'a [Uuid], + folder_id: Option, +} + +#[derive(Serialize)] +struct BulkTagRequest<'a> { + document_ids: &'a [Uuid], + tag_ids: &'a [Uuid], + action: &'a str, +} + +#[derive(Serialize)] +struct CreateFolderRequest<'a> { + name: &'a str, + parent_id: Option, +} + +#[derive(Serialize)] +struct CreateTagPayload<'a> { + label: &'a str, + color: Option<&'a str>, +} + #[tokio::test] async fn upload_and_list_document() -> Result<()> { let _lock = acquire_db_lock().await; @@ -86,8 +149,10 @@ async fn upload_and_list_document() -> Result<()> { let detail: DocumentDetail = serde_json::from_slice(&body)?; assert_eq!(detail.document.original_name, "doc.txt"); + assert_eq!(detail.document.title, "doc"); assert_eq!(detail.document.current_version, 1); assert_eq!(detail.document.deleted_at, None); + assert!(detail.document.issued_at.is_none()); assert!(detail.document.tags.is_empty()); assert_eq!(detail.current_version.size_bytes, file_bytes.len() as i64); assert!(detail.assets.is_empty()); @@ -273,3 +338,323 @@ async fn bulk_reanalyze_documents() -> Result<()> { app.cleanup().await?; Ok(()) } + +#[tokio::test] +async fn bulk_move_documents_to_folder() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "bulkmove"; + app.insert_user("mover", password, "admin").await?; + let token = app.login_token("mover", password).await?; + + let alpha = app + .upload_document( + "/api/documents", + "alpha.txt", + "text/plain", + b"alpha", + None, + &token, + ) + .await?; + assert_eq!(alpha.status(), StatusCode::CREATED); + let alpha_body = body_to_vec(alpha.into_body()).await?; + let alpha_detail: DocumentDetail = serde_json::from_slice(&alpha_body)?; + + let beta = app + .upload_document( + "/api/documents", + "beta.txt", + "text/plain", + b"beta", + None, + &token, + ) + .await?; + assert_eq!(beta.status(), StatusCode::CREATED); + let beta_body = body_to_vec(beta.into_body()).await?; + let beta_detail: DocumentDetail = serde_json::from_slice(&beta_body)?; + + let folder_resp = app + .post_json( + "/api/folders", + &CreateFolderRequest { + name: "Archives", + parent_id: None, + }, + Some(&token), + ) + .await?; + assert_eq!(folder_resp.status(), StatusCode::OK); + let folder_body = body_to_vec(folder_resp.into_body()).await?; + let folder: FolderResponse = serde_json::from_slice(&folder_body)?; + + let move_resp = app + .post_json( + "/api/documents/bulk/move", + &BulkMoveRequest { + document_ids: &[alpha_detail.document.id, beta_detail.document.id], + folder_id: Some(folder.folder.id), + }, + Some(&token), + ) + .await?; + assert_eq!(move_resp.status(), StatusCode::OK); + let move_body = body_to_vec(move_resp.into_body()).await?; + let result: BulkMoveResult = serde_json::from_slice(&move_body)?; + assert_eq!(result.updated, 2); + + let folder_contents = app + .get( + &format!("/api/folders/{}/contents", folder.folder.id), + Some(&token), + ) + .await?; + assert_eq!(folder_contents.status(), StatusCode::OK); + let folder_body = body_to_vec(folder_contents.into_body()).await?; + let folder_docs: FolderContents = serde_json::from_slice(&folder_body)?; + let moved_ids: Vec<_> = folder_docs.documents.iter().map(|doc| doc.id).collect(); + assert!(moved_ids.contains(&alpha_detail.document.id)); + assert!(moved_ids.contains(&beta_detail.document.id)); + + let root_contents = app.get("/api/folders/root/contents", Some(&token)).await?; + let root_body = body_to_vec(root_contents.into_body()).await?; + let root_docs: FolderContents = serde_json::from_slice(&root_body)?; + assert!(root_docs + .documents + .iter() + .all(|doc| doc.id != alpha_detail.document.id && doc.id != beta_detail.document.id)); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn bulk_update_tags_for_selection() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "bulktags"; + app.insert_user("tagger", password, "admin").await?; + let token = app.login_token("tagger", password).await?; + + let first = app + .upload_document( + "/api/documents", + "notes.txt", + "text/plain", + b"notes", + 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", + "report.txt", + "text/plain", + b"report", + 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 urgent_tag = app + .post_json( + "/api/tags", + &CreateTagPayload { + label: "Urgent", + color: None, + }, + Some(&token), + ) + .await?; + assert_eq!(urgent_tag.status(), StatusCode::OK); + let urgent_body = body_to_vec(urgent_tag.into_body()).await?; + let urgent: TagResponse = serde_json::from_slice(&urgent_body)?; + + let review_tag = app + .post_json( + "/api/tags", + &CreateTagPayload { + label: "Review", + color: None, + }, + Some(&token), + ) + .await?; + assert_eq!(review_tag.status(), StatusCode::OK); + let review_body = body_to_vec(review_tag.into_body()).await?; + let review: TagResponse = serde_json::from_slice(&review_body)?; + + let add_resp = app + .post_json( + "/api/documents/bulk/tags", + &BulkTagRequest { + document_ids: &[first_detail.document.id, second_detail.document.id], + tag_ids: &[urgent.id, review.id], + action: "add", + }, + Some(&token), + ) + .await?; + assert_eq!(add_resp.status(), StatusCode::OK); + let add_body = body_to_vec(add_resp.into_body()).await?; + let add_result: BulkTagResult = serde_json::from_slice(&add_body)?; + assert_eq!(add_result.added, 4); + + 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)?; + let labels: Vec<_> = detail + .document + .tags + .iter() + .map(|tag| tag.label.as_str()) + .collect(); + assert!(labels.contains(&"Urgent")); + assert!(labels.contains(&"Review")); + } + + let remove_resp = app + .post_json( + "/api/documents/bulk/tags", + &BulkTagRequest { + document_ids: &[first_detail.document.id, second_detail.document.id], + tag_ids: &[urgent.id], + action: "remove", + }, + Some(&token), + ) + .await?; + assert_eq!(remove_resp.status(), StatusCode::OK); + let remove_body = body_to_vec(remove_resp.into_body()).await?; + let remove_result: BulkTagResult = serde_json::from_slice(&remove_body)?; + 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)?; + let labels: Vec<_> = detail + .document + .tags + .iter() + .map(|tag| tag.label.as_str()) + .collect(); + assert!(!labels.contains(&"Urgent")); + assert!(labels.contains(&"Review")); + } + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn bulk_reanalyze_selected_documents() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "subsetrean"; + app.insert_user("subset", password, "admin").await?; + let token = app.login_token("subset", password).await?; + + app.clear_jobs().await?; + + let first = app + .upload_document( + "/api/documents", + "doc-one.txt", + "text/plain", + b"one", + None, + &token, + ) + .await?; + 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", + "doc-two.txt", + "text/plain", + b"two", + None, + &token, + ) + .await?; + let second_body = body_to_vec(second.into_body()).await?; + let second_detail: DocumentDetail = serde_json::from_slice(&second_body)?; + + let third = app + .upload_document( + "/api/documents", + "doc-three.txt", + "text/plain", + b"three", + None, + &token, + ) + .await?; + let third_body = body_to_vec(third.into_body()).await?; + let third_detail: DocumentDetail = serde_json::from_slice(&third_body)?; + + app.clear_jobs().await?; + + let response = app + .post_json( + "/api/documents/bulk/reanalyze", + &serde_json::json!({ + "document_ids": [ + first_detail.document.id, + third_detail.document.id + ], + "force": true + }), + Some(&token), + ) + .await?; + assert_eq!(response.status(), StatusCode::ACCEPTED); + let body = body_to_vec(response.into_body()).await?; + let bulk: BulkReanalyze = serde_json::from_slice(&body)?; + assert_eq!(bulk.queued, 2); + + let jobs = app.jobs_by_type("analyze-document").await?; + assert_eq!(jobs.len(), 2); + let mut payload_docs = Vec::new(); + for job in jobs { + let payload: AnalyzeJobPayload = serde_json::from_value(job.payload)?; + assert!(payload.force); + payload_docs.push((payload.document_id, payload.document_version_id)); + } + + assert!(payload_docs + .iter() + .all(|(doc_id, _)| *doc_id != second_detail.document.id)); + + let mut expected = vec![ + (first_detail.document.id, first_detail.current_version.id), + (third_detail.document.id, third_detail.current_version.id), + ]; + payload_docs.sort(); + expected.sort(); + assert_eq!(payload_docs, expected); + + app.cleanup().await?; + Ok(()) +} diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx index 47589ed..9463f90 100644 --- a/frontend/src/index.jsx +++ b/frontend/src/index.jsx @@ -336,9 +336,11 @@ const DocumentsTable = ({ onFolderDragEnd, draggedFolderId, onFolderDelete, - onDocumentSelect, + onDocumentRowClick, onDocumentOpen, - selectedDocumentId, + selectedDocumentIds, + focusedDocumentId, + draggingDocumentIds = [], onDocumentDragStart, onDocumentDragEnd, onDownload, @@ -346,6 +348,14 @@ const DocumentsTable = ({ }) => { const showingSearchResults = searchResults !== null; const rows = showingSearchResults ? searchResults : documents; + const selectedSet = useMemo( + () => new Set(selectedDocumentIds), + [selectedDocumentIds], + ); + const draggingSet = useMemo( + () => new Set(draggingDocumentIds || []), + [draggingDocumentIds], + ); return (
@@ -392,14 +402,13 @@ const DocumentsTable = ({ Drop files anywhere or onto a folder to upload documents. ) : ( - +
- @@ -428,81 +437,102 @@ const DocumentsTable = ({ } }} > + + + + + + + + ); + })} + {rows.map((doc) => { + const isSelected = selectedSet.has(doc.id); + const rowClasses = ['document']; + if (isSelected) rowClasses.push('selected'); + if (focusedDocumentId === doc.id) rowClasses.push('focused'); + if (draggingSet.has(doc.id)) { + rowClasses.push('dragging'); + } + + return ( + onDocumentRowClick(doc.id, event)} + onDoubleClick={(event) => { + event.stopPropagation(); + if (onDocumentOpen) { + onDocumentOpen(doc.id); + } + }} + draggable + onDragStart={(event) => onDocumentDragStart(event, doc.id)} + onDragEnd={onDocumentDragEnd} + > + - - - - + + - ); - })} - {rows.map((doc) => ( - onDocumentSelect(doc.id)} - onDoubleClick={(event) => { - event.stopPropagation(); - if (onDocumentOpen) { - onDocumentOpen(doc.id); - } - }} - draggable - onDragStart={(event) => onDocumentDragStart(event, doc.id)} - onDragEnd={onDocumentDragEnd} - > - - - - - - - - ))} + ); + })}
Preview Name Type UpdatedTags Actions
+
+ +
+
{folder.name}Folder + +
-
- + {doc.thumbnail ? ( + {`Thumbnail + ) : ( +
DOC
+ )} +
+
+ {doc.title || doc.original_name} + {(doc.tags || []).length > 0 && ( +
+ {(doc.tags || []).map((tag) => ( + + {tag.label} + + ))} +
+ )}
{folder.name}Folder{doc.content_type || 'Document'}{ + doc.updated_at + ? new Date(doc.updated_at).toLocaleString() + : '—' + }
- {doc.thumbnail ? ( - {`Thumbnail - ) : ( -
DOC
- )} -
{doc.original_name}{doc.content_type || 'Document'}{doc.updated_at ? new Date(doc.updated_at).toLocaleString() : '—'} - {(doc.tags || []).map((tag) => ( - - {tag.label} - - ))} - - -
)} @@ -520,10 +550,62 @@ const DocumentsTable = ({ ); }; +const computeStackAngle = (docId, index) => { + if (index === 0) return 0; + let hash = 0; + const source = docId || `stack-${index}`; + for (let i = 0; i < source.length; i += 1) { + hash = (hash * 31 + source.charCodeAt(i)) % 997; + } + const magnitude = (hash % 15) + 1; // 1..15 + const sign = index % 2 === 0 ? 1 : -1; + return magnitude * sign; +}; + +const MAX_PREVIEW_STACK_ITEMS = 4; + +const PreviewStack = ({ + items = [], + maxItems = MAX_PREVIEW_STACK_ITEMS, + emptyMessage = 'Preview unavailable', +}) => { + if (!items.length) { + return {emptyMessage}; + } + + const limited = items.slice(0, maxItems); + const hasMultiple = limited.length > 1; + + return ( +
+ {limited.map((item, index) => { + const angle = hasMultiple ? computeStackAngle(item.id, index) : 0; + const offset = hasMultiple ? index * 8 : 0; + const transform = hasMultiple + ? `translate(-50%, -50%) rotate(${angle}deg) translateY(${offset}px)` + : 'translate(-50%, -50%)'; + return ( +
+ {item.alt +
+ ); + })} +
+ ); +}; + const DetailPanel = ({ - document, - detail, - tags, + selectedDocuments = [], + detailMap = new Map(), + tags = [], onDownload, onTagAdd, onTagRemove, @@ -531,117 +613,309 @@ const DetailPanel = ({ loading, previewEntry, onOpenPreview, -}) => ( -