Compare commits
1
Commits
cc37fdde9a
..
ai
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5091cd005e |
Generated
-42
@@ -611,7 +611,6 @@ dependencies = [
|
||||
"diesel",
|
||||
"diesel_migrations",
|
||||
"dotenv",
|
||||
"envy",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"http-body-util",
|
||||
@@ -626,7 +625,6 @@ dependencies = [
|
||||
"rand 0.8.5",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde-aux",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
@@ -1177,15 +1175,6 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "envy"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f47e0157f2cb54f5ae1bd371b30a2ae4311e1c028f575cd4e81de7353215965"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@@ -2171,15 +2160,6 @@ version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
||||
|
||||
[[package]]
|
||||
name = "ordered-float"
|
||||
version = "2.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "outref"
|
||||
version = "0.5.2"
|
||||
@@ -2924,28 +2904,6 @@ dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde-aux"
|
||||
version = "4.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "207f67b28fe90fb596503a9bf0bf1ea5e831e21307658e177c5dfcdfc3ab8a0a"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"serde",
|
||||
"serde-value",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde-value"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c"
|
||||
dependencies = [
|
||||
"ordered-float",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
|
||||
@@ -25,8 +25,6 @@ aws-credential-types = "1.2"
|
||||
# Serialization
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
envy = "0.4"
|
||||
serde-aux = "4.4"
|
||||
|
||||
# Utilities
|
||||
tracing = "0.1"
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE document_versions
|
||||
ADD COLUMN operations_summary JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE document_versions
|
||||
DROP COLUMN IF EXISTS operations_summary;
|
||||
@@ -1,7 +0,0 @@
|
||||
ALTER TABLE document_correspondents DROP CONSTRAINT document_correspondents_pkey;
|
||||
ALTER TABLE document_correspondents ADD COLUMN role VARCHAR(32) NOT NULL DEFAULT 'other';
|
||||
UPDATE document_correspondents SET role = 'other';
|
||||
ALTER TABLE document_correspondents ALTER COLUMN role DROP DEFAULT;
|
||||
ALTER TABLE document_correspondents
|
||||
ADD CONSTRAINT document_correspondents_pkey
|
||||
PRIMARY KEY (document_id, correspondent_id, role);
|
||||
@@ -1,24 +0,0 @@
|
||||
WITH ranked AS (
|
||||
SELECT
|
||||
document_id,
|
||||
correspondent_id,
|
||||
role,
|
||||
assigned_at,
|
||||
assigned_by,
|
||||
tenant_id,
|
||||
ROW_NUMBER() OVER (PARTITION BY document_id, correspondent_id ORDER BY assigned_at DESC) AS rn
|
||||
FROM document_correspondents
|
||||
)
|
||||
DELETE FROM document_correspondents dc
|
||||
USING ranked r
|
||||
WHERE dc.document_id = r.document_id
|
||||
AND dc.correspondent_id = r.correspondent_id
|
||||
AND dc.role = r.role
|
||||
AND dc.tenant_id = r.tenant_id
|
||||
AND r.rn > 1;
|
||||
|
||||
ALTER TABLE document_correspondents DROP CONSTRAINT document_correspondents_pkey;
|
||||
ALTER TABLE document_correspondents DROP COLUMN role;
|
||||
ALTER TABLE document_correspondents
|
||||
ADD CONSTRAINT document_correspondents_pkey
|
||||
PRIMARY KEY (document_id, correspondent_id);
|
||||
+77
-96
@@ -1,60 +1,35 @@
|
||||
use std::env;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use url::Url;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_aux::field_attributes::deserialize_bool_from_anything;
|
||||
|
||||
use crate::db::DEFAULT_MAX_POOL_SIZE;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AppConfig {
|
||||
pub database_url: String,
|
||||
#[serde(default = "default_database_max_pool_size")]
|
||||
pub database_max_pool_size: u32,
|
||||
#[serde(default = "default_server_host")]
|
||||
pub server_host: String,
|
||||
#[serde(default = "default_server_port")]
|
||||
pub server_port: u16,
|
||||
#[serde(default = "default_webdav_host")]
|
||||
pub webdav_host: String,
|
||||
#[serde(default = "default_webdav_port")]
|
||||
pub webdav_port: u16,
|
||||
pub jwt_secret: String,
|
||||
#[serde(default = "default_jwt_issuer")]
|
||||
pub jwt_issuer: String,
|
||||
#[serde(default = "default_jwt_audience")]
|
||||
pub jwt_audience: String,
|
||||
#[serde(default = "default_jwt_expiry_minutes")]
|
||||
pub jwt_expiry_minutes: i64,
|
||||
#[serde(default = "default_download_token_audience")]
|
||||
pub download_token_audience: String,
|
||||
#[serde(default = "default_download_token_expiry_minutes")]
|
||||
pub download_token_expiry_minutes: i64,
|
||||
#[serde(default = "default_refresh_token_expiry_days")]
|
||||
pub refresh_token_expiry_days: i64,
|
||||
#[serde(
|
||||
default = "default_refresh_cookie_secure",
|
||||
deserialize_with = "deserialize_bool_from_anything"
|
||||
)]
|
||||
pub refresh_cookie_secure: bool,
|
||||
#[serde(default)]
|
||||
pub refresh_cookie_domain: Option<String>,
|
||||
#[serde(default)]
|
||||
pub cors_allowed_origin: Option<String>,
|
||||
#[serde(default)]
|
||||
pub aws_endpoint_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub aws_access_key_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub aws_secret_access_key: Option<String>,
|
||||
#[serde(default = "default_aws_region")]
|
||||
pub aws_region: String,
|
||||
pub s3_bucket: String,
|
||||
#[serde(default)]
|
||||
pub quickwit_endpoint: Option<String>,
|
||||
#[serde(default)]
|
||||
pub quickwit_index: Option<String>,
|
||||
#[serde(default = "default_tenant_slug")]
|
||||
pub default_tenant_slug: String,
|
||||
}
|
||||
|
||||
@@ -74,9 +49,80 @@ impl AppConfig {
|
||||
}
|
||||
|
||||
pub fn from_env() -> Result<Self> {
|
||||
let config: AppConfig = envy::from_env()
|
||||
.context("failed to parse application configuration from environment")?;
|
||||
Ok(config.normalize())
|
||||
let database_url = env::var("DATABASE_URL").context("DATABASE_URL must be set")?;
|
||||
let database_max_pool_size = env::var("DATABASE_MAX_POOL_SIZE")
|
||||
.ok()
|
||||
.and_then(|value| value.parse().ok())
|
||||
.unwrap_or(DEFAULT_MAX_POOL_SIZE);
|
||||
let server_host = env::var("SERVER_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
|
||||
let server_port = env::var("SERVER_PORT")
|
||||
.unwrap_or_else(|_| "3000".to_string())
|
||||
.parse()
|
||||
.context("SERVER_PORT must be a valid u16")?;
|
||||
let webdav_host = env::var("WEBDAV_HOST").unwrap_or_else(|_| server_host.clone());
|
||||
let webdav_port = env::var("WEBDAV_PORT")
|
||||
.unwrap_or_else(|_| "3001".to_string())
|
||||
.parse()
|
||||
.context("WEBDAV_PORT must be a valid u16")?;
|
||||
let jwt_secret = env::var("JWT_SECRET").context("JWT_SECRET must be set")?;
|
||||
let jwt_issuer = env::var("JWT_ISSUER").unwrap_or_else(|_| "papercrate".to_string());
|
||||
let jwt_audience =
|
||||
env::var("JWT_AUDIENCE").unwrap_or_else(|_| "papercrate-clients".to_string());
|
||||
let jwt_expiry_minutes = env::var("JWT_EXPIRY_MINUTES")
|
||||
.unwrap_or_else(|_| "60".to_string())
|
||||
.parse()
|
||||
.context("JWT_EXPIRY_MINUTES must be an integer")?;
|
||||
let download_token_audience = env::var("DOWNLOAD_TOKEN_AUDIENCE")
|
||||
.unwrap_or_else(|_| "papercrate-download".to_string());
|
||||
let download_token_expiry_minutes = env::var("DOWNLOAD_TOKEN_EXPIRY_MINUTES")
|
||||
.unwrap_or_else(|_| "60".to_string())
|
||||
.parse()
|
||||
.context("DOWNLOAD_TOKEN_EXPIRY_MINUTES must be an integer")?;
|
||||
let refresh_token_expiry_days = env::var("REFRESH_TOKEN_EXPIRY_DAYS")
|
||||
.unwrap_or_else(|_| "30".to_string())
|
||||
.parse()
|
||||
.context("REFRESH_TOKEN_EXPIRY_DAYS must be an integer")?;
|
||||
let refresh_cookie_secure = env::var("REFRESH_COOKIE_SECURE")
|
||||
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
.unwrap_or(false);
|
||||
let refresh_cookie_domain = env::var("REFRESH_COOKIE_DOMAIN").ok();
|
||||
let cors_allowed_origin = env::var("CORS_ALLOWED_ORIGIN").ok();
|
||||
let aws_endpoint_url = env::var("AWS_ENDPOINT_URL").ok();
|
||||
let aws_access_key_id = env::var("AWS_ACCESS_KEY_ID").ok();
|
||||
let aws_secret_access_key = env::var("AWS_SECRET_ACCESS_KEY").ok();
|
||||
let aws_region = env::var("AWS_REGION").unwrap_or_else(|_| "us-east-1".to_string());
|
||||
let s3_bucket = env::var("S3_BUCKET").context("S3_BUCKET must be set")?;
|
||||
let quickwit_endpoint = env::var("QUICKWIT_ENDPOINT").ok();
|
||||
let quickwit_index = env::var("QUICKWIT_INDEX").ok();
|
||||
let default_tenant_slug =
|
||||
env::var("DEFAULT_TENANT_SLUG").unwrap_or_else(|_| "admin".to_string());
|
||||
|
||||
Ok(Self {
|
||||
database_url,
|
||||
database_max_pool_size,
|
||||
server_host,
|
||||
server_port,
|
||||
webdav_host,
|
||||
webdav_port,
|
||||
jwt_secret,
|
||||
jwt_issuer,
|
||||
jwt_audience,
|
||||
jwt_expiry_minutes,
|
||||
download_token_audience,
|
||||
download_token_expiry_minutes,
|
||||
refresh_token_expiry_days,
|
||||
refresh_cookie_secure,
|
||||
refresh_cookie_domain,
|
||||
cors_allowed_origin,
|
||||
aws_endpoint_url,
|
||||
aws_access_key_id,
|
||||
aws_secret_access_key,
|
||||
aws_region,
|
||||
s3_bucket,
|
||||
quickwit_endpoint,
|
||||
quickwit_index,
|
||||
default_tenant_slug,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn redacted_database_url(&self) -> String {
|
||||
@@ -84,71 +130,6 @@ impl AppConfig {
|
||||
}
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
fn normalize(mut self) -> Self {
|
||||
if self.webdav_host.is_empty() {
|
||||
self.webdav_host = self.server_host.clone();
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
fn default_database_max_pool_size() -> u32 {
|
||||
DEFAULT_MAX_POOL_SIZE
|
||||
}
|
||||
|
||||
fn default_server_host() -> String {
|
||||
"127.0.0.1".to_string()
|
||||
}
|
||||
|
||||
fn default_server_port() -> u16 {
|
||||
3000
|
||||
}
|
||||
|
||||
fn default_webdav_host() -> String {
|
||||
String::new()
|
||||
}
|
||||
|
||||
fn default_webdav_port() -> u16 {
|
||||
3001
|
||||
}
|
||||
|
||||
fn default_jwt_issuer() -> String {
|
||||
"papercrate".to_string()
|
||||
}
|
||||
|
||||
fn default_jwt_audience() -> String {
|
||||
"papercrate-clients".to_string()
|
||||
}
|
||||
|
||||
fn default_jwt_expiry_minutes() -> i64 {
|
||||
60
|
||||
}
|
||||
|
||||
fn default_download_token_audience() -> String {
|
||||
"papercrate-download".to_string()
|
||||
}
|
||||
|
||||
fn default_download_token_expiry_minutes() -> i64 {
|
||||
60
|
||||
}
|
||||
|
||||
fn default_refresh_token_expiry_days() -> i64 {
|
||||
30
|
||||
}
|
||||
|
||||
fn default_refresh_cookie_secure() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn default_aws_region() -> String {
|
||||
"us-east-1".to_string()
|
||||
}
|
||||
|
||||
fn default_tenant_slug() -> String {
|
||||
"admin".to_string()
|
||||
}
|
||||
|
||||
fn redact_database_url(raw: &str) -> String {
|
||||
match Url::parse(raw) {
|
||||
Ok(mut parsed) => {
|
||||
|
||||
@@ -1,263 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path as FsPath;
|
||||
|
||||
use diesel::prelude::*;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::models::{Document, DocumentAsset, DocumentAssetObject, DocumentVersion};
|
||||
use crate::schema::{document_asset_objects, document_assets, document_versions};
|
||||
use crate::state::AppState;
|
||||
use crate::utils::time::to_iso;
|
||||
|
||||
#[derive(Serialize, Clone, ToSchema)]
|
||||
pub struct DocumentAssetResponse {
|
||||
pub id: Uuid,
|
||||
pub asset_type: String,
|
||||
pub mime_type: String,
|
||||
pub metadata: Value,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cardinality: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone, ToSchema)]
|
||||
pub struct DocumentAssetObjectResponse {
|
||||
pub id: Uuid,
|
||||
pub ordinal: i32,
|
||||
pub metadata: Value,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub expires_at: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct DocumentAssetDetailResponse {
|
||||
pub id: Uuid,
|
||||
pub asset_type: String,
|
||||
pub mime_type: String,
|
||||
pub metadata: Value,
|
||||
pub created_at: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cardinality: Option<i32>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub objects: Vec<DocumentAssetObjectResponse>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone, ToSchema)]
|
||||
pub struct DocumentVersionResponse {
|
||||
pub id: Uuid,
|
||||
pub version_number: i32,
|
||||
pub size_bytes: i64,
|
||||
pub checksum: String,
|
||||
pub created_at: String,
|
||||
pub metadata: Value,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone, ToSchema)]
|
||||
pub struct DocumentVersionDetailResponse {
|
||||
#[serde(flatten)]
|
||||
pub version: DocumentVersionResponse,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub assets: Vec<DocumentAssetResponse>,
|
||||
pub download_path: String,
|
||||
}
|
||||
|
||||
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) -> DocumentVersionResponse {
|
||||
DocumentVersionResponse {
|
||||
id: version.id,
|
||||
version_number: version.version_number,
|
||||
size_bytes: version.size_bytes,
|
||||
checksum: version.checksum,
|
||||
created_at: to_iso(version.created_at),
|
||||
metadata: version.metadata,
|
||||
}
|
||||
}
|
||||
|
||||
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 async fn load_asset_responses(
|
||||
state: &AppState,
|
||||
tenant_id: Uuid,
|
||||
version_id: Uuid,
|
||||
) -> AppResult<Vec<DocumentAssetResponse>> {
|
||||
let mut conn = state.db_for_tenant(tenant_id)?;
|
||||
let assets: Vec<(DocumentAsset, Option<DocumentAssetObject>)> = document_assets::table
|
||||
.left_outer_join(
|
||||
document_asset_objects::table.on(document_asset_objects::asset_id
|
||||
.eq(document_assets::id)
|
||||
.and(document_asset_objects::ordinal.eq(1))),
|
||||
)
|
||||
.filter(document_assets::document_version_id.eq(version_id))
|
||||
.filter(document_assets::tenant_id.eq(tenant_id))
|
||||
.order(document_assets::created_at.asc())
|
||||
.select((
|
||||
document_assets::all_columns,
|
||||
document_asset_objects::all_columns.nullable(),
|
||||
))
|
||||
.load(&mut conn)?;
|
||||
drop(conn);
|
||||
|
||||
Ok(assets
|
||||
.into_iter()
|
||||
.map(|(asset, _)| to_asset_summary(asset))
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn load_primary_assets(
|
||||
state: &AppState,
|
||||
tenant_id: Uuid,
|
||||
documents: &[Document],
|
||||
) -> AppResult<HashMap<Uuid, (DocumentVersionResponse, Vec<DocumentAssetResponse>)>> {
|
||||
if documents.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let mut doc_to_version: HashMap<Uuid, Uuid> = HashMap::with_capacity(documents.len());
|
||||
let mut version_ids: Vec<Uuid> = Vec::with_capacity(documents.len());
|
||||
for doc in documents {
|
||||
doc_to_version.insert(doc.id, doc.current_version_id);
|
||||
version_ids.push(doc.current_version_id);
|
||||
}
|
||||
|
||||
version_ids.sort();
|
||||
version_ids.dedup();
|
||||
|
||||
let mut conn = state.db_for_tenant(tenant_id)?;
|
||||
let versions: Vec<DocumentVersion> = document_versions::table
|
||||
.filter(document_versions::id.eq_any(&version_ids))
|
||||
.load(&mut conn)?;
|
||||
|
||||
let mut version_map: HashMap<Uuid, DocumentVersion> = HashMap::new();
|
||||
for version in versions {
|
||||
version_map.insert(version.id, version);
|
||||
}
|
||||
|
||||
let assets: Vec<(DocumentAsset, Option<DocumentAssetObject>)> = document_assets::table
|
||||
.left_outer_join(
|
||||
document_asset_objects::table.on(document_asset_objects::asset_id
|
||||
.eq(document_assets::id)
|
||||
.and(document_asset_objects::ordinal.eq(1))),
|
||||
)
|
||||
.filter(document_assets::document_version_id.eq_any(&version_ids))
|
||||
.order((
|
||||
document_assets::document_version_id.asc(),
|
||||
document_assets::created_at.asc(),
|
||||
))
|
||||
.select((
|
||||
document_assets::all_columns,
|
||||
document_asset_objects::all_columns.nullable(),
|
||||
))
|
||||
.load(&mut conn)?;
|
||||
|
||||
drop(conn);
|
||||
|
||||
let mut assets_by_version: HashMap<Uuid, Vec<DocumentAssetResponse>> = HashMap::new();
|
||||
for (asset, _object) in assets {
|
||||
let version_id = asset.document_version_id;
|
||||
let response = to_asset_summary(asset);
|
||||
assets_by_version
|
||||
.entry(version_id)
|
||||
.or_default()
|
||||
.push(response);
|
||||
}
|
||||
|
||||
let mut result: HashMap<Uuid, (DocumentVersionResponse, Vec<DocumentAssetResponse>)> =
|
||||
HashMap::with_capacity(doc_to_version.len());
|
||||
for (doc_id, version_id) in doc_to_version {
|
||||
if let Some(version) = version_map.remove(&version_id) {
|
||||
let assets = assets_by_version.remove(&version_id).unwrap_or_default();
|
||||
result.insert(doc_id, (to_version_response(version), assets));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::Utc;
|
||||
use diesel::prelude::*;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::models::{Correspondent, DocumentCorrespondent, NewDocumentCorrespondent};
|
||||
use crate::schema::{correspondents, document_correspondents, documents};
|
||||
use crate::utils::time::to_iso;
|
||||
|
||||
#[derive(Serialize, Clone, ToSchema)]
|
||||
pub struct DocumentCorrespondentResponse {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub metadata: Value,
|
||||
pub assigned_at: String,
|
||||
}
|
||||
|
||||
pub fn normalize_correspondent_ids(ids: &[Uuid]) -> AppResult<Vec<Uuid>> {
|
||||
let mut unique: Vec<Uuid> = ids.iter().copied().collect();
|
||||
unique.sort_unstable();
|
||||
unique.dedup();
|
||||
|
||||
if unique.is_empty() {
|
||||
return Err(AppError::bad_request(
|
||||
"assignments must contain at least one correspondent",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(unique)
|
||||
}
|
||||
|
||||
pub fn insert_document_correspondents(
|
||||
conn: &mut PgConnection,
|
||||
tenant_id: Uuid,
|
||||
document_id: Uuid,
|
||||
user_id: Uuid,
|
||||
correspondent_ids: &[Uuid],
|
||||
) -> AppResult<usize> {
|
||||
let ids = normalize_correspondent_ids(correspondent_ids)?;
|
||||
|
||||
let existing: Vec<Uuid> = correspondents::table
|
||||
.filter(correspondents::id.eq_any(&ids))
|
||||
.filter(correspondents::tenant_id.eq(tenant_id))
|
||||
.select(correspondents::id)
|
||||
.load(conn)?;
|
||||
|
||||
if existing.len() != ids.len() {
|
||||
return Err(AppError::bad_request(
|
||||
"one or more correspondents do not exist",
|
||||
));
|
||||
}
|
||||
|
||||
let new_rows: Vec<NewDocumentCorrespondent> = ids
|
||||
.into_iter()
|
||||
.map(|correspondent_id| NewDocumentCorrespondent {
|
||||
document_id,
|
||||
correspondent_id,
|
||||
assigned_by: Some(user_id),
|
||||
tenant_id,
|
||||
})
|
||||
.collect();
|
||||
|
||||
if new_rows.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let inserted = diesel::insert_into(document_correspondents::table)
|
||||
.values(&new_rows)
|
||||
.on_conflict_do_nothing()
|
||||
.execute(conn)?;
|
||||
|
||||
if inserted > 0 {
|
||||
diesel::update(
|
||||
documents::table
|
||||
.find(document_id)
|
||||
.filter(documents::tenant_id.eq(tenant_id)),
|
||||
)
|
||||
.set(documents::updated_at.eq(Utc::now().naive_utc()))
|
||||
.execute(conn)?;
|
||||
}
|
||||
|
||||
Ok(inserted)
|
||||
}
|
||||
|
||||
pub fn load_correspondents_for_documents(
|
||||
conn: &mut PgConnection,
|
||||
document_ids: &[Uuid],
|
||||
) -> AppResult<HashMap<Uuid, Vec<DocumentCorrespondentResponse>>> {
|
||||
if document_ids.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let rows: Vec<(DocumentCorrespondent, Correspondent)> = document_correspondents::table
|
||||
.inner_join(correspondents::table)
|
||||
.filter(document_correspondents::document_id.eq_any(document_ids))
|
||||
.order((
|
||||
document_correspondents::document_id.asc(),
|
||||
document_correspondents::assigned_at.asc(),
|
||||
))
|
||||
.load(conn)?;
|
||||
|
||||
let mut map: HashMap<Uuid, Vec<DocumentCorrespondentResponse>> = HashMap::new();
|
||||
for (assignment, correspondent) in rows {
|
||||
map.entry(assignment.document_id)
|
||||
.or_default()
|
||||
.push(DocumentCorrespondentResponse {
|
||||
id: correspondent.id,
|
||||
name: correspondent.name,
|
||||
metadata: correspondent.metadata,
|
||||
assigned_at: to_iso(assignment.assigned_at),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(map)
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
use diesel::dsl::exists;
|
||||
use diesel::prelude::*;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppResult;
|
||||
use crate::schema::folders;
|
||||
use crate::utils::validation::ensure_exists;
|
||||
|
||||
pub fn ensure_folder_exists_on_conn(
|
||||
conn: &mut PgConnection,
|
||||
tenant_id: Uuid,
|
||||
folder_id: Uuid,
|
||||
) -> AppResult<()> {
|
||||
let exists: bool = diesel::select(exists(
|
||||
folders::table
|
||||
.filter(folders::id.eq(folder_id))
|
||||
.filter(folders::tenant_id.eq(tenant_id)),
|
||||
))
|
||||
.get_result(conn)?;
|
||||
ensure_exists(exists, "folder")
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
use serde_json::{map::Entry, Map, Value};
|
||||
|
||||
use crate::error::{AppError, AppResult};
|
||||
|
||||
pub fn merge_document_metadata(existing: Value, updates: Value) -> AppResult<Value> {
|
||||
let mut base = match existing {
|
||||
Value::Object(map) => map,
|
||||
Value::Null => Map::new(),
|
||||
_ => {
|
||||
return Err(AppError::bad_request(
|
||||
"existing metadata is not an object; set replace=true to overwrite",
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let incoming = match updates {
|
||||
Value::Object(map) => map,
|
||||
_ => {
|
||||
return Err(AppError::bad_request(
|
||||
"metadata value must be a JSON object when replace is false",
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
merge_metadata_maps(&mut base, incoming);
|
||||
Ok(Value::Object(base))
|
||||
}
|
||||
|
||||
fn merge_metadata_maps(target: &mut Map<String, Value>, updates: Map<String, Value>) {
|
||||
for (key, value) in updates {
|
||||
match target.entry(key) {
|
||||
Entry::Occupied(mut entry) => {
|
||||
let existing = entry.get_mut();
|
||||
match value {
|
||||
Value::Object(update_map) => {
|
||||
if let Value::Object(existing_map) = existing {
|
||||
merge_metadata_maps(existing_map, update_map);
|
||||
} else {
|
||||
*existing = Value::Object(update_map);
|
||||
}
|
||||
}
|
||||
other => {
|
||||
*existing = other;
|
||||
}
|
||||
}
|
||||
}
|
||||
Entry::Vacant(entry) => {
|
||||
entry.insert(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
pub mod asset;
|
||||
pub mod correspondents;
|
||||
pub mod folders;
|
||||
pub mod metadata;
|
||||
pub mod search;
|
||||
pub mod tags;
|
||||
@@ -1,80 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use diesel::prelude::*;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::models::{Document, NewDocumentTag, Tag};
|
||||
use crate::schema::{document_tags, tags};
|
||||
|
||||
pub fn assign_tags(
|
||||
conn: &mut PgConnection,
|
||||
tenant_id: Uuid,
|
||||
document: &Document,
|
||||
raw_tag_ids: &[Uuid],
|
||||
assigned_by: Option<Uuid>,
|
||||
) -> AppResult<usize> {
|
||||
if raw_tag_ids.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let mut tag_ids: Vec<Uuid> = raw_tag_ids.iter().copied().collect();
|
||||
tag_ids.sort_unstable();
|
||||
tag_ids.dedup();
|
||||
|
||||
if tag_ids.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let existing: Vec<Uuid> = tags::table
|
||||
.filter(tags::id.eq_any(&tag_ids))
|
||||
.filter(tags::tenant_id.eq(tenant_id))
|
||||
.select(tags::id)
|
||||
.load(conn)?;
|
||||
|
||||
if existing.len() != tag_ids.len() {
|
||||
return Err(AppError::bad_request("one or more tags do not exist"));
|
||||
}
|
||||
|
||||
let new_tags: Vec<NewDocumentTag> = tag_ids
|
||||
.into_iter()
|
||||
.map(|tag_id| NewDocumentTag {
|
||||
document_id: document.id,
|
||||
tag_id,
|
||||
assigned_by,
|
||||
tenant_id,
|
||||
})
|
||||
.collect();
|
||||
|
||||
if new_tags.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let inserted = diesel::insert_into(document_tags::table)
|
||||
.values(&new_tags)
|
||||
.on_conflict_do_nothing()
|
||||
.execute(conn)?;
|
||||
|
||||
Ok(inserted)
|
||||
}
|
||||
|
||||
pub fn load_tags_for_documents(
|
||||
conn: &mut PgConnection,
|
||||
document_ids: &[Uuid],
|
||||
) -> AppResult<HashMap<Uuid, Vec<Tag>>> {
|
||||
if document_ids.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let rows: Vec<(Uuid, Tag)> = document_tags::table
|
||||
.inner_join(tags::table)
|
||||
.filter(document_tags::document_id.eq_any(document_ids))
|
||||
.select((document_tags::document_id, tags::all_columns))
|
||||
.load(conn)?;
|
||||
|
||||
let mut map: HashMap<Uuid, Vec<Tag>> = HashMap::new();
|
||||
for (doc_id, tag) in rows {
|
||||
map.entry(doc_id).or_default().push(tag);
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
pub mod auth;
|
||||
pub mod config;
|
||||
pub mod db;
|
||||
pub mod documents;
|
||||
pub mod error;
|
||||
pub mod jobs;
|
||||
pub mod models;
|
||||
|
||||
@@ -123,6 +123,7 @@ pub struct DocumentVersion {
|
||||
pub size_bytes: i64,
|
||||
pub checksum: String,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub operations_summary: serde_json::Value,
|
||||
pub metadata: serde_json::Value,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
@@ -136,6 +137,7 @@ pub struct NewDocumentVersion {
|
||||
pub s3_key: String,
|
||||
pub size_bytes: i64,
|
||||
pub checksum: String,
|
||||
pub operations_summary: serde_json::Value,
|
||||
pub metadata: serde_json::Value,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
@@ -281,10 +283,11 @@ pub struct NewCorrespondent {
|
||||
#[diesel(table_name = document_correspondents)]
|
||||
#[diesel(belongs_to(Document))]
|
||||
#[diesel(belongs_to(Correspondent))]
|
||||
#[diesel(primary_key(document_id, correspondent_id))]
|
||||
#[diesel(primary_key(document_id, correspondent_id, role))]
|
||||
pub struct DocumentCorrespondent {
|
||||
pub document_id: Uuid,
|
||||
pub correspondent_id: Uuid,
|
||||
pub role: String,
|
||||
pub assigned_at: NaiveDateTime,
|
||||
pub assigned_by: Option<Uuid>,
|
||||
pub tenant_id: Uuid,
|
||||
@@ -295,6 +298,7 @@ pub struct DocumentCorrespondent {
|
||||
pub struct NewDocumentCorrespondent {
|
||||
pub document_id: Uuid,
|
||||
pub correspondent_id: Uuid,
|
||||
pub role: String,
|
||||
pub assigned_by: Option<Uuid>,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
+34
-80
@@ -18,6 +18,7 @@ use uuid::Uuid;
|
||||
doc::get_document,
|
||||
doc::update_document,
|
||||
doc::delete_document,
|
||||
doc::download_document,
|
||||
doc::download_with_token,
|
||||
doc::move_document,
|
||||
doc::assign_tags,
|
||||
@@ -56,13 +57,13 @@ use uuid::Uuid;
|
||||
schemas::LoginResponseVariants,
|
||||
schemas::DocumentResponse,
|
||||
schemas::DocumentDetailResponse,
|
||||
schemas::DocumentVersionResponse,
|
||||
schemas::DocumentVersionDetailResponse,
|
||||
schemas::DocumentVersion,
|
||||
schemas::DocumentAssetSummary,
|
||||
schemas::DocumentAssetDetail,
|
||||
schemas::DocumentAssetObject,
|
||||
schemas::DocumentCorrespondent,
|
||||
schemas::DocumentTag,
|
||||
schemas::DocumentDownloadResponse,
|
||||
schemas::UpdateDocumentRequest,
|
||||
schemas::BulkMoveDocumentsRequest,
|
||||
schemas::BulkMoveDocumentsResponse,
|
||||
@@ -76,6 +77,7 @@ use uuid::Uuid;
|
||||
schemas::BulkCorrespondentsResponse,
|
||||
schemas::BulkCorrespondentAction,
|
||||
schemas::AssignCorrespondentsRequest,
|
||||
schemas::RemoveCorrespondentParams,
|
||||
schemas::ReanalyzeRequest,
|
||||
schemas::ReanalyzeResponse,
|
||||
schemas::DocumentAssetRequestParams,
|
||||
@@ -94,7 +96,6 @@ use uuid::Uuid;
|
||||
schemas::TagCatalogEntry,
|
||||
schemas::CreateTagRequest,
|
||||
schemas::UpdateTagRequest,
|
||||
schemas::CorrespondentUsage,
|
||||
schemas::CorrespondentCatalogEntry,
|
||||
schemas::CreateCorrespondentRequest,
|
||||
schemas::UpdateCorrespondentRequest,
|
||||
@@ -239,34 +240,12 @@ mod doc {
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/documents/{id}/versions",
|
||||
path = "/api/documents/{id}/download",
|
||||
params(("id" = Uuid, Path, description = "Document ID")),
|
||||
responses((status = 200, description = "Document versions", body = [DocumentVersionResponse])),
|
||||
responses((status = 200, description = "Download metadata", body = DocumentDownloadResponse)),
|
||||
tag = "Documents"
|
||||
)]
|
||||
pub(super) fn list_document_versions() {}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/documents/{id}/versions/{version_id}",
|
||||
params(
|
||||
("id" = Uuid, Path, description = "Document ID"),
|
||||
("version_id" = Uuid, Path, description = "Version ID"),
|
||||
),
|
||||
responses((status = 200, description = "Document version detail", body = DocumentVersionDetailResponse)),
|
||||
tag = "Documents"
|
||||
)]
|
||||
pub(super) fn get_document_version() {}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/documents/{id}/restore",
|
||||
params(("id" = Uuid, Path, description = "Document ID")),
|
||||
request_body = RestoreDocumentRequest,
|
||||
responses((status = 204, description = "Document restored")),
|
||||
tag = "Documents"
|
||||
)]
|
||||
pub(super) fn restore_document() {}
|
||||
pub(super) fn download_document() {}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
@@ -351,7 +330,8 @@ mod doc {
|
||||
path = "/api/documents/{id}/correspondents/{correspondent_id}",
|
||||
params(
|
||||
("id" = Uuid, Path, description = "Document ID"),
|
||||
("correspondent_id" = Uuid, Path, description = "Correspondent ID")
|
||||
("correspondent_id" = Uuid, Path, description = "Correspondent ID"),
|
||||
RemoveCorrespondentParams
|
||||
),
|
||||
responses((status = 204, description = "Correspondent removed")),
|
||||
tag = "Documents"
|
||||
@@ -596,26 +576,11 @@ pub mod schemas {
|
||||
#[into_params(parameter_in = Query)]
|
||||
pub struct DocumentListQuery {
|
||||
pub folder_id: Option<Uuid>,
|
||||
#[schema(nullable)]
|
||||
pub include_deleted: Option<bool>,
|
||||
pub include_descendants: Option<bool>,
|
||||
pub query: Option<String>,
|
||||
pub tags: Option<String>,
|
||||
pub correspondents: Option<String>,
|
||||
#[serde(default = "default_document_status_filter")]
|
||||
#[schema(default = "active")]
|
||||
pub status: DocumentStatusFilter,
|
||||
}
|
||||
|
||||
fn default_document_status_filter() -> DocumentStatusFilter {
|
||||
DocumentStatusFilter::Active
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum DocumentStatusFilter {
|
||||
Active,
|
||||
Deleted,
|
||||
All,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
@@ -660,21 +625,17 @@ pub mod schemas {
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct DocumentVersionResponse {
|
||||
pub struct DocumentVersion {
|
||||
pub id: Uuid,
|
||||
pub version_number: i32,
|
||||
pub size_bytes: i64,
|
||||
pub checksum: String,
|
||||
pub size_bytes: i64,
|
||||
pub created_at: String,
|
||||
pub metadata: Value,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct DocumentVersionDetailResponse {
|
||||
#[serde(flatten)]
|
||||
pub version: DocumentVersionResponse,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub assets: Vec<DocumentAssetSummary>,
|
||||
#[schema(nullable)]
|
||||
pub operations_summary: Option<Value>,
|
||||
#[schema(nullable)]
|
||||
pub assets: Option<Vec<DocumentAssetSummary>>,
|
||||
pub download_path: String,
|
||||
}
|
||||
|
||||
@@ -682,6 +643,7 @@ pub mod schemas {
|
||||
pub struct DocumentCorrespondent {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub role: String,
|
||||
pub metadata: Value,
|
||||
pub assigned_at: String,
|
||||
}
|
||||
@@ -704,10 +666,10 @@ pub mod schemas {
|
||||
pub issued_at: Option<String>,
|
||||
pub metadata: Value,
|
||||
pub tags: Vec<DocumentTag>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub correspondents: Vec<DocumentCorrespondent>,
|
||||
#[schema(nullable)]
|
||||
pub current_version: Option<DocumentVersionDetailResponse>,
|
||||
pub correspondents: Option<Vec<DocumentCorrespondent>>,
|
||||
#[schema(nullable)]
|
||||
pub current_version: Option<DocumentVersion>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
@@ -716,27 +678,19 @@ pub mod schemas {
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct DocumentMetadataUpdate {
|
||||
pub value: Value,
|
||||
#[serde(default)]
|
||||
#[schema(default = false)]
|
||||
pub replace: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct RestoreDocumentRequest {
|
||||
pub struct DocumentDownloadResponse {
|
||||
pub url: String,
|
||||
pub expires_in: u64,
|
||||
pub filename: String,
|
||||
#[schema(nullable)]
|
||||
pub folder_id: Option<Uuid>,
|
||||
pub content_type: Option<String>,
|
||||
pub size_bytes: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct UpdateDocumentRequest {
|
||||
#[schema(nullable)]
|
||||
pub title: Option<String>,
|
||||
#[schema(nullable, value_type = Option<String>)]
|
||||
pub issued_at: Option<Value>,
|
||||
#[schema(nullable)]
|
||||
pub metadata: Option<DocumentMetadataUpdate>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
@@ -785,6 +739,7 @@ pub mod schemas {
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct CorrespondentAssignment {
|
||||
pub correspondent_id: Uuid,
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
@@ -819,6 +774,12 @@ pub mod schemas {
|
||||
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>,
|
||||
@@ -972,19 +933,12 @@ pub mod schemas {
|
||||
pub color: Option<Option<String>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct CorrespondentUsage {
|
||||
pub total: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct CorrespondentCatalogEntry {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub metadata: Value,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub usage: CorrespondentUsage,
|
||||
pub role_counts: Value,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
|
||||
@@ -80,15 +80,9 @@ pub async fn login(
|
||||
) -> AppResult<Response> {
|
||||
let mut conn = state.db_unscoped()?;
|
||||
|
||||
let user: Option<User> = dsl::users
|
||||
let user: User = dsl::users
|
||||
.filter(dsl::username.eq(&payload.username))
|
||||
.first(&mut conn)
|
||||
.optional()?;
|
||||
|
||||
let user = match user {
|
||||
Some(user) => user,
|
||||
None => return Err(AppError::unauthorized()),
|
||||
};
|
||||
.first(&mut conn)?;
|
||||
|
||||
let valid = password::verify_password(&payload.password, &user.password_hash)
|
||||
.map_err(|_| AppError::unauthorized())?;
|
||||
@@ -192,19 +186,15 @@ pub async fn select_tenant(
|
||||
TypedHeader(Authorization(bearer)): TypedHeader<Authorization<Bearer>>,
|
||||
Json(payload): Json<TenantSelectionRequest>,
|
||||
) -> AppResult<Response> {
|
||||
let user_id = match state.jwt.verify_tenant_selector_token(bearer.token()) {
|
||||
Ok(claims) => claims.sub,
|
||||
Err(_) => state
|
||||
.jwt
|
||||
.verify_token(bearer.token())
|
||||
.map(|claims| claims.sub)
|
||||
.map_err(|_| AppError::unauthorized())?,
|
||||
};
|
||||
let claims = state
|
||||
.jwt
|
||||
.verify_tenant_selector_token(bearer.token())
|
||||
.map_err(|_| AppError::unauthorized())?;
|
||||
|
||||
let mut conn = state.db_unscoped()?;
|
||||
|
||||
let membership_exists = memberships_dsl::user_memberships
|
||||
.filter(memberships_dsl::user_id.eq(user_id))
|
||||
.filter(memberships_dsl::user_id.eq(claims.sub))
|
||||
.filter(memberships_dsl::tenant_id.eq(payload.tenant_id))
|
||||
.inner_join(tenant_dsl::tenants)
|
||||
.select(memberships_dsl::id)
|
||||
@@ -216,7 +206,7 @@ pub async fn select_tenant(
|
||||
}
|
||||
|
||||
let user: User = dsl::users
|
||||
.find(user_id)
|
||||
.find(claims.sub)
|
||||
.first(&mut conn)
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use axum::{extract::Path, http::StatusCode, Json};
|
||||
use chrono::Utc;
|
||||
@@ -21,6 +21,8 @@ use crate::{
|
||||
#[derive(Serialize)]
|
||||
pub struct CorrespondentUsage {
|
||||
pub total: i64,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub by_role: BTreeMap<String, i64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -65,21 +67,31 @@ pub async fn list_correspondents(
|
||||
.order(correspondents::name.asc())
|
||||
.load(&mut conn)?;
|
||||
|
||||
let usage_rows: Vec<(Uuid, i64)> = document_correspondents::table
|
||||
let usage_rows: Vec<(Uuid, String, i64)> = document_correspondents::table
|
||||
.filter(document_correspondents::tenant_id.eq(tenant_id))
|
||||
.group_by(document_correspondents::correspondent_id)
|
||||
.select((document_correspondents::correspondent_id, count_star()))
|
||||
.group_by((
|
||||
document_correspondents::correspondent_id,
|
||||
document_correspondents::role,
|
||||
))
|
||||
.select((
|
||||
document_correspondents::correspondent_id,
|
||||
document_correspondents::role,
|
||||
count_star(),
|
||||
))
|
||||
.load(&mut conn)?;
|
||||
|
||||
let mut usage_map: HashMap<Uuid, i64> = HashMap::new();
|
||||
for (correspondent_id, count) in usage_rows {
|
||||
usage_map.insert(correspondent_id, count);
|
||||
let mut usage_map: HashMap<Uuid, BTreeMap<String, i64>> = HashMap::new();
|
||||
for (correspondent_id, role, count) in usage_rows {
|
||||
usage_map
|
||||
.entry(correspondent_id)
|
||||
.or_default()
|
||||
.insert(role, count);
|
||||
}
|
||||
|
||||
let mut response = Vec::with_capacity(correspondents_list.len());
|
||||
for correspondent in correspondents_list {
|
||||
let total = usage_map.remove(&correspondent.id).unwrap_or(0);
|
||||
response.push(build_summary(correspondent, total));
|
||||
let role_counts = usage_map.remove(&correspondent.id).unwrap_or_default();
|
||||
response.push(build_summary(correspondent, role_counts));
|
||||
}
|
||||
|
||||
response.into_json()
|
||||
@@ -124,7 +136,7 @@ pub async fn create_correspondent(
|
||||
.first(&mut conn)
|
||||
.one()?;
|
||||
|
||||
build_summary(correspondent, 0).into_json()
|
||||
build_summary(correspondent, BTreeMap::new()).into_json()
|
||||
}
|
||||
|
||||
pub async fn update_correspondent(
|
||||
@@ -233,14 +245,21 @@ pub async fn delete_correspondent(
|
||||
no_content()
|
||||
}
|
||||
|
||||
fn build_summary(correspondent: Correspondent, total: i64) -> CorrespondentSummary {
|
||||
fn build_summary(
|
||||
correspondent: Correspondent,
|
||||
role_counts: BTreeMap<String, i64>,
|
||||
) -> CorrespondentSummary {
|
||||
let total = role_counts.values().copied().sum();
|
||||
CorrespondentSummary {
|
||||
id: correspondent.id,
|
||||
name: correspondent.name,
|
||||
metadata: correspondent.metadata,
|
||||
created_at: to_iso(correspondent.created_at),
|
||||
updated_at: to_iso(correspondent.updated_at),
|
||||
usage: CorrespondentUsage { total },
|
||||
usage: CorrespondentUsage {
|
||||
total,
|
||||
by_role: role_counts,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,12 +274,17 @@ fn load_usage_for_correspondent(
|
||||
conn: &mut PgConnection,
|
||||
tenant_id: Uuid,
|
||||
correspondent_id: Uuid,
|
||||
) -> AppResult<i64> {
|
||||
let total: i64 = document_correspondents::table
|
||||
) -> AppResult<BTreeMap<String, i64>> {
|
||||
let rows: Vec<(String, i64)> = document_correspondents::table
|
||||
.filter(document_correspondents::correspondent_id.eq(correspondent_id))
|
||||
.filter(document_correspondents::tenant_id.eq(tenant_id))
|
||||
.select(count_star())
|
||||
.get_result(conn)?;
|
||||
.group_by(document_correspondents::role)
|
||||
.select((document_correspondents::role, count_star()))
|
||||
.load(conn)?;
|
||||
|
||||
Ok(total)
|
||||
let mut map = BTreeMap::new();
|
||||
for (role, count) in rows {
|
||||
map.insert(role, count);
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
+668
-396
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))
|
||||
}
|
||||
@@ -1,14 +1,6 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use reqwest::Client;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use tracing::{debug, error};
|
||||
use serde_json::Value;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub const QUICKWIT_MAX_HITS: usize = 200;
|
||||
|
||||
pub fn build_quickwit_query(input: &str) -> Option<String> {
|
||||
let tokens: Vec<String> = input
|
||||
.split_whitespace()
|
||||
@@ -46,67 +38,6 @@ pub fn escape_quickwit_token(token: &str) -> String {
|
||||
escaped
|
||||
}
|
||||
|
||||
pub async fn quickwit_search(
|
||||
endpoint: &str,
|
||||
index: &str,
|
||||
tenant_id: Uuid,
|
||||
query: &str,
|
||||
) -> Result<Vec<Uuid>> {
|
||||
let tenant_clause = format!("tenant_id:{}", tenant_id);
|
||||
let quickwit_query = match build_quickwit_query(query) {
|
||||
Some(q) => {
|
||||
debug!(%query, quickwit_query = %q, "built quickwit search query");
|
||||
format!("{} AND ({})", tenant_clause, q)
|
||||
}
|
||||
None => {
|
||||
debug!(%query, "quickwit search skipped because query produced no tokens");
|
||||
return Ok(vec![]);
|
||||
}
|
||||
};
|
||||
|
||||
let client = Client::new();
|
||||
let url = format!("{}/api/v1/{}/search", endpoint.trim_end_matches('/'), index);
|
||||
|
||||
let payload = json!({
|
||||
"query": quickwit_query,
|
||||
"max_hits": QUICKWIT_MAX_HITS,
|
||||
});
|
||||
|
||||
debug!(%url, payload = %payload, "sending quickwit search request");
|
||||
let response = client.post(url).json(&payload).send().await?;
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
error!(%status, body = %body, "quickwit search request failed");
|
||||
return Err(anyhow!(
|
||||
"quickwit search failed with status {status}: {body}"
|
||||
));
|
||||
}
|
||||
|
||||
let data: QuickwitSearchResponse = response.json().await?;
|
||||
debug!("quickwit search response parsed successfully");
|
||||
let QuickwitSearchResponse { hits } = data;
|
||||
|
||||
let total_hits = hits.len();
|
||||
let mut seen = HashSet::new();
|
||||
let mut doc_ids = Vec::with_capacity(total_hits);
|
||||
|
||||
for hit in hits {
|
||||
if let Some(doc_id) = extract_document_id(&hit) {
|
||||
if seen.insert(doc_id) {
|
||||
doc_ids.push(doc_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debug!(
|
||||
total_hits = total_hits,
|
||||
unique_ids = doc_ids.len(),
|
||||
"quickwit search completed"
|
||||
);
|
||||
Ok(doc_ids)
|
||||
}
|
||||
|
||||
pub fn extract_document_id(hit: &Value) -> Option<Uuid> {
|
||||
for key in ["_source", "source", "fields", "stored_fields"] {
|
||||
if let Some(value) = hit.get(key) {
|
||||
@@ -158,9 +89,3 @@ pub fn parse_uuid_value(value: &Value) -> Option<Uuid> {
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct QuickwitSearchResponse {
|
||||
#[serde(default)]
|
||||
hits: Vec<Value>,
|
||||
}
|
||||
@@ -15,10 +15,9 @@ use crate::{
|
||||
error::{AppError, AppResult},
|
||||
};
|
||||
|
||||
use super::documents::{to_document_response, DocumentResponse};
|
||||
use crate::documents::{
|
||||
asset::load_primary_assets, correspondents::load_correspondents_for_documents,
|
||||
tags::load_tags_for_documents,
|
||||
use super::documents::{
|
||||
load_correspondents_for_documents, load_primary_assets, load_tags_for_documents,
|
||||
to_document_response, DocumentResponse,
|
||||
};
|
||||
use crate::utils::{
|
||||
json::{classify_nullable, NullableValue},
|
||||
@@ -310,7 +309,7 @@ pub async fn list_folder_contents(
|
||||
let mut correspondents_map = load_correspondents_for_documents(&mut conn, &doc_ids)?;
|
||||
drop(conn);
|
||||
|
||||
let primary_versions = load_primary_assets(&state, tenant_id, &docs)?;
|
||||
let primary_versions = load_primary_assets(&state, tenant_id, &docs).await?;
|
||||
|
||||
let mut documents = Vec::with_capacity(doc_ids.len());
|
||||
for doc in docs {
|
||||
|
||||
@@ -7,10 +7,7 @@ use axum::{
|
||||
Router,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tower_http::{
|
||||
cors::{AllowOrigin, CorsLayer},
|
||||
trace::{DefaultMakeSpan, DefaultOnFailure, DefaultOnResponse, TraceLayer},
|
||||
};
|
||||
use tower_http::cors::{AllowOrigin, CorsLayer};
|
||||
use utoipa::OpenApi;
|
||||
|
||||
use crate::{auth::AuthenticatedUser, openapi::ApiDoc, state::AppState};
|
||||
@@ -82,17 +79,12 @@ pub fn create_router(state: AppState) -> Router<()> {
|
||||
.delete(documents::delete_document)
|
||||
.patch(documents::update_document),
|
||||
)
|
||||
.route("/:id/download", get(documents::download_document))
|
||||
.route(
|
||||
"/:id/assets",
|
||||
get(documents::list_document_assets).post(documents::request_document_assets),
|
||||
)
|
||||
.route("/:id/folder", patch(documents::move_document))
|
||||
.route("/:id/versions", get(documents::list_document_versions))
|
||||
.route(
|
||||
"/:id/versions/:version_id",
|
||||
get(documents::get_document_version),
|
||||
)
|
||||
.route("/:id/restore", post(documents::restore_document))
|
||||
.route("/:id/tags", post(documents::assign_tags))
|
||||
.route("/:id/tags/:tag_id", delete(documents::remove_tag))
|
||||
.route(
|
||||
@@ -162,10 +154,4 @@ pub fn create_router(state: AppState) -> Router<()> {
|
||||
.with_state(state)
|
||||
.layer(cors)
|
||||
.layer(DefaultBodyLimit::max(1024 * 1024 * 512))
|
||||
.layer(
|
||||
TraceLayer::new_for_http()
|
||||
.make_span_with(DefaultMakeSpan::new().level(tracing::Level::INFO))
|
||||
.on_response(DefaultOnResponse::new().level(tracing::Level::INFO))
|
||||
.on_failure(DefaultOnFailure::new().level(tracing::Level::ERROR)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -37,9 +37,11 @@ diesel::table! {
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
document_correspondents (document_id, correspondent_id) {
|
||||
document_correspondents (document_id, correspondent_id, role) {
|
||||
document_id -> Uuid,
|
||||
correspondent_id -> Uuid,
|
||||
#[max_length = 32]
|
||||
role -> Varchar,
|
||||
assigned_at -> Timestamptz,
|
||||
assigned_by -> Nullable<Uuid>,
|
||||
tenant_id -> Uuid,
|
||||
@@ -67,6 +69,7 @@ diesel::table! {
|
||||
#[max_length = 64]
|
||||
checksum -> Varchar,
|
||||
created_at -> Timestamptz,
|
||||
operations_summary -> Jsonb,
|
||||
metadata -> Jsonb,
|
||||
tenant_id -> Uuid,
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::{collections::HashSet, sync::Arc, time::Duration};
|
||||
use async_trait::async_trait;
|
||||
use diesel::prelude::*;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use serde_json::{json, Map, Value};
|
||||
use tokio::task;
|
||||
use tracing::{error, warn};
|
||||
use uuid::Uuid;
|
||||
@@ -104,7 +104,7 @@ fn analyze_document(
|
||||
|
||||
let tenant_id = document.tenant_id;
|
||||
|
||||
let (supported, _reason) = determine_thumbnail_support(&document);
|
||||
let (supported, reason) = determine_thumbnail_support(&document);
|
||||
let ocr_supported = document_is_pdf(&document);
|
||||
|
||||
let existing_ocr: Option<DocumentAsset> = document_assets::table
|
||||
@@ -117,6 +117,32 @@ fn analyze_document(
|
||||
|
||||
let skip_ocr = existing_ocr.is_some() && !payload.force;
|
||||
|
||||
let mut summary_map = match version.operations_summary {
|
||||
Value::Object(map) => map,
|
||||
_ => Map::new(),
|
||||
};
|
||||
summary_map.insert("thumbnail_supported".to_string(), Value::Bool(supported));
|
||||
if let Some(reason) = reason {
|
||||
summary_map.insert("thumbnail_reason".to_string(), Value::String(reason));
|
||||
} else {
|
||||
summary_map.remove("thumbnail_reason");
|
||||
}
|
||||
|
||||
summary_map.insert("ocr_supported".to_string(), Value::Bool(ocr_supported));
|
||||
if ocr_supported {
|
||||
summary_map.remove("ocr_reason");
|
||||
} else {
|
||||
summary_map.insert(
|
||||
"ocr_reason".to_string(),
|
||||
Value::String("document is not a PDF".into()),
|
||||
);
|
||||
}
|
||||
|
||||
diesel::update(document_versions::table.find(version.id))
|
||||
.set(document_versions::operations_summary.eq(Value::Object(summary_map)))
|
||||
.execute(&mut conn)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
if supported {
|
||||
let enqueue_result = enqueue_job(
|
||||
&mut conn,
|
||||
|
||||
+4
-267
@@ -1,49 +1,15 @@
|
||||
mod common;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use axum::body::Body;
|
||||
use axum::http::{header::SET_COOKIE, StatusCode};
|
||||
use backend::models::NewUserMembership;
|
||||
use backend::schema::{tenants, user_memberships};
|
||||
use anyhow::Result;
|
||||
use axum::http::StatusCode;
|
||||
use common::{acquire_db_lock, body_to_vec, TestApp};
|
||||
use diesel::prelude::*;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AuthenticatedUser {
|
||||
username: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ErrorResponse {
|
||||
error: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LoginTenant {
|
||||
slug: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LoginResponse {
|
||||
access_token: String,
|
||||
tenant: LoginTenant,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TenantSelectionResponse {
|
||||
access_token: String,
|
||||
tenants: Vec<TenantSummary>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TenantSummary {
|
||||
tenant_id: Uuid,
|
||||
slug: String,
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_and_me_roundtrip() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
@@ -52,9 +18,9 @@ async fn login_and_me_roundtrip() -> Result<()> {
|
||||
let password = "s3cret";
|
||||
app.insert_user("alice", password, "admin").await?;
|
||||
|
||||
let (login, _) = login_with_session(&app, "alice", password).await?;
|
||||
let token = app.login_token("alice", password).await?;
|
||||
|
||||
let response = app.get("/api/auth/me", Some(&login.access_token)).await?;
|
||||
let response = app.get("/api/auth/me", Some(&token)).await?;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = body_to_vec(response.into_body()).await?;
|
||||
let user: AuthenticatedUser = serde_json::from_slice(&body)?;
|
||||
@@ -64,232 +30,3 @@ async fn login_and_me_roundtrip() -> Result<()> {
|
||||
app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_rejects_unknown_user() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
let app = TestApp::new().await?;
|
||||
|
||||
let payload = json!({ "username": "ghost", "password": "nope" });
|
||||
let response = app.post_json("/api/auth/login", &payload, None).await?;
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
let body = body_to_vec(response.into_body()).await?;
|
||||
let err: ErrorResponse = serde_json::from_slice(&body)?;
|
||||
assert_eq!(err.error, "unauthorized");
|
||||
|
||||
app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_rejects_invalid_password() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
let app = TestApp::new().await?;
|
||||
|
||||
let password = "valid";
|
||||
app.insert_user("robin", password, "admin").await?;
|
||||
|
||||
let payload = json!({ "username": "robin", "password": "wrong" });
|
||||
let response = app.post_json("/api/auth/login", &payload, None).await?;
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
let body = body_to_vec(response.into_body()).await?;
|
||||
let err: ErrorResponse = serde_json::from_slice(&body)?;
|
||||
assert_eq!(err.error, "unauthorized");
|
||||
|
||||
app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_rotates_refresh_token() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
let app = TestApp::new().await?;
|
||||
|
||||
let password = "rotate";
|
||||
app.insert_user("rita", password, "admin").await?;
|
||||
|
||||
let (login, refresh_cookie) = login_with_session(&app, "rita", password).await?;
|
||||
|
||||
let response = app
|
||||
.post_json_with_cookie("/api/auth/refresh", &json!({}), None, Some(&refresh_cookie))
|
||||
.await?;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let new_cookie = extract_refresh_cookie(response.headers())?;
|
||||
let body = body_to_vec(response.into_body()).await?;
|
||||
let refreshed: LoginResponse = serde_json::from_slice(&body)?;
|
||||
assert_eq!(refreshed.tenant.slug, login.tenant.slug);
|
||||
|
||||
let me_response = app
|
||||
.get("/api/auth/me", Some(&refreshed.access_token))
|
||||
.await?;
|
||||
assert_eq!(me_response.status(), StatusCode::OK);
|
||||
|
||||
let retry = app
|
||||
.post_json_with_cookie("/api/auth/refresh", &json!({}), None, Some(&refresh_cookie))
|
||||
.await?;
|
||||
assert_eq!(retry.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
// new cookie should differ from old to avoid reuse
|
||||
assert_ne!(new_cookie, refresh_cookie);
|
||||
|
||||
app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn logout_revokes_refresh_token() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
let app = TestApp::new().await?;
|
||||
|
||||
let password = "logout";
|
||||
app.insert_user("logan", password, "admin").await?;
|
||||
|
||||
let (login, refresh_cookie) = login_with_session(&app, "logan", password).await?;
|
||||
|
||||
let response = app
|
||||
.post_json_with_cookie(
|
||||
"/api/auth/logout",
|
||||
&json!({}),
|
||||
Some(&login.access_token),
|
||||
Some(&refresh_cookie),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||
let cleared_cookie = extract_refresh_cookie(response.headers())?;
|
||||
assert!(cleared_cookie.ends_with("="));
|
||||
|
||||
let after_logout = app
|
||||
.post_json_with_cookie("/api/auth/refresh", &json!({}), None, Some(&refresh_cookie))
|
||||
.await?;
|
||||
assert_eq!(after_logout.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn me_requires_authentication() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
let app = TestApp::new().await?;
|
||||
|
||||
let unauthenticated = app.get("/api/auth/me", None).await?;
|
||||
assert_eq!(unauthenticated.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
let invalid = app.get("/api/auth/me", Some("invalid")).await?;
|
||||
assert_eq!(invalid.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_returns_tenant_selection_when_multiple_memberships() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
let app = TestApp::new().await?;
|
||||
|
||||
let password = "multipass";
|
||||
let user_id = app.insert_user("multipass", password, "admin").await?;
|
||||
|
||||
let secondary_slug = "secondary".to_string();
|
||||
let slug_for_insert = secondary_slug.clone();
|
||||
let secondary_id = Uuid::new_v4();
|
||||
app.with_conn(move |conn| {
|
||||
diesel::insert_into(tenants::table)
|
||||
.values((
|
||||
tenants::id.eq(secondary_id),
|
||||
tenants::slug.eq(&slug_for_insert),
|
||||
))
|
||||
.execute(conn)?;
|
||||
|
||||
let membership = NewUserMembership {
|
||||
id: Uuid::new_v4(),
|
||||
user_id,
|
||||
tenant_id: secondary_id,
|
||||
role: "admin".to_string(),
|
||||
};
|
||||
|
||||
diesel::insert_into(user_memberships::table)
|
||||
.values(&membership)
|
||||
.execute(conn)?;
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
|
||||
let payload = json!({ "username": "multipass", "password": password });
|
||||
let response = app.post_json("/api/auth/login", &payload, None).await?;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = body_to_vec(response.into_body()).await?;
|
||||
let selection: TenantSelectionResponse = serde_json::from_slice(&body)?;
|
||||
assert!(selection.tenants.len() >= 2);
|
||||
let secondary = selection
|
||||
.tenants
|
||||
.iter()
|
||||
.find(|tenant| tenant.slug == secondary_slug)
|
||||
.map(|t| t.tenant_id)
|
||||
.context("secondary tenant missing from selection")?;
|
||||
|
||||
let select_response = app
|
||||
.post_json(
|
||||
"/api/auth/select-tenant",
|
||||
&json!({ "tenant_id": secondary }),
|
||||
Some(&selection.access_token),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(select_response.status(), StatusCode::OK);
|
||||
let session_cookie = extract_refresh_cookie(select_response.headers())?;
|
||||
let select_body = body_to_vec(select_response.into_body()).await?;
|
||||
let login: LoginResponse = serde_json::from_slice(&select_body)?;
|
||||
assert_eq!(login.tenant.slug, secondary_slug);
|
||||
|
||||
let me_response = app.get("/api/auth/me", Some(&login.access_token)).await?;
|
||||
assert_eq!(me_response.status(), StatusCode::OK);
|
||||
|
||||
let refresh_response = app
|
||||
.post_json_with_cookie("/api/auth/refresh", &json!({}), None, Some(&session_cookie))
|
||||
.await?;
|
||||
assert_eq!(refresh_response.status(), StatusCode::OK);
|
||||
|
||||
app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn login_with_session(
|
||||
app: &TestApp,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<(LoginResponse, String)> {
|
||||
let payload = json!({ "username": username, "password": password });
|
||||
let response = app.post_json("/api/auth/login", &payload, None).await?;
|
||||
ensure_status(&response, StatusCode::OK)?;
|
||||
let refresh_cookie = extract_refresh_cookie(response.headers())?;
|
||||
let body = body_to_vec(response.into_body()).await?;
|
||||
let login: LoginResponse = serde_json::from_slice(&body)
|
||||
.map_err(|_| anyhow!("expected login response with session"))?;
|
||||
Ok((login, refresh_cookie))
|
||||
}
|
||||
|
||||
fn extract_refresh_cookie(headers: &axum::http::HeaderMap) -> Result<String> {
|
||||
let header_value = headers
|
||||
.get(SET_COOKIE)
|
||||
.context("missing set-cookie header")?
|
||||
.to_str()
|
||||
.context("invalid set-cookie header")?;
|
||||
let cookie = header_value
|
||||
.split(';')
|
||||
.next()
|
||||
.context("set-cookie missing cookie value")?
|
||||
.to_string();
|
||||
Ok(cookie)
|
||||
}
|
||||
|
||||
fn ensure_status(response: &hyper::Response<Body>, expected: StatusCode) -> Result<()> {
|
||||
if response.status() == expected {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!(
|
||||
"unexpected status: got {}, expected {}",
|
||||
response.status(),
|
||||
expected
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::time::Duration;
|
||||
use anyhow::{anyhow, ensure, Context, Result};
|
||||
use async_trait::async_trait;
|
||||
use axum::body::Body;
|
||||
use axum::http::{header, Method, Request, StatusCode};
|
||||
use axum::http::{Method, Request, StatusCode};
|
||||
use axum::Router;
|
||||
use backend::auth::jwt::JwtService;
|
||||
use backend::config::AppConfig;
|
||||
@@ -396,16 +396,6 @@ impl TestApp {
|
||||
path: &str,
|
||||
payload: &T,
|
||||
token: Option<&str>,
|
||||
) -> Result<hyper::Response<Body>> {
|
||||
self.post_json_with_cookie(path, payload, token, None).await
|
||||
}
|
||||
|
||||
pub async fn post_json_with_cookie<T: Serialize + ?Sized>(
|
||||
&self,
|
||||
path: &str,
|
||||
payload: &T,
|
||||
token: Option<&str>,
|
||||
cookie: Option<&str>,
|
||||
) -> Result<hyper::Response<Body>> {
|
||||
let body = serde_json::to_vec(payload)?;
|
||||
let mut builder = Request::builder()
|
||||
@@ -415,9 +405,6 @@ impl TestApp {
|
||||
if let Some(token) = token {
|
||||
builder = builder.header("authorization", format!("Bearer {token}"));
|
||||
}
|
||||
if let Some(cookie) = cookie {
|
||||
builder = builder.header(header::COOKIE, cookie);
|
||||
}
|
||||
let request = builder.body(Body::from(body))?;
|
||||
Ok(self
|
||||
.router
|
||||
|
||||
@@ -1,276 +0,0 @@
|
||||
mod common;
|
||||
|
||||
use anyhow::Result;
|
||||
use common::{acquire_db_lock, body_to_vec, TestApp};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DocumentDetail {
|
||||
document: DocumentSummary,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DocumentSummary {
|
||||
id: Uuid,
|
||||
title: String,
|
||||
#[serde(default)]
|
||||
correspondents: Vec<DocumentCorrespondentSummary>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DocumentCorrespondentSummary {
|
||||
id: Uuid,
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CorrespondentSummary {
|
||||
id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct BulkCorrespondentResult {
|
||||
assigned: usize,
|
||||
removed: usize,
|
||||
}
|
||||
|
||||
struct TestContext {
|
||||
app: TestApp,
|
||||
token: String,
|
||||
document_ids: Vec<Uuid>,
|
||||
sender_id: Uuid,
|
||||
receiver_id: Uuid,
|
||||
}
|
||||
|
||||
impl TestContext {
|
||||
const SENDER_NAME: &'static str = "Acme Corp";
|
||||
const RECEIVER_NAME: &'static str = "Bank Ltd";
|
||||
|
||||
async fn new(prefix: &str) -> Result<Self> {
|
||||
let app = TestApp::new().await?;
|
||||
let username = format!("{prefix}_user");
|
||||
let password = format!("{prefix}_pw");
|
||||
app.insert_user(&username, &password, "admin").await?;
|
||||
let token = app.login_token(&username, &password).await?;
|
||||
|
||||
let first_id =
|
||||
upload_document(&app, &token, &format!("{prefix}-one.txt"), b"letter one").await?;
|
||||
let second_id =
|
||||
upload_document(&app, &token, &format!("{prefix}-two.txt"), b"letter two").await?;
|
||||
let sender_id = create_correspondent(&app, &token, Self::SENDER_NAME).await?;
|
||||
let receiver_id = create_correspondent(&app, &token, Self::RECEIVER_NAME).await?;
|
||||
|
||||
Ok(Self {
|
||||
app,
|
||||
token,
|
||||
document_ids: vec![first_id, second_id],
|
||||
sender_id,
|
||||
receiver_id,
|
||||
})
|
||||
}
|
||||
|
||||
async fn assign(&self, correspondent_ids: &[Uuid]) -> Result<BulkCorrespondentResult> {
|
||||
self.assign_with_action(correspondent_ids, None).await
|
||||
}
|
||||
|
||||
async fn assign_with_action(
|
||||
&self,
|
||||
correspondent_ids: &[Uuid],
|
||||
action: Option<&str>,
|
||||
) -> Result<BulkCorrespondentResult> {
|
||||
let assignments: Vec<_> = correspondent_ids
|
||||
.iter()
|
||||
.map(|id| json!({ "correspondent_id": id }))
|
||||
.collect();
|
||||
|
||||
let mut payload = json!({
|
||||
"document_ids": self.document_ids,
|
||||
"assignments": assignments,
|
||||
});
|
||||
|
||||
if let Some(action) = action {
|
||||
if let Some(obj) = payload.as_object_mut() {
|
||||
obj.insert("action".to_string(), json!(action));
|
||||
}
|
||||
}
|
||||
|
||||
let response = self
|
||||
.app
|
||||
.post_json(
|
||||
"/api/documents/bulk/correspondents",
|
||||
&payload,
|
||||
Some(&self.token),
|
||||
)
|
||||
.await?;
|
||||
assert!(response.status().is_success());
|
||||
let body = body_to_vec(response.into_body()).await?;
|
||||
Ok(serde_json::from_slice(&body)?)
|
||||
}
|
||||
|
||||
async fn fetch_correspondents(
|
||||
&self,
|
||||
document_id: Uuid,
|
||||
) -> Result<Vec<DocumentCorrespondentSummary>> {
|
||||
let detail = fetch_document_detail(&self.app, &self.token, document_id).await?;
|
||||
Ok(detail.document.correspondents)
|
||||
}
|
||||
|
||||
async fn create_correspondent(&self, name: &str) -> Result<Uuid> {
|
||||
create_correspondent(&self.app, &self.token, name).await
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bulk_assign_correspondents_adds_new_links() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
let context = TestContext::new("corresp_add").await?;
|
||||
|
||||
let result = context
|
||||
.assign(&[context.sender_id, context.receiver_id])
|
||||
.await?;
|
||||
assert_eq!(result.assigned, 4);
|
||||
assert_eq!(result.removed, 0);
|
||||
|
||||
for doc_id in &context.document_ids {
|
||||
let correspondents = context.fetch_correspondents(*doc_id).await?;
|
||||
let names: Vec<_> = correspondents
|
||||
.iter()
|
||||
.map(|entry| entry.name.as_str())
|
||||
.collect();
|
||||
assert!(names.contains(&TestContext::SENDER_NAME));
|
||||
assert!(names.contains(&TestContext::RECEIVER_NAME));
|
||||
let ids: Vec<_> = correspondents.iter().map(|entry| entry.id).collect();
|
||||
assert!(ids.contains(&context.sender_id));
|
||||
assert!(ids.contains(&context.receiver_id));
|
||||
}
|
||||
|
||||
context.app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bulk_assign_correspondents_is_idempotent() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
let context = TestContext::new("corresp_idempotent").await?;
|
||||
|
||||
context
|
||||
.assign(&[context.sender_id, context.receiver_id])
|
||||
.await?;
|
||||
let repeat = context
|
||||
.assign(&[context.sender_id, context.receiver_id])
|
||||
.await?;
|
||||
assert_eq!(repeat.assigned, 0);
|
||||
assert_eq!(repeat.removed, 0);
|
||||
|
||||
context.app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bulk_remove_correspondents_detaches_links() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
let context = TestContext::new("corresp_remove").await?;
|
||||
|
||||
context
|
||||
.assign(&[context.sender_id, context.receiver_id])
|
||||
.await?;
|
||||
let removal = context
|
||||
.assign_with_action(&[context.sender_id], Some("remove"))
|
||||
.await?;
|
||||
assert_eq!(removal.assigned, 0);
|
||||
assert_eq!(removal.removed, 2);
|
||||
|
||||
for doc_id in &context.document_ids {
|
||||
let correspondents = context.fetch_correspondents(*doc_id).await?;
|
||||
assert_eq!(correspondents.len(), 1);
|
||||
let entry = &correspondents[0];
|
||||
assert_eq!(entry.id, context.receiver_id);
|
||||
assert_eq!(entry.name, TestContext::RECEIVER_NAME);
|
||||
}
|
||||
|
||||
context.app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bulk_assign_correspondents_appends_new_entries() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
let context = TestContext::new("corresp_append").await?;
|
||||
|
||||
context
|
||||
.assign(&[context.sender_id, context.receiver_id])
|
||||
.await?;
|
||||
context
|
||||
.assign_with_action(&[context.sender_id], Some("remove"))
|
||||
.await?;
|
||||
|
||||
let charlie_name = "Charlie";
|
||||
let charlie_id = context.create_correspondent(charlie_name).await?;
|
||||
let add_result = context.assign(&[charlie_id]).await?;
|
||||
assert_eq!(add_result.assigned, 2);
|
||||
assert_eq!(add_result.removed, 0);
|
||||
|
||||
for doc_id in &context.document_ids {
|
||||
let correspondents = context.fetch_correspondents(*doc_id).await?;
|
||||
assert_eq!(correspondents.len(), 2);
|
||||
let ids: Vec<_> = correspondents.iter().map(|entry| entry.id).collect();
|
||||
assert!(ids.contains(&context.receiver_id));
|
||||
assert!(ids.contains(&charlie_id));
|
||||
let names: Vec<_> = correspondents
|
||||
.iter()
|
||||
.map(|entry| entry.name.as_str())
|
||||
.collect();
|
||||
assert!(names.contains(&TestContext::RECEIVER_NAME));
|
||||
assert!(names.contains(&charlie_name));
|
||||
}
|
||||
|
||||
context.app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn upload_document(
|
||||
app: &TestApp,
|
||||
token: &str,
|
||||
filename: &str,
|
||||
contents: &[u8],
|
||||
) -> Result<Uuid> {
|
||||
let response = app
|
||||
.upload_document(
|
||||
"/api/documents",
|
||||
filename,
|
||||
"text/plain",
|
||||
contents,
|
||||
None,
|
||||
token,
|
||||
)
|
||||
.await?;
|
||||
assert!(response.status().is_success());
|
||||
let body = body_to_vec(response.into_body()).await?;
|
||||
let detail: DocumentDetail = serde_json::from_slice(&body)?;
|
||||
Ok(detail.document.id)
|
||||
}
|
||||
|
||||
async fn create_correspondent(app: &TestApp, token: &str, name: &str) -> Result<Uuid> {
|
||||
let response = app
|
||||
.post_json("/api/correspondents", &json!({ "name": name }), Some(token))
|
||||
.await?;
|
||||
assert!(response.status().is_success());
|
||||
let body = body_to_vec(response.into_body()).await?;
|
||||
let summary: CorrespondentSummary = serde_json::from_slice(&body)?;
|
||||
Ok(summary.id)
|
||||
}
|
||||
|
||||
async fn fetch_document_detail(
|
||||
app: &TestApp,
|
||||
token: &str,
|
||||
document_id: Uuid,
|
||||
) -> Result<DocumentDetail> {
|
||||
let response = app
|
||||
.get(&format!("/api/documents/{document_id}"), Some(token))
|
||||
.await?;
|
||||
assert!(response.status().is_success());
|
||||
let body = body_to_vec(response.into_body()).await?;
|
||||
Ok(serde_json::from_slice(&body)?)
|
||||
}
|
||||
+344
-634
File diff suppressed because it is too large
Load Diff
+7
-9
@@ -16,24 +16,22 @@ Health
|
||||
|
||||
Documents
|
||||
---------
|
||||
- GET /api/documents - List or search documents. Optional filters: `folder_id` (defaults to root when omitted), `include_descendants` (defaults to true unless explicitly set to `false` without filters), `status` (`active`, `deleted`, or `all`; defaults to `active`), `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 - 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 `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 - 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. Use `action=add` (default) to attach correspondents or `action=remove` to detach the provided correspondents.
|
||||
- POST /api/documents/bulk/correspondents - Bulk correspondent actions. Default `action=add` replaces existing assignments for the provided roles before adding the supplied correspondents; `action=remove` drops the specified correspondent/role pairs.
|
||||
- POST /api/documents/bulk/reanalyze - Queue re-analysis jobs for selected documents.
|
||||
- GET /api/documents/:id - Retrieve metadata and current version details for a document.
|
||||
- PATCH /api/documents/:id - Update document metadata (currently title).
|
||||
- DELETE /api/documents/:id - Soft-delete a document.
|
||||
- GET /api/documents/:id/download - Create a pre-signed download URL for the current version.
|
||||
- PATCH /api/documents/:id/folder - Move a document to another folder.
|
||||
- POST /api/documents/:id/restore - Restore a soft-deleted document. Optional body `{ "folder_id": <uuid> }` to send it to a specific folder; defaults to the original folder or root if missing.
|
||||
- GET /api/documents/:id/versions - List version history for a document.
|
||||
- GET /api/documents/:id/versions/:version_id - Fetch metadata and assets for a specific version.
|
||||
- POST /api/documents/:id/tags - Assign one or more tags to a document.
|
||||
- DELETE /api/documents/:id/tags/:tag_id - Remove a single tag from a document.
|
||||
- POST /api/documents/:id/correspondents - Assign correspondents (`assignments[]` with `correspondent_id`; optional `replace=true` overwrites existing assignments).
|
||||
- DELETE /api/documents/:id/correspondents/:correspondent_id - Remove a correspondent assignment.
|
||||
- POST /api/documents/:id/correspondents - Assign correspondents to roles (`assignments[]` with `correspondent_id` and `role`; optional `replace=true` overwrites existing assignments for those roles). Valid roles: `sender`, `receiver`, `other`.
|
||||
- DELETE /api/documents/:id/correspondents/:correspondent_id - Remove a correspondent assignment (requires `role` query string).
|
||||
|
||||
Document Assets
|
||||
---------------
|
||||
@@ -64,7 +62,7 @@ Tags
|
||||
|
||||
Correspondents
|
||||
--------------
|
||||
- GET /api/correspondents - List correspondents with usage totals.
|
||||
- GET /api/correspondents - List correspondents with usage totals and per-role counts (roles: `sender`, `receiver`, `other`).
|
||||
- POST /api/correspondents - Create a correspondent (name + optional metadata JSON).
|
||||
- PATCH /api/correspondents/:id - Update name and/or metadata.
|
||||
- DELETE /api/correspondents/:id - Remove a correspondent; fails with 400 if referenced by any document.
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export const CORRESPONDENT_ROLES = ['sender', 'receiver', 'other'];
|
||||
|
||||
export default CORRESPONDENT_ROLES;
|
||||
@@ -107,7 +107,14 @@ function CorrespondentsPanel({
|
||||
return '0';
|
||||
}
|
||||
const total = typeof usage.total === 'number' ? usage.total : 0;
|
||||
return total.toString();
|
||||
const entries = usage.by_role ? Object.entries(usage.by_role) : [];
|
||||
if (!entries.length) {
|
||||
return total.toString();
|
||||
}
|
||||
const roleSummary = entries
|
||||
.map(([role, count]) => `${role}: ${count}`)
|
||||
.join(', ');
|
||||
return `${total} (${roleSummary})`;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import React, { createContext, useContext } from 'react';
|
||||
|
||||
const DesktopContext = createContext(null);
|
||||
|
||||
export const DesktopProvider = ({ value, children }) => (
|
||||
<DesktopContext.Provider value={value}>{children}</DesktopContext.Provider>
|
||||
);
|
||||
|
||||
export const useDesktopContext = () => {
|
||||
const context = useContext(DesktopContext);
|
||||
if (!context) {
|
||||
throw new Error('useDesktopContext must be used within a DesktopProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export default DesktopContext;
|
||||
@@ -1,17 +0,0 @@
|
||||
export const preventAll = (event) => {
|
||||
if (!event) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
event.preventDefault();
|
||||
} catch (
|
||||
// eslint-disable-next-line no-empty
|
||||
error
|
||||
) {}
|
||||
try {
|
||||
event.stopPropagation();
|
||||
} catch (
|
||||
// eslint-disable-next-line no-empty
|
||||
error
|
||||
) {}
|
||||
};
|
||||
@@ -1,8 +0,0 @@
|
||||
export const clamp = (value, min, max) => {
|
||||
if (value < min) return min;
|
||||
if (value > max) return max;
|
||||
return value;
|
||||
};
|
||||
|
||||
export const formatTransform = (x, y, rotation = 0, scale = 1) =>
|
||||
`translate3d(${x}px, ${y}px, 0) rotate(${rotation}deg) scale(${scale})`;
|
||||
@@ -1,252 +0,0 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useDesktopContext } from './context';
|
||||
import { preventAll } from './events';
|
||||
import { clamp, formatTransform } from './math';
|
||||
|
||||
const DRAG_HYSTERESIS_PX = 4;
|
||||
const DRAG_HYSTERESIS_SQUARED = DRAG_HYSTERESIS_PX * DRAG_HYSTERESIS_PX;
|
||||
|
||||
const useDocumentDrag = () => {
|
||||
const {
|
||||
layoutRef,
|
||||
itemRefs,
|
||||
documentLookup,
|
||||
ensureDocumentSize,
|
||||
resolveBaseMetrics,
|
||||
bringToFront,
|
||||
setDraggingId,
|
||||
syncLayoutSnapshot,
|
||||
canvasSize,
|
||||
openOverlayForDoc,
|
||||
recalcVisibleDocIds,
|
||||
settings,
|
||||
} = useDesktopContext();
|
||||
|
||||
const dragStateRef = useRef(null);
|
||||
const { canvasPadding, defaultCanvasWidth, defaultCanvasHeight, debugDrag } = settings;
|
||||
|
||||
const finishDrag = useCallback(
|
||||
(pointerId) => {
|
||||
const state = dragStateRef.current;
|
||||
if (!state || state.pointerId !== pointerId) {
|
||||
return;
|
||||
}
|
||||
const capturedTarget = state.capturedTarget;
|
||||
if (capturedTarget && typeof capturedTarget.releasePointerCapture === 'function') {
|
||||
try {
|
||||
capturedTarget.releasePointerCapture(pointerId);
|
||||
} catch (
|
||||
// eslint-disable-next-line no-empty
|
||||
error
|
||||
) {}
|
||||
}
|
||||
dragStateRef.current = null;
|
||||
setDraggingId((current) => (current === state.docId ? null : current));
|
||||
syncLayoutSnapshot();
|
||||
},
|
||||
[setDraggingId, syncLayoutSnapshot],
|
||||
);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(event, docId) => {
|
||||
if (debugDrag) {
|
||||
console.log(
|
||||
'[skeuo] handlePointerDown fired for doc',
|
||||
docId,
|
||||
'button',
|
||||
event.button,
|
||||
'pointerType',
|
||||
event.pointerType,
|
||||
'pointerId',
|
||||
event.pointerId,
|
||||
);
|
||||
}
|
||||
preventAll(event);
|
||||
const docKey = docId != null ? String(docId) : null;
|
||||
const doc = docKey ? documentLookup.get(docKey) : null;
|
||||
const { width: docWidth, height: docHeight } = ensureDocumentSize(doc);
|
||||
const { baseScale } = resolveBaseMetrics(doc, docWidth, docHeight);
|
||||
const normalizedBaseScale =
|
||||
Number.isFinite(baseScale) && baseScale > 0 ? baseScale : 1;
|
||||
const defaultCenterX = canvasPadding + docWidth / 2;
|
||||
const defaultCenterY = canvasPadding + docHeight / 2;
|
||||
bringToFront(docId);
|
||||
const entry = layoutRef.current.get(docId) || null;
|
||||
const centerX = typeof entry?.centerX === 'number' ? entry.centerX : defaultCenterX;
|
||||
const centerY = typeof entry?.centerY === 'number' ? entry.centerY : defaultCenterY;
|
||||
|
||||
if (entry && (entry.centerX !== centerX || entry.centerY !== centerY)) {
|
||||
layoutRef.current.set(docId, { ...entry, centerX, centerY });
|
||||
}
|
||||
|
||||
const capturedTarget = event.currentTarget instanceof HTMLElement ? event.currentTarget : null;
|
||||
if (capturedTarget && typeof capturedTarget.setPointerCapture === 'function') {
|
||||
try {
|
||||
capturedTarget.setPointerCapture(event.pointerId);
|
||||
} catch (
|
||||
// eslint-disable-next-line no-empty
|
||||
error
|
||||
) {}
|
||||
}
|
||||
dragStateRef.current = {
|
||||
docId,
|
||||
pointerId: event.pointerId,
|
||||
originCenterX: centerX,
|
||||
originCenterY: centerY,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
rotation: entry?.rotation ?? 0,
|
||||
moved: false,
|
||||
locked: false,
|
||||
width: docWidth,
|
||||
height: docHeight,
|
||||
dragScale: 1,
|
||||
baseScale: normalizedBaseScale,
|
||||
capturedTarget,
|
||||
};
|
||||
setDraggingId(docId);
|
||||
},
|
||||
[
|
||||
bringToFront,
|
||||
canvasPadding,
|
||||
documentLookup,
|
||||
ensureDocumentSize,
|
||||
layoutRef,
|
||||
resolveBaseMetrics,
|
||||
setDraggingId,
|
||||
debugDrag,
|
||||
],
|
||||
);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(event) => {
|
||||
const state = dragStateRef.current;
|
||||
if (!state) {
|
||||
if (debugDrag) {
|
||||
console.log('[skeuo] handlePointerMove: no drag state for pointer', event.pointerId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (state.pointerId !== event.pointerId) {
|
||||
if (debugDrag) {
|
||||
console.log(
|
||||
'[skeuo] handlePointerMove: pointer mismatch expected',
|
||||
state.pointerId,
|
||||
'got',
|
||||
event.pointerId,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
preventAll(event);
|
||||
if (state.locked) {
|
||||
if (debugDrag) {
|
||||
console.log('[skeuo] handlePointerMove: locked drag for doc', state.docId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = layoutRef.current.get(state.docId);
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaX = event.clientX - state.startX;
|
||||
const deltaY = event.clientY - state.startY;
|
||||
const nextCenterX = state.originCenterX + deltaX;
|
||||
const nextCenterY = state.originCenterY + deltaY;
|
||||
|
||||
const docWidth = state.width;
|
||||
const docHeight = state.height;
|
||||
const halfWidth = docWidth / 2;
|
||||
const halfHeight = docHeight / 2;
|
||||
const canvasWidth = canvasSize.width || defaultCanvasWidth;
|
||||
const canvasHeight = canvasSize.height || defaultCanvasHeight;
|
||||
const minCenterX = canvasPadding + halfWidth;
|
||||
const maxCenterX = Math.max(minCenterX, canvasWidth - canvasPadding - halfWidth);
|
||||
const minCenterY = canvasPadding + halfHeight;
|
||||
const maxCenterY = Math.max(minCenterY, canvasHeight - canvasPadding - halfHeight);
|
||||
const clampedCenterX = clamp(nextCenterX, minCenterX, maxCenterX);
|
||||
const clampedCenterY = clamp(nextCenterY, minCenterY, maxCenterY);
|
||||
|
||||
if (!state.moved) {
|
||||
const distanceSquared = deltaX * deltaX + deltaY * deltaY;
|
||||
if (distanceSquared < DRAG_HYSTERESIS_SQUARED) {
|
||||
return;
|
||||
}
|
||||
bringToFront(state.docId);
|
||||
state.moved = true;
|
||||
}
|
||||
|
||||
const updated = { ...entry, centerX: clampedCenterX, centerY: clampedCenterY };
|
||||
layoutRef.current.set(state.docId, updated);
|
||||
|
||||
const node = itemRefs.current.get(state.docId);
|
||||
if (node) {
|
||||
node.style.transform = formatTransform(
|
||||
clampedCenterX - state.width / 2,
|
||||
clampedCenterY - state.height / 2,
|
||||
state.rotation,
|
||||
state.dragScale || 1,
|
||||
);
|
||||
}
|
||||
if (debugDrag) {
|
||||
console.log('[skeuo] handlePointerMove: moved doc', state.docId, 'to', clampedCenterX, clampedCenterY);
|
||||
}
|
||||
recalcVisibleDocIds();
|
||||
},
|
||||
[
|
||||
canvasPadding,
|
||||
canvasSize.height,
|
||||
canvasSize.width,
|
||||
defaultCanvasHeight,
|
||||
defaultCanvasWidth,
|
||||
itemRefs,
|
||||
layoutRef,
|
||||
recalcVisibleDocIds,
|
||||
debugDrag,
|
||||
],
|
||||
);
|
||||
|
||||
const handlePointerUp = useCallback(
|
||||
(event) => {
|
||||
const state = dragStateRef.current;
|
||||
if (state && state.pointerId === event.pointerId) {
|
||||
if (state.moved) {
|
||||
finishDrag(event.pointerId);
|
||||
return;
|
||||
}
|
||||
|
||||
const docId = state.docId;
|
||||
bringToFront(docId);
|
||||
const originInfo = {
|
||||
rotation: state.rotation || 0,
|
||||
scale: state.baseScale || 1,
|
||||
width: state.width,
|
||||
height: state.height,
|
||||
};
|
||||
finishDrag(event.pointerId);
|
||||
openOverlayForDoc(docId, originInfo);
|
||||
return;
|
||||
}
|
||||
finishDrag(event.pointerId);
|
||||
},
|
||||
[bringToFront, finishDrag, openOverlayForDoc],
|
||||
);
|
||||
|
||||
const handlePointerCancel = useCallback(
|
||||
(event) => {
|
||||
finishDrag(event.pointerId);
|
||||
},
|
||||
[finishDrag],
|
||||
);
|
||||
|
||||
return {
|
||||
handlePointerDown,
|
||||
handlePointerMove,
|
||||
handlePointerUp,
|
||||
handlePointerCancel,
|
||||
};
|
||||
};
|
||||
|
||||
export default useDocumentDrag;
|
||||
@@ -14,10 +14,13 @@ import { getTagColorStyle } from '../utils/colors';
|
||||
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;
|
||||
|
||||
const normalizeRole = (role) => (role || '').toLowerCase();
|
||||
|
||||
const derivePreviewOrientation = (metadata) => {
|
||||
const width = Number(metadata?.width);
|
||||
const height = Number(metadata?.height);
|
||||
@@ -27,41 +30,56 @@ const derivePreviewOrientation = (metadata) => {
|
||||
return 'landscape';
|
||||
};
|
||||
|
||||
const formatRoleLabel = (role) => {
|
||||
const normalized = normalizeRole(role);
|
||||
if (!normalized) return 'Other';
|
||||
return normalized.charAt(0).toUpperCase() + normalized.slice(1);
|
||||
};
|
||||
|
||||
const compareCorrespondents = (a, b) => {
|
||||
const indexA = CORRESPONDENT_ROLES.indexOf(a.role);
|
||||
const indexB = CORRESPONDENT_ROLES.indexOf(b.role);
|
||||
const rankedA = indexA === -1 ? Number.MAX_SAFE_INTEGER : indexA;
|
||||
const rankedB = indexB === -1 ? Number.MAX_SAFE_INTEGER : indexB;
|
||||
if (rankedA !== rankedB) {
|
||||
return rankedA - rankedB;
|
||||
}
|
||||
return (a.name || '').localeCompare(b.name || '');
|
||||
};
|
||||
|
||||
const sortCorrespondents = (entries = []) =>
|
||||
entries
|
||||
.map((entry) => ({
|
||||
id: entry.id,
|
||||
name: entry.name || '',
|
||||
count: entry.count,
|
||||
role: normalizeRole(entry.role),
|
||||
}))
|
||||
.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||
.sort(compareCorrespondents);
|
||||
|
||||
const CorrespondentPills = ({ entries = [], onRemove, showCount = false }) => (
|
||||
<div className="correspondent-list">
|
||||
{entries.length ? (
|
||||
entries.map((entry, index) => {
|
||||
const key = entry.id ?? `${entry.name}-${index}`;
|
||||
return (
|
||||
<span key={key} className="correspondent-pill">
|
||||
<span className="correspondent-pill__label">
|
||||
<span>
|
||||
{entry.name}
|
||||
{showCount && entry.count ? ` (${entry.count})` : ''}
|
||||
</span>
|
||||
entries.map((entry) => (
|
||||
<span key={`${entry.id}:${entry.role}`} className="correspondent-pill">
|
||||
<span className="correspondent-pill__label">
|
||||
<strong>{formatRoleLabel(entry.role)}</strong>
|
||||
<span>
|
||||
{entry.name}
|
||||
{showCount && entry.count ? ` (${entry.count})` : ''}
|
||||
</span>
|
||||
{onRemove ? (
|
||||
<button
|
||||
type="button"
|
||||
className="correspondent-pill__remove"
|
||||
onClick={() => onRemove(entry)}
|
||||
aria-label={`Remove ${entry.name}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
})
|
||||
{onRemove ? (
|
||||
<button
|
||||
type="button"
|
||||
className="correspondent-pill__remove"
|
||||
onClick={() => onRemove(entry)}
|
||||
aria-label={`Remove ${entry.name} as ${formatRoleLabel(entry.role)}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="meta">No correspondents yet.</span>
|
||||
)}
|
||||
@@ -87,18 +105,11 @@ const TagSection = ({
|
||||
tags.map((tag) => {
|
||||
const key = tag.id ?? tag.label;
|
||||
const style = getTagColorStyle(tag.color);
|
||||
const removable = Boolean(onRemove);
|
||||
const className = removable ? 'badge tag-chip tag-chip--removable' : 'badge tag-chip';
|
||||
return (
|
||||
<span key={key} className={className} style={style || undefined}>
|
||||
<span className="tag-chip__label">{tag.label}</span>
|
||||
{removable ? (
|
||||
<button
|
||||
type="button"
|
||||
className="tag-chip__remove"
|
||||
onClick={() => onRemove(tag)}
|
||||
aria-label={`Remove tag ${tag.label}`}
|
||||
>
|
||||
<span key={key} className="tag-pill" style={style || undefined}>
|
||||
{tag.label}{' '}
|
||||
{onRemove ? (
|
||||
<button type="button" onClick={() => onRemove(tag)}>
|
||||
×
|
||||
</button>
|
||||
) : null}
|
||||
@@ -156,9 +167,11 @@ const CorrespondentSection = ({
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
const nameInput = form.elements.correspondent;
|
||||
const roleSelect = form.elements.role;
|
||||
const value = nameInput.value.trim();
|
||||
const role = roleSelect.value;
|
||||
if (!value) return;
|
||||
onAdd({ name: value, input: nameInput });
|
||||
onAdd({ name: value, role, input: nameInput });
|
||||
form.reset();
|
||||
}}
|
||||
>
|
||||
@@ -167,6 +180,13 @@ const CorrespondentSection = ({
|
||||
placeholder={addPlaceholder}
|
||||
list={datalistId}
|
||||
/>
|
||||
<select name="role" defaultValue={CORRESPONDENT_ROLES[0]}>
|
||||
{CORRESPONDENT_ROLES.map((role) => (
|
||||
<option key={role} value={role}>
|
||||
{role.charAt(0).toUpperCase() + role.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="submit">{addButtonLabel}</button>
|
||||
{datalistId ? (
|
||||
<datalist id={datalistId}>
|
||||
@@ -298,6 +318,7 @@ const DetailPanel = ({
|
||||
onPromoteSelection,
|
||||
activePreviewId = null,
|
||||
onUpdateTitle = async () => false,
|
||||
onUpdateIssuedAt = async () => false,
|
||||
ensureAssetUrl = null,
|
||||
getDocumentAsset = () => null,
|
||||
correspondents = [],
|
||||
@@ -305,6 +326,8 @@ const DetailPanel = ({
|
||||
onCorrespondentRemove,
|
||||
resolveApiPath,
|
||||
onFolderNavigate = null,
|
||||
ollamaUrl = null,
|
||||
ollamaModel = 'llama3',
|
||||
resolveFolderPath = null,
|
||||
onClose = () => {},
|
||||
}) => {
|
||||
@@ -334,6 +357,12 @@ const DetailPanel = ({
|
||||
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) {
|
||||
@@ -367,6 +396,15 @@ const DetailPanel = ({
|
||||
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);
|
||||
@@ -420,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;
|
||||
@@ -462,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;
|
||||
@@ -474,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',
|
||||
@@ -678,14 +1030,17 @@ const DetailPanel = ({
|
||||
if (!doc?.id) return;
|
||||
(doc.correspondents || []).forEach((entry) => {
|
||||
if (!entry?.id) return;
|
||||
if (!map.has(entry.id)) {
|
||||
map.set(entry.id, {
|
||||
const normalizedRole = normalizeRole(entry.role);
|
||||
const key = `${entry.id}:${normalizedRole}`;
|
||||
if (!map.has(key)) {
|
||||
map.set(key, {
|
||||
id: entry.id,
|
||||
name: entry.name || '',
|
||||
role: normalizedRole,
|
||||
documentIds: new Set(),
|
||||
});
|
||||
}
|
||||
map.get(entry.id).documentIds.add(doc.id);
|
||||
map.get(key).documentIds.add(doc.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -693,20 +1048,23 @@ const DetailPanel = ({
|
||||
.map((entry) => ({
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
role: entry.role,
|
||||
documentIds: [...entry.documentIds],
|
||||
count: entry.documentIds.size,
|
||||
}))
|
||||
.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||
.sort(compareCorrespondents);
|
||||
}, [selectedDocuments]);
|
||||
|
||||
const handleBulkCorrespondentRemove = useCallback(
|
||||
(entry) => {
|
||||
if (!entry?.id) return;
|
||||
const normalizedRole = normalizeRole(entry.role);
|
||||
if (onBulkCorrespondentRemove) {
|
||||
return onBulkCorrespondentRemove({
|
||||
assignments: [
|
||||
{
|
||||
correspondent_id: entry.id,
|
||||
role: normalizedRole,
|
||||
},
|
||||
],
|
||||
documentIds: entry.documentIds,
|
||||
@@ -717,7 +1075,11 @@ const DetailPanel = ({
|
||||
const targets = entry.documentIds && entry.documentIds.length
|
||||
? entry.documentIds
|
||||
: selectedDocuments
|
||||
.filter((doc) => (doc.correspondents || []).some((item) => item.id === entry.id))
|
||||
.filter((doc) =>
|
||||
(doc.correspondents || []).some(
|
||||
(item) => item.id === entry.id && normalizeRole(item.role) === normalizedRole,
|
||||
),
|
||||
)
|
||||
.map((doc) => doc.id);
|
||||
|
||||
return Promise.all(
|
||||
@@ -725,11 +1087,12 @@ const DetailPanel = ({
|
||||
onCorrespondentRemove({
|
||||
documentId,
|
||||
correspondentId: entry.id,
|
||||
role: normalizedRole,
|
||||
}),
|
||||
),
|
||||
).catch(() => {});
|
||||
},
|
||||
[onBulkCorrespondentRemove, onCorrespondentRemove, selectedDocuments],
|
||||
[bulkCorrespondents, onBulkCorrespondentRemove, onCorrespondentRemove, selectedDocuments],
|
||||
);
|
||||
|
||||
const openZoomPreview = useCallback((config) => {
|
||||
@@ -1025,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>{' '}
|
||||
@@ -1120,12 +1560,14 @@ const DetailPanel = ({
|
||||
onCorrespondentRemove?.({
|
||||
documentId: singleDoc.id,
|
||||
correspondentId: entry.id,
|
||||
role: entry.role,
|
||||
})
|
||||
}
|
||||
onAdd={({ name, input }) =>
|
||||
onAdd={({ name, role, input }) =>
|
||||
onCorrespondentAdd?.({
|
||||
document: singleDoc,
|
||||
name,
|
||||
role,
|
||||
input,
|
||||
})
|
||||
}
|
||||
@@ -1270,8 +1712,8 @@ const DetailPanel = ({
|
||||
title="Correspondents"
|
||||
entries={bulkCorrespondents}
|
||||
onRemove={handleBulkCorrespondentRemove}
|
||||
onAdd={({ name, input }) =>
|
||||
onBulkCorrespondentAdd?.({ name, input })
|
||||
onAdd={({ name, role, input }) =>
|
||||
onBulkCorrespondentAdd?.({ name, role, input })
|
||||
}
|
||||
addPlaceholder="Add correspondent to selection"
|
||||
datalistId="correspondent-catalog-bulk"
|
||||
@@ -1291,30 +1733,15 @@ const DetailPanel = ({
|
||||
<aside className="detail-panel panel">
|
||||
<div className="panel-header">
|
||||
<div className="panel-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button ghost"
|
||||
onClick={onClose}
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button ghost"
|
||||
onClick={onClose}
|
||||
aria-label="Close detail panel"
|
||||
title="Close detail panel"
|
||||
>
|
||||
<ChevronsRightIcon />
|
||||
</button>
|
||||
{singleDoc ? (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button ghost"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onOpenPreview(singleDoc.id);
|
||||
}}
|
||||
aria-label="Open preview"
|
||||
title="Open preview"
|
||||
disabled={!singleHasPreview}
|
||||
>
|
||||
<WindowMaximizeIcon />
|
||||
</button>
|
||||
) : null}
|
||||
<div className="spacer" />
|
||||
{isBulkSelection && onBulkReanalyze ? (
|
||||
<button
|
||||
@@ -1343,6 +1770,21 @@ const DetailPanel = ({
|
||||
<DownloadIcon />
|
||||
</a>
|
||||
) : null}
|
||||
{singleDoc ? (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button ghost"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onOpenPreview(singleDoc.id);
|
||||
}}
|
||||
aria-label="Open preview"
|
||||
title="Open preview"
|
||||
disabled={!singleHasPreview}
|
||||
>
|
||||
<WindowMaximizeIcon />
|
||||
</button>
|
||||
) : null}
|
||||
{showOcrAction ? (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -25,68 +25,10 @@ const PreviewZoomOverlay = ({
|
||||
const portalTarget = ensureDocumentRoot();
|
||||
const [isNativeScale, setIsNativeScale] = useState(false);
|
||||
const [naturalSize, setNaturalSize] = useState({ width: null, height: null });
|
||||
const [renderBackdrop, setRenderBackdrop] = useState(false);
|
||||
const [isBackdropVisible, setBackdropVisible] = useState(false);
|
||||
const [displaySnapshot, setDisplaySnapshot] = useState(null);
|
||||
const scrollRef = useRef(null);
|
||||
const imageRef = useRef(null);
|
||||
const focusRef = useRef(null);
|
||||
const previouslyFocusedRef = useRef(null);
|
||||
const visibilityTimerRef = useRef(null);
|
||||
const displayTimerRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (display?.url) {
|
||||
setDisplaySnapshot(display);
|
||||
}
|
||||
}, [display]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visibilityTimerRef.current) {
|
||||
clearTimeout(visibilityTimerRef.current);
|
||||
visibilityTimerRef.current = null;
|
||||
}
|
||||
if (displayTimerRef.current) {
|
||||
cancelAnimationFrame(displayTimerRef.current);
|
||||
displayTimerRef.current = null;
|
||||
}
|
||||
|
||||
if (open && display?.url) {
|
||||
setRenderBackdrop(true);
|
||||
displayTimerRef.current = requestAnimationFrame(() => {
|
||||
displayTimerRef.current = requestAnimationFrame(() => {
|
||||
setBackdropVisible(true);
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
if (displayTimerRef.current) {
|
||||
cancelAnimationFrame(displayTimerRef.current);
|
||||
displayTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
setBackdropVisible(false);
|
||||
visibilityTimerRef.current = setTimeout(() => {
|
||||
setRenderBackdrop(false);
|
||||
}, 260);
|
||||
|
||||
return () => {
|
||||
if (visibilityTimerRef.current) {
|
||||
clearTimeout(visibilityTimerRef.current);
|
||||
visibilityTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [open, display?.url]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (visibilityTimerRef.current) {
|
||||
clearTimeout(visibilityTimerRef.current);
|
||||
}
|
||||
if (displayTimerRef.current) {
|
||||
cancelAnimationFrame(displayTimerRef.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setIsNativeScale(false);
|
||||
@@ -133,7 +75,7 @@ const PreviewZoomOverlay = ({
|
||||
previouslyFocusedRef.current.focus();
|
||||
}
|
||||
previouslyFocusedRef.current = null;
|
||||
return;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
@@ -144,24 +86,24 @@ const PreviewZoomOverlay = ({
|
||||
previouslyFocusedRef.current = null;
|
||||
}
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const activeDisplay = open && display?.url ? display : displaySnapshot;
|
||||
|
||||
useEffect(() => {
|
||||
if (!renderBackdrop || !activeDisplay?.url) {
|
||||
const scrollEl = scrollRef.current;
|
||||
if (!scrollEl) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const frame = requestAnimationFrame(() => {
|
||||
const scrollEl = scrollRef.current;
|
||||
if (scrollEl && typeof scrollEl.focus === 'function') {
|
||||
scrollEl.focus({ preventScroll: true });
|
||||
}
|
||||
scrollEl.focus();
|
||||
});
|
||||
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [renderBackdrop, activeDisplay?.url]);
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
if (previouslyFocusedRef.current && typeof previouslyFocusedRef.current.focus === 'function') {
|
||||
previouslyFocusedRef.current.focus();
|
||||
previouslyFocusedRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
event.stopPropagation();
|
||||
@@ -177,27 +119,26 @@ const PreviewZoomOverlay = ({
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowLeft') {
|
||||
if (activeDisplay?.canGoPrev && activeDisplay?.goPrev) {
|
||||
if (display?.canGoPrev && display?.goPrev) {
|
||||
event.preventDefault();
|
||||
activeDisplay.goPrev();
|
||||
display.goPrev();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowRight') {
|
||||
if (activeDisplay?.canGoNext && activeDisplay?.goNext) {
|
||||
if (display?.canGoNext && display?.goNext) {
|
||||
event.preventDefault();
|
||||
activeDisplay.goNext();
|
||||
display.goNext();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!renderBackdrop || !activeDisplay?.url || !portalTarget) {
|
||||
if (!open || !display?.url || !portalTarget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const effectiveDisplay = activeDisplay;
|
||||
const navVisible = Boolean(effectiveDisplay?.canGoPrev || effectiveDisplay?.canGoNext);
|
||||
const navVisible = Boolean(display?.canGoPrev || display?.canGoNext);
|
||||
const stageClassName = [
|
||||
'preview-zoom__stage',
|
||||
]
|
||||
@@ -211,13 +152,6 @@ const PreviewZoomOverlay = ({
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
const backdropClassName = [
|
||||
'preview-zoom-backdrop',
|
||||
isBackdropVisible ? 'preview-zoom-backdrop--visible' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
const imageStyle = isNativeScale
|
||||
? {
|
||||
cursor: 'zoom-out',
|
||||
@@ -235,7 +169,7 @@ const PreviewZoomOverlay = ({
|
||||
return createPortal(
|
||||
(
|
||||
<div
|
||||
className={backdropClassName}
|
||||
className="preview-zoom-backdrop"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Enlarged document preview"
|
||||
@@ -252,8 +186,8 @@ const PreviewZoomOverlay = ({
|
||||
tabIndex={-1}
|
||||
>
|
||||
<img
|
||||
src={effectiveDisplay.url}
|
||||
alt={effectiveDisplay.alt || 'Document preview'}
|
||||
src={display.url}
|
||||
alt={display.alt || 'Document preview'}
|
||||
className="preview-zoom__image"
|
||||
ref={imageRef}
|
||||
draggable={false}
|
||||
@@ -293,12 +227,12 @@ const PreviewZoomOverlay = ({
|
||||
className="preview-zoom__nav-button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (effectiveDisplay?.canGoPrev && effectiveDisplay?.goPrev) {
|
||||
effectiveDisplay.goPrev();
|
||||
if (display?.canGoPrev && display?.goPrev) {
|
||||
display.goPrev();
|
||||
}
|
||||
}}
|
||||
aria-label="Previous preview"
|
||||
disabled={!effectiveDisplay?.canGoPrev}
|
||||
disabled={!display?.canGoPrev}
|
||||
>
|
||||
<ArrowLeftIcon />
|
||||
</button>
|
||||
@@ -307,12 +241,12 @@ const PreviewZoomOverlay = ({
|
||||
className="preview-zoom__nav-button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (effectiveDisplay?.canGoNext && effectiveDisplay?.goNext) {
|
||||
effectiveDisplay.goNext();
|
||||
if (display?.canGoNext && display?.goNext) {
|
||||
display.goNext();
|
||||
}
|
||||
}}
|
||||
aria-label="Next preview"
|
||||
disabled={!effectiveDisplay?.canGoNext}
|
||||
disabled={!display?.canGoNext}
|
||||
>
|
||||
<ArrowRightIcon />
|
||||
</button>
|
||||
|
||||
@@ -1,22 +1,10 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { getAssetFromVersion, resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
|
||||
import { getTagColorStyle } from '../utils/colors';
|
||||
import DetailPanel from '../detail/DetailPanel';
|
||||
import {
|
||||
DownloadIcon,
|
||||
EditIcon,
|
||||
ViewListIcon,
|
||||
ViewGridIcon,
|
||||
FolderIcon,
|
||||
TrashIcon,
|
||||
RefreshIcon,
|
||||
FolderPlusIcon,
|
||||
MinusVerticalIcon,
|
||||
ArrowUpIcon,
|
||||
} from '../ui/icons';
|
||||
import { DownloadIcon, EditIcon, ViewListIcon, ViewGridIcon, FolderIcon, TrashIcon } from '../ui/icons';
|
||||
|
||||
const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
|
||||
const DEFAULT_GRID_ICON_SIZE = 144;
|
||||
const DEFAULT_GRID_ICON_SIZE = 96;
|
||||
const DEFAULT_GRID_TITLE_SIZE = '11px';
|
||||
const LIST_ICON_SIZE = 48;
|
||||
|
||||
@@ -25,39 +13,6 @@ const getPageCount = (doc) =>
|
||||
? doc.current_version.metadata.page_count
|
||||
: null;
|
||||
|
||||
const resolveCorrespondents = (doc) => {
|
||||
if (!doc || !Array.isArray(doc.correspondents)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seen = new Set();
|
||||
const results = [];
|
||||
|
||||
doc.correspondents.forEach((entry, index) => {
|
||||
if (!entry) return;
|
||||
|
||||
const id = entry.id ?? entry.correspondent_id ?? null;
|
||||
const name = (entry.name || entry.label || entry.slug || '').trim();
|
||||
if (!name) return;
|
||||
|
||||
if (id && seen.has(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (id) {
|
||||
seen.add(id);
|
||||
}
|
||||
|
||||
results.push({
|
||||
id,
|
||||
name,
|
||||
key: id ?? `${name}-${index}`,
|
||||
});
|
||||
});
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
// Detects when an element becomes visible within a scroll container.
|
||||
const useLazyVisibility = (rootRef, resetKey) => {
|
||||
const targetRef = useRef(null);
|
||||
@@ -156,25 +111,6 @@ const DocumentThumbnailImage = ({
|
||||
innerClasses.push('document-thumbnail-inner--multipage');
|
||||
}
|
||||
|
||||
const aspectRatio = useMemo(() => {
|
||||
if (Number.isFinite(assetWidth) && Number.isFinite(assetHeight) && assetWidth > 0 && assetHeight > 0) {
|
||||
return assetWidth / assetHeight;
|
||||
}
|
||||
return null;
|
||||
}, [assetWidth, assetHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
const node = visibilityRef.current;
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
if (aspectRatio) {
|
||||
node.dataset.thumbnailAspect = String(aspectRatio);
|
||||
} else {
|
||||
delete node.dataset.thumbnailAspect;
|
||||
}
|
||||
}, [aspectRatio]);
|
||||
|
||||
return (
|
||||
<div className="document-thumbnail-wrapper" ref={visibilityRef}>
|
||||
<div className={innerClasses.join(' ')} style={innerStyle}>
|
||||
@@ -229,7 +165,6 @@ const DocumentsTable = ({
|
||||
onFolderRename,
|
||||
onDocumentRename,
|
||||
tagLookupById,
|
||||
activeCorrespondentIds = [],
|
||||
onDocumentListFocus,
|
||||
onDocumentListKeyDown,
|
||||
onFocusedRowChange,
|
||||
@@ -237,7 +172,6 @@ const DocumentsTable = ({
|
||||
getDocumentAsset = () => null,
|
||||
getDownloadHref,
|
||||
onTagClick,
|
||||
onCorrespondentClick,
|
||||
isSearchLoading = false,
|
||||
onDocumentTagDrop,
|
||||
viewMode = 'list',
|
||||
@@ -260,12 +194,7 @@ const DocumentsTable = ({
|
||||
() => new Set(draggingDocumentIds || []),
|
||||
[draggingDocumentIds],
|
||||
);
|
||||
const activeCorrespondentIdSet = useMemo(
|
||||
() => new Set(activeCorrespondentIds || []),
|
||||
[activeCorrespondentIds],
|
||||
);
|
||||
const scrollRef = useRef(null);
|
||||
const suppressDocumentClickRef = useRef(false);
|
||||
const [, forceVisibilityTick] = useState(0);
|
||||
const lastScrollNodeRef = useRef(null);
|
||||
const assignScrollRef = useCallback((node) => {
|
||||
@@ -404,79 +333,6 @@ const DocumentsTable = ({
|
||||
[isTagDragEvent, onDocumentTagDrop],
|
||||
);
|
||||
|
||||
const handleDocumentClick = useCallback(
|
||||
(documentId, event) => {
|
||||
if (suppressDocumentClickRef.current) {
|
||||
return;
|
||||
}
|
||||
onDocumentRowClick?.(documentId, event);
|
||||
},
|
||||
[onDocumentRowClick],
|
||||
);
|
||||
|
||||
const handleDocumentDragStartLocal = useCallback(
|
||||
(event, doc) => {
|
||||
suppressDocumentClickRef.current = true;
|
||||
onDocumentDragStart?.(event, doc);
|
||||
},
|
||||
[onDocumentDragStart],
|
||||
);
|
||||
|
||||
const handleDocumentDragEndLocal = useCallback(
|
||||
(event) => {
|
||||
onDocumentDragEnd?.(event);
|
||||
requestAnimationFrame(() => {
|
||||
suppressDocumentClickRef.current = false;
|
||||
});
|
||||
},
|
||||
[onDocumentDragEnd],
|
||||
);
|
||||
|
||||
const renderCorrespondentLinks = useCallback(
|
||||
(correspondents) =>
|
||||
correspondents.map((correspondent, index) => {
|
||||
const isActive = correspondent.id != null && activeCorrespondentIdSet.has(correspondent.id);
|
||||
const hasClickHandler = Boolean(onCorrespondentClick) && correspondent.id != null;
|
||||
const classNames = ['doc-correspondent-link'];
|
||||
if (isActive) classNames.push('is-active');
|
||||
if (!hasClickHandler) classNames.push('is-static');
|
||||
const isLast = index === correspondents.length - 1;
|
||||
const label = isLast
|
||||
? `${correspondent.name}:${String.fromCharCode(160)}`
|
||||
: correspondent.name;
|
||||
return (
|
||||
<React.Fragment key={correspondent.key ?? correspondent.id ?? `${correspondent.name}-${index}`}>
|
||||
<button
|
||||
type="button"
|
||||
className={classNames.join(' ')}
|
||||
aria-disabled={hasClickHandler ? undefined : true}
|
||||
onClick={(event) => {
|
||||
if (!hasClickHandler) {
|
||||
return;
|
||||
}
|
||||
event.stopPropagation();
|
||||
onCorrespondentClick(correspondent.id, correspondent);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (!hasClickHandler) {
|
||||
return;
|
||||
}
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.stopPropagation();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
{!isLast ? (
|
||||
<span className="doc-correspondent-link__separator">, </span>
|
||||
) : null}
|
||||
</React.Fragment>
|
||||
);
|
||||
}),
|
||||
[activeCorrespondentIdSet, onCorrespondentClick],
|
||||
);
|
||||
|
||||
const showDefaultEmptyState = !showingSearchResults && !subfolders.length && rows.length === 0;
|
||||
const showListSearchEmptyState =
|
||||
showingSearchResults && rows.length === 0 && !isGridView && !isSearchLoading;
|
||||
@@ -642,7 +498,6 @@ const DocumentsTable = ({
|
||||
const tagList = Array.isArray(doc.tags) ? doc.tags : [];
|
||||
const visibleTags = tagList.slice(0, 3);
|
||||
const remainingTagCount = tagList.length > 3 ? tagList.length - 3 : 0;
|
||||
const correspondents = resolveCorrespondents(doc);
|
||||
const cardClasses = ['document-card', 'document'];
|
||||
if (isSelected) cardClasses.push('selected');
|
||||
if (isDraggingDoc) cardClasses.push('is-dragging');
|
||||
@@ -653,11 +508,11 @@ const DocumentsTable = ({
|
||||
role="listitem"
|
||||
id={`document-card-${doc.id}`}
|
||||
data-doc-id={doc.id}
|
||||
onClick={(event) => handleDocumentClick(doc.id, event)}
|
||||
onClick={(event) => onDocumentRowClick(doc.id, event)}
|
||||
onDoubleClick={() => onDocumentOpen(doc.id)}
|
||||
draggable
|
||||
onDragStart={(event) => handleDocumentDragStartLocal(event, doc)}
|
||||
onDragEnd={handleDocumentDragEndLocal}
|
||||
onDragStart={(event) => onDocumentDragStart(event, doc)}
|
||||
onDragEnd={onDocumentDragEnd}
|
||||
onDragOver={(event) => handleDocumentTagDragOver(event)}
|
||||
onDragOverCapture={(event) => handleDocumentTagDragOver(event)}
|
||||
onDragLeave={handleDocumentTagDragLeave}
|
||||
@@ -678,12 +533,7 @@ const DocumentsTable = ({
|
||||
className="document-card__title"
|
||||
title={doc.title || doc.original_name}
|
||||
>
|
||||
{correspondents.length > 0 ? (
|
||||
<span className="doc-correspondents">
|
||||
{renderCorrespondentLinks(correspondents)}
|
||||
</span>
|
||||
) : null}
|
||||
<span>{doc.title || doc.original_name}</span>
|
||||
{doc.title || doc.original_name}
|
||||
</div>
|
||||
{visibleTags.length > 0 && (
|
||||
<div className="document-card__tags">
|
||||
@@ -756,7 +606,8 @@ const DocumentsTable = ({
|
||||
<tr>
|
||||
<th className="thumb-column">Preview</th>
|
||||
<th>Name</th>
|
||||
<th>Issued</th>
|
||||
<th>Type</th>
|
||||
<th>Updated</th>
|
||||
<th className="actions-column">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -820,6 +671,7 @@ const DocumentsTable = ({
|
||||
<span>{folder.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>Folder</td>
|
||||
<td>—</td>
|
||||
<td className="actions">
|
||||
<div className="action-buttons">
|
||||
@@ -869,7 +721,6 @@ const DocumentsTable = ({
|
||||
if (isSelected) rowClasses.push('selected');
|
||||
if (isDraggingDoc) rowClasses.push('is-dragging');
|
||||
const downloadHref = getDownloadHref?.(doc) || null;
|
||||
const correspondents = resolveCorrespondents(doc);
|
||||
|
||||
return (
|
||||
<tr
|
||||
@@ -877,11 +728,11 @@ const DocumentsTable = ({
|
||||
className={rowClasses.join(' ')}
|
||||
id={`document-row-${doc.id}`}
|
||||
data-doc-id={doc.id}
|
||||
onClick={(event) => handleDocumentClick(doc.id, event)}
|
||||
onClick={(event) => onDocumentRowClick(doc.id, event)}
|
||||
onDoubleClick={() => onDocumentOpen(doc.id)}
|
||||
draggable
|
||||
onDragStart={(event) => handleDocumentDragStartLocal(event, doc)}
|
||||
onDragEnd={handleDocumentDragEndLocal}
|
||||
onDragStart={(event) => onDocumentDragStart(event, doc)}
|
||||
onDragEnd={onDocumentDragEnd}
|
||||
onDragOver={handleDocumentTagDragOver}
|
||||
onDragLeave={handleDocumentTagDragLeave}
|
||||
onDrop={(event) => handleDocumentTagDrop(event, doc.id)}
|
||||
@@ -898,14 +749,7 @@ const DocumentsTable = ({
|
||||
<td className="doc-list__name">
|
||||
<div className="doc-name">
|
||||
<div className="doc-list__name-content">
|
||||
<span className="doc-name__title">
|
||||
{correspondents.length > 0 ? (
|
||||
<span className="doc-correspondents">
|
||||
{renderCorrespondentLinks(correspondents)}
|
||||
</span>
|
||||
) : null}
|
||||
<span>{doc.title || doc.original_name}</span>
|
||||
</span>
|
||||
<span className="doc-name__title">{doc.title || doc.original_name}</span>
|
||||
</div>
|
||||
{(doc.tags || []).length > 0 && (
|
||||
<div className="doc-name__tags">
|
||||
@@ -966,18 +810,11 @@ const DocumentsTable = ({
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td>{doc.content_type || 'Document'}</td>
|
||||
<td>
|
||||
{(() => {
|
||||
const issuedAt = doc.issued_at || doc.updated_at || null;
|
||||
if (!issuedAt) {
|
||||
return '—';
|
||||
}
|
||||
const timestamp = Date.parse(issuedAt);
|
||||
if (Number.isNaN(timestamp)) {
|
||||
return '—';
|
||||
}
|
||||
return new Date(timestamp).toLocaleDateString();
|
||||
})()}
|
||||
{doc.updated_at
|
||||
? new Date(doc.updated_at).toLocaleString()
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="actions">
|
||||
<div className="action-buttons">
|
||||
@@ -1059,139 +896,3 @@ const DocumentsTable = ({
|
||||
|
||||
export default DocumentsTable;
|
||||
export { DocumentThumbnailImage };
|
||||
|
||||
export const createDocumentsTableHeaderActions = ({
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
onRequestCreateFolder,
|
||||
creatingFolder,
|
||||
onRefresh,
|
||||
onShowSkeuoWorkspace,
|
||||
}) => {
|
||||
const isGridView = viewMode === 'grid';
|
||||
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const createDocumentsSurface = ({
|
||||
tableProps,
|
||||
parentBreadcrumb,
|
||||
onNavigateParent,
|
||||
renderSidebarToggle,
|
||||
detailProps,
|
||||
}) => {
|
||||
const {
|
||||
currentFolderName,
|
||||
searchResults,
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
onRequestCreateFolder,
|
||||
creatingFolder,
|
||||
onRefresh,
|
||||
onShowSkeuoWorkspace,
|
||||
} = tableProps;
|
||||
|
||||
const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName;
|
||||
const subtitle = Array.isArray(searchResults)
|
||||
? `${searchResults.length} matching document${searchResults.length === 1 ? '' : 's'}`
|
||||
: null;
|
||||
|
||||
const actions = createDocumentsTableHeaderActions({
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
onRequestCreateFolder,
|
||||
creatingFolder,
|
||||
onRefresh,
|
||||
onShowSkeuoWorkspace,
|
||||
});
|
||||
|
||||
const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null;
|
||||
const parentControl = parentBreadcrumb
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button ghost"
|
||||
onClick={onNavigateParent}
|
||||
aria-label="Go to parent folder"
|
||||
title="Go to parent folder"
|
||||
>
|
||||
<ArrowUpIcon />
|
||||
</button>
|
||||
)
|
||||
: null;
|
||||
const leading = sidebarToggle || parentControl
|
||||
? (
|
||||
<>
|
||||
{sidebarToggle}
|
||||
{parentControl}
|
||||
</>
|
||||
)
|
||||
: null;
|
||||
|
||||
const detail = (() => {
|
||||
if (!detailProps) {
|
||||
return null;
|
||||
}
|
||||
const count = detailProps.selectedDocuments?.length || 0;
|
||||
if (!count) {
|
||||
return null;
|
||||
}
|
||||
return <DetailPanel {...detailProps} />;
|
||||
})();
|
||||
|
||||
return {
|
||||
key: 'documents',
|
||||
variant: 'documents',
|
||||
header: { title, subtitle, leading, actions },
|
||||
content: <DocumentsTable {...tableProps} showHeader={false} />,
|
||||
detail,
|
||||
};
|
||||
};
|
||||
|
||||
+404
-567
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,13 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { resolveDocumentAssetUrl } from '../asset_manager';
|
||||
import React from 'react';
|
||||
import { DownloadIcon } from '../ui/icons';
|
||||
import { formatFileSize } from '../utils/format';
|
||||
import { DownloadIcon, TextScanIcon, AnalyzeIcon, CloseIcon } from '../ui/icons';
|
||||
|
||||
const PreviewWorkspace = ({
|
||||
document,
|
||||
previewEntry,
|
||||
resolveApiPath,
|
||||
onClose,
|
||||
onRegenerateThumbnails,
|
||||
}) => {
|
||||
if (!document) {
|
||||
return null;
|
||||
@@ -13,63 +15,59 @@ const PreviewWorkspace = ({
|
||||
|
||||
const title = document.title || document.original_name || 'Document';
|
||||
const mime = previewEntry?.contentType || document.content_type || 'application/pdf';
|
||||
const downloadHref = document.current_version?.download_path
|
||||
? resolveApiPath(document.current_version.download_path)
|
||||
: null;
|
||||
const sizeBytes = Number(document.current_version?.size_bytes) || 0;
|
||||
const sizeLabel = sizeBytes > 0 ? formatFileSize(sizeBytes) : null;
|
||||
const metadata =
|
||||
document.metadata && Object.keys(document.metadata).length > 0 ? document.metadata : null;
|
||||
const folderName = document.folder_path || document.folder_name || null;
|
||||
const issuedAt = document.issued_at || document.current_version?.issued_at || null;
|
||||
const createdAt = document.created_at || null;
|
||||
const updatedAt = document.updated_at || null;
|
||||
const tags = Array.isArray(document.tags) ? document.tags : [];
|
||||
const correspondents = Array.isArray(document.correspondents) ? document.correspondents : [];
|
||||
|
||||
const metadataSummary = useMemo(() => {
|
||||
const rows = [];
|
||||
if (mime) rows.push(['Type', mime]);
|
||||
if (sizeLabel) rows.push(['Size', sizeLabel]);
|
||||
if (issuedAt) rows.push(['Issued', new Date(issuedAt).toLocaleString()]);
|
||||
if (createdAt) rows.push(['Created', new Date(createdAt).toLocaleString()]);
|
||||
if (updatedAt) rows.push(['Updated', new Date(updatedAt).toLocaleString()]);
|
||||
if (folderName) rows.push(['Folder', folderName]);
|
||||
if (tags.length) {
|
||||
rows.push(['Tags', tags.map((tag) => tag.label || tag.name || tag.slug).filter(Boolean).join(', ')]);
|
||||
}
|
||||
if (correspondents.length) {
|
||||
rows.push([
|
||||
'Correspondents',
|
||||
correspondents
|
||||
.map((entry) => entry.name || entry.label || entry.slug)
|
||||
.filter(Boolean)
|
||||
.join(', '),
|
||||
]);
|
||||
}
|
||||
return rows;
|
||||
}, [mime, sizeLabel, issuedAt, createdAt, updatedAt, folderName, tags, correspondents]);
|
||||
|
||||
return (
|
||||
<section className="preview-workspace">
|
||||
<aside className="preview-workspace__sidebar">
|
||||
<div className="preview-workspace__info">
|
||||
{metadataSummary.length ? (
|
||||
<dl className="preview-workspace__summary">
|
||||
{metadataSummary.map(([label, value]) => (
|
||||
<div className="preview-workspace__summary-row" key={label}>
|
||||
<dt>{label}</dt>
|
||||
<dd>{value || '—'}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
) : null}
|
||||
<header className="preview-workspace__header">
|
||||
<div className="preview-workspace__meta">
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={() => onClose(document.folder_id ?? 'root')}
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<div>
|
||||
<h2>{title}</h2>
|
||||
<span className="meta">
|
||||
{document.content_type || mime}
|
||||
{sizeLabel ? ` · ${sizeLabel}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{metadata ? (
|
||||
<section className="preview-workspace__metadata">
|
||||
<h4>Metadata payload</h4>
|
||||
<pre>{JSON.stringify(metadata, null, 2)}</pre>
|
||||
</section>
|
||||
) : null}
|
||||
</aside>
|
||||
<div className="preview-workspace__viewer">
|
||||
<div className="preview-workspace__actions">
|
||||
<a
|
||||
className="button-link with-icon"
|
||||
href={downloadHref || '#'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-disabled={!downloadHref}
|
||||
onClick={(event) => {
|
||||
if (!downloadHref) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DownloadIcon className="icon-inline" />
|
||||
<span>Download</span>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={() => onRegenerateThumbnails(document.id)}
|
||||
>
|
||||
Re-run analysis
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="preview-workspace__body">
|
||||
{!previewEntry?.url ? (
|
||||
<div className="preview-workspace__message">Loading preview…</div>
|
||||
) : (
|
||||
@@ -80,147 +78,15 @@ const PreviewWorkspace = ({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{metadata && (
|
||||
<section className="preview-workspace__metadata">
|
||||
<h3>Metadata</h3>
|
||||
<pre>{JSON.stringify(metadata, null, 2)}</pre>
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default PreviewWorkspace;
|
||||
|
||||
export const createPreviewWorkspaceHeaderActions = ({
|
||||
document,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
resolveApiPath,
|
||||
notifyApiError,
|
||||
onRegenerate,
|
||||
}) => {
|
||||
if (!document) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const downloadHref = document.current_version?.download_path
|
||||
? resolveApiPath(document.current_version.download_path)
|
||||
: null;
|
||||
|
||||
const ocrAsset = getDocumentAsset(document, 'ocr-text');
|
||||
const hasOcr = Boolean(ocrAsset);
|
||||
|
||||
const handleOcrClick = async () => {
|
||||
if (!ocrAsset) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const ensured = await ensureAssetUrl(document.id, ocrAsset, { force: false });
|
||||
const entry = ensured || ocrAsset;
|
||||
const url = entry?.url
|
||||
|| resolveDocumentAssetUrl(document, 'ocr-text', {
|
||||
ensureAssetUrl,
|
||||
getAsset: getDocumentAsset,
|
||||
});
|
||||
if (!url) {
|
||||
throw new Error('OCR text URL unavailable.');
|
||||
}
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
} catch (error) {
|
||||
notifyApiError(error, 'Unable to open OCR text.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{downloadHref ? (
|
||||
<a
|
||||
className="icon-button"
|
||||
href={downloadHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Download document"
|
||||
title="Download document"
|
||||
>
|
||||
<DownloadIcon />
|
||||
</a>
|
||||
) : null}
|
||||
{hasOcr ? (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button ghost"
|
||||
onClick={handleOcrClick}
|
||||
aria-label="View OCR text"
|
||||
title="View OCR text"
|
||||
>
|
||||
<TextScanIcon />
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button ghost"
|
||||
onClick={() => onRegenerate(document.id)}
|
||||
aria-label="Re-run analysis"
|
||||
title="Re-run analysis"
|
||||
>
|
||||
<AnalyzeIcon />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const createPreviewSurface = ({
|
||||
document,
|
||||
previewEntry,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
resolveApiPath,
|
||||
notifyApiError,
|
||||
onRegenerate,
|
||||
onClose,
|
||||
renderSidebarToggle,
|
||||
}) => {
|
||||
if (!document) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const title = document.title || document.original_name || 'Document preview';
|
||||
const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null;
|
||||
const closeButton = onClose
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button ghost"
|
||||
onClick={() => onClose?.()}
|
||||
aria-label="Close preview"
|
||||
title="Close preview"
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
)
|
||||
: null;
|
||||
const leading = sidebarToggle || closeButton
|
||||
? (
|
||||
<>
|
||||
{sidebarToggle}
|
||||
{closeButton}
|
||||
</>
|
||||
)
|
||||
: null;
|
||||
const header = {
|
||||
title,
|
||||
subtitle: null,
|
||||
leading,
|
||||
actions: createPreviewWorkspaceHeaderActions({
|
||||
document,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
resolveApiPath,
|
||||
notifyApiError,
|
||||
onRegenerate,
|
||||
}),
|
||||
};
|
||||
|
||||
return {
|
||||
key: 'preview',
|
||||
variant: 'preview',
|
||||
header,
|
||||
content: <PreviewWorkspace document={document} previewEntry={previewEntry} />,
|
||||
supportsDetail: false,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { Navigate, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
import { useAppShell } from '../appShellContext';
|
||||
import PreviewWorkspace from '../preview/PreviewWorkspace';
|
||||
|
||||
const DocumentViewerRoute = () => {
|
||||
const {
|
||||
previewWorkspaceDocument,
|
||||
previewWorkspaceEntry,
|
||||
closeDocumentPreview,
|
||||
handleThumbnailRegeneration,
|
||||
ensurePreviewData,
|
||||
notifyApiError,
|
||||
resolveApiPath,
|
||||
} = useAppShell();
|
||||
const { documentId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
@@ -39,15 +44,29 @@ const DocumentViewerRoute = () => {
|
||||
};
|
||||
}, [documentId, ensurePreviewData, notifyApiError, navigate]);
|
||||
|
||||
if (!documentId) {
|
||||
return <Navigate to="/documents" replace />;
|
||||
const isReady =
|
||||
documentId && previewWorkspaceDocument && previewWorkspaceDocument.id === documentId;
|
||||
|
||||
if (!isReady) {
|
||||
return (
|
||||
<main className="preview-main">
|
||||
<div className="preview-workspace__message">Loading preview…</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (!previewWorkspaceDocument || previewWorkspaceDocument.id !== documentId) {
|
||||
return <div className="preview-workspace__message">Loading preview…</div>;
|
||||
}
|
||||
|
||||
return <Navigate to="/documents" replace />;
|
||||
return (
|
||||
<main className="preview-main">
|
||||
<PreviewWorkspace
|
||||
document={previewWorkspaceDocument}
|
||||
previewEntry={previewWorkspaceEntry}
|
||||
resolveApiPath={resolveApiPath}
|
||||
onClose={closeDocumentPreview}
|
||||
onRegenerateThumbnails={handleThumbnailRegeneration}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentViewerRoute;
|
||||
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
ChevronIcon,
|
||||
TrashIcon,
|
||||
EditIcon,
|
||||
FolderIcon,
|
||||
ChevronsLeftIcon,
|
||||
LogoutIcon,
|
||||
ChevronDownIcon,
|
||||
} from '../ui/icons';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { ChevronIcon, TrashIcon, EditIcon, FolderIcon, ChevronsLeftIcon } from '../ui/icons';
|
||||
|
||||
import { getTagColorStyle } from '../utils/colors';
|
||||
|
||||
@@ -28,9 +20,9 @@ const FolderNode = ({
|
||||
draggingFolderId,
|
||||
}) => {
|
||||
const isRoot = node.id === 'root';
|
||||
const hasChildren = Boolean(node.hasChildren);
|
||||
const hasChildren = node.children.length > 0;
|
||||
const canToggle = !isRoot && (hasChildren || !node.loaded);
|
||||
const showChevron = !isRoot && (hasChildren || !node.loaded);
|
||||
const showChevron = !isRoot && hasChildren;
|
||||
const icon = showChevron ? <ChevronIcon className="toggle-icon" /> : null;
|
||||
const canDrag = !isRoot;
|
||||
const isDragging = draggingFolderId === node.id;
|
||||
@@ -157,10 +149,6 @@ const Sidebar = ({
|
||||
onLogout,
|
||||
status,
|
||||
onCollapse,
|
||||
tenantSlug,
|
||||
tenants = [],
|
||||
activeTenantId = null,
|
||||
onSelectTenant,
|
||||
}) => {
|
||||
const sortedCorrespondents = useMemo(
|
||||
() =>
|
||||
@@ -193,59 +181,7 @@ const Sidebar = ({
|
||||
const handleSearchClear = useCallback(() => {
|
||||
onSearchClear?.();
|
||||
}, [onSearchClear]);
|
||||
const tenantButtonRef = useRef(null);
|
||||
const tenantMenuRef = useRef(null);
|
||||
const [tenantMenuOpen, setTenantMenuOpen] = useState(false);
|
||||
|
||||
const toggleTenantMenu = useCallback(() => {
|
||||
const next = !tenantMenuOpen;
|
||||
setTenantMenuOpen(next);
|
||||
if (next && tenants.length === 0 && onSelectTenant) {
|
||||
onSelectTenant(null, { refreshOnly: true });
|
||||
}
|
||||
}, [tenantMenuOpen, tenants.length, onSelectTenant]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!tenantMenuOpen) {
|
||||
return undefined;
|
||||
}
|
||||
const handlePointer = (event) => {
|
||||
const menuNode = tenantMenuRef.current;
|
||||
const buttonNode = tenantButtonRef.current;
|
||||
if (!menuNode) return;
|
||||
if (menuNode.contains(event.target)) return;
|
||||
if (buttonNode && buttonNode.contains(event.target)) return;
|
||||
setTenantMenuOpen(false);
|
||||
};
|
||||
const handleKeyDown = (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
setTenantMenuOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handlePointer);
|
||||
document.addEventListener('touchstart', handlePointer);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handlePointer);
|
||||
document.removeEventListener('touchstart', handlePointer);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [tenantMenuOpen]);
|
||||
|
||||
const handleTenantSelect = useCallback(
|
||||
(tenant) => {
|
||||
const targetId = tenant?.tenant_id || tenant?.tenantId || tenant?.id || null;
|
||||
if (!targetId) {
|
||||
return;
|
||||
}
|
||||
setTenantMenuOpen(false);
|
||||
onSelectTenant?.(tenant);
|
||||
},
|
||||
[onSelectTenant],
|
||||
);
|
||||
|
||||
const handleLogoutFromMenu = useCallback(() => {
|
||||
setTenantMenuOpen(false);
|
||||
const handleLogoutClick = useCallback(() => {
|
||||
onLogout?.();
|
||||
}, [onLogout]);
|
||||
|
||||
@@ -291,65 +227,17 @@ const Sidebar = ({
|
||||
);
|
||||
|
||||
const rootNode = folderNodes.get('root');
|
||||
const hintText = appStatus === 'bootstrapping' && loading
|
||||
? 'Loading your library…'
|
||||
: previewActive
|
||||
? 'Viewing document preview. Press ← Back to return to the library.'
|
||||
: 'Drag files here to upload.';
|
||||
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<div className="panel-header sidebar__header">
|
||||
<div className="panel-actions">
|
||||
<button
|
||||
type="button"
|
||||
className={`sidebar__title-button${tenantMenuOpen ? ' is-open' : ''}`}
|
||||
onClick={toggleTenantMenu}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={tenantMenuOpen}
|
||||
ref={tenantButtonRef}
|
||||
>
|
||||
<span className="sidebar__title">
|
||||
Papercrate
|
||||
{tenantSlug ? <span className="sidebar__tenant"> / {tenantSlug}</span> : null}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
className={`sidebar__title-chevron${tenantMenuOpen ? ' is-open' : ''}`}
|
||||
size={16}
|
||||
/>
|
||||
</button>
|
||||
{tenantMenuOpen ? (
|
||||
<div className="menu" ref={tenantMenuRef} role="menu">
|
||||
<div className="menu__list">
|
||||
{tenants.length === 0 ? (
|
||||
<span className="menu__empty">No tenants available.</span>
|
||||
) : (
|
||||
tenants.map((tenant) => {
|
||||
const tenantId = tenant?.tenant_id || tenant?.tenantId || tenant?.id || null;
|
||||
const isActive = tenantId === activeTenantId;
|
||||
return (
|
||||
<button
|
||||
key={tenantId || tenant?.slug || tenant?.name}
|
||||
type="button"
|
||||
className={`menu__item${isActive ? ' active' : ''}`}
|
||||
onClick={() => handleTenantSelect(tenant)}
|
||||
role="menuitem"
|
||||
>
|
||||
<span className="menu__label">
|
||||
{tenant?.slug || tenant?.name || tenantId || 'Tenant'}
|
||||
</span>
|
||||
{isActive ? <span className="menu__active-indicator">Active</span> : null}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<div className="menu__footer">
|
||||
<button
|
||||
type="button"
|
||||
className="menu__logout"
|
||||
onClick={handleLogoutFromMenu}
|
||||
>
|
||||
<LogoutIcon size={16} />
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<h1 className="sidebar__title">Papercrate</h1>
|
||||
<div className="spacer" />
|
||||
{onCollapse ? (
|
||||
<button
|
||||
@@ -365,6 +253,7 @@ const Sidebar = ({
|
||||
</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>
|
||||
@@ -487,6 +376,11 @@ const Sidebar = ({
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="sidebar__footer">
|
||||
<button className="secondary" type="button" onClick={handleLogoutClick}>
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* Desktop workspace styles */
|
||||
/* Skeuomorphic workspace styles */
|
||||
.skeuo-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -6,20 +6,6 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.skeuo-item {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: auto;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
transform-origin: center center;
|
||||
transition: box-shadow 0.16s ease;
|
||||
outline: none;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.skeuo-shell {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -47,14 +33,28 @@
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.skeuo-item {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: auto;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
transform-origin: center center;
|
||||
transition: transform 0.28s ease, box-shadow 0.16s ease;
|
||||
outline: none;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
|
||||
.skeuo-item__body {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.skeuo-item:focus-visible {
|
||||
@@ -68,8 +68,8 @@
|
||||
}
|
||||
|
||||
.skeuo-item.is-tag-target .skeuo-item__card {
|
||||
outline: 0.35rem dashed var(--accent);
|
||||
outline-offset: 0.35rem;
|
||||
outline: 1em dashed var(--accent);
|
||||
outline-offset: 1.41em;
|
||||
}
|
||||
|
||||
.skeuo-item.is-tag-pending .skeuo-item__card {
|
||||
@@ -98,39 +98,50 @@
|
||||
transition: transform 0.28s ease;
|
||||
}
|
||||
|
||||
|
||||
.tag-chip--draggable {
|
||||
.skeuo-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding: 0.28rem 0.7rem;
|
||||
border-radius: 1rem;
|
||||
background: linear-gradient(180deg, rgba(0, 0, 0, 0.04), rgba(0, 0, 0, 0) 70%), var(--surface);
|
||||
color: var(--fg);
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
box-shadow: 2px 2px 3px rgba(0, 0, 0, 0.18);
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.skeuo-item__tags .skeuo-tag {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.18rem 0.55rem;
|
||||
pointer-events: auto;
|
||||
cursor: grab;
|
||||
transition: transform 0.16s ease, opacity 0.2s ease, box-shadow 0.2s ease;
|
||||
box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.18);
|
||||
transition: transform 0.16s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.tag-chip--draggable:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.tag-chip--draggable.is-drag-hidden {
|
||||
.skeuo-tag.is-drag-hidden {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.skeuo-item__tags .tag-chip {
|
||||
font-size: 0.85rem;
|
||||
padding: 0.18rem 0.55rem;
|
||||
gap: 0.3rem;
|
||||
.skeuo-tag span {
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
|
||||
.skeuo-card__nav {
|
||||
position: absolute;
|
||||
bottom: 1.8rem;
|
||||
bottom: calc(3em * 0.707 * var(--nav-scale));
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
transform: translateX(-50%) scale(calc(0.707 * var(--nav-scale, 1)));
|
||||
transform-origin: center;
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
gap: 2rem;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease;
|
||||
@@ -145,10 +156,10 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.4em;
|
||||
height: 2.4em;
|
||||
padding: 0.25em;
|
||||
border-radius: 50%;
|
||||
width: 3.3em;
|
||||
height: 3.3em;
|
||||
padding: 0.36em;
|
||||
border-radius: 3.3em;
|
||||
border: none;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: #fff;
|
||||
@@ -175,8 +186,8 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.skeuo-item__tags .tag-chip--tear-pending {
|
||||
opacity: 0.35;
|
||||
.skeuo-item__tags .skeuo-tag.is-tear-pending {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
body.skeuo-cursor-remove,
|
||||
@@ -194,17 +205,9 @@ body.skeuo-cursor-remove * {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18);
|
||||
box-shadow: 0 24px 72px rgba(0, 0, 0, 0.24);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.skeuo-item__card img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
--nav-scale: 1;
|
||||
}
|
||||
|
||||
.skeuo-item__card--empty {
|
||||
@@ -246,7 +249,6 @@ body.skeuo-cursor-remove * {
|
||||
color: #3b3b3b;
|
||||
max-width: 90%;
|
||||
overflow: hidden;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
white-space: normal;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+121
-351
@@ -11,13 +11,14 @@
|
||||
--fg: oklch(0.32 0.005 calc(270deg + var(--warmth)));
|
||||
--muted: oklch(0.54 0.008 calc(270deg + var(--warmth)));
|
||||
--sidebar-fg: oklch(0.56 0.007 calc(270deg + var(--warmth)));
|
||||
--border: oklch(0.92 0.002 calc(270deg + var(--warmth)));
|
||||
--border: oklch(0.88 0.002 calc(270deg + var(--warmth)));
|
||||
|
||||
/* --- Accent (primary) --- */
|
||||
--accent: oklch(0.61 0.2 calc(250deg + var(--warmth)));
|
||||
--accent-hover: oklch(0.70 0.02 calc(100deg + var(--warmth)));
|
||||
--accent: oklch(0.61 0.20 calc(260deg + var(--warmth)));
|
||||
--accent-hover: oklch(0.70 0.18 calc(260deg + var(--warmth)));
|
||||
--on-accent: oklch(1 0 0);
|
||||
|
||||
/* --- Derived accent states --- */
|
||||
--accent-soft: color-mix(in oklch, var(--accent) 12%, transparent);
|
||||
--accent-elevated: color-mix(in oklch, var(--accent) 16%, transparent);
|
||||
--accent-elevated-strong: color-mix(in oklch, var(--accent) 22%, transparent);
|
||||
@@ -26,12 +27,6 @@
|
||||
--accent-focus: color-mix(in oklch, var(--accent) 85%, transparent);
|
||||
--surface-overlay: color-mix(in oklch, white 82%, transparent);
|
||||
|
||||
/* -- Selection --- */
|
||||
--selection: oklch(0.61 0.01 calc(100deg + var(--warmth)));
|
||||
|
||||
/* --- Derived accent states --- */
|
||||
--selection-soft: color-mix(in oklch, var(--selection) 12%, transparent);
|
||||
|
||||
/* --- Shadows & overlays --- */
|
||||
--shadow-faint: color-mix(in oklch, black 6%, transparent);
|
||||
--shadow-soft: color-mix(in oklch, black 12%, transparent);
|
||||
@@ -51,12 +46,11 @@
|
||||
--danger-subtle: color-mix(in oklch, var(--danger) 8%, transparent);
|
||||
|
||||
/* --- Explorer states --- */
|
||||
--row-hover-bg: color-mix(in oklch, var(--selection) 6%, transparent);
|
||||
--row-active-bg: color-mix(in oklch, var(--selection) 12%, transparent);
|
||||
--sidebar-hover-bg: color-mix(in oklch, var(--selection) 8%, transparent);
|
||||
--sidebar-active-bg: color-mix(in oklch, var(--selection) 16%, transparent);
|
||||
--row-hover-bg: color-mix(in oklch, var(--accent) 6%, transparent);
|
||||
--row-active-bg: color-mix(in oklch, var(--accent) 12%, transparent);
|
||||
--sidebar-hover-bg: color-mix(in oklch, var(--accent) 8%, transparent);
|
||||
--sidebar-active-bg: color-mix(in oklch, var(--accent) 16%, transparent);
|
||||
--selection-ring: oklch(0.78 0.16 calc(260deg + var(--warmth)));
|
||||
--sidebar-active-pill-border: color-mix(in oklch, var(--accent) 64%, transparent);
|
||||
|
||||
font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 15px;
|
||||
@@ -209,8 +203,8 @@ button.danger:hover:not([disabled]) {
|
||||
.panel-actions button,
|
||||
.panel-actions a.icon-button {
|
||||
display: inline-flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
@@ -219,7 +213,6 @@ button.danger:hover:not([disabled]) {
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
text-decoration: none;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.panel-actions .icon-button:hover:not([disabled]),
|
||||
@@ -244,22 +237,13 @@ button.danger:hover:not([disabled]) {
|
||||
.preview-zoom-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0);
|
||||
transition: background 0.25s ease, opacity 0.25s ease;
|
||||
background: var(--overlay-backdrop);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
z-index: 3000;
|
||||
cursor: zoom-out;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.preview-zoom-backdrop--visible {
|
||||
opacity: 1;
|
||||
background: var(--overlay-backdrop);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.preview-zoom__stage {
|
||||
@@ -460,10 +444,6 @@ button.danger:hover:not([disabled]) {
|
||||
box-shadow: -12px 0 24px -12px var(--shadow-faint);
|
||||
}
|
||||
|
||||
.documents-main:not(.documents-main--sidebar-collapsed) .main-content {
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.main-content__header {
|
||||
padding: 0.5rem 1.25rem;
|
||||
justify-content: space-between;
|
||||
@@ -557,9 +537,9 @@ button.danger:hover:not([disabled]) {
|
||||
.preview-workspace {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
min-height: 0;
|
||||
padding: 1rem 1.5rem;
|
||||
}
|
||||
|
||||
.preview-workspace__header {
|
||||
@@ -618,13 +598,6 @@ button.danger:hover:not([disabled]) {
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.document-drag-preview__thumb--image {
|
||||
background-color: #000;
|
||||
background-repeat: no-repeat;
|
||||
background-size: contain;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.document-drag-preview__thumb .document-thumbnail,
|
||||
.document-drag-preview__thumb img {
|
||||
width: 100%;
|
||||
@@ -691,84 +664,17 @@ button.danger:hover:not([disabled]) {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.preview-workspace__sidebar {
|
||||
flex: 0 0 20em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
max-width: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.preview-workspace__info {
|
||||
background: var(--surface-subtle);
|
||||
padding: 1rem;
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.preview-workspace__summary {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.preview-workspace__summary-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.preview-workspace__summary-row dt {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.preview-workspace__summary-row dd {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.preview-workspace__metadata {
|
||||
background: var(--surface-subtle);
|
||||
padding: 1rem;
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.06);
|
||||
font-size: 0.85rem;
|
||||
overflow: auto;
|
||||
max-height: 40vh;
|
||||
}
|
||||
|
||||
.preview-workspace__metadata h4 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.preview-workspace__metadata pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.preview-workspace__viewer {
|
||||
.preview-workspace__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: var(--surface-subtle);
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.06);
|
||||
display: flex;
|
||||
position: relative;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.preview-workspace__object {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.tags-panel__body {
|
||||
@@ -951,14 +857,20 @@ button.danger:hover:not([disabled]) {
|
||||
}
|
||||
|
||||
.preview-workspace__message {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 999px;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.preview-workspace__metadata {
|
||||
border-radius: 2px;
|
||||
padding: 0.75rem;
|
||||
background: var(--surface);
|
||||
max-height: 180px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.preview-workspace__metadata h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@@ -1132,140 +1044,9 @@ button.danger:hover:not([disabled]) {
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.sidebar__tenant {
|
||||
.sidebar__hint {
|
||||
color: var(--muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sidebar__header {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar__title-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sidebar__title-button:focus-visible {
|
||||
outline: 2px solid var(--accent, #2563eb);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.sidebar__title-chevron {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.sidebar__title-chevron.is-open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 1em;
|
||||
right: auto;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-muted, var(--border));
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.18);
|
||||
min-width: 220px;
|
||||
z-index: 20;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.menu__list {
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
padding: 0.35rem 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.menu button.menu__item,
|
||||
.menu .menu__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.6rem;
|
||||
width: 100%;
|
||||
padding: 0.55rem 1rem;
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.menu button.menu__item:hover,
|
||||
.menu button.menu__item:focus-visible,
|
||||
.menu .menu__item:hover,
|
||||
.menu .menu__item:focus-visible {
|
||||
background: var(--sidebar-hover-bg);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.menu button.menu__item.active,
|
||||
.menu .menu__item.active {
|
||||
font-weight: 600;
|
||||
color: var(--accent, #2563eb);
|
||||
}
|
||||
|
||||
.menu button.menu__item:focus-visible,
|
||||
.menu .menu__item:focus-visible {
|
||||
outline: 2px solid var(--accent, #2563eb);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.menu__label {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.menu__active-indicator {
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.menu__empty {
|
||||
display: block;
|
||||
padding: 0.6rem 0.75rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.menu__footer {
|
||||
border-top: 1px solid var(--border-muted, var(--border));
|
||||
padding: 0.35rem 0.5rem;
|
||||
}
|
||||
|
||||
.menu__logout {
|
||||
width: 100%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--danger, #d14343);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border-radius: 0.35rem;
|
||||
}
|
||||
|
||||
.menu__logout:hover,
|
||||
.menu__logout:focus-visible {
|
||||
background: rgba(209, 67, 67, 0.12);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.sidebar__search {
|
||||
@@ -1320,7 +1101,6 @@ button.danger:hover:not([disabled]) {
|
||||
.sidebar-section__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 0.75rem;
|
||||
justify-content: space-between;
|
||||
color: var(--muted);
|
||||
}
|
||||
@@ -1355,7 +1135,9 @@ button.danger:hover:not([disabled]) {
|
||||
|
||||
.sidebar-section__header h3 {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.sidebar-item {
|
||||
@@ -1410,7 +1192,7 @@ button.danger:hover:not([disabled]) {
|
||||
border-radius: 999px;
|
||||
padding: 0.25rem 0.6rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
background: var(--surface-soft);
|
||||
@@ -1423,7 +1205,7 @@ button.danger:hover:not([disabled]) {
|
||||
.sidebar-tag-pill:hover {
|
||||
transform: none;
|
||||
box-shadow: 0 2px 6px rgba(15, 23, 42, 0.16);
|
||||
border-color: var(--accent-soft);
|
||||
border-color: var(--accent-soft, rgba(59, 130, 246, 0.35));
|
||||
}
|
||||
|
||||
.sidebar-tag-pill:focus-visible {
|
||||
@@ -1432,8 +1214,8 @@ button.danger:hover:not([disabled]) {
|
||||
}
|
||||
|
||||
.sidebar-tag-pill.active {
|
||||
border-color: var(--sidebar-active-pill-border);
|
||||
box-shadow: 0 0 0 1.5px var(--sidebar-active-pill-border);
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 1.5px var(--accent);
|
||||
}
|
||||
|
||||
.sidebar-tag-cloud--has-active .sidebar-tag-pill:not(.active) {
|
||||
@@ -1473,8 +1255,8 @@ button.danger:hover:not([disabled]) {
|
||||
}
|
||||
|
||||
.sidebar-correspondent-item.active {
|
||||
background: var(--sidebar-active-bg, rgba(59, 130, 246, 0.18));
|
||||
box-shadow: 0 0 0 1px var(--sidebar-active-bg, rgba(59, 130, 246, 0.24));
|
||||
background: var(--accent-soft, rgba(59, 130, 246, 0.18));
|
||||
box-shadow: 0 0 0 1px var(--accent-soft, rgba(59, 130, 246, 0.24));
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
@@ -1718,7 +1500,7 @@ button.danger:hover:not([disabled]) {
|
||||
}
|
||||
|
||||
.documents-panel tbody tr.document.selected {
|
||||
background: var(--selection-soft);
|
||||
background: var(--accent-soft);
|
||||
box-shadow: inset 2px 0 0 var(--accent-outline-strong);
|
||||
}
|
||||
|
||||
@@ -1849,63 +1631,6 @@ button.danger:hover:not([disabled]) {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.doc-correspondents {
|
||||
display: inline;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.doc-correspondent-link {
|
||||
background: none;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
color: var(--accent, #2563eb);
|
||||
text-decoration: none;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
box-shadow: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.doc-correspondent-link:not(.is-static):hover,
|
||||
.doc-correspondent-link:not(.is-static):focus-visible {
|
||||
color: var(--accent-strong, var(--accent));
|
||||
text-decoration: underline;
|
||||
text-decoration-thickness: 1.5px;
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.doc-correspondent-link:not(.is-static):focus-visible {
|
||||
outline: 2px solid currentColor;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.documents-panel tbody tr.document.selected .doc-correspondents,
|
||||
.documents-panel tbody tr.document.selected .doc-correspondent-link,
|
||||
.document-card.selected .doc-correspondent-link {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.document-card.selected .doc-correspondents {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.doc-correspondent-link.is-active {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.doc-correspondent-link.is-static {
|
||||
color: inherit;
|
||||
cursor: default;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.doc-correspondent-link__separator {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.documents-panel tbody tr.document.dragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
@@ -1962,7 +1687,6 @@ button.danger:hover:not([disabled]) {
|
||||
gap: 0.25rem;
|
||||
border-radius: 1rem;
|
||||
padding: 0.2rem 0.4rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tag-chip--more {
|
||||
@@ -1971,39 +1695,6 @@ button.danger:hover:not([disabled]) {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.tag-chip--removable {
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.tag-chip__label {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tag-chip__remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.9em;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.tag-chip__remove:hover,
|
||||
.tag-chip__remove:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tag-chip__remove:focus-visible {
|
||||
outline: 2px solid currentColor;
|
||||
outline-offset: 2px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 0;
|
||||
@@ -2028,7 +1719,6 @@ button.danger:hover:not([disabled]) {
|
||||
height: 100%;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 0 24px var(--shadow-soft);
|
||||
border-left: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 20;
|
||||
@@ -2150,6 +1840,60 @@ 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;
|
||||
@@ -2403,6 +2147,32 @@ button.danger:hover:not([disabled]) {
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.tag-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.18rem;
|
||||
background: var(--surface-subtle);
|
||||
color: var(--fg);
|
||||
padding: 0.2rem 0.45rem;
|
||||
border-radius: 2px;
|
||||
font-size: 0.78rem;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.tag-pill button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.tag-pill button:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
getTagColorStyle,
|
||||
HEX_COLOR_PATTERN,
|
||||
generateRandomTagColor,
|
||||
} from '../utils/colors';
|
||||
import { getTagColorStyle, HEX_COLOR_PATTERN } from '../utils/colors';
|
||||
|
||||
function TagsPanel({
|
||||
tags,
|
||||
@@ -174,14 +170,6 @@ function TagsPanel({
|
||||
disabled={creating}
|
||||
aria-label="Tag color (optional)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={() => setCreateColor(generateRandomTagColor())}
|
||||
disabled={creating}
|
||||
>
|
||||
Random color
|
||||
</button>
|
||||
{createColor && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -256,22 +244,14 @@ function TagsPanel({
|
||||
className="tags-table__color-picker"
|
||||
value={colorPickerValue}
|
||||
onChange={(event) => setDraftColor(event.target.value)}
|
||||
disabled={saving || deletingId === tag.id}
|
||||
aria-label="Pick tag color"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={() => setDraftColor(generateRandomTagColor())}
|
||||
disabled={saving || deletingId === tag.id}
|
||||
>
|
||||
Random color
|
||||
</button>
|
||||
{draftColor && (
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={() => setDraftColor('')}
|
||||
disabled={saving || deletingId === tag.id}
|
||||
aria-label="Pick tag color"
|
||||
/>
|
||||
{draftColor && (
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={() => setDraftColor('')}
|
||||
disabled={saving || deletingId === tag.id}
|
||||
>
|
||||
Clear
|
||||
|
||||
@@ -18,9 +18,6 @@ import {
|
||||
IconFolderPlus,
|
||||
IconRefresh,
|
||||
IconMinusVertical,
|
||||
IconLogout,
|
||||
IconChevronDown,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import FolderSvg from '../assets/folder.svg';
|
||||
|
||||
@@ -186,15 +183,6 @@ export const MinusVerticalIcon = ({ className, size = '1em', stroke = 1.6, ...re
|
||||
/>
|
||||
);
|
||||
|
||||
export const CloseIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||
<IconX
|
||||
className={composeClassName('icon', className)}
|
||||
size={size}
|
||||
stroke={stroke}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
|
||||
export const AnalyzeIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||
<IconAnalyze
|
||||
className={composeClassName('icon', className)}
|
||||
@@ -213,24 +201,6 @@ export const WindowMaximizeIcon = ({ className, size = '1em', stroke = 1.6, ...r
|
||||
/>
|
||||
);
|
||||
|
||||
export const LogoutIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||
<IconLogout
|
||||
className={composeClassName('icon', className)}
|
||||
size={size}
|
||||
stroke={stroke}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
|
||||
export const ChevronDownIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||
<IconChevronDown
|
||||
className={composeClassName('icon', className)}
|
||||
size={size}
|
||||
stroke={stroke}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
|
||||
export default {
|
||||
ChevronIcon,
|
||||
TrashIcon,
|
||||
@@ -249,8 +219,6 @@ export default {
|
||||
RefreshIcon,
|
||||
ArrowUpIcon,
|
||||
MinusVerticalIcon,
|
||||
LogoutIcon,
|
||||
ChevronDownIcon,
|
||||
};
|
||||
|
||||
export const TextScanIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||
|
||||
@@ -2,8 +2,6 @@ const HEX_COLOR_PATTERN = /^#?([0-9a-fA-F]{6})$/;
|
||||
|
||||
const clamp01 = (value) => Math.min(1, Math.max(0, value));
|
||||
|
||||
const clampRange = (value, min, max) => Math.min(max, Math.max(min, value));
|
||||
|
||||
const gammaEncode = (channel) =>
|
||||
channel <= 0.0031308 ? 12.92 * channel : 1.055 * Math.pow(channel, 1 / 2.4) - 0.055;
|
||||
|
||||
@@ -79,37 +77,6 @@ const hslToHex = (h, s, l) => {
|
||||
return `#${((sr << 16) | (sg << 8) | sb).toString(16).padStart(6, '0')}`;
|
||||
};
|
||||
|
||||
const rgbToHsl = ({ r, g, b }) => {
|
||||
const rn = r / 255;
|
||||
const gn = g / 255;
|
||||
const bn = b / 255;
|
||||
|
||||
const max = Math.max(rn, gn, bn);
|
||||
const min = Math.min(rn, gn, bn);
|
||||
const delta = max - min;
|
||||
|
||||
let hue = 0;
|
||||
if (delta !== 0) {
|
||||
if (max === rn) {
|
||||
hue = ((gn - bn) / delta) % 6;
|
||||
} else if (max === gn) {
|
||||
hue = (bn - rn) / delta + 2;
|
||||
} else {
|
||||
hue = (rn - gn) / delta + 4;
|
||||
}
|
||||
hue *= 60;
|
||||
if (hue < 0) hue += 360;
|
||||
}
|
||||
|
||||
const lightness = (max + min) / 2;
|
||||
let saturation = 0;
|
||||
if (delta !== 0) {
|
||||
saturation = delta / (1 - Math.abs(2 * lightness - 1));
|
||||
}
|
||||
|
||||
return { h: hue, s: clamp01(saturation), l: clamp01(lightness) };
|
||||
};
|
||||
|
||||
export const hexToRgb = (input) => {
|
||||
if (!input) return null;
|
||||
const match = HEX_COLOR_PATTERN.exec(input.trim());
|
||||
@@ -135,184 +102,20 @@ export const relativeLuminance = ({ r, g, b }) => {
|
||||
return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
|
||||
};
|
||||
|
||||
const contrastRatio = (lumA, lumB) => {
|
||||
const [lighter, darker] = lumA >= lumB ? [lumA, lumB] : [lumB, lumA];
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
};
|
||||
|
||||
const parseCandidateColor = (candidate) => {
|
||||
const rgb = hexToRgb(candidate);
|
||||
if (!rgb) return null;
|
||||
return {
|
||||
hex: rgb.hex,
|
||||
luminance: relativeLuminance(rgb),
|
||||
};
|
||||
};
|
||||
|
||||
const contrastForPair = (backgroundHex, textHex) => {
|
||||
const background = hexToRgb(backgroundHex);
|
||||
const text = hexToRgb(textHex);
|
||||
if (!background || !text) {
|
||||
return 0;
|
||||
}
|
||||
return contrastRatio(relativeLuminance(background), relativeLuminance(text));
|
||||
};
|
||||
|
||||
export const getReadableTextColor = (
|
||||
hex,
|
||||
{ light = '#1f1f1f', dark = '#ffffff', fallback = '#1f1f1f' } = {},
|
||||
) => {
|
||||
const background = hexToRgb(hex);
|
||||
if (!background) return fallback;
|
||||
|
||||
const backgroundLuminance = relativeLuminance(background);
|
||||
const backgroundHsl = rgbToHsl(background);
|
||||
|
||||
const hueShift = 180;
|
||||
const textHue = (backgroundHsl.h + hueShift) % 360;
|
||||
const targetSaturation = clampRange(backgroundHsl.s * 1.15, 0.4, 0.85);
|
||||
const minLightness = 0.05;
|
||||
const maxLightness = 0.95;
|
||||
const sampleCount = 24;
|
||||
|
||||
const buildCandidate = (lightness) => {
|
||||
const light = clampRange(lightness, minLightness, maxLightness);
|
||||
const hexValue = hslToHex(textHue, targetSaturation, light);
|
||||
return parseCandidateColor(hexValue);
|
||||
};
|
||||
|
||||
const candidates = new Map();
|
||||
|
||||
for (let index = 0; index < sampleCount; index += 1) {
|
||||
const t = index / (sampleCount - 1);
|
||||
const candidateLightness = minLightness + t * (maxLightness - minLightness);
|
||||
const candidate = buildCandidate(candidateLightness);
|
||||
if (candidate) {
|
||||
candidates.set(candidate.hex, candidate);
|
||||
}
|
||||
}
|
||||
|
||||
[light, dark].forEach((preset) => {
|
||||
const parsed = parseCandidateColor(preset);
|
||||
if (parsed) {
|
||||
candidates.set(parsed.hex, parsed);
|
||||
}
|
||||
});
|
||||
|
||||
if (candidates.size === 0) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
let best = null;
|
||||
let bestRatio = -Infinity;
|
||||
candidates.forEach((candidate) => {
|
||||
const ratio = contrastRatio(backgroundLuminance, candidate.luminance);
|
||||
if (ratio > bestRatio) {
|
||||
bestRatio = ratio;
|
||||
best = candidate;
|
||||
}
|
||||
});
|
||||
|
||||
const MIN_CONTRAST = 4.5;
|
||||
if (bestRatio < MIN_CONTRAST) {
|
||||
const extremeLight = buildCandidate(maxLightness);
|
||||
const extremeDark = buildCandidate(minLightness);
|
||||
const extremes = [extremeLight, extremeDark].filter(Boolean);
|
||||
extremes.forEach((candidate) => {
|
||||
const ratio = contrastRatio(backgroundLuminance, candidate.luminance);
|
||||
if (ratio > bestRatio) {
|
||||
bestRatio = ratio;
|
||||
best = candidate;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return best?.hex || fallback;
|
||||
export const getReadableTextColor = (hex, { light = '#1f1f1f', dark = '#ffffff' } = {}) => {
|
||||
const rgb = hexToRgb(hex);
|
||||
if (!rgb) return dark;
|
||||
const luminance = relativeLuminance(rgb);
|
||||
return luminance > 0.6 ? light : dark;
|
||||
};
|
||||
|
||||
export const getTagColorStyle = (hex) => {
|
||||
const rgb = hexToRgb(hex);
|
||||
if (!rgb) return null;
|
||||
const baseHex = rgb.hex;
|
||||
const baseHsl = rgbToHsl(rgb);
|
||||
const baseText = getReadableTextColor(baseHex);
|
||||
const baseRatio = contrastForPair(baseHex, baseText);
|
||||
const TARGET_RATIO = 8;
|
||||
const MIN_LIGHTNESS = 0.12;
|
||||
const MAX_LIGHTNESS = 0.88;
|
||||
const adjustments = [-0.18, -0.12, -0.08, -0.04, 0.04, 0.08, 0.12, 0.18];
|
||||
const seen = new Map();
|
||||
|
||||
const registerCandidate = (lightness) => {
|
||||
const clamped = clampRange(lightness, MIN_LIGHTNESS, MAX_LIGHTNESS);
|
||||
const hexValue = hslToHex(baseHsl.h, baseHsl.s, clamped);
|
||||
if (!seen.has(hexValue)) {
|
||||
seen.set(hexValue, clamped);
|
||||
}
|
||||
};
|
||||
|
||||
registerCandidate(baseHsl.l);
|
||||
adjustments.forEach((delta) => registerCandidate(baseHsl.l + delta));
|
||||
|
||||
let bestBackground = baseHex;
|
||||
let bestText = baseText;
|
||||
let bestRatio = baseRatio;
|
||||
|
||||
if (bestRatio >= TARGET_RATIO) {
|
||||
return {
|
||||
backgroundColor: bestBackground,
|
||||
borderColor: bestBackground,
|
||||
color: bestText,
|
||||
};
|
||||
}
|
||||
|
||||
let compliantBackground = null;
|
||||
let compliantText = null;
|
||||
let compliantDelta = Infinity;
|
||||
let compliantRatio = -Infinity;
|
||||
|
||||
seen.forEach((lightness, candidateHex) => {
|
||||
const textHex = getReadableTextColor(candidateHex);
|
||||
const ratio = contrastForPair(candidateHex, textHex);
|
||||
const delta = Math.abs(lightness - baseHsl.l);
|
||||
|
||||
if (ratio > bestRatio) {
|
||||
bestBackground = candidateHex;
|
||||
bestText = textHex;
|
||||
bestRatio = ratio;
|
||||
}
|
||||
|
||||
if (ratio >= TARGET_RATIO) {
|
||||
const deltaEpsilon = 0.0025;
|
||||
const ratioEpsilon = 0.01;
|
||||
const isCloser = delta + deltaEpsilon < compliantDelta;
|
||||
const isSimilarDistance = Math.abs(delta - compliantDelta) <= deltaEpsilon;
|
||||
const improvesRatio = ratio > compliantRatio + ratioEpsilon;
|
||||
if (
|
||||
compliantBackground === null
|
||||
|| isCloser
|
||||
|| (isSimilarDistance && improvesRatio)
|
||||
) {
|
||||
compliantBackground = candidateHex;
|
||||
compliantText = textHex;
|
||||
compliantDelta = delta;
|
||||
compliantRatio = ratio;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (compliantBackground) {
|
||||
return {
|
||||
backgroundColor: compliantBackground,
|
||||
borderColor: compliantBackground,
|
||||
color: compliantText,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
backgroundColor: bestBackground,
|
||||
borderColor: bestBackground,
|
||||
color: bestText,
|
||||
backgroundColor: rgb.hex,
|
||||
borderColor: rgb.hex,
|
||||
color: getReadableTextColor(rgb.hex),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user