preview-stack
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE documents
|
||||
DROP COLUMN issued_at;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE documents
|
||||
ADD COLUMN issued_at TIMESTAMPTZ;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE documents
|
||||
DROP COLUMN name;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE documents
|
||||
RENAME COLUMN title TO name;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE documents
|
||||
RENAME COLUMN name TO title;
|
||||
@@ -58,6 +58,8 @@ pub struct Document {
|
||||
pub updated_at: NaiveDateTime,
|
||||
pub deleted_at: Option<NaiveDateTime>,
|
||||
pub metadata: serde_json::Value,
|
||||
pub issued_at: Option<NaiveDateTime>,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
@@ -70,6 +72,8 @@ pub struct NewDocument {
|
||||
pub folder_id: Option<Uuid>,
|
||||
pub current_version: i32,
|
||||
pub metadata: serde_json::Value,
|
||||
pub issued_at: Option<NaiveDateTime>,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
||||
|
||||
@@ -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<Tag> for TagResponse {
|
||||
pub struct DocumentResponse {
|
||||
pub id: Uuid,
|
||||
pub filename: String,
|
||||
pub title: String,
|
||||
pub original_name: String,
|
||||
pub content_type: Option<String>,
|
||||
pub folder_id: Option<Uuid>,
|
||||
@@ -64,6 +65,7 @@ pub struct DocumentResponse {
|
||||
pub uploaded_at: String,
|
||||
pub updated_at: String,
|
||||
pub deleted_at: Option<String>,
|
||||
pub issued_at: Option<String>,
|
||||
pub metadata: Value,
|
||||
pub tags: Vec<TagResponse>,
|
||||
pub thumbnail: Option<DocumentAssetResponse>,
|
||||
@@ -112,6 +114,48 @@ pub struct BulkReanalyzeResponse {
|
||||
pub queued: usize,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct BulkMoveRequest {
|
||||
pub document_ids: Vec<Uuid>,
|
||||
pub folder_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[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<Uuid>,
|
||||
pub tag_ids: Vec<Uuid>,
|
||||
pub action: BulkTagAction,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct BulkTagResponse {
|
||||
pub added: usize,
|
||||
pub removed: usize,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct BulkReanalyzeSelectionRequest {
|
||||
pub document_ids: Vec<Uuid>,
|
||||
#[serde(default = "default_true")]
|
||||
pub force: bool,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
struct UploadRequest {
|
||||
bytes: Vec<u8>,
|
||||
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<AppState>,
|
||||
Json(payload): Json<BulkReanalyzeSelectionRequest>,
|
||||
) -> AppResult<(StatusCode, Json<BulkReanalyzeResponse>)> {
|
||||
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<AppState>,
|
||||
Path(document_id): Path<Uuid>,
|
||||
@@ -468,6 +563,54 @@ pub async fn move_document(
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub async fn bulk_move_documents(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<BulkMoveRequest>,
|
||||
) -> AppResult<(StatusCode, Json<BulkMoveResponse>)> {
|
||||
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<NaiveDateTime>)> = 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<AppState>,
|
||||
Path(document_id): Path<Uuid>,
|
||||
@@ -511,6 +654,94 @@ pub async fn assign_tags(
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub async fn bulk_update_tags(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Json(payload): Json<BulkTagRequest>,
|
||||
) -> AppResult<(StatusCode, Json<BulkTagResponse>)> {
|
||||
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<NaiveDateTime>)> = 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<Tag> = 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<AppState>,
|
||||
Path((document_id, tag_id)): Path<(Uuid, Uuid)>,
|
||||
@@ -627,6 +858,8 @@ async fn process_upload(state: &AppState, request: UploadRequest) -> AppResult<U
|
||||
content_type: content_type.clone(),
|
||||
folder_id,
|
||||
current_version: version_number,
|
||||
issued_at: None,
|
||||
title: derive_document_title(&original_name),
|
||||
metadata: metadata_value.clone(),
|
||||
};
|
||||
diesel::insert_into(documents::table)
|
||||
@@ -793,6 +1026,7 @@ pub(crate) fn to_document_response(
|
||||
DocumentResponse {
|
||||
id: doc.id,
|
||||
filename: doc.filename,
|
||||
title: doc.title,
|
||||
original_name: doc.original_name,
|
||||
content_type: doc.content_type,
|
||||
folder_id: doc.folder_id,
|
||||
@@ -800,6 +1034,7 @@ pub(crate) fn to_document_response(
|
||||
uploaded_at: to_iso(doc.uploaded_at),
|
||||
updated_at: to_iso(doc.updated_at),
|
||||
deleted_at: doc.deleted_at.map(to_iso),
|
||||
issued_at: doc.issued_at.map(to_iso),
|
||||
metadata: doc.metadata,
|
||||
tags: tags
|
||||
.unwrap_or_default()
|
||||
@@ -834,6 +1069,22 @@ fn to_asset_response(asset: DocumentAsset, url: String) -> 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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -53,6 +53,9 @@ diesel::table! {
|
||||
updated_at -> Timestamptz,
|
||||
deleted_at -> Nullable<Timestamptz>,
|
||||
metadata -> Jsonb,
|
||||
issued_at -> Nullable<Timestamptz>,
|
||||
#[max_length = 255]
|
||||
title -> Varchar,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String>,
|
||||
tags: Vec<Value>,
|
||||
issued_at: Option<String>,
|
||||
tags: Vec<TagSummary>,
|
||||
}
|
||||
|
||||
#[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<DocumentListItem>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TagResponse {
|
||||
id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct BulkMoveRequest<'a> {
|
||||
document_ids: &'a [Uuid],
|
||||
folder_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[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<Uuid>,
|
||||
}
|
||||
|
||||
#[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(())
|
||||
}
|
||||
|
||||
+1276
-261
File diff suppressed because it is too large
Load Diff
+138
-22
@@ -109,6 +109,17 @@ button.icon-button.ghost:hover:not([disabled]) {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.icon path {
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.6;
|
||||
@@ -125,9 +136,9 @@ button.icon-button.ghost:hover:not([disabled]) {
|
||||
|
||||
.app-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.6rem 1.5rem;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
padding: 0.6rem 1.5rem 0.4rem;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky;
|
||||
@@ -135,6 +146,12 @@ button.icon-button.ghost:hover:not([disabled]) {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.app-bar__main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.app-bar__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -158,9 +175,13 @@ button.icon-button.ghost:hover:not([disabled]) {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.status-container {
|
||||
padding: 0.4rem 1.5rem 0;
|
||||
flex-shrink: 0;
|
||||
.app-bar__status {
|
||||
margin: 0 1rem;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.app-bar__status .status-banner {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
@@ -469,12 +490,9 @@ button.icon-button.ghost:hover:not([disabled]) {
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
.documents-panel thead {
|
||||
background: transparent;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.documents-panel thead th {
|
||||
border-bottom: 1px solid var(--border);
|
||||
background-color: var(--bg);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
@@ -565,6 +583,33 @@ button.icon-button.ghost:hover:not([disabled]) {
|
||||
box-shadow: inset 2px 0 0 rgba(43, 92, 255, 0.9);
|
||||
}
|
||||
|
||||
.documents-panel tbody tr.document,
|
||||
.documents-panel tbody tr.document * {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
}
|
||||
|
||||
.documents-panel tbody tr.document.focused:not(.selected) {
|
||||
box-shadow: inset 2px 0 0 rgba(43, 92, 255, 0.45);
|
||||
}
|
||||
|
||||
.doc-name {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.doc-name__title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.doc-name__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.documents-panel tbody tr.document.dragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
@@ -660,21 +705,14 @@ button.icon-button.ghost:hover:not([disabled]) {
|
||||
|
||||
.preview-pane {
|
||||
margin-top: 0.4rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
background: var(--surface-subtle);
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
min-height: 220px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.preview-object {
|
||||
width: 100%;
|
||||
height: 320px;
|
||||
border: none;
|
||||
background: var(--surface);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.thumbnail-preview {
|
||||
@@ -695,6 +733,84 @@ button.icon-button.ghost:hover:not([disabled]) {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.bulk-detail-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin: 0.6rem 0;
|
||||
}
|
||||
|
||||
.bulk-detail-actions .inline {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.bulk-move {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
margin: 0.6rem 0;
|
||||
}
|
||||
|
||||
.bulk-move select {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.preview-stack {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.preview-stack--stacked {
|
||||
width: 100%;
|
||||
min-height: 420px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.preview-stack__item {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transition: transform 120ms ease;
|
||||
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.18));
|
||||
transform-origin: center;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
.preview-stack .preview-stack__item.orientation-portrait {
|
||||
width: 80%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.preview-stack .preview-stack__item.orientation-landscape {
|
||||
width: 100%;
|
||||
height: 80%;
|
||||
}
|
||||
|
||||
.preview-stack__image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.preview-pane--stack {
|
||||
min-height: 260px;
|
||||
}
|
||||
|
||||
.bulk-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
margin: 0.4rem 0 0.6rem;
|
||||
}
|
||||
|
||||
.detail-panel dl {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user