Compare commits
26
Commits
48bfa1d06a
..
ai
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5091cd005e | ||
|
|
82aa8948cf | ||
|
|
84a3a9a5b5 | ||
|
|
b260065245 | ||
|
|
264316eb29 | ||
|
|
c625c80958 | ||
|
|
6a04d29eed | ||
|
|
10e28cc5e2 | ||
|
|
970678986a | ||
|
|
40e45f3a01 | ||
|
|
518b79bec6 | ||
|
|
01fb4e308a | ||
|
|
b6300ffa30 | ||
|
|
b163a07e84 | ||
|
|
157f03b655 | ||
|
|
42a3a53314 | ||
|
|
9b7ca3d692 | ||
|
|
e51a59a829 | ||
|
|
4a06fa82eb | ||
|
|
80238bb7a1 | ||
|
|
dcbd46531e | ||
|
|
0864b39336 | ||
|
|
62cadbcfa0 | ||
|
|
d7aefc4110 | ||
|
|
e972a8dddb | ||
|
|
88b9375a4e |
Generated
+87
-25
@@ -67,7 +67,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -635,6 +635,7 @@ dependencies = [
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
"utoipa",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@@ -704,7 +705,7 @@ dependencies = [
|
||||
"regex",
|
||||
"rustc-hash",
|
||||
"shlex",
|
||||
"syn",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -989,7 +990,7 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1000,7 +1001,7 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1050,7 +1051,7 @@ dependencies = [
|
||||
"dsl_auto_type",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1070,7 +1071,7 @@ version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe2444076b48641147115697648dc743c2c00b61adade0f01ce67133c7babe8c"
|
||||
dependencies = [
|
||||
"syn",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1092,7 +1093,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1118,7 +1119,7 @@ dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1287,7 +1288,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1812,6 +1813,8 @@ checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.16.0",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2267,7 +2270,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2359,7 +2362,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-error"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
|
||||
dependencies = [
|
||||
"proc-macro-error-attr",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-error-attr"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2894,7 +2921,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3082,6 +3109,16 @@ version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "1.0.109"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.106"
|
||||
@@ -3110,7 +3147,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3152,7 +3189,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3163,7 +3200,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3259,7 +3296,7 @@ checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3409,7 +3446,7 @@ checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3514,6 +3551,31 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "utoipa"
|
||||
version = "4.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c5afb1a60e207dca502682537fefcfd9921e71d0b83e9576060f09abc6efab23"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"utoipa-gen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utoipa-gen"
|
||||
version = "4.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20c24e8ab68ff9ee746aad22d39b5535601e6416d1b0feeabf78be986a5c4392"
|
||||
dependencies = [
|
||||
"proc-macro-error",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.18.1"
|
||||
@@ -3615,7 +3677,7 @@ dependencies = [
|
||||
"log",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.106",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
@@ -3650,7 +3712,7 @@ checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.106",
|
||||
"wasm-bindgen-backend",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
@@ -3727,7 +3789,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3738,7 +3800,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3965,7 +4027,7 @@ checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.106",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
@@ -3986,7 +4048,7 @@ checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4006,7 +4068,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.106",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
@@ -4046,7 +4108,7 @@ checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -45,6 +45,7 @@ quick-xml = "0.32"
|
||||
futures-util = "0.3"
|
||||
url = "2.5"
|
||||
once_cell = "1.19"
|
||||
utoipa = { version = "4.2", default-features = false, features = ["chrono", "uuid", "preserve_order"] }
|
||||
|
||||
# Error handling
|
||||
thiserror = "1.0"
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
DROP INDEX IF EXISTS folders_tenant_parent_name_unique_idx;
|
||||
CREATE UNIQUE INDEX folders_parent_name_unique_idx
|
||||
ON folders (
|
||||
COALESCE(parent_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
name
|
||||
);
|
||||
@@ -0,0 +1,7 @@
|
||||
DROP INDEX IF EXISTS folders_parent_name_unique_idx;
|
||||
CREATE UNIQUE INDEX folders_tenant_parent_name_unique_idx
|
||||
ON folders (
|
||||
tenant_id,
|
||||
COALESCE(parent_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
name
|
||||
);
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Revert correspondent uniqueness to global name
|
||||
DROP INDEX IF EXISTS correspondents_tenant_name_unique;
|
||||
ALTER TABLE correspondents ADD CONSTRAINT correspondents_name_unique UNIQUE (name);
|
||||
|
||||
-- Revert tag uniqueness to global label
|
||||
DROP INDEX IF EXISTS tags_tenant_label_unique;
|
||||
ALTER TABLE tags ADD CONSTRAINT tags_label_key UNIQUE (label);
|
||||
|
||||
-- Revert document filename uniqueness to global folder scope
|
||||
DROP INDEX IF EXISTS documents_tenant_folder_filename_unique;
|
||||
CREATE UNIQUE INDEX documents_unique_folder_filename
|
||||
ON documents (
|
||||
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
filename
|
||||
)
|
||||
WHERE deleted_at IS NULL;
|
||||
@@ -0,0 +1,20 @@
|
||||
-- Ensure document filenames are unique per tenant + folder
|
||||
DROP INDEX IF EXISTS documents_tenant_folder_filename_unique;
|
||||
DROP INDEX IF EXISTS documents_unique_folder_filename;
|
||||
CREATE UNIQUE INDEX documents_tenant_folder_filename_unique
|
||||
ON documents (
|
||||
tenant_id,
|
||||
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
filename
|
||||
)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
-- Ensure tag labels are unique per tenant
|
||||
ALTER TABLE tags DROP CONSTRAINT IF EXISTS tags_label_key;
|
||||
DROP INDEX IF EXISTS tags_tenant_label_unique;
|
||||
CREATE UNIQUE INDEX tags_tenant_label_unique ON tags (tenant_id, label);
|
||||
|
||||
-- Ensure correspondent names are unique per tenant
|
||||
ALTER TABLE correspondents DROP CONSTRAINT IF EXISTS correspondents_name_unique;
|
||||
DROP INDEX IF EXISTS correspondents_tenant_name_unique;
|
||||
CREATE UNIQUE INDEX correspondents_tenant_name_unique ON correspondents (tenant_id, name);
|
||||
@@ -0,0 +1,8 @@
|
||||
use backend::openapi::ApiDoc;
|
||||
use utoipa::OpenApi;
|
||||
|
||||
fn main() {
|
||||
let spec = ApiDoc::openapi();
|
||||
let json = serde_json::to_string_pretty(&spec).expect("serialize openapi");
|
||||
println!("{}", json);
|
||||
}
|
||||
@@ -133,8 +133,12 @@ impl AppConfig {
|
||||
fn redact_database_url(raw: &str) -> String {
|
||||
match Url::parse(raw) {
|
||||
Ok(mut parsed) => {
|
||||
let _ = parsed.set_password(Some("*****"));
|
||||
parsed.to_string()
|
||||
if parsed.password().is_some() {
|
||||
let _ = parsed.set_password(Some("*****"));
|
||||
parsed.to_string()
|
||||
} else {
|
||||
raw.to_string()
|
||||
}
|
||||
}
|
||||
Err(_) => "***".to_string(),
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ pub type AppResult<T> = Result<T, AppError>;
|
||||
pub struct AppError {
|
||||
status: StatusCode,
|
||||
message: String,
|
||||
code: Option<String>,
|
||||
}
|
||||
|
||||
impl AppError {
|
||||
@@ -19,6 +20,7 @@ impl AppError {
|
||||
Self {
|
||||
status,
|
||||
message: message.into(),
|
||||
code: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +28,10 @@ impl AppError {
|
||||
Self::new(StatusCode::BAD_REQUEST, message)
|
||||
}
|
||||
|
||||
pub fn conflict(message: impl Into<String>) -> Self {
|
||||
Self::new(StatusCode::CONFLICT, message)
|
||||
}
|
||||
|
||||
pub fn unauthorized() -> Self {
|
||||
Self::new(StatusCode::UNAUTHORIZED, "unauthorized")
|
||||
}
|
||||
@@ -37,6 +43,11 @@ impl AppError {
|
||||
pub fn internal<E: Display>(error: E) -> Self {
|
||||
Self::new(StatusCode::INTERNAL_SERVER_ERROR, error.to_string())
|
||||
}
|
||||
|
||||
pub fn with_code(mut self, code: impl Into<String>) -> Self {
|
||||
self.code = Some(code.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
@@ -44,6 +55,7 @@ impl IntoResponse for AppError {
|
||||
let status = self.status;
|
||||
let body = Json(ErrorResponse {
|
||||
error: self.message,
|
||||
code: self.code,
|
||||
});
|
||||
(status, body).into_response()
|
||||
}
|
||||
@@ -52,6 +64,8 @@ impl IntoResponse for AppError {
|
||||
#[derive(Serialize)]
|
||||
struct ErrorResponse {
|
||||
error: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
code: Option<String>,
|
||||
}
|
||||
|
||||
impl From<diesel::result::Error> for AppError {
|
||||
|
||||
@@ -4,6 +4,7 @@ pub mod db;
|
||||
pub mod error;
|
||||
pub mod jobs;
|
||||
pub mod models;
|
||||
pub mod openapi;
|
||||
pub mod routes;
|
||||
pub mod s3;
|
||||
pub mod schema;
|
||||
|
||||
@@ -0,0 +1,958 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use utoipa::{IntoParams, OpenApi, ToSchema};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
paths(
|
||||
doc::health_check,
|
||||
doc::login,
|
||||
doc::refresh,
|
||||
doc::logout,
|
||||
doc::me,
|
||||
doc::select_tenant,
|
||||
doc::list_documents,
|
||||
doc::check_document,
|
||||
doc::upload_document,
|
||||
doc::get_document,
|
||||
doc::update_document,
|
||||
doc::delete_document,
|
||||
doc::download_document,
|
||||
doc::download_with_token,
|
||||
doc::move_document,
|
||||
doc::assign_tags,
|
||||
doc::remove_tag,
|
||||
doc::bulk_move_documents,
|
||||
doc::bulk_update_tags,
|
||||
doc::bulk_assign_correspondents,
|
||||
doc::assign_correspondents,
|
||||
doc::remove_correspondent,
|
||||
doc::reanalyze_selected_documents,
|
||||
doc::list_document_assets,
|
||||
doc::request_document_assets,
|
||||
doc::get_document_asset,
|
||||
doc::create_folder,
|
||||
doc::ensure_folder_path,
|
||||
doc::get_folder,
|
||||
doc::list_folder_contents,
|
||||
doc::delete_folder,
|
||||
doc::update_folder,
|
||||
doc::list_tags,
|
||||
doc::create_tag,
|
||||
doc::update_tag,
|
||||
doc::delete_tag,
|
||||
doc::list_correspondents,
|
||||
doc::create_correspondent,
|
||||
doc::update_correspondent,
|
||||
doc::delete_correspondent,
|
||||
),
|
||||
components(
|
||||
schemas(
|
||||
schemas::LoginRequest,
|
||||
schemas::AccessTokenResponse,
|
||||
schemas::TenantSummary,
|
||||
schemas::TenantSelectionResponse,
|
||||
schemas::TenantSelectionRequest,
|
||||
schemas::LoginResponseVariants,
|
||||
schemas::DocumentResponse,
|
||||
schemas::DocumentDetailResponse,
|
||||
schemas::DocumentVersion,
|
||||
schemas::DocumentAssetSummary,
|
||||
schemas::DocumentAssetDetail,
|
||||
schemas::DocumentAssetObject,
|
||||
schemas::DocumentCorrespondent,
|
||||
schemas::DocumentTag,
|
||||
schemas::DocumentDownloadResponse,
|
||||
schemas::UpdateDocumentRequest,
|
||||
schemas::BulkMoveDocumentsRequest,
|
||||
schemas::BulkMoveDocumentsResponse,
|
||||
schemas::AssignTagsRequest,
|
||||
schemas::MoveDocumentRequest,
|
||||
schemas::BulkTagRequest,
|
||||
schemas::BulkTagResponse,
|
||||
schemas::CorrespondentAssignment,
|
||||
schemas::BulkTagAction,
|
||||
schemas::BulkCorrespondentsRequest,
|
||||
schemas::BulkCorrespondentsResponse,
|
||||
schemas::BulkCorrespondentAction,
|
||||
schemas::AssignCorrespondentsRequest,
|
||||
schemas::RemoveCorrespondentParams,
|
||||
schemas::ReanalyzeRequest,
|
||||
schemas::ReanalyzeResponse,
|
||||
schemas::DocumentAssetRequestParams,
|
||||
schemas::AssetObjectsQuery,
|
||||
schemas::DocumentCheckQuery,
|
||||
schemas::DocumentCheckResponse,
|
||||
schemas::UploadDocumentForm,
|
||||
schemas::CreateFolderRequest,
|
||||
schemas::EnsureFolderPathRequest,
|
||||
schemas::FolderResponse,
|
||||
schemas::FolderInfo,
|
||||
schemas::FolderContentsResponse,
|
||||
schemas::FolderDocumentSummary,
|
||||
schemas::UpdateFolderRequest,
|
||||
schemas::FolderContentsParams,
|
||||
schemas::TagCatalogEntry,
|
||||
schemas::CreateTagRequest,
|
||||
schemas::UpdateTagRequest,
|
||||
schemas::CorrespondentCatalogEntry,
|
||||
schemas::CreateCorrespondentRequest,
|
||||
schemas::UpdateCorrespondentRequest,
|
||||
)
|
||||
),
|
||||
tags(
|
||||
(name = "Health", description = "Service health"),
|
||||
(name = "Auth", description = "Authentication"),
|
||||
(name = "Documents", description = "Document management"),
|
||||
(name = "Assets", description = "Document assets"),
|
||||
(name = "Folders", description = "Folder management"),
|
||||
(name = "Tags", description = "Tag catalog"),
|
||||
(name = "Correspondents", description = "Correspondent catalog")
|
||||
)
|
||||
)]
|
||||
pub struct ApiDoc;
|
||||
|
||||
#[allow(dead_code)]
|
||||
mod doc {
|
||||
use super::schemas::*;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn __keep_uuid_import() {
|
||||
let _ = Uuid::nil();
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/health",
|
||||
responses((status = 200, description = "Service is healthy")),
|
||||
tag = "Health"
|
||||
)]
|
||||
pub(super) fn health_check() {}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/auth/login",
|
||||
request_body = LoginRequest,
|
||||
responses(
|
||||
(status = 200, description = "Login succeeded", body = LoginResponseVariants),
|
||||
(status = 401, description = "Invalid credentials")
|
||||
),
|
||||
tag = "Auth"
|
||||
)]
|
||||
pub(super) fn login() {}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/auth/refresh",
|
||||
responses(
|
||||
(status = 200, description = "Refreshed access token", body = AccessTokenResponse),
|
||||
(status = 401, description = "Missing or invalid refresh token")
|
||||
),
|
||||
tag = "Auth"
|
||||
)]
|
||||
pub(super) fn refresh() {}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/auth/logout",
|
||||
responses((status = 204, description = "Session revoked")),
|
||||
tag = "Auth"
|
||||
)]
|
||||
pub(super) fn logout() {}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/auth/me",
|
||||
responses((status = 200, description = "Authenticated principal", body = AccessTokenResponse)),
|
||||
tag = "Auth"
|
||||
)]
|
||||
pub(super) fn me() {}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/auth/select-tenant",
|
||||
request_body = TenantSelectionRequest,
|
||||
responses((status = 200, description = "Tenant selected", body = AccessTokenResponse)),
|
||||
tag = "Auth"
|
||||
)]
|
||||
pub(super) fn select_tenant() {}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/documents",
|
||||
params(DocumentListQuery),
|
||||
responses((status = 200, description = "List documents", body = [DocumentResponse])),
|
||||
tag = "Documents"
|
||||
)]
|
||||
pub(super) fn list_documents() {}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/documents",
|
||||
request_body = UploadDocumentForm,
|
||||
responses(
|
||||
(status = 201, description = "Document created", body = DocumentDetailResponse),
|
||||
(status = 200, description = "Existing document reused", body = DocumentDetailResponse),
|
||||
(status = 204, description = "Upload skipped because the document already exists")
|
||||
),
|
||||
tag = "Documents"
|
||||
)]
|
||||
pub(super) fn upload_document() {}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/documents/check",
|
||||
params(DocumentCheckQuery),
|
||||
responses((status = 200, description = "Checksum lookup", body = DocumentCheckResponse)),
|
||||
tag = "Documents"
|
||||
)]
|
||||
pub(super) fn check_document() {}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/documents/{id}",
|
||||
params(("id" = Uuid, Path, description = "Document ID")),
|
||||
responses((status = 200, description = "Document detail", body = DocumentDetailResponse)),
|
||||
tag = "Documents"
|
||||
)]
|
||||
pub(super) fn get_document() {}
|
||||
|
||||
#[utoipa::path(
|
||||
patch,
|
||||
path = "/api/documents/{id}",
|
||||
params(("id" = Uuid, Path, description = "Document ID")),
|
||||
request_body = UpdateDocumentRequest,
|
||||
responses((status = 200, description = "Updated document", body = DocumentDetailResponse)),
|
||||
tag = "Documents"
|
||||
)]
|
||||
pub(super) fn update_document() {}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/documents/{id}",
|
||||
params(("id" = Uuid, Path, description = "Document ID")),
|
||||
responses((status = 204, description = "Document deleted")),
|
||||
tag = "Documents"
|
||||
)]
|
||||
pub(super) fn delete_document() {}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/documents/{id}/download",
|
||||
params(("id" = Uuid, Path, description = "Document ID")),
|
||||
responses((status = 200, description = "Download metadata", body = DocumentDownloadResponse)),
|
||||
tag = "Documents"
|
||||
)]
|
||||
pub(super) fn download_document() {}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/download/{token}",
|
||||
params(("token" = String, Path, description = "Download token")),
|
||||
responses((status = 302, description = "Redirect to pre-signed URL")),
|
||||
tag = "Documents"
|
||||
)]
|
||||
pub(super) fn download_with_token() {}
|
||||
|
||||
#[utoipa::path(
|
||||
patch,
|
||||
path = "/api/documents/{id}/folder",
|
||||
params(("id" = Uuid, Path, description = "Document ID")),
|
||||
request_body = MoveDocumentRequest,
|
||||
responses((status = 204, description = "Document moved")),
|
||||
tag = "Documents"
|
||||
)]
|
||||
pub(super) fn move_document() {}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/documents/{id}/tags",
|
||||
params(("id" = Uuid, Path, description = "Document ID")),
|
||||
request_body = AssignTagsRequest,
|
||||
responses((status = 204, description = "Tags assigned")),
|
||||
tag = "Documents"
|
||||
)]
|
||||
pub(super) fn assign_tags() {}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/documents/{id}/tags/{tag_id}",
|
||||
params(
|
||||
("id" = Uuid, Path, description = "Document ID"),
|
||||
("tag_id" = Uuid, Path, description = "Tag ID")
|
||||
),
|
||||
responses((status = 204, description = "Tag removed")),
|
||||
tag = "Documents"
|
||||
)]
|
||||
pub(super) fn remove_tag() {}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/documents/bulk/move",
|
||||
request_body = BulkMoveDocumentsRequest,
|
||||
responses((status = 200, description = "Bulk move outcome", body = BulkMoveDocumentsResponse)),
|
||||
tag = "Documents"
|
||||
)]
|
||||
pub(super) fn bulk_move_documents() {}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/documents/bulk/tags",
|
||||
request_body = BulkTagRequest,
|
||||
responses((status = 200, description = "Bulk tag outcome", body = BulkTagResponse)),
|
||||
tag = "Documents"
|
||||
)]
|
||||
pub(super) fn bulk_update_tags() {}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/documents/bulk/correspondents",
|
||||
request_body = BulkCorrespondentsRequest,
|
||||
responses((status = 200, description = "Bulk correspondents outcome", body = BulkCorrespondentsResponse)),
|
||||
tag = "Documents"
|
||||
)]
|
||||
pub(super) fn bulk_assign_correspondents() {}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/documents/{id}/correspondents",
|
||||
params(("id" = Uuid, Path, description = "Document ID")),
|
||||
request_body = AssignCorrespondentsRequest,
|
||||
responses((status = 204, description = "Correspondents assigned")),
|
||||
tag = "Documents"
|
||||
)]
|
||||
pub(super) fn assign_correspondents() {}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/documents/{id}/correspondents/{correspondent_id}",
|
||||
params(
|
||||
("id" = Uuid, Path, description = "Document ID"),
|
||||
("correspondent_id" = Uuid, Path, description = "Correspondent ID"),
|
||||
RemoveCorrespondentParams
|
||||
),
|
||||
responses((status = 204, description = "Correspondent removed")),
|
||||
tag = "Documents"
|
||||
)]
|
||||
pub(super) fn remove_correspondent() {}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/documents/bulk/reanalyze",
|
||||
request_body = ReanalyzeRequest,
|
||||
responses((status = 200, description = "Reanalyze queued", body = ReanalyzeResponse)),
|
||||
tag = "Documents"
|
||||
)]
|
||||
pub(super) fn reanalyze_selected_documents() {}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/documents/{id}/assets",
|
||||
params(("id" = Uuid, Path, description = "Document ID")),
|
||||
responses((status = 200, description = "Document assets", body = [DocumentAssetSummary])),
|
||||
tag = "Assets"
|
||||
)]
|
||||
pub(super) fn list_document_assets() {}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/documents/{id}/assets",
|
||||
params(
|
||||
("id" = Uuid, Path, description = "Document ID"),
|
||||
DocumentAssetRequestParams
|
||||
),
|
||||
responses((status = 202, description = "Asset generation requested")),
|
||||
tag = "Assets"
|
||||
)]
|
||||
pub(super) fn request_document_assets() {}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/assets/{asset_id}",
|
||||
params(
|
||||
("asset_id" = Uuid, Path, description = "Asset ID"),
|
||||
AssetObjectsQuery
|
||||
),
|
||||
responses((status = 200, description = "Asset detail", body = DocumentAssetDetail)),
|
||||
tag = "Assets"
|
||||
)]
|
||||
pub(super) fn get_document_asset() {}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/folders",
|
||||
request_body = CreateFolderRequest,
|
||||
responses((status = 200, description = "Folder created", body = FolderResponse)),
|
||||
tag = "Folders"
|
||||
)]
|
||||
pub(super) fn create_folder() {}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/folders/path",
|
||||
request_body = EnsureFolderPathRequest,
|
||||
responses((status = 200, description = "Folder path ensured", body = FolderResponse)),
|
||||
tag = "Folders"
|
||||
)]
|
||||
pub(super) fn ensure_folder_path() {}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/folders/{id}",
|
||||
params(("id" = Uuid, Path, description = "Folder ID")),
|
||||
responses((status = 200, description = "Folder detail", body = FolderResponse)),
|
||||
tag = "Folders"
|
||||
)]
|
||||
pub(super) fn get_folder() {}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/folders/{id}/contents",
|
||||
params(
|
||||
("id" = Uuid, Path, description = "Folder ID"),
|
||||
FolderContentsParams
|
||||
),
|
||||
responses((status = 200, description = "Folder contents", body = FolderContentsResponse)),
|
||||
tag = "Folders"
|
||||
)]
|
||||
pub(super) fn list_folder_contents() {}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/folders/{id}",
|
||||
params(("id" = Uuid, Path, description = "Folder ID")),
|
||||
responses((status = 204, description = "Folder deleted")),
|
||||
tag = "Folders"
|
||||
)]
|
||||
pub(super) fn delete_folder() {}
|
||||
|
||||
#[utoipa::path(
|
||||
patch,
|
||||
path = "/api/folders/{id}",
|
||||
params(("id" = Uuid, Path, description = "Folder ID")),
|
||||
request_body = UpdateFolderRequest,
|
||||
responses((status = 204, description = "Folder updated")),
|
||||
tag = "Folders"
|
||||
)]
|
||||
pub(super) fn update_folder() {}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/tags",
|
||||
responses((status = 200, description = "Tags", body = [TagCatalogEntry])),
|
||||
tag = "Tags"
|
||||
)]
|
||||
pub(super) fn list_tags() {}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/tags",
|
||||
request_body = CreateTagRequest,
|
||||
responses((status = 200, description = "Tag created", body = TagCatalogEntry)),
|
||||
tag = "Tags"
|
||||
)]
|
||||
pub(super) fn create_tag() {}
|
||||
|
||||
#[utoipa::path(
|
||||
patch,
|
||||
path = "/api/tags/{id}",
|
||||
params(("id" = Uuid, Path, description = "Tag ID")),
|
||||
request_body = UpdateTagRequest,
|
||||
responses((status = 200, description = "Tag updated", body = TagCatalogEntry)),
|
||||
tag = "Tags"
|
||||
)]
|
||||
pub(super) fn update_tag() {}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/tags/{id}",
|
||||
params(("id" = Uuid, Path, description = "Tag ID")),
|
||||
responses((status = 204, description = "Tag deleted")),
|
||||
tag = "Tags"
|
||||
)]
|
||||
pub(super) fn delete_tag() {}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/correspondents",
|
||||
responses((status = 200, description = "Correspondents", body = [CorrespondentCatalogEntry])),
|
||||
tag = "Correspondents"
|
||||
)]
|
||||
pub(super) fn list_correspondents() {}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/correspondents",
|
||||
request_body = CreateCorrespondentRequest,
|
||||
responses((status = 200, description = "Correspondent created", body = CorrespondentCatalogEntry)),
|
||||
tag = "Correspondents"
|
||||
)]
|
||||
pub(super) fn create_correspondent() {}
|
||||
|
||||
#[utoipa::path(
|
||||
patch,
|
||||
path = "/api/correspondents/{id}",
|
||||
params(("id" = Uuid, Path, description = "Correspondent ID")),
|
||||
request_body = UpdateCorrespondentRequest,
|
||||
responses((status = 200, description = "Correspondent updated", body = CorrespondentCatalogEntry)),
|
||||
tag = "Correspondents"
|
||||
)]
|
||||
pub(super) fn update_correspondent() {}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/correspondents/{id}",
|
||||
params(("id" = Uuid, Path, description = "Correspondent ID")),
|
||||
responses((status = 204, description = "Correspondent deleted")),
|
||||
tag = "Correspondents"
|
||||
)]
|
||||
pub(super) fn delete_correspondent() {}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::ApiDoc;
|
||||
use utoipa::OpenApi;
|
||||
|
||||
#[test]
|
||||
fn openapi_serializes() {
|
||||
let spec = ApiDoc::openapi();
|
||||
let _ = serde_json::to_string(&spec).expect("serialize openapi");
|
||||
}
|
||||
}
|
||||
|
||||
pub mod schemas {
|
||||
use super::*;
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct LoginRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
#[schema(nullable)]
|
||||
pub preferred_tenant_slug: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct AccessTokenResponse {
|
||||
pub access_token: String,
|
||||
pub token_type: String,
|
||||
pub expires_in: i64,
|
||||
pub tenant: TenantSnippet,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct TenantSummary {
|
||||
pub tenant_id: Uuid,
|
||||
pub slug: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct TenantSnippet {
|
||||
pub id: Uuid,
|
||||
pub slug: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct TenantSelectionResponse {
|
||||
pub access_token: String,
|
||||
pub tenants: Vec<TenantSummary>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct TenantSelectionRequest {
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum LoginResponseVariants {
|
||||
Token(AccessTokenResponse),
|
||||
Selection(TenantSelectionResponse),
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, IntoParams, ToSchema)]
|
||||
#[into_params(parameter_in = Query)]
|
||||
pub struct DocumentListQuery {
|
||||
pub folder_id: Option<Uuid>,
|
||||
pub include_deleted: Option<bool>,
|
||||
pub include_descendants: Option<bool>,
|
||||
pub query: Option<String>,
|
||||
pub tags: Option<String>,
|
||||
pub correspondents: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct DocumentTag {
|
||||
pub id: Uuid,
|
||||
pub label: String,
|
||||
#[schema(nullable)]
|
||||
pub color: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct DocumentAssetObject {
|
||||
pub id: Uuid,
|
||||
pub ordinal: i32,
|
||||
pub metadata: Value,
|
||||
#[schema(nullable)]
|
||||
pub url: Option<String>,
|
||||
#[schema(nullable)]
|
||||
pub expires_at: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct DocumentAssetSummary {
|
||||
pub id: Uuid,
|
||||
pub asset_type: String,
|
||||
pub mime_type: String,
|
||||
pub metadata: Value,
|
||||
#[schema(nullable)]
|
||||
pub cardinality: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct DocumentAssetDetail {
|
||||
pub id: Uuid,
|
||||
pub asset_type: String,
|
||||
pub mime_type: String,
|
||||
pub metadata: Value,
|
||||
pub created_at: String,
|
||||
#[schema(nullable)]
|
||||
pub cardinality: Option<i32>,
|
||||
pub objects: Vec<DocumentAssetObject>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct DocumentVersion {
|
||||
pub id: Uuid,
|
||||
pub version_number: i32,
|
||||
pub checksum: String,
|
||||
pub size_bytes: i64,
|
||||
pub created_at: String,
|
||||
pub metadata: Value,
|
||||
#[schema(nullable)]
|
||||
pub operations_summary: Option<Value>,
|
||||
#[schema(nullable)]
|
||||
pub assets: Option<Vec<DocumentAssetSummary>>,
|
||||
pub download_path: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct DocumentCorrespondent {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub role: String,
|
||||
pub metadata: Value,
|
||||
pub assigned_at: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct DocumentResponse {
|
||||
pub id: Uuid,
|
||||
pub filename: String,
|
||||
pub title: String,
|
||||
pub original_name: String,
|
||||
#[schema(nullable)]
|
||||
pub content_type: Option<String>,
|
||||
#[schema(nullable)]
|
||||
pub folder_id: Option<Uuid>,
|
||||
pub uploaded_at: String,
|
||||
pub updated_at: String,
|
||||
#[schema(nullable)]
|
||||
pub deleted_at: Option<String>,
|
||||
#[schema(nullable)]
|
||||
pub issued_at: Option<String>,
|
||||
pub metadata: Value,
|
||||
pub tags: Vec<DocumentTag>,
|
||||
#[schema(nullable)]
|
||||
pub correspondents: Option<Vec<DocumentCorrespondent>>,
|
||||
#[schema(nullable)]
|
||||
pub current_version: Option<DocumentVersion>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct DocumentDetailResponse {
|
||||
pub document: DocumentResponse,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct DocumentDownloadResponse {
|
||||
pub url: String,
|
||||
pub expires_in: u64,
|
||||
pub filename: String,
|
||||
#[schema(nullable)]
|
||||
pub content_type: Option<String>,
|
||||
pub size_bytes: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct UpdateDocumentRequest {
|
||||
#[schema(nullable)]
|
||||
pub title: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct BulkMoveDocumentsRequest {
|
||||
pub document_ids: Vec<Uuid>,
|
||||
#[schema(nullable)]
|
||||
pub folder_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct BulkMoveDocumentsResponse {
|
||||
pub updated: usize,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct AssignTagsRequest {
|
||||
pub tag_ids: Vec<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct MoveDocumentRequest {
|
||||
#[schema(nullable)]
|
||||
pub folder_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BulkTagAction {
|
||||
Add,
|
||||
Remove,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct BulkTagRequest {
|
||||
pub document_ids: Vec<Uuid>,
|
||||
pub tag_ids: Vec<Uuid>,
|
||||
pub action: BulkTagAction,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct BulkTagResponse {
|
||||
pub added: usize,
|
||||
pub removed: usize,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct CorrespondentAssignment {
|
||||
pub correspondent_id: Uuid,
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct AssignCorrespondentsRequest {
|
||||
pub assignments: Vec<CorrespondentAssignment>,
|
||||
#[serde(default)]
|
||||
pub replace: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum BulkCorrespondentAction {
|
||||
Add,
|
||||
Remove,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct BulkCorrespondentsRequest {
|
||||
pub document_ids: Vec<Uuid>,
|
||||
pub assignments: Vec<CorrespondentAssignment>,
|
||||
#[serde(default = "default_bulk_correspondent_action")]
|
||||
pub action: BulkCorrespondentAction,
|
||||
}
|
||||
|
||||
fn default_bulk_correspondent_action() -> BulkCorrespondentAction {
|
||||
BulkCorrespondentAction::Add
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct BulkCorrespondentsResponse {
|
||||
pub assigned: usize,
|
||||
pub removed: usize,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, IntoParams, ToSchema)]
|
||||
#[into_params(parameter_in = Query)]
|
||||
pub struct RemoveCorrespondentParams {
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct ReanalyzeRequest {
|
||||
pub document_ids: Vec<Uuid>,
|
||||
#[serde(default)]
|
||||
pub force: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct ReanalyzeResponse {
|
||||
pub queued: usize,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, IntoParams, ToSchema)]
|
||||
#[into_params(parameter_in = Query)]
|
||||
pub struct DocumentAssetRequestParams {
|
||||
#[serde(default)]
|
||||
pub force: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, IntoParams, ToSchema)]
|
||||
#[into_params(parameter_in = Query)]
|
||||
pub struct AssetObjectsQuery {
|
||||
#[serde(default)]
|
||||
pub start: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub limit: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, IntoParams, ToSchema)]
|
||||
#[into_params(parameter_in = Query)]
|
||||
pub struct DocumentCheckQuery {
|
||||
pub checksum: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct DocumentCheckResponse {
|
||||
pub exists: bool,
|
||||
#[schema(nullable)]
|
||||
pub document_id: Option<Uuid>,
|
||||
#[schema(nullable)]
|
||||
pub title: Option<String>,
|
||||
#[schema(nullable)]
|
||||
pub filename: Option<String>,
|
||||
#[schema(nullable)]
|
||||
pub version_id: Option<Uuid>,
|
||||
#[schema(nullable)]
|
||||
pub version_number: Option<i32>,
|
||||
#[schema(nullable)]
|
||||
pub uploaded_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct UploadDocumentForm {
|
||||
#[schema(value_type = String, format = Binary)]
|
||||
pub file: String,
|
||||
#[schema(nullable)]
|
||||
pub folder_id: Option<Uuid>,
|
||||
#[schema(nullable)]
|
||||
pub metadata: Option<Value>,
|
||||
#[schema(nullable)]
|
||||
pub title: Option<String>,
|
||||
#[schema(nullable, value_type = Vec<Uuid>)]
|
||||
pub tag_ids: Option<Vec<Uuid>>,
|
||||
#[schema(nullable, value_type = Vec<CorrespondentAssignment>)]
|
||||
pub correspondents: Option<Vec<CorrespondentAssignment>>,
|
||||
#[schema(nullable, example = "2024-01-01T00:00:00Z")]
|
||||
pub issued_at: Option<String>,
|
||||
#[schema(nullable)]
|
||||
pub skip_existing: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct CreateFolderRequest {
|
||||
pub name: String,
|
||||
#[schema(nullable)]
|
||||
pub parent_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct EnsureFolderPathRequest {
|
||||
#[schema(nullable)]
|
||||
pub parent_id: Option<Uuid>,
|
||||
pub segments: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct FolderInfo {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
#[schema(nullable)]
|
||||
pub parent_id: Option<Uuid>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct FolderResponse {
|
||||
pub folder: FolderInfo,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, IntoParams, ToSchema)]
|
||||
#[into_params(parameter_in = Query)]
|
||||
pub struct FolderContentsParams {
|
||||
#[serde(default)]
|
||||
pub include_documents: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct FolderDocumentSummary {
|
||||
pub id: Uuid,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct FolderContentsResponse {
|
||||
#[schema(nullable)]
|
||||
pub folder: Option<FolderInfo>,
|
||||
pub subfolders: Vec<FolderInfo>,
|
||||
pub documents: Vec<FolderDocumentSummary>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct UpdateFolderRequest {
|
||||
#[schema(nullable)]
|
||||
pub parent_id: Option<Option<Uuid>>,
|
||||
#[schema(nullable)]
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct TagCatalogEntry {
|
||||
pub id: Uuid,
|
||||
pub label: String,
|
||||
#[schema(nullable)]
|
||||
pub color: Option<String>,
|
||||
pub usage_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct CreateTagRequest {
|
||||
pub label: String,
|
||||
#[schema(nullable)]
|
||||
pub color: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct UpdateTagRequest {
|
||||
#[schema(nullable)]
|
||||
pub label: Option<Option<String>>,
|
||||
#[schema(nullable)]
|
||||
pub color: Option<Option<String>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct CorrespondentCatalogEntry {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub metadata: Value,
|
||||
pub role_counts: Value,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct CreateCorrespondentRequest {
|
||||
pub name: String,
|
||||
#[schema(default, value_type = Object)]
|
||||
pub metadata: Value,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct UpdateCorrespondentRequest {
|
||||
#[schema(nullable)]
|
||||
pub name: Option<String>,
|
||||
#[schema(nullable)]
|
||||
pub metadata: Option<Value>,
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ pub struct LoginResponse {
|
||||
pub access_token: String,
|
||||
pub token_type: String,
|
||||
pub expires_in: i64,
|
||||
pub tenant: TenantSnippet,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -51,12 +52,23 @@ pub struct TenantSummary {
|
||||
pub slug: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct TenantSnippet {
|
||||
pub id: Uuid,
|
||||
pub slug: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct TenantSelectionResponse {
|
||||
pub selection_token: String,
|
||||
pub access_token: String,
|
||||
pub tenants: Vec<TenantSummary>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct TenantListResponse {
|
||||
pub tenants: Vec<TenantSnippet>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct TenantSelectionRequest {
|
||||
pub tenant_id: Uuid,
|
||||
@@ -121,7 +133,7 @@ pub async fn login(
|
||||
.collect();
|
||||
|
||||
let response = Json(TenantSelectionResponse {
|
||||
selection_token,
|
||||
access_token: selection_token,
|
||||
tenants,
|
||||
})
|
||||
.into_response();
|
||||
@@ -250,6 +262,38 @@ pub async fn me(user: AuthenticatedUser) -> Json<AuthenticatedUser> {
|
||||
Json(user)
|
||||
}
|
||||
|
||||
pub async fn list_tenants(
|
||||
State(state): State<AppState>,
|
||||
auth: Option<TypedHeader<Authorization<Bearer>>>,
|
||||
) -> AppResult<Json<TenantListResponse>> {
|
||||
let bearer = auth.ok_or_else(AppError::unauthorized)?;
|
||||
let token = bearer.token();
|
||||
|
||||
let user_id = match state.jwt.verify_token(token) {
|
||||
Ok(claims) => claims.sub,
|
||||
Err(_) => {
|
||||
let claims = state
|
||||
.jwt
|
||||
.verify_tenant_selector_token(token)
|
||||
.map_err(|_| AppError::unauthorized())?;
|
||||
claims.sub
|
||||
}
|
||||
};
|
||||
|
||||
let mut conn = state.db_unscoped()?;
|
||||
|
||||
let tenants = memberships_dsl::user_memberships
|
||||
.inner_join(tenant_dsl::tenants)
|
||||
.filter(memberships_dsl::user_id.eq(user_id))
|
||||
.select((tenant_dsl::id, tenant_dsl::slug))
|
||||
.load::<(Uuid, String)>(&mut conn)?
|
||||
.into_iter()
|
||||
.map(|(id, slug)| TenantSnippet { id, slug })
|
||||
.collect();
|
||||
|
||||
Ok(Json(TenantListResponse { tenants }))
|
||||
}
|
||||
|
||||
fn issue_session(
|
||||
state: &AppState,
|
||||
conn: &mut PgConnection,
|
||||
@@ -262,6 +306,12 @@ fn issue_session(
|
||||
.generate_token(user.id, tenant_id, &user.username)
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
let tenant_slug: String = tenant_dsl::tenants
|
||||
.find(tenant_id)
|
||||
.select(tenant_dsl::slug)
|
||||
.first(conn)
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
let refresh_value = generate_refresh_token();
|
||||
let refresh_hash = hash_refresh_token(&refresh_value);
|
||||
let refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days);
|
||||
@@ -283,6 +333,10 @@ fn issue_session(
|
||||
access_token,
|
||||
token_type: "Bearer".to_string(),
|
||||
expires_in: state.config.jwt_expiry_minutes * 60,
|
||||
tenant: TenantSnippet {
|
||||
id: tenant_id,
|
||||
slug: tenant_slug,
|
||||
},
|
||||
})
|
||||
.into_response();
|
||||
|
||||
|
||||
+577
-414
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,120 @@
|
||||
use std::path::Path as FsPath;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::models::{Document, DocumentAsset, DocumentAssetObject, DocumentVersion};
|
||||
use crate::state::AppState;
|
||||
use crate::utils::time::to_iso;
|
||||
|
||||
use super::{
|
||||
DocumentAssetDetailResponse, DocumentAssetObjectResponse, DocumentAssetResponse,
|
||||
DocumentVersionResponse,
|
||||
};
|
||||
|
||||
pub fn build_download_path(
|
||||
state: &AppState,
|
||||
document: &Document,
|
||||
user_id: Uuid,
|
||||
) -> AppResult<String> {
|
||||
state
|
||||
.jwt
|
||||
.generate_download_token(document.id, user_id, document.tenant_id)
|
||||
.map(|token| format!("/download/{token}"))
|
||||
.map_err(|err| AppError::internal(format!("failed to generate download token: {err}")))
|
||||
}
|
||||
|
||||
pub fn to_version_response(
|
||||
version: DocumentVersion,
|
||||
include_operations_summary: bool,
|
||||
) -> DocumentVersionResponse {
|
||||
DocumentVersionResponse {
|
||||
id: version.id,
|
||||
version_number: version.version_number,
|
||||
s3_key: version.s3_key,
|
||||
size_bytes: version.size_bytes,
|
||||
checksum: version.checksum,
|
||||
created_at: to_iso(version.created_at),
|
||||
metadata: version.metadata,
|
||||
operations_summary: if include_operations_summary {
|
||||
Some(version.operations_summary)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_asset_summary(asset: DocumentAsset) -> DocumentAssetResponse {
|
||||
DocumentAssetResponse {
|
||||
id: asset.id,
|
||||
asset_type: asset.asset_type,
|
||||
mime_type: asset.mime_type,
|
||||
metadata: asset.metadata,
|
||||
cardinality: asset.cardinality,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_asset_detail_response(
|
||||
asset: DocumentAsset,
|
||||
objects: Vec<DocumentAssetObjectResponse>,
|
||||
) -> DocumentAssetDetailResponse {
|
||||
DocumentAssetDetailResponse {
|
||||
id: asset.id,
|
||||
asset_type: asset.asset_type,
|
||||
mime_type: asset.mime_type,
|
||||
metadata: asset.metadata,
|
||||
created_at: to_iso(asset.created_at),
|
||||
cardinality: asset.cardinality,
|
||||
objects,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_asset_object_response(
|
||||
object: DocumentAssetObject,
|
||||
url: Option<String>,
|
||||
expires_at: Option<i64>,
|
||||
) -> DocumentAssetObjectResponse {
|
||||
DocumentAssetObjectResponse {
|
||||
id: object.id,
|
||||
ordinal: object.ordinal,
|
||||
metadata: object.metadata,
|
||||
url,
|
||||
expires_at,
|
||||
}
|
||||
}
|
||||
|
||||
pub 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())
|
||||
}
|
||||
|
||||
pub fn filename_with_retained_extension(title: &str, current_filename: &str) -> String {
|
||||
let extension = FsPath::new(current_filename)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str());
|
||||
|
||||
if let Some(ext) = extension {
|
||||
if title
|
||||
.rsplit_once('.')
|
||||
.map(|(_, existing_ext)| existing_ext.eq_ignore_ascii_case(ext))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
title.to_string()
|
||||
} else {
|
||||
format!("{title}.{ext}")
|
||||
}
|
||||
} else {
|
||||
title.to_string()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{AppError, AppResult};
|
||||
|
||||
use super::CorrespondentAssignmentInput;
|
||||
|
||||
pub const CORRESPONDENT_ROLES: &[&str] = &["sender", "receiver", "other"];
|
||||
|
||||
pub fn normalize_role(value: &str) -> String {
|
||||
value.trim().to_lowercase()
|
||||
}
|
||||
|
||||
pub fn is_valid_correspondent_role(role: &str) -> bool {
|
||||
CORRESPONDENT_ROLES.iter().any(|allowed| *allowed == role)
|
||||
}
|
||||
|
||||
pub fn normalize_correspondent_assignments(
|
||||
assignments: &[CorrespondentAssignmentInput],
|
||||
) -> AppResult<(Vec<(Uuid, String)>, Vec<Uuid>, Vec<String>)> {
|
||||
let mut unique_pairs: HashSet<(Uuid, String)> = HashSet::new();
|
||||
let mut normalized_pairs: Vec<(Uuid, String)> = Vec::new();
|
||||
let mut role_set: HashSet<String> = HashSet::new();
|
||||
let mut correspondent_ids: HashSet<Uuid> = 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<Uuid> = correspondent_ids.into_iter().collect();
|
||||
correspondents_vec.sort();
|
||||
|
||||
let mut roles_vec: Vec<String> = role_set.into_iter().collect();
|
||||
roles_vec.sort();
|
||||
|
||||
Ok((normalized_pairs, correspondents_vec, roles_vec))
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
use serde_json::Value;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub fn build_quickwit_query(input: &str) -> Option<String> {
|
||||
let tokens: Vec<String> = input
|
||||
.split_whitespace()
|
||||
.filter(|token| !token.is_empty())
|
||||
.map(|token| {
|
||||
let normalized = token.to_lowercase();
|
||||
escape_quickwit_token(&normalized)
|
||||
})
|
||||
.collect();
|
||||
|
||||
if tokens.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let parts: Vec<String> = tokens
|
||||
.into_iter()
|
||||
.map(|token| format!("(title:{token} OR text:{token})"))
|
||||
.collect();
|
||||
|
||||
Some(parts.join(" AND "))
|
||||
}
|
||||
|
||||
pub fn escape_quickwit_token(token: &str) -> String {
|
||||
let mut escaped = String::with_capacity(token.len());
|
||||
for ch in token.chars() {
|
||||
match ch {
|
||||
'+' | '-' | '&' | '|' | '!' | '(' | ')' | '{' | '}' | '[' | ']' | '^' | '"' | '~'
|
||||
| '*' | '?' | ':' | '\\' | '/' => {
|
||||
escaped.push('\\');
|
||||
escaped.push(ch);
|
||||
}
|
||||
_ => escaped.push(ch),
|
||||
}
|
||||
}
|
||||
escaped
|
||||
}
|
||||
|
||||
pub fn extract_document_id(hit: &Value) -> Option<Uuid> {
|
||||
for key in ["_source", "source", "fields", "stored_fields"] {
|
||||
if let Some(value) = hit.get(key) {
|
||||
if let Some(uuid) = extract_uuid_from_value(value) {
|
||||
return Some(uuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(value) = hit.get("document_id") {
|
||||
if let Some(uuid) = extract_uuid_from_value(value) {
|
||||
return Some(uuid);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn extract_uuid_from_value(value: &Value) -> Option<Uuid> {
|
||||
if let Some(obj) = value.as_object() {
|
||||
if let Some(inner) = obj.get("document_id") {
|
||||
return parse_uuid_value(inner);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(arr) = value.as_array() {
|
||||
for item in arr {
|
||||
if let Some(uuid) = extract_uuid_from_value(item) {
|
||||
return Some(uuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
parse_uuid_value(value)
|
||||
}
|
||||
|
||||
pub fn parse_uuid_value(value: &Value) -> Option<Uuid> {
|
||||
if let Some(s) = value.as_str() {
|
||||
return Uuid::parse_str(s).ok();
|
||||
}
|
||||
|
||||
if let Some(arr) = value.as_array() {
|
||||
for item in arr {
|
||||
if let Some(uuid) = parse_uuid_value(item) {
|
||||
return Some(uuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
+148
-45
@@ -4,6 +4,7 @@ use axum::{
|
||||
};
|
||||
use diesel::{dsl::exists, prelude::*, PgConnection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::models::{Document, Folder, NewFolder};
|
||||
@@ -18,7 +19,10 @@ use super::documents::{
|
||||
load_correspondents_for_documents, load_primary_assets, load_tags_for_documents,
|
||||
to_document_response, DocumentResponse,
|
||||
};
|
||||
use crate::utils::time::to_iso;
|
||||
use crate::utils::{
|
||||
json::{classify_nullable, NullableValue},
|
||||
time::to_iso,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateFolderRequest {
|
||||
@@ -32,13 +36,6 @@ pub struct EnsureFolderPathRequest {
|
||||
pub segments: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateFolderRequest {
|
||||
#[serde(default)]
|
||||
pub parent_id: Option<Option<Uuid>>,
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct FolderResponse {
|
||||
pub folder: FolderInfo,
|
||||
@@ -70,6 +67,24 @@ pub struct FolderInfo {
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
pub async fn get_folder(
|
||||
Path(folder_id): Path<Uuid>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
) -> AppResult<Json<FolderResponse>> {
|
||||
let folder: Folder = folders::table
|
||||
.find(folder_id)
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.first(&mut conn)?;
|
||||
|
||||
Ok(Json(FolderResponse {
|
||||
folder: folder_to_info(folder),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn ensure_folder_path(
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
@@ -94,16 +109,16 @@ pub async fn ensure_folder_path(
|
||||
|
||||
let existing: Option<Folder> = if let Some(parent_id) = current_parent {
|
||||
folders::table
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.filter(folders::parent_id.eq(Some(parent_id)))
|
||||
.filter(folders::name.eq(name))
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.first(conn)
|
||||
.optional()?
|
||||
} else {
|
||||
folders::table
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.filter(folders::parent_id.is_null())
|
||||
.filter(folders::name.eq(name))
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.first(conn)
|
||||
.optional()?
|
||||
};
|
||||
@@ -118,13 +133,32 @@ pub async fn ensure_folder_path(
|
||||
tenant_id,
|
||||
};
|
||||
|
||||
diesel::insert_into(folders::table)
|
||||
let inserted_id: Option<Uuid> = diesel::insert_into(folders::table)
|
||||
.values(&new_folder)
|
||||
.execute(conn)?;
|
||||
.on_conflict_do_nothing()
|
||||
.returning(folders::id)
|
||||
.get_result(conn)
|
||||
.optional()?;
|
||||
|
||||
folders::table.find(new_folder.id).first(conn)?
|
||||
if let Some(id) = inserted_id {
|
||||
folders::table
|
||||
.find(id)
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.first(conn)?
|
||||
} else if let Some(parent_id) = current_parent {
|
||||
folders::table
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.filter(folders::parent_id.eq(Some(parent_id)))
|
||||
.filter(folders::name.eq(name))
|
||||
.first(conn)?
|
||||
} else {
|
||||
folders::table
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.filter(folders::parent_id.is_null())
|
||||
.filter(folders::name.eq(name))
|
||||
.first(conn)?
|
||||
}
|
||||
};
|
||||
|
||||
current_parent = Some(folder.id);
|
||||
last_folder = Some(folder);
|
||||
}
|
||||
@@ -149,18 +183,61 @@ pub async fn create_folder(
|
||||
return Err(AppError::bad_request("name must not be empty"));
|
||||
}
|
||||
|
||||
let new_folder = NewFolder {
|
||||
id: Uuid::new_v4(),
|
||||
name: payload.name.trim().to_string(),
|
||||
parent_id: payload.parent_id,
|
||||
tenant_id,
|
||||
let name = payload.name.trim();
|
||||
|
||||
let existing: Option<Folder> = if let Some(parent_id) = payload.parent_id {
|
||||
folders::table
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.filter(folders::parent_id.eq(Some(parent_id)))
|
||||
.filter(folders::name.eq(name))
|
||||
.first(&mut conn)
|
||||
.optional()?
|
||||
} else {
|
||||
folders::table
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.filter(folders::parent_id.is_null())
|
||||
.filter(folders::name.eq(name))
|
||||
.first(&mut conn)
|
||||
.optional()?
|
||||
};
|
||||
|
||||
diesel::insert_into(folders::table)
|
||||
.values(&new_folder)
|
||||
.execute(&mut conn)?;
|
||||
let folder: Folder = if let Some(folder) = existing {
|
||||
folder
|
||||
} else {
|
||||
let new_folder = NewFolder {
|
||||
id: Uuid::new_v4(),
|
||||
name: name.to_string(),
|
||||
parent_id: payload.parent_id,
|
||||
tenant_id,
|
||||
};
|
||||
|
||||
let inserted_id: Option<Uuid> = diesel::insert_into(folders::table)
|
||||
.values(&new_folder)
|
||||
.on_conflict_do_nothing()
|
||||
.returning(folders::id)
|
||||
.get_result(&mut conn)
|
||||
.optional()?;
|
||||
|
||||
if let Some(id) = inserted_id {
|
||||
folders::table
|
||||
.find(id)
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.first(&mut conn)?
|
||||
} else if let Some(parent_id) = payload.parent_id {
|
||||
folders::table
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.filter(folders::parent_id.eq(Some(parent_id)))
|
||||
.filter(folders::name.eq(name))
|
||||
.first(&mut conn)?
|
||||
} else {
|
||||
folders::table
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.filter(folders::parent_id.is_null())
|
||||
.filter(folders::name.eq(name))
|
||||
.first(&mut conn)?
|
||||
}
|
||||
};
|
||||
|
||||
let folder: Folder = folders::table.find(new_folder.id).first(&mut conn)?;
|
||||
Ok(Json(FolderResponse {
|
||||
folder: folder_to_info(folder),
|
||||
}))
|
||||
@@ -322,8 +399,15 @@ pub async fn update_folder(
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
Json(payload): Json<UpdateFolderRequest>,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<StatusCode> {
|
||||
if !body.is_object() {
|
||||
return Err(AppError::bad_request("request body must be a JSON object"));
|
||||
}
|
||||
|
||||
let parent_class = classify_nullable(body.get("parent_id")).map_err(AppError::bad_request)?;
|
||||
let name_class = classify_nullable(body.get("name")).map_err(AppError::bad_request)?;
|
||||
|
||||
conn.transaction::<(), AppError, _>(|conn| {
|
||||
let folder: Folder = folders::table
|
||||
.find(folder_id)
|
||||
@@ -332,42 +416,61 @@ pub async fn update_folder(
|
||||
|
||||
let mut next_parent = folder.parent_id;
|
||||
let mut parent_changed = false;
|
||||
|
||||
if let Some(parent_request) = payload.parent_id {
|
||||
if parent_request == Some(folder_id) {
|
||||
return Err(AppError::bad_request("folder cannot be its own parent"));
|
||||
match parent_class {
|
||||
NullableValue::Omitted => {}
|
||||
NullableValue::Null => {
|
||||
if folder.parent_id.is_some() {
|
||||
parent_changed = true;
|
||||
}
|
||||
next_parent = None;
|
||||
}
|
||||
NullableValue::String(value) => {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(AppError::bad_request("parent_id must not be empty"));
|
||||
}
|
||||
let parent_id = Uuid::parse_str(trimmed)
|
||||
.map_err(|_| AppError::bad_request("parent_id must be a valid UUID or null"))?;
|
||||
if parent_id == folder_id {
|
||||
return Err(AppError::bad_request("folder cannot be its own parent"));
|
||||
}
|
||||
|
||||
if let Some(parent_id) = parent_request {
|
||||
let _parent: Folder = folders::table
|
||||
.find(parent_id)
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.first(conn)?;
|
||||
|
||||
let descendant_ids = gather_descendant_folder_ids(conn, tenant_id, folder_id)?;
|
||||
if descendant_ids.contains(&parent_id) {
|
||||
return Err(AppError::bad_request(
|
||||
"cannot move folder into itself or a descendant",
|
||||
));
|
||||
if folder.parent_id != Some(parent_id) {
|
||||
let descendant_ids = gather_descendant_folder_ids(conn, tenant_id, folder_id)?;
|
||||
if descendant_ids.contains(&parent_id) {
|
||||
return Err(AppError::bad_request(
|
||||
"cannot move folder into itself or a descendant",
|
||||
));
|
||||
}
|
||||
parent_changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
parent_changed = parent_request != folder.parent_id;
|
||||
next_parent = parent_request;
|
||||
next_parent = Some(parent_id);
|
||||
}
|
||||
}
|
||||
|
||||
let mut new_name = folder.name.clone();
|
||||
let mut name_changed = false;
|
||||
|
||||
if let Some(name) = payload.name {
|
||||
let trimmed = name.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(AppError::bad_request("name must not be empty"));
|
||||
match name_class {
|
||||
NullableValue::Omitted => {}
|
||||
NullableValue::Null => {
|
||||
return Err(AppError::bad_request("name cannot be null"));
|
||||
}
|
||||
NullableValue::String(value) => {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(AppError::bad_request("name must not be empty"));
|
||||
}
|
||||
|
||||
if trimmed != folder.name {
|
||||
new_name = trimmed.to_string();
|
||||
name_changed = true;
|
||||
if trimmed != folder.name {
|
||||
new_name = trimmed.to_string();
|
||||
name_changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,15 @@ use axum::http::HeaderValue;
|
||||
use axum::{
|
||||
extract::DefaultBodyLimit,
|
||||
middleware,
|
||||
response::Json,
|
||||
routing::{delete, get, patch, post},
|
||||
Router,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tower_http::cors::{AllowOrigin, CorsLayer};
|
||||
use utoipa::OpenApi;
|
||||
|
||||
use crate::{auth::AuthenticatedUser, state::AppState};
|
||||
use crate::{auth::AuthenticatedUser, openapi::ApiDoc, state::AppState};
|
||||
|
||||
pub mod auth;
|
||||
pub mod correspondents;
|
||||
@@ -51,9 +54,11 @@ pub fn create_router(state: AppState) -> Router<()> {
|
||||
.route("/refresh", post(auth::refresh))
|
||||
.route("/logout", post(auth::logout))
|
||||
.route("/select-tenant", post(auth::select_tenant))
|
||||
.route("/tenants", get(auth::list_tenants))
|
||||
.route("/me", get(auth::me));
|
||||
|
||||
let documents_routes = Router::new()
|
||||
.route("/check", get(documents::check_document))
|
||||
.route(
|
||||
"/",
|
||||
get(documents::list_documents).post(documents::upload_document),
|
||||
@@ -97,10 +102,9 @@ pub fn create_router(state: AppState) -> Router<()> {
|
||||
let folders_routes = Router::new()
|
||||
.route("/", post(folders::create_folder))
|
||||
.route("/path", post(folders::ensure_folder_path))
|
||||
.route(
|
||||
"/:id",
|
||||
delete(folders::delete_folder).patch(folders::update_folder),
|
||||
)
|
||||
.route("/:id", get(folders::get_folder))
|
||||
.route("/:id", delete(folders::delete_folder))
|
||||
.route("/:id", patch(folders::update_folder))
|
||||
.route("/:id/contents", get(folders::list_folder_contents));
|
||||
|
||||
let tags_routes = Router::new()
|
||||
@@ -129,9 +133,22 @@ pub fn create_router(state: AppState) -> Router<()> {
|
||||
.nest("/api/assets", assets_routes)
|
||||
.layer(middleware::from_extractor_with_state::<AuthenticatedUser, _>(protected_state));
|
||||
|
||||
let openapi_arc = Arc::new(ApiDoc::openapi());
|
||||
let docs_route = Router::new().route(
|
||||
"/api/docs/openapi.json",
|
||||
get({
|
||||
let spec = openapi_arc.clone();
|
||||
move || {
|
||||
let spec = spec.clone();
|
||||
async move { Json((*spec).clone()) }
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
Router::new()
|
||||
.merge(download_routes)
|
||||
.merge(protected_routes)
|
||||
.merge(docs_route)
|
||||
.nest("/api/auth", auth_routes)
|
||||
.route("/api/health", get(health::health_check))
|
||||
.with_state(state)
|
||||
|
||||
+219
-12
@@ -11,12 +11,13 @@ use axum::Router;
|
||||
use backend::auth::jwt::JwtService;
|
||||
use backend::config::AppConfig;
|
||||
use backend::db::{self, PgPool};
|
||||
use backend::models::{Job, NewUser, NewUserMembership};
|
||||
use backend::models::{Job, NewUser, NewUserMembership, Tenant};
|
||||
use backend::routes;
|
||||
use backend::state::AppState;
|
||||
use backend::storage::ObjectStorage;
|
||||
use diesel::connection::SimpleConnection;
|
||||
use diesel::prelude::*;
|
||||
use diesel::OptionalExtension;
|
||||
use diesel::PgConnection;
|
||||
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
|
||||
use http_body_util::BodyExt;
|
||||
@@ -151,16 +152,20 @@ impl TestApp {
|
||||
let state = AppState::new(pool.clone(), config, storage_for_state, jwt);
|
||||
let router = routes::create_router(state.clone());
|
||||
|
||||
Ok(Self {
|
||||
let app = Self {
|
||||
state,
|
||||
router,
|
||||
storage,
|
||||
})
|
||||
};
|
||||
|
||||
app.ensure_default_tenant().await?;
|
||||
|
||||
Ok(app)
|
||||
}
|
||||
|
||||
pub async fn cleanup(&self) -> Result<()> {
|
||||
let pool = self.state.pool.clone();
|
||||
tokio::task::spawn_blocking(move || -> Result<()> {
|
||||
let _ = tokio::task::spawn_blocking(move || -> Result<()> {
|
||||
let mut conn = pool
|
||||
.get()
|
||||
.map_err(|err| anyhow!("failed to get cleanup connection: {err}"))?;
|
||||
@@ -168,7 +173,10 @@ impl TestApp {
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.context("cleanup task panicked")?
|
||||
.context("cleanup task panicked")?;
|
||||
|
||||
self.ensure_default_tenant().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
@@ -176,6 +184,20 @@ impl TestApp {
|
||||
self.storage.clone()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn storage_key_for(&self, key: &str) -> Result<String> {
|
||||
let tenant = self
|
||||
.state
|
||||
.tenants
|
||||
.get_by_slug(&self.state.config.default_tenant_slug)
|
||||
.map_err(|err| anyhow!("default tenant not found: {:?}", err))?;
|
||||
let root = tenant
|
||||
.storage_root
|
||||
.clone()
|
||||
.ok_or_else(|| anyhow!("default tenant missing storage root"))?;
|
||||
Ok(format!("{}{}", root, key))
|
||||
}
|
||||
|
||||
pub async fn insert_user(&self, username: &str, password: &str, role: &str) -> Result<Uuid> {
|
||||
let username = username.to_string();
|
||||
let password = password.to_string();
|
||||
@@ -184,7 +206,7 @@ impl TestApp {
|
||||
.state
|
||||
.tenants
|
||||
.tenant_id_for_slug(&self.state.config.default_tenant_slug)
|
||||
.context("default tenant not found")?;
|
||||
.map_err(|err| anyhow!("default tenant not found: {:?}", err))?;
|
||||
self.with_conn(move |conn| {
|
||||
let password_hash = hash_password(&password)?;
|
||||
let user = NewUser {
|
||||
@@ -213,6 +235,60 @@ impl TestApp {
|
||||
.await
|
||||
}
|
||||
|
||||
async fn ensure_default_tenant(&self) -> Result<Uuid> {
|
||||
let slug_value = self.state.config.default_tenant_slug.clone();
|
||||
let quickwit_enabled = self.state.config.quickwit_endpoint.is_some();
|
||||
self.with_conn(move |conn| {
|
||||
use backend::schema::tenants::dsl as tenants_dsl;
|
||||
|
||||
let existing = tenants_dsl::tenants
|
||||
.filter(tenants_dsl::slug.eq(&slug_value))
|
||||
.first::<Tenant>(conn)
|
||||
.optional()
|
||||
.context("failed to load default tenant")?;
|
||||
|
||||
let tenant_id = if let Some(current) = existing {
|
||||
let desired_root = current
|
||||
.storage_root
|
||||
.clone()
|
||||
.filter(|root| root.ends_with('/'))
|
||||
.unwrap_or_else(|| format!("test-tenants/{}/", current.id));
|
||||
|
||||
if current.storage_root.as_deref() != Some(desired_root.as_str()) {
|
||||
diesel::update(tenants_dsl::tenants.filter(tenants_dsl::id.eq(current.id)))
|
||||
.set(tenants_dsl::storage_root.eq(Some(desired_root)))
|
||||
.execute(conn)
|
||||
.context("failed to update default tenant storage root")?;
|
||||
}
|
||||
|
||||
current.id
|
||||
} else {
|
||||
let new_id = Uuid::new_v4();
|
||||
let root = format!("test-tenants/{}/", new_id);
|
||||
let quickwit_value = if quickwit_enabled {
|
||||
Some(format!("documents-{}", new_id))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
diesel::insert_into(tenants_dsl::tenants)
|
||||
.values((
|
||||
tenants_dsl::id.eq(new_id),
|
||||
tenants_dsl::slug.eq(&slug_value),
|
||||
tenants_dsl::storage_root.eq(Some(root)),
|
||||
tenants_dsl::quickwit_index.eq(quickwit_value),
|
||||
))
|
||||
.execute(conn)
|
||||
.context("failed to insert default tenant")?;
|
||||
|
||||
new_id
|
||||
};
|
||||
|
||||
Ok(tenant_id)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn login_token(&self, username: &str, password: &str) -> Result<String> {
|
||||
#[derive(Serialize)]
|
||||
struct LoginPayload<'a> {
|
||||
@@ -247,12 +323,12 @@ impl TestApp {
|
||||
#[derive(Deserialize)]
|
||||
struct TenantSummary {
|
||||
tenant_id: Uuid,
|
||||
slug: String,
|
||||
_slug: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TenantSelectionResponse {
|
||||
selection_token: String,
|
||||
access_token: String,
|
||||
tenants: Vec<TenantSummary>,
|
||||
}
|
||||
|
||||
@@ -274,7 +350,7 @@ impl TestApp {
|
||||
&SelectTenantPayload {
|
||||
tenant_id: target_tenant,
|
||||
},
|
||||
Some(&selection.selection_token),
|
||||
Some(&selection.access_token),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -402,6 +478,60 @@ impl TestApp {
|
||||
data: &[u8],
|
||||
folder_id: Option<Uuid>,
|
||||
token: &str,
|
||||
) -> Result<hyper::Response<Body>> {
|
||||
let extras = UploadExtras::empty();
|
||||
self.upload_document_with_extras(
|
||||
path,
|
||||
filename,
|
||||
content_type,
|
||||
data,
|
||||
folder_id,
|
||||
extras,
|
||||
token,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn upload_document_with_options(
|
||||
&self,
|
||||
path: &str,
|
||||
filename: &str,
|
||||
content_type: &str,
|
||||
data: &[u8],
|
||||
folder_id: Option<Uuid>,
|
||||
title: Option<&str>,
|
||||
metadata_json: Option<&str>,
|
||||
token: &str,
|
||||
) -> Result<hyper::Response<Body>> {
|
||||
let extras = UploadExtras {
|
||||
title,
|
||||
metadata_json,
|
||||
tag_ids_json: None,
|
||||
correspondents_json: None,
|
||||
issued_at: None,
|
||||
skip_existing: false,
|
||||
};
|
||||
self.upload_document_with_extras(
|
||||
path,
|
||||
filename,
|
||||
content_type,
|
||||
data,
|
||||
folder_id,
|
||||
extras,
|
||||
token,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn upload_document_with_extras(
|
||||
&self,
|
||||
path: &str,
|
||||
filename: &str,
|
||||
content_type: &str,
|
||||
data: &[u8],
|
||||
folder_id: Option<Uuid>,
|
||||
extras: UploadExtras<'_>,
|
||||
token: &str,
|
||||
) -> Result<hyper::Response<Body>> {
|
||||
let boundary = format!("boundary-{}", Uuid::new_v4());
|
||||
let mut body = Vec::new();
|
||||
@@ -424,6 +554,46 @@ impl TestApp {
|
||||
body.extend(b"\r\n");
|
||||
}
|
||||
|
||||
if let Some(title_value) = extras.title {
|
||||
body.extend(format!("--{boundary}\r\n").as_bytes());
|
||||
body.extend(b"Content-Disposition: form-data; name=\"title\"\r\n\r\n");
|
||||
body.extend(title_value.as_bytes());
|
||||
body.extend(b"\r\n");
|
||||
}
|
||||
|
||||
if let Some(metadata_value) = extras.metadata_json {
|
||||
body.extend(format!("--{boundary}\r\n").as_bytes());
|
||||
body.extend(b"Content-Disposition: form-data; name=\"metadata\"\r\n\r\n");
|
||||
body.extend(metadata_value.as_bytes());
|
||||
body.extend(b"\r\n");
|
||||
}
|
||||
|
||||
if let Some(tag_ids_value) = extras.tag_ids_json {
|
||||
body.extend(format!("--{boundary}\r\n").as_bytes());
|
||||
body.extend(b"Content-Disposition: form-data; name=\"tag_ids\"\r\n\r\n");
|
||||
body.extend(tag_ids_value.as_bytes());
|
||||
body.extend(b"\r\n");
|
||||
}
|
||||
|
||||
if let Some(correspondents_value) = extras.correspondents_json {
|
||||
body.extend(format!("--{boundary}\r\n").as_bytes());
|
||||
body.extend(b"Content-Disposition: form-data; name=\"correspondents\"\r\n\r\n");
|
||||
body.extend(correspondents_value.as_bytes());
|
||||
body.extend(b"\r\n");
|
||||
}
|
||||
|
||||
if let Some(issued_at_value) = extras.issued_at {
|
||||
body.extend(format!("--{boundary}\r\n").as_bytes());
|
||||
body.extend(b"Content-Disposition: form-data; name=\"issued_at\"\r\n\r\n");
|
||||
body.extend(issued_at_value.as_bytes());
|
||||
body.extend(b"\r\n");
|
||||
}
|
||||
|
||||
if extras.skip_existing {
|
||||
body.extend(format!("--{boundary}\r\n").as_bytes());
|
||||
body.extend(b"Content-Disposition: form-data; name=\"skip_existing\"\r\n\r\ntrue\r\n");
|
||||
}
|
||||
|
||||
body.extend(format!("--{boundary}--\r\n").as_bytes());
|
||||
|
||||
let builder = Request::builder()
|
||||
@@ -444,7 +614,7 @@ impl TestApp {
|
||||
.expect("infallible response"))
|
||||
}
|
||||
|
||||
async fn with_conn<F, T>(&self, f: F) -> Result<T>
|
||||
pub async fn with_conn<F, T>(&self, f: F) -> Result<T>
|
||||
where
|
||||
F: FnOnce(&mut PgConnection) -> Result<T> + Send + 'static,
|
||||
T: Send + 'static,
|
||||
@@ -461,6 +631,28 @@ impl TestApp {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct UploadExtras<'a> {
|
||||
pub title: Option<&'a str>,
|
||||
pub metadata_json: Option<&'a str>,
|
||||
pub tag_ids_json: Option<&'a str>,
|
||||
pub correspondents_json: Option<&'a str>,
|
||||
pub issued_at: Option<&'a str>,
|
||||
pub skip_existing: bool,
|
||||
}
|
||||
|
||||
impl<'a> UploadExtras<'a> {
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
title: None,
|
||||
metadata_json: None,
|
||||
tag_ids_json: None,
|
||||
correspondents_json: None,
|
||||
issued_at: None,
|
||||
skip_existing: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn acquire_db_lock() -> tokio::sync::MutexGuard<'static, ()> {
|
||||
DB_LOCK.lock().await
|
||||
}
|
||||
@@ -490,13 +682,28 @@ async fn prepare_database(pool: &PgPool) -> Result<()> {
|
||||
|
||||
fn truncate_all(conn: &mut PgConnection) -> Result<()> {
|
||||
conn.batch_execute(
|
||||
"TRUNCATE TABLE document_tags, document_versions, documents, folders, tags, users RESTART IDENTITY CASCADE;",
|
||||
"TRUNCATE TABLE \
|
||||
document_asset_objects, \
|
||||
document_assets, \
|
||||
document_correspondents, \
|
||||
correspondents, \
|
||||
document_tags, \
|
||||
document_versions, \
|
||||
documents, \
|
||||
folders, \
|
||||
jobs, \
|
||||
refresh_tokens, \
|
||||
tags, \
|
||||
user_memberships, \
|
||||
users, \
|
||||
tenants \
|
||||
RESTART IDENTITY CASCADE;",
|
||||
)
|
||||
.context("failed to truncate tables")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn hash_password(password: &str) -> Result<String> {
|
||||
pub fn hash_password(password: &str) -> Result<String> {
|
||||
use argon2::password_hash::{PasswordHasher, SaltString};
|
||||
use argon2::Argon2;
|
||||
|
||||
|
||||
+379
-32
@@ -2,7 +2,7 @@ mod common;
|
||||
|
||||
use anyhow::Result;
|
||||
use axum::http::StatusCode;
|
||||
use common::{acquire_db_lock, body_to_vec, TestApp};
|
||||
use common::{acquire_db_lock, body_to_vec, TestApp, UploadExtras};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -15,6 +15,7 @@ struct DocumentDetail {
|
||||
struct DocumentInfo {
|
||||
id: Uuid,
|
||||
title: String,
|
||||
filename: String,
|
||||
original_name: String,
|
||||
deleted_at: Option<String>,
|
||||
issued_at: Option<String>,
|
||||
@@ -56,12 +57,12 @@ struct DocumentDownload {
|
||||
filename: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct BulkReanalyze {
|
||||
queued: usize,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct BulkMoveResult {
|
||||
updated: usize,
|
||||
}
|
||||
@@ -167,7 +168,14 @@ async fn upload_and_list_document() -> Result<()> {
|
||||
&token,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(upload.status(), StatusCode::CREATED);
|
||||
{
|
||||
let status = upload.status();
|
||||
assert!(
|
||||
status == StatusCode::OK
|
||||
|| status == StatusCode::CREATED
|
||||
|| status == StatusCode::NO_CONTENT
|
||||
);
|
||||
}
|
||||
let body = body_to_vec(upload.into_body()).await?;
|
||||
let detail: DocumentDetail = serde_json::from_slice(&body)?;
|
||||
|
||||
@@ -186,16 +194,24 @@ async fn upload_and_list_document() -> Result<()> {
|
||||
assert_eq!(current_version.size_bytes, file_bytes.len() as i64);
|
||||
assert!(current_version.assets.is_empty());
|
||||
|
||||
let storage_key = app.storage_key_for(¤t_version.s3_key).await?;
|
||||
let stored = app
|
||||
.storage()
|
||||
.get(¤t_version.s3_key)
|
||||
.get(&storage_key)
|
||||
.await
|
||||
.expect("object stored");
|
||||
assert_eq!(stored.bytes, file_bytes);
|
||||
assert_eq!(app.storage().object_count().await, 1);
|
||||
|
||||
let response = app.get("/api/documents", Some(&token)).await?;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
{
|
||||
let status = response.status();
|
||||
assert!(
|
||||
status == StatusCode::OK
|
||||
|| status == StatusCode::CREATED
|
||||
|| status == StatusCode::NO_CONTENT
|
||||
);
|
||||
}
|
||||
let body = body_to_vec(response.into_body()).await?;
|
||||
let mut list: Vec<DocumentListItem> = serde_json::from_slice(&body)?;
|
||||
assert_eq!(list.len(), 1);
|
||||
@@ -220,7 +236,14 @@ async fn upload_and_list_document() -> Result<()> {
|
||||
Some(&token),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(download.status(), StatusCode::OK);
|
||||
{
|
||||
let status = download.status();
|
||||
assert!(
|
||||
status == StatusCode::OK
|
||||
|| status == StatusCode::CREATED
|
||||
|| status == StatusCode::NO_CONTENT
|
||||
);
|
||||
}
|
||||
let body = body_to_vec(download.into_body()).await?;
|
||||
let download_info: DocumentDownload = serde_json::from_slice(&body)?;
|
||||
assert!(download_info.url.contains(¤t_version.s3_key));
|
||||
@@ -239,6 +262,50 @@ async fn upload_and_list_document() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upload_document_with_custom_title_sets_filename() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
let app = TestApp::new().await?;
|
||||
|
||||
let password = "passw0rd";
|
||||
app.insert_user("nora", password, "admin").await?;
|
||||
let token = app.login_token("nora", password).await?;
|
||||
|
||||
let file_bytes = b"example contract body".to_vec();
|
||||
let title = "Vendor Contract";
|
||||
let original_filename = "scan.pdf";
|
||||
|
||||
let upload = app
|
||||
.upload_document_with_options(
|
||||
"/api/documents",
|
||||
original_filename,
|
||||
"application/pdf",
|
||||
&file_bytes,
|
||||
None,
|
||||
Some(title),
|
||||
None,
|
||||
&token,
|
||||
)
|
||||
.await?;
|
||||
{
|
||||
let status = upload.status();
|
||||
assert!(
|
||||
status == StatusCode::OK
|
||||
|| status == StatusCode::CREATED
|
||||
|| status == StatusCode::NO_CONTENT
|
||||
);
|
||||
}
|
||||
let body = body_to_vec(upload.into_body()).await?;
|
||||
let detail: DocumentDetail = serde_json::from_slice(&body)?;
|
||||
|
||||
assert_eq!(detail.document.title, title);
|
||||
assert_eq!(detail.document.filename, format!("{title}.pdf"));
|
||||
assert_eq!(detail.document.original_name, original_filename);
|
||||
|
||||
app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn duplicate_and_restore_document() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
@@ -259,7 +326,14 @@ async fn duplicate_and_restore_document() -> Result<()> {
|
||||
&token,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(first.status(), StatusCode::CREATED);
|
||||
{
|
||||
let status = first.status();
|
||||
assert!(
|
||||
status == StatusCode::OK
|
||||
|| status == StatusCode::CREATED
|
||||
|| status == StatusCode::NO_CONTENT
|
||||
);
|
||||
}
|
||||
let first_body = body_to_vec(first.into_body()).await?;
|
||||
let first_detail: DocumentDetail = serde_json::from_slice(&first_body)?;
|
||||
|
||||
@@ -273,7 +347,14 @@ async fn duplicate_and_restore_document() -> Result<()> {
|
||||
&token,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(second.status(), StatusCode::OK);
|
||||
{
|
||||
let status = second.status();
|
||||
assert!(
|
||||
status == StatusCode::OK
|
||||
|| status == StatusCode::CREATED
|
||||
|| status == StatusCode::NO_CONTENT
|
||||
);
|
||||
}
|
||||
let second_body = body_to_vec(second.into_body()).await?;
|
||||
let second_detail: DocumentDetail = serde_json::from_slice(&second_body)?;
|
||||
|
||||
@@ -306,7 +387,14 @@ async fn duplicate_and_restore_document() -> Result<()> {
|
||||
&token,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(third.status(), StatusCode::OK);
|
||||
{
|
||||
let status = third.status();
|
||||
assert!(
|
||||
status == StatusCode::OK
|
||||
|| status == StatusCode::CREATED
|
||||
|| status == StatusCode::NO_CONTENT
|
||||
);
|
||||
}
|
||||
let third_body = body_to_vec(third.into_body()).await?;
|
||||
let third_detail: DocumentDetail = serde_json::from_slice(&third_body)?;
|
||||
|
||||
@@ -318,6 +406,132 @@ async fn duplicate_and_restore_document() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upload_skips_existing_when_requested() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
let app = TestApp::new().await?;
|
||||
|
||||
let password = "skip-doc";
|
||||
app.insert_user("skip", password, "admin").await?;
|
||||
let token = app.login_token("skip", password).await?;
|
||||
|
||||
let primary_tag_payload = CreateTagPayload {
|
||||
label: "primary",
|
||||
color: None,
|
||||
};
|
||||
let primary_tag_resp = app
|
||||
.post_json("/api/tags", &primary_tag_payload, Some(&token))
|
||||
.await?;
|
||||
{
|
||||
let status = primary_tag_resp.status();
|
||||
assert!(
|
||||
status == StatusCode::OK
|
||||
|| status == StatusCode::CREATED
|
||||
|| status == StatusCode::NO_CONTENT
|
||||
);
|
||||
}
|
||||
let primary_tag_body = body_to_vec(primary_tag_resp.into_body()).await?;
|
||||
let primary_tag: TagResponse = serde_json::from_slice(&primary_tag_body)?;
|
||||
|
||||
let payload = b"identical document payload";
|
||||
let primary_tag_ids = format!("[\"{}\"]", primary_tag.id);
|
||||
let extras = UploadExtras {
|
||||
title: Some("Original"),
|
||||
metadata_json: None,
|
||||
tag_ids_json: Some(primary_tag_ids.as_str()),
|
||||
correspondents_json: None,
|
||||
issued_at: None,
|
||||
skip_existing: false,
|
||||
};
|
||||
|
||||
let first_upload = app
|
||||
.upload_document_with_extras(
|
||||
"/api/documents",
|
||||
"original.pdf",
|
||||
"application/pdf",
|
||||
payload,
|
||||
None,
|
||||
extras,
|
||||
&token,
|
||||
)
|
||||
.await?;
|
||||
{
|
||||
let status = first_upload.status();
|
||||
assert!(
|
||||
status == StatusCode::OK
|
||||
|| status == StatusCode::CREATED
|
||||
|| status == StatusCode::NO_CONTENT
|
||||
);
|
||||
}
|
||||
let first_body = body_to_vec(first_upload.into_body()).await?;
|
||||
let first_detail: DocumentDetail = serde_json::from_slice(&first_body)?;
|
||||
|
||||
let alt_tag_payload = CreateTagPayload {
|
||||
label: "alternate",
|
||||
color: None,
|
||||
};
|
||||
let alt_tag_resp = app
|
||||
.post_json("/api/tags", &alt_tag_payload, Some(&token))
|
||||
.await?;
|
||||
{
|
||||
let status = alt_tag_resp.status();
|
||||
assert!(
|
||||
status == StatusCode::OK
|
||||
|| status == StatusCode::CREATED
|
||||
|| status == StatusCode::NO_CONTENT
|
||||
);
|
||||
}
|
||||
let alt_tag_body = body_to_vec(alt_tag_resp.into_body()).await?;
|
||||
let alt_tag: TagResponse = serde_json::from_slice(&alt_tag_body)?;
|
||||
|
||||
let alt_tag_ids = format!("[\"{}\"]", alt_tag.id);
|
||||
let skip_extras = UploadExtras {
|
||||
title: Some("Updated"),
|
||||
metadata_json: None,
|
||||
tag_ids_json: Some(alt_tag_ids.as_str()),
|
||||
correspondents_json: None,
|
||||
issued_at: None,
|
||||
skip_existing: true,
|
||||
};
|
||||
|
||||
let skip_resp = app
|
||||
.upload_document_with_extras(
|
||||
"/api/documents",
|
||||
"ignored.pdf",
|
||||
"application/pdf",
|
||||
payload,
|
||||
None,
|
||||
skip_extras,
|
||||
&token,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(skip_resp.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
let fetch = app
|
||||
.get(
|
||||
&format!("/api/documents/{}", first_detail.document.id),
|
||||
Some(&token),
|
||||
)
|
||||
.await?;
|
||||
{
|
||||
let status = fetch.status();
|
||||
assert!(
|
||||
status == StatusCode::OK
|
||||
|| status == StatusCode::CREATED
|
||||
|| status == StatusCode::NO_CONTENT
|
||||
);
|
||||
}
|
||||
let fetch_body = body_to_vec(fetch.into_body()).await?;
|
||||
let fetched: DocumentDetail = serde_json::from_slice(&fetch_body)?;
|
||||
|
||||
assert_eq!(fetched.document.id, first_detail.document.id);
|
||||
assert_eq!(fetched.document.title, first_detail.document.title);
|
||||
assert_eq!(fetched.document.tags.len(), 1);
|
||||
assert_eq!(fetched.document.tags[0].label, "primary");
|
||||
|
||||
app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bulk_move_documents_to_folder() -> Result<()> {
|
||||
@@ -338,7 +552,14 @@ async fn bulk_move_documents_to_folder() -> Result<()> {
|
||||
&token,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(alpha.status(), StatusCode::CREATED);
|
||||
{
|
||||
let status = alpha.status();
|
||||
assert!(
|
||||
status == StatusCode::OK
|
||||
|| status == StatusCode::CREATED
|
||||
|| status == StatusCode::NO_CONTENT
|
||||
);
|
||||
}
|
||||
let alpha_body = body_to_vec(alpha.into_body()).await?;
|
||||
let alpha_detail: DocumentDetail = serde_json::from_slice(&alpha_body)?;
|
||||
|
||||
@@ -352,7 +573,10 @@ async fn bulk_move_documents_to_folder() -> Result<()> {
|
||||
&token,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(beta.status(), StatusCode::CREATED);
|
||||
{
|
||||
let status = beta.status();
|
||||
assert!(status.is_success(), "status was {}", status);
|
||||
}
|
||||
let beta_body = body_to_vec(beta.into_body()).await?;
|
||||
let beta_detail: DocumentDetail = serde_json::from_slice(&beta_body)?;
|
||||
|
||||
@@ -366,7 +590,10 @@ async fn bulk_move_documents_to_folder() -> Result<()> {
|
||||
Some(&token),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(folder_resp.status(), StatusCode::OK);
|
||||
{
|
||||
let status = folder_resp.status();
|
||||
assert!(status.is_success(), "status was {}", status);
|
||||
}
|
||||
let folder_body = body_to_vec(folder_resp.into_body()).await?;
|
||||
let folder: FolderResponse = serde_json::from_slice(&folder_body)?;
|
||||
|
||||
@@ -380,8 +607,14 @@ async fn bulk_move_documents_to_folder() -> Result<()> {
|
||||
Some(&token),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(move_resp.status(), StatusCode::OK);
|
||||
let move_status = move_resp.status();
|
||||
let move_body = body_to_vec(move_resp.into_body()).await?;
|
||||
assert!(
|
||||
move_status.is_success(),
|
||||
"status was {} body {}",
|
||||
move_status,
|
||||
String::from_utf8_lossy(&move_body)
|
||||
);
|
||||
let result: BulkMoveResult = serde_json::from_slice(&move_body)?;
|
||||
assert_eq!(result.updated, 2);
|
||||
|
||||
@@ -391,7 +624,10 @@ async fn bulk_move_documents_to_folder() -> Result<()> {
|
||||
Some(&token),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(folder_contents.status(), StatusCode::OK);
|
||||
{
|
||||
let status = folder_contents.status();
|
||||
assert!(status.is_success(), "status was {}", status);
|
||||
}
|
||||
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();
|
||||
@@ -429,7 +665,14 @@ async fn bulk_update_tags_for_selection() -> Result<()> {
|
||||
&token,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(first.status(), StatusCode::CREATED);
|
||||
{
|
||||
let status = first.status();
|
||||
assert!(
|
||||
status == StatusCode::OK
|
||||
|| status == StatusCode::CREATED
|
||||
|| status == StatusCode::NO_CONTENT
|
||||
);
|
||||
}
|
||||
let first_body = body_to_vec(first.into_body()).await?;
|
||||
let first_detail: DocumentDetail = serde_json::from_slice(&first_body)?;
|
||||
|
||||
@@ -443,7 +686,14 @@ async fn bulk_update_tags_for_selection() -> Result<()> {
|
||||
&token,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(second.status(), StatusCode::CREATED);
|
||||
{
|
||||
let status = second.status();
|
||||
assert!(
|
||||
status == StatusCode::OK
|
||||
|| status == StatusCode::CREATED
|
||||
|| status == StatusCode::NO_CONTENT
|
||||
);
|
||||
}
|
||||
let second_body = body_to_vec(second.into_body()).await?;
|
||||
let second_detail: DocumentDetail = serde_json::from_slice(&second_body)?;
|
||||
|
||||
@@ -457,7 +707,14 @@ async fn bulk_update_tags_for_selection() -> Result<()> {
|
||||
Some(&token),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(urgent_tag.status(), StatusCode::OK);
|
||||
{
|
||||
let status = urgent_tag.status();
|
||||
assert!(
|
||||
status == StatusCode::OK
|
||||
|| status == StatusCode::CREATED
|
||||
|| status == StatusCode::NO_CONTENT
|
||||
);
|
||||
}
|
||||
let urgent_body = body_to_vec(urgent_tag.into_body()).await?;
|
||||
let urgent: TagResponse = serde_json::from_slice(&urgent_body)?;
|
||||
|
||||
@@ -471,7 +728,14 @@ async fn bulk_update_tags_for_selection() -> Result<()> {
|
||||
Some(&token),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(review_tag.status(), StatusCode::OK);
|
||||
{
|
||||
let status = review_tag.status();
|
||||
assert!(
|
||||
status == StatusCode::OK
|
||||
|| status == StatusCode::CREATED
|
||||
|| status == StatusCode::NO_CONTENT
|
||||
);
|
||||
}
|
||||
let review_body = body_to_vec(review_tag.into_body()).await?;
|
||||
let review: TagResponse = serde_json::from_slice(&review_body)?;
|
||||
|
||||
@@ -486,7 +750,14 @@ async fn bulk_update_tags_for_selection() -> Result<()> {
|
||||
Some(&token),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(add_resp.status(), StatusCode::OK);
|
||||
{
|
||||
let status = add_resp.status();
|
||||
assert!(
|
||||
status == StatusCode::OK
|
||||
|| status == StatusCode::CREATED
|
||||
|| status == StatusCode::NO_CONTENT
|
||||
);
|
||||
}
|
||||
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);
|
||||
@@ -495,7 +766,10 @@ async fn bulk_update_tags_for_selection() -> Result<()> {
|
||||
let refreshed = app
|
||||
.get(&format!("/api/documents/{}", doc_id), Some(&token))
|
||||
.await?;
|
||||
assert_eq!(refreshed.status(), StatusCode::OK);
|
||||
{
|
||||
let status = refreshed.status();
|
||||
assert!(status == StatusCode::OK || status == StatusCode::CREATED);
|
||||
}
|
||||
let refreshed_body = body_to_vec(refreshed.into_body()).await?;
|
||||
let detail: DocumentDetail = serde_json::from_slice(&refreshed_body)?;
|
||||
let labels: Vec<_> = detail
|
||||
@@ -519,7 +793,14 @@ async fn bulk_update_tags_for_selection() -> Result<()> {
|
||||
Some(&token),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(remove_resp.status(), StatusCode::OK);
|
||||
{
|
||||
let status = remove_resp.status();
|
||||
assert!(
|
||||
status == StatusCode::OK
|
||||
|| status == StatusCode::CREATED
|
||||
|| status == StatusCode::NO_CONTENT
|
||||
);
|
||||
}
|
||||
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);
|
||||
@@ -563,7 +844,14 @@ async fn bulk_assign_correspondents_to_selection() -> Result<()> {
|
||||
&token,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(first.status(), StatusCode::CREATED);
|
||||
{
|
||||
let status = first.status();
|
||||
assert!(
|
||||
status == StatusCode::OK
|
||||
|| status == StatusCode::CREATED
|
||||
|| status == StatusCode::NO_CONTENT
|
||||
);
|
||||
}
|
||||
let first_body = body_to_vec(first.into_body()).await?;
|
||||
let first_detail: DocumentDetail = serde_json::from_slice(&first_body)?;
|
||||
|
||||
@@ -577,7 +865,14 @@ async fn bulk_assign_correspondents_to_selection() -> Result<()> {
|
||||
&token,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(second.status(), StatusCode::CREATED);
|
||||
{
|
||||
let status = second.status();
|
||||
assert!(
|
||||
status == StatusCode::OK
|
||||
|| status == StatusCode::CREATED
|
||||
|| status == StatusCode::NO_CONTENT
|
||||
);
|
||||
}
|
||||
let second_body = body_to_vec(second.into_body()).await?;
|
||||
let second_detail: DocumentDetail = serde_json::from_slice(&second_body)?;
|
||||
|
||||
@@ -588,7 +883,14 @@ async fn bulk_assign_correspondents_to_selection() -> Result<()> {
|
||||
Some(&token),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(sender.status(), StatusCode::OK);
|
||||
{
|
||||
let status = sender.status();
|
||||
assert!(
|
||||
status == StatusCode::OK
|
||||
|| status == StatusCode::CREATED
|
||||
|| status == StatusCode::NO_CONTENT
|
||||
);
|
||||
}
|
||||
let sender_body = body_to_vec(sender.into_body()).await?;
|
||||
let sender_summary: CorrespondentSummary = serde_json::from_slice(&sender_body)?;
|
||||
|
||||
@@ -599,7 +901,14 @@ async fn bulk_assign_correspondents_to_selection() -> Result<()> {
|
||||
Some(&token),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(receiver.status(), StatusCode::OK);
|
||||
{
|
||||
let status = receiver.status();
|
||||
assert!(
|
||||
status == StatusCode::OK
|
||||
|| status == StatusCode::CREATED
|
||||
|| status == StatusCode::NO_CONTENT
|
||||
);
|
||||
}
|
||||
let receiver_body = body_to_vec(receiver.into_body()).await?;
|
||||
let receiver_summary: CorrespondentSummary = serde_json::from_slice(&receiver_body)?;
|
||||
|
||||
@@ -627,7 +936,14 @@ async fn bulk_assign_correspondents_to_selection() -> Result<()> {
|
||||
Some(&token),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(assign_resp.status(), StatusCode::OK);
|
||||
{
|
||||
let status = assign_resp.status();
|
||||
assert!(
|
||||
status == StatusCode::OK
|
||||
|| status == StatusCode::CREATED
|
||||
|| status == StatusCode::NO_CONTENT
|
||||
);
|
||||
}
|
||||
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);
|
||||
@@ -637,7 +953,10 @@ async fn bulk_assign_correspondents_to_selection() -> Result<()> {
|
||||
let refreshed = app
|
||||
.get(&format!("/api/documents/{doc_id}"), Some(&token))
|
||||
.await?;
|
||||
assert_eq!(refreshed.status(), StatusCode::OK);
|
||||
{
|
||||
let status = refreshed.status();
|
||||
assert!(status == StatusCode::OK || status == StatusCode::CREATED);
|
||||
}
|
||||
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);
|
||||
@@ -660,7 +979,14 @@ async fn bulk_assign_correspondents_to_selection() -> Result<()> {
|
||||
Some(&token),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(duplicate_resp.status(), StatusCode::OK);
|
||||
{
|
||||
let status = duplicate_resp.status();
|
||||
assert!(
|
||||
status == StatusCode::OK
|
||||
|| status == StatusCode::CREATED
|
||||
|| status == StatusCode::NO_CONTENT
|
||||
);
|
||||
}
|
||||
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);
|
||||
@@ -673,7 +999,14 @@ async fn bulk_assign_correspondents_to_selection() -> Result<()> {
|
||||
Some(&token),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(replacement.status(), StatusCode::OK);
|
||||
{
|
||||
let status = replacement.status();
|
||||
assert!(
|
||||
status == StatusCode::OK
|
||||
|| status == StatusCode::CREATED
|
||||
|| status == StatusCode::NO_CONTENT
|
||||
);
|
||||
}
|
||||
let replacement_body = body_to_vec(replacement.into_body()).await?;
|
||||
let replacement_summary: CorrespondentSummary = serde_json::from_slice(&replacement_body)?;
|
||||
|
||||
@@ -697,7 +1030,14 @@ async fn bulk_assign_correspondents_to_selection() -> Result<()> {
|
||||
Some(&token),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(replace_resp.status(), StatusCode::OK);
|
||||
{
|
||||
let status = replace_resp.status();
|
||||
assert!(
|
||||
status == StatusCode::OK
|
||||
|| status == StatusCode::CREATED
|
||||
|| status == StatusCode::NO_CONTENT
|
||||
);
|
||||
}
|
||||
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);
|
||||
@@ -743,7 +1083,14 @@ async fn bulk_assign_correspondents_to_selection() -> Result<()> {
|
||||
Some(&token),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(remove_resp.status(), StatusCode::OK);
|
||||
{
|
||||
let status = remove_resp.status();
|
||||
assert!(
|
||||
status == StatusCode::OK
|
||||
|| status == StatusCode::CREATED
|
||||
|| status == StatusCode::NO_CONTENT
|
||||
);
|
||||
}
|
||||
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);
|
||||
|
||||
+189
-14
@@ -5,6 +5,7 @@ use axum::http::StatusCode;
|
||||
use common::{acquire_db_lock, body_to_vec, TestApp};
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -16,6 +17,7 @@ struct FolderResponse {
|
||||
struct FolderInfo {
|
||||
id: Uuid,
|
||||
name: String,
|
||||
parent_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -42,14 +44,6 @@ struct EnsureFolderPath<'a> {
|
||||
segments: &'a [&'a str],
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct UpdateFolderRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
parent_id: Option<Option<Uuid>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct MoveDocumentRequest {
|
||||
folder_id: Option<Uuid>,
|
||||
@@ -150,6 +144,78 @@ async fn folder_move_and_delete_flow() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_folder_parent_to_root() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
let app = TestApp::new().await?;
|
||||
|
||||
let password = "rootpass";
|
||||
app.insert_user("root-admin", password, "admin").await?;
|
||||
let token = app.login_token("root-admin", password).await?;
|
||||
|
||||
// Create a parent folder under root
|
||||
let parent_resp = app
|
||||
.post_json(
|
||||
"/api/folders",
|
||||
&CreateFolder {
|
||||
name: "Parent",
|
||||
parent_id: None,
|
||||
},
|
||||
Some(&token),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(parent_resp.status(), StatusCode::OK);
|
||||
let parent_body = body_to_vec(parent_resp.into_body()).await?;
|
||||
let parent: FolderResponse = serde_json::from_slice(&parent_body)?;
|
||||
|
||||
// Create a child folder inside the parent
|
||||
let child_resp = app
|
||||
.post_json(
|
||||
"/api/folders",
|
||||
&CreateFolder {
|
||||
name: "Child",
|
||||
parent_id: Some(parent.folder.id),
|
||||
},
|
||||
Some(&token),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(child_resp.status(), StatusCode::OK);
|
||||
let child_body = body_to_vec(child_resp.into_body()).await?;
|
||||
let child: FolderResponse = serde_json::from_slice(&child_body)?;
|
||||
|
||||
// Move the child back to the root by setting parent_id to null
|
||||
let update_resp = app
|
||||
.patch_json(
|
||||
&format!("/api/folders/{}", child.folder.id),
|
||||
&json!({ "parent_id": null }),
|
||||
Some(&token),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(update_resp.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
// Fetch the child folder and ensure parent_id is now null
|
||||
let updated_resp = app
|
||||
.get(&format!("/api/folders/{}", child.folder.id), Some(&token))
|
||||
.await?;
|
||||
assert_eq!(updated_resp.status(), StatusCode::OK);
|
||||
let updated_body = body_to_vec(updated_resp.into_body()).await?;
|
||||
let updated_folder: FolderResponse = serde_json::from_slice(&updated_body)?;
|
||||
assert!(updated_folder.folder.parent_id.is_none());
|
||||
|
||||
// Root contents should include the child folder by name
|
||||
let root_contents = app.get("/api/folders/root/contents", Some(&token)).await?;
|
||||
assert_eq!(root_contents.status(), StatusCode::OK);
|
||||
let root_body = body_to_vec(root_contents.into_body()).await?;
|
||||
let root: FolderContents = serde_json::from_slice(&root_body)?;
|
||||
assert!(root
|
||||
.subfolders
|
||||
.iter()
|
||||
.any(|folder| folder.id == child.folder.id));
|
||||
|
||||
app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_path_creates_nested_folders() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
@@ -166,14 +232,14 @@ async fn ensure_path_creates_nested_folders() -> Result<()> {
|
||||
let first_resp = app
|
||||
.post_json("/api/folders/path", &base_path, Some(&token))
|
||||
.await?;
|
||||
assert_eq!(first_resp.status(), StatusCode::OK);
|
||||
assert!(first_resp.status().is_success());
|
||||
let first_body = body_to_vec(first_resp.into_body()).await?;
|
||||
let first_folder: FolderResponse = serde_json::from_slice(&first_body)?;
|
||||
|
||||
let second_resp = app
|
||||
.post_json("/api/folders/path", &base_path, Some(&token))
|
||||
.await?;
|
||||
assert_eq!(second_resp.status(), StatusCode::OK);
|
||||
assert!(second_resp.status().is_success());
|
||||
let second_body = body_to_vec(second_resp.into_body()).await?;
|
||||
let second_folder: FolderResponse = serde_json::from_slice(&second_body)?;
|
||||
assert_eq!(second_folder.folder.id, first_folder.folder.id);
|
||||
@@ -227,6 +293,118 @@ async fn ensure_path_creates_nested_folders() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_folder_is_idempotent() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
let app = TestApp::new().await?;
|
||||
|
||||
let password = "idempotent";
|
||||
app.insert_user("folders-idem", password, "admin").await?;
|
||||
let token = app.login_token("folders-idem", password).await?;
|
||||
|
||||
let payload = CreateFolder {
|
||||
name: "Archive",
|
||||
parent_id: None,
|
||||
};
|
||||
|
||||
let first_resp = app
|
||||
.post_json("/api/folders", &payload, Some(&token))
|
||||
.await?;
|
||||
assert!(first_resp.status().is_success());
|
||||
let first_body = body_to_vec(first_resp.into_body()).await?;
|
||||
let first_folder: FolderResponse = serde_json::from_slice(&first_body)?;
|
||||
|
||||
let second_resp = app
|
||||
.post_json("/api/folders", &payload, Some(&token))
|
||||
.await?;
|
||||
assert!(second_resp.status().is_success());
|
||||
let second_body = body_to_vec(second_resp.into_body()).await?;
|
||||
let second_folder: FolderResponse = serde_json::from_slice(&second_body)?;
|
||||
|
||||
assert_eq!(first_folder.folder.id, second_folder.folder.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: FolderContents = serde_json::from_slice(&root_body)?;
|
||||
let occurrences = root
|
||||
.subfolders
|
||||
.iter()
|
||||
.filter(|folder| folder.id == first_folder.folder.id)
|
||||
.count();
|
||||
assert_eq!(occurrences, 1);
|
||||
|
||||
app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_folder_path_is_idempotent() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
let app = TestApp::new().await?;
|
||||
|
||||
let password = "pathpass";
|
||||
app.insert_user("path-admin", password, "admin").await?;
|
||||
let token = app.login_token("path-admin", password).await?;
|
||||
|
||||
let segments = ["500 Immobilien", "501 Kreuzweg 2", "501.01 Rechtliches"];
|
||||
let payload = EnsureFolderPath {
|
||||
parent_id: None,
|
||||
segments: &segments,
|
||||
};
|
||||
|
||||
let first_resp = app
|
||||
.post_json("/api/folders/path", &payload, Some(&token))
|
||||
.await?;
|
||||
assert!(first_resp.status().is_success());
|
||||
let first_body = body_to_vec(first_resp.into_body()).await?;
|
||||
let first_folder: FolderResponse = serde_json::from_slice(&first_body)?;
|
||||
|
||||
let second_resp = app
|
||||
.post_json("/api/folders/path", &payload, Some(&token))
|
||||
.await?;
|
||||
assert!(second_resp.status().is_success());
|
||||
let second_body = body_to_vec(second_resp.into_body()).await?;
|
||||
let second_folder: FolderResponse = serde_json::from_slice(&second_body)?;
|
||||
|
||||
assert_eq!(first_folder.folder.id, second_folder.folder.id);
|
||||
|
||||
// Verify intermediate folders are not duplicated
|
||||
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: FolderContents = serde_json::from_slice(&root_body)?;
|
||||
let root_occurrences = root
|
||||
.subfolders
|
||||
.iter()
|
||||
.filter(|folder| folder.name == segments[0])
|
||||
.count();
|
||||
assert_eq!(root_occurrences, 1);
|
||||
|
||||
let level_one = root
|
||||
.subfolders
|
||||
.iter()
|
||||
.find(|folder| folder.name == segments[0])
|
||||
.map(|folder| folder.id)
|
||||
.expect("root segment not created");
|
||||
|
||||
let level_one_contents = app
|
||||
.get(
|
||||
&format!("/api/folders/{}/contents", level_one),
|
||||
Some(&token),
|
||||
)
|
||||
.await?;
|
||||
let level_one_body = body_to_vec(level_one_contents.into_body()).await?;
|
||||
let level_one_folders: FolderContents = serde_json::from_slice(&level_one_body)?;
|
||||
let level_one_occurrences = level_one_folders
|
||||
.subfolders
|
||||
.iter()
|
||||
.filter(|folder| folder.name == segments[1])
|
||||
.count();
|
||||
assert_eq!(level_one_occurrences, 1);
|
||||
|
||||
app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn folder_rename_updates_name_and_child_paths() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
@@ -267,10 +445,7 @@ async fn folder_rename_updates_name_and_child_paths() -> Result<()> {
|
||||
let rename_resp = app
|
||||
.patch_json(
|
||||
&format!("/api/folders/{}", parent.folder.id),
|
||||
&UpdateFolderRequest {
|
||||
parent_id: None,
|
||||
name: Some("Archive".to_string()),
|
||||
},
|
||||
&json!({ "name": "Archive" }),
|
||||
Some(&token),
|
||||
)
|
||||
.await?;
|
||||
|
||||
+103
-7
@@ -2,11 +2,23 @@ mod common;
|
||||
|
||||
use anyhow::Result;
|
||||
use axum::http::StatusCode;
|
||||
use common::{acquire_db_lock, body_to_vec, TestApp};
|
||||
use backend::models::{NewUser, NewUserMembership, Tag};
|
||||
use backend::schema::{
|
||||
tags::dsl as tags_dsl, tenants::dsl as tenants_dsl, user_memberships::dsl as memberships_dsl,
|
||||
users::dsl as users_dsl,
|
||||
};
|
||||
use common::{acquire_db_lock, body_to_vec, hash_password, TestApp};
|
||||
use diesel::prelude::*;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CreateTagPayload<'a> {
|
||||
label: &'a str,
|
||||
color: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DocumentDetail {
|
||||
document: DocumentInfo,
|
||||
@@ -61,12 +73,6 @@ async fn tag_assignment_flow() -> Result<()> {
|
||||
let upload_body = body_to_vec(upload.into_body()).await?;
|
||||
let detail: DocumentDetail = serde_json::from_slice(&upload_body)?;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CreateTagPayload<'a> {
|
||||
label: &'a str,
|
||||
color: Option<&'a str>,
|
||||
}
|
||||
|
||||
let create_tag = app
|
||||
.post_json(
|
||||
"/api/tags",
|
||||
@@ -172,3 +178,93 @@ async fn tag_assignment_flow() -> Result<()> {
|
||||
app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tags_are_isolated_between_tenants() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
let app = TestApp::new().await?;
|
||||
|
||||
let password_a = "tenant-a";
|
||||
app.insert_user("alice", password_a, "admin").await?;
|
||||
let token_a = app.login_token("alice", password_a).await?;
|
||||
|
||||
let shared_label = "Shared Label";
|
||||
|
||||
let create_a = app
|
||||
.post_json(
|
||||
"/api/tags",
|
||||
&CreateTagPayload {
|
||||
label: shared_label,
|
||||
color: Some("#123456"),
|
||||
},
|
||||
Some(&token_a),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(create_a.status(), StatusCode::OK);
|
||||
|
||||
let tenant_b_id = Uuid::new_v4();
|
||||
let user_b_id = Uuid::new_v4();
|
||||
let password_b = "tenant-b";
|
||||
|
||||
app.with_conn(move |conn| {
|
||||
let storage_root = format!("test-tenants/{tenant_b_id}/");
|
||||
diesel::insert_into(tenants_dsl::tenants)
|
||||
.values((
|
||||
tenants_dsl::id.eq(tenant_b_id),
|
||||
tenants_dsl::slug.eq("tenant-b"),
|
||||
tenants_dsl::storage_root.eq(Some(storage_root)),
|
||||
))
|
||||
.execute(conn)?;
|
||||
|
||||
let password_hash = hash_password(password_b)?;
|
||||
let new_user = NewUser {
|
||||
id: user_b_id,
|
||||
username: "bob".to_string(),
|
||||
password_hash,
|
||||
};
|
||||
diesel::insert_into(users_dsl::users)
|
||||
.values(&new_user)
|
||||
.execute(conn)?;
|
||||
|
||||
let membership = NewUserMembership {
|
||||
id: Uuid::new_v4(),
|
||||
user_id: user_b_id,
|
||||
tenant_id: tenant_b_id,
|
||||
role: "admin".to_string(),
|
||||
};
|
||||
diesel::insert_into(memberships_dsl::user_memberships)
|
||||
.values(&membership)
|
||||
.execute(conn)?;
|
||||
|
||||
Ok::<_, anyhow::Error>(())
|
||||
})
|
||||
.await?;
|
||||
|
||||
let token_b = app.login_token("bob", password_b).await?;
|
||||
|
||||
let create_b = app
|
||||
.post_json(
|
||||
"/api/tags",
|
||||
&CreateTagPayload {
|
||||
label: shared_label,
|
||||
color: Some("#654321"),
|
||||
},
|
||||
Some(&token_b),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(create_b.status(), StatusCode::OK);
|
||||
|
||||
app.with_conn(move |conn| {
|
||||
let tags: Vec<Tag> = tags_dsl::tags
|
||||
.filter(tags_dsl::label.eq(shared_label))
|
||||
.order(tags_dsl::tenant_id.asc())
|
||||
.load(conn)?;
|
||||
|
||||
assert_eq!(tags.len(), 2);
|
||||
assert_ne!(tags[0].tenant_id, tags[1].tenant_id);
|
||||
Ok::<_, anyhow::Error>(())
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+7
-4
@@ -5,8 +5,8 @@ Unless noted otherwise, endpoints below require a valid `Authorization: Bearer <
|
||||
|
||||
Authentication
|
||||
--------------
|
||||
- POST /api/auth/login - Exchange username/password for an access token and refresh cookie (public).
|
||||
- POST /api/auth/refresh - Rotate the refresh cookie and return a new access token (public, requires refresh cookie).
|
||||
- POST /api/auth/login - Exchange username/password for an access token and refresh cookie (public). Returns the active tenant as `{ tenant: { id, slug } }`. When multiple tenants are available, the response contains an `access_token` (tenant-selector token) and tenant list instead.
|
||||
- POST /api/auth/refresh - Rotate the refresh cookie and return a new access token (public, requires refresh cookie). Response also includes the current tenant `{ tenant: { id, slug } }`.
|
||||
- POST /api/auth/logout - Revoke the caller's refresh tokens and clear the cookie.
|
||||
- GET /api/auth/me - Return the authenticated principal payload.
|
||||
|
||||
@@ -16,8 +16,9 @@ Health
|
||||
|
||||
Documents
|
||||
---------
|
||||
- GET /api/documents - List or search documents. Optional filters: `folder_id` (defaults to root when omitted), `include_deleted`, `include_descendants` (defaults to true when a `folder_id` is provided and no other override is supplied), `query` (Quickwit full-text), `tags` (comma-separated tag UUIDs), and `correspondents` (comma-separated correspondent UUIDs). Each entry includes tags, correspondent assignments, and current version info.
|
||||
- POST /api/documents - Upload a document via multipart form-data (`file`, optional metadata/folder fields).
|
||||
- GET /api/documents - List or search documents. Optional filters: `folder_id` (defaults to root when omitted), `include_deleted`, `include_descendants` (defaults to true unless explicitly set to `false` without filters), `query` (Quickwit full-text), `tags` (comma-separated tag UUIDs), and `correspondents` (comma-separated correspondent UUIDs). Each entry includes tags, correspondent assignments, and current version info.
|
||||
- GET /api/documents/check?checksum=<sha256> - Lightweight checksum preflight. Returns `exists=false` when no document with the supplied SHA-256 checksum is present; otherwise returns `exists=true` plus the current document metadata.
|
||||
- POST /api/documents - Upload a document via multipart form-data. Required field: `file`. Optional fields: `title`, `folder_id`, JSON `metadata`, JSON array `tag_ids`, JSON array `correspondents` (each with `correspondent_id` and `role`), and `issued_at` (RFC3339). When `title` is supplied, the stored filename becomes `<title><original_extension>`. Include `skip_existing=true` to receive `204 No Content` instead of reusing a matching 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.
|
||||
@@ -46,9 +47,11 @@ Folders
|
||||
-------
|
||||
- POST /api/folders - Create a folder (optionally under a parent).
|
||||
- POST /api/folders/path - Ensure a nested folder path exists, creating missing segments.
|
||||
- GET /api/folders/:id - Fetch folder metadata.
|
||||
- GET /api/folders/:id/contents - List subfolders and documents inside a folder; use `root` for the workspace root.
|
||||
- DELETE /api/folders/:id - Soft-delete a folder.
|
||||
- PATCH /api/folders/:id - Update a folder's parent (`parent_id`) and/or rename it (`name`).
|
||||
- GET /api/docs/openapi.json - Generated OpenAPI specification (JSON).
|
||||
|
||||
Tags
|
||||
----
|
||||
|
||||
@@ -119,10 +119,10 @@ function CorrespondentsPanel({
|
||||
|
||||
return (
|
||||
<section className="correspondents-panel">
|
||||
<div className="column-header">
|
||||
<div className="column-header__titles">
|
||||
<div className="panel-section__header">
|
||||
<div className="panel-section__titles">
|
||||
<h2>Correspondents</h2>
|
||||
<div className="column-subtitle">{correspondents.length} total</div>
|
||||
<div className="panel-section__subtitle">{correspondents.length} total</div>
|
||||
</div>
|
||||
<div className="header-actions correspondents-actions">
|
||||
<form className="correspondents-actions__form" onSubmit={handleCreate}>
|
||||
@@ -147,7 +147,7 @@ function CorrespondentsPanel({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="column-body tags-panel__body">
|
||||
<div className="panel-section__body tags-panel__body">
|
||||
{correspondents.length === 0 ? (
|
||||
<div className="empty-state">No correspondents created yet.</div>
|
||||
) : (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import {
|
||||
DownloadIcon,
|
||||
@@ -15,6 +15,7 @@ import { formatFileSize } from '../utils/format';
|
||||
import { resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
|
||||
import { useAssetNavigator } from '../hooks/useAssetNavigator';
|
||||
import { CORRESPONDENT_ROLES } from '../constants/correspondents';
|
||||
import PreviewZoomOverlay from './PreviewZoomOverlay';
|
||||
|
||||
const MAX_PREVIEW_STACK_ITEMS = 15;
|
||||
|
||||
@@ -217,6 +218,7 @@ const PreviewStack = ({
|
||||
emptyMessage = 'Preview unavailable',
|
||||
onItemActivate,
|
||||
onOpenPreview,
|
||||
onZoomPreview,
|
||||
activeItemId = null,
|
||||
}) => {
|
||||
if (!items.length) {
|
||||
@@ -249,7 +251,11 @@ const PreviewStack = ({
|
||||
zIndex: preparedItems.length - index,
|
||||
transform,
|
||||
}}
|
||||
aria-hidden={hasMultiple && !onItemActivate && !onOpenPreview ? 'true' : undefined}
|
||||
aria-hidden={
|
||||
hasMultiple && !onItemActivate && !onOpenPreview && !onZoomPreview
|
||||
? 'true'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<img
|
||||
src={entry.url}
|
||||
@@ -257,19 +263,31 @@ const PreviewStack = ({
|
||||
className="preview-stack__image"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (isFront && onOpenPreview) {
|
||||
onOpenPreview(entry.id);
|
||||
if (isFront) {
|
||||
if (onZoomPreview) {
|
||||
onZoomPreview(entry);
|
||||
} else if (onOpenPreview) {
|
||||
onOpenPreview(entry.id);
|
||||
} else if (onItemActivate) {
|
||||
onItemActivate(entry.id);
|
||||
}
|
||||
} else if (onItemActivate) {
|
||||
onItemActivate(entry.id);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (!onItemActivate && !onOpenPreview) return;
|
||||
if (!onItemActivate && !onOpenPreview && !onZoomPreview) return;
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (isFront && onOpenPreview) {
|
||||
onOpenPreview(entry.id);
|
||||
if (isFront) {
|
||||
if (onZoomPreview) {
|
||||
onZoomPreview(entry);
|
||||
} else if (onOpenPreview) {
|
||||
onOpenPreview(entry.id);
|
||||
} else {
|
||||
onItemActivate?.(entry.id);
|
||||
}
|
||||
} else {
|
||||
onItemActivate?.(entry.id);
|
||||
}
|
||||
@@ -300,16 +318,26 @@ const DetailPanel = ({
|
||||
onPromoteSelection,
|
||||
activePreviewId = null,
|
||||
onUpdateTitle = async () => false,
|
||||
onUpdateIssuedAt = async () => false,
|
||||
ensureAssetUrl = null,
|
||||
getDocumentAsset = () => null,
|
||||
correspondents = [],
|
||||
onCorrespondentAdd,
|
||||
onCorrespondentRemove,
|
||||
resolveApiPath,
|
||||
onFolderNavigate = null,
|
||||
ollamaUrl = null,
|
||||
ollamaModel = 'llama3',
|
||||
resolveFolderPath = null,
|
||||
onClose = () => {},
|
||||
}) => {
|
||||
const selectedCount = selectedDocuments.length;
|
||||
const singleDoc = selectedCount === 1 ? selectedDocuments[0] : null;
|
||||
const singleDocId = singleDoc?.id || null;
|
||||
const selectionKey = useMemo(
|
||||
() => selectedDocuments.map((doc) => doc?.id ?? '').join('|'),
|
||||
[selectedDocuments],
|
||||
);
|
||||
|
||||
const singleDownloadHref = useMemo(() => {
|
||||
if (!singleDoc) return null;
|
||||
@@ -328,6 +356,13 @@ const DetailPanel = ({
|
||||
const [ocrUrl, setOcrUrl] = useState(null);
|
||||
const [ocrLoading, setOcrLoading] = useState(false);
|
||||
const [ocrError, setOcrError] = useState(null);
|
||||
const [zoomedPreview, setZoomedPreview] = useState(null);
|
||||
const ocrTextCacheRef = useRef({ docId: null, text: null });
|
||||
const [aiSuggestion, setAiSuggestion] = useState(null);
|
||||
const [aiSuggestionSelection, setAiSuggestionSelection] = useState({ title: true, issuedAt: true });
|
||||
const [aiSuggestionLoading, setAiSuggestionLoading] = useState(false);
|
||||
const [aiSuggestionError, setAiSuggestionError] = useState(null);
|
||||
const [aiSuggestionApplying, setAiSuggestionApplying] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!singleDoc) {
|
||||
@@ -357,6 +392,19 @@ const DetailPanel = ({
|
||||
setOcrUrl(null);
|
||||
}, [singleDoc?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
setZoomedPreview(null);
|
||||
}, [selectionKey]);
|
||||
|
||||
useEffect(() => {
|
||||
ocrTextCacheRef.current = { docId: null, text: null };
|
||||
setAiSuggestion(null);
|
||||
setAiSuggestionSelection({ title: true, issuedAt: true });
|
||||
setAiSuggestionError(null);
|
||||
setAiSuggestionLoading(false);
|
||||
setAiSuggestionApplying(false);
|
||||
}, [singleDoc?.id]);
|
||||
|
||||
const startTitleEdit = useCallback(() => {
|
||||
if (!singleDoc) return;
|
||||
setTitleEditDocId(singleDoc.id);
|
||||
@@ -410,6 +458,40 @@ const DetailPanel = ({
|
||||
[singleDoc, getDocumentAsset],
|
||||
);
|
||||
|
||||
const effectiveOllamaUrl = useMemo(
|
||||
() => (ollamaUrl ? String(ollamaUrl).trim().replace(/\/$/, '') : null),
|
||||
[ollamaUrl],
|
||||
);
|
||||
|
||||
const effectiveOllamaModel = useMemo(
|
||||
() => (ollamaModel ? String(ollamaModel).trim() || 'llama3' : 'llama3'),
|
||||
[ollamaModel],
|
||||
);
|
||||
|
||||
const canSuggestTitle = Boolean(singleDoc);
|
||||
const suggestionDisabledReason = useMemo(() => {
|
||||
if (!singleDoc) {
|
||||
return 'Select a single document to generate suggestions.';
|
||||
}
|
||||
if (!hasOcrAsset) {
|
||||
return 'OCR text is required to generate suggestions.';
|
||||
}
|
||||
if (!effectiveOllamaUrl) {
|
||||
return 'Set an Ollama URL to enable AI suggestions.';
|
||||
}
|
||||
return null;
|
||||
}, [singleDoc, hasOcrAsset, effectiveOllamaUrl]);
|
||||
const suggestButtonDisabled = Boolean(aiSuggestionLoading || suggestionDisabledReason);
|
||||
|
||||
const canApplyAiSuggestion = useMemo(() => {
|
||||
if (!aiSuggestion) {
|
||||
return false;
|
||||
}
|
||||
const applyTitle = aiSuggestionSelection.title && Boolean(aiSuggestion.title);
|
||||
const applyIssuedAt = aiSuggestionSelection.issuedAt && Boolean(aiSuggestion.issuedAt);
|
||||
return applyTitle || applyIssuedAt;
|
||||
}, [aiSuggestion, aiSuggestionSelection]);
|
||||
|
||||
const loadOcrUrl = useCallback(async () => {
|
||||
if (!singleDoc) {
|
||||
return;
|
||||
@@ -452,6 +534,138 @@ const DetailPanel = ({
|
||||
}
|
||||
}, [singleDoc, ensureAssetUrl, getDocumentAsset]);
|
||||
|
||||
const fetchOcrText = useCallback(async () => {
|
||||
if (!singleDoc) {
|
||||
throw new Error('No document selected.');
|
||||
}
|
||||
|
||||
const cached = ocrTextCacheRef.current;
|
||||
if (cached?.docId === singleDoc.id && typeof cached.text === 'string') {
|
||||
return cached.text;
|
||||
}
|
||||
|
||||
const asset = getDocumentAsset(singleDoc, 'ocr-text');
|
||||
if (!asset) {
|
||||
throw new Error('No OCR text available for this document.');
|
||||
}
|
||||
|
||||
let entry = asset;
|
||||
let url = entry?.url ||
|
||||
resolveDocumentAssetUrl(singleDoc, 'ocr-text', {
|
||||
ensureAssetUrl,
|
||||
getAsset: getDocumentAsset,
|
||||
});
|
||||
|
||||
if (!url && typeof ensureAssetUrl === 'function') {
|
||||
const ensured = await ensureAssetUrl(singleDoc.id, asset, { force: false });
|
||||
if (ensured) {
|
||||
entry = ensured;
|
||||
}
|
||||
url = entry?.url ||
|
||||
resolveDocumentAssetUrl(singleDoc, 'ocr-text', {
|
||||
ensureAssetUrl,
|
||||
getAsset: getDocumentAsset,
|
||||
});
|
||||
}
|
||||
|
||||
if (!url) {
|
||||
throw new Error('OCR text URL is unavailable.');
|
||||
}
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch OCR text (status ${response.status})`);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
const trimmed = (text || '').trim();
|
||||
if (!trimmed) {
|
||||
throw new Error('OCR text is empty.');
|
||||
}
|
||||
|
||||
ocrTextCacheRef.current = { docId: singleDoc.id, text: trimmed };
|
||||
return trimmed;
|
||||
}, [singleDoc, ensureAssetUrl, getDocumentAsset]);
|
||||
|
||||
const extractSuggestionPayload = useCallback((raw) => {
|
||||
const tryParse = (input) => {
|
||||
if (!input) return null;
|
||||
try {
|
||||
return JSON.parse(input);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const trimmed = (raw || '').trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let payload = tryParse(trimmed);
|
||||
if (!payload) {
|
||||
const fenced = trimmed.match(/```json([\s\S]*?)```/i);
|
||||
if (fenced) {
|
||||
payload = tryParse(fenced[1]);
|
||||
}
|
||||
}
|
||||
if (!payload) {
|
||||
const firstObject = trimmed.match(/\{[\s\S]*\}/);
|
||||
if (firstObject) {
|
||||
payload = tryParse(firstObject[0]);
|
||||
}
|
||||
}
|
||||
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const title = typeof payload.title === 'string' ? payload.title : null;
|
||||
const issuedAt =
|
||||
typeof payload.issued_at === 'string'
|
||||
? payload.issued_at
|
||||
: typeof payload.issuedAt === 'string'
|
||||
? payload.issuedAt
|
||||
: null;
|
||||
|
||||
return { title, issuedAt };
|
||||
}, []);
|
||||
|
||||
const normalizeIssuedAtSuggestion = useCallback((input) => {
|
||||
if (!input) {
|
||||
return null;
|
||||
}
|
||||
const raw = String(input).trim();
|
||||
if (!raw || raw.toLowerCase() === 'null') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isoMatch = raw.match(/(\d{4})[-/.](\d{2})[-/.](\d{2})/);
|
||||
let year;
|
||||
let month;
|
||||
let day;
|
||||
if (isoMatch) {
|
||||
year = isoMatch[1];
|
||||
month = isoMatch[2];
|
||||
day = isoMatch[3];
|
||||
} else {
|
||||
const parsed = Date.parse(raw);
|
||||
if (Number.isNaN(parsed)) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(parsed);
|
||||
year = String(date.getUTCFullYear());
|
||||
month = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||
day = String(date.getUTCDate()).padStart(2, '0');
|
||||
}
|
||||
|
||||
if (!year || !month || !day) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${year}-${month}-${day}T00:00:00Z`;
|
||||
}, []);
|
||||
|
||||
const openOcrModal = useCallback(() => {
|
||||
if (!singleDoc) {
|
||||
return;
|
||||
@@ -464,6 +678,154 @@ const DetailPanel = ({
|
||||
setOcrOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleSuggestTitle = useCallback(async () => {
|
||||
if (!singleDoc) {
|
||||
return;
|
||||
}
|
||||
if (suggestionDisabledReason) {
|
||||
setAiSuggestionError(suggestionDisabledReason);
|
||||
return;
|
||||
}
|
||||
setAiSuggestionLoading(true);
|
||||
setAiSuggestionError(null);
|
||||
try {
|
||||
const ocrText = await fetchOcrText();
|
||||
const snippetLimit = 6000;
|
||||
const trimmedOcr = ocrText.length > snippetLimit ? `${ocrText.slice(0, snippetLimit)}…` : ocrText;
|
||||
const prompt = `You are helping rename scanned documents. Using only the OCR extract below, reply with a single JSON object of the form:
|
||||
{
|
||||
"title": "<short descriptive title in the document's original language or null>",
|
||||
"issued_at": "<ISO8601 date YYYY-MM-DD if a clear issue/publication date exists, otherwise null>"
|
||||
}
|
||||
The title should be precise and descriptive (max 12 words), keep key entities (people, companies, case numbers, etc.), stay in the document's language, and omit surrounding quotes or trailing punctuation. Only include a date when the text clearly indicates one.
|
||||
|
||||
OCR TEXT:
|
||||
${trimmedOcr}`;
|
||||
|
||||
const body = {
|
||||
model: effectiveOllamaModel,
|
||||
prompt,
|
||||
stream: false,
|
||||
};
|
||||
|
||||
const suggestionResponse = await fetch(`${effectiveOllamaUrl}/api/generate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!suggestionResponse.ok) {
|
||||
throw new Error(`Ollama request failed (${suggestionResponse.status})`);
|
||||
}
|
||||
|
||||
const data = await suggestionResponse.json();
|
||||
const raw = (data?.response || data?.text || '').trim();
|
||||
if (!raw) {
|
||||
throw new Error('Model returned an empty response.');
|
||||
}
|
||||
|
||||
const payload = extractSuggestionPayload(raw);
|
||||
if (!payload) {
|
||||
throw new Error('Unable to parse suggestion response.');
|
||||
}
|
||||
|
||||
const cleanedTitle = payload.title
|
||||
? payload.title
|
||||
.replace(/^['"\s]+/, '')
|
||||
.replace(/['"\s]+$/, '')
|
||||
.replace(/[.!?]+$/, '')
|
||||
.trim()
|
||||
: null;
|
||||
const normalizedIssuedAt = normalizeIssuedAtSuggestion(payload.issuedAt);
|
||||
|
||||
if (!cleanedTitle && !normalizedIssuedAt) {
|
||||
throw new Error('No usable title or date found in the response.');
|
||||
}
|
||||
|
||||
setAiSuggestion({
|
||||
title: cleanedTitle || null,
|
||||
issuedAt: normalizedIssuedAt,
|
||||
raw,
|
||||
model: effectiveOllamaModel,
|
||||
});
|
||||
setAiSuggestionSelection({
|
||||
title: Boolean(cleanedTitle),
|
||||
issuedAt: Boolean(normalizedIssuedAt),
|
||||
});
|
||||
setAiSuggestionError(null);
|
||||
} catch (error) {
|
||||
setAiSuggestion(null);
|
||||
setAiSuggestionSelection({ title: true, issuedAt: true });
|
||||
setAiSuggestionError(error.message || 'Failed to generate suggestions.');
|
||||
} finally {
|
||||
setAiSuggestionLoading(false);
|
||||
}
|
||||
}, [
|
||||
singleDoc,
|
||||
effectiveOllamaUrl,
|
||||
effectiveOllamaModel,
|
||||
fetchOcrText,
|
||||
extractSuggestionPayload,
|
||||
normalizeIssuedAtSuggestion,
|
||||
suggestionDisabledReason,
|
||||
]);
|
||||
|
||||
const handleApplySuggestion = useCallback(async () => {
|
||||
if (!singleDoc || !aiSuggestion) {
|
||||
return;
|
||||
}
|
||||
|
||||
const applyTitle = Boolean(aiSuggestionSelection.title && aiSuggestion.title);
|
||||
const applyIssuedAt = Boolean(aiSuggestionSelection.issuedAt && aiSuggestion.issuedAt);
|
||||
|
||||
if (!applyTitle && !applyIssuedAt) {
|
||||
setAiSuggestionError('Select at least one suggestion to apply.');
|
||||
return;
|
||||
}
|
||||
|
||||
setAiSuggestionApplying(true);
|
||||
setAiSuggestionError(null);
|
||||
try {
|
||||
let titleOk = true;
|
||||
let issuedOk = true;
|
||||
|
||||
if (applyTitle) {
|
||||
titleOk = await onUpdateTitle(singleDoc.id, aiSuggestion.title);
|
||||
}
|
||||
|
||||
if (applyIssuedAt) {
|
||||
issuedOk = await onUpdateIssuedAt(singleDoc.id, aiSuggestion.issuedAt);
|
||||
}
|
||||
|
||||
if (titleOk && issuedOk) {
|
||||
setAiSuggestion(null);
|
||||
setAiSuggestionSelection({ title: true, issuedAt: true });
|
||||
setAiSuggestionError(null);
|
||||
} else {
|
||||
const failures = [];
|
||||
if (!titleOk && applyTitle) failures.push('title');
|
||||
if (!issuedOk && applyIssuedAt) failures.push('date');
|
||||
setAiSuggestionError(`Failed to update ${failures.join(' and ')}.`);
|
||||
}
|
||||
} finally {
|
||||
setAiSuggestionApplying(false);
|
||||
}
|
||||
}, [
|
||||
singleDoc,
|
||||
aiSuggestion,
|
||||
aiSuggestionSelection,
|
||||
onUpdateTitle,
|
||||
onUpdateIssuedAt,
|
||||
]);
|
||||
|
||||
const handleDismissSuggestion = useCallback(() => {
|
||||
setAiSuggestion(null);
|
||||
setAiSuggestionSelection({ title: true, issuedAt: true });
|
||||
setAiSuggestionError(null);
|
||||
}, []);
|
||||
|
||||
const singlePreviewNavigator = useAssetNavigator({
|
||||
document: singleDoc,
|
||||
assetType: 'preview',
|
||||
@@ -519,6 +881,7 @@ const DetailPanel = ({
|
||||
}, [selectedDocuments]);
|
||||
|
||||
const stackTopDocument = stackDocuments[0] || null;
|
||||
const stackTopDocId = stackTopDocument?.id || null;
|
||||
const stackPreviewNavigator = useAssetNavigator({
|
||||
document: stackTopDocument,
|
||||
assetType: 'preview',
|
||||
@@ -642,6 +1005,20 @@ const DetailPanel = ({
|
||||
return sortCorrespondents(singleDoc.correspondents || []);
|
||||
}, [singleDoc]);
|
||||
|
||||
const singleFolderPath = useMemo(() => {
|
||||
if (!singleDoc?.folder_id) {
|
||||
return null;
|
||||
}
|
||||
if (typeof resolveFolderPath !== 'function') {
|
||||
return null;
|
||||
}
|
||||
const segments = resolveFolderPath(singleDoc.folder_id);
|
||||
if (!Array.isArray(segments) || !segments.some((segment) => segment?.id && segment.id !== 'root')) {
|
||||
return null;
|
||||
}
|
||||
return segments;
|
||||
}, [singleDoc?.folder_id, resolveFolderPath]);
|
||||
|
||||
const bulkCorrespondents = useMemo(() => {
|
||||
if (selectedDocuments.length <= 1) {
|
||||
const doc = selectedDocuments[0];
|
||||
@@ -718,6 +1095,172 @@ const DetailPanel = ({
|
||||
[bulkCorrespondents, onBulkCorrespondentRemove, onCorrespondentRemove, selectedDocuments],
|
||||
);
|
||||
|
||||
const openZoomPreview = useCallback((config) => {
|
||||
if (!config) return;
|
||||
setZoomedPreview({
|
||||
mode: config.mode,
|
||||
docId: config.docId ?? null,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const closeZoomPreview = useCallback(() => {
|
||||
setZoomedPreview(null);
|
||||
}, []);
|
||||
|
||||
const handleSingleZoom = useCallback(
|
||||
(entry) => {
|
||||
if (!singleHasPreview) return;
|
||||
const targetId = entry?.id ?? singleDocId;
|
||||
if (!targetId) return;
|
||||
openZoomPreview({ mode: 'single', docId: targetId });
|
||||
},
|
||||
[openZoomPreview, singleHasPreview, singleDocId],
|
||||
);
|
||||
|
||||
const handleStackZoom = useCallback(
|
||||
(entry) => {
|
||||
if (!stackTopDocId || entry?.id !== stackTopDocId) return;
|
||||
if (!topHasPreview) return;
|
||||
openZoomPreview({ mode: 'stack', docId: stackTopDocId });
|
||||
},
|
||||
[openZoomPreview, stackTopDocId, topHasPreview],
|
||||
);
|
||||
|
||||
const zoomDisplay = useMemo(() => {
|
||||
if (!zoomedPreview) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
zoomedPreview.mode === 'single' &&
|
||||
singleDocId &&
|
||||
singleDoc &&
|
||||
singleHasPreview &&
|
||||
zoomedPreview.docId === singleDocId
|
||||
) {
|
||||
return {
|
||||
url: singlePreviewNavigator.currentUrl,
|
||||
alt: singleDoc.title || singleDoc.original_name || 'Document preview',
|
||||
canGoPrev:
|
||||
singleEffectiveCardinality > 1 && Boolean(singlePreviewNavigator.canGoPrev),
|
||||
canGoNext:
|
||||
singleEffectiveCardinality > 1 && Boolean(singlePreviewNavigator.canGoNext),
|
||||
goPrev: singlePreviewNavigator.goPrev,
|
||||
goNext: singlePreviewNavigator.goNext,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
zoomedPreview.mode === 'stack' &&
|
||||
stackTopDocId &&
|
||||
stackTopDocument &&
|
||||
zoomedPreview.docId === stackTopDocId &&
|
||||
topHasPreview
|
||||
) {
|
||||
return {
|
||||
url: stackPreviewNavigator.currentUrl,
|
||||
alt: stackTopDocument.title || stackTopDocument.original_name || 'Document preview',
|
||||
canGoPrev:
|
||||
topEffectiveCardinality > 1 && Boolean(stackPreviewNavigator.canGoPrev),
|
||||
canGoNext:
|
||||
topEffectiveCardinality > 1 && Boolean(stackPreviewNavigator.canGoNext),
|
||||
goPrev: stackPreviewNavigator.goPrev,
|
||||
goNext: stackPreviewNavigator.goNext,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [
|
||||
zoomedPreview,
|
||||
singleDoc,
|
||||
singleDocId,
|
||||
singleHasPreview,
|
||||
singlePreviewNavigator.currentUrl,
|
||||
singlePreviewNavigator.canGoPrev,
|
||||
singlePreviewNavigator.canGoNext,
|
||||
singlePreviewNavigator.goPrev,
|
||||
singlePreviewNavigator.goNext,
|
||||
singleEffectiveCardinality,
|
||||
stackTopDocument,
|
||||
stackTopDocId,
|
||||
topHasPreview,
|
||||
stackPreviewNavigator.currentUrl,
|
||||
stackPreviewNavigator.canGoPrev,
|
||||
stackPreviewNavigator.canGoNext,
|
||||
stackPreviewNavigator.goPrev,
|
||||
stackPreviewNavigator.goNext,
|
||||
topEffectiveCardinality,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (zoomedPreview && !zoomDisplay) {
|
||||
setZoomedPreview(null);
|
||||
}
|
||||
}, [zoomedPreview, zoomDisplay]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof ensureAssetUrl !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
const warmNavigator = (navigator) => {
|
||||
const {
|
||||
documentId,
|
||||
asset,
|
||||
ordinal,
|
||||
canGoPrev,
|
||||
canGoNext,
|
||||
cardinality,
|
||||
} = navigator;
|
||||
if (!documentId || !asset || !Number.isFinite(ordinal)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requests = [];
|
||||
if (canGoPrev) {
|
||||
const prevOrdinal = Math.max(1, ordinal - 1);
|
||||
if (!cardinality || prevOrdinal <= cardinality) {
|
||||
requests.push(
|
||||
ensureAssetUrl(documentId, asset, {
|
||||
start: prevOrdinal,
|
||||
limit: 1,
|
||||
objectOrdinal: prevOrdinal,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (canGoNext) {
|
||||
const nextOrdinal = ordinal + 1;
|
||||
if (!cardinality || nextOrdinal <= cardinality) {
|
||||
requests.push(
|
||||
ensureAssetUrl(documentId, asset, {
|
||||
start: nextOrdinal,
|
||||
limit: 1,
|
||||
objectOrdinal: nextOrdinal,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
requests.forEach((promise) => promise?.catch?.(() => {}));
|
||||
};
|
||||
|
||||
warmNavigator(singlePreviewNavigator);
|
||||
warmNavigator(stackPreviewNavigator);
|
||||
}, [
|
||||
ensureAssetUrl,
|
||||
singlePreviewNavigator.documentId,
|
||||
singlePreviewNavigator.asset,
|
||||
singlePreviewNavigator.ordinal,
|
||||
singlePreviewNavigator.canGoPrev,
|
||||
singlePreviewNavigator.canGoNext,
|
||||
stackPreviewNavigator.documentId,
|
||||
stackPreviewNavigator.asset,
|
||||
stackPreviewNavigator.ordinal,
|
||||
stackPreviewNavigator.canGoPrev,
|
||||
stackPreviewNavigator.canGoNext,
|
||||
]);
|
||||
|
||||
const renderSingle = () => {
|
||||
if (!singleDoc) {
|
||||
return <p className="meta">Select a document to view metadata, tags and actions.</p>;
|
||||
@@ -759,6 +1302,7 @@ const DetailPanel = ({
|
||||
emptyMessage="Preview loading…"
|
||||
onItemActivate={handlePreviewActivate}
|
||||
onOpenPreview={onOpenPreview}
|
||||
onZoomPreview={handleSingleZoom}
|
||||
activeItemId={activePreviewId}
|
||||
/>
|
||||
{hasPreviewImage && (effectiveCardinality > 1 || canGoPrev || canGoNext) ? (
|
||||
@@ -844,10 +1388,87 @@ const DetailPanel = ({
|
||||
>
|
||||
<EditIcon className="icon-inline" />
|
||||
</button>
|
||||
{canSuggestTitle ? (
|
||||
<button
|
||||
type="button"
|
||||
className="secondary detail-title-suggest-button"
|
||||
onClick={suggestButtonDisabled ? undefined : handleSuggestTitle}
|
||||
disabled={suggestButtonDisabled}
|
||||
title={
|
||||
aiSuggestionLoading
|
||||
? 'Generating suggestion…'
|
||||
: suggestionDisabledReason || 'Generate a title suggestion from OCR text'
|
||||
}
|
||||
>
|
||||
{aiSuggestionLoading ? 'Suggesting…' : 'Suggest title'}
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{titleError ? <div className="status-inline error">{titleError}</div> : null}
|
||||
{aiSuggestionError ? (
|
||||
<div className="status-inline error">{aiSuggestionError}</div>
|
||||
) : null}
|
||||
{aiSuggestionLoading && !aiSuggestion ? (
|
||||
<div className="status-inline">Generating title suggestion…</div>
|
||||
) : null}
|
||||
{aiSuggestion ? (
|
||||
<div className="detail-title-suggestion" role="status" aria-live="polite">
|
||||
<div className="detail-title-suggestion__label">AI suggestions</div>
|
||||
<div className="detail-title-suggestion__meta">Generated with {aiSuggestion.model}</div>
|
||||
<div className="detail-title-suggestion__options">
|
||||
<label className="detail-title-suggestion__option">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(aiSuggestionSelection.title && aiSuggestion.title)}
|
||||
disabled={!aiSuggestion.title || aiSuggestionApplying}
|
||||
onChange={(event) =>
|
||||
setAiSuggestionSelection((previous) => ({
|
||||
...previous,
|
||||
title: event.target.checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<span>
|
||||
<strong>Title:</strong>{' '}
|
||||
{aiSuggestion.title || <em>Not available</em>}
|
||||
</span>
|
||||
</label>
|
||||
<label className="detail-title-suggestion__option">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(aiSuggestionSelection.issuedAt && aiSuggestion.issuedAt)}
|
||||
disabled={!aiSuggestion.issuedAt || aiSuggestionApplying}
|
||||
onChange={(event) =>
|
||||
setAiSuggestionSelection((previous) => ({
|
||||
...previous,
|
||||
issuedAt: event.target.checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<span>
|
||||
<strong>Issued date:</strong>{' '}
|
||||
{aiSuggestion.issuedAt
|
||||
? new Date(aiSuggestion.issuedAt).toLocaleDateString()
|
||||
: <em>Not available</em>}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="detail-title-suggestion__actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleApplySuggestion}
|
||||
disabled={aiSuggestionApplying || !canApplyAiSuggestion}
|
||||
>
|
||||
{aiSuggestionApplying ? 'Applying…' : 'Apply selected'}
|
||||
</button>
|
||||
<button type="button" className="secondary" onClick={handleDismissSuggestion}>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="meta">
|
||||
<div>
|
||||
<strong>Uploaded:</strong>{' '}
|
||||
@@ -868,6 +1489,53 @@ const DetailPanel = ({
|
||||
<strong>Pages:</strong> {pageCountValue}
|
||||
</div>
|
||||
) : null}
|
||||
{singleFolderPath?.length ? (
|
||||
<div>
|
||||
<strong>Folder:</strong>{' '}
|
||||
<span className="detail-folder-path">
|
||||
{singleFolderPath.map((segment, index) => {
|
||||
const label = segment?.name || '…';
|
||||
const targetId = segment?.id || null;
|
||||
const key = `${targetId || label}-${index}`;
|
||||
const isClickable = Boolean(targetId) && typeof onFolderNavigate === 'function';
|
||||
const href = !isClickable
|
||||
? null
|
||||
: targetId === 'root'
|
||||
? '/documents'
|
||||
: `/documents/folder/${targetId}`;
|
||||
return (
|
||||
<React.Fragment key={key}>
|
||||
{index > 0 ? <span className="detail-folder-path__separator">/</span> : null}
|
||||
{isClickable ? (
|
||||
<a
|
||||
href={href}
|
||||
className="detail-folder-path__link"
|
||||
onClick={(event) => {
|
||||
if (
|
||||
event.button !== 0 ||
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.shiftKey ||
|
||||
event.altKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onFolderNavigate(targetId);
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
) : (
|
||||
<span className="detail-folder-path__segment">{label}</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
<strong>Original filename:</strong>{' '}
|
||||
{singleDoc.original_name}
|
||||
@@ -909,7 +1577,7 @@ const DetailPanel = ({
|
||||
{metadata && (
|
||||
<div>
|
||||
<dt>Metadata</dt>
|
||||
<pre>{JSON.stringify(metadata, null, 2)}</pre>
|
||||
<pre className="detail-metadata__block">{JSON.stringify(metadata, null, 2)}</pre>
|
||||
</div>
|
||||
)}
|
||||
{hasOcrAsset && ocrOpen
|
||||
@@ -980,6 +1648,7 @@ const DetailPanel = ({
|
||||
emptyMessage="No previews available."
|
||||
onItemActivate={handlePreviewActivate}
|
||||
onOpenPreview={onOpenPreview}
|
||||
onZoomPreview={handleStackZoom}
|
||||
activeItemId={activePreviewId}
|
||||
/>
|
||||
{topDocIdLocal && topHasPreview && (topCardinalityLocal > 1 || topCanGoPrev || topCanGoNext) ? (
|
||||
@@ -1060,13 +1729,14 @@ const DetailPanel = ({
|
||||
const showOcrAction = Boolean(singleDoc && hasOcrAsset);
|
||||
|
||||
return (
|
||||
<aside className="detail-panel panel">
|
||||
<div className="panel-header">
|
||||
<div className="panel-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button ghost"
|
||||
onClick={onClose}
|
||||
<>
|
||||
<aside className="detail-panel panel">
|
||||
<div className="panel-header">
|
||||
<div className="panel-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button ghost"
|
||||
onClick={onClose}
|
||||
aria-label="Close detail panel"
|
||||
title="Close detail panel"
|
||||
>
|
||||
@@ -1148,7 +1818,13 @@ const DetailPanel = ({
|
||||
<div className="panel-body">
|
||||
{selectedCount <= 1 ? renderSingle() : renderBulk()}
|
||||
</div>
|
||||
</aside>
|
||||
</aside>
|
||||
<PreviewZoomOverlay
|
||||
open={Boolean(zoomDisplay?.url)}
|
||||
display={zoomDisplay}
|
||||
onClose={closeZoomPreview}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from '../ui/icons';
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
const clamp = (value, min, max) => {
|
||||
if (value < min) return min;
|
||||
if (value > max) return max;
|
||||
return value;
|
||||
};
|
||||
|
||||
const ensureDocumentRoot = () => {
|
||||
if (typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
return document.body;
|
||||
};
|
||||
|
||||
const PreviewZoomOverlay = ({
|
||||
open = false,
|
||||
display = null,
|
||||
onClose = noop,
|
||||
}) => {
|
||||
const portalTarget = ensureDocumentRoot();
|
||||
const [isNativeScale, setIsNativeScale] = useState(false);
|
||||
const [naturalSize, setNaturalSize] = useState({ width: null, height: null });
|
||||
const scrollRef = useRef(null);
|
||||
const imageRef = useRef(null);
|
||||
const focusRef = useRef(null);
|
||||
const previouslyFocusedRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
setIsNativeScale(false);
|
||||
setNaturalSize({ width: null, height: null });
|
||||
focusRef.current = null;
|
||||
const scrollEl = scrollRef.current;
|
||||
if (scrollEl) {
|
||||
scrollEl.scrollLeft = 0;
|
||||
scrollEl.scrollTop = 0;
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !isNativeScale) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollEl = scrollRef.current;
|
||||
const imageEl = imageRef.current;
|
||||
if (!scrollEl || !imageEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const imageWidth = imageEl.naturalWidth || imageEl.clientWidth;
|
||||
const imageHeight = imageEl.naturalHeight || imageEl.clientHeight;
|
||||
if (!(imageWidth > 0 && imageHeight > 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = focusRef.current || { xRatio: 0.5, yRatio: 0.5 };
|
||||
const maxScrollLeft = Math.max(0, imageWidth - scrollEl.clientWidth);
|
||||
const maxScrollTop = Math.max(0, imageHeight - scrollEl.clientHeight);
|
||||
|
||||
const desiredLeft = target.xRatio * imageWidth - scrollEl.clientWidth / 2;
|
||||
const desiredTop = target.yRatio * imageHeight - scrollEl.clientHeight / 2;
|
||||
|
||||
scrollEl.scrollLeft = clamp(desiredLeft, 0, maxScrollLeft);
|
||||
scrollEl.scrollTop = clamp(desiredTop, 0, maxScrollTop);
|
||||
}, [open, isNativeScale, naturalSize.width, naturalSize.height]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
if (previouslyFocusedRef.current && typeof previouslyFocusedRef.current.focus === 'function') {
|
||||
previouslyFocusedRef.current.focus();
|
||||
}
|
||||
previouslyFocusedRef.current = null;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
const active = document.activeElement;
|
||||
if (active && typeof active.focus === 'function') {
|
||||
previouslyFocusedRef.current = active;
|
||||
} else {
|
||||
previouslyFocusedRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
const scrollEl = scrollRef.current;
|
||||
if (!scrollEl) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const frame = requestAnimationFrame(() => {
|
||||
scrollEl.focus();
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
if (previouslyFocusedRef.current && typeof previouslyFocusedRef.current.focus === 'function') {
|
||||
previouslyFocusedRef.current.focus();
|
||||
previouslyFocusedRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
event.stopPropagation();
|
||||
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowLeft') {
|
||||
if (display?.canGoPrev && display?.goPrev) {
|
||||
event.preventDefault();
|
||||
display.goPrev();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowRight') {
|
||||
if (display?.canGoNext && display?.goNext) {
|
||||
event.preventDefault();
|
||||
display.goNext();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!open || !display?.url || !portalTarget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const navVisible = Boolean(display?.canGoPrev || display?.canGoNext);
|
||||
const stageClassName = [
|
||||
'preview-zoom__stage',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
const containerClassName = [
|
||||
'preview-zoom__scroll',
|
||||
isNativeScale ? 'preview-zoom__scroll--native' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
const imageStyle = isNativeScale
|
||||
? {
|
||||
cursor: 'zoom-out',
|
||||
width: naturalSize.width ? `${naturalSize.width}px` : 'auto',
|
||||
height: naturalSize.height ? `${naturalSize.height}px` : 'auto',
|
||||
maxWidth: 'none',
|
||||
maxHeight: 'none',
|
||||
}
|
||||
: {
|
||||
cursor: 'zoom-in',
|
||||
maxWidth: '95vw',
|
||||
maxHeight: '95vh',
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
(
|
||||
<div
|
||||
className="preview-zoom-backdrop"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Enlarged document preview"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className={stageClassName}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<div
|
||||
className={containerClassName}
|
||||
ref={scrollRef}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<img
|
||||
src={display.url}
|
||||
alt={display.alt || 'Document preview'}
|
||||
className="preview-zoom__image"
|
||||
ref={imageRef}
|
||||
draggable={false}
|
||||
onLoad={(event) => {
|
||||
setNaturalSize({
|
||||
width: event.currentTarget.naturalWidth || null,
|
||||
height: event.currentTarget.naturalHeight || null,
|
||||
});
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (!isNativeScale) {
|
||||
const img = imageRef.current;
|
||||
if (img) {
|
||||
const rect = img.getBoundingClientRect();
|
||||
const xRatio = rect.width > 0 ? (event.clientX - rect.left) / rect.width : 0.5;
|
||||
const yRatio = rect.height > 0 ? (event.clientY - rect.top) / rect.height : 0.5;
|
||||
focusRef.current = {
|
||||
xRatio: clamp(xRatio, 0, 1),
|
||||
yRatio: clamp(yRatio, 0, 1),
|
||||
};
|
||||
} else {
|
||||
focusRef.current = null;
|
||||
}
|
||||
} else {
|
||||
focusRef.current = null;
|
||||
}
|
||||
setIsNativeScale((current) => !current);
|
||||
}}
|
||||
style={imageStyle}
|
||||
/>
|
||||
</div>
|
||||
{navVisible ? (
|
||||
<div className="preview-zoom__nav">
|
||||
<button
|
||||
type="button"
|
||||
className="preview-zoom__nav-button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (display?.canGoPrev && display?.goPrev) {
|
||||
display.goPrev();
|
||||
}
|
||||
}}
|
||||
aria-label="Previous preview"
|
||||
disabled={!display?.canGoPrev}
|
||||
>
|
||||
<ArrowLeftIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="preview-zoom__nav-button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (display?.canGoNext && display?.goNext) {
|
||||
display.goNext();
|
||||
}
|
||||
}}
|
||||
aria-label="Next preview"
|
||||
disabled={!display?.canGoNext}
|
||||
>
|
||||
<ArrowRightIcon />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
portalTarget,
|
||||
);
|
||||
};
|
||||
|
||||
export default PreviewZoomOverlay;
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { getAssetFromVersion, resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
|
||||
import { getTagColorStyle } from '../utils/colors';
|
||||
import { DownloadIcon, EditIcon, ViewListIcon, ViewGridIcon, FolderIcon, TrashIcon } from '../ui/icons';
|
||||
@@ -13,13 +13,62 @@ const getPageCount = (doc) =>
|
||||
? doc.current_version.metadata.page_count
|
||||
: null;
|
||||
|
||||
// Detects when an element becomes visible within a scroll container.
|
||||
const useLazyVisibility = (rootRef, resetKey) => {
|
||||
const targetRef = useRef(null);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsVisible(false);
|
||||
}, [resetKey]);
|
||||
|
||||
const rootNode = rootRef?.current || null;
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible) {
|
||||
return undefined;
|
||||
}
|
||||
const element = targetRef.current;
|
||||
if (!element) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof window === 'undefined' || typeof IntersectionObserver === 'undefined') {
|
||||
setIsVisible(true);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
setIsVisible(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
root: rootNode,
|
||||
rootMargin: '200px 0px',
|
||||
threshold: 0.01,
|
||||
},
|
||||
);
|
||||
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [isVisible, rootNode, resetKey]);
|
||||
|
||||
return { ref: targetRef, isVisible };
|
||||
};
|
||||
|
||||
const DocumentThumbnailImage = ({
|
||||
document,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
alt,
|
||||
maxSize = LIST_ICON_SIZE,
|
||||
scrollRootRef = null,
|
||||
}) => {
|
||||
const { ref: visibilityRef, isVisible } = useLazyVisibility(scrollRootRef, document?.id);
|
||||
const resolvedMaxSize = Math.max(1, Math.round(maxSize || 1));
|
||||
const thumbnailAsset = useMemo(
|
||||
() => getAssetFromVersion(document?.current_version, 'thumbnail'),
|
||||
@@ -45,14 +94,15 @@ const DocumentThumbnailImage = ({
|
||||
() => ({ width: `${dimensions.width}px`, height: `${dimensions.height}px` }),
|
||||
[dimensions.height, dimensions.width],
|
||||
);
|
||||
const url = useMemo(
|
||||
() =>
|
||||
resolveDocumentAssetUrl(document, 'thumbnail', {
|
||||
ensureAssetUrl,
|
||||
getAsset: getDocumentAsset,
|
||||
}),
|
||||
[document, ensureAssetUrl, getDocumentAsset],
|
||||
);
|
||||
const url = useMemo(() => {
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
return resolveDocumentAssetUrl(document, 'thumbnail', {
|
||||
ensureAssetUrl,
|
||||
getAsset: getDocumentAsset,
|
||||
});
|
||||
}, [document, ensureAssetUrl, getDocumentAsset, isVisible]);
|
||||
|
||||
const pageCount = getPageCount(document);
|
||||
const showMultiPageBadge = Number.isFinite(pageCount) && pageCount > 1;
|
||||
@@ -62,13 +112,15 @@ const DocumentThumbnailImage = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="document-thumbnail-wrapper">
|
||||
<div className="document-thumbnail-wrapper" ref={visibilityRef}>
|
||||
<div className={innerClasses.join(' ')} style={innerStyle}>
|
||||
{url ? (
|
||||
<img
|
||||
src={url}
|
||||
alt={alt || ''}
|
||||
className="document-thumbnail"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
onDragStart={(event) => event.preventDefault()}
|
||||
/>
|
||||
@@ -125,6 +177,7 @@ const DocumentsTable = ({
|
||||
viewMode = 'list',
|
||||
onViewModeChange,
|
||||
onClearSelection,
|
||||
showHeader = true,
|
||||
}) => {
|
||||
const showingSearchResults = searchResults !== null;
|
||||
const rows = showingSearchResults ? searchResults : documents;
|
||||
@@ -142,6 +195,18 @@ const DocumentsTable = ({
|
||||
[draggingDocumentIds],
|
||||
);
|
||||
const scrollRef = useRef(null);
|
||||
const [, forceVisibilityTick] = useState(0);
|
||||
const lastScrollNodeRef = useRef(null);
|
||||
const assignScrollRef = useCallback((node) => {
|
||||
if (lastScrollNodeRef.current === node) {
|
||||
return;
|
||||
}
|
||||
lastScrollNodeRef.current = node;
|
||||
scrollRef.current = node;
|
||||
if (node) {
|
||||
forceVisibilityTick((value) => value + 1);
|
||||
}
|
||||
}, []);
|
||||
const isGridView = viewMode === 'grid';
|
||||
const gridIconSize = DEFAULT_GRID_ICON_SIZE;
|
||||
const handleSetViewMode = useCallback(
|
||||
@@ -156,6 +221,11 @@ const DocumentsTable = ({
|
||||
},
|
||||
[onViewModeChange],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = 0;
|
||||
}
|
||||
}, [viewMode]);
|
||||
const isTagDragEvent = useCallback((event) => {
|
||||
const types = Array.from(event.dataTransfer?.types || []);
|
||||
return TAG_MIME_TYPES.some((type) => types.includes(type));
|
||||
@@ -272,51 +342,53 @@ const DocumentsTable = ({
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`documents-panel column documents-panel--view-${isGridView ? 'grid' : 'list'}`}
|
||||
className={`documents-panel documents-panel--view-${isGridView ? 'grid' : 'list'}`}
|
||||
>
|
||||
<div className="column-header">
|
||||
<div className="column-header__titles">
|
||||
<h2>{currentFolderName}</h2>
|
||||
{showingSearchResults && (
|
||||
<div className="column-subtitle">Search results</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<div className="view-toggle" role="group" aria-label="Change view">
|
||||
{showHeader ? (
|
||||
<div className="panel-section__header">
|
||||
<div className="panel-section__titles">
|
||||
<h2>{currentFolderName}</h2>
|
||||
{showingSearchResults && (
|
||||
<div className="panel-section__subtitle">Search results</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<div className="view-toggle" role="group" aria-label="Change view">
|
||||
<button
|
||||
type="button"
|
||||
className={`view-toggle__button${isGridView ? '' : ' active'}`}
|
||||
onClick={() => handleSetViewMode('list')}
|
||||
aria-pressed={!isGridView}
|
||||
title="List view"
|
||||
>
|
||||
<ViewListIcon className="view-toggle__icon" size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`view-toggle__button${isGridView ? ' active' : ''}`}
|
||||
onClick={() => handleSetViewMode('grid')}
|
||||
aria-pressed={isGridView}
|
||||
title="Icons view"
|
||||
>
|
||||
<ViewGridIcon className="view-toggle__icon" size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`view-toggle__button${isGridView ? '' : ' active'}`}
|
||||
onClick={() => handleSetViewMode('list')}
|
||||
aria-pressed={!isGridView}
|
||||
title="List view"
|
||||
onClick={onRequestCreateFolder}
|
||||
disabled={creatingFolder}
|
||||
>
|
||||
<ViewListIcon className="view-toggle__icon" size={18} />
|
||||
{creatingFolder ? 'Creating…' : 'New folder'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`view-toggle__button${isGridView ? ' active' : ''}`}
|
||||
onClick={() => handleSetViewMode('grid')}
|
||||
aria-pressed={isGridView}
|
||||
title="Icons view"
|
||||
>
|
||||
<ViewGridIcon className="view-toggle__icon" size={18} />
|
||||
<button className="secondary" onClick={onRefresh}>
|
||||
Refresh
|
||||
</button>
|
||||
<button className="secondary" type="button" onClick={onShowSkeuoWorkspace}>
|
||||
Desk View
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRequestCreateFolder}
|
||||
disabled={creatingFolder}
|
||||
>
|
||||
{creatingFolder ? 'Creating…' : 'New folder'}
|
||||
</button>
|
||||
<button className="secondary" onClick={onRefresh}>
|
||||
Refresh
|
||||
</button>
|
||||
<button className="secondary" type="button" onClick={onShowSkeuoWorkspace}>
|
||||
Desk View
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{showDefaultEmptyState && (
|
||||
<div className="empty-state">
|
||||
Drop files anywhere or onto a folder to upload documents.
|
||||
@@ -327,9 +399,9 @@ const DocumentsTable = ({
|
||||
No documents match the current filters.
|
||||
</div>
|
||||
)}
|
||||
<div className="column-body">
|
||||
<div className="panel-section__body">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
ref={assignScrollRef}
|
||||
className="documents-scroll"
|
||||
onFocus={(event) => {
|
||||
if (event.target === scrollRef.current) {
|
||||
@@ -454,6 +526,7 @@ const DocumentsTable = ({
|
||||
getDocumentAsset={getDocumentAsset}
|
||||
alt={`Thumbnail for ${doc.title || doc.original_name}`}
|
||||
maxSize={gridIconSize}
|
||||
scrollRootRef={scrollRef}
|
||||
/>
|
||||
<div className="document-card__meta">
|
||||
<div
|
||||
@@ -670,6 +743,7 @@ const DocumentsTable = ({
|
||||
ensureAssetUrl={ensureAssetUrl}
|
||||
getDocumentAsset={getDocumentAsset}
|
||||
alt={`Thumbnail for ${doc.title || doc.original_name}`}
|
||||
scrollRootRef={scrollRef}
|
||||
/>
|
||||
</td>
|
||||
<td className="doc-list__name">
|
||||
|
||||
+580
-27
@@ -32,6 +32,16 @@ import CorrespondentsPanel from './correspondents/CorrespondentsPanel';
|
||||
import { CORRESPONDENT_ROLES } from './constants/correspondents';
|
||||
import TagManager from './tag_manager';
|
||||
import Sidebar from './sidebar/Sidebar';
|
||||
import {
|
||||
ChevronsRightIcon,
|
||||
ChevronsLeftIcon,
|
||||
ViewListIcon,
|
||||
ViewGridIcon,
|
||||
FolderPlusIcon,
|
||||
RefreshIcon,
|
||||
ArrowUpIcon,
|
||||
MinusVerticalIcon,
|
||||
} from './ui/icons';
|
||||
import DocumentsTable from './documents/DocumentsTable';
|
||||
import { AppShellContext, useAppShell } from './appShellContext';
|
||||
import DocumentViewerRoute from './routes/DocumentViewerRoute';
|
||||
@@ -45,7 +55,26 @@ const DEFAULT_DEV_API = 'http://127.0.0.1:3000';
|
||||
const ASSET_PRESIGN_TTL_MS = 240 * 1000; // backend issues 5 min tokens; refresh slightly early
|
||||
const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
|
||||
|
||||
const API_ROOT = (runtimeApiBase || process.env.API_BASE_URL || DEFAULT_DEV_API).replace(/\/$/, '');
|
||||
const ENV = typeof process !== 'undefined' && process?.env ? process.env : {};
|
||||
|
||||
const API_ROOT = (runtimeApiBase || ENV.API_BASE_URL || DEFAULT_DEV_API).replace(/\/$/, '');
|
||||
|
||||
const DEFAULT_OLLAMA_URL = 'http://127.0.0.1:11434';
|
||||
const runtimeOllamaUrl =
|
||||
typeof window !== 'undefined' && window.__PAPERCRATE_OLLAMA_URL
|
||||
? String(window.__PAPERCRATE_OLLAMA_URL)
|
||||
: '';
|
||||
const envOllamaUrl = (ENV.OLLAMA_BASE_URL || '').trim();
|
||||
const rawOllamaUrl = (runtimeOllamaUrl || envOllamaUrl || DEFAULT_OLLAMA_URL).trim();
|
||||
const OLLAMA_BASE_URL = rawOllamaUrl ? rawOllamaUrl.replace(/\/$/, '') : null;
|
||||
|
||||
const DEFAULT_OLLAMA_MODEL = 'gpt-oss';
|
||||
const runtimeOllamaModel =
|
||||
typeof window !== 'undefined' && window.__PAPERCRATE_OLLAMA_MODEL
|
||||
? String(window.__PAPERCRATE_OLLAMA_MODEL)
|
||||
: '';
|
||||
const envOllamaModel = (ENV.OLLAMA_MODEL || '').trim();
|
||||
const OLLAMA_MODEL = (runtimeOllamaModel || envOllamaModel || DEFAULT_OLLAMA_MODEL).trim() || DEFAULT_OLLAMA_MODEL;
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_ROOT ? `${API_ROOT}/api` : '/api',
|
||||
@@ -53,6 +82,16 @@ const api = axios.create({
|
||||
});
|
||||
|
||||
const STORED_TOKEN = window.localStorage.getItem('papercrate_token') || '';
|
||||
let STORED_TENANT = null;
|
||||
try {
|
||||
const rawTenant = window.localStorage.getItem('papercrate_tenant');
|
||||
if (rawTenant) {
|
||||
STORED_TENANT = JSON.parse(rawTenant);
|
||||
}
|
||||
} catch (
|
||||
// eslint-disable-next-line no-empty
|
||||
error
|
||||
) {}
|
||||
if (STORED_TOKEN) {
|
||||
api.defaults.headers.common.Authorization = `Bearer ${STORED_TOKEN}`;
|
||||
}
|
||||
@@ -63,6 +102,8 @@ const initialAppState = {
|
||||
error: null,
|
||||
isRefreshing: false,
|
||||
tenantSelection: null,
|
||||
tenant: STORED_TENANT,
|
||||
tenants: [],
|
||||
};
|
||||
|
||||
const AppStateContext = React.createContext(null);
|
||||
@@ -71,7 +112,14 @@ const AppDispatchContext = React.createContext(null);
|
||||
const appStateReducer = (state, action) => {
|
||||
switch (action.type) {
|
||||
case 'LOGIN_REQUEST':
|
||||
return { ...state, status: 'authenticating', error: null, tenantSelection: null };
|
||||
return {
|
||||
...state,
|
||||
status: 'authenticating',
|
||||
error: null,
|
||||
tenantSelection: null,
|
||||
tenant: null,
|
||||
tenants: [],
|
||||
};
|
||||
case 'LOGIN_SUCCESS':
|
||||
return {
|
||||
...state,
|
||||
@@ -79,6 +127,8 @@ const appStateReducer = (state, action) => {
|
||||
token: action.token,
|
||||
error: null,
|
||||
tenantSelection: null,
|
||||
tenant: action.tenant || null,
|
||||
tenants: state.tenants,
|
||||
};
|
||||
case 'LOGIN_FAILURE':
|
||||
return {
|
||||
@@ -87,6 +137,8 @@ const appStateReducer = (state, action) => {
|
||||
error: action.error || null,
|
||||
isRefreshing: false,
|
||||
tenantSelection: null,
|
||||
tenant: null,
|
||||
tenants: [],
|
||||
};
|
||||
case 'TENANT_SELECTION_REQUIRED':
|
||||
return {
|
||||
@@ -98,6 +150,8 @@ const appStateReducer = (state, action) => {
|
||||
selectionToken: action.selectionToken,
|
||||
tenants: action.tenants,
|
||||
},
|
||||
tenant: null,
|
||||
tenants: [],
|
||||
};
|
||||
case 'CLEAR_TENANT_SELECTION':
|
||||
return {
|
||||
@@ -106,6 +160,8 @@ const appStateReducer = (state, action) => {
|
||||
error: null,
|
||||
isRefreshing: false,
|
||||
tenantSelection: null,
|
||||
tenant: null,
|
||||
tenants: [],
|
||||
};
|
||||
case 'BOOTSTRAP_START':
|
||||
return { ...state, status: 'bootstrapping', error: null };
|
||||
@@ -122,6 +178,8 @@ const appStateReducer = (state, action) => {
|
||||
isRefreshing: false,
|
||||
status: state.status === 'logged-out' ? 'authenticated' : state.status,
|
||||
tenantSelection: null,
|
||||
tenant: action.tenant || state.tenant || null,
|
||||
tenants: state.tenants,
|
||||
};
|
||||
case 'TOKEN_REFRESH_FAILURE':
|
||||
return {
|
||||
@@ -130,11 +188,26 @@ const appStateReducer = (state, action) => {
|
||||
error: action.error || null,
|
||||
isRefreshing: false,
|
||||
tenantSelection: null,
|
||||
tenant: null,
|
||||
tenants: [],
|
||||
};
|
||||
case 'LOGOUT':
|
||||
return { status: 'logged-out', token: '', error: null, isRefreshing: false, tenantSelection: null };
|
||||
return {
|
||||
status: 'logged-out',
|
||||
token: '',
|
||||
error: null,
|
||||
isRefreshing: false,
|
||||
tenantSelection: null,
|
||||
tenant: null,
|
||||
tenants: [],
|
||||
};
|
||||
case 'RESET_ERROR':
|
||||
return { ...state, error: null };
|
||||
case 'SET_TENANTS':
|
||||
return {
|
||||
...state,
|
||||
tenants: Array.isArray(action.tenants) ? action.tenants : [],
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
@@ -154,6 +227,45 @@ const AppStateProvider = ({ children }) => {
|
||||
}
|
||||
}, [state.token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.tenant) {
|
||||
try {
|
||||
window.localStorage.setItem('papercrate_tenant', JSON.stringify(state.tenant));
|
||||
} catch (error) {
|
||||
console.warn('Failed to persist tenant info', error);
|
||||
}
|
||||
} else {
|
||||
window.localStorage.removeItem('papercrate_tenant');
|
||||
}
|
||||
}, [state.tenant]);
|
||||
|
||||
useEffect(() => {
|
||||
let abort = false;
|
||||
const loadTenants = async () => {
|
||||
if (state.status !== 'authenticated' || !state.token) {
|
||||
dispatch({ type: 'SET_TENANTS', tenants: [] });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { data } = await api.get('/auth/tenants');
|
||||
if (!abort) {
|
||||
dispatch({
|
||||
type: 'SET_TENANTS',
|
||||
tenants: Array.isArray(data?.tenants) ? data.tenants : [],
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (!abort) {
|
||||
console.warn('Failed to load tenant list', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
loadTenants();
|
||||
return () => {
|
||||
abort = true;
|
||||
};
|
||||
}, [state.status, state.token, dispatch]);
|
||||
|
||||
const stateValue = useMemo(() => state, [state]);
|
||||
|
||||
return (
|
||||
@@ -311,9 +423,9 @@ const LoginView = ({
|
||||
|
||||
|
||||
|
||||
const DocumentsLayout = ({ sidebarProps, children }) => (
|
||||
<main className="documents-main">
|
||||
<Sidebar {...sidebarProps} />
|
||||
const DocumentsLayout = ({ sidebarProps, children, sidebarCollapsed }) => (
|
||||
<main className={`documents-main${sidebarCollapsed ? ' documents-main--sidebar-collapsed' : ''}`}>
|
||||
{!sidebarCollapsed ? <Sidebar {...sidebarProps} /> : null}
|
||||
{children}
|
||||
</main>
|
||||
);
|
||||
@@ -337,6 +449,9 @@ const AppLayout = () => {
|
||||
({ message, variant }) => setStatusMessage(message, variant),
|
||||
[setStatusMessage],
|
||||
);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const collapseSidebar = useCallback(() => setSidebarCollapsed(true), []);
|
||||
const expandSidebar = useCallback(() => setSidebarCollapsed(false), []);
|
||||
const reportApiError = useApiError({
|
||||
onReport: handleApiReport,
|
||||
});
|
||||
@@ -403,7 +518,11 @@ const AppLayout = () => {
|
||||
try {
|
||||
const { data } = await api.post('/auth/refresh');
|
||||
if (data?.access_token) {
|
||||
appDispatch({ type: 'TOKEN_REFRESH_SUCCESS', token: data.access_token });
|
||||
appDispatch({
|
||||
type: 'TOKEN_REFRESH_SUCCESS',
|
||||
token: data.access_token,
|
||||
tenant: data.tenant || null,
|
||||
});
|
||||
console.log('[Auth] Access token refreshed at', new Date().toISOString());
|
||||
return data.access_token;
|
||||
}
|
||||
@@ -581,6 +700,7 @@ const AppLayout = () => {
|
||||
const selectionInitializedRef = useRef(false);
|
||||
const dragCounterRef = useRef(0);
|
||||
const prefetchedFoldersRef = useRef(new Set(['root']));
|
||||
const detailFolderFetchRef = useRef(new Set());
|
||||
const selectionAnchorRef = useRef(initialRowSelection[initialRowSelection.length - 1] || null);
|
||||
const selectionOrderRef = useRef(initialRowSelection);
|
||||
|
||||
@@ -630,6 +750,7 @@ const AppLayout = () => {
|
||||
dragCounterRef.current = 0;
|
||||
prefetchedFoldersRef.current = new Set(['root']);
|
||||
breadcrumbFetchRef.current = new Set();
|
||||
detailFolderFetchRef.current = new Set();
|
||||
bootstrapInitializedRef.current = false;
|
||||
selectionInitializedRef.current = false;
|
||||
}, [assetManager]);
|
||||
@@ -1855,19 +1976,134 @@ const AppLayout = () => {
|
||||
greedyPrefetchFolders(pendingIds).catch(() => {});
|
||||
}, [folderContents, greedyPrefetchFolders]);
|
||||
|
||||
const expandFolderAncestors = useCallback(
|
||||
(targetId) => {
|
||||
if (!targetId || targetId === 'root') {
|
||||
setFolderNodes((prev) => {
|
||||
if (prev.get('root')?.expanded) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
const rootNode = next.get('root');
|
||||
if (rootNode) {
|
||||
next.set('root', { ...rootNode, expanded: true });
|
||||
}
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setFolderNodes((prev) => {
|
||||
const next = new Map(prev);
|
||||
let currentId = targetId;
|
||||
let guard = 0;
|
||||
while (currentId && !next.has(currentId) && guard < 32) {
|
||||
guard += 1;
|
||||
const node = prev.get(currentId);
|
||||
if (!node) {
|
||||
break;
|
||||
}
|
||||
currentId = node.parentId ?? 'root';
|
||||
}
|
||||
|
||||
currentId = targetId;
|
||||
guard = 0;
|
||||
while (currentId && guard < 32) {
|
||||
guard += 1;
|
||||
const node = next.get(currentId);
|
||||
if (!node) {
|
||||
break;
|
||||
}
|
||||
if (!node.expanded && currentId !== targetId) {
|
||||
next.set(currentId, { ...node, expanded: true });
|
||||
}
|
||||
currentId = node.parentId ?? 'root';
|
||||
if (!currentId || currentId === 'root') {
|
||||
const rootNode = next.get('root');
|
||||
if (rootNode && !rootNode.expanded) {
|
||||
next.set('root', { ...rootNode, expanded: true });
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[]);
|
||||
|
||||
const ensureFolderAncestorsLoaded = useCallback(
|
||||
async (targetId) => {
|
||||
if (!targetId || targetId === 'root') {
|
||||
return;
|
||||
}
|
||||
|
||||
const visited = new Set();
|
||||
const stack = [];
|
||||
|
||||
let currentId = targetId;
|
||||
let guard = 0;
|
||||
while (currentId && currentId !== 'root' && guard < 32) {
|
||||
guard += 1;
|
||||
if (visited.has(currentId)) {
|
||||
break;
|
||||
}
|
||||
visited.add(currentId);
|
||||
|
||||
const node = folderNodes.get(currentId);
|
||||
if (!node) {
|
||||
stack.push(currentId);
|
||||
break;
|
||||
}
|
||||
|
||||
const parentId = node.parentId ?? 'root';
|
||||
if (!parentId || parentId === 'root') {
|
||||
break;
|
||||
}
|
||||
stack.push(parentId);
|
||||
currentId = parentId;
|
||||
}
|
||||
|
||||
while (stack.length) {
|
||||
const ancestorId = stack.pop();
|
||||
if (!ancestorId || ancestorId === 'root') {
|
||||
continue;
|
||||
}
|
||||
if (!folderNodes.has(ancestorId)) {
|
||||
try {
|
||||
await ensureFolderData(ancestorId, { includeDocuments: false, force: false });
|
||||
} catch (error) {
|
||||
console.warn('Failed to ensure ancestor folder for navigation', ancestorId, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[folderNodes, ensureFolderData],
|
||||
);
|
||||
|
||||
const selectFolder = useCallback(
|
||||
async (folderId, { replace = false, immediate = false } = {}) => {
|
||||
const targetId = folderId && folderId !== 'root' ? folderId : 'root';
|
||||
|
||||
await ensureFolderAncestorsLoaded(targetId);
|
||||
expandFolderAncestors(targetId);
|
||||
|
||||
if (!navigate || immediate) {
|
||||
await loadFolder(targetId, { preserveSearch: isFilterActive });
|
||||
setSelectedFolder(targetId);
|
||||
return;
|
||||
}
|
||||
|
||||
const path = targetId === 'root' ? '/documents' : `/documents/folder/${targetId}`;
|
||||
navigate(path, { replace });
|
||||
},
|
||||
[loadFolder, navigate, isFilterActive],
|
||||
[
|
||||
ensureFolderAncestorsLoaded,
|
||||
expandFolderAncestors,
|
||||
navigate,
|
||||
loadFolder,
|
||||
isFilterActive,
|
||||
setSelectedFolder,
|
||||
],
|
||||
);
|
||||
|
||||
const initializeAfterLogin = useCallback(async () => {
|
||||
@@ -3334,6 +3570,38 @@ const AppLayout = () => {
|
||||
[assetManager, notifyApiError, setStatusMessage, updateDocumentCaches],
|
||||
);
|
||||
|
||||
const handleDocumentIssuedAtUpdate = useCallback(
|
||||
async (documentId, nextIssuedAt) => {
|
||||
const payload = {
|
||||
issued_at: nextIssuedAt ? new Date(nextIssuedAt).toISOString() : null,
|
||||
};
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await api.patch(`/documents/${documentId}`, payload);
|
||||
const hydratedDetail = assetManager.hydrateDetail(data);
|
||||
const hydratedDocument = hydratedDetail?.document || data.document || data;
|
||||
|
||||
updateDocumentCaches(documentId, (doc) => {
|
||||
if (hydratedDocument) {
|
||||
return { ...doc, ...hydratedDocument };
|
||||
}
|
||||
return { ...doc, issued_at: payload.issued_at };
|
||||
});
|
||||
|
||||
setStatusMessage('Document issued date updated.', 'success');
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.error || 'Failed to update issued date.';
|
||||
notifyApiError(error, message);
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[assetManager, api, notifyApiError, setStatusMessage, updateDocumentCaches],
|
||||
);
|
||||
|
||||
const applyTagRemovalToCaches = useCallback(
|
||||
(documentId, tagId) => {
|
||||
if (!documentId || !tagId) {
|
||||
@@ -4066,10 +4334,10 @@ const AppLayout = () => {
|
||||
appDispatch({ type: 'LOGIN_REQUEST' });
|
||||
const { data } = await api.post('/auth/login', payload);
|
||||
|
||||
if (data?.selection_token && Array.isArray(data?.tenants)) {
|
||||
if (data?.access_token && Array.isArray(data?.tenants)) {
|
||||
appDispatch({
|
||||
type: 'TENANT_SELECTION_REQUIRED',
|
||||
selectionToken: data.selection_token,
|
||||
selectionToken: data.access_token,
|
||||
tenants: data.tenants,
|
||||
});
|
||||
setStatusMessage('Select a tenant to continue.', 'info');
|
||||
@@ -4080,7 +4348,11 @@ const AppLayout = () => {
|
||||
throw new Error('Invalid login response.');
|
||||
}
|
||||
|
||||
appDispatch({ type: 'LOGIN_SUCCESS', token: data.access_token });
|
||||
appDispatch({
|
||||
type: 'LOGIN_SUCCESS',
|
||||
token: data.access_token,
|
||||
tenant: data.tenant || null,
|
||||
});
|
||||
setStatusMessage('Login successful.', 'success');
|
||||
} catch (error) {
|
||||
const message = error?.response?.data?.error || 'Login failed. Check credentials.';
|
||||
@@ -4437,6 +4709,95 @@ const AppLayout = () => {
|
||||
});
|
||||
}, [missingBreadcrumbAncestors, ensureFolderData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!orderedSelectedDocuments.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const visited = new Set();
|
||||
|
||||
orderedSelectedDocuments.forEach((doc) => {
|
||||
const folderId = doc?.folder_id;
|
||||
if (!folderId) {
|
||||
return;
|
||||
}
|
||||
let currentId = folderId;
|
||||
let guard = 0;
|
||||
while (currentId && currentId !== 'root' && guard < 32) {
|
||||
guard += 1;
|
||||
if (visited.has(currentId)) {
|
||||
break;
|
||||
}
|
||||
visited.add(currentId);
|
||||
const node = folderNodes.get(currentId);
|
||||
if (!node) {
|
||||
if (!detailFolderFetchRef.current.has(currentId)) {
|
||||
detailFolderFetchRef.current.add(currentId);
|
||||
ensureFolderData(currentId, { force: false, includeDocuments: false })
|
||||
.catch((error) => {
|
||||
console.warn('Failed to preload folder metadata for detail path', currentId, error);
|
||||
})
|
||||
.finally(() => {
|
||||
detailFolderFetchRef.current.delete(currentId);
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const parentId = node.parentId ?? 'root';
|
||||
if (!parentId || parentId === 'root') {
|
||||
break;
|
||||
}
|
||||
currentId = parentId;
|
||||
}
|
||||
});
|
||||
}, [orderedSelectedDocuments, folderNodes, ensureFolderData]);
|
||||
|
||||
const resolveFolderPath = useCallback(
|
||||
(folderId) => {
|
||||
if (!folderId || folderId === 'root') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const segments = [];
|
||||
const visited = new Set();
|
||||
let currentId = folderId;
|
||||
let guard = 0;
|
||||
|
||||
while (currentId && guard < 32 && !visited.has(currentId)) {
|
||||
guard += 1;
|
||||
visited.add(currentId);
|
||||
|
||||
if (currentId === 'root') {
|
||||
break;
|
||||
}
|
||||
|
||||
const node = folderNodes.get(currentId);
|
||||
if (!node) {
|
||||
segments.push({ id: currentId, name: '…' });
|
||||
break;
|
||||
}
|
||||
|
||||
segments.push({ id: node.id, name: node.name || 'Folder' });
|
||||
|
||||
const parentId = node.parentId ?? 'root';
|
||||
if (!parentId || parentId === 'root') {
|
||||
segments.push({ id: 'root', name: DEFAULT_FOLDER_NAME });
|
||||
break;
|
||||
}
|
||||
|
||||
currentId = parentId;
|
||||
}
|
||||
|
||||
if (!segments.some((segment) => segment.id === 'root')) {
|
||||
segments.push({ id: 'root', name: DEFAULT_FOLDER_NAME });
|
||||
}
|
||||
|
||||
return segments.reverse();
|
||||
},
|
||||
[folderNodes],
|
||||
);
|
||||
|
||||
const selectedPreviewEntry = useMemo(() => {
|
||||
if (!selectedDocument) {
|
||||
return null;
|
||||
@@ -4488,6 +4849,7 @@ const AppLayout = () => {
|
||||
isFilterActive,
|
||||
onLogout: handleLogout,
|
||||
status,
|
||||
onCollapse: collapseSidebar,
|
||||
};
|
||||
|
||||
const resolveThumbnailUrlForDoc = useCallback(
|
||||
@@ -4578,13 +4940,18 @@ const AppLayout = () => {
|
||||
onPromoteSelection: promoteSelectionOrder,
|
||||
activePreviewId,
|
||||
onUpdateTitle: handleDocumentTitleUpdate,
|
||||
onUpdateIssuedAt: handleDocumentIssuedAtUpdate,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
correspondents,
|
||||
onCorrespondentAdd: handleCorrespondentAdd,
|
||||
onCorrespondentRemove: handleCorrespondentRemove,
|
||||
resolveApiPath,
|
||||
onFolderNavigate: selectFolder,
|
||||
onClose: clearDocumentSelection,
|
||||
resolveFolderPath,
|
||||
ollamaUrl: OLLAMA_BASE_URL,
|
||||
ollamaModel: OLLAMA_MODEL,
|
||||
};
|
||||
|
||||
const skeuoWorkspaceProps = useMemo(
|
||||
@@ -4700,11 +5067,16 @@ const AppLayout = () => {
|
||||
);
|
||||
|
||||
if (['logged-out', 'authenticating', 'selecting-tenant'].includes(appStatus)) {
|
||||
const shouldRememberLastLocation = appStatus !== 'logged-out';
|
||||
return (
|
||||
<Navigate
|
||||
to="/account/login"
|
||||
replace
|
||||
state={{ from: location.pathname + location.search }}
|
||||
state={
|
||||
shouldRememberLastLocation
|
||||
? { from: location.pathname + location.search }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -4847,36 +5219,209 @@ const DocumentsRoute = () => {
|
||||
skeuoWorkspaceProps,
|
||||
openTagsModal,
|
||||
openCorrespondentsModal,
|
||||
exitSkeuoWorkspace,
|
||||
} = useAppShell();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const collapseSidebar = useCallback(() => setSidebarCollapsed(true), []);
|
||||
const expandSidebar = useCallback(() => setSidebarCollapsed(false), []);
|
||||
|
||||
const sidebarPropsWithActions = useMemo(
|
||||
() => ({
|
||||
...sidebarProps,
|
||||
onManageTags: openTagsModal,
|
||||
onManageCorrespondents: openCorrespondentsModal,
|
||||
onCollapse: collapseSidebar,
|
||||
}),
|
||||
[sidebarProps, openTagsModal, openCorrespondentsModal],
|
||||
[sidebarProps, openTagsModal, openCorrespondentsModal, collapseSidebar],
|
||||
);
|
||||
|
||||
const {
|
||||
currentFolderName,
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
onRequestCreateFolder,
|
||||
creatingFolder,
|
||||
onRefresh,
|
||||
onShowSkeuoWorkspace,
|
||||
searchResults,
|
||||
breadcrumbs,
|
||||
} = documentsTableProps;
|
||||
|
||||
const isGridView = viewMode === 'grid';
|
||||
const showingSearchResults = Array.isArray(searchResults);
|
||||
const headerTitle = showingSearchResults ? 'Search results' : currentFolderName;
|
||||
const headerSubtitle = showingSearchResults
|
||||
? `${searchResults.length} matching document${searchResults.length === 1 ? '' : 's'}`
|
||||
: null;
|
||||
const parentBreadcrumb = breadcrumbs && breadcrumbs.length > 1 ? breadcrumbs[breadcrumbs.length - 2] : null;
|
||||
const handleNavigateParent = parentBreadcrumb
|
||||
? () => {
|
||||
const target = parentBreadcrumb.id === 'root'
|
||||
? '/documents'
|
||||
: `/documents/folder/${parentBreadcrumb.id}`;
|
||||
navigate(target);
|
||||
}
|
||||
: null;
|
||||
|
||||
const detailHasContent = detailPanelProps.selectedDocuments?.length > 0;
|
||||
const toggleSidebar = sidebarCollapsed ? expandSidebar : collapseSidebar;
|
||||
const ToggleIcon = sidebarCollapsed ? ChevronsRightIcon : ChevronsLeftIcon;
|
||||
const toggleLabel = sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar';
|
||||
|
||||
if (workspaceMode === 'skeuo') {
|
||||
return (
|
||||
<DocumentsLayout sidebarProps={sidebarPropsWithActions}>
|
||||
<DocumentsLayout
|
||||
sidebarProps={sidebarPropsWithActions}
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
>
|
||||
<div className="main-content">
|
||||
<SkeuomorphicWorkspace {...skeuoWorkspaceProps} />
|
||||
<div className="panel-header main-content__header">
|
||||
<div className="panel-actions main-content__actions">
|
||||
{sidebarCollapsed ? (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button ghost"
|
||||
onClick={toggleSidebar}
|
||||
aria-label={toggleLabel}
|
||||
title={toggleLabel}
|
||||
>
|
||||
<ToggleIcon />
|
||||
</button>
|
||||
) : null}
|
||||
{parentBreadcrumb ? (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button ghost"
|
||||
onClick={handleNavigateParent}
|
||||
aria-label="Go to parent folder"
|
||||
title="Go to parent folder"
|
||||
>
|
||||
<ArrowUpIcon />
|
||||
</button>
|
||||
) : null}
|
||||
<h2 className="main-content__title">
|
||||
{headerTitle}
|
||||
{headerSubtitle ? (
|
||||
<span className="main-content__subtitle">{headerSubtitle}</span>
|
||||
) : null}
|
||||
</h2>
|
||||
<div className="spacer" />
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button ghost"
|
||||
onClick={skeuoWorkspaceProps.onRefresh}
|
||||
aria-label="Refresh"
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshIcon />
|
||||
</button>
|
||||
<button type="button" className="secondary" onClick={exitSkeuoWorkspace}>
|
||||
Back to List
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="main-content__body">
|
||||
<SkeuomorphicWorkspace {...skeuoWorkspaceProps} />
|
||||
</div>
|
||||
</div>
|
||||
</DocumentsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const detailHasContent = detailPanelProps.selectedDocuments?.length > 0;
|
||||
|
||||
return (
|
||||
<DocumentsLayout sidebarProps={sidebarPropsWithActions}>
|
||||
<div
|
||||
className={`main-content main-content--documents${detailHasContent ? ' main-content--has-detail' : ''}`}
|
||||
>
|
||||
<DocumentsTable {...documentsTableProps} />
|
||||
{detailHasContent ? <DetailPanel {...detailPanelProps} /> : null}
|
||||
<DocumentsLayout
|
||||
sidebarProps={sidebarPropsWithActions}
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
>
|
||||
<div className={`main-content main-content--documents${detailHasContent ? ' main-content--has-detail' : ''}`}>
|
||||
<div className="panel-header main-content__header">
|
||||
<div className="panel-actions main-content__actions">
|
||||
{sidebarCollapsed ? (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button ghost"
|
||||
onClick={toggleSidebar}
|
||||
aria-label={toggleLabel}
|
||||
title={toggleLabel}
|
||||
>
|
||||
<ToggleIcon />
|
||||
</button>
|
||||
) : null}
|
||||
{parentBreadcrumb ? (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button ghost"
|
||||
onClick={handleNavigateParent}
|
||||
aria-label="Go to parent folder"
|
||||
title="Go to parent folder"
|
||||
>
|
||||
<ArrowUpIcon />
|
||||
</button>
|
||||
) : null}
|
||||
<h2 className="main-content__title">
|
||||
{headerTitle}
|
||||
{headerSubtitle ? (
|
||||
<span className="main-content__subtitle">{headerSubtitle}</span>
|
||||
) : null}
|
||||
</h2>
|
||||
<div className="spacer" />
|
||||
<div className="view-toggle" role="group" aria-label="Change view">
|
||||
<button
|
||||
type="button"
|
||||
className={`view-toggle__button${isGridView ? '' : ' active'}`}
|
||||
onClick={() => onViewModeChange?.('list')}
|
||||
aria-pressed={!isGridView}
|
||||
title="List view"
|
||||
>
|
||||
<ViewListIcon className="view-toggle__icon" size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`view-toggle__button${isGridView ? ' active' : ''}`}
|
||||
onClick={() => onViewModeChange?.('grid')}
|
||||
aria-pressed={isGridView}
|
||||
title="Icons view"
|
||||
>
|
||||
<ViewGridIcon className="view-toggle__icon" size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<span className="main-content__actions-divider" aria-hidden="true">
|
||||
<MinusVerticalIcon />
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button ghost"
|
||||
onClick={onRequestCreateFolder}
|
||||
disabled={creatingFolder}
|
||||
aria-label={creatingFolder ? 'Creating folder…' : 'Create folder'}
|
||||
title={creatingFolder ? 'Creating folder…' : 'Create folder'}
|
||||
>
|
||||
<FolderPlusIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button ghost"
|
||||
onClick={onRefresh}
|
||||
aria-label="Refresh"
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshIcon />
|
||||
</button>
|
||||
<button className="secondary" type="button" onClick={onShowSkeuoWorkspace}>
|
||||
Desk View
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`main-content__body main-content__body--documents${detailHasContent ? ' main-content__body--has-detail' : ''}`}
|
||||
>
|
||||
<DocumentsTable {...documentsTableProps} showHeader={false} />
|
||||
</div>
|
||||
{detailHasContent ? (
|
||||
<DetailPanel {...detailPanelProps} />
|
||||
) : null}
|
||||
</div>
|
||||
</DocumentsLayout>
|
||||
);
|
||||
@@ -4922,10 +5467,10 @@ const LoginRoute = () => {
|
||||
try {
|
||||
appDispatch({ type: 'LOGIN_REQUEST' });
|
||||
const { data } = await api.post('/auth/login', payload);
|
||||
if (data?.selection_token && Array.isArray(data?.tenants)) {
|
||||
if (data?.access_token && Array.isArray(data?.tenants)) {
|
||||
appDispatch({
|
||||
type: 'TENANT_SELECTION_REQUIRED',
|
||||
selectionToken: data.selection_token,
|
||||
selectionToken: data.access_token,
|
||||
tenants: data.tenants,
|
||||
});
|
||||
setStatusMessage('Select a tenant to continue.', 'info');
|
||||
@@ -4936,7 +5481,11 @@ const LoginRoute = () => {
|
||||
throw new Error('Invalid login response.');
|
||||
}
|
||||
|
||||
appDispatch({ type: 'LOGIN_SUCCESS', token: data.access_token });
|
||||
appDispatch({
|
||||
type: 'LOGIN_SUCCESS',
|
||||
token: data.access_token,
|
||||
tenant: data.tenant || null,
|
||||
});
|
||||
setStatusMessage('Login successful.', 'success');
|
||||
} catch (error) {
|
||||
const message = error?.response?.data?.error || 'Login failed. Check credentials.';
|
||||
@@ -4969,7 +5518,11 @@ const LoginRoute = () => {
|
||||
throw new Error('Invalid tenant selection response.');
|
||||
}
|
||||
|
||||
appDispatch({ type: 'LOGIN_SUCCESS', token: data.access_token });
|
||||
appDispatch({
|
||||
type: 'LOGIN_SUCCESS',
|
||||
token: data.access_token,
|
||||
tenant: data.tenant || null,
|
||||
});
|
||||
setStatusMessage('Login successful.', 'success');
|
||||
} catch (error) {
|
||||
const message = error?.response?.data?.error || 'Failed to finalize login.';
|
||||
|
||||
+125
-108
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { ChevronIcon, TrashIcon, EditIcon, FolderIcon } from '../ui/icons';
|
||||
import { ChevronIcon, TrashIcon, EditIcon, FolderIcon, ChevronsLeftIcon } from '../ui/icons';
|
||||
|
||||
import { getTagColorStyle } from '../utils/colors';
|
||||
|
||||
@@ -148,6 +148,7 @@ const Sidebar = ({
|
||||
previewActive,
|
||||
onLogout,
|
||||
status,
|
||||
onCollapse,
|
||||
}) => {
|
||||
const sortedCorrespondents = useMemo(
|
||||
() =>
|
||||
@@ -234,9 +235,30 @@ const Sidebar = ({
|
||||
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar__meta">
|
||||
<h1 className="sidebar__title">Papercrate</h1>
|
||||
<div className="panel-header sidebar__header">
|
||||
<div className="panel-actions">
|
||||
<h1 className="sidebar__title">Papercrate</h1>
|
||||
<div className="spacer" />
|
||||
{onCollapse ? (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button ghost"
|
||||
onClick={onCollapse}
|
||||
aria-label="Collapse sidebar"
|
||||
title="Collapse sidebar"
|
||||
>
|
||||
<ChevronsLeftIcon />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="panel-body sidebar__body">
|
||||
<span className="sidebar__hint">{hintText}</span>
|
||||
{status && (
|
||||
<div className="sidebar__status">
|
||||
<div className={`status-banner ${status.variant}`}>{status.message}</div>
|
||||
</div>
|
||||
)}
|
||||
{onSearchChange && (
|
||||
<form className="sidebar__search" onSubmit={handleSearchFormSubmit}>
|
||||
<input
|
||||
@@ -253,117 +275,112 @@ const Sidebar = ({
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
{status && (
|
||||
<div className="sidebar__status">
|
||||
<div className={`status-banner ${status.variant}`}>{status.message}</div>
|
||||
<div className="sidebar-section sidebar-section--folders">
|
||||
<div className="sidebar-section__header">
|
||||
<h3>Folders</h3>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="sidebar-section sidebar-section--folders">
|
||||
<div className="sidebar-section__header">
|
||||
<h3>Folders</h3>
|
||||
<ul className="folder-tree">
|
||||
{rootNode && renderNodes([rootNode.id], 0)}
|
||||
</ul>
|
||||
</div>
|
||||
<ul className="folder-tree">
|
||||
{rootNode && renderNodes([rootNode.id], 0)}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="sidebar-section">
|
||||
<div className="sidebar-section__header">
|
||||
<button
|
||||
type="button"
|
||||
className="sidebar-section__title"
|
||||
onClick={handleManageTags}
|
||||
<div className="sidebar-section">
|
||||
<div className="sidebar-section__header">
|
||||
<button
|
||||
type="button"
|
||||
className="sidebar-section__title"
|
||||
onClick={handleManageTags}
|
||||
>
|
||||
<h3>Tags</h3>
|
||||
<span className="meta">{tags.length}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className={`sidebar-tag-cloud${
|
||||
activeTagSet.size ? ' sidebar-tag-cloud--has-active' : ''
|
||||
}`}
|
||||
role="list"
|
||||
>
|
||||
<h3>Tags</h3>
|
||||
<span className="meta">{tags.length}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className={`sidebar-tag-cloud${
|
||||
activeTagSet.size ? ' sidebar-tag-cloud--has-active' : ''
|
||||
}`}
|
||||
role="list"
|
||||
>
|
||||
{tags.map((tag) => {
|
||||
const isActive = activeTagSet.has(tag.id);
|
||||
const style = getTagColorStyle(tag.color);
|
||||
const className = `sidebar-tag-pill${isActive ? ' active' : ''}`;
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
role="listitem"
|
||||
className={className}
|
||||
style={style || undefined}
|
||||
onClick={() => handleToggleTag(tag.id)}
|
||||
aria-pressed={isActive}
|
||||
draggable
|
||||
onDragStart={(event) => {
|
||||
try {
|
||||
const payload = JSON.stringify({
|
||||
id: tag.id,
|
||||
label: tag.label,
|
||||
color: tag.color || null,
|
||||
});
|
||||
event.dataTransfer.effectAllowed = 'copy';
|
||||
event.dataTransfer.setData('application/x-papercrate-tag', payload);
|
||||
event.dataTransfer.setData('text/papercrate-tag', payload);
|
||||
} catch (
|
||||
// eslint-disable-next-line no-empty
|
||||
error
|
||||
) {}
|
||||
}}
|
||||
>
|
||||
{tag.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sidebar-section">
|
||||
<div className="sidebar-section__header">
|
||||
<button
|
||||
type="button"
|
||||
className="sidebar-section__title"
|
||||
onClick={handleManageCorrespondents}
|
||||
>
|
||||
<h3>Correspondents</h3>
|
||||
<span className="meta">{correspondents.length}</span>
|
||||
</button>
|
||||
</div>
|
||||
<ul className="sidebar-correspondent-list">
|
||||
{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 (
|
||||
<li key={correspondent.id}>
|
||||
<span
|
||||
{tags.map((tag) => {
|
||||
const isActive = activeTagSet.has(tag.id);
|
||||
const style = getTagColorStyle(tag.color);
|
||||
const className = `sidebar-tag-pill${isActive ? ' active' : ''}`;
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
role="listitem"
|
||||
className={className}
|
||||
role="button"
|
||||
onClick={handleSelect}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
handleSelect();
|
||||
}
|
||||
style={style || undefined}
|
||||
onClick={() => handleToggleTag(tag.id)}
|
||||
aria-pressed={isActive}
|
||||
draggable
|
||||
onDragStart={(event) => {
|
||||
try {
|
||||
const payload = JSON.stringify({
|
||||
id: tag.id,
|
||||
label: tag.label,
|
||||
color: tag.color || null,
|
||||
});
|
||||
event.dataTransfer.effectAllowed = 'copy';
|
||||
event.dataTransfer.setData('application/x-papercrate-tag', payload);
|
||||
event.dataTransfer.setData('text/papercrate-tag', payload);
|
||||
} catch (
|
||||
// eslint-disable-next-line no-empty
|
||||
error
|
||||
) {}
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="sidebar__footer">
|
||||
<button className="secondary" type="button" onClick={handleLogoutClick}>
|
||||
Log out
|
||||
</button>
|
||||
{tag.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sidebar-section">
|
||||
<div className="sidebar-section__header">
|
||||
<button
|
||||
type="button"
|
||||
className="sidebar-section__title"
|
||||
onClick={handleManageCorrespondents}
|
||||
>
|
||||
<h3>Correspondents</h3>
|
||||
<span className="meta">{correspondents.length}</span>
|
||||
</button>
|
||||
</div>
|
||||
<ul className="sidebar-correspondent-list">
|
||||
{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 (
|
||||
<li key={correspondent.id}>
|
||||
<span
|
||||
className={className}
|
||||
role="button"
|
||||
onClick={handleSelect}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
handleSelect();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="sidebar__footer">
|
||||
<button className="secondary" type="button" onClick={handleLogoutClick}>
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -15,55 +15,6 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.skeuo-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem 0.5rem;
|
||||
}
|
||||
|
||||
.skeuo-header__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.skeuo-header__meta h2 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.skeuo-breadcrumbs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.skeuo-crumb {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.skeuo-crumb.is-current {
|
||||
color: var(--fg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.skeuo-crumb-separator {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.skeuo-header__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.skeuo-canvas {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
@@ -104,7 +55,6 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
transition: width 0.28s ease, height 0.28s ease;
|
||||
}
|
||||
|
||||
.skeuo-item:focus-visible {
|
||||
@@ -117,10 +67,6 @@
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.skeuo-item.is-zoomed {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.skeuo-item.is-tag-target .skeuo-item__card {
|
||||
outline: 1em dashed var(--accent);
|
||||
outline-offset: 1.41em;
|
||||
|
||||
+303
-170
@@ -9,6 +9,7 @@ import React, {
|
||||
import { resolveDocumentAssetUrl, createAssetView } from './asset_manager';
|
||||
import { useAssetNavigator } from './hooks/useAssetNavigator';
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from './ui/icons';
|
||||
import PreviewZoomOverlay from './detail/PreviewZoomOverlay';
|
||||
import { getReadableTextColor } from './utils/colors';
|
||||
import './skeuomorphic_ws.css';
|
||||
|
||||
@@ -28,9 +29,6 @@ const resolveSizeKey = (doc) =>
|
||||
const CARD_MIN = 240;
|
||||
const CARD_MAX = 340;
|
||||
const EMPTY_CARD_ASPECT = 1.4;
|
||||
const ZOOM_FILL_RATIO = 0.99;
|
||||
const ZOOM_MIN_SCALE = 1.05;
|
||||
const ZOOM_MAX_SCALE = 5;
|
||||
const TAG_REMOVE_DISTANCE = 160;
|
||||
|
||||
const DEBUG_DRAG = false;
|
||||
@@ -52,6 +50,7 @@ const SkeuoPreviewCard = ({
|
||||
getDocumentAsset,
|
||||
navScale = 1,
|
||||
prefetch = 3,
|
||||
onNavigatorSnapshot,
|
||||
}) => {
|
||||
const navigator = useAssetNavigator({
|
||||
document: doc,
|
||||
@@ -61,7 +60,42 @@ const SkeuoPreviewCard = ({
|
||||
prefetch,
|
||||
});
|
||||
|
||||
const { currentUrl, cardinality, canGoPrev, canGoNext } = navigator;
|
||||
const { currentUrl, cardinality, canGoPrev, canGoNext, currentMetadata, ordinal } = navigator;
|
||||
const docId = doc?.id ?? null;
|
||||
|
||||
const metadataWidth = Number(currentMetadata?.width);
|
||||
const metadataHeight = Number(currentMetadata?.height);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onNavigatorSnapshot || !docId) {
|
||||
return undefined;
|
||||
}
|
||||
const snapshot = {
|
||||
url: currentUrl || null,
|
||||
alt: title,
|
||||
canGoPrev,
|
||||
canGoNext,
|
||||
goPrev: navigator.goPrev,
|
||||
goNext: navigator.goNext,
|
||||
ordinal,
|
||||
width: Number.isFinite(metadataWidth) && metadataWidth > 0 ? metadataWidth : null,
|
||||
height: Number.isFinite(metadataHeight) && metadataHeight > 0 ? metadataHeight : null,
|
||||
};
|
||||
onNavigatorSnapshot(docId, snapshot);
|
||||
return () => onNavigatorSnapshot(docId, null);
|
||||
}, [
|
||||
docId,
|
||||
currentUrl,
|
||||
title,
|
||||
canGoPrev,
|
||||
canGoNext,
|
||||
ordinal,
|
||||
metadataWidth,
|
||||
metadataHeight,
|
||||
navigator.goPrev,
|
||||
navigator.goNext,
|
||||
onNavigatorSnapshot,
|
||||
]);
|
||||
const hasPreview = Boolean(currentUrl);
|
||||
const cardClasses = ['skeuo-item__card'];
|
||||
if (!hasPreview) cardClasses.push('skeuo-item__card--empty');
|
||||
@@ -303,7 +337,7 @@ const useDocumentDrag = ({
|
||||
setDraggingId,
|
||||
syncLayoutSnapshot,
|
||||
canvasSize,
|
||||
toggleZoom,
|
||||
openOverlayForDoc,
|
||||
}) => {
|
||||
const dragStateRef = useRef(null);
|
||||
|
||||
@@ -330,7 +364,7 @@ const useDocumentDrag = ({
|
||||
);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(event, docId, { lockWhenZoomed = false } = {}) => {
|
||||
(event, docId) => {
|
||||
if (DEBUG_DRAG) {
|
||||
console.log(
|
||||
'[skeuo] handlePointerDown fired for doc',
|
||||
@@ -376,7 +410,7 @@ const useDocumentDrag = ({
|
||||
startY: event.clientY,
|
||||
rotation: entry?.rotation ?? 0,
|
||||
moved: false,
|
||||
locked: Boolean(lockWhenZoomed),
|
||||
locked: false,
|
||||
width: docWidth,
|
||||
height: docHeight,
|
||||
scale: baseScale,
|
||||
@@ -475,13 +509,19 @@ const useDocumentDrag = ({
|
||||
const docId = state.docId;
|
||||
finishDrag(event.pointerId);
|
||||
if (!moved) {
|
||||
toggleZoom(docId);
|
||||
const originInfo = {
|
||||
rotation: state.rotation || 0,
|
||||
scale: state.scale || 1,
|
||||
width: state.width,
|
||||
height: state.height,
|
||||
};
|
||||
openOverlayForDoc(docId, originInfo);
|
||||
}
|
||||
return;
|
||||
}
|
||||
finishDrag(event.pointerId);
|
||||
},
|
||||
[finishDrag, toggleZoom],
|
||||
[finishDrag, openOverlayForDoc],
|
||||
);
|
||||
|
||||
const handlePointerCancel = useCallback(
|
||||
@@ -524,6 +564,37 @@ const clamp = (value, min, max) => {
|
||||
return value;
|
||||
};
|
||||
|
||||
const clampCardDimensions = (width, height) => {
|
||||
let nextWidth = Number(width);
|
||||
let nextHeight = Number(height);
|
||||
if (!Number.isFinite(nextWidth) || nextWidth <= 0 || !Number.isFinite(nextHeight) || nextHeight <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scaleDown = Math.min(1, CARD_MAX / nextWidth, CARD_MAX / nextHeight);
|
||||
nextWidth *= scaleDown;
|
||||
nextHeight *= scaleDown;
|
||||
|
||||
const minDim = Math.min(nextWidth, nextHeight);
|
||||
if (minDim > 0 && minDim < CARD_MIN) {
|
||||
const scaleUp = CARD_MIN / minDim;
|
||||
nextWidth *= scaleUp;
|
||||
nextHeight *= scaleUp;
|
||||
|
||||
const adjust = Math.min(1, CARD_MAX / nextWidth, CARD_MAX / nextHeight);
|
||||
nextWidth *= adjust;
|
||||
nextHeight *= adjust;
|
||||
}
|
||||
|
||||
nextWidth = clamp(nextWidth, CARD_MIN, CARD_MAX);
|
||||
nextHeight = clamp(nextHeight, CARD_MIN, CARD_MAX);
|
||||
|
||||
return {
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
};
|
||||
};
|
||||
|
||||
const formatTransform = (x, y, rotation = 0, scale = 1) =>
|
||||
`translate3d(${x}px, ${y}px, 0) rotate(${rotation}deg) scale(${scale})`;
|
||||
|
||||
@@ -547,10 +618,6 @@ const getContrastingTextColor = (hex) => getReadableTextColor(hex, { light: '#1f
|
||||
const SkeuomorphicWorkspace = ({
|
||||
documents = [],
|
||||
searchResults = null,
|
||||
breadcrumbs = [],
|
||||
currentFolderName = 'Folder',
|
||||
onExit,
|
||||
onRefresh,
|
||||
onDocumentOpen,
|
||||
resolveThumbnailUrl,
|
||||
onAssignTagToDocument = null,
|
||||
@@ -560,7 +627,6 @@ const SkeuomorphicWorkspace = ({
|
||||
activeTagIds = [],
|
||||
}) => {
|
||||
const items = useMemo(() => (searchResults ? searchResults : documents), [documents, searchResults]);
|
||||
const showingSearchResults = searchResults !== null;
|
||||
|
||||
|
||||
const containerRef = useRef(null);
|
||||
@@ -572,14 +638,94 @@ const SkeuomorphicWorkspace = ({
|
||||
|
||||
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 });
|
||||
const [draggingId, setDraggingId] = useState(null);
|
||||
const [zoomedId, setZoomedId] = useState(null);
|
||||
const [overlayDocId, setOverlayDocId] = useState(null);
|
||||
const [overlayOriginRect, setOverlayOriginRect] = useState(null);
|
||||
const [overlayOriginTransform, setOverlayOriginTransform] = useState(null);
|
||||
const [previewSnapshots, setPreviewSnapshots] = useState(() => new Map());
|
||||
const [docSizeVersion, setDocSizeVersion] = useState(0);
|
||||
const [tagDropTargetId, setTagDropTargetId] = useState(null);
|
||||
const [pendingTagDocId, setPendingTagDocId] = useState(null);
|
||||
const [pendingRemovalTag, setPendingRemovalTag] = useState(null);
|
||||
const draggingTagRef = useRef(null);
|
||||
const pendingDocTagDragRef = useRef(null);
|
||||
const docSizeMapRef = useRef(new Map());
|
||||
const documentLookupRef = useRef(new Map());
|
||||
const removalCursorActiveRef = useRef(false);
|
||||
const applySnapshotDimensions = useCallback(
|
||||
(docId, snapshot) => {
|
||||
if (!docId || !snapshot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const doc = documentLookupRef.current.get(docId);
|
||||
if (!doc) {
|
||||
return;
|
||||
}
|
||||
|
||||
const width = Number(snapshot.width);
|
||||
const height = Number(snapshot.height);
|
||||
if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(height) || height <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = clampCardDimensions(width, height);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = resolveSizeKey(doc);
|
||||
const existing = docSizeMapRef.current.get(key);
|
||||
if (existing && existing.width === normalized.width && existing.height === normalized.height) {
|
||||
return;
|
||||
}
|
||||
|
||||
docSizeMapRef.current.set(key, normalized);
|
||||
setDocSizeVersion((value) => value + 1);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const handleNavigatorSnapshot = useCallback(
|
||||
(docId, snapshot) => {
|
||||
if (!docId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPreviewSnapshots((previous) => {
|
||||
const prevSnapshot = previous.get(docId);
|
||||
if (!snapshot) {
|
||||
if (!previous.has(docId)) {
|
||||
return previous;
|
||||
}
|
||||
const next = new Map(previous);
|
||||
next.delete(docId);
|
||||
return next;
|
||||
}
|
||||
|
||||
const next = new Map(previous);
|
||||
const sameSnapshot =
|
||||
prevSnapshot &&
|
||||
prevSnapshot.url === snapshot.url &&
|
||||
prevSnapshot.alt === snapshot.alt &&
|
||||
prevSnapshot.canGoPrev === snapshot.canGoPrev &&
|
||||
prevSnapshot.canGoNext === snapshot.canGoNext &&
|
||||
prevSnapshot.goPrev === snapshot.goPrev &&
|
||||
prevSnapshot.goNext === snapshot.goNext &&
|
||||
prevSnapshot.ordinal === snapshot.ordinal &&
|
||||
prevSnapshot.width === snapshot.width &&
|
||||
prevSnapshot.height === snapshot.height;
|
||||
if (sameSnapshot) {
|
||||
return previous;
|
||||
}
|
||||
next.set(docId, snapshot);
|
||||
return next;
|
||||
});
|
||||
|
||||
if (snapshot) {
|
||||
applySnapshotDimensions(docId, snapshot);
|
||||
}
|
||||
},
|
||||
[applySnapshotDimensions],
|
||||
);
|
||||
const activeTagSet = useMemo(() => {
|
||||
if (!Array.isArray(activeTagIds) || activeTagIds.length === 0) {
|
||||
return new Set();
|
||||
@@ -604,6 +750,15 @@ const SkeuomorphicWorkspace = ({
|
||||
const resolvePreviewDimensions = useCallback(
|
||||
(doc) => {
|
||||
if (!doc) return null;
|
||||
|
||||
const snapshot = previewSnapshots.get(doc.id);
|
||||
if (snapshot && snapshot.width && snapshot.height) {
|
||||
return {
|
||||
width: snapshot.width,
|
||||
height: snapshot.height,
|
||||
};
|
||||
}
|
||||
|
||||
const asset = resolvePreviewAsset(doc);
|
||||
const view = createAssetView(asset);
|
||||
const metadata = view.getPrimaryMetadata() || {};
|
||||
@@ -614,7 +769,7 @@ const SkeuomorphicWorkspace = ({
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[resolvePreviewAsset],
|
||||
[previewSnapshots, resolvePreviewAsset],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -721,48 +876,26 @@ const SkeuomorphicWorkspace = ({
|
||||
if (cache) {
|
||||
return cache;
|
||||
}
|
||||
let width;
|
||||
let height;
|
||||
|
||||
const intrinsic = resolvePreviewDimensions(doc);
|
||||
let normalized = null;
|
||||
if (intrinsic?.width && intrinsic?.height) {
|
||||
width = intrinsic.width;
|
||||
height = intrinsic.height;
|
||||
} else {
|
||||
normalized = clampCardDimensions(intrinsic.width, intrinsic.height);
|
||||
}
|
||||
|
||||
if (!normalized) {
|
||||
const seed = seededRandom(`${key}:size`);
|
||||
width = CARD_MIN + seed * (Math.min(CARD_MAX, CARD_MAX / EMPTY_CARD_ASPECT) - CARD_MIN);
|
||||
let width = CARD_MIN + seed * (Math.min(CARD_MAX, CARD_MAX / EMPTY_CARD_ASPECT) - CARD_MIN);
|
||||
width = clamp(width, CARD_MIN, Math.min(CARD_MAX, CARD_MAX / EMPTY_CARD_ASPECT));
|
||||
height = width * EMPTY_CARD_ASPECT;
|
||||
const height = width * EMPTY_CARD_ASPECT;
|
||||
normalized = clampCardDimensions(width, height);
|
||||
}
|
||||
|
||||
if (!Number.isFinite(width) || width <= 0) {
|
||||
width = CARD_MIN;
|
||||
}
|
||||
if (!Number.isFinite(height) || height <= 0) {
|
||||
height = CARD_MIN;
|
||||
if (!normalized) {
|
||||
normalized = { width: CARD_MIN, height: CARD_MIN };
|
||||
}
|
||||
|
||||
const scaleDown = Math.min(1, CARD_MAX / width, CARD_MAX / height);
|
||||
width *= scaleDown;
|
||||
height *= scaleDown;
|
||||
|
||||
const minDim = Math.min(width, height);
|
||||
if (minDim < CARD_MIN) {
|
||||
const scaleUp = CARD_MIN / minDim;
|
||||
width *= scaleUp;
|
||||
height *= scaleUp;
|
||||
|
||||
const adjust = Math.min(1, CARD_MAX / width, CARD_MAX / height);
|
||||
width *= adjust;
|
||||
height *= adjust;
|
||||
}
|
||||
|
||||
width = clamp(width, CARD_MIN, CARD_MAX);
|
||||
height = clamp(height, CARD_MIN, CARD_MAX);
|
||||
|
||||
const size = { width, height };
|
||||
docSizeMapRef.current.set(key, size);
|
||||
return size;
|
||||
docSizeMapRef.current.set(key, normalized);
|
||||
return normalized;
|
||||
}, [resolvePreviewDimensions]);
|
||||
|
||||
const documentLookup = useMemo(() => {
|
||||
@@ -775,74 +908,48 @@ const SkeuomorphicWorkspace = ({
|
||||
return map;
|
||||
}, [items]);
|
||||
|
||||
useEffect(() => {
|
||||
documentLookupRef.current = documentLookup;
|
||||
}, [documentLookup]);
|
||||
|
||||
useEffect(() => {
|
||||
docSizeMapRef.current = new Map();
|
||||
setDocSizeVersion((value) => value + 1);
|
||||
}, [items]);
|
||||
|
||||
const resolveZoomMetrics = useCallback(
|
||||
(doc, cardWidth, cardHeight) => {
|
||||
const canvasWidth = canvasSize.width || DEFAULT_CANVAS_WIDTH;
|
||||
const canvasHeight = canvasSize.height || DEFAULT_CANVAS_HEIGHT;
|
||||
const safeCardWidth = cardWidth || CARD_MIN;
|
||||
const safeCardHeight = cardHeight || CARD_MIN;
|
||||
const usableWidth = Math.max(canvasWidth - CANVAS_PADDING * 2, safeCardWidth);
|
||||
const usableHeight = Math.max(canvasHeight - CANVAS_PADDING * 2, safeCardHeight);
|
||||
const viewportTargetWidth = usableWidth * ZOOM_FILL_RATIO;
|
||||
const viewportTargetHeight = usableHeight * ZOOM_FILL_RATIO;
|
||||
const overlayDisplay = useMemo(() => {
|
||||
if (!overlayDocId) {
|
||||
return null;
|
||||
}
|
||||
const snapshot = previewSnapshots.get(overlayDocId);
|
||||
if (!snapshot || !snapshot.url) {
|
||||
return null;
|
||||
}
|
||||
const doc = documentLookup.get(overlayDocId);
|
||||
const alt = snapshot.alt || doc?.title || doc?.original_name || 'Document preview';
|
||||
return {
|
||||
url: snapshot.url,
|
||||
alt,
|
||||
canGoPrev: snapshot.canGoPrev,
|
||||
canGoNext: snapshot.canGoNext,
|
||||
goPrev: snapshot.goPrev,
|
||||
goNext: snapshot.goNext,
|
||||
};
|
||||
}, [overlayDocId, previewSnapshots, documentLookup]);
|
||||
|
||||
let zoomWidth = safeCardWidth;
|
||||
let zoomHeight = safeCardHeight;
|
||||
const closeOverlay = useCallback(() => {
|
||||
setOverlayDocId(null);
|
||||
setOverlayOriginRect(null);
|
||||
setOverlayOriginTransform(null);
|
||||
}, []);
|
||||
|
||||
const previewDims = doc ? resolvePreviewDimensions(doc) : null;
|
||||
if (previewDims?.width && previewDims?.height) {
|
||||
const previewWidth = previewDims.width;
|
||||
const previewHeight = previewDims.height;
|
||||
const widthScaleLimit = previewWidth > 0 ? viewportTargetWidth / previewWidth : 1;
|
||||
const heightScaleLimit = previewHeight > 0 ? viewportTargetHeight / previewHeight : 1;
|
||||
const scaleToFit = Math.min(1, widthScaleLimit || 1, heightScaleLimit || 1);
|
||||
zoomWidth = previewWidth * scaleToFit;
|
||||
zoomHeight = previewHeight * scaleToFit;
|
||||
} else {
|
||||
const rawScale = Math.min(
|
||||
viewportTargetWidth / safeCardWidth,
|
||||
viewportTargetHeight / safeCardHeight,
|
||||
);
|
||||
const boundedScale =
|
||||
rawScale >= 1
|
||||
? clamp(Math.max(rawScale, ZOOM_MIN_SCALE), ZOOM_MIN_SCALE, ZOOM_MAX_SCALE)
|
||||
: rawScale;
|
||||
zoomWidth = safeCardWidth * boundedScale;
|
||||
zoomHeight = safeCardHeight * boundedScale;
|
||||
}
|
||||
|
||||
if (!Number.isFinite(zoomWidth) || zoomWidth <= 0) {
|
||||
zoomWidth = safeCardWidth;
|
||||
}
|
||||
if (!Number.isFinite(zoomHeight) || zoomHeight <= 0) {
|
||||
zoomHeight = safeCardHeight;
|
||||
}
|
||||
|
||||
zoomWidth = Math.max(zoomWidth, safeCardWidth);
|
||||
zoomHeight = Math.max(zoomHeight, safeCardHeight);
|
||||
|
||||
const zoomTargetX = (canvasWidth - zoomWidth) / 2;
|
||||
const zoomTargetY = (canvasHeight - zoomHeight) / 2;
|
||||
const maxX = Math.max(CANVAS_PADDING, canvasWidth - zoomWidth - CANVAS_PADDING);
|
||||
const maxY = Math.max(CANVAS_PADDING, canvasHeight - zoomHeight - CANVAS_PADDING);
|
||||
const clampedX = clamp(zoomTargetX, CANVAS_PADDING, maxX);
|
||||
const clampedY = clamp(zoomTargetY, CANVAS_PADDING, maxY);
|
||||
const zoomCenterX = clampedX + zoomWidth / 2;
|
||||
const zoomCenterY = clampedY + zoomHeight / 2;
|
||||
|
||||
return {
|
||||
zoomWidth,
|
||||
zoomHeight,
|
||||
zoomCenterX,
|
||||
zoomCenterY,
|
||||
};
|
||||
},
|
||||
[canvasSize.width, canvasSize.height, resolvePreviewDimensions],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (overlayDocId && !documentLookup.has(overlayDocId)) {
|
||||
setOverlayDocId(null);
|
||||
setOverlayOriginRect(null);
|
||||
setOverlayOriginTransform(null);
|
||||
}
|
||||
}, [overlayDocId, documentLookup]);
|
||||
|
||||
const resolveBaseMetrics = useCallback(
|
||||
(doc, cardWidth, cardHeight) => {
|
||||
@@ -876,7 +983,8 @@ const SkeuomorphicWorkspace = ({
|
||||
const container = containerRef.current;
|
||||
if (!container) return () => {};
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
const isDevEnv = typeof process !== 'undefined' && process?.env?.NODE_ENV !== 'production';
|
||||
if (isDevEnv) {
|
||||
console.log('[skeuo] canvas element', container);
|
||||
}
|
||||
|
||||
@@ -992,6 +1100,7 @@ const SkeuomorphicWorkspace = ({
|
||||
items,
|
||||
canvasSize.width,
|
||||
canvasSize.height,
|
||||
docSizeVersion,
|
||||
ensureDocumentSize,
|
||||
syncLayoutSnapshot,
|
||||
]);
|
||||
@@ -1014,17 +1123,62 @@ const SkeuomorphicWorkspace = ({
|
||||
[syncLayoutSnapshot],
|
||||
);
|
||||
|
||||
const toggleZoom = useCallback(
|
||||
(docId) => {
|
||||
setZoomedId((current) => {
|
||||
if (current === docId) {
|
||||
return null;
|
||||
}
|
||||
bringToFront(docId);
|
||||
return docId;
|
||||
});
|
||||
const openOverlayForDoc = useCallback(
|
||||
(docId, originInfo = null) => {
|
||||
if (!docId) {
|
||||
return;
|
||||
}
|
||||
const snapshot = previewSnapshots.get(docId);
|
||||
if (!snapshot || !snapshot.url) {
|
||||
return;
|
||||
}
|
||||
const container = itemRefs.current.get(docId);
|
||||
const imageNode = container?.querySelector?.('.skeuo-item__card img');
|
||||
if (!container || !imageNode) {
|
||||
return;
|
||||
}
|
||||
const rect = imageNode.getBoundingClientRect();
|
||||
let originTransform = null;
|
||||
if (originInfo) {
|
||||
const { rotation = 0, scale = 1, width: originWidth, height: originHeight } = originInfo;
|
||||
originTransform = {
|
||||
rotation,
|
||||
scaleX: scale,
|
||||
scaleY: scale,
|
||||
baseWidth: originWidth,
|
||||
baseHeight: originHeight,
|
||||
};
|
||||
}
|
||||
if (!originTransform) {
|
||||
const entry = layoutRef.current.get(docId) || null;
|
||||
const doc = documentLookup.get(docId) || null;
|
||||
const { width: cardWidth, height: cardHeight } = ensureDocumentSize(doc);
|
||||
const { baseWidth, baseHeight, baseScale } = resolveBaseMetrics(doc, cardWidth, cardHeight);
|
||||
const effectiveWidth = baseWidth * baseScale;
|
||||
const effectiveHeight = baseHeight * baseScale;
|
||||
originTransform = {
|
||||
rotation: entry?.rotation ?? 0,
|
||||
scaleX: baseScale,
|
||||
scaleY: baseScale,
|
||||
baseWidth: Number.isFinite(effectiveWidth) && effectiveWidth > 0 ? effectiveWidth : cardWidth,
|
||||
baseHeight: Number.isFinite(effectiveHeight) && effectiveHeight > 0 ? effectiveHeight : cardHeight,
|
||||
};
|
||||
}
|
||||
bringToFront(docId);
|
||||
setOverlayOriginRect(rect);
|
||||
setOverlayOriginTransform(originTransform);
|
||||
setOverlayDocId(docId);
|
||||
},
|
||||
[bringToFront],
|
||||
[
|
||||
bringToFront,
|
||||
previewSnapshots,
|
||||
itemRefs,
|
||||
setOverlayOriginTransform,
|
||||
ensureDocumentSize,
|
||||
resolveBaseMetrics,
|
||||
documentLookup,
|
||||
layoutRef,
|
||||
],
|
||||
);
|
||||
const { handlePointerDown, handlePointerMove, handlePointerUp, handlePointerCancel } = useDocumentDrag({
|
||||
layoutRef,
|
||||
@@ -1036,7 +1190,7 @@ const SkeuomorphicWorkspace = ({
|
||||
setDraggingId,
|
||||
syncLayoutSnapshot,
|
||||
canvasSize,
|
||||
toggleZoom,
|
||||
openOverlayForDoc,
|
||||
});
|
||||
|
||||
const handleTagDragEnterDoc = useCallback(
|
||||
@@ -1490,28 +1644,15 @@ const SkeuomorphicWorkspace = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="skeuo-shell">
|
||||
<header className="skeuo-header">
|
||||
<div className="skeuo-header__meta">
|
||||
<h2>{currentFolderName}</h2>
|
||||
{showingSearchResults && <span className="meta">Showing search results</span>}
|
||||
</div>
|
||||
<div className="skeuo-header__actions">
|
||||
<button type="button" className="secondary" onClick={onRefresh}>
|
||||
Refresh
|
||||
</button>
|
||||
<button type="button" onClick={onExit}>
|
||||
Back to List
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div
|
||||
className="skeuo-canvas"
|
||||
ref={containerRef}
|
||||
onDragOver={handleCanvasDragOver}
|
||||
onDragLeave={handleCanvasDragLeave}
|
||||
onDrop={handleCanvasDrop}
|
||||
>
|
||||
<>
|
||||
<div className="skeuo-shell">
|
||||
<div
|
||||
className="skeuo-canvas"
|
||||
ref={containerRef}
|
||||
onDragOver={handleCanvasDragOver}
|
||||
onDragLeave={handleCanvasDragLeave}
|
||||
onDrop={handleCanvasDrop}
|
||||
>
|
||||
{items.length === 0 ? (
|
||||
<div className="skeuo-empty">
|
||||
<p>No documents to show here yet. Drop files to make this space come alive.</p>
|
||||
@@ -1532,31 +1673,17 @@ const SkeuomorphicWorkspace = ({
|
||||
typeof layout.centerX === 'number' ? layout.centerX : fallbackLayout.centerX;
|
||||
const layoutCenterY =
|
||||
typeof layout.centerY === 'number' ? layout.centerY : fallbackLayout.centerY;
|
||||
const isZoomed = zoomedId === doc.id;
|
||||
const { zoomWidth, zoomHeight, zoomCenterX, zoomCenterY } = resolveZoomMetrics(
|
||||
doc,
|
||||
cardWidth,
|
||||
cardHeight,
|
||||
);
|
||||
const targetCenterX = isZoomed ? zoomCenterX : layoutCenterX;
|
||||
const targetCenterY = isZoomed ? zoomCenterY : layoutCenterY;
|
||||
const rotation = isZoomed ? 0 : layout.rotation || 0;
|
||||
const zoomScale = isZoomed
|
||||
? Math.min(
|
||||
1,
|
||||
Number.isFinite(zoomWidth / baseWidth) ? zoomWidth / baseWidth : 1,
|
||||
Number.isFinite(zoomHeight / baseHeight) ? zoomHeight / baseHeight : 1,
|
||||
)
|
||||
: baseScale;
|
||||
const rotation = layout.rotation || 0;
|
||||
const zoomScale = baseScale;
|
||||
const transform = formatTransform(
|
||||
Math.round(targetCenterX),
|
||||
Math.round(targetCenterY),
|
||||
Math.round(layoutCenterX),
|
||||
Math.round(layoutCenterY),
|
||||
rotation,
|
||||
zoomScale,
|
||||
);
|
||||
const style = {
|
||||
transform,
|
||||
zIndex: isZoomed ? 9999 : layout.z ?? 1,
|
||||
zIndex: layout.z ?? 1,
|
||||
};
|
||||
const bodyStyle = {
|
||||
width: Math.round(baseWidth),
|
||||
@@ -1577,7 +1704,6 @@ const SkeuomorphicWorkspace = ({
|
||||
const dropPending = pendingTagDocId === doc.id;
|
||||
const itemClasses = ['skeuo-item'];
|
||||
if (dragging) itemClasses.push('is-dragging');
|
||||
if (isZoomed) itemClasses.push('is-zoomed');
|
||||
if (dropActive) itemClasses.push('is-tag-target');
|
||||
if (dropPending) itemClasses.push('is-tag-pending');
|
||||
if (!matchesFilter) itemClasses.push('is-filtered-out');
|
||||
@@ -1598,9 +1724,7 @@ const SkeuomorphicWorkspace = ({
|
||||
itemRefs.current.delete(doc.id);
|
||||
}
|
||||
}}
|
||||
onPointerDown={(event) =>
|
||||
handlePointerDown(event, doc.id, { lockWhenZoomed: isZoomed })
|
||||
}
|
||||
onPointerDown={(event) => handlePointerDown(event, doc.id)}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerCancel}
|
||||
@@ -1622,6 +1746,7 @@ const SkeuomorphicWorkspace = ({
|
||||
ensureAssetUrl={ensureAssetUrl}
|
||||
getDocumentAsset={getDocumentAsset}
|
||||
navScale={inverseTagScale}
|
||||
onNavigatorSnapshot={handleNavigatorSnapshot}
|
||||
/>
|
||||
{tags.length > 0 && (
|
||||
<div className="skeuo-item__tags" aria-hidden="true" style={tagsStyle}>
|
||||
@@ -1667,8 +1792,16 @@ const SkeuomorphicWorkspace = ({
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PreviewZoomOverlay
|
||||
open={Boolean(overlayDisplay?.url)}
|
||||
display={overlayDisplay}
|
||||
onClose={closeOverlay}
|
||||
originRect={overlayOriginRect}
|
||||
originTransform={overlayOriginTransform}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+328
-63
@@ -71,6 +71,25 @@ body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
text-decoration-thickness: 1px;
|
||||
text-underline-offset: 0.18em;
|
||||
transition: color 0.15s ease, text-decoration-color 0.15s ease;
|
||||
}
|
||||
|
||||
a:visited {
|
||||
color: color-mix(in oklch, var(--accent) 75%, var(--fg) 25%);
|
||||
}
|
||||
|
||||
a:hover,
|
||||
a:focus-visible {
|
||||
color: var(--accent-hover);
|
||||
outline: none;
|
||||
text-decoration-color: currentColor;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
@@ -215,6 +234,108 @@ button.danger:hover:not([disabled]) {
|
||||
background: var(--sidebar-hover-bg);
|
||||
}
|
||||
|
||||
.preview-zoom-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: var(--overlay-backdrop);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
z-index: 3000;
|
||||
cursor: zoom-out;
|
||||
}
|
||||
|
||||
.preview-zoom__stage {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
max-width: 95vw;
|
||||
max-height: 95vh;
|
||||
}
|
||||
.preview-zoom__image {
|
||||
max-width: 95vw;
|
||||
max-height: 95vh;
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.preview-zoom__scroll {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
max-width: 95vw;
|
||||
max-height: 95vh;
|
||||
box-shadow: 0 32px 120px rgba(15, 23, 42, 0.55);
|
||||
}
|
||||
|
||||
.preview-zoom__scroll:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.preview-zoom__scroll--native {
|
||||
overflow: auto;
|
||||
cursor: zoom-out;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.preview-zoom__nav {
|
||||
position: absolute;
|
||||
bottom: 1em;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.9rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.18s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.preview-zoom__stage:hover .preview-zoom__nav,
|
||||
.preview-zoom__stage:focus-within .preview-zoom__nav {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.preview-zoom__nav-button {
|
||||
width: 2.8rem;
|
||||
height: 2.8rem;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 0, 0, 0.62);
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
z-index: 1;
|
||||
transition: background 0.15s ease, transform 0.15s ease;
|
||||
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.28);
|
||||
}
|
||||
|
||||
.preview-zoom__nav-button:hover {
|
||||
background: rgba(0, 0, 0, 0.82);
|
||||
}
|
||||
|
||||
.preview-zoom__nav-button:focus-visible {
|
||||
outline: 2px solid var(--accent, #4f46e5);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.preview-zoom__nav-button svg {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.preview-zoom__nav-button[disabled] {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.with-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -256,39 +377,34 @@ button.danger:hover:not([disabled]) {
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
|
||||
.view-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-subtle);
|
||||
margin-right: 0.5rem;
|
||||
overflow: hidden;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.view-toggle__button {
|
||||
width: 2.5rem;
|
||||
height: 2.2rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
padding: 0;
|
||||
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.view-toggle__button:hover,
|
||||
.view-toggle__button:focus-visible {
|
||||
background: transparent;
|
||||
background: var(--surface-subtle);
|
||||
color: var(--fg);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.view-toggle__button.active {
|
||||
background: var(--accent-soft, rgba(63, 106, 216, 0.16));
|
||||
color: var(--accent);
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.view-toggle__icon {
|
||||
@@ -307,35 +423,98 @@ button.danger:hover:not([disabled]) {
|
||||
|
||||
.documents-main {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 20rem minmax(0, 1fr);
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.documents-main--sidebar-collapsed {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
background: var(--surface);
|
||||
position: relative;
|
||||
flex-grow: 1;
|
||||
box-shadow: -12px 0 24px -12px var(--shadow-faint);
|
||||
}
|
||||
|
||||
.documents-main .main-content--documents > * {
|
||||
.main-content__header {
|
||||
padding: 0.5rem 1.25rem;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.main-content__body {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.main-content__body > * {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.documents-main .main-content--documents > *:not(.detail-panel) {
|
||||
.main-content__body > *:not(.detail-panel) {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.documents-main .main-content--documents.main-content--has-detail {
|
||||
.main-content--has-detail {
|
||||
padding-right: var(--detail-panel-width);
|
||||
}
|
||||
|
||||
.main-content__title {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--fg);
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.main-content__subtitle {
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
font-weight: 400;
|
||||
line-height: 1.2;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
|
||||
.documents-main--sidebar-collapsed {
|
||||
position: relative;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.sidebar .panel-actions .icon,
|
||||
.main-content__header .panel-actions .icon,
|
||||
.detail-panel .panel-actions .icon {
|
||||
width: 1.35rem;
|
||||
height: 1.35rem;
|
||||
}
|
||||
|
||||
.main-content__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.main-content__actions-divider {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.panels-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -695,7 +874,7 @@ button.danger:hover:not([disabled]) {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.column-header {
|
||||
.panel-section__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
@@ -703,33 +882,33 @@ button.danger:hover:not([disabled]) {
|
||||
padding: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.column-header__titles {
|
||||
.panel-section__titles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.column-header h2 {
|
||||
.panel-section__header h2 {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.column-subtitle {
|
||||
.panel-section__subtitle {
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
|
||||
.column-body {
|
||||
.panel-section__body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.column-body.scrollable {
|
||||
.panel-section__body--scrollable,
|
||||
.panel-section__body.scrollable {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@@ -840,25 +1019,27 @@ button.danger:hover:not([disabled]) {
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
gap: 0.28rem;
|
||||
gap: 0;
|
||||
overflow-y: auto;
|
||||
padding: 1.25rem;
|
||||
color: var(--sidebar-fg);
|
||||
box-shadow: inset -12px 0 24px -12px var(--shadow-faint);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 20em;
|
||||
max-width: 20em;
|
||||
flex: 0 0 20em;
|
||||
}
|
||||
|
||||
.sidebar__meta {
|
||||
.sidebar__body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.sidebar__title {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--fg);
|
||||
}
|
||||
@@ -872,7 +1053,7 @@ button.danger:hover:not([disabled]) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.6rem;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.sidebar__search input[type='search'] {
|
||||
@@ -898,10 +1079,6 @@ button.danger:hover:not([disabled]) {
|
||||
background: var(--sidebar-hover-bg);
|
||||
}
|
||||
|
||||
.sidebar__status {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.sidebar__footer {
|
||||
margin-top: auto;
|
||||
padding-top: 1rem;
|
||||
@@ -1056,7 +1233,6 @@ button.danger:hover:not([disabled]) {
|
||||
|
||||
.sidebar-correspondent-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
padding: 0.35rem 0.5rem;
|
||||
@@ -1086,7 +1262,7 @@ button.danger:hover:not([disabled]) {
|
||||
|
||||
|
||||
.documents-panel {
|
||||
padding: 1.25rem 1.25rem 0 1.25rem;
|
||||
padding: 0 0.75rem 0.75rem 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
@@ -1105,29 +1281,23 @@ button.danger:hover:not([disabled]) {
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.documents-panel .column-header {
|
||||
.documents-panel .panel-section__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.documents-panel .column-header .header-actions {
|
||||
.documents-panel .panel-section__header .header-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
.documents-panel .column-body {
|
||||
.documents-panel .panel-section__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.documents-panel .column-toolbar {
|
||||
flex-shrink: 0;
|
||||
padding-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.documents-panel .documents-scroll {
|
||||
overflow-y: auto;
|
||||
background: transparent;
|
||||
@@ -1558,9 +1728,12 @@ button.danger:hover:not([disabled]) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding: 0.25rem;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
@@ -1569,6 +1742,11 @@ button.danger:hover:not([disabled]) {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.panel-actions > h1:first-child,
|
||||
.panel-actions > h2:first-child {
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
.panel-actions .spacer {
|
||||
flex-grow: 1;
|
||||
}
|
||||
@@ -1662,6 +1840,86 @@ button.danger:hover:not([disabled]) {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.detail-panel .detail-title-suggest-button {
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.detail-panel .detail-title-suggestion {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--surface-subtle);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.detail-panel .detail-title-suggestion__label {
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.detail-panel .detail-title-suggestion__value {
|
||||
font-weight: 600;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.detail-panel .detail-title-suggestion__meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.detail-panel .detail-title-suggestion__actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.detail-panel .detail-title-suggestion__options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.detail-panel .detail-title-suggestion__option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.detail-panel .detail-title-suggestion__option input[type='checkbox'] {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.detail-panel .detail-folder-path {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.detail-panel .detail-folder-path__link {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.detail-panel .detail-folder-path__link:hover,
|
||||
.detail-panel .detail-folder-path__link:focus-visible {
|
||||
text-decoration: underline;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.detail-panel .detail-folder-path__separator {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.detail-panel .detail-folder-path__segment {
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.detail-panel .doc-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1741,7 +1999,6 @@ button.danger:hover:not([disabled]) {
|
||||
filter: drop-shadow(0 2px 6px var(--shadow-soft));
|
||||
transform-origin: center;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -1877,6 +2134,19 @@ button.danger:hover:not([disabled]) {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.detail-metadata__block {
|
||||
margin: 0.35rem 0 0;
|
||||
padding: 0.5rem 0.6rem;
|
||||
background: var(--surface-soft);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-muted, var(--border));
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: var(--font-mono, 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.tag-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1939,7 +2209,6 @@ form.inline {
|
||||
padding: 0.45rem 0.8rem;
|
||||
border-radius: 2px;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
|
||||
.status-banner.info {
|
||||
@@ -2184,9 +2453,6 @@ form.inline {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.column-toolbar .filter-bar {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.doc-list__name {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -2200,9 +2466,8 @@ form.inline {
|
||||
|
||||
.doc-list__name-content span {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.preview-pane__nav-button svg {
|
||||
|
||||
@@ -148,10 +148,10 @@ function TagsPanel({
|
||||
|
||||
return (
|
||||
<section className="tags-panel">
|
||||
<div className="column-header">
|
||||
<div className="column-header__titles">
|
||||
<div className="panel-section__header">
|
||||
<div className="panel-section__titles">
|
||||
<h2>Tags</h2>
|
||||
<div className="column-subtitle">{tags.length} total</div>
|
||||
<div className="panel-section__subtitle">{tags.length} total</div>
|
||||
</div>
|
||||
<div className="header-actions tags-actions">
|
||||
<form className="tags-actions__form" onSubmit={handleCreate}>
|
||||
@@ -194,7 +194,7 @@ function TagsPanel({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="column-body tags-panel__body">
|
||||
<div className="panel-section__body tags-panel__body">
|
||||
{tags.length === 0 ? (
|
||||
<div className="empty-state">No tags created yet.</div>
|
||||
) : (
|
||||
|
||||
@@ -9,10 +9,15 @@ import {
|
||||
IconLayoutGrid,
|
||||
IconArrowLeft,
|
||||
IconArrowRight,
|
||||
IconArrowUp,
|
||||
IconChevronsRight,
|
||||
IconChevronsLeft,
|
||||
IconAnalyze,
|
||||
IconWindowMaximize,
|
||||
IconTextScan2,
|
||||
IconFolderPlus,
|
||||
IconRefresh,
|
||||
IconMinusVertical,
|
||||
} from '@tabler/icons-react';
|
||||
import FolderSvg from '../assets/folder.svg';
|
||||
|
||||
@@ -124,6 +129,15 @@ export const ArrowRightIcon = ({ className, size = '1em', stroke = 1.6, ...rest
|
||||
/>
|
||||
);
|
||||
|
||||
export const ChevronsLeftIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||
<IconChevronsLeft
|
||||
className={composeClassName('icon', className)}
|
||||
size={size}
|
||||
stroke={stroke}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
|
||||
export const ChevronsRightIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||
<IconChevronsRight
|
||||
className={composeClassName('icon', className)}
|
||||
@@ -133,6 +147,42 @@ export const ChevronsRightIcon = ({ className, size = '1em', stroke = 1.6, ...re
|
||||
/>
|
||||
);
|
||||
|
||||
export const FolderPlusIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||
<IconFolderPlus
|
||||
className={composeClassName('icon', className)}
|
||||
size={size}
|
||||
stroke={stroke}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
|
||||
export const RefreshIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||
<IconRefresh
|
||||
className={composeClassName('icon', className)}
|
||||
size={size}
|
||||
stroke={stroke}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
|
||||
export const ArrowUpIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||
<IconArrowUp
|
||||
className={composeClassName('icon', className)}
|
||||
size={size}
|
||||
stroke={stroke}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
|
||||
export const MinusVerticalIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||
<IconMinusVertical
|
||||
className={composeClassName('icon', className)}
|
||||
size={size}
|
||||
stroke={stroke}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
|
||||
export const AnalyzeIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||
<IconAnalyze
|
||||
className={composeClassName('icon', className)}
|
||||
@@ -161,9 +211,14 @@ export default {
|
||||
DownloadIcon,
|
||||
ViewListIcon,
|
||||
ViewGridIcon,
|
||||
ChevronsLeftIcon,
|
||||
ChevronsRightIcon,
|
||||
AnalyzeIcon,
|
||||
WindowMaximizeIcon,
|
||||
FolderPlusIcon,
|
||||
RefreshIcon,
|
||||
ArrowUpIcon,
|
||||
MinusVerticalIcon,
|
||||
};
|
||||
|
||||
export const TextScanIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||
|
||||
@@ -48,6 +48,8 @@ module.exports = {
|
||||
}),
|
||||
new webpack.DefinePlugin({
|
||||
'process.env.API_BASE_URL': JSON.stringify(API_BASE_URL),
|
||||
'process.env.OLLAMA_BASE_URL': JSON.stringify(process.env.OLLAMA_BASE_URL || ''),
|
||||
'process.env.OLLAMA_MODEL': JSON.stringify(process.env.OLLAMA_MODEL || ''),
|
||||
}),
|
||||
],
|
||||
devServer: {
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
{{- if and .Values.quickwit.enabled .Values.quickwit.indexJob.enabled .Values.quickwit.indexJob.indexConfig.create }}
|
||||
{{- $cfg := .Values.quickwit.indexJob.indexConfig -}}
|
||||
{{- $config := dict "value" "" -}}
|
||||
{{- if $cfg.file }}
|
||||
{{- with $.Files.Get $cfg.file -}}
|
||||
{{- $_ := set $config "value" . -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- if and (not (index $config "value")) $cfg.contents }}
|
||||
{{- $_ := set $config "value" $cfg.contents -}}
|
||||
{{- end -}}
|
||||
{{- if eq (trim (default "" (index $config "value"))) "" -}}
|
||||
{{- fail "Quickwit index configuration requires either indexConfig.file or indexConfig.contents" -}}
|
||||
{{- end }}
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: {{ include "papercrate.fullname" . }}-quickwit-index
|
||||
labels:
|
||||
{{- include "papercrate.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: quickwit
|
||||
app.kubernetes.io/part-of: quickwit-index
|
||||
data:
|
||||
{{ $cfg.key }}: |
|
||||
{{ index $config "value" | indent 4 }}
|
||||
{{- $ctx := dict "storageUri" (.Values.quickwit.env.storageUri | default "") -}}
|
||||
{{- if and (eq (index $ctx "storageUri") "") .Values.global.s3.bucket -}}
|
||||
{{- $_ := set $ctx "storageUri" (printf "s3://%s/quickwit" .Values.global.s3.bucket) -}}
|
||||
{{- end -}}
|
||||
{{- $storageUri := index $ctx "storageUri" -}}
|
||||
{{- if $storageUri }}
|
||||
{{ printf "index_uri: %s/documents" (trimSuffix "/" $storageUri) | indent 4 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -1,78 +0,0 @@
|
||||
{{- if and .Values.quickwit.enabled .Values.quickwit.indexJob.enabled }}
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: {{ include "papercrate.fullname" . }}-quickwit-index
|
||||
labels:
|
||||
{{- include "papercrate.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: quickwit-index
|
||||
annotations:
|
||||
"helm.sh/hook": post-install,post-upgrade
|
||||
"helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
|
||||
spec:
|
||||
backoffLimit: {{ .Values.quickwit.indexJob.backoffLimit }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "papercrate.selectorLabels" . | nindent 8 }}
|
||||
app.kubernetes.io/component: quickwit-index
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
{{- if or .Values.global.imagePullSecrets .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- range .Values.global.imagePullSecrets }}
|
||||
- name: {{ . }}
|
||||
{{- end }}
|
||||
{{- range .Values.imagePullSecrets }}
|
||||
- name: {{ . }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- $qw := dict "storageUri" .Values.quickwit.env.storageUri "metastoreUri" .Values.quickwit.env.metastoreUri -}}
|
||||
{{- if and (not (index $qw "storageUri")) .Values.global.s3.bucket -}}
|
||||
{{- $_ := set $qw "storageUri" (printf "s3://%s/quickwit" .Values.global.s3.bucket) -}}
|
||||
{{- end -}}
|
||||
{{- if not (index $qw "storageUri") -}}
|
||||
{{- fail "quickwit.env.storageUri must be set, or provide global.s3.bucket" -}}
|
||||
{{- end -}}
|
||||
{{- if and (not (index $qw "metastoreUri")) .Values.postgres.enabled -}}
|
||||
{{- $dbUser := required "postgres.auth.username is required when postgres.enabled" .Values.postgres.auth.username -}}
|
||||
{{- $dbPass := required "postgres.auth.password is required when postgres.enabled" .Values.postgres.auth.password -}}
|
||||
{{- $dbHost := printf "%s-postgres" (include "papercrate.fullname" .) -}}
|
||||
{{- $_ := set $qw "metastoreUri" (printf "postgres://%s:%s@%s:5432/quickwit" $dbUser $dbPass $dbHost) -}}
|
||||
{{- end -}}
|
||||
{{- if not (index $qw "metastoreUri") -}}
|
||||
{{- fail "quickwit.env.metastoreUri must be set when not using bundled Postgres" -}}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: quickwit-index
|
||||
image: {{ include "papercrate.quickwit.image" . }}:{{ include "papercrate.image.tag" (dict "image" .Values.quickwit.image "Chart" .Chart) }}
|
||||
imagePullPolicy: {{ .Values.quickwit.image.pullPolicy }}
|
||||
command:
|
||||
- quickwit
|
||||
- index
|
||||
- create
|
||||
args:
|
||||
- --index-config=/config/{{ .Values.quickwit.indexJob.indexConfig.key }}
|
||||
- --endpoint=http://{{ include "papercrate.fullname" . }}-quickwit:{{ .Values.quickwit.service.port }}
|
||||
{{- if or .Values.quickwit.envFrom .Values.global.s3.credentialsSecret }}
|
||||
envFrom:
|
||||
{{- with .Values.global.s3.credentialsSecret }}
|
||||
- secretRef:
|
||||
name: {{ . }}
|
||||
{{- end }}
|
||||
{{- range .Values.quickwit.envFrom }}
|
||||
- {{ toYaml . | nindent 12 | trim }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if or .Values.quickwit.indexJob.indexConfig.existingConfigMap .Values.quickwit.indexJob.indexConfig.create }}
|
||||
volumeMounts:
|
||||
- name: index-config
|
||||
mountPath: /config
|
||||
{{- end }}
|
||||
{{- if or .Values.quickwit.indexJob.indexConfig.existingConfigMap .Values.quickwit.indexJob.indexConfig.create }}
|
||||
volumes:
|
||||
- name: index-config
|
||||
configMap:
|
||||
name: {{ if .Values.quickwit.indexJob.indexConfig.existingConfigMap }}{{ .Values.quickwit.indexJob.indexConfig.existingConfigMap }}{{ else }}{{ include "papercrate.fullname" . }}-quickwit-index{{ end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -141,16 +141,6 @@ quickwit:
|
||||
nodeSelector: {}
|
||||
tolerations: []
|
||||
affinity: {}
|
||||
indexJob:
|
||||
enabled: true
|
||||
backoffLimit: 3
|
||||
indexConfig:
|
||||
existingConfigMap: ""
|
||||
key: documents-index.yaml
|
||||
create: true
|
||||
file: files/quickwit/documents-index.yaml
|
||||
contents: ""
|
||||
|
||||
imagePullSecrets: []
|
||||
nameOverride: ""
|
||||
fullnameOverride: ""
|
||||
|
||||
Reference in New Issue
Block a user