Compare commits
51
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0052dff119 | ||
|
|
3bdca88144 | ||
|
|
4b18429945 | ||
|
|
d9b2297eca | ||
|
|
b4282c2b4b | ||
|
|
8554fbd6e5 | ||
|
|
550bb7f0eb | ||
|
|
cc37fdde9a | ||
|
|
e351638e52 | ||
|
|
90a642c1c5 | ||
|
|
e150b81190 | ||
|
|
94578475ea | ||
|
|
92c491b742 | ||
|
|
eb93a2131f | ||
|
|
1e177580c9 | ||
|
|
7df9a6415a | ||
|
|
1179f4fcd4 | ||
|
|
709d32050e | ||
|
|
b9d84fa72b | ||
|
|
7abc10acde | ||
|
|
9be0e3b5c4 | ||
|
|
ed0f0fb759 | ||
|
|
2d80515ad5 | ||
|
|
50125f4659 | ||
|
|
a6da34740f | ||
|
|
7ed06ccdcf | ||
|
|
db07e0debb | ||
|
|
a0be094cbd | ||
|
|
7366d2e16b | ||
|
|
82aa8948cf | ||
|
|
84a3a9a5b5 | ||
|
|
b260065245 | ||
|
|
264316eb29 | ||
|
|
c625c80958 | ||
|
|
6a04d29eed | ||
|
|
10e28cc5e2 | ||
|
|
970678986a | ||
|
|
40e45f3a01 | ||
|
|
518b79bec6 | ||
|
|
01fb4e308a | ||
|
|
b6300ffa30 | ||
|
|
b163a07e84 | ||
|
|
42a3a53314 | ||
|
|
4a06fa82eb | ||
|
|
80238bb7a1 | ||
|
|
dcbd46531e | ||
|
|
0864b39336 | ||
|
|
2a0c96bb4c | ||
|
|
c53213a357 | ||
|
|
dc633783e0 | ||
|
|
e33ab71fac |
Generated
+42
@@ -611,6 +611,7 @@ dependencies = [
|
|||||||
"diesel",
|
"diesel",
|
||||||
"diesel_migrations",
|
"diesel_migrations",
|
||||||
"dotenv",
|
"dotenv",
|
||||||
|
"envy",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"hex",
|
"hex",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
@@ -625,6 +626,7 @@ dependencies = [
|
|||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde-aux",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
@@ -1175,6 +1177,15 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "envy"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3f47e0157f2cb54f5ae1bd371b30a2ae4311e1c028f575cd4e81de7353215965"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
@@ -2160,6 +2171,15 @@ version = "0.1.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
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]]
|
[[package]]
|
||||||
name = "outref"
|
name = "outref"
|
||||||
version = "0.5.2"
|
version = "0.5.2"
|
||||||
@@ -2904,6 +2924,28 @@ dependencies = [
|
|||||||
"serde_derive",
|
"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]]
|
[[package]]
|
||||||
name = "serde_core"
|
name = "serde_core"
|
||||||
version = "1.0.228"
|
version = "1.0.228"
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ aws-credential-types = "1.2"
|
|||||||
# Serialization
|
# Serialization
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
|
envy = "0.4"
|
||||||
|
serde-aux = "4.4"
|
||||||
|
|
||||||
# Utilities
|
# Utilities
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
DROP INDEX IF EXISTS folders_tenant_parent_name_unique_idx;
|
||||||
|
CREATE UNIQUE INDEX folders_parent_name_unique_idx
|
||||||
|
ON folders (
|
||||||
|
COALESCE(parent_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||||
|
name
|
||||||
|
);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
DROP INDEX IF EXISTS folders_parent_name_unique_idx;
|
||||||
|
CREATE UNIQUE INDEX folders_tenant_parent_name_unique_idx
|
||||||
|
ON folders (
|
||||||
|
tenant_id,
|
||||||
|
COALESCE(parent_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||||
|
name
|
||||||
|
);
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- Revert correspondent uniqueness to global name
|
||||||
|
DROP INDEX IF EXISTS correspondents_tenant_name_unique;
|
||||||
|
ALTER TABLE correspondents ADD CONSTRAINT correspondents_name_unique UNIQUE (name);
|
||||||
|
|
||||||
|
-- Revert tag uniqueness to global label
|
||||||
|
DROP INDEX IF EXISTS tags_tenant_label_unique;
|
||||||
|
ALTER TABLE tags ADD CONSTRAINT tags_label_key UNIQUE (label);
|
||||||
|
|
||||||
|
-- Revert document filename uniqueness to global folder scope
|
||||||
|
DROP INDEX IF EXISTS documents_tenant_folder_filename_unique;
|
||||||
|
CREATE UNIQUE INDEX documents_unique_folder_filename
|
||||||
|
ON documents (
|
||||||
|
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||||
|
filename
|
||||||
|
)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- Ensure document filenames are unique per tenant + folder
|
||||||
|
DROP INDEX IF EXISTS documents_tenant_folder_filename_unique;
|
||||||
|
DROP INDEX IF EXISTS documents_unique_folder_filename;
|
||||||
|
CREATE UNIQUE INDEX documents_tenant_folder_filename_unique
|
||||||
|
ON documents (
|
||||||
|
tenant_id,
|
||||||
|
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||||
|
filename
|
||||||
|
)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
|
-- Ensure tag labels are unique per tenant
|
||||||
|
ALTER TABLE tags DROP CONSTRAINT IF EXISTS tags_label_key;
|
||||||
|
DROP INDEX IF EXISTS tags_tenant_label_unique;
|
||||||
|
CREATE UNIQUE INDEX tags_tenant_label_unique ON tags (tenant_id, label);
|
||||||
|
|
||||||
|
-- Ensure correspondent names are unique per tenant
|
||||||
|
ALTER TABLE correspondents DROP CONSTRAINT IF EXISTS correspondents_name_unique;
|
||||||
|
DROP INDEX IF EXISTS correspondents_tenant_name_unique;
|
||||||
|
CREATE UNIQUE INDEX correspondents_tenant_name_unique ON correspondents (tenant_id, name);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE document_versions
|
||||||
|
ADD COLUMN operations_summary JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE document_versions
|
||||||
|
DROP COLUMN IF EXISTS operations_summary;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
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);
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
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);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
DROP INDEX IF EXISTS webdav_tokens_user_tenant_idx;
|
||||||
|
DROP INDEX IF EXISTS webdav_tokens_token_prefix_key;
|
||||||
|
DROP TABLE IF EXISTS webdav_tokens;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
CREATE TABLE webdav_tokens (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
token_prefix TEXT NOT NULL,
|
||||||
|
token_hash TEXT NOT NULL,
|
||||||
|
label TEXT,
|
||||||
|
scopes JSONB NOT NULL DEFAULT '["webdav"]'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
last_used_at TIMESTAMPTZ,
|
||||||
|
expires_at TIMESTAMPTZ,
|
||||||
|
revoked_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX webdav_tokens_token_prefix_key ON webdav_tokens(token_prefix);
|
||||||
|
CREATE INDEX webdav_tokens_user_tenant_idx ON webdav_tokens(user_id, tenant_id);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE webdav_tokens
|
||||||
|
ADD COLUMN scopes JSONB NOT NULL DEFAULT '["webdav"]'::jsonb;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE webdav_tokens
|
||||||
|
DROP COLUMN IF EXISTS scopes;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
pub mod jwt;
|
pub mod jwt;
|
||||||
pub mod password;
|
pub mod password;
|
||||||
|
pub mod webdav_tokens;
|
||||||
|
|
||||||
use axum::{async_trait, extract::FromRequestParts, http::request::Parts};
|
use axum::{async_trait, extract::FromRequestParts, http::request::Parts};
|
||||||
use axum_extra::headers::{authorization::Bearer, Authorization};
|
use axum_extra::headers::{authorization::Bearer, Authorization};
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
use argon2::{
|
||||||
|
password_hash::{PasswordHasher, SaltString},
|
||||||
|
Argon2,
|
||||||
|
};
|
||||||
|
use chrono::{NaiveDateTime, Utc};
|
||||||
|
use diesel::prelude::*;
|
||||||
|
use rand::rngs::OsRng;
|
||||||
|
use rand::RngCore;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
error::AppError,
|
||||||
|
models::{NewWebdavToken, WebdavToken},
|
||||||
|
schema::webdav_tokens,
|
||||||
|
state::PgPooledConnection,
|
||||||
|
};
|
||||||
|
|
||||||
|
const TOKEN_PREFIX_LENGTH: usize = 12;
|
||||||
|
const TOKEN_SECRET_LENGTH: usize = 32;
|
||||||
|
|
||||||
|
pub struct IssuedWebdavToken {
|
||||||
|
pub token: String,
|
||||||
|
pub record: WebdavToken,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_webdav_token(
|
||||||
|
conn: &mut PgPooledConnection,
|
||||||
|
user_id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
label: Option<String>,
|
||||||
|
expires_at: Option<NaiveDateTime>,
|
||||||
|
) -> Result<IssuedWebdavToken, AppError> {
|
||||||
|
let raw_secret = generate_secret()?;
|
||||||
|
let token_prefix = raw_secret[..TOKEN_PREFIX_LENGTH].to_string();
|
||||||
|
let token_hash = hash_secret(&raw_secret)?;
|
||||||
|
let new_token = NewWebdavToken {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
user_id,
|
||||||
|
tenant_id,
|
||||||
|
token_prefix,
|
||||||
|
token_hash,
|
||||||
|
label,
|
||||||
|
expires_at,
|
||||||
|
};
|
||||||
|
|
||||||
|
let record = diesel::insert_into(webdav_tokens::table)
|
||||||
|
.values(&new_token)
|
||||||
|
.get_result::<WebdavToken>(conn)?;
|
||||||
|
|
||||||
|
Ok(IssuedWebdavToken {
|
||||||
|
token: raw_secret,
|
||||||
|
record,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_webdav_tokens(
|
||||||
|
conn: &mut PgPooledConnection,
|
||||||
|
user_id: Uuid,
|
||||||
|
tenant_id: Option<Uuid>,
|
||||||
|
) -> Result<Vec<WebdavToken>, AppError> {
|
||||||
|
let mut query = webdav_tokens::table
|
||||||
|
.filter(webdav_tokens::user_id.eq(user_id))
|
||||||
|
.into_boxed();
|
||||||
|
|
||||||
|
if let Some(tenant_id) = tenant_id {
|
||||||
|
query = query.filter(webdav_tokens::tenant_id.eq(tenant_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
let tokens = query
|
||||||
|
.order(webdav_tokens::created_at.asc())
|
||||||
|
.load::<WebdavToken>(conn)?;
|
||||||
|
|
||||||
|
Ok(tokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn find_active_token_by_secret(
|
||||||
|
conn: &mut PgPooledConnection,
|
||||||
|
user_id: Uuid,
|
||||||
|
tenant_id: Option<Uuid>,
|
||||||
|
secret: &str,
|
||||||
|
) -> Result<Option<WebdavToken>, AppError> {
|
||||||
|
if secret.len() < TOKEN_PREFIX_LENGTH {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let prefix = &secret[..TOKEN_PREFIX_LENGTH];
|
||||||
|
let mut query = webdav_tokens::table
|
||||||
|
.filter(webdav_tokens::user_id.eq(user_id))
|
||||||
|
.filter(webdav_tokens::token_prefix.eq(prefix))
|
||||||
|
.filter(webdav_tokens::revoked_at.is_null())
|
||||||
|
.into_boxed();
|
||||||
|
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
query = query.filter(
|
||||||
|
webdav_tokens::expires_at
|
||||||
|
.is_null()
|
||||||
|
.or(webdav_tokens::expires_at.gt(now)),
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Some(tenant_id) = tenant_id {
|
||||||
|
query = query.filter(webdav_tokens::tenant_id.eq(tenant_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
let candidates = query.load::<WebdavToken>(conn)?;
|
||||||
|
|
||||||
|
for token in candidates {
|
||||||
|
if verify_token_secret(secret, &token.token_hash)? {
|
||||||
|
return Ok(Some(token));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn revoke_webdav_token(
|
||||||
|
conn: &mut PgPooledConnection,
|
||||||
|
token_id: Uuid,
|
||||||
|
user_id: Uuid,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let affected = diesel::update(
|
||||||
|
webdav_tokens::table
|
||||||
|
.filter(webdav_tokens::id.eq(token_id))
|
||||||
|
.filter(webdav_tokens::user_id.eq(user_id)),
|
||||||
|
)
|
||||||
|
.set(webdav_tokens::revoked_at.eq(Utc::now().naive_utc()))
|
||||||
|
.execute(conn)?;
|
||||||
|
|
||||||
|
if affected == 0 {
|
||||||
|
return Err(AppError::not_found());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn touch_webdav_token(conn: &mut PgPooledConnection, token_id: Uuid) -> Result<(), AppError> {
|
||||||
|
diesel::update(webdav_tokens::table.filter(webdav_tokens::id.eq(token_id)))
|
||||||
|
.set(webdav_tokens::last_used_at.eq(Utc::now().naive_utc()))
|
||||||
|
.execute(conn)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verify_token_secret(secret: &str, token_hash: &str) -> Result<bool, AppError> {
|
||||||
|
crate::auth::password::verify_password(secret, token_hash)
|
||||||
|
.map_err(|err| AppError::internal(format!("failed to verify token: {err}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_secret() -> Result<String, AppError> {
|
||||||
|
let mut buffer = [0u8; TOKEN_SECRET_LENGTH];
|
||||||
|
OsRng
|
||||||
|
.try_fill_bytes(&mut buffer)
|
||||||
|
.map_err(|err| AppError::internal(format!("failed to generate token: {err}")))?;
|
||||||
|
Ok(hex::encode(buffer))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hash_secret(secret: &str) -> Result<String, AppError> {
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
let hash = Argon2::default()
|
||||||
|
.hash_password(secret.as_bytes(), &salt)
|
||||||
|
.map_err(|err| AppError::internal(format!("failed to hash token: {err}")))?;
|
||||||
|
Ok(hash.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn _ensure_constants() {
|
||||||
|
assert!(TOKEN_PREFIX_LENGTH < TOKEN_SECRET_LENGTH * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generated_secret_has_expected_length() {
|
||||||
|
let secret = generate_secret().unwrap();
|
||||||
|
assert_eq!(secret.len(), TOKEN_SECRET_LENGTH * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hash_and_verify_secret_round_trip() {
|
||||||
|
let secret = generate_secret().unwrap();
|
||||||
|
let hash = hash_secret(&secret).unwrap();
|
||||||
|
assert!(verify_token_secret(&secret, &hash).unwrap());
|
||||||
|
assert!(!verify_token_secret("wrong", &hash).unwrap());
|
||||||
|
}
|
||||||
|
}
|
||||||
+96
-77
@@ -1,35 +1,60 @@
|
|||||||
use std::env;
|
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_aux::field_attributes::deserialize_bool_from_anything;
|
||||||
|
|
||||||
use crate::db::DEFAULT_MAX_POOL_SIZE;
|
use crate::db::DEFAULT_MAX_POOL_SIZE;
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
pub struct AppConfig {
|
pub struct AppConfig {
|
||||||
pub database_url: String,
|
pub database_url: String,
|
||||||
|
#[serde(default = "default_database_max_pool_size")]
|
||||||
pub database_max_pool_size: u32,
|
pub database_max_pool_size: u32,
|
||||||
|
#[serde(default = "default_server_host")]
|
||||||
pub server_host: String,
|
pub server_host: String,
|
||||||
|
#[serde(default = "default_server_port")]
|
||||||
pub server_port: u16,
|
pub server_port: u16,
|
||||||
|
#[serde(default = "default_webdav_host")]
|
||||||
pub webdav_host: String,
|
pub webdav_host: String,
|
||||||
|
#[serde(default = "default_webdav_port")]
|
||||||
pub webdav_port: u16,
|
pub webdav_port: u16,
|
||||||
pub jwt_secret: String,
|
pub jwt_secret: String,
|
||||||
|
#[serde(default = "default_jwt_issuer")]
|
||||||
pub jwt_issuer: String,
|
pub jwt_issuer: String,
|
||||||
|
#[serde(default = "default_jwt_audience")]
|
||||||
pub jwt_audience: String,
|
pub jwt_audience: String,
|
||||||
|
#[serde(default = "default_jwt_expiry_minutes")]
|
||||||
pub jwt_expiry_minutes: i64,
|
pub jwt_expiry_minutes: i64,
|
||||||
|
#[serde(default = "default_download_token_audience")]
|
||||||
pub download_token_audience: String,
|
pub download_token_audience: String,
|
||||||
|
#[serde(default = "default_download_token_expiry_minutes")]
|
||||||
pub download_token_expiry_minutes: i64,
|
pub download_token_expiry_minutes: i64,
|
||||||
|
#[serde(default = "default_refresh_token_expiry_days")]
|
||||||
pub refresh_token_expiry_days: i64,
|
pub refresh_token_expiry_days: i64,
|
||||||
|
#[serde(
|
||||||
|
default = "default_refresh_cookie_secure",
|
||||||
|
deserialize_with = "deserialize_bool_from_anything"
|
||||||
|
)]
|
||||||
pub refresh_cookie_secure: bool,
|
pub refresh_cookie_secure: bool,
|
||||||
|
#[serde(default)]
|
||||||
pub refresh_cookie_domain: Option<String>,
|
pub refresh_cookie_domain: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
pub cors_allowed_origin: Option<String>,
|
pub cors_allowed_origin: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
pub aws_endpoint_url: Option<String>,
|
pub aws_endpoint_url: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
pub aws_access_key_id: Option<String>,
|
pub aws_access_key_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
pub aws_secret_access_key: Option<String>,
|
pub aws_secret_access_key: Option<String>,
|
||||||
|
#[serde(default = "default_aws_region")]
|
||||||
pub aws_region: String,
|
pub aws_region: String,
|
||||||
pub s3_bucket: String,
|
pub s3_bucket: String,
|
||||||
|
#[serde(default)]
|
||||||
pub quickwit_endpoint: Option<String>,
|
pub quickwit_endpoint: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
pub quickwit_index: Option<String>,
|
pub quickwit_index: Option<String>,
|
||||||
|
#[serde(default = "default_tenant_slug")]
|
||||||
pub default_tenant_slug: String,
|
pub default_tenant_slug: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,80 +74,9 @@ impl AppConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn from_env() -> Result<Self> {
|
pub fn from_env() -> Result<Self> {
|
||||||
let database_url = env::var("DATABASE_URL").context("DATABASE_URL must be set")?;
|
let config: AppConfig = envy::from_env()
|
||||||
let database_max_pool_size = env::var("DATABASE_MAX_POOL_SIZE")
|
.context("failed to parse application configuration from environment")?;
|
||||||
.ok()
|
Ok(config.normalize())
|
||||||
.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 {
|
pub fn redacted_database_url(&self) -> String {
|
||||||
@@ -130,6 +84,71 @@ 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 {
|
fn redact_database_url(raw: &str) -> String {
|
||||||
match Url::parse(raw) {
|
match Url::parse(raw) {
|
||||||
Ok(mut parsed) => {
|
Ok(mut parsed) => {
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
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 fn delete_asset(state: &AppState, tenant_id: Uuid, asset_id: Uuid) -> AppResult<()> {
|
||||||
|
let mut conn = state.db_for_tenant(tenant_id)?;
|
||||||
|
|
||||||
|
diesel::delete(
|
||||||
|
document_assets::table
|
||||||
|
.filter(document_assets::id.eq(asset_id))
|
||||||
|
.filter(document_assets::tenant_id.eq(tenant_id)),
|
||||||
|
)
|
||||||
|
.execute(&mut conn)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
pub mod asset;
|
||||||
|
pub mod correspondents;
|
||||||
|
pub mod folders;
|
||||||
|
pub mod metadata;
|
||||||
|
pub mod search;
|
||||||
|
pub mod tags;
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
use serde_json::Value;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use reqwest::Client;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use tracing::{debug, error};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
pub const QUICKWIT_MAX_HITS: usize = 200;
|
||||||
|
|
||||||
pub fn build_quickwit_query(input: &str) -> Option<String> {
|
pub fn build_quickwit_query(input: &str) -> Option<String> {
|
||||||
let tokens: Vec<String> = input
|
let tokens: Vec<String> = input
|
||||||
.split_whitespace()
|
.split_whitespace()
|
||||||
@@ -38,6 +46,67 @@ pub fn escape_quickwit_token(token: &str) -> String {
|
|||||||
escaped
|
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> {
|
pub fn extract_document_id(hit: &Value) -> Option<Uuid> {
|
||||||
for key in ["_source", "source", "fields", "stored_fields"] {
|
for key in ["_source", "source", "fields", "stored_fields"] {
|
||||||
if let Some(value) = hit.get(key) {
|
if let Some(value) = hit.get(key) {
|
||||||
@@ -89,3 +158,9 @@ pub fn parse_uuid_value(value: &Value) -> Option<Uuid> {
|
|||||||
|
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct QuickwitSearchResponse {
|
||||||
|
#[serde(default)]
|
||||||
|
hits: Vec<Value>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
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,6 +1,7 @@
|
|||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod db;
|
pub mod db;
|
||||||
|
pub mod documents;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod jobs;
|
pub mod jobs;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
|
|||||||
+30
-5
@@ -58,6 +58,35 @@ pub struct NewUser {
|
|||||||
pub password_hash: String,
|
pub password_hash: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
||||||
|
#[diesel(table_name = webdav_tokens)]
|
||||||
|
#[diesel(belongs_to(User))]
|
||||||
|
#[diesel(belongs_to(Tenant))]
|
||||||
|
pub struct WebdavToken {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
|
pub token_prefix: String,
|
||||||
|
pub token_hash: String,
|
||||||
|
pub label: Option<String>,
|
||||||
|
pub created_at: NaiveDateTime,
|
||||||
|
pub last_used_at: Option<NaiveDateTime>,
|
||||||
|
pub expires_at: Option<NaiveDateTime>,
|
||||||
|
pub revoked_at: Option<NaiveDateTime>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Insertable)]
|
||||||
|
#[diesel(table_name = webdav_tokens)]
|
||||||
|
pub struct NewWebdavToken {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
|
pub token_prefix: String,
|
||||||
|
pub token_hash: String,
|
||||||
|
pub label: Option<String>,
|
||||||
|
pub expires_at: Option<NaiveDateTime>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable)]
|
#[derive(Debug, Clone, Queryable, Identifiable)]
|
||||||
#[diesel(table_name = folders)]
|
#[diesel(table_name = folders)]
|
||||||
pub struct Folder {
|
pub struct Folder {
|
||||||
@@ -123,7 +152,6 @@ pub struct DocumentVersion {
|
|||||||
pub size_bytes: i64,
|
pub size_bytes: i64,
|
||||||
pub checksum: String,
|
pub checksum: String,
|
||||||
pub created_at: NaiveDateTime,
|
pub created_at: NaiveDateTime,
|
||||||
pub operations_summary: serde_json::Value,
|
|
||||||
pub metadata: serde_json::Value,
|
pub metadata: serde_json::Value,
|
||||||
pub tenant_id: Uuid,
|
pub tenant_id: Uuid,
|
||||||
}
|
}
|
||||||
@@ -137,7 +165,6 @@ pub struct NewDocumentVersion {
|
|||||||
pub s3_key: String,
|
pub s3_key: String,
|
||||||
pub size_bytes: i64,
|
pub size_bytes: i64,
|
||||||
pub checksum: String,
|
pub checksum: String,
|
||||||
pub operations_summary: serde_json::Value,
|
|
||||||
pub metadata: serde_json::Value,
|
pub metadata: serde_json::Value,
|
||||||
pub tenant_id: Uuid,
|
pub tenant_id: Uuid,
|
||||||
}
|
}
|
||||||
@@ -283,11 +310,10 @@ pub struct NewCorrespondent {
|
|||||||
#[diesel(table_name = document_correspondents)]
|
#[diesel(table_name = document_correspondents)]
|
||||||
#[diesel(belongs_to(Document))]
|
#[diesel(belongs_to(Document))]
|
||||||
#[diesel(belongs_to(Correspondent))]
|
#[diesel(belongs_to(Correspondent))]
|
||||||
#[diesel(primary_key(document_id, correspondent_id, role))]
|
#[diesel(primary_key(document_id, correspondent_id))]
|
||||||
pub struct DocumentCorrespondent {
|
pub struct DocumentCorrespondent {
|
||||||
pub document_id: Uuid,
|
pub document_id: Uuid,
|
||||||
pub correspondent_id: Uuid,
|
pub correspondent_id: Uuid,
|
||||||
pub role: String,
|
|
||||||
pub assigned_at: NaiveDateTime,
|
pub assigned_at: NaiveDateTime,
|
||||||
pub assigned_by: Option<Uuid>,
|
pub assigned_by: Option<Uuid>,
|
||||||
pub tenant_id: Uuid,
|
pub tenant_id: Uuid,
|
||||||
@@ -298,7 +324,6 @@ pub struct DocumentCorrespondent {
|
|||||||
pub struct NewDocumentCorrespondent {
|
pub struct NewDocumentCorrespondent {
|
||||||
pub document_id: Uuid,
|
pub document_id: Uuid,
|
||||||
pub correspondent_id: Uuid,
|
pub correspondent_id: Uuid,
|
||||||
pub role: String,
|
|
||||||
pub assigned_by: Option<Uuid>,
|
pub assigned_by: Option<Uuid>,
|
||||||
pub tenant_id: Uuid,
|
pub tenant_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|||||||
+164
-40
@@ -11,6 +11,7 @@ use uuid::Uuid;
|
|||||||
doc::refresh,
|
doc::refresh,
|
||||||
doc::logout,
|
doc::logout,
|
||||||
doc::me,
|
doc::me,
|
||||||
|
doc::list_tenants,
|
||||||
doc::select_tenant,
|
doc::select_tenant,
|
||||||
doc::list_documents,
|
doc::list_documents,
|
||||||
doc::check_document,
|
doc::check_document,
|
||||||
@@ -18,7 +19,6 @@ use uuid::Uuid;
|
|||||||
doc::get_document,
|
doc::get_document,
|
||||||
doc::update_document,
|
doc::update_document,
|
||||||
doc::delete_document,
|
doc::delete_document,
|
||||||
doc::download_document,
|
|
||||||
doc::download_with_token,
|
doc::download_with_token,
|
||||||
doc::move_document,
|
doc::move_document,
|
||||||
doc::assign_tags,
|
doc::assign_tags,
|
||||||
@@ -46,24 +46,28 @@ use uuid::Uuid;
|
|||||||
doc::create_correspondent,
|
doc::create_correspondent,
|
||||||
doc::update_correspondent,
|
doc::update_correspondent,
|
||||||
doc::delete_correspondent,
|
doc::delete_correspondent,
|
||||||
|
doc::list_webdav_tokens,
|
||||||
|
doc::create_webdav_token,
|
||||||
|
doc::delete_webdav_token,
|
||||||
),
|
),
|
||||||
components(
|
components(
|
||||||
schemas(
|
schemas(
|
||||||
schemas::LoginRequest,
|
schemas::LoginRequest,
|
||||||
schemas::AccessTokenResponse,
|
schemas::AccessTokenResponse,
|
||||||
schemas::TenantSummary,
|
schemas::TenantSnippet,
|
||||||
schemas::TenantSelectionResponse,
|
schemas::TenantSelectionResponse,
|
||||||
schemas::TenantSelectionRequest,
|
schemas::TenantSelectionRequest,
|
||||||
|
schemas::TenantListResponse,
|
||||||
schemas::LoginResponseVariants,
|
schemas::LoginResponseVariants,
|
||||||
schemas::DocumentResponse,
|
schemas::DocumentResponse,
|
||||||
schemas::DocumentDetailResponse,
|
schemas::DocumentDetailResponse,
|
||||||
schemas::DocumentVersion,
|
schemas::DocumentVersionResponse,
|
||||||
|
schemas::DocumentVersionDetailResponse,
|
||||||
schemas::DocumentAssetSummary,
|
schemas::DocumentAssetSummary,
|
||||||
schemas::DocumentAssetDetail,
|
schemas::DocumentAssetDetail,
|
||||||
schemas::DocumentAssetObject,
|
schemas::DocumentAssetObject,
|
||||||
schemas::DocumentCorrespondent,
|
schemas::DocumentCorrespondent,
|
||||||
schemas::DocumentTag,
|
schemas::DocumentTag,
|
||||||
schemas::DocumentDownloadResponse,
|
|
||||||
schemas::UpdateDocumentRequest,
|
schemas::UpdateDocumentRequest,
|
||||||
schemas::BulkMoveDocumentsRequest,
|
schemas::BulkMoveDocumentsRequest,
|
||||||
schemas::BulkMoveDocumentsResponse,
|
schemas::BulkMoveDocumentsResponse,
|
||||||
@@ -77,7 +81,6 @@ use uuid::Uuid;
|
|||||||
schemas::BulkCorrespondentsResponse,
|
schemas::BulkCorrespondentsResponse,
|
||||||
schemas::BulkCorrespondentAction,
|
schemas::BulkCorrespondentAction,
|
||||||
schemas::AssignCorrespondentsRequest,
|
schemas::AssignCorrespondentsRequest,
|
||||||
schemas::RemoveCorrespondentParams,
|
|
||||||
schemas::ReanalyzeRequest,
|
schemas::ReanalyzeRequest,
|
||||||
schemas::ReanalyzeResponse,
|
schemas::ReanalyzeResponse,
|
||||||
schemas::DocumentAssetRequestParams,
|
schemas::DocumentAssetRequestParams,
|
||||||
@@ -96,9 +99,13 @@ use uuid::Uuid;
|
|||||||
schemas::TagCatalogEntry,
|
schemas::TagCatalogEntry,
|
||||||
schemas::CreateTagRequest,
|
schemas::CreateTagRequest,
|
||||||
schemas::UpdateTagRequest,
|
schemas::UpdateTagRequest,
|
||||||
|
schemas::CorrespondentUsage,
|
||||||
schemas::CorrespondentCatalogEntry,
|
schemas::CorrespondentCatalogEntry,
|
||||||
schemas::CreateCorrespondentRequest,
|
schemas::CreateCorrespondentRequest,
|
||||||
schemas::UpdateCorrespondentRequest,
|
schemas::UpdateCorrespondentRequest,
|
||||||
|
schemas::WebdavTokenResponse,
|
||||||
|
schemas::WebdavTokenCreatedResponse,
|
||||||
|
schemas::CreateWebdavTokenRequest,
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
tags(
|
tags(
|
||||||
@@ -108,7 +115,8 @@ use uuid::Uuid;
|
|||||||
(name = "Assets", description = "Document assets"),
|
(name = "Assets", description = "Document assets"),
|
||||||
(name = "Folders", description = "Folder management"),
|
(name = "Folders", description = "Folder management"),
|
||||||
(name = "Tags", description = "Tag catalog"),
|
(name = "Tags", description = "Tag catalog"),
|
||||||
(name = "Correspondents", description = "Correspondent catalog")
|
(name = "Correspondents", description = "Correspondent catalog"),
|
||||||
|
(name = "Profile", description = "User profile and WebDAV tokens")
|
||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
pub struct ApiDoc;
|
pub struct ApiDoc;
|
||||||
@@ -170,6 +178,14 @@ mod doc {
|
|||||||
)]
|
)]
|
||||||
pub(super) fn me() {}
|
pub(super) fn me() {}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/auth/tenants",
|
||||||
|
responses((status = 200, description = "Available tenants", body = TenantListResponse)),
|
||||||
|
tag = "Auth"
|
||||||
|
)]
|
||||||
|
pub(super) fn list_tenants() {}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
path = "/api/auth/select-tenant",
|
path = "/api/auth/select-tenant",
|
||||||
@@ -240,12 +256,34 @@ mod doc {
|
|||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/api/documents/{id}/download",
|
path = "/api/documents/{id}/versions",
|
||||||
params(("id" = Uuid, Path, description = "Document ID")),
|
params(("id" = Uuid, Path, description = "Document ID")),
|
||||||
responses((status = 200, description = "Download metadata", body = DocumentDownloadResponse)),
|
responses((status = 200, description = "Document versions", body = [DocumentVersionResponse])),
|
||||||
tag = "Documents"
|
tag = "Documents"
|
||||||
)]
|
)]
|
||||||
pub(super) fn download_document() {}
|
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() {}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
@@ -330,8 +368,7 @@ mod doc {
|
|||||||
path = "/api/documents/{id}/correspondents/{correspondent_id}",
|
path = "/api/documents/{id}/correspondents/{correspondent_id}",
|
||||||
params(
|
params(
|
||||||
("id" = Uuid, Path, description = "Document ID"),
|
("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")),
|
responses((status = 204, description = "Correspondent removed")),
|
||||||
tag = "Documents"
|
tag = "Documents"
|
||||||
@@ -509,6 +546,32 @@ mod doc {
|
|||||||
tag = "Correspondents"
|
tag = "Correspondents"
|
||||||
)]
|
)]
|
||||||
pub(super) fn delete_correspondent() {}
|
pub(super) fn delete_correspondent() {}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/profile/webdav-tokens",
|
||||||
|
responses((status = 200, description = "List WebDAV tokens", body = [WebdavTokenResponse])),
|
||||||
|
tag = "Profile"
|
||||||
|
)]
|
||||||
|
pub(super) fn list_webdav_tokens() {}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/profile/webdav-tokens",
|
||||||
|
request_body = CreateWebdavTokenRequest,
|
||||||
|
responses((status = 201, description = "WebDAV token created", body = WebdavTokenCreatedResponse)),
|
||||||
|
tag = "Profile"
|
||||||
|
)]
|
||||||
|
pub(super) fn create_webdav_token() {}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
delete,
|
||||||
|
path = "/api/profile/webdav-tokens/{id}",
|
||||||
|
params(("id" = Uuid, Path, description = "WebDAV token ID")),
|
||||||
|
responses((status = 204, description = "WebDAV token revoked")),
|
||||||
|
tag = "Profile"
|
||||||
|
)]
|
||||||
|
pub(super) fn delete_webdav_token() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -539,18 +602,19 @@ pub mod schemas {
|
|||||||
pub access_token: String,
|
pub access_token: String,
|
||||||
pub token_type: String,
|
pub token_type: String,
|
||||||
pub expires_in: i64,
|
pub expires_in: i64,
|
||||||
|
pub tenant: TenantSnippet,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, ToSchema)]
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
pub struct TenantSummary {
|
pub struct TenantSnippet {
|
||||||
pub tenant_id: Uuid,
|
pub id: Uuid,
|
||||||
pub slug: String,
|
pub slug: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, ToSchema)]
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
pub struct TenantSelectionResponse {
|
pub struct TenantSelectionResponse {
|
||||||
pub selection_token: String,
|
pub access_token: String,
|
||||||
pub tenants: Vec<TenantSummary>,
|
pub tenants: Vec<TenantSnippet>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, ToSchema)]
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
@@ -558,6 +622,11 @@ pub mod schemas {
|
|||||||
pub tenant_id: Uuid,
|
pub tenant_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct TenantListResponse {
|
||||||
|
pub tenants: Vec<TenantSnippet>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, ToSchema)]
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
#[serde(untagged)]
|
#[serde(untagged)]
|
||||||
pub enum LoginResponseVariants {
|
pub enum LoginResponseVariants {
|
||||||
@@ -569,11 +638,26 @@ pub mod schemas {
|
|||||||
#[into_params(parameter_in = Query)]
|
#[into_params(parameter_in = Query)]
|
||||||
pub struct DocumentListQuery {
|
pub struct DocumentListQuery {
|
||||||
pub folder_id: Option<Uuid>,
|
pub folder_id: Option<Uuid>,
|
||||||
pub include_deleted: Option<bool>,
|
#[schema(nullable)]
|
||||||
pub include_descendants: Option<bool>,
|
pub include_descendants: Option<bool>,
|
||||||
pub query: Option<String>,
|
pub query: Option<String>,
|
||||||
pub tags: Option<String>,
|
pub tags: Option<String>,
|
||||||
pub correspondents: 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)]
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
@@ -618,17 +702,21 @@ pub mod schemas {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, ToSchema)]
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
pub struct DocumentVersion {
|
pub struct DocumentVersionResponse {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub version_number: i32,
|
pub version_number: i32,
|
||||||
pub checksum: String,
|
|
||||||
pub size_bytes: i64,
|
pub size_bytes: i64,
|
||||||
|
pub checksum: String,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub metadata: Value,
|
pub metadata: Value,
|
||||||
#[schema(nullable)]
|
}
|
||||||
pub operations_summary: Option<Value>,
|
|
||||||
#[schema(nullable)]
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
pub assets: Option<Vec<DocumentAssetSummary>>,
|
pub struct DocumentVersionDetailResponse {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub version: DocumentVersionResponse,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub assets: Vec<DocumentAssetSummary>,
|
||||||
pub download_path: String,
|
pub download_path: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -636,7 +724,6 @@ pub mod schemas {
|
|||||||
pub struct DocumentCorrespondent {
|
pub struct DocumentCorrespondent {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub role: String,
|
|
||||||
pub metadata: Value,
|
pub metadata: Value,
|
||||||
pub assigned_at: String,
|
pub assigned_at: String,
|
||||||
}
|
}
|
||||||
@@ -659,10 +746,10 @@ pub mod schemas {
|
|||||||
pub issued_at: Option<String>,
|
pub issued_at: Option<String>,
|
||||||
pub metadata: Value,
|
pub metadata: Value,
|
||||||
pub tags: Vec<DocumentTag>,
|
pub tags: Vec<DocumentTag>,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub correspondents: Vec<DocumentCorrespondent>,
|
||||||
#[schema(nullable)]
|
#[schema(nullable)]
|
||||||
pub correspondents: Option<Vec<DocumentCorrespondent>>,
|
pub current_version: Option<DocumentVersionDetailResponse>,
|
||||||
#[schema(nullable)]
|
|
||||||
pub current_version: Option<DocumentVersion>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, ToSchema)]
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
@@ -671,19 +758,27 @@ pub mod schemas {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, ToSchema)]
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
pub struct DocumentDownloadResponse {
|
pub struct DocumentMetadataUpdate {
|
||||||
pub url: String,
|
pub value: Value,
|
||||||
pub expires_in: u64,
|
#[serde(default)]
|
||||||
pub filename: String,
|
#[schema(default = false)]
|
||||||
|
pub replace: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct RestoreDocumentRequest {
|
||||||
#[schema(nullable)]
|
#[schema(nullable)]
|
||||||
pub content_type: Option<String>,
|
pub folder_id: Option<Uuid>,
|
||||||
pub size_bytes: i64,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, ToSchema)]
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
pub struct UpdateDocumentRequest {
|
pub struct UpdateDocumentRequest {
|
||||||
#[schema(nullable)]
|
#[schema(nullable)]
|
||||||
pub title: Option<String>,
|
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)]
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
@@ -732,7 +827,6 @@ pub mod schemas {
|
|||||||
#[derive(Serialize, Deserialize, ToSchema)]
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
pub struct CorrespondentAssignment {
|
pub struct CorrespondentAssignment {
|
||||||
pub correspondent_id: Uuid,
|
pub correspondent_id: Uuid,
|
||||||
pub role: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, ToSchema)]
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
@@ -767,12 +861,6 @@ pub mod schemas {
|
|||||||
pub removed: usize,
|
pub removed: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, IntoParams, ToSchema)]
|
|
||||||
#[into_params(parameter_in = Query)]
|
|
||||||
pub struct RemoveCorrespondentParams {
|
|
||||||
pub role: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, ToSchema)]
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
pub struct ReanalyzeRequest {
|
pub struct ReanalyzeRequest {
|
||||||
pub document_ids: Vec<Uuid>,
|
pub document_ids: Vec<Uuid>,
|
||||||
@@ -926,12 +1014,19 @@ pub mod schemas {
|
|||||||
pub color: Option<Option<String>>,
|
pub color: Option<Option<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct CorrespondentUsage {
|
||||||
|
pub total: i64,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, ToSchema)]
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
pub struct CorrespondentCatalogEntry {
|
pub struct CorrespondentCatalogEntry {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub metadata: Value,
|
pub metadata: Value,
|
||||||
pub role_counts: Value,
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
pub usage: CorrespondentUsage,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, ToSchema)]
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
@@ -948,4 +1043,33 @@ pub mod schemas {
|
|||||||
#[schema(nullable)]
|
#[schema(nullable)]
|
||||||
pub metadata: Option<Value>,
|
pub metadata: Option<Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct WebdavTokenResponse {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
|
#[schema(nullable)]
|
||||||
|
pub label: Option<String>,
|
||||||
|
pub created_at: String,
|
||||||
|
#[schema(nullable)]
|
||||||
|
pub last_used_at: Option<String>,
|
||||||
|
#[schema(nullable)]
|
||||||
|
pub expires_at: Option<String>,
|
||||||
|
#[schema(nullable)]
|
||||||
|
pub revoked_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct WebdavTokenCreatedResponse {
|
||||||
|
pub token: String,
|
||||||
|
pub token_info: WebdavTokenResponse,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct CreateWebdavTokenRequest {
|
||||||
|
#[schema(nullable)]
|
||||||
|
pub label: Option<String>,
|
||||||
|
#[schema(nullable, example = "2025-01-01T00:00:00Z")]
|
||||||
|
pub expires_at: Option<String>,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+73
-15
@@ -43,18 +43,24 @@ pub struct LoginResponse {
|
|||||||
pub access_token: String,
|
pub access_token: String,
|
||||||
pub token_type: String,
|
pub token_type: String,
|
||||||
pub expires_in: i64,
|
pub expires_in: i64,
|
||||||
|
pub tenant: TenantSnippet,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct TenantSummary {
|
pub struct TenantSnippet {
|
||||||
pub tenant_id: Uuid,
|
pub id: Uuid,
|
||||||
pub slug: String,
|
pub slug: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct TenantSelectionResponse {
|
pub struct TenantSelectionResponse {
|
||||||
pub selection_token: String,
|
pub access_token: String,
|
||||||
pub tenants: Vec<TenantSummary>,
|
pub tenants: Vec<TenantSnippet>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct TenantListResponse {
|
||||||
|
pub tenants: Vec<TenantSnippet>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -68,9 +74,15 @@ pub async fn login(
|
|||||||
) -> AppResult<Response> {
|
) -> AppResult<Response> {
|
||||||
let mut conn = state.db_unscoped()?;
|
let mut conn = state.db_unscoped()?;
|
||||||
|
|
||||||
let user: User = dsl::users
|
let user: Option<User> = dsl::users
|
||||||
.filter(dsl::username.eq(&payload.username))
|
.filter(dsl::username.eq(&payload.username))
|
||||||
.first(&mut conn)?;
|
.first(&mut conn)
|
||||||
|
.optional()?;
|
||||||
|
|
||||||
|
let user = match user {
|
||||||
|
Some(user) => user,
|
||||||
|
None => return Err(AppError::unauthorized()),
|
||||||
|
};
|
||||||
|
|
||||||
let valid = password::verify_password(&payload.password, &user.password_hash)
|
let valid = password::verify_password(&payload.password, &user.password_hash)
|
||||||
.map_err(|_| AppError::unauthorized())?;
|
.map_err(|_| AppError::unauthorized())?;
|
||||||
@@ -114,14 +126,14 @@ pub async fn login(
|
|||||||
|
|
||||||
let tenants = memberships
|
let tenants = memberships
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(_, tenant)| TenantSummary {
|
.map(|(_, tenant)| TenantSnippet {
|
||||||
tenant_id: tenant.id,
|
id: tenant.id,
|
||||||
slug: tenant.slug,
|
slug: tenant.slug,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let response = Json(TenantSelectionResponse {
|
let response = Json(TenantSelectionResponse {
|
||||||
selection_token,
|
access_token: selection_token,
|
||||||
tenants,
|
tenants,
|
||||||
})
|
})
|
||||||
.into_response();
|
.into_response();
|
||||||
@@ -174,15 +186,19 @@ pub async fn select_tenant(
|
|||||||
TypedHeader(Authorization(bearer)): TypedHeader<Authorization<Bearer>>,
|
TypedHeader(Authorization(bearer)): TypedHeader<Authorization<Bearer>>,
|
||||||
Json(payload): Json<TenantSelectionRequest>,
|
Json(payload): Json<TenantSelectionRequest>,
|
||||||
) -> AppResult<Response> {
|
) -> AppResult<Response> {
|
||||||
let claims = state
|
let user_id = match state.jwt.verify_tenant_selector_token(bearer.token()) {
|
||||||
.jwt
|
Ok(claims) => claims.sub,
|
||||||
.verify_tenant_selector_token(bearer.token())
|
Err(_) => state
|
||||||
.map_err(|_| AppError::unauthorized())?;
|
.jwt
|
||||||
|
.verify_token(bearer.token())
|
||||||
|
.map(|claims| claims.sub)
|
||||||
|
.map_err(|_| AppError::unauthorized())?,
|
||||||
|
};
|
||||||
|
|
||||||
let mut conn = state.db_unscoped()?;
|
let mut conn = state.db_unscoped()?;
|
||||||
|
|
||||||
let membership_exists = memberships_dsl::user_memberships
|
let membership_exists = memberships_dsl::user_memberships
|
||||||
.filter(memberships_dsl::user_id.eq(claims.sub))
|
.filter(memberships_dsl::user_id.eq(user_id))
|
||||||
.filter(memberships_dsl::tenant_id.eq(payload.tenant_id))
|
.filter(memberships_dsl::tenant_id.eq(payload.tenant_id))
|
||||||
.inner_join(tenant_dsl::tenants)
|
.inner_join(tenant_dsl::tenants)
|
||||||
.select(memberships_dsl::id)
|
.select(memberships_dsl::id)
|
||||||
@@ -194,7 +210,7 @@ pub async fn select_tenant(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let user: User = dsl::users
|
let user: User = dsl::users
|
||||||
.find(claims.sub)
|
.find(user_id)
|
||||||
.first(&mut conn)
|
.first(&mut conn)
|
||||||
.map_err(AppError::from)?;
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
@@ -250,6 +266,38 @@ pub async fn me(user: AuthenticatedUser) -> Json<AuthenticatedUser> {
|
|||||||
Json(user)
|
Json(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn list_tenants(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: Option<TypedHeader<Authorization<Bearer>>>,
|
||||||
|
) -> AppResult<Json<TenantListResponse>> {
|
||||||
|
let bearer = auth.ok_or_else(AppError::unauthorized)?;
|
||||||
|
let token = bearer.token();
|
||||||
|
|
||||||
|
let user_id = match state.jwt.verify_token(token) {
|
||||||
|
Ok(claims) => claims.sub,
|
||||||
|
Err(_) => {
|
||||||
|
let claims = state
|
||||||
|
.jwt
|
||||||
|
.verify_tenant_selector_token(token)
|
||||||
|
.map_err(|_| AppError::unauthorized())?;
|
||||||
|
claims.sub
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut conn = state.db_unscoped()?;
|
||||||
|
|
||||||
|
let tenants = memberships_dsl::user_memberships
|
||||||
|
.inner_join(tenant_dsl::tenants)
|
||||||
|
.filter(memberships_dsl::user_id.eq(user_id))
|
||||||
|
.select((tenant_dsl::id, tenant_dsl::slug))
|
||||||
|
.load::<(Uuid, String)>(&mut conn)?
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, slug)| TenantSnippet { id, slug })
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(TenantListResponse { tenants }))
|
||||||
|
}
|
||||||
|
|
||||||
fn issue_session(
|
fn issue_session(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
conn: &mut PgConnection,
|
conn: &mut PgConnection,
|
||||||
@@ -262,6 +310,12 @@ fn issue_session(
|
|||||||
.generate_token(user.id, tenant_id, &user.username)
|
.generate_token(user.id, tenant_id, &user.username)
|
||||||
.map_err(AppError::from)?;
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
|
let tenant_slug: String = tenant_dsl::tenants
|
||||||
|
.find(tenant_id)
|
||||||
|
.select(tenant_dsl::slug)
|
||||||
|
.first(conn)
|
||||||
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
let refresh_value = generate_refresh_token();
|
let refresh_value = generate_refresh_token();
|
||||||
let refresh_hash = hash_refresh_token(&refresh_value);
|
let refresh_hash = hash_refresh_token(&refresh_value);
|
||||||
let refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days);
|
let refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days);
|
||||||
@@ -283,6 +337,10 @@ fn issue_session(
|
|||||||
access_token,
|
access_token,
|
||||||
token_type: "Bearer".to_string(),
|
token_type: "Bearer".to_string(),
|
||||||
expires_in: state.config.jwt_expiry_minutes * 60,
|
expires_in: state.config.jwt_expiry_minutes * 60,
|
||||||
|
tenant: TenantSnippet {
|
||||||
|
id: tenant_id,
|
||||||
|
slug: tenant_slug,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
.into_response();
|
.into_response();
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use std::collections::{BTreeMap, HashMap};
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use axum::{extract::Path, http::StatusCode, Json};
|
use axum::{extract::Path, http::StatusCode, Json};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
@@ -21,8 +21,6 @@ use crate::{
|
|||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct CorrespondentUsage {
|
pub struct CorrespondentUsage {
|
||||||
pub total: i64,
|
pub total: i64,
|
||||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
|
||||||
pub by_role: BTreeMap<String, i64>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -67,31 +65,21 @@ pub async fn list_correspondents(
|
|||||||
.order(correspondents::name.asc())
|
.order(correspondents::name.asc())
|
||||||
.load(&mut conn)?;
|
.load(&mut conn)?;
|
||||||
|
|
||||||
let usage_rows: Vec<(Uuid, String, i64)> = document_correspondents::table
|
let usage_rows: Vec<(Uuid, i64)> = document_correspondents::table
|
||||||
.filter(document_correspondents::tenant_id.eq(tenant_id))
|
.filter(document_correspondents::tenant_id.eq(tenant_id))
|
||||||
.group_by((
|
.group_by(document_correspondents::correspondent_id)
|
||||||
document_correspondents::correspondent_id,
|
.select((document_correspondents::correspondent_id, count_star()))
|
||||||
document_correspondents::role,
|
|
||||||
))
|
|
||||||
.select((
|
|
||||||
document_correspondents::correspondent_id,
|
|
||||||
document_correspondents::role,
|
|
||||||
count_star(),
|
|
||||||
))
|
|
||||||
.load(&mut conn)?;
|
.load(&mut conn)?;
|
||||||
|
|
||||||
let mut usage_map: HashMap<Uuid, BTreeMap<String, i64>> = HashMap::new();
|
let mut usage_map: HashMap<Uuid, i64> = HashMap::new();
|
||||||
for (correspondent_id, role, count) in usage_rows {
|
for (correspondent_id, count) in usage_rows {
|
||||||
usage_map
|
usage_map.insert(correspondent_id, count);
|
||||||
.entry(correspondent_id)
|
|
||||||
.or_default()
|
|
||||||
.insert(role, count);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut response = Vec::with_capacity(correspondents_list.len());
|
let mut response = Vec::with_capacity(correspondents_list.len());
|
||||||
for correspondent in correspondents_list {
|
for correspondent in correspondents_list {
|
||||||
let role_counts = usage_map.remove(&correspondent.id).unwrap_or_default();
|
let total = usage_map.remove(&correspondent.id).unwrap_or(0);
|
||||||
response.push(build_summary(correspondent, role_counts));
|
response.push(build_summary(correspondent, total));
|
||||||
}
|
}
|
||||||
|
|
||||||
response.into_json()
|
response.into_json()
|
||||||
@@ -136,7 +124,7 @@ pub async fn create_correspondent(
|
|||||||
.first(&mut conn)
|
.first(&mut conn)
|
||||||
.one()?;
|
.one()?;
|
||||||
|
|
||||||
build_summary(correspondent, BTreeMap::new()).into_json()
|
build_summary(correspondent, 0).into_json()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_correspondent(
|
pub async fn update_correspondent(
|
||||||
@@ -245,21 +233,14 @@ pub async fn delete_correspondent(
|
|||||||
no_content()
|
no_content()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_summary(
|
fn build_summary(correspondent: Correspondent, total: i64) -> CorrespondentSummary {
|
||||||
correspondent: Correspondent,
|
|
||||||
role_counts: BTreeMap<String, i64>,
|
|
||||||
) -> CorrespondentSummary {
|
|
||||||
let total = role_counts.values().copied().sum();
|
|
||||||
CorrespondentSummary {
|
CorrespondentSummary {
|
||||||
id: correspondent.id,
|
id: correspondent.id,
|
||||||
name: correspondent.name,
|
name: correspondent.name,
|
||||||
metadata: correspondent.metadata,
|
metadata: correspondent.metadata,
|
||||||
created_at: to_iso(correspondent.created_at),
|
created_at: to_iso(correspondent.created_at),
|
||||||
updated_at: to_iso(correspondent.updated_at),
|
updated_at: to_iso(correspondent.updated_at),
|
||||||
usage: CorrespondentUsage {
|
usage: CorrespondentUsage { total },
|
||||||
total,
|
|
||||||
by_role: role_counts,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,17 +255,12 @@ fn load_usage_for_correspondent(
|
|||||||
conn: &mut PgConnection,
|
conn: &mut PgConnection,
|
||||||
tenant_id: Uuid,
|
tenant_id: Uuid,
|
||||||
correspondent_id: Uuid,
|
correspondent_id: Uuid,
|
||||||
) -> AppResult<BTreeMap<String, i64>> {
|
) -> AppResult<i64> {
|
||||||
let rows: Vec<(String, i64)> = document_correspondents::table
|
let total: i64 = document_correspondents::table
|
||||||
.filter(document_correspondents::correspondent_id.eq(correspondent_id))
|
.filter(document_correspondents::correspondent_id.eq(correspondent_id))
|
||||||
.filter(document_correspondents::tenant_id.eq(tenant_id))
|
.filter(document_correspondents::tenant_id.eq(tenant_id))
|
||||||
.group_by(document_correspondents::role)
|
.select(count_star())
|
||||||
.select((document_correspondents::role, count_star()))
|
.get_result(conn)?;
|
||||||
.load(conn)?;
|
|
||||||
|
|
||||||
let mut map = BTreeMap::new();
|
Ok(total)
|
||||||
for (role, count) in rows {
|
|
||||||
map.insert(role, count);
|
|
||||||
}
|
|
||||||
Ok(map)
|
|
||||||
}
|
}
|
||||||
|
|||||||
+432
-678
File diff suppressed because it is too large
Load Diff
@@ -1,120 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
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))
|
|
||||||
}
|
|
||||||
@@ -15,9 +15,10 @@ use crate::{
|
|||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::documents::{
|
use super::documents::{to_document_response, DocumentResponse};
|
||||||
load_correspondents_for_documents, load_primary_assets, load_tags_for_documents,
|
use crate::documents::{
|
||||||
to_document_response, DocumentResponse,
|
asset::load_primary_assets, correspondents::load_correspondents_for_documents,
|
||||||
|
tags::load_tags_for_documents,
|
||||||
};
|
};
|
||||||
use crate::utils::{
|
use crate::utils::{
|
||||||
json::{classify_nullable, NullableValue},
|
json::{classify_nullable, NullableValue},
|
||||||
@@ -109,16 +110,16 @@ pub async fn ensure_folder_path(
|
|||||||
|
|
||||||
let existing: Option<Folder> = if let Some(parent_id) = current_parent {
|
let existing: Option<Folder> = if let Some(parent_id) = current_parent {
|
||||||
folders::table
|
folders::table
|
||||||
|
.filter(folders::tenant_id.eq(tenant_id))
|
||||||
.filter(folders::parent_id.eq(Some(parent_id)))
|
.filter(folders::parent_id.eq(Some(parent_id)))
|
||||||
.filter(folders::name.eq(name))
|
.filter(folders::name.eq(name))
|
||||||
.filter(folders::tenant_id.eq(tenant_id))
|
|
||||||
.first(conn)
|
.first(conn)
|
||||||
.optional()?
|
.optional()?
|
||||||
} else {
|
} else {
|
||||||
folders::table
|
folders::table
|
||||||
|
.filter(folders::tenant_id.eq(tenant_id))
|
||||||
.filter(folders::parent_id.is_null())
|
.filter(folders::parent_id.is_null())
|
||||||
.filter(folders::name.eq(name))
|
.filter(folders::name.eq(name))
|
||||||
.filter(folders::tenant_id.eq(tenant_id))
|
|
||||||
.first(conn)
|
.first(conn)
|
||||||
.optional()?
|
.optional()?
|
||||||
};
|
};
|
||||||
@@ -133,13 +134,32 @@ pub async fn ensure_folder_path(
|
|||||||
tenant_id,
|
tenant_id,
|
||||||
};
|
};
|
||||||
|
|
||||||
diesel::insert_into(folders::table)
|
let inserted_id: Option<Uuid> = diesel::insert_into(folders::table)
|
||||||
.values(&new_folder)
|
.values(&new_folder)
|
||||||
.execute(conn)?;
|
.on_conflict_do_nothing()
|
||||||
|
.returning(folders::id)
|
||||||
|
.get_result(conn)
|
||||||
|
.optional()?;
|
||||||
|
|
||||||
folders::table.find(new_folder.id).first(conn)?
|
if let Some(id) = inserted_id {
|
||||||
|
folders::table
|
||||||
|
.find(id)
|
||||||
|
.filter(folders::tenant_id.eq(tenant_id))
|
||||||
|
.first(conn)?
|
||||||
|
} else if let Some(parent_id) = current_parent {
|
||||||
|
folders::table
|
||||||
|
.filter(folders::tenant_id.eq(tenant_id))
|
||||||
|
.filter(folders::parent_id.eq(Some(parent_id)))
|
||||||
|
.filter(folders::name.eq(name))
|
||||||
|
.first(conn)?
|
||||||
|
} else {
|
||||||
|
folders::table
|
||||||
|
.filter(folders::tenant_id.eq(tenant_id))
|
||||||
|
.filter(folders::parent_id.is_null())
|
||||||
|
.filter(folders::name.eq(name))
|
||||||
|
.first(conn)?
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
current_parent = Some(folder.id);
|
current_parent = Some(folder.id);
|
||||||
last_folder = Some(folder);
|
last_folder = Some(folder);
|
||||||
}
|
}
|
||||||
@@ -164,18 +184,61 @@ pub async fn create_folder(
|
|||||||
return Err(AppError::bad_request("name must not be empty"));
|
return Err(AppError::bad_request("name must not be empty"));
|
||||||
}
|
}
|
||||||
|
|
||||||
let new_folder = NewFolder {
|
let name = payload.name.trim();
|
||||||
id: Uuid::new_v4(),
|
|
||||||
name: payload.name.trim().to_string(),
|
let existing: Option<Folder> = if let Some(parent_id) = payload.parent_id {
|
||||||
parent_id: payload.parent_id,
|
folders::table
|
||||||
tenant_id,
|
.filter(folders::tenant_id.eq(tenant_id))
|
||||||
|
.filter(folders::parent_id.eq(Some(parent_id)))
|
||||||
|
.filter(folders::name.eq(name))
|
||||||
|
.first(&mut conn)
|
||||||
|
.optional()?
|
||||||
|
} else {
|
||||||
|
folders::table
|
||||||
|
.filter(folders::tenant_id.eq(tenant_id))
|
||||||
|
.filter(folders::parent_id.is_null())
|
||||||
|
.filter(folders::name.eq(name))
|
||||||
|
.first(&mut conn)
|
||||||
|
.optional()?
|
||||||
};
|
};
|
||||||
|
|
||||||
diesel::insert_into(folders::table)
|
let folder: Folder = if let Some(folder) = existing {
|
||||||
.values(&new_folder)
|
folder
|
||||||
.execute(&mut conn)?;
|
} else {
|
||||||
|
let new_folder = NewFolder {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
name: name.to_string(),
|
||||||
|
parent_id: payload.parent_id,
|
||||||
|
tenant_id,
|
||||||
|
};
|
||||||
|
|
||||||
|
let inserted_id: Option<Uuid> = diesel::insert_into(folders::table)
|
||||||
|
.values(&new_folder)
|
||||||
|
.on_conflict_do_nothing()
|
||||||
|
.returning(folders::id)
|
||||||
|
.get_result(&mut conn)
|
||||||
|
.optional()?;
|
||||||
|
|
||||||
|
if let Some(id) = inserted_id {
|
||||||
|
folders::table
|
||||||
|
.find(id)
|
||||||
|
.filter(folders::tenant_id.eq(tenant_id))
|
||||||
|
.first(&mut conn)?
|
||||||
|
} else if let Some(parent_id) = payload.parent_id {
|
||||||
|
folders::table
|
||||||
|
.filter(folders::tenant_id.eq(tenant_id))
|
||||||
|
.filter(folders::parent_id.eq(Some(parent_id)))
|
||||||
|
.filter(folders::name.eq(name))
|
||||||
|
.first(&mut conn)?
|
||||||
|
} else {
|
||||||
|
folders::table
|
||||||
|
.filter(folders::tenant_id.eq(tenant_id))
|
||||||
|
.filter(folders::parent_id.is_null())
|
||||||
|
.filter(folders::name.eq(name))
|
||||||
|
.first(&mut conn)?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let folder: Folder = folders::table.find(new_folder.id).first(&mut conn)?;
|
|
||||||
Ok(Json(FolderResponse {
|
Ok(Json(FolderResponse {
|
||||||
folder: folder_to_info(folder),
|
folder: folder_to_info(folder),
|
||||||
}))
|
}))
|
||||||
@@ -247,7 +310,7 @@ pub async fn list_folder_contents(
|
|||||||
let mut correspondents_map = load_correspondents_for_documents(&mut conn, &doc_ids)?;
|
let mut correspondents_map = load_correspondents_for_documents(&mut conn, &doc_ids)?;
|
||||||
drop(conn);
|
drop(conn);
|
||||||
|
|
||||||
let primary_versions = load_primary_assets(&state, tenant_id, &docs).await?;
|
let primary_versions = load_primary_assets(&state, tenant_id, &docs)?;
|
||||||
|
|
||||||
let mut documents = Vec::with_capacity(doc_ids.len());
|
let mut documents = Vec::with_capacity(doc_ids.len());
|
||||||
for doc in docs {
|
for doc in docs {
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ use axum::{
|
|||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tower_http::cors::{AllowOrigin, CorsLayer};
|
use tower_http::{
|
||||||
|
cors::{AllowOrigin, CorsLayer},
|
||||||
|
trace::{DefaultMakeSpan, DefaultOnFailure, DefaultOnResponse, TraceLayer},
|
||||||
|
};
|
||||||
use utoipa::OpenApi;
|
use utoipa::OpenApi;
|
||||||
|
|
||||||
use crate::{auth::AuthenticatedUser, openapi::ApiDoc, state::AppState};
|
use crate::{auth::AuthenticatedUser, openapi::ApiDoc, state::AppState};
|
||||||
@@ -17,6 +20,7 @@ pub mod correspondents;
|
|||||||
pub mod documents;
|
pub mod documents;
|
||||||
pub mod folders;
|
pub mod folders;
|
||||||
pub mod health;
|
pub mod health;
|
||||||
|
pub mod profile;
|
||||||
pub mod tags;
|
pub mod tags;
|
||||||
pub mod webdav;
|
pub mod webdav;
|
||||||
|
|
||||||
@@ -54,6 +58,7 @@ pub fn create_router(state: AppState) -> Router<()> {
|
|||||||
.route("/refresh", post(auth::refresh))
|
.route("/refresh", post(auth::refresh))
|
||||||
.route("/logout", post(auth::logout))
|
.route("/logout", post(auth::logout))
|
||||||
.route("/select-tenant", post(auth::select_tenant))
|
.route("/select-tenant", post(auth::select_tenant))
|
||||||
|
.route("/tenants", get(auth::list_tenants))
|
||||||
.route("/me", get(auth::me));
|
.route("/me", get(auth::me));
|
||||||
|
|
||||||
let documents_routes = Router::new()
|
let documents_routes = Router::new()
|
||||||
@@ -78,12 +83,17 @@ pub fn create_router(state: AppState) -> Router<()> {
|
|||||||
.delete(documents::delete_document)
|
.delete(documents::delete_document)
|
||||||
.patch(documents::update_document),
|
.patch(documents::update_document),
|
||||||
)
|
)
|
||||||
.route("/:id/download", get(documents::download_document))
|
|
||||||
.route(
|
.route(
|
||||||
"/:id/assets",
|
"/:id/assets",
|
||||||
get(documents::list_document_assets).post(documents::request_document_assets),
|
get(documents::list_document_assets).post(documents::request_document_assets),
|
||||||
)
|
)
|
||||||
.route("/:id/folder", patch(documents::move_document))
|
.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", post(documents::assign_tags))
|
||||||
.route("/:id/tags/:tag_id", delete(documents::remove_tag))
|
.route("/:id/tags/:tag_id", delete(documents::remove_tag))
|
||||||
.route(
|
.route(
|
||||||
@@ -121,6 +131,13 @@ pub fn create_router(state: AppState) -> Router<()> {
|
|||||||
.delete(correspondents::delete_correspondent),
|
.delete(correspondents::delete_correspondent),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let profile_routes = Router::new()
|
||||||
|
.route(
|
||||||
|
"/webdav-tokens",
|
||||||
|
get(profile::list_webdav_tokens).post(profile::create_webdav_token),
|
||||||
|
)
|
||||||
|
.route("/webdav-tokens/:id", delete(profile::delete_webdav_token));
|
||||||
|
|
||||||
let protected_state = state.clone();
|
let protected_state = state.clone();
|
||||||
let assets_routes = Router::new().route("/:asset_id", get(documents::get_document_asset));
|
let assets_routes = Router::new().route("/:asset_id", get(documents::get_document_asset));
|
||||||
|
|
||||||
@@ -129,6 +146,7 @@ pub fn create_router(state: AppState) -> Router<()> {
|
|||||||
.nest("/api/folders", folders_routes)
|
.nest("/api/folders", folders_routes)
|
||||||
.nest("/api/tags", tags_routes)
|
.nest("/api/tags", tags_routes)
|
||||||
.nest("/api/correspondents", correspondents_routes)
|
.nest("/api/correspondents", correspondents_routes)
|
||||||
|
.nest("/api/profile", profile_routes)
|
||||||
.nest("/api/assets", assets_routes)
|
.nest("/api/assets", assets_routes)
|
||||||
.layer(middleware::from_extractor_with_state::<AuthenticatedUser, _>(protected_state));
|
.layer(middleware::from_extractor_with_state::<AuthenticatedUser, _>(protected_state));
|
||||||
|
|
||||||
@@ -153,4 +171,10 @@ pub fn create_router(state: AppState) -> Router<()> {
|
|||||||
.with_state(state)
|
.with_state(state)
|
||||||
.layer(cors)
|
.layer(cors)
|
||||||
.layer(DefaultBodyLimit::max(1024 * 1024 * 512))
|
.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)),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
use axum::{extract::Path, http::StatusCode, Json};
|
||||||
|
use chrono::{DateTime, NaiveDateTime};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::auth::{
|
||||||
|
webdav_tokens::{
|
||||||
|
create_webdav_token as issue_token, list_webdav_tokens as load_tokens,
|
||||||
|
revoke_webdav_token as revoke_token,
|
||||||
|
},
|
||||||
|
TenantScopedConn,
|
||||||
|
};
|
||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
use crate::models::WebdavToken;
|
||||||
|
use crate::utils::{db::no_content, time::to_iso};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct WebdavTokenResponse {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
|
pub label: Option<String>,
|
||||||
|
pub created_at: String,
|
||||||
|
pub last_used_at: Option<String>,
|
||||||
|
pub expires_at: Option<String>,
|
||||||
|
pub revoked_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct WebdavTokenCreatedResponse {
|
||||||
|
pub token: String,
|
||||||
|
pub token_info: WebdavTokenResponse,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateWebdavTokenRequest {
|
||||||
|
pub label: Option<String>,
|
||||||
|
pub expires_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_webdav_tokens(
|
||||||
|
TenantScopedConn {
|
||||||
|
mut conn,
|
||||||
|
tenant_id,
|
||||||
|
user_id,
|
||||||
|
..
|
||||||
|
}: TenantScopedConn,
|
||||||
|
) -> AppResult<Json<Vec<WebdavTokenResponse>>> {
|
||||||
|
let tokens = load_tokens(&mut conn, user_id, Some(tenant_id))?;
|
||||||
|
let responses = tokens.into_iter().map(webdav_token_to_response).collect();
|
||||||
|
Ok(Json(responses))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_webdav_token(
|
||||||
|
TenantScopedConn {
|
||||||
|
mut conn,
|
||||||
|
tenant_id,
|
||||||
|
user_id,
|
||||||
|
..
|
||||||
|
}: TenantScopedConn,
|
||||||
|
Json(payload): Json<CreateWebdavTokenRequest>,
|
||||||
|
) -> AppResult<(StatusCode, Json<WebdavTokenCreatedResponse>)> {
|
||||||
|
let expires_at = match payload.expires_at {
|
||||||
|
Some(ref value) => Some(parse_timestamp(value)?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let issued = issue_token(
|
||||||
|
&mut conn,
|
||||||
|
user_id,
|
||||||
|
tenant_id,
|
||||||
|
payload.label.clone(),
|
||||||
|
expires_at,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let response = WebdavTokenCreatedResponse {
|
||||||
|
token: issued.token,
|
||||||
|
token_info: webdav_token_to_response(issued.record),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok((StatusCode::CREATED, Json(response)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_webdav_token(
|
||||||
|
TenantScopedConn {
|
||||||
|
mut conn, user_id, ..
|
||||||
|
}: TenantScopedConn,
|
||||||
|
Path(token_id): Path<Uuid>,
|
||||||
|
) -> AppResult<StatusCode> {
|
||||||
|
revoke_token(&mut conn, token_id, user_id)?;
|
||||||
|
no_content()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn webdav_token_to_response(token: WebdavToken) -> WebdavTokenResponse {
|
||||||
|
WebdavTokenResponse {
|
||||||
|
id: token.id,
|
||||||
|
tenant_id: token.tenant_id,
|
||||||
|
label: token.label,
|
||||||
|
created_at: to_iso(token.created_at),
|
||||||
|
last_used_at: token.last_used_at.map(to_iso),
|
||||||
|
expires_at: token.expires_at.map(to_iso),
|
||||||
|
revoked_at: token.revoked_at.map(to_iso),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_timestamp(value: &str) -> AppResult<NaiveDateTime> {
|
||||||
|
let dt = DateTime::parse_from_rfc3339(value)
|
||||||
|
.map_err(|_| AppError::bad_request("invalid expires_at timestamp"))?;
|
||||||
|
Ok(dt.naive_utc())
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ use axum::Router;
|
|||||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
|
use diesel::OptionalExtension;
|
||||||
use diesel::PgConnection;
|
use diesel::PgConnection;
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
use percent_encoding::{percent_decode_str, utf8_percent_encode, NON_ALPHANUMERIC};
|
use percent_encoding::{percent_decode_str, utf8_percent_encode, NON_ALPHANUMERIC};
|
||||||
@@ -15,7 +16,7 @@ use quick_xml::events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event};
|
|||||||
use quick_xml::Writer;
|
use quick_xml::Writer;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::auth::password;
|
use crate::auth::webdav_tokens::{find_active_token_by_secret, touch_webdav_token};
|
||||||
use crate::error::{AppError, AppResult};
|
use crate::error::{AppError, AppResult};
|
||||||
use crate::models::{Document, DocumentVersion, Folder, User};
|
use crate::models::{Document, DocumentVersion, Folder, User};
|
||||||
use crate::schema::{
|
use crate::schema::{
|
||||||
@@ -29,17 +30,11 @@ use crate::utils::{http::inline_content_disposition, time::to_http_date};
|
|||||||
const REALM: &str = "Papercrate WebDAV";
|
const REALM: &str = "Papercrate WebDAV";
|
||||||
const DOWNLOAD_URL_TTL_SECONDS: u64 = 300;
|
const DOWNLOAD_URL_TTL_SECONDS: u64 = 300;
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
struct TenantEntry {
|
|
||||||
tenant_id: Uuid,
|
|
||||||
slug: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
struct WebDavContext {
|
struct WebDavContext {
|
||||||
|
tenant_id: Uuid,
|
||||||
_user_id: Uuid,
|
_user_id: Uuid,
|
||||||
_username: String,
|
_username: String,
|
||||||
tenants: Vec<TenantEntry>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn create_router() -> Router<AppState> {
|
pub fn create_router() -> Router<AppState> {
|
||||||
@@ -89,35 +84,20 @@ async fn handle_propfind(
|
|||||||
|
|
||||||
let segments = parse_segments(path)?;
|
let segments = parse_segments(path)?;
|
||||||
|
|
||||||
let resources = if segments.is_empty() {
|
let tenant_id = context.tenant_id;
|
||||||
build_account_root_resources(&context.tenants, depth)
|
|
||||||
} else {
|
|
||||||
let (requested_slug, remainder) = segments.split_first().unwrap();
|
|
||||||
let tenant_entry = match context
|
|
||||||
.tenants
|
|
||||||
.iter()
|
|
||||||
.find(|entry| entry.slug.eq_ignore_ascii_case(requested_slug))
|
|
||||||
{
|
|
||||||
Some(entry) => TenantEntry {
|
|
||||||
tenant_id: entry.tenant_id,
|
|
||||||
slug: entry.slug.clone(),
|
|
||||||
},
|
|
||||||
None => return Ok(not_found_response()),
|
|
||||||
};
|
|
||||||
|
|
||||||
let resolution = match resolve_path(state, &tenant_entry, remainder)? {
|
let resources = if segments.is_empty() {
|
||||||
|
let contents = fetch_folder_contents(state, tenant_id, None)?;
|
||||||
|
build_resources_for_folder(None, &[], &contents, depth)
|
||||||
|
} else {
|
||||||
|
let resolution = match resolve_path(state, tenant_id, &segments)? {
|
||||||
Some(resolved) => resolved,
|
Some(resolved) => resolved,
|
||||||
None => return Ok(not_found_response()),
|
None => return Ok(not_found_response()),
|
||||||
};
|
};
|
||||||
|
|
||||||
match resolution {
|
match resolution {
|
||||||
ResolvedPath::TenantRoot { chain } => {
|
|
||||||
let contents = fetch_folder_contents(state, tenant_entry.tenant_id, None)?;
|
|
||||||
build_resources_for_folder(None, &chain, &contents, depth)
|
|
||||||
}
|
|
||||||
ResolvedPath::Folder { folder, chain } => {
|
ResolvedPath::Folder { folder, chain } => {
|
||||||
let contents =
|
let contents = fetch_folder_contents(state, tenant_id, Some(folder.id))?;
|
||||||
fetch_folder_contents(state, tenant_entry.tenant_id, Some(folder.id))?;
|
|
||||||
build_resources_for_folder(Some(&folder), &chain, &contents, depth)
|
build_resources_for_folder(Some(&folder), &chain, &contents, depth)
|
||||||
}
|
}
|
||||||
ResolvedPath::Document {
|
ResolvedPath::Document {
|
||||||
@@ -151,29 +131,13 @@ async fn handle_get_or_head(
|
|||||||
None => return Ok(unauthorized_response()),
|
None => return Ok(unauthorized_response()),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let tenant_id = context.tenant_id;
|
||||||
let segments = parse_segments(path)?;
|
let segments = parse_segments(path)?;
|
||||||
let (requested_slug, remainder) = match segments.split_first() {
|
if segments.is_empty() {
|
||||||
Some(values) => values,
|
|
||||||
None => return Ok(method_not_allowed()),
|
|
||||||
};
|
|
||||||
|
|
||||||
let tenant_entry = match context
|
|
||||||
.tenants
|
|
||||||
.iter()
|
|
||||||
.find(|entry| entry.slug.eq_ignore_ascii_case(requested_slug))
|
|
||||||
{
|
|
||||||
Some(entry) => TenantEntry {
|
|
||||||
tenant_id: entry.tenant_id,
|
|
||||||
slug: entry.slug.clone(),
|
|
||||||
},
|
|
||||||
None => return Ok(not_found_response()),
|
|
||||||
};
|
|
||||||
|
|
||||||
if remainder.is_empty() {
|
|
||||||
return Ok(method_not_allowed());
|
return Ok(method_not_allowed());
|
||||||
}
|
}
|
||||||
|
|
||||||
let resolution = match resolve_path(state, &tenant_entry, remainder)? {
|
let resolution = match resolve_path(state, tenant_id, &segments)? {
|
||||||
Some(resolved) => resolved,
|
Some(resolved) => resolved,
|
||||||
None => return Ok(not_found_response()),
|
None => return Ok(not_found_response()),
|
||||||
};
|
};
|
||||||
@@ -458,8 +422,8 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavCo
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let (username, password) = match credential_str.split_once(':') {
|
let (username, secret) = match credential_str.split_once(':') {
|
||||||
Some((username, password)) if !username.is_empty() => (username, password),
|
Some((username, secret)) if !username.is_empty() => (username, secret),
|
||||||
_ => return Ok(None),
|
_ => return Ok(None),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -478,35 +442,46 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavCo
|
|||||||
Err(err) => return Err(AppError::from(err)),
|
Err(err) => return Err(AppError::from(err)),
|
||||||
};
|
};
|
||||||
|
|
||||||
let valid = password::verify_password(password, &user.password_hash)
|
let token = match find_active_token_by_secret(&mut conn, user.id, None, secret)? {
|
||||||
.map_err(|_| AppError::internal("failed to verify password"))?;
|
Some(token) => token,
|
||||||
|
None => {
|
||||||
|
tracing::warn!(%username, "webdav token invalid or expired");
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if !valid {
|
let tenant_row = memberships_dsl::user_memberships
|
||||||
tracing::warn!(%username, "webdav password invalid");
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let tenant_rows: Vec<(Uuid, String)> = memberships_dsl::user_memberships
|
|
||||||
.inner_join(tenant_dsl::tenants)
|
.inner_join(tenant_dsl::tenants)
|
||||||
.filter(memberships_dsl::user_id.eq(user.id))
|
.filter(memberships_dsl::user_id.eq(user.id))
|
||||||
|
.filter(memberships_dsl::tenant_id.eq(token.tenant_id))
|
||||||
.select((tenant_dsl::id, tenant_dsl::slug))
|
.select((tenant_dsl::id, tenant_dsl::slug))
|
||||||
.load(&mut conn)?;
|
.first::<(Uuid, String)>(&mut conn)
|
||||||
|
.optional()?;
|
||||||
|
|
||||||
if tenant_rows.is_empty() {
|
let (tenant_id, _slug) = match tenant_row {
|
||||||
tracing::warn!(%username, "webdav user has no tenant memberships");
|
Some(row) => row,
|
||||||
return Ok(None);
|
None => {
|
||||||
}
|
tracing::warn!(
|
||||||
|
%username,
|
||||||
|
tenant_id = %token.tenant_id,
|
||||||
|
"webdav token tenant membership missing"
|
||||||
|
);
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let tenants: Vec<TenantEntry> = tenant_rows
|
touch_webdav_token(&mut conn, token.id)?;
|
||||||
.into_iter()
|
|
||||||
.map(|(tenant_id, slug)| TenantEntry { tenant_id, slug })
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
tracing::debug!(%username, tenant_count = tenants.len(), "webdav login success");
|
tracing::debug!(
|
||||||
|
%username,
|
||||||
|
tenant_id = %tenant_id,
|
||||||
|
token_id = %token.id,
|
||||||
|
"webdav token login success"
|
||||||
|
);
|
||||||
Ok(Some(WebDavContext {
|
Ok(Some(WebDavContext {
|
||||||
|
tenant_id,
|
||||||
_user_id: user.id,
|
_user_id: user.id,
|
||||||
_username: user.username,
|
_username: user.username,
|
||||||
tenants,
|
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -564,37 +539,6 @@ fn build_resources_for_folder(
|
|||||||
resources
|
resources
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_account_root_resources(tenants: &[TenantEntry], depth: u8) -> Vec<DavResource> {
|
|
||||||
let mut resources = Vec::new();
|
|
||||||
|
|
||||||
resources.push(DavResource {
|
|
||||||
href: "/".to_string(),
|
|
||||||
display_name: "/".to_string(),
|
|
||||||
is_collection: true,
|
|
||||||
content_length: None,
|
|
||||||
content_type: None,
|
|
||||||
last_modified: None,
|
|
||||||
});
|
|
||||||
|
|
||||||
if depth == 0 {
|
|
||||||
return resources;
|
|
||||||
}
|
|
||||||
|
|
||||||
for tenant in tenants {
|
|
||||||
let href = build_href(&[tenant.slug.clone()], true);
|
|
||||||
resources.push(DavResource {
|
|
||||||
href,
|
|
||||||
display_name: tenant.slug.clone(),
|
|
||||||
is_collection: true,
|
|
||||||
content_length: None,
|
|
||||||
content_type: None,
|
|
||||||
last_modified: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
resources
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_resources_for_document(
|
fn build_resources_for_document(
|
||||||
chain: &[String],
|
chain: &[String],
|
||||||
document: &Document,
|
document: &Document,
|
||||||
@@ -717,9 +661,6 @@ struct DavResource {
|
|||||||
last_modified: Option<String>,
|
last_modified: Option<String>,
|
||||||
}
|
}
|
||||||
enum ResolvedPath {
|
enum ResolvedPath {
|
||||||
TenantRoot {
|
|
||||||
chain: Vec<String>,
|
|
||||||
},
|
|
||||||
Folder {
|
Folder {
|
||||||
folder: Folder,
|
folder: Folder,
|
||||||
chain: Vec<String>,
|
chain: Vec<String>,
|
||||||
@@ -733,23 +674,18 @@ enum ResolvedPath {
|
|||||||
|
|
||||||
fn resolve_path(
|
fn resolve_path(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
tenant: &TenantEntry,
|
tenant_id: Uuid,
|
||||||
segments: &[String],
|
segments: &[String],
|
||||||
) -> AppResult<Option<ResolvedPath>> {
|
) -> AppResult<Option<ResolvedPath>> {
|
||||||
let mut conn = state.db_for_tenant(tenant.tenant_id)?;
|
let mut conn = state.db_for_tenant(tenant_id)?;
|
||||||
let mut parent_id: Option<Uuid> = None;
|
let mut parent_id: Option<Uuid> = None;
|
||||||
let mut chain: Vec<String> = vec![tenant.slug.clone()];
|
let mut chain: Vec<String> = Vec::new();
|
||||||
let mut current_folder: Option<Folder> = None;
|
let mut current_folder: Option<Folder> = None;
|
||||||
|
|
||||||
if segments.is_empty() {
|
|
||||||
return Ok(Some(ResolvedPath::TenantRoot { chain }));
|
|
||||||
}
|
|
||||||
|
|
||||||
for (index, segment) in segments.iter().enumerate() {
|
for (index, segment) in segments.iter().enumerate() {
|
||||||
let is_last = index == segments.len() - 1;
|
let is_last = index == segments.len() - 1;
|
||||||
|
|
||||||
if let Some(folder) = find_folder_by_name(&mut conn, tenant.tenant_id, parent_id, segment)?
|
if let Some(folder) = find_folder_by_name(&mut conn, tenant_id, parent_id, segment)? {
|
||||||
{
|
|
||||||
chain.push(folder.name.clone());
|
chain.push(folder.name.clone());
|
||||||
if is_last {
|
if is_last {
|
||||||
return Ok(Some(ResolvedPath::Folder { folder, chain }));
|
return Ok(Some(ResolvedPath::Folder { folder, chain }));
|
||||||
@@ -761,7 +697,7 @@ fn resolve_path(
|
|||||||
|
|
||||||
if is_last {
|
if is_last {
|
||||||
if let Some((document, version)) =
|
if let Some((document, version)) =
|
||||||
find_document_by_filename(&mut conn, tenant.tenant_id, parent_id, segment)?
|
find_document_by_filename(&mut conn, tenant_id, parent_id, segment)?
|
||||||
{
|
{
|
||||||
chain.push(document.filename.clone());
|
chain.push(document.filename.clone());
|
||||||
return Ok(Some(ResolvedPath::Document {
|
return Ok(Some(ResolvedPath::Document {
|
||||||
@@ -773,7 +709,7 @@ fn resolve_path(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(uuid) = Uuid::parse_str(segment) {
|
if let Ok(uuid) = Uuid::parse_str(segment) {
|
||||||
if let Some(folder) = find_folder_by_id(&mut conn, tenant.tenant_id, uuid)? {
|
if let Some(folder) = find_folder_by_id(&mut conn, tenant_id, uuid)? {
|
||||||
if folder.parent_id != parent_id {
|
if folder.parent_id != parent_id {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
@@ -786,9 +722,7 @@ fn resolve_path(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some((document, version)) =
|
if let Some((document, version)) = find_document_by_id(&mut conn, tenant_id, uuid)? {
|
||||||
find_document_by_id(&mut conn, tenant.tenant_id, uuid)?
|
|
||||||
{
|
|
||||||
if document.folder_id != parent_id {
|
if document.folder_id != parent_id {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-4
@@ -37,11 +37,9 @@ diesel::table! {
|
|||||||
}
|
}
|
||||||
|
|
||||||
diesel::table! {
|
diesel::table! {
|
||||||
document_correspondents (document_id, correspondent_id, role) {
|
document_correspondents (document_id, correspondent_id) {
|
||||||
document_id -> Uuid,
|
document_id -> Uuid,
|
||||||
correspondent_id -> Uuid,
|
correspondent_id -> Uuid,
|
||||||
#[max_length = 32]
|
|
||||||
role -> Varchar,
|
|
||||||
assigned_at -> Timestamptz,
|
assigned_at -> Timestamptz,
|
||||||
assigned_by -> Nullable<Uuid>,
|
assigned_by -> Nullable<Uuid>,
|
||||||
tenant_id -> Uuid,
|
tenant_id -> Uuid,
|
||||||
@@ -69,7 +67,6 @@ diesel::table! {
|
|||||||
#[max_length = 64]
|
#[max_length = 64]
|
||||||
checksum -> Varchar,
|
checksum -> Varchar,
|
||||||
created_at -> Timestamptz,
|
created_at -> Timestamptz,
|
||||||
operations_summary -> Jsonb,
|
|
||||||
metadata -> Jsonb,
|
metadata -> Jsonb,
|
||||||
tenant_id -> Uuid,
|
tenant_id -> Uuid,
|
||||||
}
|
}
|
||||||
@@ -186,6 +183,21 @@ diesel::table! {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
webdav_tokens (id) {
|
||||||
|
id -> Uuid,
|
||||||
|
user_id -> Uuid,
|
||||||
|
tenant_id -> Uuid,
|
||||||
|
token_prefix -> Text,
|
||||||
|
token_hash -> Text,
|
||||||
|
label -> Nullable<Text>,
|
||||||
|
created_at -> Timestamptz,
|
||||||
|
last_used_at -> Nullable<Timestamptz>,
|
||||||
|
expires_at -> Nullable<Timestamptz>,
|
||||||
|
revoked_at -> Nullable<Timestamptz>,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
diesel::joinable!(correspondents -> tenants (tenant_id));
|
diesel::joinable!(correspondents -> tenants (tenant_id));
|
||||||
diesel::joinable!(document_asset_objects -> document_assets (asset_id));
|
diesel::joinable!(document_asset_objects -> document_assets (asset_id));
|
||||||
diesel::joinable!(document_asset_objects -> tenants (tenant_id));
|
diesel::joinable!(document_asset_objects -> tenants (tenant_id));
|
||||||
@@ -209,6 +221,8 @@ diesel::joinable!(refresh_tokens -> users (user_id));
|
|||||||
diesel::joinable!(tags -> tenants (tenant_id));
|
diesel::joinable!(tags -> tenants (tenant_id));
|
||||||
diesel::joinable!(user_memberships -> tenants (tenant_id));
|
diesel::joinable!(user_memberships -> tenants (tenant_id));
|
||||||
diesel::joinable!(user_memberships -> users (user_id));
|
diesel::joinable!(user_memberships -> users (user_id));
|
||||||
|
diesel::joinable!(webdav_tokens -> tenants (tenant_id));
|
||||||
|
diesel::joinable!(webdav_tokens -> users (user_id));
|
||||||
|
|
||||||
diesel::allow_tables_to_appear_in_same_query!(
|
diesel::allow_tables_to_appear_in_same_query!(
|
||||||
correspondents,
|
correspondents,
|
||||||
@@ -225,4 +239,5 @@ diesel::allow_tables_to_appear_in_same_query!(
|
|||||||
tenants,
|
tenants,
|
||||||
user_memberships,
|
user_memberships,
|
||||||
users,
|
users,
|
||||||
|
webdav_tokens,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use std::{collections::HashSet, sync::Arc, time::Duration};
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{json, Map, Value};
|
use serde_json::json;
|
||||||
use tokio::task;
|
use tokio::task;
|
||||||
use tracing::{error, warn};
|
use tracing::{error, warn};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -104,7 +104,7 @@ fn analyze_document(
|
|||||||
|
|
||||||
let tenant_id = document.tenant_id;
|
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 ocr_supported = document_is_pdf(&document);
|
||||||
|
|
||||||
let existing_ocr: Option<DocumentAsset> = document_assets::table
|
let existing_ocr: Option<DocumentAsset> = document_assets::table
|
||||||
@@ -117,32 +117,6 @@ fn analyze_document(
|
|||||||
|
|
||||||
let skip_ocr = existing_ocr.is_some() && !payload.force;
|
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 {
|
if supported {
|
||||||
let enqueue_result = enqueue_job(
|
let enqueue_result = enqueue_job(
|
||||||
&mut conn,
|
&mut conn,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ use tracing::{error, info, warn};
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
documents::asset::delete_asset,
|
||||||
jobs::{enqueue_job, JOB_GENERATE_OCR_TEXT, JOB_INDEX_DOCUMENT_TEXT},
|
jobs::{enqueue_job, JOB_GENERATE_OCR_TEXT, JOB_INDEX_DOCUMENT_TEXT},
|
||||||
models::{
|
models::{
|
||||||
Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocumentAsset,
|
Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocumentAsset,
|
||||||
@@ -133,12 +134,29 @@ impl JobHandler for GenerateOcrTextJob {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
if context.existing_asset.is_some() {
|
if let Some(existing_asset) = &context.existing_asset {
|
||||||
for object in &context.existing_objects {
|
for object in &context.existing_objects {
|
||||||
if let Err(err) = storage.delete_object(&object.s3_key).await {
|
if let Err(err) = storage.delete_object(&object.s3_key).await {
|
||||||
warn!(job_id = %job.id, error = %err, s3_key = %object.s3_key, "failed to delete existing ocr asset object");
|
warn!(job_id = %job.id, error = %err, s3_key = %object.s3_key, "failed to delete existing ocr asset object");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let tenant_id = context.document.tenant_id;
|
||||||
|
let asset_id = existing_asset.id;
|
||||||
|
let state_clone = state.clone();
|
||||||
|
match task::spawn_blocking(move || {
|
||||||
|
delete_asset(state_clone.as_ref(), tenant_id, asset_id)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Ok(())) => {}
|
||||||
|
Ok(Err(err)) => {
|
||||||
|
warn!(job_id = %job.id, error = ?err, asset_id = %asset_id, "failed to remove ocr asset metadata after deletion");
|
||||||
|
}
|
||||||
|
Err(join_err) => {
|
||||||
|
warn!(job_id = %job.id, error = %join_err, asset_id = %asset_id, "failed to remove ocr asset metadata: task panicked");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let asset_id = Uuid::new_v4();
|
let asset_id = Uuid::new_v4();
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use tracing::{error, info, warn};
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
documents::asset::delete_asset,
|
||||||
jobs::JOB_GENERATE_THUMBNAILS,
|
jobs::JOB_GENERATE_THUMBNAILS,
|
||||||
models::{
|
models::{
|
||||||
Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocumentAsset,
|
Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocumentAsset,
|
||||||
@@ -152,7 +153,7 @@ impl JobHandler for GenerateThumbnailsJob {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if initial.existing_preview.is_some() {
|
if let Some(existing_preview) = &initial.existing_preview {
|
||||||
for object in &initial.existing_preview_objects {
|
for object in &initial.existing_preview_objects {
|
||||||
if let Err(err) = storage.delete_object(&object.s3_key).await {
|
if let Err(err) = storage.delete_object(&object.s3_key).await {
|
||||||
warn!(
|
warn!(
|
||||||
@@ -163,9 +164,36 @@ impl JobHandler for GenerateThumbnailsJob {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let tenant_id = initial.document.tenant_id;
|
||||||
|
let asset_id = existing_preview.id;
|
||||||
|
let state_clone = state.clone();
|
||||||
|
match task::spawn_blocking(move || {
|
||||||
|
delete_asset(state_clone.as_ref(), tenant_id, asset_id)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Ok(())) => {}
|
||||||
|
Ok(Err(err)) => {
|
||||||
|
warn!(
|
||||||
|
job_id = %job.id,
|
||||||
|
error = ?err,
|
||||||
|
asset_id = %asset_id,
|
||||||
|
"failed to remove preview metadata after deletion"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(join_err) => {
|
||||||
|
warn!(
|
||||||
|
job_id = %job.id,
|
||||||
|
error = %join_err,
|
||||||
|
asset_id = %asset_id,
|
||||||
|
"failed to remove preview metadata: task panicked"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if initial.existing_thumbnail.is_some() {
|
if let Some(existing_thumbnail) = &initial.existing_thumbnail {
|
||||||
for object in &initial.existing_thumbnail_objects {
|
for object in &initial.existing_thumbnail_objects {
|
||||||
if let Err(err) = storage.delete_object(&object.s3_key).await {
|
if let Err(err) = storage.delete_object(&object.s3_key).await {
|
||||||
warn!(
|
warn!(
|
||||||
@@ -176,6 +204,33 @@ impl JobHandler for GenerateThumbnailsJob {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let tenant_id = initial.document.tenant_id;
|
||||||
|
let asset_id = existing_thumbnail.id;
|
||||||
|
let state_clone = state.clone();
|
||||||
|
match task::spawn_blocking(move || {
|
||||||
|
delete_asset(state_clone.as_ref(), tenant_id, asset_id)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Ok(())) => {}
|
||||||
|
Ok(Err(err)) => {
|
||||||
|
warn!(
|
||||||
|
job_id = %job.id,
|
||||||
|
error = ?err,
|
||||||
|
asset_id = %asset_id,
|
||||||
|
"failed to remove thumbnail metadata after deletion"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(join_err) => {
|
||||||
|
warn!(
|
||||||
|
job_id = %job.id,
|
||||||
|
error = %join_err,
|
||||||
|
asset_id = %asset_id,
|
||||||
|
"failed to remove thumbnail metadata: task panicked"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let preview_asset_id = Uuid::new_v4();
|
let preview_asset_id = Uuid::new_v4();
|
||||||
|
|||||||
+267
-4
@@ -1,15 +1,49 @@
|
|||||||
mod common;
|
mod common;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::{anyhow, Context, Result};
|
||||||
use axum::http::StatusCode;
|
use axum::body::Body;
|
||||||
|
use axum::http::{header::SET_COOKIE, StatusCode};
|
||||||
|
use backend::models::NewUserMembership;
|
||||||
|
use backend::schema::{tenants, user_memberships};
|
||||||
use common::{acquire_db_lock, body_to_vec, TestApp};
|
use common::{acquire_db_lock, body_to_vec, TestApp};
|
||||||
|
use diesel::prelude::*;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
use serde_json::json;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct AuthenticatedUser {
|
struct AuthenticatedUser {
|
||||||
username: String,
|
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]
|
#[tokio::test]
|
||||||
async fn login_and_me_roundtrip() -> Result<()> {
|
async fn login_and_me_roundtrip() -> Result<()> {
|
||||||
let _lock = acquire_db_lock().await;
|
let _lock = acquire_db_lock().await;
|
||||||
@@ -18,9 +52,9 @@ async fn login_and_me_roundtrip() -> Result<()> {
|
|||||||
let password = "s3cret";
|
let password = "s3cret";
|
||||||
app.insert_user("alice", password, "admin").await?;
|
app.insert_user("alice", password, "admin").await?;
|
||||||
|
|
||||||
let token = app.login_token("alice", password).await?;
|
let (login, _) = login_with_session(&app, "alice", password).await?;
|
||||||
|
|
||||||
let response = app.get("/api/auth/me", Some(&token)).await?;
|
let response = app.get("/api/auth/me", Some(&login.access_token)).await?;
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
let body = body_to_vec(response.into_body()).await?;
|
let body = body_to_vec(response.into_body()).await?;
|
||||||
let user: AuthenticatedUser = serde_json::from_slice(&body)?;
|
let user: AuthenticatedUser = serde_json::from_slice(&body)?;
|
||||||
@@ -30,3 +64,232 @@ async fn login_and_me_roundtrip() -> Result<()> {
|
|||||||
app.cleanup().await?;
|
app.cleanup().await?;
|
||||||
Ok(())
|
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
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+102
-10
@@ -6,7 +6,7 @@ use std::time::Duration;
|
|||||||
use anyhow::{anyhow, ensure, Context, Result};
|
use anyhow::{anyhow, ensure, Context, Result};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use axum::body::Body;
|
use axum::body::Body;
|
||||||
use axum::http::{Method, Request, StatusCode};
|
use axum::http::{header, Method, Request, StatusCode};
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use backend::auth::jwt::JwtService;
|
use backend::auth::jwt::JwtService;
|
||||||
use backend::config::AppConfig;
|
use backend::config::AppConfig;
|
||||||
@@ -328,7 +328,7 @@ impl TestApp {
|
|||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct TenantSelectionResponse {
|
struct TenantSelectionResponse {
|
||||||
selection_token: String,
|
access_token: String,
|
||||||
tenants: Vec<TenantSummary>,
|
tenants: Vec<TenantSummary>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,7 +350,7 @@ impl TestApp {
|
|||||||
&SelectTenantPayload {
|
&SelectTenantPayload {
|
||||||
tenant_id: target_tenant,
|
tenant_id: target_tenant,
|
||||||
},
|
},
|
||||||
Some(&selection.selection_token),
|
Some(&selection.access_token),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -396,6 +396,16 @@ impl TestApp {
|
|||||||
path: &str,
|
path: &str,
|
||||||
payload: &T,
|
payload: &T,
|
||||||
token: Option<&str>,
|
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>> {
|
) -> Result<hyper::Response<Body>> {
|
||||||
let body = serde_json::to_vec(payload)?;
|
let body = serde_json::to_vec(payload)?;
|
||||||
let mut builder = Request::builder()
|
let mut builder = Request::builder()
|
||||||
@@ -405,6 +415,9 @@ impl TestApp {
|
|||||||
if let Some(token) = token {
|
if let Some(token) = token {
|
||||||
builder = builder.header("authorization", format!("Bearer {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))?;
|
let request = builder.body(Body::from(body))?;
|
||||||
Ok(self
|
Ok(self
|
||||||
.router
|
.router
|
||||||
@@ -479,14 +492,14 @@ impl TestApp {
|
|||||||
folder_id: Option<Uuid>,
|
folder_id: Option<Uuid>,
|
||||||
token: &str,
|
token: &str,
|
||||||
) -> Result<hyper::Response<Body>> {
|
) -> Result<hyper::Response<Body>> {
|
||||||
self.upload_document_with_options(
|
let extras = UploadExtras::empty();
|
||||||
|
self.upload_document_with_extras(
|
||||||
path,
|
path,
|
||||||
filename,
|
filename,
|
||||||
content_type,
|
content_type,
|
||||||
data,
|
data,
|
||||||
folder_id,
|
folder_id,
|
||||||
None,
|
extras,
|
||||||
None,
|
|
||||||
token,
|
token,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -502,6 +515,36 @@ impl TestApp {
|
|||||||
title: Option<&str>,
|
title: Option<&str>,
|
||||||
metadata_json: Option<&str>,
|
metadata_json: Option<&str>,
|
||||||
token: &str,
|
token: &str,
|
||||||
|
) -> Result<hyper::Response<Body>> {
|
||||||
|
let extras = UploadExtras {
|
||||||
|
title,
|
||||||
|
metadata_json,
|
||||||
|
tag_ids_json: None,
|
||||||
|
correspondents_json: None,
|
||||||
|
issued_at: None,
|
||||||
|
skip_existing: false,
|
||||||
|
};
|
||||||
|
self.upload_document_with_extras(
|
||||||
|
path,
|
||||||
|
filename,
|
||||||
|
content_type,
|
||||||
|
data,
|
||||||
|
folder_id,
|
||||||
|
extras,
|
||||||
|
token,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn upload_document_with_extras(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
filename: &str,
|
||||||
|
content_type: &str,
|
||||||
|
data: &[u8],
|
||||||
|
folder_id: Option<Uuid>,
|
||||||
|
extras: UploadExtras<'_>,
|
||||||
|
token: &str,
|
||||||
) -> Result<hyper::Response<Body>> {
|
) -> Result<hyper::Response<Body>> {
|
||||||
let boundary = format!("boundary-{}", Uuid::new_v4());
|
let boundary = format!("boundary-{}", Uuid::new_v4());
|
||||||
let mut body = Vec::new();
|
let mut body = Vec::new();
|
||||||
@@ -524,20 +567,46 @@ impl TestApp {
|
|||||||
body.extend(b"\r\n");
|
body.extend(b"\r\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(title_value) = title {
|
if let Some(title_value) = extras.title {
|
||||||
body.extend(format!("--{boundary}\r\n").as_bytes());
|
body.extend(format!("--{boundary}\r\n").as_bytes());
|
||||||
body.extend(b"Content-Disposition: form-data; name=\"title\"\r\n\r\n");
|
body.extend(b"Content-Disposition: form-data; name=\"title\"\r\n\r\n");
|
||||||
body.extend(title_value.as_bytes());
|
body.extend(title_value.as_bytes());
|
||||||
body.extend(b"\r\n");
|
body.extend(b"\r\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(metadata_value) = metadata_json {
|
if let Some(metadata_value) = extras.metadata_json {
|
||||||
body.extend(format!("--{boundary}\r\n").as_bytes());
|
body.extend(format!("--{boundary}\r\n").as_bytes());
|
||||||
body.extend(b"Content-Disposition: form-data; name=\"metadata\"\r\n\r\n");
|
body.extend(b"Content-Disposition: form-data; name=\"metadata\"\r\n\r\n");
|
||||||
body.extend(metadata_value.as_bytes());
|
body.extend(metadata_value.as_bytes());
|
||||||
body.extend(b"\r\n");
|
body.extend(b"\r\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(tag_ids_value) = extras.tag_ids_json {
|
||||||
|
body.extend(format!("--{boundary}\r\n").as_bytes());
|
||||||
|
body.extend(b"Content-Disposition: form-data; name=\"tag_ids\"\r\n\r\n");
|
||||||
|
body.extend(tag_ids_value.as_bytes());
|
||||||
|
body.extend(b"\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(correspondents_value) = extras.correspondents_json {
|
||||||
|
body.extend(format!("--{boundary}\r\n").as_bytes());
|
||||||
|
body.extend(b"Content-Disposition: form-data; name=\"correspondents\"\r\n\r\n");
|
||||||
|
body.extend(correspondents_value.as_bytes());
|
||||||
|
body.extend(b"\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(issued_at_value) = extras.issued_at {
|
||||||
|
body.extend(format!("--{boundary}\r\n").as_bytes());
|
||||||
|
body.extend(b"Content-Disposition: form-data; name=\"issued_at\"\r\n\r\n");
|
||||||
|
body.extend(issued_at_value.as_bytes());
|
||||||
|
body.extend(b"\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
if extras.skip_existing {
|
||||||
|
body.extend(format!("--{boundary}\r\n").as_bytes());
|
||||||
|
body.extend(b"Content-Disposition: form-data; name=\"skip_existing\"\r\n\r\ntrue\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
body.extend(format!("--{boundary}--\r\n").as_bytes());
|
body.extend(format!("--{boundary}--\r\n").as_bytes());
|
||||||
|
|
||||||
let builder = Request::builder()
|
let builder = Request::builder()
|
||||||
@@ -558,7 +627,7 @@ impl TestApp {
|
|||||||
.expect("infallible response"))
|
.expect("infallible response"))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn with_conn<F, T>(&self, f: F) -> Result<T>
|
pub async fn with_conn<F, T>(&self, f: F) -> Result<T>
|
||||||
where
|
where
|
||||||
F: FnOnce(&mut PgConnection) -> Result<T> + Send + 'static,
|
F: FnOnce(&mut PgConnection) -> Result<T> + Send + 'static,
|
||||||
T: Send + 'static,
|
T: Send + 'static,
|
||||||
@@ -575,6 +644,28 @@ impl TestApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct UploadExtras<'a> {
|
||||||
|
pub title: Option<&'a str>,
|
||||||
|
pub metadata_json: Option<&'a str>,
|
||||||
|
pub tag_ids_json: Option<&'a str>,
|
||||||
|
pub correspondents_json: Option<&'a str>,
|
||||||
|
pub issued_at: Option<&'a str>,
|
||||||
|
pub skip_existing: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> UploadExtras<'a> {
|
||||||
|
pub fn empty() -> Self {
|
||||||
|
Self {
|
||||||
|
title: None,
|
||||||
|
metadata_json: None,
|
||||||
|
tag_ids_json: None,
|
||||||
|
correspondents_json: None,
|
||||||
|
issued_at: None,
|
||||||
|
skip_existing: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn acquire_db_lock() -> tokio::sync::MutexGuard<'static, ()> {
|
pub async fn acquire_db_lock() -> tokio::sync::MutexGuard<'static, ()> {
|
||||||
DB_LOCK.lock().await
|
DB_LOCK.lock().await
|
||||||
}
|
}
|
||||||
@@ -616,6 +707,7 @@ fn truncate_all(conn: &mut PgConnection) -> Result<()> {
|
|||||||
jobs, \
|
jobs, \
|
||||||
refresh_tokens, \
|
refresh_tokens, \
|
||||||
tags, \
|
tags, \
|
||||||
|
webdav_tokens, \
|
||||||
user_memberships, \
|
user_memberships, \
|
||||||
users, \
|
users, \
|
||||||
tenants \
|
tenants \
|
||||||
@@ -625,7 +717,7 @@ fn truncate_all(conn: &mut PgConnection) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn hash_password(password: &str) -> Result<String> {
|
pub fn hash_password(password: &str) -> Result<String> {
|
||||||
use argon2::password_hash::{PasswordHasher, SaltString};
|
use argon2::password_hash::{PasswordHasher, SaltString};
|
||||||
use argon2::Argon2;
|
use argon2::Argon2;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
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)?)
|
||||||
|
}
|
||||||
+889
-290
File diff suppressed because it is too large
Load Diff
@@ -232,14 +232,14 @@ async fn ensure_path_creates_nested_folders() -> Result<()> {
|
|||||||
let first_resp = app
|
let first_resp = app
|
||||||
.post_json("/api/folders/path", &base_path, Some(&token))
|
.post_json("/api/folders/path", &base_path, Some(&token))
|
||||||
.await?;
|
.await?;
|
||||||
assert_eq!(first_resp.status(), StatusCode::OK);
|
assert!(first_resp.status().is_success());
|
||||||
let first_body = body_to_vec(first_resp.into_body()).await?;
|
let first_body = body_to_vec(first_resp.into_body()).await?;
|
||||||
let first_folder: FolderResponse = serde_json::from_slice(&first_body)?;
|
let first_folder: FolderResponse = serde_json::from_slice(&first_body)?;
|
||||||
|
|
||||||
let second_resp = app
|
let second_resp = app
|
||||||
.post_json("/api/folders/path", &base_path, Some(&token))
|
.post_json("/api/folders/path", &base_path, Some(&token))
|
||||||
.await?;
|
.await?;
|
||||||
assert_eq!(second_resp.status(), StatusCode::OK);
|
assert!(second_resp.status().is_success());
|
||||||
let second_body = body_to_vec(second_resp.into_body()).await?;
|
let second_body = body_to_vec(second_resp.into_body()).await?;
|
||||||
let second_folder: FolderResponse = serde_json::from_slice(&second_body)?;
|
let second_folder: FolderResponse = serde_json::from_slice(&second_body)?;
|
||||||
assert_eq!(second_folder.folder.id, first_folder.folder.id);
|
assert_eq!(second_folder.folder.id, first_folder.folder.id);
|
||||||
@@ -293,6 +293,118 @@ async fn ensure_path_creates_nested_folders() -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn create_folder_is_idempotent() -> Result<()> {
|
||||||
|
let _lock = acquire_db_lock().await;
|
||||||
|
let app = TestApp::new().await?;
|
||||||
|
|
||||||
|
let password = "idempotent";
|
||||||
|
app.insert_user("folders-idem", password, "admin").await?;
|
||||||
|
let token = app.login_token("folders-idem", password).await?;
|
||||||
|
|
||||||
|
let payload = CreateFolder {
|
||||||
|
name: "Archive",
|
||||||
|
parent_id: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let first_resp = app
|
||||||
|
.post_json("/api/folders", &payload, Some(&token))
|
||||||
|
.await?;
|
||||||
|
assert!(first_resp.status().is_success());
|
||||||
|
let first_body = body_to_vec(first_resp.into_body()).await?;
|
||||||
|
let first_folder: FolderResponse = serde_json::from_slice(&first_body)?;
|
||||||
|
|
||||||
|
let second_resp = app
|
||||||
|
.post_json("/api/folders", &payload, Some(&token))
|
||||||
|
.await?;
|
||||||
|
assert!(second_resp.status().is_success());
|
||||||
|
let second_body = body_to_vec(second_resp.into_body()).await?;
|
||||||
|
let second_folder: FolderResponse = serde_json::from_slice(&second_body)?;
|
||||||
|
|
||||||
|
assert_eq!(first_folder.folder.id, second_folder.folder.id);
|
||||||
|
|
||||||
|
let root_contents = app.get("/api/folders/root/contents", Some(&token)).await?;
|
||||||
|
let root_body = body_to_vec(root_contents.into_body()).await?;
|
||||||
|
let root: FolderContents = serde_json::from_slice(&root_body)?;
|
||||||
|
let occurrences = root
|
||||||
|
.subfolders
|
||||||
|
.iter()
|
||||||
|
.filter(|folder| folder.id == first_folder.folder.id)
|
||||||
|
.count();
|
||||||
|
assert_eq!(occurrences, 1);
|
||||||
|
|
||||||
|
app.cleanup().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ensure_folder_path_is_idempotent() -> Result<()> {
|
||||||
|
let _lock = acquire_db_lock().await;
|
||||||
|
let app = TestApp::new().await?;
|
||||||
|
|
||||||
|
let password = "pathpass";
|
||||||
|
app.insert_user("path-admin", password, "admin").await?;
|
||||||
|
let token = app.login_token("path-admin", password).await?;
|
||||||
|
|
||||||
|
let segments = ["500 Immobilien", "501 Kreuzweg 2", "501.01 Rechtliches"];
|
||||||
|
let payload = EnsureFolderPath {
|
||||||
|
parent_id: None,
|
||||||
|
segments: &segments,
|
||||||
|
};
|
||||||
|
|
||||||
|
let first_resp = app
|
||||||
|
.post_json("/api/folders/path", &payload, Some(&token))
|
||||||
|
.await?;
|
||||||
|
assert!(first_resp.status().is_success());
|
||||||
|
let first_body = body_to_vec(first_resp.into_body()).await?;
|
||||||
|
let first_folder: FolderResponse = serde_json::from_slice(&first_body)?;
|
||||||
|
|
||||||
|
let second_resp = app
|
||||||
|
.post_json("/api/folders/path", &payload, Some(&token))
|
||||||
|
.await?;
|
||||||
|
assert!(second_resp.status().is_success());
|
||||||
|
let second_body = body_to_vec(second_resp.into_body()).await?;
|
||||||
|
let second_folder: FolderResponse = serde_json::from_slice(&second_body)?;
|
||||||
|
|
||||||
|
assert_eq!(first_folder.folder.id, second_folder.folder.id);
|
||||||
|
|
||||||
|
// Verify intermediate folders are not duplicated
|
||||||
|
let root_contents = app.get("/api/folders/root/contents", Some(&token)).await?;
|
||||||
|
let root_body = body_to_vec(root_contents.into_body()).await?;
|
||||||
|
let root: FolderContents = serde_json::from_slice(&root_body)?;
|
||||||
|
let root_occurrences = root
|
||||||
|
.subfolders
|
||||||
|
.iter()
|
||||||
|
.filter(|folder| folder.name == segments[0])
|
||||||
|
.count();
|
||||||
|
assert_eq!(root_occurrences, 1);
|
||||||
|
|
||||||
|
let level_one = root
|
||||||
|
.subfolders
|
||||||
|
.iter()
|
||||||
|
.find(|folder| folder.name == segments[0])
|
||||||
|
.map(|folder| folder.id)
|
||||||
|
.expect("root segment not created");
|
||||||
|
|
||||||
|
let level_one_contents = app
|
||||||
|
.get(
|
||||||
|
&format!("/api/folders/{}/contents", level_one),
|
||||||
|
Some(&token),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let level_one_body = body_to_vec(level_one_contents.into_body()).await?;
|
||||||
|
let level_one_folders: FolderContents = serde_json::from_slice(&level_one_body)?;
|
||||||
|
let level_one_occurrences = level_one_folders
|
||||||
|
.subfolders
|
||||||
|
.iter()
|
||||||
|
.filter(|folder| folder.name == segments[1])
|
||||||
|
.count();
|
||||||
|
assert_eq!(level_one_occurrences, 1);
|
||||||
|
|
||||||
|
app.cleanup().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn folder_rename_updates_name_and_child_paths() -> Result<()> {
|
async fn folder_rename_updates_name_and_child_paths() -> Result<()> {
|
||||||
let _lock = acquire_db_lock().await;
|
let _lock = acquire_db_lock().await;
|
||||||
|
|||||||
+103
-7
@@ -2,11 +2,23 @@ mod common;
|
|||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use common::{acquire_db_lock, body_to_vec, TestApp};
|
use backend::models::{NewUser, NewUserMembership, Tag};
|
||||||
|
use backend::schema::{
|
||||||
|
tags::dsl as tags_dsl, tenants::dsl as tenants_dsl, user_memberships::dsl as memberships_dsl,
|
||||||
|
users::dsl as users_dsl,
|
||||||
|
};
|
||||||
|
use common::{acquire_db_lock, body_to_vec, hash_password, TestApp};
|
||||||
|
use diesel::prelude::*;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct CreateTagPayload<'a> {
|
||||||
|
label: &'a str,
|
||||||
|
color: Option<&'a str>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct DocumentDetail {
|
struct DocumentDetail {
|
||||||
document: DocumentInfo,
|
document: DocumentInfo,
|
||||||
@@ -61,12 +73,6 @@ async fn tag_assignment_flow() -> Result<()> {
|
|||||||
let upload_body = body_to_vec(upload.into_body()).await?;
|
let upload_body = body_to_vec(upload.into_body()).await?;
|
||||||
let detail: DocumentDetail = serde_json::from_slice(&upload_body)?;
|
let detail: DocumentDetail = serde_json::from_slice(&upload_body)?;
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct CreateTagPayload<'a> {
|
|
||||||
label: &'a str,
|
|
||||||
color: Option<&'a str>,
|
|
||||||
}
|
|
||||||
|
|
||||||
let create_tag = app
|
let create_tag = app
|
||||||
.post_json(
|
.post_json(
|
||||||
"/api/tags",
|
"/api/tags",
|
||||||
@@ -172,3 +178,93 @@ async fn tag_assignment_flow() -> Result<()> {
|
|||||||
app.cleanup().await?;
|
app.cleanup().await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn tags_are_isolated_between_tenants() -> Result<()> {
|
||||||
|
let _lock = acquire_db_lock().await;
|
||||||
|
let app = TestApp::new().await?;
|
||||||
|
|
||||||
|
let password_a = "tenant-a";
|
||||||
|
app.insert_user("alice", password_a, "admin").await?;
|
||||||
|
let token_a = app.login_token("alice", password_a).await?;
|
||||||
|
|
||||||
|
let shared_label = "Shared Label";
|
||||||
|
|
||||||
|
let create_a = app
|
||||||
|
.post_json(
|
||||||
|
"/api/tags",
|
||||||
|
&CreateTagPayload {
|
||||||
|
label: shared_label,
|
||||||
|
color: Some("#123456"),
|
||||||
|
},
|
||||||
|
Some(&token_a),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(create_a.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let tenant_b_id = Uuid::new_v4();
|
||||||
|
let user_b_id = Uuid::new_v4();
|
||||||
|
let password_b = "tenant-b";
|
||||||
|
|
||||||
|
app.with_conn(move |conn| {
|
||||||
|
let storage_root = format!("test-tenants/{tenant_b_id}/");
|
||||||
|
diesel::insert_into(tenants_dsl::tenants)
|
||||||
|
.values((
|
||||||
|
tenants_dsl::id.eq(tenant_b_id),
|
||||||
|
tenants_dsl::slug.eq("tenant-b"),
|
||||||
|
tenants_dsl::storage_root.eq(Some(storage_root)),
|
||||||
|
))
|
||||||
|
.execute(conn)?;
|
||||||
|
|
||||||
|
let password_hash = hash_password(password_b)?;
|
||||||
|
let new_user = NewUser {
|
||||||
|
id: user_b_id,
|
||||||
|
username: "bob".to_string(),
|
||||||
|
password_hash,
|
||||||
|
};
|
||||||
|
diesel::insert_into(users_dsl::users)
|
||||||
|
.values(&new_user)
|
||||||
|
.execute(conn)?;
|
||||||
|
|
||||||
|
let membership = NewUserMembership {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
user_id: user_b_id,
|
||||||
|
tenant_id: tenant_b_id,
|
||||||
|
role: "admin".to_string(),
|
||||||
|
};
|
||||||
|
diesel::insert_into(memberships_dsl::user_memberships)
|
||||||
|
.values(&membership)
|
||||||
|
.execute(conn)?;
|
||||||
|
|
||||||
|
Ok::<_, anyhow::Error>(())
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let token_b = app.login_token("bob", password_b).await?;
|
||||||
|
|
||||||
|
let create_b = app
|
||||||
|
.post_json(
|
||||||
|
"/api/tags",
|
||||||
|
&CreateTagPayload {
|
||||||
|
label: shared_label,
|
||||||
|
color: Some("#654321"),
|
||||||
|
},
|
||||||
|
Some(&token_b),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(create_b.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
app.with_conn(move |conn| {
|
||||||
|
let tags: Vec<Tag> = tags_dsl::tags
|
||||||
|
.filter(tags_dsl::label.eq(shared_label))
|
||||||
|
.order(tags_dsl::tenant_id.asc())
|
||||||
|
.load(conn)?;
|
||||||
|
|
||||||
|
assert_eq!(tags.len(), 2);
|
||||||
|
assert_ne!(tags[0].tenant_id, tags[1].tenant_id);
|
||||||
|
Ok::<_, anyhow::Error>(())
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
mod common;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::http::{header, Method, Request, StatusCode};
|
||||||
|
use backend::models::WebdavToken;
|
||||||
|
use backend::routes::webdav;
|
||||||
|
use backend::schema::webdav_tokens;
|
||||||
|
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||||
|
use base64::Engine;
|
||||||
|
use common::{acquire_db_lock, body_to_vec, TestApp};
|
||||||
|
use diesel::prelude::*;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::json;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct TokenInfo {
|
||||||
|
id: Uuid,
|
||||||
|
label: Option<String>,
|
||||||
|
last_used_at: Option<String>,
|
||||||
|
revoked_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct CreateTokenResponse {
|
||||||
|
token: String,
|
||||||
|
#[serde(rename = "token_info")]
|
||||||
|
info: TokenInfo,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn webdav_token_api_crud() -> Result<()> {
|
||||||
|
let _guard = acquire_db_lock().await;
|
||||||
|
let app = TestApp::new().await?;
|
||||||
|
|
||||||
|
let username = "alice";
|
||||||
|
let password = "correct horse battery";
|
||||||
|
app.insert_user(username, password, "admin").await?;
|
||||||
|
let access_token = app.login_token(username, password).await?;
|
||||||
|
|
||||||
|
let create_response = app
|
||||||
|
.post_json(
|
||||||
|
"/api/profile/webdav-tokens",
|
||||||
|
&json!({ "label": "dav" }),
|
||||||
|
Some(&access_token),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(create_response.status(), StatusCode::CREATED);
|
||||||
|
let create_body = body_to_vec(create_response.into_body()).await?;
|
||||||
|
let created: CreateTokenResponse = serde_json::from_slice(&create_body)?;
|
||||||
|
let token_id = created.info.id;
|
||||||
|
assert_eq!(created.info.label.as_deref(), Some("dav"));
|
||||||
|
assert!(created.info.last_used_at.is_none());
|
||||||
|
|
||||||
|
let list_response = app
|
||||||
|
.get("/api/profile/webdav-tokens", Some(&access_token))
|
||||||
|
.await?;
|
||||||
|
assert_eq!(list_response.status(), StatusCode::OK);
|
||||||
|
let list_body = body_to_vec(list_response.into_body()).await?;
|
||||||
|
let listed: Vec<TokenInfo> = serde_json::from_slice(&list_body)?;
|
||||||
|
assert_eq!(listed.len(), 1);
|
||||||
|
assert_eq!(listed[0].id, token_id);
|
||||||
|
|
||||||
|
let delete_response = app
|
||||||
|
.delete(
|
||||||
|
&format!("/api/profile/webdav-tokens/{token_id}"),
|
||||||
|
Some(&access_token),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(delete_response.status(), StatusCode::NO_CONTENT);
|
||||||
|
|
||||||
|
let list_after = app
|
||||||
|
.get("/api/profile/webdav-tokens", Some(&access_token))
|
||||||
|
.await?;
|
||||||
|
let list_after_body = body_to_vec(list_after.into_body()).await?;
|
||||||
|
let listed_after: Vec<TokenInfo> = serde_json::from_slice(&list_after_body)?;
|
||||||
|
assert_eq!(listed_after.len(), 1);
|
||||||
|
assert_eq!(listed_after[0].id, token_id);
|
||||||
|
assert!(listed_after[0].revoked_at.is_some());
|
||||||
|
|
||||||
|
app.cleanup().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn webdav_basic_auth_uses_tokens() -> Result<()> {
|
||||||
|
let _guard = acquire_db_lock().await;
|
||||||
|
let app = TestApp::new().await?;
|
||||||
|
|
||||||
|
let username = "bruce";
|
||||||
|
let password = "wayne";
|
||||||
|
app.insert_user(username, password, "admin").await?;
|
||||||
|
let access_token = app.login_token(username, password).await?;
|
||||||
|
|
||||||
|
let create_response = app
|
||||||
|
.post_json(
|
||||||
|
"/api/profile/webdav-tokens",
|
||||||
|
&json!({ "label": "webdav" }),
|
||||||
|
Some(&access_token),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let create_body = body_to_vec(create_response.into_body()).await?;
|
||||||
|
let created: CreateTokenResponse = serde_json::from_slice(&create_body)?;
|
||||||
|
let token_id = created.info.id;
|
||||||
|
|
||||||
|
let router = webdav::create_router().with_state(app.state.clone());
|
||||||
|
let auth_header = format!(
|
||||||
|
"Basic {}",
|
||||||
|
BASE64.encode(format!("{}:{}", username, created.token))
|
||||||
|
);
|
||||||
|
|
||||||
|
let propfind = Method::from_bytes(b"PROPFIND")?;
|
||||||
|
let success_request = Request::builder()
|
||||||
|
.method(propfind.clone())
|
||||||
|
.uri("/")
|
||||||
|
.header(header::AUTHORIZATION, auth_header.clone())
|
||||||
|
.header("depth", "0")
|
||||||
|
.body(Body::empty())?;
|
||||||
|
let response = router.clone().oneshot(success_request).await?;
|
||||||
|
assert_eq!(response.status(), StatusCode::MULTI_STATUS);
|
||||||
|
|
||||||
|
let used = app
|
||||||
|
.with_conn(move |conn| {
|
||||||
|
let record = webdav_tokens::table
|
||||||
|
.find(token_id)
|
||||||
|
.first::<WebdavToken>(conn)?;
|
||||||
|
Ok::<_, anyhow::Error>(record.last_used_at)
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
assert!(used.is_some());
|
||||||
|
|
||||||
|
let delete_response = app
|
||||||
|
.delete(
|
||||||
|
&format!("/api/profile/webdav-tokens/{token_id}"),
|
||||||
|
Some(&access_token),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(delete_response.status(), StatusCode::NO_CONTENT);
|
||||||
|
|
||||||
|
let failure_request = Request::builder()
|
||||||
|
.method(propfind)
|
||||||
|
.uri("/")
|
||||||
|
.header(header::AUTHORIZATION, auth_header)
|
||||||
|
.header("depth", "0")
|
||||||
|
.body(Body::empty())?;
|
||||||
|
let response = router.oneshot(failure_request).await?;
|
||||||
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
|
||||||
|
app.cleanup().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
+11
-9
@@ -5,8 +5,8 @@ Unless noted otherwise, endpoints below require a valid `Authorization: Bearer <
|
|||||||
|
|
||||||
Authentication
|
Authentication
|
||||||
--------------
|
--------------
|
||||||
- POST /api/auth/login - Exchange username/password for an access token and refresh cookie (public).
|
- POST /api/auth/login - Exchange username/password for an access token and refresh cookie (public). Returns the active tenant as `{ tenant: { id, slug } }`. When multiple tenants are available, the response contains an `access_token` (tenant-selector token) and tenant list instead.
|
||||||
- POST /api/auth/refresh - Rotate the refresh cookie and return a new access token (public, requires refresh cookie).
|
- POST /api/auth/refresh - Rotate the refresh cookie and return a new access token (public, requires refresh cookie). Response also includes the current tenant `{ tenant: { id, slug } }`.
|
||||||
- POST /api/auth/logout - Revoke the caller's refresh tokens and clear the cookie.
|
- POST /api/auth/logout - Revoke the caller's refresh tokens and clear the cookie.
|
||||||
- GET /api/auth/me - Return the authenticated principal payload.
|
- GET /api/auth/me - Return the authenticated principal payload.
|
||||||
|
|
||||||
@@ -16,22 +16,24 @@ Health
|
|||||||
|
|
||||||
Documents
|
Documents
|
||||||
---------
|
---------
|
||||||
- GET /api/documents - List or search documents. Optional filters: `folder_id` (defaults to root when omitted), `include_deleted`, `include_descendants` (defaults to true when a `folder_id` is provided and no other override is supplied), `query` (Quickwit full-text), `tags` (comma-separated tag UUIDs), and `correspondents` (comma-separated correspondent UUIDs). Each entry includes tags, correspondent assignments, and current version info.
|
- 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/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.
|
- GET /api/documents/check?checksum=<sha256> - Lightweight checksum preflight. Returns `exists=false` when no document with the supplied SHA-256 checksum is present; otherwise returns `exists=true` plus the current document metadata.
|
||||||
- POST /api/documents - Upload a document via multipart form-data. Required field: `file`. Optional fields: `title`, `folder_id`, JSON `metadata`, JSON array `tag_ids`, JSON array `correspondents` (each with `correspondent_id` and `role`), and `issued_at` (RFC3339). When `title` is supplied, the stored filename becomes `<title><original_extension>`. Include `skip_existing=true` to receive `204 No Content` instead of reusing a matching document.
|
- POST /api/documents - 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/bulk/move - Move multiple documents to a target folder.
|
- 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/tags - Add or remove tags across multiple documents.
|
||||||
- POST /api/documents/bulk/correspondents - Bulk correspondent actions. Default `action=add` replaces existing assignments for the provided roles before adding the supplied correspondents; `action=remove` drops the specified correspondent/role pairs.
|
- POST /api/documents/bulk/correspondents - Bulk correspondent actions. Use `action=add` (default) to attach correspondents or `action=remove` to detach the provided correspondents.
|
||||||
- POST /api/documents/bulk/reanalyze - Queue re-analysis jobs for selected documents.
|
- 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.
|
- GET /api/documents/:id - Retrieve metadata and current version details for a document.
|
||||||
- PATCH /api/documents/:id - Update document metadata (currently title).
|
- PATCH /api/documents/:id - Update document metadata (currently title).
|
||||||
- DELETE /api/documents/:id - Soft-delete a document.
|
- 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.
|
- 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.
|
- 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.
|
- DELETE /api/documents/:id/tags/:tag_id - Remove a single tag from a document.
|
||||||
- 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`.
|
- 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 (requires `role` query string).
|
- DELETE /api/documents/:id/correspondents/:correspondent_id - Remove a correspondent assignment.
|
||||||
|
|
||||||
Document Assets
|
Document Assets
|
||||||
---------------
|
---------------
|
||||||
@@ -62,7 +64,7 @@ Tags
|
|||||||
|
|
||||||
Correspondents
|
Correspondents
|
||||||
--------------
|
--------------
|
||||||
- GET /api/correspondents - List correspondents with usage totals and per-role counts (roles: `sender`, `receiver`, `other`).
|
- GET /api/correspondents - List correspondents with usage totals.
|
||||||
- POST /api/correspondents - Create a correspondent (name + optional metadata JSON).
|
- POST /api/correspondents - Create a correspondent (name + optional metadata JSON).
|
||||||
- PATCH /api/correspondents/:id - Update name and/or metadata.
|
- PATCH /api/correspondents/:id - Update name and/or metadata.
|
||||||
- DELETE /api/correspondents/:id - Remove a correspondent; fails with 400 if referenced by any document.
|
- DELETE /api/correspondents/:id - Remove a correspondent; fails with 400 if referenced by any document.
|
||||||
|
|||||||
+1
-2
@@ -12,10 +12,9 @@ RUN npm run build
|
|||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
WORKDIR /usr/share/nginx/html
|
WORKDIR /usr/share/nginx/html
|
||||||
|
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
|
||||||
COPY --from=build /app/dist ./
|
COPY --from=build /app/dist ./
|
||||||
|
|
||||||
ENV API_BASE_URL=""
|
ENV API_PROXY_PASS=""
|
||||||
|
|
||||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||||
RUN chmod +x /docker-entrypoint.sh
|
RUN chmod +x /docker-entrypoint.sh
|
||||||
|
|||||||
@@ -1,11 +1,37 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
API_BASE_URL_TRIMMED="${API_BASE_URL:-}"
|
API_PROXY_PASS_TRIMMED="${API_PROXY_PASS:-}"
|
||||||
API_BASE_URL_TRIMMED="${API_BASE_URL_TRIMMED%%/}"
|
API_PROXY_PASS_TRIMMED="${API_PROXY_PASS_TRIMMED%%/}"
|
||||||
|
|
||||||
cat <<CONFIG > /usr/share/nginx/html/config.js
|
cat <<'BASE' > /etc/nginx/conf.d/default.conf
|
||||||
window.__PAPERCRATE_API_BASE_URL = "${API_BASE_URL_TRIMMED}";
|
server {
|
||||||
CONFIG
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri /index.html;
|
||||||
|
}
|
||||||
|
BASE
|
||||||
|
|
||||||
|
if [ -n "$API_PROXY_PASS_TRIMMED" ]; then
|
||||||
|
cat <<PROXY >> /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass ${API_PROXY_PASS_TRIMMED};
|
||||||
|
proxy_set_header Host \$host;
|
||||||
|
proxy_set_header X-Real-IP \$remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||||
|
}
|
||||||
|
PROXY
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat <<'ENDCFG' >> /etc/nginx/conf.d/default.conf
|
||||||
|
}
|
||||||
|
ENDCFG
|
||||||
|
|
||||||
exec "$@"
|
exec "$@"
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name _;
|
|
||||||
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
try_files $uri /index.html;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Generated
+10
@@ -8,6 +8,7 @@
|
|||||||
"name": "papercrate-frontend",
|
"name": "papercrate-frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource/inter": "^5.2.8",
|
||||||
"@tabler/icons-react": "3.11.0",
|
"@tabler/icons-react": "3.11.0",
|
||||||
"axios": "1.7.7",
|
"axios": "1.7.7",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
@@ -1852,6 +1853,15 @@
|
|||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fontsource/inter": {
|
||||||
|
"version": "5.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz",
|
||||||
|
"integrity": "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg==",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@jridgewell/gen-mapping": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.13",
|
"version": "0.3.13",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"lint": "echo \"No linting configured\""
|
"lint": "echo \"No linting configured\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource/inter": "^5.2.8",
|
||||||
"@tabler/icons-react": "3.11.0",
|
"@tabler/icons-react": "3.11.0",
|
||||||
"axios": "1.7.7",
|
"axios": "1.7.7",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
@@ -19,6 +20,7 @@
|
|||||||
"@babel/core": "7.26.0",
|
"@babel/core": "7.26.0",
|
||||||
"@babel/preset-env": "7.26.0",
|
"@babel/preset-env": "7.26.0",
|
||||||
"@babel/preset-react": "7.26.3",
|
"@babel/preset-react": "7.26.3",
|
||||||
|
"@svgr/webpack": "8.1.0",
|
||||||
"babel-loader": "9.2.1",
|
"babel-loader": "9.2.1",
|
||||||
"css-loader": "7.1.2",
|
"css-loader": "7.1.2",
|
||||||
"dotenv": "16.4.5",
|
"dotenv": "16.4.5",
|
||||||
@@ -26,7 +28,6 @@
|
|||||||
"style-loader": "4.0.0",
|
"style-loader": "4.0.0",
|
||||||
"webpack": "5.95.0",
|
"webpack": "5.95.0",
|
||||||
"webpack-cli": "5.1.4",
|
"webpack-cli": "5.1.4",
|
||||||
"webpack-dev-server": "5.1.0",
|
"webpack-dev-server": "5.1.0"
|
||||||
"@svgr/webpack": "8.1.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
window.__PAPERCRATE_API_BASE_URL = window.__PAPERCRATE_API_BASE_URL || '';
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
/* Skeuomorphic workspace styles */
|
/* Desktop workspace styles */
|
||||||
.skeuo-main {
|
.skeuo-main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -6,6 +6,18 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.skeuo-item {
|
||||||
|
position: absolute;
|
||||||
|
display: block;
|
||||||
|
width: auto;
|
||||||
|
cursor: grab;
|
||||||
|
touch-action: none;
|
||||||
|
transform-origin: center center;
|
||||||
|
transition: box-shadow 0.16s ease;
|
||||||
|
outline: none;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
.skeuo-shell {
|
.skeuo-shell {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -13,57 +25,6 @@
|
|||||||
grid-column: 2 / -1;
|
grid-column: 2 / -1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
background-color: var(--surface-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeuo-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
padding: 0.75rem 1rem 0.5rem;
|
|
||||||
background-color: var(--surface-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeuo-header__meta {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeuo-header__meta h2 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeuo-breadcrumbs {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.35rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeuo-crumb {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeuo-crumb.is-current {
|
|
||||||
color: var(--fg);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeuo-crumb-separator {
|
|
||||||
opacity: 0.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeuo-header__actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeuo-canvas {
|
.skeuo-canvas {
|
||||||
@@ -84,29 +45,12 @@
|
|||||||
font-size: 0.95rem;
|
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 {
|
.skeuo-item__body {
|
||||||
position: absolute;
|
flex-grow: 1;
|
||||||
top: 50%;
|
width: 100%;
|
||||||
left: 50%;
|
height: 100%;
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
transition: width 0.28s ease, height 0.28s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeuo-item:focus-visible {
|
.skeuo-item:focus-visible {
|
||||||
@@ -119,13 +63,9 @@
|
|||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeuo-item.is-zoomed {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeuo-item.is-tag-target .skeuo-item__card {
|
.skeuo-item.is-tag-target .skeuo-item__card {
|
||||||
outline: 1em dashed var(--accent);
|
outline: 0.35rem dashed var(--accent);
|
||||||
outline-offset: 1.41em;
|
outline-offset: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeuo-item.is-tag-pending .skeuo-item__card {
|
.skeuo-item.is-tag-pending .skeuo-item__card {
|
||||||
@@ -154,50 +94,39 @@
|
|||||||
transition: transform 0.28s ease;
|
transition: transform 0.28s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.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 {
|
.tag-chip--draggable {
|
||||||
font-size: 0.75rem;
|
user-select: none;
|
||||||
padding: 0.18rem 0.55rem;
|
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
transition: transform 0.16s ease, opacity 0.2s ease;
|
transition: transform 0.16s ease, opacity 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
box-shadow: 2px 2px 4px color-mix(in oklch, black 18%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeuo-tag.is-drag-hidden {
|
.tag-chip--draggable:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-chip--draggable.is-drag-hidden {
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeuo-tag span {
|
.skeuo-item__tags .tag-chip {
|
||||||
display: block;
|
font-size: 0.85rem;
|
||||||
pointer-events: none;
|
padding: 0.18rem 0.55rem;
|
||||||
user-select: none;
|
gap: 0.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.skeuo-card__nav {
|
.skeuo-card__nav {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: calc(3em * 0.707 * var(--nav-scale));
|
bottom: 1.8rem;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%) scale(calc(0.707 * var(--nav-scale, 1)));
|
transform: translateX(-50%);
|
||||||
transform-origin: center;
|
transform-origin: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 2rem;
|
gap: 1.5rem;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transition: opacity 0.2s ease;
|
transition: opacity 0.2s ease;
|
||||||
@@ -212,19 +141,19 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 3.3em;
|
width: 2.4em;
|
||||||
height: 3.3em;
|
height: 2.4em;
|
||||||
padding: 0.36em;
|
padding: 0.25em;
|
||||||
border-radius: 3.3em;
|
border-radius: 50%;
|
||||||
border: none;
|
border: none;
|
||||||
background: rgba(0, 0, 0, 0.55);
|
background: oklch(0.22 0.06 260deg);
|
||||||
color: #fff;
|
color: var(--on-accent);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s ease, opacity 0.15s ease;
|
transition: background 0.15s ease, opacity 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeuo-card__nav-button:hover:not([disabled]) {
|
.skeuo-card__nav-button:hover:not([disabled]) {
|
||||||
background: rgba(0, 0, 0, 0.75);
|
background: oklch(0.18 0.06 260deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeuo-card__nav-button:disabled {
|
.skeuo-card__nav-button:disabled {
|
||||||
@@ -242,8 +171,8 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeuo-item__tags .skeuo-tag.is-tear-pending {
|
.skeuo-item__tags .tag-chip--tear-pending {
|
||||||
opacity: 0.4;
|
opacity: 0.35;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.skeuo-cursor-remove,
|
body.skeuo-cursor-remove,
|
||||||
@@ -261,15 +190,23 @@ body.skeuo-cursor-remove * {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
box-shadow: 0 24px 72px rgba(0, 0, 0, 0.24);
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
box-shadow: 0 12px 32px color-mix(in oklch, black 18%, transparent);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
--nav-scale: 1;
|
}
|
||||||
|
|
||||||
|
.skeuo-item__card img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeuo-item__card--empty {
|
.skeuo-item__card--empty {
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 28% 24%, rgba(255, 255, 255, 0.32), transparent 60%),
|
radial-gradient(circle at 28% 24%, color-mix(in oklch, white 32%, transparent), transparent 60%),
|
||||||
radial-gradient(circle at 72% 78%, rgba(0, 0, 0, 0.08), transparent 65%),
|
radial-gradient(circle at 72% 78%, color-mix(in oklch, black 8%, transparent), transparent 65%),
|
||||||
linear-gradient(135deg, #e6e1d6 0%, #d2cdc2 100%);
|
linear-gradient(135deg, #e6e1d6 0%, #d2cdc2 100%);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -305,6 +242,7 @@ body.skeuo-cursor-remove * {
|
|||||||
color: #3b3b3b;
|
color: #3b3b3b;
|
||||||
max-width: 90%;
|
max-width: 90%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
overflow-wrap: anywhere;
|
||||||
white-space: nowrap;
|
word-break: break-word;
|
||||||
|
white-space: normal;
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +0,0 @@
|
|||||||
export const CORRESPONDENT_ROLES = ['sender', 'receiver', 'other'];
|
|
||||||
|
|
||||||
export default CORRESPONDENT_ROLES;
|
|
||||||
@@ -107,22 +107,15 @@ function CorrespondentsPanel({
|
|||||||
return '0';
|
return '0';
|
||||||
}
|
}
|
||||||
const total = typeof usage.total === 'number' ? usage.total : 0;
|
const total = typeof usage.total === 'number' ? usage.total : 0;
|
||||||
const entries = usage.by_role ? Object.entries(usage.by_role) : [];
|
return total.toString();
|
||||||
if (!entries.length) {
|
|
||||||
return total.toString();
|
|
||||||
}
|
|
||||||
const roleSummary = entries
|
|
||||||
.map(([role, count]) => `${role}: ${count}`)
|
|
||||||
.join(', ');
|
|
||||||
return `${total} (${roleSummary})`;
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="correspondents-panel column">
|
<section className="correspondents-panel">
|
||||||
<div className="column-header">
|
<div className="panel-section__header">
|
||||||
<div className="column-header__titles">
|
<div className="panel-section__titles">
|
||||||
<h2>Correspondents</h2>
|
<h2>Correspondents</h2>
|
||||||
<div className="column-subtitle">{correspondents.length} total</div>
|
<div className="panel-section__subtitle">{correspondents.length} total</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="header-actions correspondents-actions">
|
<div className="header-actions correspondents-actions">
|
||||||
<form className="correspondents-actions__form" onSubmit={handleCreate}>
|
<form className="correspondents-actions__form" onSubmit={handleCreate}>
|
||||||
@@ -147,7 +140,7 @@ function CorrespondentsPanel({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="column-body tags-panel__body">
|
<div className="panel-section__body tags-panel__body">
|
||||||
{correspondents.length === 0 ? (
|
{correspondents.length === 0 ? (
|
||||||
<div className="empty-state">No correspondents created yet.</div>
|
<div className="empty-state">No correspondents created yet.</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
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
|
||||||
|
) {}
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
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})`;
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
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;
|
||||||
+429
-137
@@ -1,16 +1,23 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { DownloadIcon, EditIcon, ArrowLeftIcon, ArrowRightIcon } from '../ui/icons';
|
import {
|
||||||
|
DownloadIcon,
|
||||||
|
EditIcon,
|
||||||
|
ArrowLeftIcon,
|
||||||
|
ArrowRightIcon,
|
||||||
|
ChevronsRightIcon,
|
||||||
|
AnalyzeIcon,
|
||||||
|
WindowMaximizeIcon,
|
||||||
|
TextScanIcon,
|
||||||
|
} from '../ui/icons';
|
||||||
import { getTagColorStyle } from '../utils/colors';
|
import { getTagColorStyle } from '../utils/colors';
|
||||||
import { formatFileSize } from '../utils/format';
|
import { formatFileSize } from '../utils/format';
|
||||||
import { resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
|
import { resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
|
||||||
import { useAssetNavigator } from '../hooks/useAssetNavigator';
|
import { useAssetNavigator } from '../hooks/useAssetNavigator';
|
||||||
import { CORRESPONDENT_ROLES } from '../constants/correspondents';
|
import PreviewZoomOverlay from './PreviewZoomOverlay';
|
||||||
|
|
||||||
const MAX_PREVIEW_STACK_ITEMS = 15;
|
const MAX_PREVIEW_STACK_ITEMS = 15;
|
||||||
|
|
||||||
const normalizeRole = (role) => (role || '').toLowerCase();
|
|
||||||
|
|
||||||
const derivePreviewOrientation = (metadata) => {
|
const derivePreviewOrientation = (metadata) => {
|
||||||
const width = Number(metadata?.width);
|
const width = Number(metadata?.width);
|
||||||
const height = Number(metadata?.height);
|
const height = Number(metadata?.height);
|
||||||
@@ -20,56 +27,41 @@ const derivePreviewOrientation = (metadata) => {
|
|||||||
return 'landscape';
|
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 = []) =>
|
const sortCorrespondents = (entries = []) =>
|
||||||
entries
|
entries
|
||||||
.map((entry) => ({
|
.map((entry) => ({
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
name: entry.name || '',
|
name: entry.name || '',
|
||||||
role: normalizeRole(entry.role),
|
count: entry.count,
|
||||||
}))
|
}))
|
||||||
.sort(compareCorrespondents);
|
.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||||
|
|
||||||
const CorrespondentPills = ({ entries = [], onRemove, showCount = false }) => (
|
const CorrespondentPills = ({ entries = [], onRemove, showCount = false }) => (
|
||||||
<div className="correspondent-list">
|
<div className="correspondent-list">
|
||||||
{entries.length ? (
|
{entries.length ? (
|
||||||
entries.map((entry) => (
|
entries.map((entry, index) => {
|
||||||
<span key={`${entry.id}:${entry.role}`} className="correspondent-pill">
|
const key = entry.id ?? `${entry.name}-${index}`;
|
||||||
<span className="correspondent-pill__label">
|
return (
|
||||||
<strong>{formatRoleLabel(entry.role)}</strong>
|
<span key={key} className="correspondent-pill">
|
||||||
<span>
|
<span className="correspondent-pill__label">
|
||||||
{entry.name}
|
<span>
|
||||||
{showCount && entry.count ? ` (${entry.count})` : ''}
|
{entry.name}
|
||||||
|
{showCount && entry.count ? ` (${entry.count})` : ''}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
{onRemove ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="correspondent-pill__remove"
|
||||||
|
onClick={() => onRemove(entry)}
|
||||||
|
aria-label={`Remove ${entry.name}`}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</span>
|
</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>
|
<span className="meta">No correspondents yet.</span>
|
||||||
)}
|
)}
|
||||||
@@ -95,11 +87,18 @@ const TagSection = ({
|
|||||||
tags.map((tag) => {
|
tags.map((tag) => {
|
||||||
const key = tag.id ?? tag.label;
|
const key = tag.id ?? tag.label;
|
||||||
const style = getTagColorStyle(tag.color);
|
const style = getTagColorStyle(tag.color);
|
||||||
|
const removable = Boolean(onRemove);
|
||||||
|
const className = removable ? 'badge tag-chip tag-chip--removable' : 'badge tag-chip';
|
||||||
return (
|
return (
|
||||||
<span key={key} className="tag-pill" style={style || undefined}>
|
<span key={key} className={className} style={style || undefined}>
|
||||||
{tag.label}{' '}
|
<span className="tag-chip__label">{tag.label}</span>
|
||||||
{onRemove ? (
|
{removable ? (
|
||||||
<button type="button" onClick={() => onRemove(tag)}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tag-chip__remove"
|
||||||
|
onClick={() => onRemove(tag)}
|
||||||
|
aria-label={`Remove tag ${tag.label}`}
|
||||||
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -157,11 +156,9 @@ const CorrespondentSection = ({
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const form = event.currentTarget;
|
const form = event.currentTarget;
|
||||||
const nameInput = form.elements.correspondent;
|
const nameInput = form.elements.correspondent;
|
||||||
const roleSelect = form.elements.role;
|
|
||||||
const value = nameInput.value.trim();
|
const value = nameInput.value.trim();
|
||||||
const role = roleSelect.value;
|
|
||||||
if (!value) return;
|
if (!value) return;
|
||||||
onAdd({ name: value, role, input: nameInput });
|
onAdd({ name: value, input: nameInput });
|
||||||
form.reset();
|
form.reset();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -170,13 +167,6 @@ const CorrespondentSection = ({
|
|||||||
placeholder={addPlaceholder}
|
placeholder={addPlaceholder}
|
||||||
list={datalistId}
|
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>
|
<button type="submit">{addButtonLabel}</button>
|
||||||
{datalistId ? (
|
{datalistId ? (
|
||||||
<datalist id={datalistId}>
|
<datalist id={datalistId}>
|
||||||
@@ -208,6 +198,7 @@ const PreviewStack = ({
|
|||||||
emptyMessage = 'Preview unavailable',
|
emptyMessage = 'Preview unavailable',
|
||||||
onItemActivate,
|
onItemActivate,
|
||||||
onOpenPreview,
|
onOpenPreview,
|
||||||
|
onZoomPreview,
|
||||||
activeItemId = null,
|
activeItemId = null,
|
||||||
}) => {
|
}) => {
|
||||||
if (!items.length) {
|
if (!items.length) {
|
||||||
@@ -240,7 +231,11 @@ const PreviewStack = ({
|
|||||||
zIndex: preparedItems.length - index,
|
zIndex: preparedItems.length - index,
|
||||||
transform,
|
transform,
|
||||||
}}
|
}}
|
||||||
aria-hidden={hasMultiple && !onItemActivate && !onOpenPreview ? 'true' : undefined}
|
aria-hidden={
|
||||||
|
hasMultiple && !onItemActivate && !onOpenPreview && !onZoomPreview
|
||||||
|
? 'true'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={entry.url}
|
src={entry.url}
|
||||||
@@ -248,19 +243,31 @@ const PreviewStack = ({
|
|||||||
className="preview-stack__image"
|
className="preview-stack__image"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (isFront && onOpenPreview) {
|
if (isFront) {
|
||||||
onOpenPreview(entry.id);
|
if (onZoomPreview) {
|
||||||
|
onZoomPreview(entry);
|
||||||
|
} else if (onOpenPreview) {
|
||||||
|
onOpenPreview(entry.id);
|
||||||
|
} else if (onItemActivate) {
|
||||||
|
onItemActivate(entry.id);
|
||||||
|
}
|
||||||
} else if (onItemActivate) {
|
} else if (onItemActivate) {
|
||||||
onItemActivate(entry.id);
|
onItemActivate(entry.id);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (!onItemActivate && !onOpenPreview) return;
|
if (!onItemActivate && !onOpenPreview && !onZoomPreview) return;
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (isFront && onOpenPreview) {
|
if (isFront) {
|
||||||
onOpenPreview(entry.id);
|
if (onZoomPreview) {
|
||||||
|
onZoomPreview(entry);
|
||||||
|
} else if (onOpenPreview) {
|
||||||
|
onOpenPreview(entry.id);
|
||||||
|
} else {
|
||||||
|
onItemActivate?.(entry.id);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
onItemActivate?.(entry.id);
|
onItemActivate?.(entry.id);
|
||||||
}
|
}
|
||||||
@@ -297,9 +304,26 @@ const DetailPanel = ({
|
|||||||
onCorrespondentAdd,
|
onCorrespondentAdd,
|
||||||
onCorrespondentRemove,
|
onCorrespondentRemove,
|
||||||
resolveApiPath,
|
resolveApiPath,
|
||||||
|
onFolderNavigate = null,
|
||||||
|
resolveFolderPath = null,
|
||||||
|
onClose = () => {},
|
||||||
}) => {
|
}) => {
|
||||||
const selectedCount = selectedDocuments.length;
|
const selectedCount = selectedDocuments.length;
|
||||||
const singleDoc = selectedCount === 1 ? selectedDocuments[0] : null;
|
const singleDoc = selectedCount === 1 ? selectedDocuments[0] : null;
|
||||||
|
const singleDocId = singleDoc?.id || null;
|
||||||
|
const selectionKey = useMemo(
|
||||||
|
() => selectedDocuments.map((doc) => doc?.id ?? '').join('|'),
|
||||||
|
[selectedDocuments],
|
||||||
|
);
|
||||||
|
|
||||||
|
const singleDownloadHref = useMemo(() => {
|
||||||
|
if (!singleDoc) return null;
|
||||||
|
const downloadPath = singleDoc.current_version?.download_path;
|
||||||
|
if (!downloadPath || !resolveApiPath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return resolveApiPath(downloadPath);
|
||||||
|
}, [singleDoc, resolveApiPath]);
|
||||||
|
|
||||||
const [titleEditDocId, setTitleEditDocId] = useState(null);
|
const [titleEditDocId, setTitleEditDocId] = useState(null);
|
||||||
const [titleDraft, setTitleDraft] = useState('');
|
const [titleDraft, setTitleDraft] = useState('');
|
||||||
@@ -309,6 +333,7 @@ const DetailPanel = ({
|
|||||||
const [ocrUrl, setOcrUrl] = useState(null);
|
const [ocrUrl, setOcrUrl] = useState(null);
|
||||||
const [ocrLoading, setOcrLoading] = useState(false);
|
const [ocrLoading, setOcrLoading] = useState(false);
|
||||||
const [ocrError, setOcrError] = useState(null);
|
const [ocrError, setOcrError] = useState(null);
|
||||||
|
const [zoomedPreview, setZoomedPreview] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!singleDoc) {
|
if (!singleDoc) {
|
||||||
@@ -338,6 +363,10 @@ const DetailPanel = ({
|
|||||||
setOcrUrl(null);
|
setOcrUrl(null);
|
||||||
}, [singleDoc?.id]);
|
}, [singleDoc?.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setZoomedPreview(null);
|
||||||
|
}, [selectionKey]);
|
||||||
|
|
||||||
const startTitleEdit = useCallback(() => {
|
const startTitleEdit = useCallback(() => {
|
||||||
if (!singleDoc) return;
|
if (!singleDoc) return;
|
||||||
setTitleEditDocId(singleDoc.id);
|
setTitleEditDocId(singleDoc.id);
|
||||||
@@ -500,6 +529,7 @@ const DetailPanel = ({
|
|||||||
}, [selectedDocuments]);
|
}, [selectedDocuments]);
|
||||||
|
|
||||||
const stackTopDocument = stackDocuments[0] || null;
|
const stackTopDocument = stackDocuments[0] || null;
|
||||||
|
const stackTopDocId = stackTopDocument?.id || null;
|
||||||
const stackPreviewNavigator = useAssetNavigator({
|
const stackPreviewNavigator = useAssetNavigator({
|
||||||
document: stackTopDocument,
|
document: stackTopDocument,
|
||||||
assetType: 'preview',
|
assetType: 'preview',
|
||||||
@@ -623,6 +653,20 @@ const DetailPanel = ({
|
|||||||
return sortCorrespondents(singleDoc.correspondents || []);
|
return sortCorrespondents(singleDoc.correspondents || []);
|
||||||
}, [singleDoc]);
|
}, [singleDoc]);
|
||||||
|
|
||||||
|
const singleFolderPath = useMemo(() => {
|
||||||
|
if (!singleDoc?.folder_id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof resolveFolderPath !== 'function') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const segments = resolveFolderPath(singleDoc.folder_id);
|
||||||
|
if (!Array.isArray(segments) || !segments.some((segment) => segment?.id && segment.id !== 'root')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return segments;
|
||||||
|
}, [singleDoc?.folder_id, resolveFolderPath]);
|
||||||
|
|
||||||
const bulkCorrespondents = useMemo(() => {
|
const bulkCorrespondents = useMemo(() => {
|
||||||
if (selectedDocuments.length <= 1) {
|
if (selectedDocuments.length <= 1) {
|
||||||
const doc = selectedDocuments[0];
|
const doc = selectedDocuments[0];
|
||||||
@@ -634,17 +678,14 @@ const DetailPanel = ({
|
|||||||
if (!doc?.id) return;
|
if (!doc?.id) return;
|
||||||
(doc.correspondents || []).forEach((entry) => {
|
(doc.correspondents || []).forEach((entry) => {
|
||||||
if (!entry?.id) return;
|
if (!entry?.id) return;
|
||||||
const normalizedRole = normalizeRole(entry.role);
|
if (!map.has(entry.id)) {
|
||||||
const key = `${entry.id}:${normalizedRole}`;
|
map.set(entry.id, {
|
||||||
if (!map.has(key)) {
|
|
||||||
map.set(key, {
|
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
name: entry.name || '',
|
name: entry.name || '',
|
||||||
role: normalizedRole,
|
|
||||||
documentIds: new Set(),
|
documentIds: new Set(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
map.get(key).documentIds.add(doc.id);
|
map.get(entry.id).documentIds.add(doc.id);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -652,23 +693,20 @@ const DetailPanel = ({
|
|||||||
.map((entry) => ({
|
.map((entry) => ({
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
name: entry.name,
|
name: entry.name,
|
||||||
role: entry.role,
|
|
||||||
documentIds: [...entry.documentIds],
|
documentIds: [...entry.documentIds],
|
||||||
count: entry.documentIds.size,
|
count: entry.documentIds.size,
|
||||||
}))
|
}))
|
||||||
.sort(compareCorrespondents);
|
.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||||
}, [selectedDocuments]);
|
}, [selectedDocuments]);
|
||||||
|
|
||||||
const handleBulkCorrespondentRemove = useCallback(
|
const handleBulkCorrespondentRemove = useCallback(
|
||||||
(entry) => {
|
(entry) => {
|
||||||
if (!entry?.id) return;
|
if (!entry?.id) return;
|
||||||
const normalizedRole = normalizeRole(entry.role);
|
|
||||||
if (onBulkCorrespondentRemove) {
|
if (onBulkCorrespondentRemove) {
|
||||||
return onBulkCorrespondentRemove({
|
return onBulkCorrespondentRemove({
|
||||||
assignments: [
|
assignments: [
|
||||||
{
|
{
|
||||||
correspondent_id: entry.id,
|
correspondent_id: entry.id,
|
||||||
role: normalizedRole,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
documentIds: entry.documentIds,
|
documentIds: entry.documentIds,
|
||||||
@@ -679,11 +717,7 @@ const DetailPanel = ({
|
|||||||
const targets = entry.documentIds && entry.documentIds.length
|
const targets = entry.documentIds && entry.documentIds.length
|
||||||
? entry.documentIds
|
? entry.documentIds
|
||||||
: selectedDocuments
|
: selectedDocuments
|
||||||
.filter((doc) =>
|
.filter((doc) => (doc.correspondents || []).some((item) => item.id === entry.id))
|
||||||
(doc.correspondents || []).some(
|
|
||||||
(item) => item.id === entry.id && normalizeRole(item.role) === normalizedRole,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.map((doc) => doc.id);
|
.map((doc) => doc.id);
|
||||||
|
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
@@ -691,23 +725,185 @@ const DetailPanel = ({
|
|||||||
onCorrespondentRemove({
|
onCorrespondentRemove({
|
||||||
documentId,
|
documentId,
|
||||||
correspondentId: entry.id,
|
correspondentId: entry.id,
|
||||||
role: normalizedRole,
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
).catch(() => {});
|
).catch(() => {});
|
||||||
},
|
},
|
||||||
[bulkCorrespondents, onBulkCorrespondentRemove, onCorrespondentRemove, selectedDocuments],
|
[onBulkCorrespondentRemove, onCorrespondentRemove, selectedDocuments],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const openZoomPreview = useCallback((config) => {
|
||||||
|
if (!config) return;
|
||||||
|
setZoomedPreview({
|
||||||
|
mode: config.mode,
|
||||||
|
docId: config.docId ?? null,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeZoomPreview = useCallback(() => {
|
||||||
|
setZoomedPreview(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSingleZoom = useCallback(
|
||||||
|
(entry) => {
|
||||||
|
if (!singleHasPreview) return;
|
||||||
|
const targetId = entry?.id ?? singleDocId;
|
||||||
|
if (!targetId) return;
|
||||||
|
openZoomPreview({ mode: 'single', docId: targetId });
|
||||||
|
},
|
||||||
|
[openZoomPreview, singleHasPreview, singleDocId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleStackZoom = useCallback(
|
||||||
|
(entry) => {
|
||||||
|
if (!stackTopDocId || entry?.id !== stackTopDocId) return;
|
||||||
|
if (!topHasPreview) return;
|
||||||
|
openZoomPreview({ mode: 'stack', docId: stackTopDocId });
|
||||||
|
},
|
||||||
|
[openZoomPreview, stackTopDocId, topHasPreview],
|
||||||
|
);
|
||||||
|
|
||||||
|
const zoomDisplay = useMemo(() => {
|
||||||
|
if (!zoomedPreview) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
zoomedPreview.mode === 'single' &&
|
||||||
|
singleDocId &&
|
||||||
|
singleDoc &&
|
||||||
|
singleHasPreview &&
|
||||||
|
zoomedPreview.docId === singleDocId
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
url: singlePreviewNavigator.currentUrl,
|
||||||
|
alt: singleDoc.title || singleDoc.original_name || 'Document preview',
|
||||||
|
canGoPrev:
|
||||||
|
singleEffectiveCardinality > 1 && Boolean(singlePreviewNavigator.canGoPrev),
|
||||||
|
canGoNext:
|
||||||
|
singleEffectiveCardinality > 1 && Boolean(singlePreviewNavigator.canGoNext),
|
||||||
|
goPrev: singlePreviewNavigator.goPrev,
|
||||||
|
goNext: singlePreviewNavigator.goNext,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
zoomedPreview.mode === 'stack' &&
|
||||||
|
stackTopDocId &&
|
||||||
|
stackTopDocument &&
|
||||||
|
zoomedPreview.docId === stackTopDocId &&
|
||||||
|
topHasPreview
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
url: stackPreviewNavigator.currentUrl,
|
||||||
|
alt: stackTopDocument.title || stackTopDocument.original_name || 'Document preview',
|
||||||
|
canGoPrev:
|
||||||
|
topEffectiveCardinality > 1 && Boolean(stackPreviewNavigator.canGoPrev),
|
||||||
|
canGoNext:
|
||||||
|
topEffectiveCardinality > 1 && Boolean(stackPreviewNavigator.canGoNext),
|
||||||
|
goPrev: stackPreviewNavigator.goPrev,
|
||||||
|
goNext: stackPreviewNavigator.goNext,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}, [
|
||||||
|
zoomedPreview,
|
||||||
|
singleDoc,
|
||||||
|
singleDocId,
|
||||||
|
singleHasPreview,
|
||||||
|
singlePreviewNavigator.currentUrl,
|
||||||
|
singlePreviewNavigator.canGoPrev,
|
||||||
|
singlePreviewNavigator.canGoNext,
|
||||||
|
singlePreviewNavigator.goPrev,
|
||||||
|
singlePreviewNavigator.goNext,
|
||||||
|
singleEffectiveCardinality,
|
||||||
|
stackTopDocument,
|
||||||
|
stackTopDocId,
|
||||||
|
topHasPreview,
|
||||||
|
stackPreviewNavigator.currentUrl,
|
||||||
|
stackPreviewNavigator.canGoPrev,
|
||||||
|
stackPreviewNavigator.canGoNext,
|
||||||
|
stackPreviewNavigator.goPrev,
|
||||||
|
stackPreviewNavigator.goNext,
|
||||||
|
topEffectiveCardinality,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (zoomedPreview && !zoomDisplay) {
|
||||||
|
setZoomedPreview(null);
|
||||||
|
}
|
||||||
|
}, [zoomedPreview, zoomDisplay]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof ensureAssetUrl !== 'function') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const warmNavigator = (navigator) => {
|
||||||
|
const {
|
||||||
|
documentId,
|
||||||
|
asset,
|
||||||
|
ordinal,
|
||||||
|
canGoPrev,
|
||||||
|
canGoNext,
|
||||||
|
cardinality,
|
||||||
|
} = navigator;
|
||||||
|
if (!documentId || !asset || !Number.isFinite(ordinal)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requests = [];
|
||||||
|
if (canGoPrev) {
|
||||||
|
const prevOrdinal = Math.max(1, ordinal - 1);
|
||||||
|
if (!cardinality || prevOrdinal <= cardinality) {
|
||||||
|
requests.push(
|
||||||
|
ensureAssetUrl(documentId, asset, {
|
||||||
|
start: prevOrdinal,
|
||||||
|
limit: 1,
|
||||||
|
objectOrdinal: prevOrdinal,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (canGoNext) {
|
||||||
|
const nextOrdinal = ordinal + 1;
|
||||||
|
if (!cardinality || nextOrdinal <= cardinality) {
|
||||||
|
requests.push(
|
||||||
|
ensureAssetUrl(documentId, asset, {
|
||||||
|
start: nextOrdinal,
|
||||||
|
limit: 1,
|
||||||
|
objectOrdinal: nextOrdinal,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
requests.forEach((promise) => promise?.catch?.(() => {}));
|
||||||
|
};
|
||||||
|
|
||||||
|
warmNavigator(singlePreviewNavigator);
|
||||||
|
warmNavigator(stackPreviewNavigator);
|
||||||
|
}, [
|
||||||
|
ensureAssetUrl,
|
||||||
|
singlePreviewNavigator.documentId,
|
||||||
|
singlePreviewNavigator.asset,
|
||||||
|
singlePreviewNavigator.ordinal,
|
||||||
|
singlePreviewNavigator.canGoPrev,
|
||||||
|
singlePreviewNavigator.canGoNext,
|
||||||
|
stackPreviewNavigator.documentId,
|
||||||
|
stackPreviewNavigator.asset,
|
||||||
|
stackPreviewNavigator.ordinal,
|
||||||
|
stackPreviewNavigator.canGoPrev,
|
||||||
|
stackPreviewNavigator.canGoNext,
|
||||||
|
]);
|
||||||
|
|
||||||
const renderSingle = () => {
|
const renderSingle = () => {
|
||||||
if (!singleDoc) {
|
if (!singleDoc) {
|
||||||
return <p className="meta">Select a document to view metadata, tags and actions.</p>;
|
return <p className="meta">Select a document to view metadata, tags and actions.</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const displayName = singleDoc.title || singleDoc.original_name;
|
const displayName = singleDoc.title || singleDoc.original_name;
|
||||||
const downloadHref = singleDoc.current_version?.download_path
|
|
||||||
? resolveApiPath?.(singleDoc.current_version.download_path)
|
|
||||||
: null;
|
|
||||||
const isEditingTitle = titleEditDocId === singleDoc.id;
|
const isEditingTitle = titleEditDocId === singleDoc.id;
|
||||||
const sizeBytes = Number(singleDoc.current_version?.size_bytes) || 0;
|
const sizeBytes = Number(singleDoc.current_version?.size_bytes) || 0;
|
||||||
const sizeLabel = sizeBytes > 0 ? formatFileSize(sizeBytes) : '—';
|
const sizeLabel = sizeBytes > 0 ? formatFileSize(sizeBytes) : '—';
|
||||||
@@ -743,6 +939,7 @@ const DetailPanel = ({
|
|||||||
emptyMessage="Preview loading…"
|
emptyMessage="Preview loading…"
|
||||||
onItemActivate={handlePreviewActivate}
|
onItemActivate={handlePreviewActivate}
|
||||||
onOpenPreview={onOpenPreview}
|
onOpenPreview={onOpenPreview}
|
||||||
|
onZoomPreview={handleSingleZoom}
|
||||||
activeItemId={activePreviewId}
|
activeItemId={activePreviewId}
|
||||||
/>
|
/>
|
||||||
{hasPreviewImage && (effectiveCardinality > 1 || canGoPrev || canGoNext) ? (
|
{hasPreviewImage && (effectiveCardinality > 1 || canGoPrev || canGoNext) ? (
|
||||||
@@ -852,49 +1049,58 @@ const DetailPanel = ({
|
|||||||
<strong>Pages:</strong> {pageCountValue}
|
<strong>Pages:</strong> {pageCountValue}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{singleFolderPath?.length ? (
|
||||||
|
<div>
|
||||||
|
<strong>Folder:</strong>{' '}
|
||||||
|
<span className="detail-folder-path">
|
||||||
|
{singleFolderPath.map((segment, index) => {
|
||||||
|
const label = segment?.name || '…';
|
||||||
|
const targetId = segment?.id || null;
|
||||||
|
const key = `${targetId || label}-${index}`;
|
||||||
|
const isClickable = Boolean(targetId) && typeof onFolderNavigate === 'function';
|
||||||
|
const href = !isClickable
|
||||||
|
? null
|
||||||
|
: targetId === 'root'
|
||||||
|
? '/documents'
|
||||||
|
: `/documents/folder/${targetId}`;
|
||||||
|
return (
|
||||||
|
<React.Fragment key={key}>
|
||||||
|
{index > 0 ? <span className="detail-folder-path__separator">/</span> : null}
|
||||||
|
{isClickable ? (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
className="detail-folder-path__link"
|
||||||
|
onClick={(event) => {
|
||||||
|
if (
|
||||||
|
event.button !== 0 ||
|
||||||
|
event.metaKey ||
|
||||||
|
event.ctrlKey ||
|
||||||
|
event.shiftKey ||
|
||||||
|
event.altKey
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
onFolderNavigate(targetId);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="detail-folder-path__segment">{label}</span>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div>
|
<div>
|
||||||
<strong>Original filename:</strong>{' '}
|
<strong>Original filename:</strong>{' '}
|
||||||
{singleDoc.original_name}
|
{singleDoc.original_name}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="detail-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={() => onOpenPreview(singleDoc.id)}
|
|
||||||
>
|
|
||||||
Open preview
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="secondary"
|
|
||||||
onClick={() => onRegenerateThumbnails(singleDoc.id)}
|
|
||||||
>
|
|
||||||
Re-run analysis
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{hasOcrAsset ? (
|
|
||||||
<div className="detail-ocr-trigger">
|
|
||||||
<button type="button" className="secondary" onClick={openOcrModal}>
|
|
||||||
View OCR text
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<TagSection
|
<TagSection
|
||||||
title="Tags"
|
title="Tags"
|
||||||
tags={tagsForDoc.map((tag) => ({
|
tags={tagsForDoc.map((tag) => ({
|
||||||
@@ -914,14 +1120,12 @@ const DetailPanel = ({
|
|||||||
onCorrespondentRemove?.({
|
onCorrespondentRemove?.({
|
||||||
documentId: singleDoc.id,
|
documentId: singleDoc.id,
|
||||||
correspondentId: entry.id,
|
correspondentId: entry.id,
|
||||||
role: entry.role,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
onAdd={({ name, role, input }) =>
|
onAdd={({ name, input }) =>
|
||||||
onCorrespondentAdd?.({
|
onCorrespondentAdd?.({
|
||||||
document: singleDoc,
|
document: singleDoc,
|
||||||
name,
|
name,
|
||||||
role,
|
|
||||||
input,
|
input,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -931,7 +1135,7 @@ const DetailPanel = ({
|
|||||||
{metadata && (
|
{metadata && (
|
||||||
<div>
|
<div>
|
||||||
<dt>Metadata</dt>
|
<dt>Metadata</dt>
|
||||||
<pre>{JSON.stringify(metadata, null, 2)}</pre>
|
<pre className="detail-metadata__block">{JSON.stringify(metadata, null, 2)}</pre>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{hasOcrAsset && ocrOpen
|
{hasOcrAsset && ocrOpen
|
||||||
@@ -1002,6 +1206,7 @@ const DetailPanel = ({
|
|||||||
emptyMessage="No previews available."
|
emptyMessage="No previews available."
|
||||||
onItemActivate={handlePreviewActivate}
|
onItemActivate={handlePreviewActivate}
|
||||||
onOpenPreview={onOpenPreview}
|
onOpenPreview={onOpenPreview}
|
||||||
|
onZoomPreview={handleStackZoom}
|
||||||
activeItemId={activePreviewId}
|
activeItemId={activePreviewId}
|
||||||
/>
|
/>
|
||||||
{topDocIdLocal && topHasPreview && (topCardinalityLocal > 1 || topCanGoPrev || topCanGoNext) ? (
|
{topDocIdLocal && topHasPreview && (topCardinalityLocal > 1 || topCanGoPrev || topCanGoNext) ? (
|
||||||
@@ -1065,8 +1270,8 @@ const DetailPanel = ({
|
|||||||
title="Correspondents"
|
title="Correspondents"
|
||||||
entries={bulkCorrespondents}
|
entries={bulkCorrespondents}
|
||||||
onRemove={handleBulkCorrespondentRemove}
|
onRemove={handleBulkCorrespondentRemove}
|
||||||
onAdd={({ name, role, input }) =>
|
onAdd={({ name, input }) =>
|
||||||
onBulkCorrespondentAdd?.({ name, role, input })
|
onBulkCorrespondentAdd?.({ name, input })
|
||||||
}
|
}
|
||||||
addPlaceholder="Add correspondent to selection"
|
addPlaceholder="Add correspondent to selection"
|
||||||
datalistId="correspondent-catalog-bulk"
|
datalistId="correspondent-catalog-bulk"
|
||||||
@@ -1074,23 +1279,110 @@ const DetailPanel = ({
|
|||||||
showCount
|
showCount
|
||||||
className="bulk-correspondents"
|
className="bulk-correspondents"
|
||||||
/>
|
/>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="secondary"
|
|
||||||
onClick={() => onBulkReanalyze?.()}
|
|
||||||
>
|
|
||||||
Re-analyze selection
|
|
||||||
</button>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isBulkSelection = selectedCount > 1;
|
||||||
|
const showOcrAction = Boolean(singleDoc && hasOcrAsset);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="detail-panel column">
|
<>
|
||||||
<div className="column-body scrollable">
|
<aside className="detail-panel panel">
|
||||||
|
<div className="panel-header">
|
||||||
|
<div className="panel-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button ghost"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close detail panel"
|
||||||
|
title="Close detail panel"
|
||||||
|
>
|
||||||
|
<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
|
||||||
|
type="button"
|
||||||
|
className="icon-button ghost"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onBulkReanalyze();
|
||||||
|
}}
|
||||||
|
aria-label="Re-run analysis for selection"
|
||||||
|
title="Re-run analysis for selection"
|
||||||
|
>
|
||||||
|
<AnalyzeIcon />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{singleDoc && singleDownloadHref ? (
|
||||||
|
<a
|
||||||
|
className="icon-button"
|
||||||
|
href={singleDownloadHref}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label="Download document"
|
||||||
|
title="Download document"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<DownloadIcon />
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
{showOcrAction ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button ghost"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
openOcrModal();
|
||||||
|
}}
|
||||||
|
aria-label="View OCR text"
|
||||||
|
title="View OCR text"
|
||||||
|
>
|
||||||
|
<TextScanIcon />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{singleDoc ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button ghost"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onRegenerateThumbnails(singleDoc.id);
|
||||||
|
}}
|
||||||
|
aria-label="Re-run analysis"
|
||||||
|
title="Re-run analysis"
|
||||||
|
>
|
||||||
|
<AnalyzeIcon />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="panel-body">
|
||||||
{selectedCount <= 1 ? renderSingle() : renderBulk()}
|
{selectedCount <= 1 ? renderSingle() : renderBulk()}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
<PreviewZoomOverlay
|
||||||
|
open={Boolean(zoomDisplay?.url)}
|
||||||
|
display={zoomDisplay}
|
||||||
|
onClose={closeZoomPreview}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,328 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { ArrowLeftIcon, ArrowRightIcon } from '../ui/icons';
|
||||||
|
|
||||||
|
const noop = () => {};
|
||||||
|
|
||||||
|
const clamp = (value, min, max) => {
|
||||||
|
if (value < min) return min;
|
||||||
|
if (value > max) return max;
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureDocumentRoot = () => {
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return document.body;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PreviewZoomOverlay = ({
|
||||||
|
open = false,
|
||||||
|
display = null,
|
||||||
|
onClose = noop,
|
||||||
|
}) => {
|
||||||
|
const portalTarget = ensureDocumentRoot();
|
||||||
|
const [isNativeScale, setIsNativeScale] = useState(false);
|
||||||
|
const [naturalSize, setNaturalSize] = useState({ width: null, height: null });
|
||||||
|
const [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);
|
||||||
|
setNaturalSize({ width: null, height: null });
|
||||||
|
focusRef.current = null;
|
||||||
|
const scrollEl = scrollRef.current;
|
||||||
|
if (scrollEl) {
|
||||||
|
scrollEl.scrollLeft = 0;
|
||||||
|
scrollEl.scrollTop = 0;
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !isNativeScale) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollEl = scrollRef.current;
|
||||||
|
const imageEl = imageRef.current;
|
||||||
|
if (!scrollEl || !imageEl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageWidth = imageEl.naturalWidth || imageEl.clientWidth;
|
||||||
|
const imageHeight = imageEl.naturalHeight || imageEl.clientHeight;
|
||||||
|
if (!(imageWidth > 0 && imageHeight > 0)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = focusRef.current || { xRatio: 0.5, yRatio: 0.5 };
|
||||||
|
const maxScrollLeft = Math.max(0, imageWidth - scrollEl.clientWidth);
|
||||||
|
const maxScrollTop = Math.max(0, imageHeight - scrollEl.clientHeight);
|
||||||
|
|
||||||
|
const desiredLeft = target.xRatio * imageWidth - scrollEl.clientWidth / 2;
|
||||||
|
const desiredTop = target.yRatio * imageHeight - scrollEl.clientHeight / 2;
|
||||||
|
|
||||||
|
scrollEl.scrollLeft = clamp(desiredLeft, 0, maxScrollLeft);
|
||||||
|
scrollEl.scrollTop = clamp(desiredTop, 0, maxScrollTop);
|
||||||
|
}, [open, isNativeScale, naturalSize.width, naturalSize.height]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
if (previouslyFocusedRef.current && typeof previouslyFocusedRef.current.focus === 'function') {
|
||||||
|
previouslyFocusedRef.current.focus();
|
||||||
|
}
|
||||||
|
previouslyFocusedRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
const active = document.activeElement;
|
||||||
|
if (active && typeof active.focus === 'function') {
|
||||||
|
previouslyFocusedRef.current = active;
|
||||||
|
} else {
|
||||||
|
previouslyFocusedRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const activeDisplay = open && display?.url ? display : displaySnapshot;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!renderBackdrop || !activeDisplay?.url) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const frame = requestAnimationFrame(() => {
|
||||||
|
const scrollEl = scrollRef.current;
|
||||||
|
if (scrollEl && typeof scrollEl.focus === 'function') {
|
||||||
|
scrollEl.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => cancelAnimationFrame(frame);
|
||||||
|
}, [renderBackdrop, activeDisplay?.url]);
|
||||||
|
|
||||||
|
const handleKeyDown = (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'ArrowLeft') {
|
||||||
|
if (activeDisplay?.canGoPrev && activeDisplay?.goPrev) {
|
||||||
|
event.preventDefault();
|
||||||
|
activeDisplay.goPrev();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'ArrowRight') {
|
||||||
|
if (activeDisplay?.canGoNext && activeDisplay?.goNext) {
|
||||||
|
event.preventDefault();
|
||||||
|
activeDisplay.goNext();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!renderBackdrop || !activeDisplay?.url || !portalTarget) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectiveDisplay = activeDisplay;
|
||||||
|
const navVisible = Boolean(effectiveDisplay?.canGoPrev || effectiveDisplay?.canGoNext);
|
||||||
|
const stageClassName = [
|
||||||
|
'preview-zoom__stage',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
const containerClassName = [
|
||||||
|
'preview-zoom__scroll',
|
||||||
|
isNativeScale ? 'preview-zoom__scroll--native' : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
const backdropClassName = [
|
||||||
|
'preview-zoom-backdrop',
|
||||||
|
isBackdropVisible ? 'preview-zoom-backdrop--visible' : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
const imageStyle = isNativeScale
|
||||||
|
? {
|
||||||
|
cursor: 'zoom-out',
|
||||||
|
width: naturalSize.width ? `${naturalSize.width}px` : 'auto',
|
||||||
|
height: naturalSize.height ? `${naturalSize.height}px` : 'auto',
|
||||||
|
maxWidth: 'none',
|
||||||
|
maxHeight: 'none',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
cursor: 'zoom-in',
|
||||||
|
maxWidth: '95vw',
|
||||||
|
maxHeight: '95vh',
|
||||||
|
};
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
(
|
||||||
|
<div
|
||||||
|
className={backdropClassName}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Enlarged document preview"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={stageClassName}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={containerClassName}
|
||||||
|
ref={scrollRef}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={effectiveDisplay.url}
|
||||||
|
alt={effectiveDisplay.alt || 'Document preview'}
|
||||||
|
className="preview-zoom__image"
|
||||||
|
ref={imageRef}
|
||||||
|
draggable={false}
|
||||||
|
onLoad={(event) => {
|
||||||
|
setNaturalSize({
|
||||||
|
width: event.currentTarget.naturalWidth || null,
|
||||||
|
height: event.currentTarget.naturalHeight || null,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (!isNativeScale) {
|
||||||
|
const img = imageRef.current;
|
||||||
|
if (img) {
|
||||||
|
const rect = img.getBoundingClientRect();
|
||||||
|
const xRatio = rect.width > 0 ? (event.clientX - rect.left) / rect.width : 0.5;
|
||||||
|
const yRatio = rect.height > 0 ? (event.clientY - rect.top) / rect.height : 0.5;
|
||||||
|
focusRef.current = {
|
||||||
|
xRatio: clamp(xRatio, 0, 1),
|
||||||
|
yRatio: clamp(yRatio, 0, 1),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
focusRef.current = null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
focusRef.current = null;
|
||||||
|
}
|
||||||
|
setIsNativeScale((current) => !current);
|
||||||
|
}}
|
||||||
|
style={imageStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{navVisible ? (
|
||||||
|
<div className="preview-zoom__nav">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="preview-zoom__nav-button"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (effectiveDisplay?.canGoPrev && effectiveDisplay?.goPrev) {
|
||||||
|
effectiveDisplay.goPrev();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-label="Previous preview"
|
||||||
|
disabled={!effectiveDisplay?.canGoPrev}
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="preview-zoom__nav-button"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (effectiveDisplay?.canGoNext && effectiveDisplay?.goNext) {
|
||||||
|
effectiveDisplay.goNext();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-label="Next preview"
|
||||||
|
disabled={!effectiveDisplay?.canGoNext}
|
||||||
|
>
|
||||||
|
<ArrowRightIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
portalTarget,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PreviewZoomOverlay;
|
||||||
@@ -1,10 +1,22 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { getAssetFromVersion, resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
|
import { getAssetFromVersion, resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
|
||||||
import { getTagColorStyle } from '../utils/colors';
|
import { getTagColorStyle } from '../utils/colors';
|
||||||
import { DownloadIcon, EditIcon, ViewListIcon, ViewGridIcon, FolderIcon } from '../ui/icons';
|
import DetailPanel from '../detail/DetailPanel';
|
||||||
|
import {
|
||||||
|
DownloadIcon,
|
||||||
|
EditIcon,
|
||||||
|
ViewListIcon,
|
||||||
|
ViewGridIcon,
|
||||||
|
FolderIcon,
|
||||||
|
TrashIcon,
|
||||||
|
RefreshIcon,
|
||||||
|
FolderPlusIcon,
|
||||||
|
MinusVerticalIcon,
|
||||||
|
ArrowUpIcon,
|
||||||
|
} from '../ui/icons';
|
||||||
|
|
||||||
const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
|
const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
|
||||||
const DEFAULT_GRID_ICON_SIZE = 96;
|
const DEFAULT_GRID_ICON_SIZE = 144;
|
||||||
const DEFAULT_GRID_TITLE_SIZE = '11px';
|
const DEFAULT_GRID_TITLE_SIZE = '11px';
|
||||||
const LIST_ICON_SIZE = 48;
|
const LIST_ICON_SIZE = 48;
|
||||||
|
|
||||||
@@ -13,13 +25,95 @@ const getPageCount = (doc) =>
|
|||||||
? doc.current_version.metadata.page_count
|
? doc.current_version.metadata.page_count
|
||||||
: null;
|
: 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);
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsVisible(false);
|
||||||
|
}, [resetKey]);
|
||||||
|
|
||||||
|
const rootNode = rootRef?.current || null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isVisible) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const element = targetRef.current;
|
||||||
|
if (!element) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (typeof window === 'undefined' || typeof IntersectionObserver === 'undefined') {
|
||||||
|
setIsVisible(true);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
setIsVisible(true);
|
||||||
|
observer.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{
|
||||||
|
root: rootNode,
|
||||||
|
rootMargin: '200px 0px',
|
||||||
|
threshold: 0.01,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(element);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [isVisible, rootNode, resetKey]);
|
||||||
|
|
||||||
|
return { ref: targetRef, isVisible };
|
||||||
|
};
|
||||||
|
|
||||||
const DocumentThumbnailImage = ({
|
const DocumentThumbnailImage = ({
|
||||||
document,
|
document,
|
||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
getDocumentAsset,
|
getDocumentAsset,
|
||||||
alt,
|
alt,
|
||||||
maxSize = LIST_ICON_SIZE,
|
maxSize = LIST_ICON_SIZE,
|
||||||
|
scrollRootRef = null,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { ref: visibilityRef, isVisible } = useLazyVisibility(scrollRootRef, document?.id);
|
||||||
const resolvedMaxSize = Math.max(1, Math.round(maxSize || 1));
|
const resolvedMaxSize = Math.max(1, Math.round(maxSize || 1));
|
||||||
const thumbnailAsset = useMemo(
|
const thumbnailAsset = useMemo(
|
||||||
() => getAssetFromVersion(document?.current_version, 'thumbnail'),
|
() => getAssetFromVersion(document?.current_version, 'thumbnail'),
|
||||||
@@ -45,14 +139,15 @@ const DocumentThumbnailImage = ({
|
|||||||
() => ({ width: `${dimensions.width}px`, height: `${dimensions.height}px` }),
|
() => ({ width: `${dimensions.width}px`, height: `${dimensions.height}px` }),
|
||||||
[dimensions.height, dimensions.width],
|
[dimensions.height, dimensions.width],
|
||||||
);
|
);
|
||||||
const url = useMemo(
|
const url = useMemo(() => {
|
||||||
() =>
|
if (!isVisible) {
|
||||||
resolveDocumentAssetUrl(document, 'thumbnail', {
|
return null;
|
||||||
ensureAssetUrl,
|
}
|
||||||
getAsset: getDocumentAsset,
|
return resolveDocumentAssetUrl(document, 'thumbnail', {
|
||||||
}),
|
ensureAssetUrl,
|
||||||
[document, ensureAssetUrl, getDocumentAsset],
|
getAsset: getDocumentAsset,
|
||||||
);
|
});
|
||||||
|
}, [document, ensureAssetUrl, getDocumentAsset, isVisible]);
|
||||||
|
|
||||||
const pageCount = getPageCount(document);
|
const pageCount = getPageCount(document);
|
||||||
const showMultiPageBadge = Number.isFinite(pageCount) && pageCount > 1;
|
const showMultiPageBadge = Number.isFinite(pageCount) && pageCount > 1;
|
||||||
@@ -61,14 +156,35 @@ const DocumentThumbnailImage = ({
|
|||||||
innerClasses.push('document-thumbnail-inner--multipage');
|
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 (
|
return (
|
||||||
<div className="document-thumbnail-wrapper">
|
<div className="document-thumbnail-wrapper" ref={visibilityRef}>
|
||||||
<div className={innerClasses.join(' ')} style={innerStyle}>
|
<div className={innerClasses.join(' ')} style={innerStyle}>
|
||||||
{url ? (
|
{url ? (
|
||||||
<img
|
<img
|
||||||
src={url}
|
src={url}
|
||||||
alt={alt || ''}
|
alt={alt || ''}
|
||||||
className="document-thumbnail"
|
className="document-thumbnail"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
onDragStart={(event) => event.preventDefault()}
|
onDragStart={(event) => event.preventDefault()}
|
||||||
/>
|
/>
|
||||||
@@ -113,6 +229,7 @@ const DocumentsTable = ({
|
|||||||
onFolderRename,
|
onFolderRename,
|
||||||
onDocumentRename,
|
onDocumentRename,
|
||||||
tagLookupById,
|
tagLookupById,
|
||||||
|
activeCorrespondentIds = [],
|
||||||
onDocumentListFocus,
|
onDocumentListFocus,
|
||||||
onDocumentListKeyDown,
|
onDocumentListKeyDown,
|
||||||
onFocusedRowChange,
|
onFocusedRowChange,
|
||||||
@@ -120,11 +237,13 @@ const DocumentsTable = ({
|
|||||||
getDocumentAsset = () => null,
|
getDocumentAsset = () => null,
|
||||||
getDownloadHref,
|
getDownloadHref,
|
||||||
onTagClick,
|
onTagClick,
|
||||||
|
onCorrespondentClick,
|
||||||
isSearchLoading = false,
|
isSearchLoading = false,
|
||||||
onDocumentTagDrop,
|
onDocumentTagDrop,
|
||||||
viewMode = 'list',
|
viewMode = 'list',
|
||||||
onViewModeChange,
|
onViewModeChange,
|
||||||
onClearSelection,
|
onClearSelection,
|
||||||
|
showHeader = true,
|
||||||
}) => {
|
}) => {
|
||||||
const showingSearchResults = searchResults !== null;
|
const showingSearchResults = searchResults !== null;
|
||||||
const rows = showingSearchResults ? searchResults : documents;
|
const rows = showingSearchResults ? searchResults : documents;
|
||||||
@@ -141,7 +260,24 @@ const DocumentsTable = ({
|
|||||||
() => new Set(draggingDocumentIds || []),
|
() => new Set(draggingDocumentIds || []),
|
||||||
[draggingDocumentIds],
|
[draggingDocumentIds],
|
||||||
);
|
);
|
||||||
|
const activeCorrespondentIdSet = useMemo(
|
||||||
|
() => new Set(activeCorrespondentIds || []),
|
||||||
|
[activeCorrespondentIds],
|
||||||
|
);
|
||||||
const scrollRef = useRef(null);
|
const scrollRef = useRef(null);
|
||||||
|
const suppressDocumentClickRef = useRef(false);
|
||||||
|
const [, forceVisibilityTick] = useState(0);
|
||||||
|
const lastScrollNodeRef = useRef(null);
|
||||||
|
const assignScrollRef = useCallback((node) => {
|
||||||
|
if (lastScrollNodeRef.current === node) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastScrollNodeRef.current = node;
|
||||||
|
scrollRef.current = node;
|
||||||
|
if (node) {
|
||||||
|
forceVisibilityTick((value) => value + 1);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
const isGridView = viewMode === 'grid';
|
const isGridView = viewMode === 'grid';
|
||||||
const gridIconSize = DEFAULT_GRID_ICON_SIZE;
|
const gridIconSize = DEFAULT_GRID_ICON_SIZE;
|
||||||
const handleSetViewMode = useCallback(
|
const handleSetViewMode = useCallback(
|
||||||
@@ -156,6 +292,11 @@ const DocumentsTable = ({
|
|||||||
},
|
},
|
||||||
[onViewModeChange],
|
[onViewModeChange],
|
||||||
);
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTop = 0;
|
||||||
|
}
|
||||||
|
}, [viewMode]);
|
||||||
const isTagDragEvent = useCallback((event) => {
|
const isTagDragEvent = useCallback((event) => {
|
||||||
const types = Array.from(event.dataTransfer?.types || []);
|
const types = Array.from(event.dataTransfer?.types || []);
|
||||||
return TAG_MIME_TYPES.some((type) => types.includes(type));
|
return TAG_MIME_TYPES.some((type) => types.includes(type));
|
||||||
@@ -263,14 +404,77 @@ const DocumentsTable = ({
|
|||||||
[isTagDragEvent, onDocumentTagDrop],
|
[isTagDragEvent, onDocumentTagDrop],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleGridBackgroundClick = useCallback(
|
const handleDocumentClick = useCallback(
|
||||||
(event) => {
|
(documentId, event) => {
|
||||||
if (event.target !== event.currentTarget) {
|
if (suppressDocumentClickRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onClearSelection?.();
|
onDocumentRowClick?.(documentId, event);
|
||||||
},
|
},
|
||||||
[onClearSelection],
|
[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 showDefaultEmptyState = !showingSearchResults && !subfolders.length && rows.length === 0;
|
||||||
@@ -282,73 +486,53 @@ const DocumentsTable = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className={`documents-panel column documents-panel--view-${isGridView ? 'grid' : 'list'}`}
|
className={`documents-panel documents-panel--view-${isGridView ? 'grid' : 'list'}`}
|
||||||
>
|
>
|
||||||
<div className="column-header">
|
{showHeader ? (
|
||||||
<div className="column-header__titles">
|
<div className="panel-section__header">
|
||||||
<nav className="breadcrumb" aria-label="Folder breadcrumbs">
|
<div className="panel-section__titles">
|
||||||
{breadcrumbs.map((crumb, index) => {
|
<h2>{currentFolderName}</h2>
|
||||||
const isLast = index === breadcrumbs.length - 1;
|
{showingSearchResults && (
|
||||||
return (
|
<div className="panel-section__subtitle">Search results</div>
|
||||||
<span key={crumb.id} className="breadcrumb-item">
|
)}
|
||||||
{isLast ? (
|
</div>
|
||||||
<span className="breadcrumb-current">{crumb.name}</span>
|
<div className="header-actions">
|
||||||
) : (
|
<div className="view-toggle" role="group" aria-label="Change view">
|
||||||
<a
|
<button
|
||||||
href="#"
|
type="button"
|
||||||
onClick={(event) => {
|
className={`view-toggle__button${isGridView ? '' : ' active'}`}
|
||||||
event.preventDefault();
|
onClick={() => handleSetViewMode('list')}
|
||||||
onFolderSelect(crumb.id);
|
aria-pressed={!isGridView}
|
||||||
}}
|
title="List view"
|
||||||
>
|
>
|
||||||
{crumb.name}
|
<ViewListIcon className="view-toggle__icon" size={18} />
|
||||||
</a>
|
</button>
|
||||||
)}
|
<button
|
||||||
{!isLast && <span className="breadcrumb-separator">›</span>}
|
type="button"
|
||||||
</span>
|
className={`view-toggle__button${isGridView ? ' active' : ''}`}
|
||||||
);
|
onClick={() => handleSetViewMode('grid')}
|
||||||
})}
|
aria-pressed={isGridView}
|
||||||
</nav>
|
title="Icons view"
|
||||||
{showingSearchResults && (
|
>
|
||||||
<div className="column-subtitle">Search results</div>
|
<ViewGridIcon className="view-toggle__icon" size={18} />
|
||||||
)}
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="header-actions">
|
|
||||||
<div className="view-toggle" role="group" aria-label="Change view">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`view-toggle__button${isGridView ? '' : ' active'}`}
|
onClick={onRequestCreateFolder}
|
||||||
onClick={() => handleSetViewMode('list')}
|
disabled={creatingFolder}
|
||||||
aria-pressed={!isGridView}
|
|
||||||
title="List view"
|
|
||||||
>
|
>
|
||||||
<ViewListIcon className="view-toggle__icon" size={18} />
|
{creatingFolder ? 'Creating…' : 'New folder'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button className="secondary" onClick={onRefresh}>
|
||||||
type="button"
|
Refresh
|
||||||
className={`view-toggle__button${isGridView ? ' active' : ''}`}
|
</button>
|
||||||
onClick={() => handleSetViewMode('grid')}
|
<button className="secondary" type="button" onClick={onShowSkeuoWorkspace}>
|
||||||
aria-pressed={isGridView}
|
Desk View
|
||||||
title="Icons view"
|
|
||||||
>
|
|
||||||
<ViewGridIcon className="view-toggle__icon" size={18} />
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onRequestCreateFolder}
|
|
||||||
disabled={creatingFolder}
|
|
||||||
>
|
|
||||||
{creatingFolder ? 'Creating…' : 'New folder'}
|
|
||||||
</button>
|
|
||||||
<button className="secondary" onClick={onRefresh}>
|
|
||||||
Refresh
|
|
||||||
</button>
|
|
||||||
<button className="secondary" type="button" onClick={onShowSkeuoWorkspace}>
|
|
||||||
Desk View
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
{showDefaultEmptyState && (
|
{showDefaultEmptyState && (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
Drop files anywhere or onto a folder to upload documents.
|
Drop files anywhere or onto a folder to upload documents.
|
||||||
@@ -359,9 +543,9 @@ const DocumentsTable = ({
|
|||||||
No documents match the current filters.
|
No documents match the current filters.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="column-body">
|
<div className="panel-section__body">
|
||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={assignScrollRef}
|
||||||
className="documents-scroll"
|
className="documents-scroll"
|
||||||
onFocus={(event) => {
|
onFocus={(event) => {
|
||||||
if (event.target === scrollRef.current) {
|
if (event.target === scrollRef.current) {
|
||||||
@@ -376,13 +560,22 @@ const DocumentsTable = ({
|
|||||||
onDocumentListKeyDown(event);
|
onDocumentListKeyDown(event);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onClick={(event) => {
|
||||||
|
if (event.target === event.currentTarget) {
|
||||||
|
onClearSelection?.();
|
||||||
|
}
|
||||||
|
}}
|
||||||
aria-activedescendant={isGridView ? undefined : activeDescendantId}
|
aria-activedescendant={isGridView ? undefined : activeDescendantId}
|
||||||
>
|
>
|
||||||
{showDefaultEmptyState ? null : isGridView ? (
|
{showDefaultEmptyState ? null : isGridView ? (
|
||||||
<div
|
<div
|
||||||
className="documents-grid"
|
className="documents-grid"
|
||||||
role="list"
|
role="list"
|
||||||
onClick={handleGridBackgroundClick}
|
onClick={(event) => {
|
||||||
|
if (event.target === event.currentTarget) {
|
||||||
|
onClearSelection?.();
|
||||||
|
}
|
||||||
|
}}
|
||||||
style={{ '--documents-grid-icon-size': `${gridIconSize}px` }}
|
style={{ '--documents-grid-icon-size': `${gridIconSize}px` }}
|
||||||
>
|
>
|
||||||
{!showingSearchResults &&
|
{!showingSearchResults &&
|
||||||
@@ -449,6 +642,7 @@ const DocumentsTable = ({
|
|||||||
const tagList = Array.isArray(doc.tags) ? doc.tags : [];
|
const tagList = Array.isArray(doc.tags) ? doc.tags : [];
|
||||||
const visibleTags = tagList.slice(0, 3);
|
const visibleTags = tagList.slice(0, 3);
|
||||||
const remainingTagCount = tagList.length > 3 ? tagList.length - 3 : 0;
|
const remainingTagCount = tagList.length > 3 ? tagList.length - 3 : 0;
|
||||||
|
const correspondents = resolveCorrespondents(doc);
|
||||||
const cardClasses = ['document-card', 'document'];
|
const cardClasses = ['document-card', 'document'];
|
||||||
if (isSelected) cardClasses.push('selected');
|
if (isSelected) cardClasses.push('selected');
|
||||||
if (isDraggingDoc) cardClasses.push('is-dragging');
|
if (isDraggingDoc) cardClasses.push('is-dragging');
|
||||||
@@ -459,11 +653,11 @@ const DocumentsTable = ({
|
|||||||
role="listitem"
|
role="listitem"
|
||||||
id={`document-card-${doc.id}`}
|
id={`document-card-${doc.id}`}
|
||||||
data-doc-id={doc.id}
|
data-doc-id={doc.id}
|
||||||
onClick={(event) => onDocumentRowClick(doc.id, event)}
|
onClick={(event) => handleDocumentClick(doc.id, event)}
|
||||||
onDoubleClick={() => onDocumentOpen(doc.id)}
|
onDoubleClick={() => onDocumentOpen(doc.id)}
|
||||||
draggable
|
draggable
|
||||||
onDragStart={(event) => onDocumentDragStart(event, doc)}
|
onDragStart={(event) => handleDocumentDragStartLocal(event, doc)}
|
||||||
onDragEnd={onDocumentDragEnd}
|
onDragEnd={handleDocumentDragEndLocal}
|
||||||
onDragOver={(event) => handleDocumentTagDragOver(event)}
|
onDragOver={(event) => handleDocumentTagDragOver(event)}
|
||||||
onDragOverCapture={(event) => handleDocumentTagDragOver(event)}
|
onDragOverCapture={(event) => handleDocumentTagDragOver(event)}
|
||||||
onDragLeave={handleDocumentTagDragLeave}
|
onDragLeave={handleDocumentTagDragLeave}
|
||||||
@@ -477,13 +671,19 @@ const DocumentsTable = ({
|
|||||||
getDocumentAsset={getDocumentAsset}
|
getDocumentAsset={getDocumentAsset}
|
||||||
alt={`Thumbnail for ${doc.title || doc.original_name}`}
|
alt={`Thumbnail for ${doc.title || doc.original_name}`}
|
||||||
maxSize={gridIconSize}
|
maxSize={gridIconSize}
|
||||||
|
scrollRootRef={scrollRef}
|
||||||
/>
|
/>
|
||||||
<div className="document-card__meta">
|
<div className="document-card__meta">
|
||||||
<div
|
<div
|
||||||
className="document-card__title"
|
className="document-card__title"
|
||||||
title={doc.title || doc.original_name}
|
title={doc.title || doc.original_name}
|
||||||
>
|
>
|
||||||
{doc.title || doc.original_name}
|
{correspondents.length > 0 ? (
|
||||||
|
<span className="doc-correspondents">
|
||||||
|
{renderCorrespondentLinks(correspondents)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<span>{doc.title || doc.original_name}</span>
|
||||||
</div>
|
</div>
|
||||||
{visibleTags.length > 0 && (
|
{visibleTags.length > 0 && (
|
||||||
<div className="document-card__tags">
|
<div className="document-card__tags">
|
||||||
@@ -556,8 +756,7 @@ const DocumentsTable = ({
|
|||||||
<tr>
|
<tr>
|
||||||
<th className="thumb-column">Preview</th>
|
<th className="thumb-column">Preview</th>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Type</th>
|
<th>Issued</th>
|
||||||
<th>Updated</th>
|
|
||||||
<th className="actions-column">Actions</th>
|
<th className="actions-column">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -619,13 +818,19 @@ const DocumentsTable = ({
|
|||||||
<td className="doc-list__name">
|
<td className="doc-list__name">
|
||||||
<div className="doc-list__name-content">
|
<div className="doc-list__name-content">
|
||||||
<span>{folder.name}</span>
|
<span>{folder.name}</span>
|
||||||
{folder.id !== 'root' && (
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>—</td>
|
||||||
|
<td className="actions">
|
||||||
|
<div className="action-buttons">
|
||||||
|
{folder.id !== 'root' && onFolderRename && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="icon-button ghost doc-list__icon-button"
|
className="icon-button ghost"
|
||||||
|
title="Rename"
|
||||||
|
aria-label={`Rename folder ${folder.name}`}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (!onFolderRename) return;
|
|
||||||
const nextName = window.prompt('Rename folder', folder.name || '');
|
const nextName = window.prompt('Rename folder', folder.name || '');
|
||||||
if (!nextName) {
|
if (!nextName) {
|
||||||
return;
|
return;
|
||||||
@@ -636,28 +841,24 @@ const DocumentsTable = ({
|
|||||||
}
|
}
|
||||||
onFolderRename(folder.id, trimmed);
|
onFolderRename(folder.id, trimmed);
|
||||||
}}
|
}}
|
||||||
title="Rename folder"
|
|
||||||
aria-label={`Rename folder ${folder.name}`}
|
|
||||||
>
|
>
|
||||||
<EditIcon className="doc-list__icon" size={16} />
|
<EditIcon className="icon-inline" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button danger"
|
||||||
|
title="Delete"
|
||||||
|
aria-label={`Delete folder ${folder.name}`}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onFolderDelete(folder.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TrashIcon className="icon-inline" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>Folder</td>
|
|
||||||
<td>—</td>
|
|
||||||
<td className="actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="icon-button danger"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
onFolderDelete(folder.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -668,6 +869,7 @@ const DocumentsTable = ({
|
|||||||
if (isSelected) rowClasses.push('selected');
|
if (isSelected) rowClasses.push('selected');
|
||||||
if (isDraggingDoc) rowClasses.push('is-dragging');
|
if (isDraggingDoc) rowClasses.push('is-dragging');
|
||||||
const downloadHref = getDownloadHref?.(doc) || null;
|
const downloadHref = getDownloadHref?.(doc) || null;
|
||||||
|
const correspondents = resolveCorrespondents(doc);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
@@ -675,11 +877,11 @@ const DocumentsTable = ({
|
|||||||
className={rowClasses.join(' ')}
|
className={rowClasses.join(' ')}
|
||||||
id={`document-row-${doc.id}`}
|
id={`document-row-${doc.id}`}
|
||||||
data-doc-id={doc.id}
|
data-doc-id={doc.id}
|
||||||
onClick={(event) => onDocumentRowClick(doc.id, event)}
|
onClick={(event) => handleDocumentClick(doc.id, event)}
|
||||||
onDoubleClick={() => onDocumentOpen(doc.id)}
|
onDoubleClick={() => onDocumentOpen(doc.id)}
|
||||||
draggable
|
draggable
|
||||||
onDragStart={(event) => onDocumentDragStart(event, doc)}
|
onDragStart={(event) => handleDocumentDragStartLocal(event, doc)}
|
||||||
onDragEnd={onDocumentDragEnd}
|
onDragEnd={handleDocumentDragEndLocal}
|
||||||
onDragOver={handleDocumentTagDragOver}
|
onDragOver={handleDocumentTagDragOver}
|
||||||
onDragLeave={handleDocumentTagDragLeave}
|
onDragLeave={handleDocumentTagDragLeave}
|
||||||
onDrop={(event) => handleDocumentTagDrop(event, doc.id)}
|
onDrop={(event) => handleDocumentTagDrop(event, doc.id)}
|
||||||
@@ -690,36 +892,20 @@ const DocumentsTable = ({
|
|||||||
ensureAssetUrl={ensureAssetUrl}
|
ensureAssetUrl={ensureAssetUrl}
|
||||||
getDocumentAsset={getDocumentAsset}
|
getDocumentAsset={getDocumentAsset}
|
||||||
alt={`Thumbnail for ${doc.title || doc.original_name}`}
|
alt={`Thumbnail for ${doc.title || doc.original_name}`}
|
||||||
|
scrollRootRef={scrollRef}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="doc-list__name">
|
<td className="doc-list__name">
|
||||||
<div className="doc-name">
|
<div className="doc-name">
|
||||||
<div className="doc-list__name-content">
|
<div className="doc-list__name-content">
|
||||||
<span className="doc-name__title">{doc.title || doc.original_name}</span>
|
<span className="doc-name__title">
|
||||||
<button
|
{correspondents.length > 0 ? (
|
||||||
type="button"
|
<span className="doc-correspondents">
|
||||||
className="icon-button ghost doc-list__icon-button"
|
{renderCorrespondentLinks(correspondents)}
|
||||||
onClick={(event) => {
|
</span>
|
||||||
event.stopPropagation();
|
) : null}
|
||||||
if (!onDocumentRename) return;
|
<span>{doc.title || doc.original_name}</span>
|
||||||
const nextName = window.prompt(
|
</span>
|
||||||
'Rename document',
|
|
||||||
doc.title || doc.original_name || '',
|
|
||||||
);
|
|
||||||
if (!nextName) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const trimmed = nextName.trim();
|
|
||||||
if (!trimmed || trimmed === (doc.title || doc.original_name)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onDocumentRename(doc.id, trimmed);
|
|
||||||
}}
|
|
||||||
title="Rename document"
|
|
||||||
aria-label={`Rename document ${doc.title || doc.original_name}`}
|
|
||||||
>
|
|
||||||
<EditIcon className="doc-list__icon" size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{(doc.tags || []).length > 0 && (
|
{(doc.tags || []).length > 0 && (
|
||||||
<div className="doc-name__tags">
|
<div className="doc-name__tags">
|
||||||
@@ -780,39 +966,74 @@ const DocumentsTable = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{doc.content_type || 'Document'}</td>
|
|
||||||
<td>
|
<td>
|
||||||
{doc.updated_at
|
{(() => {
|
||||||
? new Date(doc.updated_at).toLocaleString()
|
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();
|
||||||
|
})()}
|
||||||
</td>
|
</td>
|
||||||
<td className="actions">
|
<td className="actions">
|
||||||
<div className="action-buttons">
|
<div className="action-buttons">
|
||||||
|
{onDocumentRename && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button ghost"
|
||||||
|
title="Rename"
|
||||||
|
aria-label={`Rename document ${doc.title || doc.original_name}`}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
const nextName = window.prompt(
|
||||||
|
'Rename document',
|
||||||
|
doc.title || doc.original_name || '',
|
||||||
|
);
|
||||||
|
if (!nextName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const trimmed = nextName.trim();
|
||||||
|
if (!trimmed || trimmed === (doc.title || doc.original_name)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onDocumentRename(doc.id, trimmed);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EditIcon className="icon-inline" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{downloadHref ? (
|
{downloadHref ? (
|
||||||
<a
|
<a
|
||||||
className="button-link with-icon"
|
className="icon-button"
|
||||||
href={downloadHref}
|
href={downloadHref}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
|
title="Download"
|
||||||
|
aria-label="Download document"
|
||||||
onClick={(event) => event.stopPropagation()}
|
onClick={(event) => event.stopPropagation()}
|
||||||
onAuxClick={(event) => event.stopPropagation()}
|
onAuxClick={(event) => event.stopPropagation()}
|
||||||
onContextMenu={(event) => event.stopPropagation()}
|
onContextMenu={(event) => event.stopPropagation()}
|
||||||
>
|
>
|
||||||
<DownloadIcon className="icon-inline" />
|
<DownloadIcon className="icon-inline" />
|
||||||
<span>Download</span>
|
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<span className="meta">No download</span>
|
<span className="meta">No download</span>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="danger"
|
className="icon-button danger"
|
||||||
|
title="Delete"
|
||||||
|
aria-label="Delete document"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
onDocumentDelete?.(doc.id);
|
onDocumentDelete?.(doc.id);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Delete
|
<TrashIcon className="icon-inline" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -838,3 +1059,139 @@ const DocumentsTable = ({
|
|||||||
|
|
||||||
export default DocumentsTable;
|
export default DocumentsTable;
|
||||||
export { DocumentThumbnailImage };
|
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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
<title>Papercrate</title>
|
<title>Papercrate</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<script src="/config.js"></script>
|
|
||||||
<main id="app"></main>
|
<main id="app"></main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+1206
-350
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,11 @@
|
|||||||
import React from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { DownloadIcon } from '../ui/icons';
|
import { resolveDocumentAssetUrl } from '../asset_manager';
|
||||||
import { formatFileSize } from '../utils/format';
|
import { formatFileSize } from '../utils/format';
|
||||||
|
import { DownloadIcon, TextScanIcon, AnalyzeIcon, CloseIcon } from '../ui/icons';
|
||||||
|
|
||||||
const PreviewWorkspace = ({
|
const PreviewWorkspace = ({
|
||||||
document,
|
document,
|
||||||
previewEntry,
|
previewEntry,
|
||||||
resolveApiPath,
|
|
||||||
onClose,
|
|
||||||
onRegenerateThumbnails,
|
|
||||||
}) => {
|
}) => {
|
||||||
if (!document) {
|
if (!document) {
|
||||||
return null;
|
return null;
|
||||||
@@ -15,59 +13,63 @@ const PreviewWorkspace = ({
|
|||||||
|
|
||||||
const title = document.title || document.original_name || 'Document';
|
const title = document.title || document.original_name || 'Document';
|
||||||
const mime = previewEntry?.contentType || document.content_type || 'application/pdf';
|
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 sizeBytes = Number(document.current_version?.size_bytes) || 0;
|
||||||
const sizeLabel = sizeBytes > 0 ? formatFileSize(sizeBytes) : null;
|
const sizeLabel = sizeBytes > 0 ? formatFileSize(sizeBytes) : null;
|
||||||
const metadata =
|
const metadata =
|
||||||
document.metadata && Object.keys(document.metadata).length > 0 ? document.metadata : null;
|
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 (
|
return (
|
||||||
<section className="preview-workspace">
|
<section className="preview-workspace">
|
||||||
<header className="preview-workspace__header">
|
<aside className="preview-workspace__sidebar">
|
||||||
<div className="preview-workspace__meta">
|
<div className="preview-workspace__info">
|
||||||
<button
|
{metadataSummary.length ? (
|
||||||
type="button"
|
<dl className="preview-workspace__summary">
|
||||||
className="secondary"
|
{metadataSummary.map(([label, value]) => (
|
||||||
onClick={() => onClose(document.folder_id ?? 'root')}
|
<div className="preview-workspace__summary-row" key={label}>
|
||||||
>
|
<dt>{label}</dt>
|
||||||
← Back
|
<dd>{value || '—'}</dd>
|
||||||
</button>
|
</div>
|
||||||
<div>
|
))}
|
||||||
<h2>{title}</h2>
|
</dl>
|
||||||
<span className="meta">
|
) : null}
|
||||||
{document.content_type || mime}
|
|
||||||
{sizeLabel ? ` · ${sizeLabel}` : ''}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="preview-workspace__actions">
|
{metadata ? (
|
||||||
<a
|
<section className="preview-workspace__metadata">
|
||||||
className="button-link with-icon"
|
<h4>Metadata payload</h4>
|
||||||
href={downloadHref || '#'}
|
<pre>{JSON.stringify(metadata, null, 2)}</pre>
|
||||||
target="_blank"
|
</section>
|
||||||
rel="noopener noreferrer"
|
) : null}
|
||||||
aria-disabled={!downloadHref}
|
</aside>
|
||||||
onClick={(event) => {
|
<div className="preview-workspace__viewer">
|
||||||
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 ? (
|
{!previewEntry?.url ? (
|
||||||
<div className="preview-workspace__message">Loading preview…</div>
|
<div className="preview-workspace__message">Loading preview…</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -78,15 +80,147 @@ const PreviewWorkspace = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{metadata && (
|
|
||||||
<section className="preview-workspace__metadata">
|
|
||||||
<h3>Metadata</h3>
|
|
||||||
<pre>{JSON.stringify(metadata, null, 2)}</pre>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default PreviewWorkspace;
|
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,18 +1,13 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { Navigate, useNavigate, useParams } from 'react-router-dom';
|
||||||
|
|
||||||
import { useAppShell } from '../appShellContext';
|
import { useAppShell } from '../appShellContext';
|
||||||
import PreviewWorkspace from '../preview/PreviewWorkspace';
|
|
||||||
|
|
||||||
const DocumentViewerRoute = () => {
|
const DocumentViewerRoute = () => {
|
||||||
const {
|
const {
|
||||||
previewWorkspaceDocument,
|
previewWorkspaceDocument,
|
||||||
previewWorkspaceEntry,
|
|
||||||
closeDocumentPreview,
|
|
||||||
handleThumbnailRegeneration,
|
|
||||||
ensurePreviewData,
|
ensurePreviewData,
|
||||||
notifyApiError,
|
notifyApiError,
|
||||||
resolveApiPath,
|
|
||||||
} = useAppShell();
|
} = useAppShell();
|
||||||
const { documentId } = useParams();
|
const { documentId } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -44,29 +39,15 @@ const DocumentViewerRoute = () => {
|
|||||||
};
|
};
|
||||||
}, [documentId, ensurePreviewData, notifyApiError, navigate]);
|
}, [documentId, ensurePreviewData, notifyApiError, navigate]);
|
||||||
|
|
||||||
const isReady =
|
if (!documentId) {
|
||||||
documentId && previewWorkspaceDocument && previewWorkspaceDocument.id === documentId;
|
return <Navigate to="/documents" replace />;
|
||||||
|
|
||||||
if (!isReady) {
|
|
||||||
return (
|
|
||||||
<main className="preview-main">
|
|
||||||
<div className="preview-workspace__message">Loading preview…</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
if (!previewWorkspaceDocument || previewWorkspaceDocument.id !== documentId) {
|
||||||
<main className="preview-main">
|
return <div className="preview-workspace__message">Loading preview…</div>;
|
||||||
<PreviewWorkspace
|
}
|
||||||
document={previewWorkspaceDocument}
|
|
||||||
previewEntry={previewWorkspaceEntry}
|
return <Navigate to="/documents" replace />;
|
||||||
resolveApiPath={resolveApiPath}
|
|
||||||
onClose={closeDocumentPreview}
|
|
||||||
onRegenerateThumbnails={handleThumbnailRegeneration}
|
|
||||||
/>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default DocumentViewerRoute;
|
export default DocumentViewerRoute;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,279 @@
|
|||||||
|
import React, { useMemo, useState, useCallback, useEffect } from 'react';
|
||||||
|
|
||||||
|
const SECTIONS = [
|
||||||
|
{
|
||||||
|
id: 'webdav',
|
||||||
|
label: 'WebDAV',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const SettingsModal = ({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
tokens = [],
|
||||||
|
loading = false,
|
||||||
|
creating = false,
|
||||||
|
deletingId = null,
|
||||||
|
onRefresh,
|
||||||
|
onCreate,
|
||||||
|
onDelete,
|
||||||
|
createdToken = null,
|
||||||
|
onDismissCreatedToken,
|
||||||
|
}) => {
|
||||||
|
const defaultSection = SECTIONS[0]?.id || 'webdav';
|
||||||
|
const [activeSection, setActiveSection] = useState(defaultSection);
|
||||||
|
const [newTokenLabel, setNewTokenLabel] = useState('');
|
||||||
|
const [newTokenExpires, setNewTokenExpires] = useState('');
|
||||||
|
const [formError, setFormError] = useState(null);
|
||||||
|
|
||||||
|
const handleBackdropClick = useCallback(() => {
|
||||||
|
onClose?.();
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const handleInnerClick = useCallback((event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setActiveSection(defaultSection);
|
||||||
|
setNewTokenLabel('');
|
||||||
|
setNewTokenExpires('');
|
||||||
|
setFormError(null);
|
||||||
|
}
|
||||||
|
}, [open, defaultSection]);
|
||||||
|
|
||||||
|
const formatDateTime = useCallback((value) => {
|
||||||
|
if (!value) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
const timestamp = new Date(value);
|
||||||
|
if (Number.isNaN(timestamp.getTime())) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return timestamp.toLocaleString();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(() => {
|
||||||
|
onRefresh?.();
|
||||||
|
}, [onRefresh]);
|
||||||
|
|
||||||
|
const handleCopyToken = useCallback(() => {
|
||||||
|
if (!createdToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (navigator?.clipboard?.writeText) {
|
||||||
|
navigator.clipboard.writeText(createdToken).catch(() => {});
|
||||||
|
}
|
||||||
|
}, [createdToken]);
|
||||||
|
|
||||||
|
const handleDismissSecret = useCallback(() => {
|
||||||
|
onDismissCreatedToken?.();
|
||||||
|
}, [onDismissCreatedToken]);
|
||||||
|
|
||||||
|
const handleCreateToken = useCallback(
|
||||||
|
async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setFormError(null);
|
||||||
|
let normalizedLabel = newTokenLabel.trim();
|
||||||
|
if (normalizedLabel.length === 0) {
|
||||||
|
normalizedLabel = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalizedExpires;
|
||||||
|
if (newTokenExpires) {
|
||||||
|
const parsed = new Date(newTokenExpires);
|
||||||
|
if (Number.isNaN(parsed.getTime())) {
|
||||||
|
setFormError('Enter a valid expiration date.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
normalizedExpires = parsed.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await onCreate?.({
|
||||||
|
label: normalizedLabel,
|
||||||
|
expires_at: normalizedExpires,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result !== false) {
|
||||||
|
setNewTokenLabel('');
|
||||||
|
setNewTokenExpires('');
|
||||||
|
setFormError(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[newTokenExpires, newTokenLabel, onCreate],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderWebdavSection = useMemo(() => {
|
||||||
|
const hasTokens = Array.isArray(tokens) && tokens.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-section">
|
||||||
|
<div className="settings-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary"
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? 'Refreshing…' : 'Refresh'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{createdToken ? (
|
||||||
|
<div className="settings-notice">
|
||||||
|
<p>
|
||||||
|
Copy this token now; you will not be able to view it again after closing this window.
|
||||||
|
</p>
|
||||||
|
<pre className="token-display">{createdToken}</pre>
|
||||||
|
<div className="settings-notice__actions">
|
||||||
|
<button type="button" className="secondary" onClick={handleCopyToken}>
|
||||||
|
Copy token
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={handleDismissSecret}>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<form className="settings-form" onSubmit={handleCreateToken}>
|
||||||
|
<div className="settings-form__field">
|
||||||
|
<label htmlFor="webdav-token-label">Label</label>
|
||||||
|
<input
|
||||||
|
id="webdav-token-label"
|
||||||
|
type="text"
|
||||||
|
value={newTokenLabel}
|
||||||
|
onChange={(event) => setNewTokenLabel(event.target.value)}
|
||||||
|
placeholder="Personal WebDAV token"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="settings-form__field">
|
||||||
|
<label htmlFor="webdav-token-expires">Expires at</label>
|
||||||
|
<input
|
||||||
|
id="webdav-token-expires"
|
||||||
|
type="datetime-local"
|
||||||
|
value={newTokenExpires}
|
||||||
|
onChange={(event) => setNewTokenExpires(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="settings-form__actions">
|
||||||
|
<button type="submit" disabled={creating}>
|
||||||
|
{creating ? 'Creating…' : 'Create token'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{formError ? <p className="settings-form__error">{formError}</p> : null}
|
||||||
|
|
||||||
|
{loading && !hasTokens ? (
|
||||||
|
<p className="settings-empty">Loading tokens…</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!loading && !hasTokens ? (
|
||||||
|
<p className="settings-empty">No WebDAV tokens yet.</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{hasTokens ? (
|
||||||
|
<table className="settings-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Label</th>
|
||||||
|
<th scope="col">Created</th>
|
||||||
|
<th scope="col">Last used</th>
|
||||||
|
<th scope="col">Expires</th>
|
||||||
|
<th scope="col">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{tokens.map((token) => {
|
||||||
|
const isRevoked = Boolean(token?.revoked_at);
|
||||||
|
return (
|
||||||
|
<tr key={token.id} className={isRevoked ? 'is-revoked' : undefined}>
|
||||||
|
<td>{token.label || '—'}</td>
|
||||||
|
<td>{formatDateTime(token.created_at)}</td>
|
||||||
|
<td>{formatDateTime(token.last_used_at)}</td>
|
||||||
|
<td>{formatDateTime(token.expires_at)}</td>
|
||||||
|
<td className="settings-table__actions">
|
||||||
|
{isRevoked ? (
|
||||||
|
<span className="settings-status">Revoked</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="danger"
|
||||||
|
onClick={() => onDelete?.(token.id)}
|
||||||
|
disabled={deletingId === token.id}
|
||||||
|
>
|
||||||
|
{deletingId === token.id ? 'Revoking…' : 'Revoke'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
tokens,
|
||||||
|
loading,
|
||||||
|
createdToken,
|
||||||
|
creating,
|
||||||
|
deletingId,
|
||||||
|
newTokenLabel,
|
||||||
|
newTokenExpires,
|
||||||
|
formError,
|
||||||
|
formatDateTime,
|
||||||
|
handleCopyToken,
|
||||||
|
handleCreateToken,
|
||||||
|
handleRefresh,
|
||||||
|
onDelete,
|
||||||
|
handleDismissSecret,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop" role="presentation" onClick={handleBackdropClick}>
|
||||||
|
<div
|
||||||
|
className="modal modal--panel settings-modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="settings-modal-title"
|
||||||
|
onClick={handleInnerClick}
|
||||||
|
>
|
||||||
|
<div className="panel-modal__header">
|
||||||
|
<h3 id="settings-modal-title">Settings</h3>
|
||||||
|
<button type="button" className="secondary" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="settings-modal__body">
|
||||||
|
<nav className="settings-modal__sidebar" aria-label="Settings sections">
|
||||||
|
<ul>
|
||||||
|
{SECTIONS.map((section) => (
|
||||||
|
<li key={section.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={section.id === activeSection ? 'active' : ''}
|
||||||
|
onClick={() => setActiveSection(section.id)}
|
||||||
|
>
|
||||||
|
{section.label}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<div className="settings-modal__content">
|
||||||
|
{activeSection === 'webdav' ? renderWebdavSection : <p>Select a settings section.</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SettingsModal;
|
||||||
@@ -1,5 +1,15 @@
|
|||||||
import React, { useCallback, useMemo } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { ChevronIcon, TrashIcon, EditIcon, FolderIcon } from '../ui/icons';
|
import {
|
||||||
|
ChevronIcon,
|
||||||
|
TrashIcon,
|
||||||
|
EditIcon,
|
||||||
|
FolderIcon,
|
||||||
|
ChevronsLeftIcon,
|
||||||
|
LogoutIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
|
SettingsIcon,
|
||||||
|
CheckIcon,
|
||||||
|
} from '../ui/icons';
|
||||||
|
|
||||||
import { getTagColorStyle } from '../utils/colors';
|
import { getTagColorStyle } from '../utils/colors';
|
||||||
|
|
||||||
@@ -20,13 +30,13 @@ const FolderNode = ({
|
|||||||
draggingFolderId,
|
draggingFolderId,
|
||||||
}) => {
|
}) => {
|
||||||
const isRoot = node.id === 'root';
|
const isRoot = node.id === 'root';
|
||||||
const hasChildren = node.children.length > 0;
|
const hasChildren = Boolean(node.hasChildren);
|
||||||
const canToggle = !isRoot && (hasChildren || !node.loaded);
|
const canToggle = hasChildren || !node.loaded;
|
||||||
const showChevron = !isRoot && hasChildren;
|
const showChevron = canToggle;
|
||||||
const icon = showChevron ? <ChevronIcon className="toggle-icon" /> : null;
|
const icon = showChevron ? <ChevronIcon className="toggle-icon" /> : null;
|
||||||
const canDrag = !isRoot;
|
const canDrag = !isRoot;
|
||||||
const isDragging = draggingFolderId === node.id;
|
const isDragging = draggingFolderId === node.id;
|
||||||
const isExpanded = isRoot ? true : Boolean(node.expanded);
|
const isExpanded = Boolean(node.expanded);
|
||||||
const rowClasses = ['folder-row'];
|
const rowClasses = ['folder-row'];
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
rowClasses.push('active');
|
rowClasses.push('active');
|
||||||
@@ -58,14 +68,12 @@ const FolderNode = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{!isRoot && (
|
<span
|
||||||
<span
|
className={`toggle${showChevron ? '' : ' invisible'}${isExpanded ? ' expanded' : ''}`}
|
||||||
className={`toggle${showChevron ? '' : ' invisible'}${isExpanded ? ' expanded' : ''}`}
|
onClick={handleToggleClick}
|
||||||
onClick={handleToggleClick}
|
>
|
||||||
>
|
{icon}
|
||||||
{icon}
|
</span>
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="name">
|
<span className="name">
|
||||||
<FolderIcon className="folder-icon-image" size={16} />
|
<FolderIcon className="folder-icon-image" size={16} />
|
||||||
{node.name}
|
{node.name}
|
||||||
@@ -136,6 +144,24 @@ const Sidebar = ({
|
|||||||
correspondents = [],
|
correspondents = [],
|
||||||
activeCorrespondentIds = [],
|
activeCorrespondentIds = [],
|
||||||
onToggleCorrespondentFilter,
|
onToggleCorrespondentFilter,
|
||||||
|
onManageTags,
|
||||||
|
onManageCorrespondents,
|
||||||
|
searchQuery = '',
|
||||||
|
onSearchChange,
|
||||||
|
onSearchSubmit,
|
||||||
|
onSearchClear,
|
||||||
|
isFilterActive,
|
||||||
|
appStatus,
|
||||||
|
loading,
|
||||||
|
previewActive,
|
||||||
|
onLogout,
|
||||||
|
status,
|
||||||
|
onCollapse,
|
||||||
|
tenantSlug,
|
||||||
|
tenants = [],
|
||||||
|
activeTenantId = null,
|
||||||
|
onSelectTenant,
|
||||||
|
onOpenSettings,
|
||||||
}) => {
|
}) => {
|
||||||
const sortedCorrespondents = useMemo(
|
const sortedCorrespondents = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -150,6 +176,84 @@ const Sidebar = ({
|
|||||||
);
|
);
|
||||||
const handleToggleTag = onToggleTagFilter || (() => {});
|
const handleToggleTag = onToggleTagFilter || (() => {});
|
||||||
const activeTagSet = new Set(activeTagIds);
|
const activeTagSet = new Set(activeTagIds);
|
||||||
|
const handleManageTags = onManageTags || (() => {});
|
||||||
|
const handleManageCorrespondents = onManageCorrespondents || (() => {});
|
||||||
|
const handleSearchInputChange = useCallback(
|
||||||
|
(event) => {
|
||||||
|
onSearchChange?.(event.target.value);
|
||||||
|
},
|
||||||
|
[onSearchChange],
|
||||||
|
);
|
||||||
|
const handleSearchFormSubmit = useCallback(
|
||||||
|
(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onSearchSubmit?.();
|
||||||
|
},
|
||||||
|
[onSearchSubmit],
|
||||||
|
);
|
||||||
|
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?.id || null;
|
||||||
|
if (!targetId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTenantMenuOpen(false);
|
||||||
|
onSelectTenant?.(tenant);
|
||||||
|
},
|
||||||
|
[onSelectTenant],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleLogoutFromMenu = useCallback(() => {
|
||||||
|
setTenantMenuOpen(false);
|
||||||
|
onLogout?.();
|
||||||
|
}, [onLogout]);
|
||||||
|
|
||||||
|
const handleSettingsFromMenu = useCallback(() => {
|
||||||
|
setTenantMenuOpen(false);
|
||||||
|
onOpenSettings?.();
|
||||||
|
}, [onOpenSettings]);
|
||||||
|
|
||||||
const renderNodes = useCallback(
|
const renderNodes = useCallback(
|
||||||
(ids, depth) =>
|
(ids, depth) =>
|
||||||
@@ -193,30 +297,137 @@ const Sidebar = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const rootNode = folderNodes.get('root');
|
const rootNode = folderNodes.get('root');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="sidebar column">
|
<aside className="sidebar">
|
||||||
<div className="sidebar-section sidebar-section--folders">
|
<div className="panel-header sidebar__header">
|
||||||
<div className="sidebar-section__header">
|
<div className="panel-actions">
|
||||||
<h3>Folders</h3>
|
<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?.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__check-slot">
|
||||||
|
{isActive ? <CheckIcon size={16} /> : null}
|
||||||
|
</span>
|
||||||
|
<span className="menu__label">
|
||||||
|
{tenant?.slug || tenant?.name || tenantId || 'Tenant'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="menu__footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="menu__settings"
|
||||||
|
onClick={handleSettingsFromMenu}
|
||||||
|
>
|
||||||
|
<SettingsIcon size={16} />
|
||||||
|
Settings
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="menu__logout"
|
||||||
|
onClick={handleLogoutFromMenu}
|
||||||
|
>
|
||||||
|
<LogoutIcon size={16} />
|
||||||
|
Log out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="spacer" />
|
||||||
|
{onCollapse ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button ghost"
|
||||||
|
onClick={onCollapse}
|
||||||
|
aria-label="Collapse sidebar"
|
||||||
|
title="Collapse sidebar"
|
||||||
|
>
|
||||||
|
<ChevronsLeftIcon />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<ul className="folder-tree">
|
|
||||||
{rootNode && renderNodes([rootNode.id], 0)}
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="sidebar-section">
|
<div className="panel-body sidebar__body">
|
||||||
<div className="sidebar-section__header">
|
{status && (
|
||||||
<h3>Tags</h3>
|
<div className="sidebar__status">
|
||||||
<span className="meta">{tags.length}</span>
|
<div className={`status-banner ${status.variant}`}>{status.message}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{onSearchChange && (
|
||||||
|
<form className="sidebar__search" onSubmit={handleSearchFormSubmit}>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={handleSearchInputChange}
|
||||||
|
placeholder="Search documents"
|
||||||
|
aria-label="Search documents"
|
||||||
|
/>
|
||||||
|
{isFilterActive && (
|
||||||
|
<button type="button" onClick={handleSearchClear}>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
<div className="sidebar-section sidebar-section--folders">
|
||||||
|
<div className="sidebar-section__header">
|
||||||
|
<h3>Folders</h3>
|
||||||
|
</div>
|
||||||
|
<ul className="folder-tree">
|
||||||
|
{rootNode && renderNodes([rootNode.id], 0)}
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className="sidebar-section">
|
||||||
className={`sidebar-tag-cloud${
|
<div className="sidebar-section__header">
|
||||||
activeTagSet.size ? ' sidebar-tag-cloud--has-active' : ''
|
<button
|
||||||
}`}
|
type="button"
|
||||||
role="list"
|
className="sidebar-section__title"
|
||||||
>
|
onClick={handleManageTags}
|
||||||
{tags.length ? (
|
>
|
||||||
tags.map((tag) => {
|
<h3>Tags</h3>
|
||||||
|
<span className="meta">{tags.length}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`sidebar-tag-cloud${
|
||||||
|
activeTagSet.size ? ' sidebar-tag-cloud--has-active' : ''
|
||||||
|
}`}
|
||||||
|
role="list"
|
||||||
|
>
|
||||||
|
{tags.map((tag) => {
|
||||||
const isActive = activeTagSet.has(tag.id);
|
const isActive = activeTagSet.has(tag.id);
|
||||||
const style = getTagColorStyle(tag.color);
|
const style = getTagColorStyle(tag.color);
|
||||||
const className = `sidebar-tag-pill${isActive ? ' active' : ''}`;
|
const className = `sidebar-tag-pill${isActive ? ' active' : ''}`;
|
||||||
@@ -249,20 +460,22 @@ const Sidebar = ({
|
|||||||
{tag.label}
|
{tag.label}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})
|
})}
|
||||||
) : (
|
</div>
|
||||||
<span className="meta">No tags yet</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="sidebar-section">
|
||||||
<div className="sidebar-section">
|
<div className="sidebar-section__header">
|
||||||
<div className="sidebar-section__header">
|
<button
|
||||||
<h3>Correspondents</h3>
|
type="button"
|
||||||
<span className="meta">{correspondents.length}</span>
|
className="sidebar-section__title"
|
||||||
</div>
|
onClick={handleManageCorrespondents}
|
||||||
<ul className="sidebar-correspondent-list">
|
>
|
||||||
{sortedCorrespondents.length ? (
|
<h3>Correspondents</h3>
|
||||||
sortedCorrespondents.map((correspondent) => {
|
<span className="meta">{correspondents.length}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ul className="sidebar-correspondent-list">
|
||||||
|
{sortedCorrespondents.map((correspondent) => {
|
||||||
const isActive = activeCorrespondentSet.has(correspondent.id);
|
const isActive = activeCorrespondentSet.has(correspondent.id);
|
||||||
const className = `sidebar-correspondent-item${isActive ? ' active' : ''}`;
|
const className = `sidebar-correspondent-item${isActive ? ' active' : ''}`;
|
||||||
const label = correspondent.name || 'Unnamed';
|
const label = correspondent.name || 'Unnamed';
|
||||||
@@ -287,11 +500,9 @@ const Sidebar = ({
|
|||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})
|
})}
|
||||||
) : (
|
</ul>
|
||||||
<li className="meta">No correspondents yet</li>
|
</div>
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+1208
-435
File diff suppressed because it is too large
Load Diff
+115
-17
@@ -1,12 +1,26 @@
|
|||||||
import React, { useCallback, useMemo, useState } from 'react';
|
import React, { useCallback, useMemo, useState } from 'react';
|
||||||
import { getTagColorStyle, HEX_COLOR_PATTERN } from '../utils/colors';
|
import {
|
||||||
|
getTagColorStyle,
|
||||||
|
HEX_COLOR_PATTERN,
|
||||||
|
generateRandomTagColor,
|
||||||
|
} from '../utils/colors';
|
||||||
|
|
||||||
function TagsPanel({ tags, onRefresh, onUpdateTag, onDeleteTag, onNotify }) {
|
function TagsPanel({
|
||||||
|
tags,
|
||||||
|
onRefresh,
|
||||||
|
onCreateTag,
|
||||||
|
onUpdateTag,
|
||||||
|
onDeleteTag,
|
||||||
|
onNotify,
|
||||||
|
}) {
|
||||||
const [editingId, setEditingId] = useState(null);
|
const [editingId, setEditingId] = useState(null);
|
||||||
const [draftLabel, setDraftLabel] = useState('');
|
const [draftLabel, setDraftLabel] = useState('');
|
||||||
const [draftColor, setDraftColor] = useState('');
|
const [draftColor, setDraftColor] = useState('');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [deletingId, setDeletingId] = useState(null);
|
const [deletingId, setDeletingId] = useState(null);
|
||||||
|
const [createLabel, setCreateLabel] = useState('');
|
||||||
|
const [createColor, setCreateColor] = useState('');
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
||||||
const startEdit = useCallback((tag) => {
|
const startEdit = useCallback((tag) => {
|
||||||
setEditingId(tag.id);
|
setEditingId(tag.id);
|
||||||
@@ -98,25 +112,101 @@ function TagsPanel({ tags, onRefresh, onUpdateTag, onDeleteTag, onNotify }) {
|
|||||||
[onDeleteTag, editingId, cancelEdit, onNotify],
|
[onDeleteTag, editingId, cancelEdit, onNotify],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleCreate = useCallback(
|
||||||
|
async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (typeof onCreateTag !== 'function') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedLabel = createLabel.trim();
|
||||||
|
if (!trimmedLabel) {
|
||||||
|
onNotify?.('Tag label cannot be empty.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedColor = createColor.trim();
|
||||||
|
const colorPattern = /^#([0-9a-fA-F]{6})$/;
|
||||||
|
if (trimmedColor && !colorPattern.test(trimmedColor)) {
|
||||||
|
onNotify?.('Colors must use the #RRGGBB format.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCreating(true);
|
||||||
|
try {
|
||||||
|
await onCreateTag({
|
||||||
|
label: trimmedLabel,
|
||||||
|
color: trimmedColor ? trimmedColor : null,
|
||||||
|
});
|
||||||
|
setCreateLabel('');
|
||||||
|
setCreateColor('');
|
||||||
|
} catch (createError) {
|
||||||
|
const message = createError?.message || 'Failed to create tag.';
|
||||||
|
onNotify?.(message, 'error');
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[createLabel, createColor, onCreateTag, onNotify],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="tags-panel column">
|
<section className="tags-panel">
|
||||||
<div className="column-header">
|
<div className="panel-section__header">
|
||||||
<div className="column-header__titles">
|
<div className="panel-section__titles">
|
||||||
<h2>Tags</h2>
|
<h2>Tags</h2>
|
||||||
<div className="column-subtitle">{tags.length} total</div>
|
<div className="panel-section__subtitle">{tags.length} total</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="header-actions">
|
<div className="header-actions tags-actions">
|
||||||
|
<form className="tags-actions__form" onSubmit={handleCreate}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="New tag label"
|
||||||
|
value={createLabel}
|
||||||
|
onChange={(event) => setCreateLabel(event.target.value)}
|
||||||
|
disabled={creating}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
className="tags-table__color-picker"
|
||||||
|
value={createColor || '#3366ff'}
|
||||||
|
onChange={(event) => setCreateColor(event.target.value)}
|
||||||
|
disabled={creating}
|
||||||
|
aria-label="Tag color (optional)"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary"
|
||||||
|
onClick={() => setCreateColor(generateRandomTagColor())}
|
||||||
|
disabled={creating}
|
||||||
|
>
|
||||||
|
Random color
|
||||||
|
</button>
|
||||||
|
{createColor && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary"
|
||||||
|
onClick={() => setCreateColor('')}
|
||||||
|
disabled={creating}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button type="submit" disabled={creating || !createLabel.trim()}>
|
||||||
|
{creating ? 'Creating…' : 'Create'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
<button
|
<button
|
||||||
className="secondary"
|
className="secondary"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onRefresh}
|
onClick={onRefresh}
|
||||||
disabled={saving || Boolean(deletingId)}
|
disabled={saving || creating || Boolean(deletingId)}
|
||||||
>
|
>
|
||||||
Refresh
|
Refresh
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="column-body tags-panel__body">
|
<div className="panel-section__body tags-panel__body">
|
||||||
{tags.length === 0 ? (
|
{tags.length === 0 ? (
|
||||||
<div className="empty-state">No tags created yet.</div>
|
<div className="empty-state">No tags created yet.</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -166,14 +256,22 @@ function TagsPanel({ tags, onRefresh, onUpdateTag, onDeleteTag, onNotify }) {
|
|||||||
className="tags-table__color-picker"
|
className="tags-table__color-picker"
|
||||||
value={colorPickerValue}
|
value={colorPickerValue}
|
||||||
onChange={(event) => setDraftColor(event.target.value)}
|
onChange={(event) => setDraftColor(event.target.value)}
|
||||||
disabled={saving || deletingId === tag.id}
|
disabled={saving || deletingId === tag.id}
|
||||||
aria-label="Pick tag color"
|
aria-label="Pick tag color"
|
||||||
/>
|
/>
|
||||||
{draftColor && (
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
className="secondary"
|
||||||
className="secondary"
|
onClick={() => setDraftColor(generateRandomTagColor())}
|
||||||
onClick={() => setDraftColor('')}
|
disabled={saving || deletingId === tag.id}
|
||||||
|
>
|
||||||
|
Random color
|
||||||
|
</button>
|
||||||
|
{draftColor && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary"
|
||||||
|
onClick={() => setDraftColor('')}
|
||||||
disabled={saving || deletingId === tag.id}
|
disabled={saving || deletingId === tag.id}
|
||||||
>
|
>
|
||||||
Clear
|
Clear
|
||||||
|
|||||||
@@ -9,6 +9,20 @@ import {
|
|||||||
IconLayoutGrid,
|
IconLayoutGrid,
|
||||||
IconArrowLeft,
|
IconArrowLeft,
|
||||||
IconArrowRight,
|
IconArrowRight,
|
||||||
|
IconArrowUp,
|
||||||
|
IconChevronsRight,
|
||||||
|
IconChevronsLeft,
|
||||||
|
IconAnalyze,
|
||||||
|
IconWindowMaximize,
|
||||||
|
IconTextScan2,
|
||||||
|
IconFolderPlus,
|
||||||
|
IconRefresh,
|
||||||
|
IconMinusVertical,
|
||||||
|
IconLogout,
|
||||||
|
IconChevronDown,
|
||||||
|
IconX,
|
||||||
|
IconSettings,
|
||||||
|
IconCheck,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import FolderSvg from '../assets/folder.svg';
|
import FolderSvg from '../assets/folder.svg';
|
||||||
|
|
||||||
@@ -120,6 +134,123 @@ export const ArrowRightIcon = ({ className, size = '1em', stroke = 1.6, ...rest
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const ChevronsLeftIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
|
<IconChevronsLeft
|
||||||
|
className={composeClassName('icon', className)}
|
||||||
|
size={size}
|
||||||
|
stroke={stroke}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ChevronsRightIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
|
<IconChevronsRight
|
||||||
|
className={composeClassName('icon', className)}
|
||||||
|
size={size}
|
||||||
|
stroke={stroke}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const FolderPlusIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
|
<IconFolderPlus
|
||||||
|
className={composeClassName('icon', className)}
|
||||||
|
size={size}
|
||||||
|
stroke={stroke}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const RefreshIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
|
<IconRefresh
|
||||||
|
className={composeClassName('icon', className)}
|
||||||
|
size={size}
|
||||||
|
stroke={stroke}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ArrowUpIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
|
<IconArrowUp
|
||||||
|
className={composeClassName('icon', className)}
|
||||||
|
size={size}
|
||||||
|
stroke={stroke}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const MinusVerticalIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
|
<IconMinusVertical
|
||||||
|
className={composeClassName('icon', className)}
|
||||||
|
size={size}
|
||||||
|
stroke={stroke}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const CloseIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
|
<IconX
|
||||||
|
className={composeClassName('icon', className)}
|
||||||
|
size={size}
|
||||||
|
stroke={stroke}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const SettingsIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
|
<IconSettings
|
||||||
|
className={composeClassName('icon', className)}
|
||||||
|
size={size}
|
||||||
|
stroke={stroke}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const CheckIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
|
<IconCheck
|
||||||
|
className={composeClassName('icon', className)}
|
||||||
|
size={size}
|
||||||
|
stroke={stroke}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const AnalyzeIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
|
<IconAnalyze
|
||||||
|
className={composeClassName('icon', className)}
|
||||||
|
size={size}
|
||||||
|
stroke={stroke}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const WindowMaximizeIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
|
<IconWindowMaximize
|
||||||
|
className={composeClassName('icon', className)}
|
||||||
|
size={size}
|
||||||
|
stroke={stroke}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
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 {
|
export default {
|
||||||
ChevronIcon,
|
ChevronIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
@@ -130,4 +261,23 @@ export default {
|
|||||||
DownloadIcon,
|
DownloadIcon,
|
||||||
ViewListIcon,
|
ViewListIcon,
|
||||||
ViewGridIcon,
|
ViewGridIcon,
|
||||||
|
ChevronsLeftIcon,
|
||||||
|
ChevronsRightIcon,
|
||||||
|
AnalyzeIcon,
|
||||||
|
WindowMaximizeIcon,
|
||||||
|
FolderPlusIcon,
|
||||||
|
RefreshIcon,
|
||||||
|
ArrowUpIcon,
|
||||||
|
MinusVerticalIcon,
|
||||||
|
LogoutIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const TextScanIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
|
<IconTextScan2
|
||||||
|
className={composeClassName('icon', className)}
|
||||||
|
size={size}
|
||||||
|
stroke={stroke}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ const HEX_COLOR_PATTERN = /^#?([0-9a-fA-F]{6})$/;
|
|||||||
|
|
||||||
const clamp01 = (value) => Math.min(1, Math.max(0, value));
|
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) =>
|
const gammaEncode = (channel) =>
|
||||||
channel <= 0.0031308 ? 12.92 * channel : 1.055 * Math.pow(channel, 1 / 2.4) - 0.055;
|
channel <= 0.0031308 ? 12.92 * channel : 1.055 * Math.pow(channel, 1 / 2.4) - 0.055;
|
||||||
|
|
||||||
@@ -77,6 +79,37 @@ const hslToHex = (h, s, l) => {
|
|||||||
return `#${((sr << 16) | (sg << 8) | sb).toString(16).padStart(6, '0')}`;
|
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) => {
|
export const hexToRgb = (input) => {
|
||||||
if (!input) return null;
|
if (!input) return null;
|
||||||
const match = HEX_COLOR_PATTERN.exec(input.trim());
|
const match = HEX_COLOR_PATTERN.exec(input.trim());
|
||||||
@@ -102,20 +135,184 @@ export const relativeLuminance = ({ r, g, b }) => {
|
|||||||
return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
|
return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getReadableTextColor = (hex, { light = '#1f1f1f', dark = '#ffffff' } = {}) => {
|
const contrastRatio = (lumA, lumB) => {
|
||||||
const rgb = hexToRgb(hex);
|
const [lighter, darker] = lumA >= lumB ? [lumA, lumB] : [lumB, lumA];
|
||||||
if (!rgb) return dark;
|
return (lighter + 0.05) / (darker + 0.05);
|
||||||
const luminance = relativeLuminance(rgb);
|
};
|
||||||
return luminance > 0.6 ? light : dark;
|
|
||||||
|
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 getTagColorStyle = (hex) => {
|
export const getTagColorStyle = (hex) => {
|
||||||
const rgb = hexToRgb(hex);
|
const rgb = hexToRgb(hex);
|
||||||
if (!rgb) return null;
|
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 {
|
return {
|
||||||
backgroundColor: rgb.hex,
|
backgroundColor: bestBackground,
|
||||||
borderColor: rgb.hex,
|
borderColor: bestBackground,
|
||||||
color: getReadableTextColor(rgb.hex),
|
color: bestText,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,5 @@
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||||
const webpack = require('webpack');
|
|
||||||
const dotenv = require('dotenv');
|
|
||||||
|
|
||||||
const env = dotenv.config({ path: path.resolve(__dirname, '.env.local') }).parsed || {};
|
|
||||||
|
|
||||||
const DEFAULT_DEV_API = 'http://127.0.0.1:3000';
|
|
||||||
|
|
||||||
const API_BASE_URL = env.API_BASE_URL || process.env.API_BASE_URL || DEFAULT_DEV_API;
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
entry: './src/index.jsx',
|
entry: './src/index.jsx',
|
||||||
output: {
|
output: {
|
||||||
@@ -46,9 +37,6 @@ module.exports = {
|
|||||||
template: path.resolve(__dirname, 'src/index.html'),
|
template: path.resolve(__dirname, 'src/index.html'),
|
||||||
favicon: false,
|
favicon: false,
|
||||||
}),
|
}),
|
||||||
new webpack.DefinePlugin({
|
|
||||||
'process.env.API_BASE_URL': JSON.stringify(API_BASE_URL),
|
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
devServer: {
|
devServer: {
|
||||||
static: {
|
static: {
|
||||||
@@ -58,6 +46,14 @@ module.exports = {
|
|||||||
port: 5173,
|
port: 5173,
|
||||||
historyApiFallback: true,
|
historyApiFallback: true,
|
||||||
open: true,
|
open: true,
|
||||||
|
proxy: [
|
||||||
|
{
|
||||||
|
context: ['/api'],
|
||||||
|
target: 'http://127.0.0.1:3000',
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
devtool: 'source-map',
|
devtool: 'source-map',
|
||||||
resolve: {
|
resolve: {
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ spec:
|
|||||||
containerPort: {{ .Values.frontend.service.port }}
|
containerPort: {{ .Values.frontend.service.port }}
|
||||||
protocol: TCP
|
protocol: TCP
|
||||||
env:
|
env:
|
||||||
|
- name: API_PROXY_PASS
|
||||||
|
value: {{ printf "http://%s-backend:%d" (include "papercrate.fullname" . ) (int (.Values.backend.service.port)) | quote }}
|
||||||
{{- range .Values.frontend.env.extra }}
|
{{- range .Values.frontend.env.extra }}
|
||||||
- name: {{ .name }}
|
- name: {{ .name }}
|
||||||
value: {{ .value | quote }}
|
value: {{ .value | quote }}
|
||||||
|
|||||||
Reference in New Issue
Block a user