Compare commits
29
Commits
ai
..
0052dff119
| 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 |
Generated
+42
@@ -611,6 +611,7 @@ dependencies = [
|
||||
"diesel",
|
||||
"diesel_migrations",
|
||||
"dotenv",
|
||||
"envy",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"http-body-util",
|
||||
@@ -625,6 +626,7 @@ dependencies = [
|
||||
"rand 0.8.5",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde-aux",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
@@ -1175,6 +1177,15 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "envy"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f47e0157f2cb54f5ae1bd371b30a2ae4311e1c028f575cd4e81de7353215965"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@@ -2160,6 +2171,15 @@ version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
||||
|
||||
[[package]]
|
||||
name = "ordered-float"
|
||||
version = "2.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "outref"
|
||||
version = "0.5.2"
|
||||
@@ -2904,6 +2924,28 @@ dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde-aux"
|
||||
version = "4.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "207f67b28fe90fb596503a9bf0bf1ea5e831e21307658e177c5dfcdfc3ab8a0a"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"serde",
|
||||
"serde-value",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde-value"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c"
|
||||
dependencies = [
|
||||
"ordered-float",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
|
||||
@@ -25,6 +25,8 @@ aws-credential-types = "1.2"
|
||||
# Serialization
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
envy = "0.4"
|
||||
serde-aux = "4.4"
|
||||
|
||||
# Utilities
|
||||
tracing = "0.1"
|
||||
|
||||
@@ -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 password;
|
||||
pub mod webdav_tokens;
|
||||
|
||||
use axum::{async_trait, extract::FromRequestParts, http::request::Parts};
|
||||
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 url::Url;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_aux::field_attributes::deserialize_bool_from_anything;
|
||||
|
||||
use crate::db::DEFAULT_MAX_POOL_SIZE;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct AppConfig {
|
||||
pub database_url: String,
|
||||
#[serde(default = "default_database_max_pool_size")]
|
||||
pub database_max_pool_size: u32,
|
||||
#[serde(default = "default_server_host")]
|
||||
pub server_host: String,
|
||||
#[serde(default = "default_server_port")]
|
||||
pub server_port: u16,
|
||||
#[serde(default = "default_webdav_host")]
|
||||
pub webdav_host: String,
|
||||
#[serde(default = "default_webdav_port")]
|
||||
pub webdav_port: u16,
|
||||
pub jwt_secret: String,
|
||||
#[serde(default = "default_jwt_issuer")]
|
||||
pub jwt_issuer: String,
|
||||
#[serde(default = "default_jwt_audience")]
|
||||
pub jwt_audience: String,
|
||||
#[serde(default = "default_jwt_expiry_minutes")]
|
||||
pub jwt_expiry_minutes: i64,
|
||||
#[serde(default = "default_download_token_audience")]
|
||||
pub download_token_audience: String,
|
||||
#[serde(default = "default_download_token_expiry_minutes")]
|
||||
pub download_token_expiry_minutes: i64,
|
||||
#[serde(default = "default_refresh_token_expiry_days")]
|
||||
pub refresh_token_expiry_days: i64,
|
||||
#[serde(
|
||||
default = "default_refresh_cookie_secure",
|
||||
deserialize_with = "deserialize_bool_from_anything"
|
||||
)]
|
||||
pub refresh_cookie_secure: bool,
|
||||
#[serde(default)]
|
||||
pub refresh_cookie_domain: Option<String>,
|
||||
#[serde(default)]
|
||||
pub cors_allowed_origin: Option<String>,
|
||||
#[serde(default)]
|
||||
pub aws_endpoint_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub aws_access_key_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub aws_secret_access_key: Option<String>,
|
||||
#[serde(default = "default_aws_region")]
|
||||
pub aws_region: String,
|
||||
pub s3_bucket: String,
|
||||
#[serde(default)]
|
||||
pub quickwit_endpoint: Option<String>,
|
||||
#[serde(default)]
|
||||
pub quickwit_index: Option<String>,
|
||||
#[serde(default = "default_tenant_slug")]
|
||||
pub default_tenant_slug: String,
|
||||
}
|
||||
|
||||
@@ -49,80 +74,9 @@ impl AppConfig {
|
||||
}
|
||||
|
||||
pub fn from_env() -> Result<Self> {
|
||||
let database_url = env::var("DATABASE_URL").context("DATABASE_URL must be set")?;
|
||||
let database_max_pool_size = env::var("DATABASE_MAX_POOL_SIZE")
|
||||
.ok()
|
||||
.and_then(|value| value.parse().ok())
|
||||
.unwrap_or(DEFAULT_MAX_POOL_SIZE);
|
||||
let server_host = env::var("SERVER_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
|
||||
let server_port = env::var("SERVER_PORT")
|
||||
.unwrap_or_else(|_| "3000".to_string())
|
||||
.parse()
|
||||
.context("SERVER_PORT must be a valid u16")?;
|
||||
let webdav_host = env::var("WEBDAV_HOST").unwrap_or_else(|_| server_host.clone());
|
||||
let webdav_port = env::var("WEBDAV_PORT")
|
||||
.unwrap_or_else(|_| "3001".to_string())
|
||||
.parse()
|
||||
.context("WEBDAV_PORT must be a valid u16")?;
|
||||
let jwt_secret = env::var("JWT_SECRET").context("JWT_SECRET must be set")?;
|
||||
let jwt_issuer = env::var("JWT_ISSUER").unwrap_or_else(|_| "papercrate".to_string());
|
||||
let jwt_audience =
|
||||
env::var("JWT_AUDIENCE").unwrap_or_else(|_| "papercrate-clients".to_string());
|
||||
let jwt_expiry_minutes = env::var("JWT_EXPIRY_MINUTES")
|
||||
.unwrap_or_else(|_| "60".to_string())
|
||||
.parse()
|
||||
.context("JWT_EXPIRY_MINUTES must be an integer")?;
|
||||
let download_token_audience = env::var("DOWNLOAD_TOKEN_AUDIENCE")
|
||||
.unwrap_or_else(|_| "papercrate-download".to_string());
|
||||
let download_token_expiry_minutes = env::var("DOWNLOAD_TOKEN_EXPIRY_MINUTES")
|
||||
.unwrap_or_else(|_| "60".to_string())
|
||||
.parse()
|
||||
.context("DOWNLOAD_TOKEN_EXPIRY_MINUTES must be an integer")?;
|
||||
let refresh_token_expiry_days = env::var("REFRESH_TOKEN_EXPIRY_DAYS")
|
||||
.unwrap_or_else(|_| "30".to_string())
|
||||
.parse()
|
||||
.context("REFRESH_TOKEN_EXPIRY_DAYS must be an integer")?;
|
||||
let refresh_cookie_secure = env::var("REFRESH_COOKIE_SECURE")
|
||||
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
.unwrap_or(false);
|
||||
let refresh_cookie_domain = env::var("REFRESH_COOKIE_DOMAIN").ok();
|
||||
let cors_allowed_origin = env::var("CORS_ALLOWED_ORIGIN").ok();
|
||||
let aws_endpoint_url = env::var("AWS_ENDPOINT_URL").ok();
|
||||
let aws_access_key_id = env::var("AWS_ACCESS_KEY_ID").ok();
|
||||
let aws_secret_access_key = env::var("AWS_SECRET_ACCESS_KEY").ok();
|
||||
let aws_region = env::var("AWS_REGION").unwrap_or_else(|_| "us-east-1".to_string());
|
||||
let s3_bucket = env::var("S3_BUCKET").context("S3_BUCKET must be set")?;
|
||||
let quickwit_endpoint = env::var("QUICKWIT_ENDPOINT").ok();
|
||||
let quickwit_index = env::var("QUICKWIT_INDEX").ok();
|
||||
let default_tenant_slug =
|
||||
env::var("DEFAULT_TENANT_SLUG").unwrap_or_else(|_| "admin".to_string());
|
||||
|
||||
Ok(Self {
|
||||
database_url,
|
||||
database_max_pool_size,
|
||||
server_host,
|
||||
server_port,
|
||||
webdav_host,
|
||||
webdav_port,
|
||||
jwt_secret,
|
||||
jwt_issuer,
|
||||
jwt_audience,
|
||||
jwt_expiry_minutes,
|
||||
download_token_audience,
|
||||
download_token_expiry_minutes,
|
||||
refresh_token_expiry_days,
|
||||
refresh_cookie_secure,
|
||||
refresh_cookie_domain,
|
||||
cors_allowed_origin,
|
||||
aws_endpoint_url,
|
||||
aws_access_key_id,
|
||||
aws_secret_access_key,
|
||||
aws_region,
|
||||
s3_bucket,
|
||||
quickwit_endpoint,
|
||||
quickwit_index,
|
||||
default_tenant_slug,
|
||||
})
|
||||
let config: AppConfig = envy::from_env()
|
||||
.context("failed to parse application configuration from environment")?;
|
||||
Ok(config.normalize())
|
||||
}
|
||||
|
||||
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 {
|
||||
match Url::parse(raw) {
|
||||
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;
|
||||
|
||||
pub const QUICKWIT_MAX_HITS: usize = 200;
|
||||
|
||||
pub fn build_quickwit_query(input: &str) -> Option<String> {
|
||||
let tokens: Vec<String> = input
|
||||
.split_whitespace()
|
||||
@@ -38,6 +46,67 @@ pub fn escape_quickwit_token(token: &str) -> String {
|
||||
escaped
|
||||
}
|
||||
|
||||
pub async fn quickwit_search(
|
||||
endpoint: &str,
|
||||
index: &str,
|
||||
tenant_id: Uuid,
|
||||
query: &str,
|
||||
) -> Result<Vec<Uuid>> {
|
||||
let tenant_clause = format!("tenant_id:{}", tenant_id);
|
||||
let quickwit_query = match build_quickwit_query(query) {
|
||||
Some(q) => {
|
||||
debug!(%query, quickwit_query = %q, "built quickwit search query");
|
||||
format!("{} AND ({})", tenant_clause, q)
|
||||
}
|
||||
None => {
|
||||
debug!(%query, "quickwit search skipped because query produced no tokens");
|
||||
return Ok(vec![]);
|
||||
}
|
||||
};
|
||||
|
||||
let client = Client::new();
|
||||
let url = format!("{}/api/v1/{}/search", endpoint.trim_end_matches('/'), index);
|
||||
|
||||
let payload = json!({
|
||||
"query": quickwit_query,
|
||||
"max_hits": QUICKWIT_MAX_HITS,
|
||||
});
|
||||
|
||||
debug!(%url, payload = %payload, "sending quickwit search request");
|
||||
let response = client.post(url).json(&payload).send().await?;
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
error!(%status, body = %body, "quickwit search request failed");
|
||||
return Err(anyhow!(
|
||||
"quickwit search failed with status {status}: {body}"
|
||||
));
|
||||
}
|
||||
|
||||
let data: QuickwitSearchResponse = response.json().await?;
|
||||
debug!("quickwit search response parsed successfully");
|
||||
let QuickwitSearchResponse { hits } = data;
|
||||
|
||||
let total_hits = hits.len();
|
||||
let mut seen = HashSet::new();
|
||||
let mut doc_ids = Vec::with_capacity(total_hits);
|
||||
|
||||
for hit in hits {
|
||||
if let Some(doc_id) = extract_document_id(&hit) {
|
||||
if seen.insert(doc_id) {
|
||||
doc_ids.push(doc_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debug!(
|
||||
total_hits = total_hits,
|
||||
unique_ids = doc_ids.len(),
|
||||
"quickwit search completed"
|
||||
);
|
||||
Ok(doc_ids)
|
||||
}
|
||||
|
||||
pub fn extract_document_id(hit: &Value) -> Option<Uuid> {
|
||||
for key in ["_source", "source", "fields", "stored_fields"] {
|
||||
if let Some(value) = hit.get(key) {
|
||||
@@ -89,3 +158,9 @@ pub fn parse_uuid_value(value: &Value) -> Option<Uuid> {
|
||||
|
||||
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 config;
|
||||
pub mod db;
|
||||
pub mod documents;
|
||||
pub mod error;
|
||||
pub mod jobs;
|
||||
pub mod models;
|
||||
|
||||
+30
-5
@@ -58,6 +58,35 @@ pub struct NewUser {
|
||||
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)]
|
||||
#[diesel(table_name = folders)]
|
||||
pub struct Folder {
|
||||
@@ -123,7 +152,6 @@ pub struct DocumentVersion {
|
||||
pub size_bytes: i64,
|
||||
pub checksum: String,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub operations_summary: serde_json::Value,
|
||||
pub metadata: serde_json::Value,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
@@ -137,7 +165,6 @@ pub struct NewDocumentVersion {
|
||||
pub s3_key: String,
|
||||
pub size_bytes: i64,
|
||||
pub checksum: String,
|
||||
pub operations_summary: serde_json::Value,
|
||||
pub metadata: serde_json::Value,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
@@ -283,11 +310,10 @@ pub struct NewCorrespondent {
|
||||
#[diesel(table_name = document_correspondents)]
|
||||
#[diesel(belongs_to(Document))]
|
||||
#[diesel(belongs_to(Correspondent))]
|
||||
#[diesel(primary_key(document_id, correspondent_id, role))]
|
||||
#[diesel(primary_key(document_id, correspondent_id))]
|
||||
pub struct DocumentCorrespondent {
|
||||
pub document_id: Uuid,
|
||||
pub correspondent_id: Uuid,
|
||||
pub role: String,
|
||||
pub assigned_at: NaiveDateTime,
|
||||
pub assigned_by: Option<Uuid>,
|
||||
pub tenant_id: Uuid,
|
||||
@@ -298,7 +324,6 @@ pub struct DocumentCorrespondent {
|
||||
pub struct NewDocumentCorrespondent {
|
||||
pub document_id: Uuid,
|
||||
pub correspondent_id: Uuid,
|
||||
pub role: String,
|
||||
pub assigned_by: Option<Uuid>,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
+160
-43
@@ -11,6 +11,7 @@ use uuid::Uuid;
|
||||
doc::refresh,
|
||||
doc::logout,
|
||||
doc::me,
|
||||
doc::list_tenants,
|
||||
doc::select_tenant,
|
||||
doc::list_documents,
|
||||
doc::check_document,
|
||||
@@ -18,7 +19,6 @@ use uuid::Uuid;
|
||||
doc::get_document,
|
||||
doc::update_document,
|
||||
doc::delete_document,
|
||||
doc::download_document,
|
||||
doc::download_with_token,
|
||||
doc::move_document,
|
||||
doc::assign_tags,
|
||||
@@ -46,24 +46,28 @@ use uuid::Uuid;
|
||||
doc::create_correspondent,
|
||||
doc::update_correspondent,
|
||||
doc::delete_correspondent,
|
||||
doc::list_webdav_tokens,
|
||||
doc::create_webdav_token,
|
||||
doc::delete_webdav_token,
|
||||
),
|
||||
components(
|
||||
schemas(
|
||||
schemas::LoginRequest,
|
||||
schemas::AccessTokenResponse,
|
||||
schemas::TenantSummary,
|
||||
schemas::TenantSnippet,
|
||||
schemas::TenantSelectionResponse,
|
||||
schemas::TenantSelectionRequest,
|
||||
schemas::TenantListResponse,
|
||||
schemas::LoginResponseVariants,
|
||||
schemas::DocumentResponse,
|
||||
schemas::DocumentDetailResponse,
|
||||
schemas::DocumentVersion,
|
||||
schemas::DocumentVersionResponse,
|
||||
schemas::DocumentVersionDetailResponse,
|
||||
schemas::DocumentAssetSummary,
|
||||
schemas::DocumentAssetDetail,
|
||||
schemas::DocumentAssetObject,
|
||||
schemas::DocumentCorrespondent,
|
||||
schemas::DocumentTag,
|
||||
schemas::DocumentDownloadResponse,
|
||||
schemas::UpdateDocumentRequest,
|
||||
schemas::BulkMoveDocumentsRequest,
|
||||
schemas::BulkMoveDocumentsResponse,
|
||||
@@ -77,7 +81,6 @@ use uuid::Uuid;
|
||||
schemas::BulkCorrespondentsResponse,
|
||||
schemas::BulkCorrespondentAction,
|
||||
schemas::AssignCorrespondentsRequest,
|
||||
schemas::RemoveCorrespondentParams,
|
||||
schemas::ReanalyzeRequest,
|
||||
schemas::ReanalyzeResponse,
|
||||
schemas::DocumentAssetRequestParams,
|
||||
@@ -96,9 +99,13 @@ use uuid::Uuid;
|
||||
schemas::TagCatalogEntry,
|
||||
schemas::CreateTagRequest,
|
||||
schemas::UpdateTagRequest,
|
||||
schemas::CorrespondentUsage,
|
||||
schemas::CorrespondentCatalogEntry,
|
||||
schemas::CreateCorrespondentRequest,
|
||||
schemas::UpdateCorrespondentRequest,
|
||||
schemas::WebdavTokenResponse,
|
||||
schemas::WebdavTokenCreatedResponse,
|
||||
schemas::CreateWebdavTokenRequest,
|
||||
)
|
||||
),
|
||||
tags(
|
||||
@@ -108,7 +115,8 @@ use uuid::Uuid;
|
||||
(name = "Assets", description = "Document assets"),
|
||||
(name = "Folders", description = "Folder management"),
|
||||
(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;
|
||||
@@ -170,6 +178,14 @@ mod doc {
|
||||
)]
|
||||
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(
|
||||
post,
|
||||
path = "/api/auth/select-tenant",
|
||||
@@ -240,12 +256,34 @@ mod doc {
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/documents/{id}/download",
|
||||
path = "/api/documents/{id}/versions",
|
||||
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"
|
||||
)]
|
||||
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(
|
||||
get,
|
||||
@@ -330,8 +368,7 @@ mod doc {
|
||||
path = "/api/documents/{id}/correspondents/{correspondent_id}",
|
||||
params(
|
||||
("id" = Uuid, Path, description = "Document ID"),
|
||||
("correspondent_id" = Uuid, Path, description = "Correspondent ID"),
|
||||
RemoveCorrespondentParams
|
||||
("correspondent_id" = Uuid, Path, description = "Correspondent ID")
|
||||
),
|
||||
responses((status = 204, description = "Correspondent removed")),
|
||||
tag = "Documents"
|
||||
@@ -509,6 +546,32 @@ mod doc {
|
||||
tag = "Correspondents"
|
||||
)]
|
||||
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)]
|
||||
@@ -542,12 +605,6 @@ pub mod schemas {
|
||||
pub tenant: TenantSnippet,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct TenantSummary {
|
||||
pub tenant_id: Uuid,
|
||||
pub slug: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct TenantSnippet {
|
||||
pub id: Uuid,
|
||||
@@ -557,7 +614,7 @@ pub mod schemas {
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct TenantSelectionResponse {
|
||||
pub access_token: String,
|
||||
pub tenants: Vec<TenantSummary>,
|
||||
pub tenants: Vec<TenantSnippet>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
@@ -565,6 +622,11 @@ pub mod schemas {
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct TenantListResponse {
|
||||
pub tenants: Vec<TenantSnippet>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum LoginResponseVariants {
|
||||
@@ -576,11 +638,26 @@ pub mod schemas {
|
||||
#[into_params(parameter_in = Query)]
|
||||
pub struct DocumentListQuery {
|
||||
pub folder_id: Option<Uuid>,
|
||||
pub include_deleted: Option<bool>,
|
||||
#[schema(nullable)]
|
||||
pub include_descendants: Option<bool>,
|
||||
pub query: Option<String>,
|
||||
pub tags: Option<String>,
|
||||
pub correspondents: Option<String>,
|
||||
#[serde(default = "default_document_status_filter")]
|
||||
#[schema(default = "active")]
|
||||
pub status: DocumentStatusFilter,
|
||||
}
|
||||
|
||||
fn default_document_status_filter() -> DocumentStatusFilter {
|
||||
DocumentStatusFilter::Active
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum DocumentStatusFilter {
|
||||
Active,
|
||||
Deleted,
|
||||
All,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
@@ -625,17 +702,21 @@ pub mod schemas {
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct DocumentVersion {
|
||||
pub struct DocumentVersionResponse {
|
||||
pub id: Uuid,
|
||||
pub version_number: i32,
|
||||
pub checksum: String,
|
||||
pub size_bytes: i64,
|
||||
pub checksum: String,
|
||||
pub created_at: String,
|
||||
pub metadata: Value,
|
||||
#[schema(nullable)]
|
||||
pub operations_summary: Option<Value>,
|
||||
#[schema(nullable)]
|
||||
pub assets: Option<Vec<DocumentAssetSummary>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct DocumentVersionDetailResponse {
|
||||
#[serde(flatten)]
|
||||
pub version: DocumentVersionResponse,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub assets: Vec<DocumentAssetSummary>,
|
||||
pub download_path: String,
|
||||
}
|
||||
|
||||
@@ -643,7 +724,6 @@ pub mod schemas {
|
||||
pub struct DocumentCorrespondent {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub role: String,
|
||||
pub metadata: Value,
|
||||
pub assigned_at: String,
|
||||
}
|
||||
@@ -666,10 +746,10 @@ pub mod schemas {
|
||||
pub issued_at: Option<String>,
|
||||
pub metadata: Value,
|
||||
pub tags: Vec<DocumentTag>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub correspondents: Vec<DocumentCorrespondent>,
|
||||
#[schema(nullable)]
|
||||
pub correspondents: Option<Vec<DocumentCorrespondent>>,
|
||||
#[schema(nullable)]
|
||||
pub current_version: Option<DocumentVersion>,
|
||||
pub current_version: Option<DocumentVersionDetailResponse>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
@@ -678,19 +758,27 @@ pub mod schemas {
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct DocumentDownloadResponse {
|
||||
pub url: String,
|
||||
pub expires_in: u64,
|
||||
pub filename: String,
|
||||
pub struct DocumentMetadataUpdate {
|
||||
pub value: Value,
|
||||
#[serde(default)]
|
||||
#[schema(default = false)]
|
||||
pub replace: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct RestoreDocumentRequest {
|
||||
#[schema(nullable)]
|
||||
pub content_type: Option<String>,
|
||||
pub size_bytes: i64,
|
||||
pub folder_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct UpdateDocumentRequest {
|
||||
#[schema(nullable)]
|
||||
pub title: Option<String>,
|
||||
#[schema(nullable, value_type = Option<String>)]
|
||||
pub issued_at: Option<Value>,
|
||||
#[schema(nullable)]
|
||||
pub metadata: Option<DocumentMetadataUpdate>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
@@ -739,7 +827,6 @@ pub mod schemas {
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct CorrespondentAssignment {
|
||||
pub correspondent_id: Uuid,
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
@@ -774,12 +861,6 @@ pub mod schemas {
|
||||
pub removed: usize,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, IntoParams, ToSchema)]
|
||||
#[into_params(parameter_in = Query)]
|
||||
pub struct RemoveCorrespondentParams {
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct ReanalyzeRequest {
|
||||
pub document_ids: Vec<Uuid>,
|
||||
@@ -933,12 +1014,19 @@ pub mod schemas {
|
||||
pub color: Option<Option<String>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct CorrespondentUsage {
|
||||
pub total: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct CorrespondentCatalogEntry {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub metadata: Value,
|
||||
pub role_counts: Value,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub usage: CorrespondentUsage,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
@@ -955,4 +1043,33 @@ pub mod schemas {
|
||||
#[schema(nullable)]
|
||||
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>,
|
||||
}
|
||||
}
|
||||
|
||||
+20
-16
@@ -46,12 +46,6 @@ pub struct LoginResponse {
|
||||
pub tenant: TenantSnippet,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct TenantSummary {
|
||||
pub tenant_id: Uuid,
|
||||
pub slug: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct TenantSnippet {
|
||||
pub id: Uuid,
|
||||
@@ -61,7 +55,7 @@ pub struct TenantSnippet {
|
||||
#[derive(Serialize)]
|
||||
pub struct TenantSelectionResponse {
|
||||
pub access_token: String,
|
||||
pub tenants: Vec<TenantSummary>,
|
||||
pub tenants: Vec<TenantSnippet>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -80,9 +74,15 @@ pub async fn login(
|
||||
) -> AppResult<Response> {
|
||||
let mut conn = state.db_unscoped()?;
|
||||
|
||||
let user: User = dsl::users
|
||||
let user: Option<User> = dsl::users
|
||||
.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)
|
||||
.map_err(|_| AppError::unauthorized())?;
|
||||
@@ -126,8 +126,8 @@ pub async fn login(
|
||||
|
||||
let tenants = memberships
|
||||
.into_iter()
|
||||
.map(|(_, tenant)| TenantSummary {
|
||||
tenant_id: tenant.id,
|
||||
.map(|(_, tenant)| TenantSnippet {
|
||||
id: tenant.id,
|
||||
slug: tenant.slug,
|
||||
})
|
||||
.collect();
|
||||
@@ -186,15 +186,19 @@ pub async fn select_tenant(
|
||||
TypedHeader(Authorization(bearer)): TypedHeader<Authorization<Bearer>>,
|
||||
Json(payload): Json<TenantSelectionRequest>,
|
||||
) -> AppResult<Response> {
|
||||
let claims = state
|
||||
let user_id = match state.jwt.verify_tenant_selector_token(bearer.token()) {
|
||||
Ok(claims) => claims.sub,
|
||||
Err(_) => state
|
||||
.jwt
|
||||
.verify_tenant_selector_token(bearer.token())
|
||||
.map_err(|_| AppError::unauthorized())?;
|
||||
.verify_token(bearer.token())
|
||||
.map(|claims| claims.sub)
|
||||
.map_err(|_| AppError::unauthorized())?,
|
||||
};
|
||||
|
||||
let mut conn = state.db_unscoped()?;
|
||||
|
||||
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))
|
||||
.inner_join(tenant_dsl::tenants)
|
||||
.select(memberships_dsl::id)
|
||||
@@ -206,7 +210,7 @@ pub async fn select_tenant(
|
||||
}
|
||||
|
||||
let user: User = dsl::users
|
||||
.find(claims.sub)
|
||||
.find(user_id)
|
||||
.first(&mut conn)
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use axum::{extract::Path, http::StatusCode, Json};
|
||||
use chrono::Utc;
|
||||
@@ -21,8 +21,6 @@ use crate::{
|
||||
#[derive(Serialize)]
|
||||
pub struct CorrespondentUsage {
|
||||
pub total: i64,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub by_role: BTreeMap<String, i64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -67,31 +65,21 @@ pub async fn list_correspondents(
|
||||
.order(correspondents::name.asc())
|
||||
.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))
|
||||
.group_by((
|
||||
document_correspondents::correspondent_id,
|
||||
document_correspondents::role,
|
||||
))
|
||||
.select((
|
||||
document_correspondents::correspondent_id,
|
||||
document_correspondents::role,
|
||||
count_star(),
|
||||
))
|
||||
.group_by(document_correspondents::correspondent_id)
|
||||
.select((document_correspondents::correspondent_id, count_star()))
|
||||
.load(&mut conn)?;
|
||||
|
||||
let mut usage_map: HashMap<Uuid, BTreeMap<String, i64>> = HashMap::new();
|
||||
for (correspondent_id, role, count) in usage_rows {
|
||||
usage_map
|
||||
.entry(correspondent_id)
|
||||
.or_default()
|
||||
.insert(role, count);
|
||||
let mut usage_map: HashMap<Uuid, i64> = HashMap::new();
|
||||
for (correspondent_id, count) in usage_rows {
|
||||
usage_map.insert(correspondent_id, count);
|
||||
}
|
||||
|
||||
let mut response = Vec::with_capacity(correspondents_list.len());
|
||||
for correspondent in correspondents_list {
|
||||
let role_counts = usage_map.remove(&correspondent.id).unwrap_or_default();
|
||||
response.push(build_summary(correspondent, role_counts));
|
||||
let total = usage_map.remove(&correspondent.id).unwrap_or(0);
|
||||
response.push(build_summary(correspondent, total));
|
||||
}
|
||||
|
||||
response.into_json()
|
||||
@@ -136,7 +124,7 @@ pub async fn create_correspondent(
|
||||
.first(&mut conn)
|
||||
.one()?;
|
||||
|
||||
build_summary(correspondent, BTreeMap::new()).into_json()
|
||||
build_summary(correspondent, 0).into_json()
|
||||
}
|
||||
|
||||
pub async fn update_correspondent(
|
||||
@@ -245,21 +233,14 @@ pub async fn delete_correspondent(
|
||||
no_content()
|
||||
}
|
||||
|
||||
fn build_summary(
|
||||
correspondent: Correspondent,
|
||||
role_counts: BTreeMap<String, i64>,
|
||||
) -> CorrespondentSummary {
|
||||
let total = role_counts.values().copied().sum();
|
||||
fn build_summary(correspondent: Correspondent, total: i64) -> CorrespondentSummary {
|
||||
CorrespondentSummary {
|
||||
id: correspondent.id,
|
||||
name: correspondent.name,
|
||||
metadata: correspondent.metadata,
|
||||
created_at: to_iso(correspondent.created_at),
|
||||
updated_at: to_iso(correspondent.updated_at),
|
||||
usage: CorrespondentUsage {
|
||||
total,
|
||||
by_role: role_counts,
|
||||
},
|
||||
usage: CorrespondentUsage { total },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,17 +255,12 @@ fn load_usage_for_correspondent(
|
||||
conn: &mut PgConnection,
|
||||
tenant_id: Uuid,
|
||||
correspondent_id: Uuid,
|
||||
) -> AppResult<BTreeMap<String, i64>> {
|
||||
let rows: Vec<(String, i64)> = document_correspondents::table
|
||||
) -> AppResult<i64> {
|
||||
let total: i64 = document_correspondents::table
|
||||
.filter(document_correspondents::correspondent_id.eq(correspondent_id))
|
||||
.filter(document_correspondents::tenant_id.eq(tenant_id))
|
||||
.group_by(document_correspondents::role)
|
||||
.select((document_correspondents::role, count_star()))
|
||||
.load(conn)?;
|
||||
.select(count_star())
|
||||
.get_result(conn)?;
|
||||
|
||||
let mut map = BTreeMap::new();
|
||||
for (role, count) in rows {
|
||||
map.insert(role, count);
|
||||
}
|
||||
Ok(map)
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
+364
-636
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},
|
||||
};
|
||||
|
||||
use super::documents::{
|
||||
load_correspondents_for_documents, load_primary_assets, load_tags_for_documents,
|
||||
to_document_response, DocumentResponse,
|
||||
use super::documents::{to_document_response, DocumentResponse};
|
||||
use crate::documents::{
|
||||
asset::load_primary_assets, correspondents::load_correspondents_for_documents,
|
||||
tags::load_tags_for_documents,
|
||||
};
|
||||
use crate::utils::{
|
||||
json::{classify_nullable, NullableValue},
|
||||
@@ -309,7 +310,7 @@ pub async fn list_folder_contents(
|
||||
let mut correspondents_map = load_correspondents_for_documents(&mut conn, &doc_ids)?;
|
||||
drop(conn);
|
||||
|
||||
let primary_versions = load_primary_assets(&state, tenant_id, &docs).await?;
|
||||
let primary_versions = load_primary_assets(&state, tenant_id, &docs)?;
|
||||
|
||||
let mut documents = Vec::with_capacity(doc_ids.len());
|
||||
for doc in docs {
|
||||
|
||||
@@ -7,7 +7,10 @@ use axum::{
|
||||
Router,
|
||||
};
|
||||
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 crate::{auth::AuthenticatedUser, openapi::ApiDoc, state::AppState};
|
||||
@@ -17,6 +20,7 @@ pub mod correspondents;
|
||||
pub mod documents;
|
||||
pub mod folders;
|
||||
pub mod health;
|
||||
pub mod profile;
|
||||
pub mod tags;
|
||||
pub mod webdav;
|
||||
|
||||
@@ -79,12 +83,17 @@ pub fn create_router(state: AppState) -> Router<()> {
|
||||
.delete(documents::delete_document)
|
||||
.patch(documents::update_document),
|
||||
)
|
||||
.route("/:id/download", get(documents::download_document))
|
||||
.route(
|
||||
"/:id/assets",
|
||||
get(documents::list_document_assets).post(documents::request_document_assets),
|
||||
)
|
||||
.route("/:id/folder", patch(documents::move_document))
|
||||
.route("/:id/versions", get(documents::list_document_versions))
|
||||
.route(
|
||||
"/:id/versions/:version_id",
|
||||
get(documents::get_document_version),
|
||||
)
|
||||
.route("/:id/restore", post(documents::restore_document))
|
||||
.route("/:id/tags", post(documents::assign_tags))
|
||||
.route("/:id/tags/:tag_id", delete(documents::remove_tag))
|
||||
.route(
|
||||
@@ -122,6 +131,13 @@ pub fn create_router(state: AppState) -> Router<()> {
|
||||
.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 assets_routes = Router::new().route("/:asset_id", get(documents::get_document_asset));
|
||||
|
||||
@@ -130,6 +146,7 @@ pub fn create_router(state: AppState) -> Router<()> {
|
||||
.nest("/api/folders", folders_routes)
|
||||
.nest("/api/tags", tags_routes)
|
||||
.nest("/api/correspondents", correspondents_routes)
|
||||
.nest("/api/profile", profile_routes)
|
||||
.nest("/api/assets", assets_routes)
|
||||
.layer(middleware::from_extractor_with_state::<AuthenticatedUser, _>(protected_state));
|
||||
|
||||
@@ -154,4 +171,10 @@ pub fn create_router(state: AppState) -> Router<()> {
|
||||
.with_state(state)
|
||||
.layer(cors)
|
||||
.layer(DefaultBodyLimit::max(1024 * 1024 * 512))
|
||||
.layer(
|
||||
TraceLayer::new_for_http()
|
||||
.make_span_with(DefaultMakeSpan::new().level(tracing::Level::INFO))
|
||||
.on_response(DefaultOnResponse::new().level(tracing::Level::INFO))
|
||||
.on_failure(DefaultOnFailure::new().level(tracing::Level::ERROR)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
use diesel::prelude::*;
|
||||
use diesel::OptionalExtension;
|
||||
use diesel::PgConnection;
|
||||
use futures_util::StreamExt;
|
||||
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 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::models::{Document, DocumentVersion, Folder, User};
|
||||
use crate::schema::{
|
||||
@@ -29,17 +30,11 @@ use crate::utils::{http::inline_content_disposition, time::to_http_date};
|
||||
const REALM: &str = "Papercrate WebDAV";
|
||||
const DOWNLOAD_URL_TTL_SECONDS: u64 = 300;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct TenantEntry {
|
||||
tenant_id: Uuid,
|
||||
slug: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct WebDavContext {
|
||||
tenant_id: Uuid,
|
||||
_user_id: Uuid,
|
||||
_username: String,
|
||||
tenants: Vec<TenantEntry>,
|
||||
}
|
||||
|
||||
pub fn create_router() -> Router<AppState> {
|
||||
@@ -89,35 +84,20 @@ async fn handle_propfind(
|
||||
|
||||
let segments = parse_segments(path)?;
|
||||
|
||||
let resources = if segments.is_empty() {
|
||||
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 tenant_id = context.tenant_id;
|
||||
|
||||
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,
|
||||
None => return Ok(not_found_response()),
|
||||
};
|
||||
|
||||
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 } => {
|
||||
let contents =
|
||||
fetch_folder_contents(state, tenant_entry.tenant_id, Some(folder.id))?;
|
||||
let contents = fetch_folder_contents(state, tenant_id, Some(folder.id))?;
|
||||
build_resources_for_folder(Some(&folder), &chain, &contents, depth)
|
||||
}
|
||||
ResolvedPath::Document {
|
||||
@@ -151,29 +131,13 @@ async fn handle_get_or_head(
|
||||
None => return Ok(unauthorized_response()),
|
||||
};
|
||||
|
||||
let tenant_id = context.tenant_id;
|
||||
let segments = parse_segments(path)?;
|
||||
let (requested_slug, remainder) = match segments.split_first() {
|
||||
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() {
|
||||
if segments.is_empty() {
|
||||
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,
|
||||
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(':') {
|
||||
Some((username, password)) if !username.is_empty() => (username, password),
|
||||
let (username, secret) = match credential_str.split_once(':') {
|
||||
Some((username, secret)) if !username.is_empty() => (username, secret),
|
||||
_ => return Ok(None),
|
||||
};
|
||||
|
||||
@@ -478,35 +442,46 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavCo
|
||||
Err(err) => return Err(AppError::from(err)),
|
||||
};
|
||||
|
||||
let valid = password::verify_password(password, &user.password_hash)
|
||||
.map_err(|_| AppError::internal("failed to verify password"))?;
|
||||
|
||||
if !valid {
|
||||
tracing::warn!(%username, "webdav password invalid");
|
||||
let token = match find_active_token_by_secret(&mut conn, user.id, None, secret)? {
|
||||
Some(token) => token,
|
||||
None => {
|
||||
tracing::warn!(%username, "webdav token invalid or expired");
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
|
||||
let tenant_rows: Vec<(Uuid, String)> = memberships_dsl::user_memberships
|
||||
let tenant_row = memberships_dsl::user_memberships
|
||||
.inner_join(tenant_dsl::tenants)
|
||||
.filter(memberships_dsl::user_id.eq(user.id))
|
||||
.filter(memberships_dsl::tenant_id.eq(token.tenant_id))
|
||||
.select((tenant_dsl::id, tenant_dsl::slug))
|
||||
.load(&mut conn)?;
|
||||
.first::<(Uuid, String)>(&mut conn)
|
||||
.optional()?;
|
||||
|
||||
if tenant_rows.is_empty() {
|
||||
tracing::warn!(%username, "webdav user has no tenant memberships");
|
||||
let (tenant_id, _slug) = match tenant_row {
|
||||
Some(row) => row,
|
||||
None => {
|
||||
tracing::warn!(
|
||||
%username,
|
||||
tenant_id = %token.tenant_id,
|
||||
"webdav token tenant membership missing"
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
|
||||
let tenants: Vec<TenantEntry> = tenant_rows
|
||||
.into_iter()
|
||||
.map(|(tenant_id, slug)| TenantEntry { tenant_id, slug })
|
||||
.collect();
|
||||
touch_webdav_token(&mut conn, token.id)?;
|
||||
|
||||
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 {
|
||||
tenant_id,
|
||||
_user_id: user.id,
|
||||
_username: user.username,
|
||||
tenants,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -564,37 +539,6 @@ fn build_resources_for_folder(
|
||||
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(
|
||||
chain: &[String],
|
||||
document: &Document,
|
||||
@@ -717,9 +661,6 @@ struct DavResource {
|
||||
last_modified: Option<String>,
|
||||
}
|
||||
enum ResolvedPath {
|
||||
TenantRoot {
|
||||
chain: Vec<String>,
|
||||
},
|
||||
Folder {
|
||||
folder: Folder,
|
||||
chain: Vec<String>,
|
||||
@@ -733,23 +674,18 @@ enum ResolvedPath {
|
||||
|
||||
fn resolve_path(
|
||||
state: &AppState,
|
||||
tenant: &TenantEntry,
|
||||
tenant_id: Uuid,
|
||||
segments: &[String],
|
||||
) -> 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 chain: Vec<String> = vec![tenant.slug.clone()];
|
||||
let mut chain: Vec<String> = Vec::new();
|
||||
let mut current_folder: Option<Folder> = None;
|
||||
|
||||
if segments.is_empty() {
|
||||
return Ok(Some(ResolvedPath::TenantRoot { chain }));
|
||||
}
|
||||
|
||||
for (index, segment) in segments.iter().enumerate() {
|
||||
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());
|
||||
if is_last {
|
||||
return Ok(Some(ResolvedPath::Folder { folder, chain }));
|
||||
@@ -761,7 +697,7 @@ fn resolve_path(
|
||||
|
||||
if is_last {
|
||||
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());
|
||||
return Ok(Some(ResolvedPath::Document {
|
||||
@@ -773,7 +709,7 @@ fn resolve_path(
|
||||
}
|
||||
|
||||
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 {
|
||||
return Ok(None);
|
||||
}
|
||||
@@ -786,9 +722,7 @@ fn resolve_path(
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some((document, version)) =
|
||||
find_document_by_id(&mut conn, tenant.tenant_id, uuid)?
|
||||
{
|
||||
if let Some((document, version)) = find_document_by_id(&mut conn, tenant_id, uuid)? {
|
||||
if document.folder_id != parent_id {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
+19
-4
@@ -37,11 +37,9 @@ diesel::table! {
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
document_correspondents (document_id, correspondent_id, role) {
|
||||
document_correspondents (document_id, correspondent_id) {
|
||||
document_id -> Uuid,
|
||||
correspondent_id -> Uuid,
|
||||
#[max_length = 32]
|
||||
role -> Varchar,
|
||||
assigned_at -> Timestamptz,
|
||||
assigned_by -> Nullable<Uuid>,
|
||||
tenant_id -> Uuid,
|
||||
@@ -69,7 +67,6 @@ diesel::table! {
|
||||
#[max_length = 64]
|
||||
checksum -> Varchar,
|
||||
created_at -> Timestamptz,
|
||||
operations_summary -> Jsonb,
|
||||
metadata -> Jsonb,
|
||||
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!(document_asset_objects -> document_assets (asset_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!(user_memberships -> tenants (tenant_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!(
|
||||
correspondents,
|
||||
@@ -225,4 +239,5 @@ diesel::allow_tables_to_appear_in_same_query!(
|
||||
tenants,
|
||||
user_memberships,
|
||||
users,
|
||||
webdav_tokens,
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::{collections::HashSet, sync::Arc, time::Duration};
|
||||
use async_trait::async_trait;
|
||||
use diesel::prelude::*;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Map, Value};
|
||||
use serde_json::json;
|
||||
use tokio::task;
|
||||
use tracing::{error, warn};
|
||||
use uuid::Uuid;
|
||||
@@ -104,7 +104,7 @@ fn analyze_document(
|
||||
|
||||
let tenant_id = document.tenant_id;
|
||||
|
||||
let (supported, reason) = determine_thumbnail_support(&document);
|
||||
let (supported, _reason) = determine_thumbnail_support(&document);
|
||||
let ocr_supported = document_is_pdf(&document);
|
||||
|
||||
let existing_ocr: Option<DocumentAsset> = document_assets::table
|
||||
@@ -117,32 +117,6 @@ fn analyze_document(
|
||||
|
||||
let skip_ocr = existing_ocr.is_some() && !payload.force;
|
||||
|
||||
let mut summary_map = match version.operations_summary {
|
||||
Value::Object(map) => map,
|
||||
_ => Map::new(),
|
||||
};
|
||||
summary_map.insert("thumbnail_supported".to_string(), Value::Bool(supported));
|
||||
if let Some(reason) = reason {
|
||||
summary_map.insert("thumbnail_reason".to_string(), Value::String(reason));
|
||||
} else {
|
||||
summary_map.remove("thumbnail_reason");
|
||||
}
|
||||
|
||||
summary_map.insert("ocr_supported".to_string(), Value::Bool(ocr_supported));
|
||||
if ocr_supported {
|
||||
summary_map.remove("ocr_reason");
|
||||
} else {
|
||||
summary_map.insert(
|
||||
"ocr_reason".to_string(),
|
||||
Value::String("document is not a PDF".into()),
|
||||
);
|
||||
}
|
||||
|
||||
diesel::update(document_versions::table.find(version.id))
|
||||
.set(document_versions::operations_summary.eq(Value::Object(summary_map)))
|
||||
.execute(&mut conn)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
if supported {
|
||||
let enqueue_result = enqueue_job(
|
||||
&mut conn,
|
||||
|
||||
@@ -18,6 +18,7 @@ use tracing::{error, info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
documents::asset::delete_asset,
|
||||
jobs::{enqueue_job, JOB_GENERATE_OCR_TEXT, JOB_INDEX_DOCUMENT_TEXT},
|
||||
models::{
|
||||
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 {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -12,6 +12,7 @@ use tracing::{error, info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
documents::asset::delete_asset,
|
||||
jobs::JOB_GENERATE_THUMBNAILS,
|
||||
models::{
|
||||
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 {
|
||||
if let Err(err) = storage.delete_object(&object.s3_key).await {
|
||||
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 {
|
||||
if let Err(err) = storage.delete_object(&object.s3_key).await {
|
||||
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();
|
||||
|
||||
+267
-4
@@ -1,15 +1,49 @@
|
||||
mod common;
|
||||
|
||||
use anyhow::Result;
|
||||
use axum::http::StatusCode;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use axum::body::Body;
|
||||
use axum::http::{header::SET_COOKIE, StatusCode};
|
||||
use backend::models::NewUserMembership;
|
||||
use backend::schema::{tenants, user_memberships};
|
||||
use common::{acquire_db_lock, body_to_vec, TestApp};
|
||||
use diesel::prelude::*;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AuthenticatedUser {
|
||||
username: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ErrorResponse {
|
||||
error: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LoginTenant {
|
||||
slug: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LoginResponse {
|
||||
access_token: String,
|
||||
tenant: LoginTenant,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TenantSelectionResponse {
|
||||
access_token: String,
|
||||
tenants: Vec<TenantSummary>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TenantSummary {
|
||||
tenant_id: Uuid,
|
||||
slug: String,
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_and_me_roundtrip() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
@@ -18,9 +52,9 @@ async fn login_and_me_roundtrip() -> Result<()> {
|
||||
let password = "s3cret";
|
||||
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);
|
||||
let body = body_to_vec(response.into_body()).await?;
|
||||
let user: AuthenticatedUser = serde_json::from_slice(&body)?;
|
||||
@@ -30,3 +64,232 @@ async fn login_and_me_roundtrip() -> Result<()> {
|
||||
app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_rejects_unknown_user() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
let app = TestApp::new().await?;
|
||||
|
||||
let payload = json!({ "username": "ghost", "password": "nope" });
|
||||
let response = app.post_json("/api/auth/login", &payload, None).await?;
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
let body = body_to_vec(response.into_body()).await?;
|
||||
let err: ErrorResponse = serde_json::from_slice(&body)?;
|
||||
assert_eq!(err.error, "unauthorized");
|
||||
|
||||
app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_rejects_invalid_password() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
let app = TestApp::new().await?;
|
||||
|
||||
let password = "valid";
|
||||
app.insert_user("robin", password, "admin").await?;
|
||||
|
||||
let payload = json!({ "username": "robin", "password": "wrong" });
|
||||
let response = app.post_json("/api/auth/login", &payload, None).await?;
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
let body = body_to_vec(response.into_body()).await?;
|
||||
let err: ErrorResponse = serde_json::from_slice(&body)?;
|
||||
assert_eq!(err.error, "unauthorized");
|
||||
|
||||
app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_rotates_refresh_token() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
let app = TestApp::new().await?;
|
||||
|
||||
let password = "rotate";
|
||||
app.insert_user("rita", password, "admin").await?;
|
||||
|
||||
let (login, refresh_cookie) = login_with_session(&app, "rita", password).await?;
|
||||
|
||||
let response = app
|
||||
.post_json_with_cookie("/api/auth/refresh", &json!({}), None, Some(&refresh_cookie))
|
||||
.await?;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let new_cookie = extract_refresh_cookie(response.headers())?;
|
||||
let body = body_to_vec(response.into_body()).await?;
|
||||
let refreshed: LoginResponse = serde_json::from_slice(&body)?;
|
||||
assert_eq!(refreshed.tenant.slug, login.tenant.slug);
|
||||
|
||||
let me_response = app
|
||||
.get("/api/auth/me", Some(&refreshed.access_token))
|
||||
.await?;
|
||||
assert_eq!(me_response.status(), StatusCode::OK);
|
||||
|
||||
let retry = app
|
||||
.post_json_with_cookie("/api/auth/refresh", &json!({}), None, Some(&refresh_cookie))
|
||||
.await?;
|
||||
assert_eq!(retry.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
// new cookie should differ from old to avoid reuse
|
||||
assert_ne!(new_cookie, refresh_cookie);
|
||||
|
||||
app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn logout_revokes_refresh_token() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
let app = TestApp::new().await?;
|
||||
|
||||
let password = "logout";
|
||||
app.insert_user("logan", password, "admin").await?;
|
||||
|
||||
let (login, refresh_cookie) = login_with_session(&app, "logan", password).await?;
|
||||
|
||||
let response = app
|
||||
.post_json_with_cookie(
|
||||
"/api/auth/logout",
|
||||
&json!({}),
|
||||
Some(&login.access_token),
|
||||
Some(&refresh_cookie),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||
let cleared_cookie = extract_refresh_cookie(response.headers())?;
|
||||
assert!(cleared_cookie.ends_with("="));
|
||||
|
||||
let after_logout = app
|
||||
.post_json_with_cookie("/api/auth/refresh", &json!({}), None, Some(&refresh_cookie))
|
||||
.await?;
|
||||
assert_eq!(after_logout.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn me_requires_authentication() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
let app = TestApp::new().await?;
|
||||
|
||||
let unauthenticated = app.get("/api/auth/me", None).await?;
|
||||
assert_eq!(unauthenticated.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
let invalid = app.get("/api/auth/me", Some("invalid")).await?;
|
||||
assert_eq!(invalid.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_returns_tenant_selection_when_multiple_memberships() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
let app = TestApp::new().await?;
|
||||
|
||||
let password = "multipass";
|
||||
let user_id = app.insert_user("multipass", password, "admin").await?;
|
||||
|
||||
let secondary_slug = "secondary".to_string();
|
||||
let slug_for_insert = secondary_slug.clone();
|
||||
let secondary_id = Uuid::new_v4();
|
||||
app.with_conn(move |conn| {
|
||||
diesel::insert_into(tenants::table)
|
||||
.values((
|
||||
tenants::id.eq(secondary_id),
|
||||
tenants::slug.eq(&slug_for_insert),
|
||||
))
|
||||
.execute(conn)?;
|
||||
|
||||
let membership = NewUserMembership {
|
||||
id: Uuid::new_v4(),
|
||||
user_id,
|
||||
tenant_id: secondary_id,
|
||||
role: "admin".to_string(),
|
||||
};
|
||||
|
||||
diesel::insert_into(user_memberships::table)
|
||||
.values(&membership)
|
||||
.execute(conn)?;
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
|
||||
let payload = json!({ "username": "multipass", "password": password });
|
||||
let response = app.post_json("/api/auth/login", &payload, None).await?;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = body_to_vec(response.into_body()).await?;
|
||||
let selection: TenantSelectionResponse = serde_json::from_slice(&body)?;
|
||||
assert!(selection.tenants.len() >= 2);
|
||||
let secondary = selection
|
||||
.tenants
|
||||
.iter()
|
||||
.find(|tenant| tenant.slug == secondary_slug)
|
||||
.map(|t| t.tenant_id)
|
||||
.context("secondary tenant missing from selection")?;
|
||||
|
||||
let select_response = app
|
||||
.post_json(
|
||||
"/api/auth/select-tenant",
|
||||
&json!({ "tenant_id": secondary }),
|
||||
Some(&selection.access_token),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(select_response.status(), StatusCode::OK);
|
||||
let session_cookie = extract_refresh_cookie(select_response.headers())?;
|
||||
let select_body = body_to_vec(select_response.into_body()).await?;
|
||||
let login: LoginResponse = serde_json::from_slice(&select_body)?;
|
||||
assert_eq!(login.tenant.slug, secondary_slug);
|
||||
|
||||
let me_response = app.get("/api/auth/me", Some(&login.access_token)).await?;
|
||||
assert_eq!(me_response.status(), StatusCode::OK);
|
||||
|
||||
let refresh_response = app
|
||||
.post_json_with_cookie("/api/auth/refresh", &json!({}), None, Some(&session_cookie))
|
||||
.await?;
|
||||
assert_eq!(refresh_response.status(), StatusCode::OK);
|
||||
|
||||
app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn login_with_session(
|
||||
app: &TestApp,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<(LoginResponse, String)> {
|
||||
let payload = json!({ "username": username, "password": password });
|
||||
let response = app.post_json("/api/auth/login", &payload, None).await?;
|
||||
ensure_status(&response, StatusCode::OK)?;
|
||||
let refresh_cookie = extract_refresh_cookie(response.headers())?;
|
||||
let body = body_to_vec(response.into_body()).await?;
|
||||
let login: LoginResponse = serde_json::from_slice(&body)
|
||||
.map_err(|_| anyhow!("expected login response with session"))?;
|
||||
Ok((login, refresh_cookie))
|
||||
}
|
||||
|
||||
fn extract_refresh_cookie(headers: &axum::http::HeaderMap) -> Result<String> {
|
||||
let header_value = headers
|
||||
.get(SET_COOKIE)
|
||||
.context("missing set-cookie header")?
|
||||
.to_str()
|
||||
.context("invalid set-cookie header")?;
|
||||
let cookie = header_value
|
||||
.split(';')
|
||||
.next()
|
||||
.context("set-cookie missing cookie value")?
|
||||
.to_string();
|
||||
Ok(cookie)
|
||||
}
|
||||
|
||||
fn ensure_status(response: &hyper::Response<Body>, expected: StatusCode) -> Result<()> {
|
||||
if response.status() == expected {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!(
|
||||
"unexpected status: got {}, expected {}",
|
||||
response.status(),
|
||||
expected
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::time::Duration;
|
||||
use anyhow::{anyhow, ensure, Context, Result};
|
||||
use async_trait::async_trait;
|
||||
use axum::body::Body;
|
||||
use axum::http::{Method, Request, StatusCode};
|
||||
use axum::http::{header, Method, Request, StatusCode};
|
||||
use axum::Router;
|
||||
use backend::auth::jwt::JwtService;
|
||||
use backend::config::AppConfig;
|
||||
@@ -396,6 +396,16 @@ impl TestApp {
|
||||
path: &str,
|
||||
payload: &T,
|
||||
token: Option<&str>,
|
||||
) -> Result<hyper::Response<Body>> {
|
||||
self.post_json_with_cookie(path, payload, token, None).await
|
||||
}
|
||||
|
||||
pub async fn post_json_with_cookie<T: Serialize + ?Sized>(
|
||||
&self,
|
||||
path: &str,
|
||||
payload: &T,
|
||||
token: Option<&str>,
|
||||
cookie: Option<&str>,
|
||||
) -> Result<hyper::Response<Body>> {
|
||||
let body = serde_json::to_vec(payload)?;
|
||||
let mut builder = Request::builder()
|
||||
@@ -405,6 +415,9 @@ impl TestApp {
|
||||
if let Some(token) = token {
|
||||
builder = builder.header("authorization", format!("Bearer {token}"));
|
||||
}
|
||||
if let Some(cookie) = cookie {
|
||||
builder = builder.header(header::COOKIE, cookie);
|
||||
}
|
||||
let request = builder.body(Body::from(body))?;
|
||||
Ok(self
|
||||
.router
|
||||
@@ -694,6 +707,7 @@ fn truncate_all(conn: &mut PgConnection) -> Result<()> {
|
||||
jobs, \
|
||||
refresh_tokens, \
|
||||
tags, \
|
||||
webdav_tokens, \
|
||||
user_memberships, \
|
||||
users, \
|
||||
tenants \
|
||||
|
||||
@@ -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)?)
|
||||
}
|
||||
+634
-344
File diff suppressed because it is too large
Load Diff
@@ -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(())
|
||||
}
|
||||
+9
-7
@@ -16,22 +16,24 @@ Health
|
||||
|
||||
Documents
|
||||
---------
|
||||
- GET /api/documents - List or search documents. Optional filters: `folder_id` (defaults to root when omitted), `include_deleted`, `include_descendants` (defaults to true unless explicitly set to `false` without filters), `query` (Quickwit full-text), `tags` (comma-separated tag UUIDs), and `correspondents` (comma-separated correspondent UUIDs). Each entry includes tags, correspondent assignments, and current version info.
|
||||
- GET /api/documents - 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.
|
||||
- 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/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.
|
||||
- GET /api/documents/:id - Retrieve metadata and current version details for a document.
|
||||
- PATCH /api/documents/:id - Update document metadata (currently title).
|
||||
- DELETE /api/documents/:id - Soft-delete a document.
|
||||
- GET /api/documents/:id/download - Create a pre-signed download URL for the current version.
|
||||
- PATCH /api/documents/:id/folder - Move a document to another folder.
|
||||
- POST /api/documents/:id/restore - Restore a soft-deleted document. Optional body `{ "folder_id": <uuid> }` to send it to a specific folder; defaults to the original folder or root if missing.
|
||||
- GET /api/documents/:id/versions - List version history for a document.
|
||||
- GET /api/documents/:id/versions/:version_id - Fetch metadata and assets for a specific version.
|
||||
- POST /api/documents/:id/tags - Assign one or more tags to a document.
|
||||
- DELETE /api/documents/:id/tags/:tag_id - Remove a single tag from a document.
|
||||
- POST /api/documents/:id/correspondents - Assign correspondents to roles (`assignments[]` with `correspondent_id` and `role`; optional `replace=true` overwrites existing assignments for those roles). Valid roles: `sender`, `receiver`, `other`.
|
||||
- DELETE /api/documents/:id/correspondents/:correspondent_id - Remove a correspondent assignment (requires `role` query string).
|
||||
- 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.
|
||||
|
||||
Document Assets
|
||||
---------------
|
||||
@@ -62,7 +64,7 @@ Tags
|
||||
|
||||
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).
|
||||
- PATCH /api/correspondents/:id - Update name and/or metadata.
|
||||
- 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
|
||||
WORKDIR /usr/share/nginx/html
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist ./
|
||||
|
||||
ENV API_BASE_URL=""
|
||||
ENV API_PROXY_PASS=""
|
||||
|
||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||
RUN chmod +x /docker-entrypoint.sh
|
||||
|
||||
@@ -1,11 +1,37 @@
|
||||
#!/bin/sh
|
||||
set -euo pipefail
|
||||
|
||||
API_BASE_URL_TRIMMED="${API_BASE_URL:-}"
|
||||
API_BASE_URL_TRIMMED="${API_BASE_URL_TRIMMED%%/}"
|
||||
API_PROXY_PASS_TRIMMED="${API_PROXY_PASS:-}"
|
||||
API_PROXY_PASS_TRIMMED="${API_PROXY_PASS_TRIMMED%%/}"
|
||||
|
||||
cat <<CONFIG > /usr/share/nginx/html/config.js
|
||||
window.__PAPERCRATE_API_BASE_URL = "${API_BASE_URL_TRIMMED}";
|
||||
CONFIG
|
||||
cat <<'BASE' > /etc/nginx/conf.d/default.conf
|
||||
server {
|
||||
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 "$@"
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -6,6 +6,18 @@
|
||||
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 {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -33,28 +45,12 @@
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.skeuo-item {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: auto;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
transform-origin: center center;
|
||||
transition: transform 0.28s ease, box-shadow 0.16s ease;
|
||||
outline: none;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
|
||||
.skeuo-item__body {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.skeuo-item:focus-visible {
|
||||
@@ -68,8 +64,8 @@
|
||||
}
|
||||
|
||||
.skeuo-item.is-tag-target .skeuo-item__card {
|
||||
outline: 1em dashed var(--accent);
|
||||
outline-offset: 1.41em;
|
||||
outline: 0.35rem dashed var(--accent);
|
||||
outline-offset: 0.35rem;
|
||||
}
|
||||
|
||||
.skeuo-item.is-tag-pending .skeuo-item__card {
|
||||
@@ -98,50 +94,39 @@
|
||||
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 {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.18rem 0.55rem;
|
||||
.tag-chip--draggable {
|
||||
user-select: none;
|
||||
pointer-events: auto;
|
||||
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;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.skeuo-tag span {
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
.skeuo-item__tags .tag-chip {
|
||||
font-size: 0.85rem;
|
||||
padding: 0.18rem 0.55rem;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
|
||||
.skeuo-card__nav {
|
||||
position: absolute;
|
||||
bottom: calc(3em * 0.707 * var(--nav-scale));
|
||||
bottom: 1.8rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) scale(calc(0.707 * var(--nav-scale, 1)));
|
||||
transform: translateX(-50%);
|
||||
transform-origin: center;
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
gap: 1.5rem;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease;
|
||||
@@ -156,19 +141,19 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 3.3em;
|
||||
height: 3.3em;
|
||||
padding: 0.36em;
|
||||
border-radius: 3.3em;
|
||||
width: 2.4em;
|
||||
height: 2.4em;
|
||||
padding: 0.25em;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: #fff;
|
||||
background: oklch(0.22 0.06 260deg);
|
||||
color: var(--on-accent);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.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 {
|
||||
@@ -186,8 +171,8 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.skeuo-item__tags .skeuo-tag.is-tear-pending {
|
||||
opacity: 0.4;
|
||||
.skeuo-item__tags .tag-chip--tear-pending {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
body.skeuo-cursor-remove,
|
||||
@@ -205,15 +190,23 @@ body.skeuo-cursor-remove * {
|
||||
display: flex;
|
||||
align-items: 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;
|
||||
--nav-scale: 1;
|
||||
}
|
||||
|
||||
.skeuo-item__card img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.skeuo-item__card--empty {
|
||||
background:
|
||||
radial-gradient(circle at 28% 24%, rgba(255, 255, 255, 0.32), transparent 60%),
|
||||
radial-gradient(circle at 72% 78%, rgba(0, 0, 0, 0.08), transparent 65%),
|
||||
radial-gradient(circle at 28% 24%, color-mix(in oklch, white 32%, transparent), transparent 60%),
|
||||
radial-gradient(circle at 72% 78%, color-mix(in oklch, black 8%, transparent), transparent 65%),
|
||||
linear-gradient(135deg, #e6e1d6 0%, #d2cdc2 100%);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -249,6 +242,7 @@ body.skeuo-cursor-remove * {
|
||||
color: #3b3b3b;
|
||||
max-width: 90%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow-wrap: anywhere;
|
||||
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,14 +107,7 @@ function CorrespondentsPanel({
|
||||
return '0';
|
||||
}
|
||||
const total = typeof usage.total === 'number' ? usage.total : 0;
|
||||
const entries = usage.by_role ? Object.entries(usage.by_role) : [];
|
||||
if (!entries.length) {
|
||||
return total.toString();
|
||||
}
|
||||
const roleSummary = entries
|
||||
.map(([role, count]) => `${role}: ${count}`)
|
||||
.join(', ');
|
||||
return `${total} (${roleSummary})`;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
||||
@@ -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;
|
||||
@@ -14,13 +14,10 @@ import { getTagColorStyle } from '../utils/colors';
|
||||
import { formatFileSize } from '../utils/format';
|
||||
import { resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
|
||||
import { useAssetNavigator } from '../hooks/useAssetNavigator';
|
||||
import { CORRESPONDENT_ROLES } from '../constants/correspondents';
|
||||
import PreviewZoomOverlay from './PreviewZoomOverlay';
|
||||
|
||||
const MAX_PREVIEW_STACK_ITEMS = 15;
|
||||
|
||||
const normalizeRole = (role) => (role || '').toLowerCase();
|
||||
|
||||
const derivePreviewOrientation = (metadata) => {
|
||||
const width = Number(metadata?.width);
|
||||
const height = Number(metadata?.height);
|
||||
@@ -30,39 +27,23 @@ const derivePreviewOrientation = (metadata) => {
|
||||
return 'landscape';
|
||||
};
|
||||
|
||||
const formatRoleLabel = (role) => {
|
||||
const normalized = normalizeRole(role);
|
||||
if (!normalized) return 'Other';
|
||||
return normalized.charAt(0).toUpperCase() + normalized.slice(1);
|
||||
};
|
||||
|
||||
const compareCorrespondents = (a, b) => {
|
||||
const indexA = CORRESPONDENT_ROLES.indexOf(a.role);
|
||||
const indexB = CORRESPONDENT_ROLES.indexOf(b.role);
|
||||
const rankedA = indexA === -1 ? Number.MAX_SAFE_INTEGER : indexA;
|
||||
const rankedB = indexB === -1 ? Number.MAX_SAFE_INTEGER : indexB;
|
||||
if (rankedA !== rankedB) {
|
||||
return rankedA - rankedB;
|
||||
}
|
||||
return (a.name || '').localeCompare(b.name || '');
|
||||
};
|
||||
|
||||
const sortCorrespondents = (entries = []) =>
|
||||
entries
|
||||
.map((entry) => ({
|
||||
id: entry.id,
|
||||
name: entry.name || '',
|
||||
role: normalizeRole(entry.role),
|
||||
count: entry.count,
|
||||
}))
|
||||
.sort(compareCorrespondents);
|
||||
.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||
|
||||
const CorrespondentPills = ({ entries = [], onRemove, showCount = false }) => (
|
||||
<div className="correspondent-list">
|
||||
{entries.length ? (
|
||||
entries.map((entry) => (
|
||||
<span key={`${entry.id}:${entry.role}`} className="correspondent-pill">
|
||||
entries.map((entry, index) => {
|
||||
const key = entry.id ?? `${entry.name}-${index}`;
|
||||
return (
|
||||
<span key={key} className="correspondent-pill">
|
||||
<span className="correspondent-pill__label">
|
||||
<strong>{formatRoleLabel(entry.role)}</strong>
|
||||
<span>
|
||||
{entry.name}
|
||||
{showCount && entry.count ? ` (${entry.count})` : ''}
|
||||
@@ -73,13 +54,14 @@ const CorrespondentPills = ({ entries = [], onRemove, showCount = false }) => (
|
||||
type="button"
|
||||
className="correspondent-pill__remove"
|
||||
onClick={() => onRemove(entry)}
|
||||
aria-label={`Remove ${entry.name} as ${formatRoleLabel(entry.role)}`}
|
||||
aria-label={`Remove ${entry.name}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
))
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<span className="meta">No correspondents yet.</span>
|
||||
)}
|
||||
@@ -105,11 +87,18 @@ const TagSection = ({
|
||||
tags.map((tag) => {
|
||||
const key = tag.id ?? tag.label;
|
||||
const style = getTagColorStyle(tag.color);
|
||||
const removable = Boolean(onRemove);
|
||||
const className = removable ? 'badge tag-chip tag-chip--removable' : 'badge tag-chip';
|
||||
return (
|
||||
<span key={key} className="tag-pill" style={style || undefined}>
|
||||
{tag.label}{' '}
|
||||
{onRemove ? (
|
||||
<button type="button" onClick={() => onRemove(tag)}>
|
||||
<span key={key} className={className} style={style || undefined}>
|
||||
<span className="tag-chip__label">{tag.label}</span>
|
||||
{removable ? (
|
||||
<button
|
||||
type="button"
|
||||
className="tag-chip__remove"
|
||||
onClick={() => onRemove(tag)}
|
||||
aria-label={`Remove tag ${tag.label}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
) : null}
|
||||
@@ -167,11 +156,9 @@ const CorrespondentSection = ({
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
const nameInput = form.elements.correspondent;
|
||||
const roleSelect = form.elements.role;
|
||||
const value = nameInput.value.trim();
|
||||
const role = roleSelect.value;
|
||||
if (!value) return;
|
||||
onAdd({ name: value, role, input: nameInput });
|
||||
onAdd({ name: value, input: nameInput });
|
||||
form.reset();
|
||||
}}
|
||||
>
|
||||
@@ -180,13 +167,6 @@ const CorrespondentSection = ({
|
||||
placeholder={addPlaceholder}
|
||||
list={datalistId}
|
||||
/>
|
||||
<select name="role" defaultValue={CORRESPONDENT_ROLES[0]}>
|
||||
{CORRESPONDENT_ROLES.map((role) => (
|
||||
<option key={role} value={role}>
|
||||
{role.charAt(0).toUpperCase() + role.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="submit">{addButtonLabel}</button>
|
||||
{datalistId ? (
|
||||
<datalist id={datalistId}>
|
||||
@@ -318,7 +298,6 @@ const DetailPanel = ({
|
||||
onPromoteSelection,
|
||||
activePreviewId = null,
|
||||
onUpdateTitle = async () => false,
|
||||
onUpdateIssuedAt = async () => false,
|
||||
ensureAssetUrl = null,
|
||||
getDocumentAsset = () => null,
|
||||
correspondents = [],
|
||||
@@ -326,8 +305,6 @@ const DetailPanel = ({
|
||||
onCorrespondentRemove,
|
||||
resolveApiPath,
|
||||
onFolderNavigate = null,
|
||||
ollamaUrl = null,
|
||||
ollamaModel = 'llama3',
|
||||
resolveFolderPath = null,
|
||||
onClose = () => {},
|
||||
}) => {
|
||||
@@ -357,12 +334,6 @@ const DetailPanel = ({
|
||||
const [ocrLoading, setOcrLoading] = useState(false);
|
||||
const [ocrError, setOcrError] = useState(null);
|
||||
const [zoomedPreview, setZoomedPreview] = useState(null);
|
||||
const ocrTextCacheRef = useRef({ docId: null, text: null });
|
||||
const [aiSuggestion, setAiSuggestion] = useState(null);
|
||||
const [aiSuggestionSelection, setAiSuggestionSelection] = useState({ title: true, issuedAt: true });
|
||||
const [aiSuggestionLoading, setAiSuggestionLoading] = useState(false);
|
||||
const [aiSuggestionError, setAiSuggestionError] = useState(null);
|
||||
const [aiSuggestionApplying, setAiSuggestionApplying] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!singleDoc) {
|
||||
@@ -396,15 +367,6 @@ const DetailPanel = ({
|
||||
setZoomedPreview(null);
|
||||
}, [selectionKey]);
|
||||
|
||||
useEffect(() => {
|
||||
ocrTextCacheRef.current = { docId: null, text: null };
|
||||
setAiSuggestion(null);
|
||||
setAiSuggestionSelection({ title: true, issuedAt: true });
|
||||
setAiSuggestionError(null);
|
||||
setAiSuggestionLoading(false);
|
||||
setAiSuggestionApplying(false);
|
||||
}, [singleDoc?.id]);
|
||||
|
||||
const startTitleEdit = useCallback(() => {
|
||||
if (!singleDoc) return;
|
||||
setTitleEditDocId(singleDoc.id);
|
||||
@@ -458,40 +420,6 @@ const DetailPanel = ({
|
||||
[singleDoc, getDocumentAsset],
|
||||
);
|
||||
|
||||
const effectiveOllamaUrl = useMemo(
|
||||
() => (ollamaUrl ? String(ollamaUrl).trim().replace(/\/$/, '') : null),
|
||||
[ollamaUrl],
|
||||
);
|
||||
|
||||
const effectiveOllamaModel = useMemo(
|
||||
() => (ollamaModel ? String(ollamaModel).trim() || 'llama3' : 'llama3'),
|
||||
[ollamaModel],
|
||||
);
|
||||
|
||||
const canSuggestTitle = Boolean(singleDoc);
|
||||
const suggestionDisabledReason = useMemo(() => {
|
||||
if (!singleDoc) {
|
||||
return 'Select a single document to generate suggestions.';
|
||||
}
|
||||
if (!hasOcrAsset) {
|
||||
return 'OCR text is required to generate suggestions.';
|
||||
}
|
||||
if (!effectiveOllamaUrl) {
|
||||
return 'Set an Ollama URL to enable AI suggestions.';
|
||||
}
|
||||
return null;
|
||||
}, [singleDoc, hasOcrAsset, effectiveOllamaUrl]);
|
||||
const suggestButtonDisabled = Boolean(aiSuggestionLoading || suggestionDisabledReason);
|
||||
|
||||
const canApplyAiSuggestion = useMemo(() => {
|
||||
if (!aiSuggestion) {
|
||||
return false;
|
||||
}
|
||||
const applyTitle = aiSuggestionSelection.title && Boolean(aiSuggestion.title);
|
||||
const applyIssuedAt = aiSuggestionSelection.issuedAt && Boolean(aiSuggestion.issuedAt);
|
||||
return applyTitle || applyIssuedAt;
|
||||
}, [aiSuggestion, aiSuggestionSelection]);
|
||||
|
||||
const loadOcrUrl = useCallback(async () => {
|
||||
if (!singleDoc) {
|
||||
return;
|
||||
@@ -534,138 +462,6 @@ const DetailPanel = ({
|
||||
}
|
||||
}, [singleDoc, ensureAssetUrl, getDocumentAsset]);
|
||||
|
||||
const fetchOcrText = useCallback(async () => {
|
||||
if (!singleDoc) {
|
||||
throw new Error('No document selected.');
|
||||
}
|
||||
|
||||
const cached = ocrTextCacheRef.current;
|
||||
if (cached?.docId === singleDoc.id && typeof cached.text === 'string') {
|
||||
return cached.text;
|
||||
}
|
||||
|
||||
const asset = getDocumentAsset(singleDoc, 'ocr-text');
|
||||
if (!asset) {
|
||||
throw new Error('No OCR text available for this document.');
|
||||
}
|
||||
|
||||
let entry = asset;
|
||||
let url = entry?.url ||
|
||||
resolveDocumentAssetUrl(singleDoc, 'ocr-text', {
|
||||
ensureAssetUrl,
|
||||
getAsset: getDocumentAsset,
|
||||
});
|
||||
|
||||
if (!url && typeof ensureAssetUrl === 'function') {
|
||||
const ensured = await ensureAssetUrl(singleDoc.id, asset, { force: false });
|
||||
if (ensured) {
|
||||
entry = ensured;
|
||||
}
|
||||
url = entry?.url ||
|
||||
resolveDocumentAssetUrl(singleDoc, 'ocr-text', {
|
||||
ensureAssetUrl,
|
||||
getAsset: getDocumentAsset,
|
||||
});
|
||||
}
|
||||
|
||||
if (!url) {
|
||||
throw new Error('OCR text URL is unavailable.');
|
||||
}
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch OCR text (status ${response.status})`);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
const trimmed = (text || '').trim();
|
||||
if (!trimmed) {
|
||||
throw new Error('OCR text is empty.');
|
||||
}
|
||||
|
||||
ocrTextCacheRef.current = { docId: singleDoc.id, text: trimmed };
|
||||
return trimmed;
|
||||
}, [singleDoc, ensureAssetUrl, getDocumentAsset]);
|
||||
|
||||
const extractSuggestionPayload = useCallback((raw) => {
|
||||
const tryParse = (input) => {
|
||||
if (!input) return null;
|
||||
try {
|
||||
return JSON.parse(input);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const trimmed = (raw || '').trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let payload = tryParse(trimmed);
|
||||
if (!payload) {
|
||||
const fenced = trimmed.match(/```json([\s\S]*?)```/i);
|
||||
if (fenced) {
|
||||
payload = tryParse(fenced[1]);
|
||||
}
|
||||
}
|
||||
if (!payload) {
|
||||
const firstObject = trimmed.match(/\{[\s\S]*\}/);
|
||||
if (firstObject) {
|
||||
payload = tryParse(firstObject[0]);
|
||||
}
|
||||
}
|
||||
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const title = typeof payload.title === 'string' ? payload.title : null;
|
||||
const issuedAt =
|
||||
typeof payload.issued_at === 'string'
|
||||
? payload.issued_at
|
||||
: typeof payload.issuedAt === 'string'
|
||||
? payload.issuedAt
|
||||
: null;
|
||||
|
||||
return { title, issuedAt };
|
||||
}, []);
|
||||
|
||||
const normalizeIssuedAtSuggestion = useCallback((input) => {
|
||||
if (!input) {
|
||||
return null;
|
||||
}
|
||||
const raw = String(input).trim();
|
||||
if (!raw || raw.toLowerCase() === 'null') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isoMatch = raw.match(/(\d{4})[-/.](\d{2})[-/.](\d{2})/);
|
||||
let year;
|
||||
let month;
|
||||
let day;
|
||||
if (isoMatch) {
|
||||
year = isoMatch[1];
|
||||
month = isoMatch[2];
|
||||
day = isoMatch[3];
|
||||
} else {
|
||||
const parsed = Date.parse(raw);
|
||||
if (Number.isNaN(parsed)) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(parsed);
|
||||
year = String(date.getUTCFullYear());
|
||||
month = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||
day = String(date.getUTCDate()).padStart(2, '0');
|
||||
}
|
||||
|
||||
if (!year || !month || !day) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${year}-${month}-${day}T00:00:00Z`;
|
||||
}, []);
|
||||
|
||||
const openOcrModal = useCallback(() => {
|
||||
if (!singleDoc) {
|
||||
return;
|
||||
@@ -678,154 +474,6 @@ const DetailPanel = ({
|
||||
setOcrOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleSuggestTitle = useCallback(async () => {
|
||||
if (!singleDoc) {
|
||||
return;
|
||||
}
|
||||
if (suggestionDisabledReason) {
|
||||
setAiSuggestionError(suggestionDisabledReason);
|
||||
return;
|
||||
}
|
||||
setAiSuggestionLoading(true);
|
||||
setAiSuggestionError(null);
|
||||
try {
|
||||
const ocrText = await fetchOcrText();
|
||||
const snippetLimit = 6000;
|
||||
const trimmedOcr = ocrText.length > snippetLimit ? `${ocrText.slice(0, snippetLimit)}…` : ocrText;
|
||||
const prompt = `You are helping rename scanned documents. Using only the OCR extract below, reply with a single JSON object of the form:
|
||||
{
|
||||
"title": "<short descriptive title in the document's original language or null>",
|
||||
"issued_at": "<ISO8601 date YYYY-MM-DD if a clear issue/publication date exists, otherwise null>"
|
||||
}
|
||||
The title should be precise and descriptive (max 12 words), keep key entities (people, companies, case numbers, etc.), stay in the document's language, and omit surrounding quotes or trailing punctuation. Only include a date when the text clearly indicates one.
|
||||
|
||||
OCR TEXT:
|
||||
${trimmedOcr}`;
|
||||
|
||||
const body = {
|
||||
model: effectiveOllamaModel,
|
||||
prompt,
|
||||
stream: false,
|
||||
};
|
||||
|
||||
const suggestionResponse = await fetch(`${effectiveOllamaUrl}/api/generate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!suggestionResponse.ok) {
|
||||
throw new Error(`Ollama request failed (${suggestionResponse.status})`);
|
||||
}
|
||||
|
||||
const data = await suggestionResponse.json();
|
||||
const raw = (data?.response || data?.text || '').trim();
|
||||
if (!raw) {
|
||||
throw new Error('Model returned an empty response.');
|
||||
}
|
||||
|
||||
const payload = extractSuggestionPayload(raw);
|
||||
if (!payload) {
|
||||
throw new Error('Unable to parse suggestion response.');
|
||||
}
|
||||
|
||||
const cleanedTitle = payload.title
|
||||
? payload.title
|
||||
.replace(/^['"\s]+/, '')
|
||||
.replace(/['"\s]+$/, '')
|
||||
.replace(/[.!?]+$/, '')
|
||||
.trim()
|
||||
: null;
|
||||
const normalizedIssuedAt = normalizeIssuedAtSuggestion(payload.issuedAt);
|
||||
|
||||
if (!cleanedTitle && !normalizedIssuedAt) {
|
||||
throw new Error('No usable title or date found in the response.');
|
||||
}
|
||||
|
||||
setAiSuggestion({
|
||||
title: cleanedTitle || null,
|
||||
issuedAt: normalizedIssuedAt,
|
||||
raw,
|
||||
model: effectiveOllamaModel,
|
||||
});
|
||||
setAiSuggestionSelection({
|
||||
title: Boolean(cleanedTitle),
|
||||
issuedAt: Boolean(normalizedIssuedAt),
|
||||
});
|
||||
setAiSuggestionError(null);
|
||||
} catch (error) {
|
||||
setAiSuggestion(null);
|
||||
setAiSuggestionSelection({ title: true, issuedAt: true });
|
||||
setAiSuggestionError(error.message || 'Failed to generate suggestions.');
|
||||
} finally {
|
||||
setAiSuggestionLoading(false);
|
||||
}
|
||||
}, [
|
||||
singleDoc,
|
||||
effectiveOllamaUrl,
|
||||
effectiveOllamaModel,
|
||||
fetchOcrText,
|
||||
extractSuggestionPayload,
|
||||
normalizeIssuedAtSuggestion,
|
||||
suggestionDisabledReason,
|
||||
]);
|
||||
|
||||
const handleApplySuggestion = useCallback(async () => {
|
||||
if (!singleDoc || !aiSuggestion) {
|
||||
return;
|
||||
}
|
||||
|
||||
const applyTitle = Boolean(aiSuggestionSelection.title && aiSuggestion.title);
|
||||
const applyIssuedAt = Boolean(aiSuggestionSelection.issuedAt && aiSuggestion.issuedAt);
|
||||
|
||||
if (!applyTitle && !applyIssuedAt) {
|
||||
setAiSuggestionError('Select at least one suggestion to apply.');
|
||||
return;
|
||||
}
|
||||
|
||||
setAiSuggestionApplying(true);
|
||||
setAiSuggestionError(null);
|
||||
try {
|
||||
let titleOk = true;
|
||||
let issuedOk = true;
|
||||
|
||||
if (applyTitle) {
|
||||
titleOk = await onUpdateTitle(singleDoc.id, aiSuggestion.title);
|
||||
}
|
||||
|
||||
if (applyIssuedAt) {
|
||||
issuedOk = await onUpdateIssuedAt(singleDoc.id, aiSuggestion.issuedAt);
|
||||
}
|
||||
|
||||
if (titleOk && issuedOk) {
|
||||
setAiSuggestion(null);
|
||||
setAiSuggestionSelection({ title: true, issuedAt: true });
|
||||
setAiSuggestionError(null);
|
||||
} else {
|
||||
const failures = [];
|
||||
if (!titleOk && applyTitle) failures.push('title');
|
||||
if (!issuedOk && applyIssuedAt) failures.push('date');
|
||||
setAiSuggestionError(`Failed to update ${failures.join(' and ')}.`);
|
||||
}
|
||||
} finally {
|
||||
setAiSuggestionApplying(false);
|
||||
}
|
||||
}, [
|
||||
singleDoc,
|
||||
aiSuggestion,
|
||||
aiSuggestionSelection,
|
||||
onUpdateTitle,
|
||||
onUpdateIssuedAt,
|
||||
]);
|
||||
|
||||
const handleDismissSuggestion = useCallback(() => {
|
||||
setAiSuggestion(null);
|
||||
setAiSuggestionSelection({ title: true, issuedAt: true });
|
||||
setAiSuggestionError(null);
|
||||
}, []);
|
||||
|
||||
const singlePreviewNavigator = useAssetNavigator({
|
||||
document: singleDoc,
|
||||
assetType: 'preview',
|
||||
@@ -1030,17 +678,14 @@ ${trimmedOcr}`;
|
||||
if (!doc?.id) return;
|
||||
(doc.correspondents || []).forEach((entry) => {
|
||||
if (!entry?.id) return;
|
||||
const normalizedRole = normalizeRole(entry.role);
|
||||
const key = `${entry.id}:${normalizedRole}`;
|
||||
if (!map.has(key)) {
|
||||
map.set(key, {
|
||||
if (!map.has(entry.id)) {
|
||||
map.set(entry.id, {
|
||||
id: entry.id,
|
||||
name: entry.name || '',
|
||||
role: normalizedRole,
|
||||
documentIds: new Set(),
|
||||
});
|
||||
}
|
||||
map.get(key).documentIds.add(doc.id);
|
||||
map.get(entry.id).documentIds.add(doc.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1048,23 +693,20 @@ ${trimmedOcr}`;
|
||||
.map((entry) => ({
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
role: entry.role,
|
||||
documentIds: [...entry.documentIds],
|
||||
count: entry.documentIds.size,
|
||||
}))
|
||||
.sort(compareCorrespondents);
|
||||
.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||
}, [selectedDocuments]);
|
||||
|
||||
const handleBulkCorrespondentRemove = useCallback(
|
||||
(entry) => {
|
||||
if (!entry?.id) return;
|
||||
const normalizedRole = normalizeRole(entry.role);
|
||||
if (onBulkCorrespondentRemove) {
|
||||
return onBulkCorrespondentRemove({
|
||||
assignments: [
|
||||
{
|
||||
correspondent_id: entry.id,
|
||||
role: normalizedRole,
|
||||
},
|
||||
],
|
||||
documentIds: entry.documentIds,
|
||||
@@ -1075,11 +717,7 @@ ${trimmedOcr}`;
|
||||
const targets = entry.documentIds && entry.documentIds.length
|
||||
? entry.documentIds
|
||||
: selectedDocuments
|
||||
.filter((doc) =>
|
||||
(doc.correspondents || []).some(
|
||||
(item) => item.id === entry.id && normalizeRole(item.role) === normalizedRole,
|
||||
),
|
||||
)
|
||||
.filter((doc) => (doc.correspondents || []).some((item) => item.id === entry.id))
|
||||
.map((doc) => doc.id);
|
||||
|
||||
return Promise.all(
|
||||
@@ -1087,12 +725,11 @@ ${trimmedOcr}`;
|
||||
onCorrespondentRemove({
|
||||
documentId,
|
||||
correspondentId: entry.id,
|
||||
role: normalizedRole,
|
||||
}),
|
||||
),
|
||||
).catch(() => {});
|
||||
},
|
||||
[bulkCorrespondents, onBulkCorrespondentRemove, onCorrespondentRemove, selectedDocuments],
|
||||
[onBulkCorrespondentRemove, onCorrespondentRemove, selectedDocuments],
|
||||
);
|
||||
|
||||
const openZoomPreview = useCallback((config) => {
|
||||
@@ -1388,87 +1025,10 @@ ${trimmedOcr}`;
|
||||
>
|
||||
<EditIcon className="icon-inline" />
|
||||
</button>
|
||||
{canSuggestTitle ? (
|
||||
<button
|
||||
type="button"
|
||||
className="secondary detail-title-suggest-button"
|
||||
onClick={suggestButtonDisabled ? undefined : handleSuggestTitle}
|
||||
disabled={suggestButtonDisabled}
|
||||
title={
|
||||
aiSuggestionLoading
|
||||
? 'Generating suggestion…'
|
||||
: suggestionDisabledReason || 'Generate a title suggestion from OCR text'
|
||||
}
|
||||
>
|
||||
{aiSuggestionLoading ? 'Suggesting…' : 'Suggest title'}
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{titleError ? <div className="status-inline error">{titleError}</div> : null}
|
||||
{aiSuggestionError ? (
|
||||
<div className="status-inline error">{aiSuggestionError}</div>
|
||||
) : null}
|
||||
{aiSuggestionLoading && !aiSuggestion ? (
|
||||
<div className="status-inline">Generating title suggestion…</div>
|
||||
) : null}
|
||||
{aiSuggestion ? (
|
||||
<div className="detail-title-suggestion" role="status" aria-live="polite">
|
||||
<div className="detail-title-suggestion__label">AI suggestions</div>
|
||||
<div className="detail-title-suggestion__meta">Generated with {aiSuggestion.model}</div>
|
||||
<div className="detail-title-suggestion__options">
|
||||
<label className="detail-title-suggestion__option">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(aiSuggestionSelection.title && aiSuggestion.title)}
|
||||
disabled={!aiSuggestion.title || aiSuggestionApplying}
|
||||
onChange={(event) =>
|
||||
setAiSuggestionSelection((previous) => ({
|
||||
...previous,
|
||||
title: event.target.checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<span>
|
||||
<strong>Title:</strong>{' '}
|
||||
{aiSuggestion.title || <em>Not available</em>}
|
||||
</span>
|
||||
</label>
|
||||
<label className="detail-title-suggestion__option">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(aiSuggestionSelection.issuedAt && aiSuggestion.issuedAt)}
|
||||
disabled={!aiSuggestion.issuedAt || aiSuggestionApplying}
|
||||
onChange={(event) =>
|
||||
setAiSuggestionSelection((previous) => ({
|
||||
...previous,
|
||||
issuedAt: event.target.checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<span>
|
||||
<strong>Issued date:</strong>{' '}
|
||||
{aiSuggestion.issuedAt
|
||||
? new Date(aiSuggestion.issuedAt).toLocaleDateString()
|
||||
: <em>Not available</em>}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="detail-title-suggestion__actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleApplySuggestion}
|
||||
disabled={aiSuggestionApplying || !canApplyAiSuggestion}
|
||||
>
|
||||
{aiSuggestionApplying ? 'Applying…' : 'Apply selected'}
|
||||
</button>
|
||||
<button type="button" className="secondary" onClick={handleDismissSuggestion}>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="meta">
|
||||
<div>
|
||||
<strong>Uploaded:</strong>{' '}
|
||||
@@ -1560,14 +1120,12 @@ ${trimmedOcr}`;
|
||||
onCorrespondentRemove?.({
|
||||
documentId: singleDoc.id,
|
||||
correspondentId: entry.id,
|
||||
role: entry.role,
|
||||
})
|
||||
}
|
||||
onAdd={({ name, role, input }) =>
|
||||
onAdd={({ name, input }) =>
|
||||
onCorrespondentAdd?.({
|
||||
document: singleDoc,
|
||||
name,
|
||||
role,
|
||||
input,
|
||||
})
|
||||
}
|
||||
@@ -1712,8 +1270,8 @@ ${trimmedOcr}`;
|
||||
title="Correspondents"
|
||||
entries={bulkCorrespondents}
|
||||
onRemove={handleBulkCorrespondentRemove}
|
||||
onAdd={({ name, role, input }) =>
|
||||
onBulkCorrespondentAdd?.({ name, role, input })
|
||||
onAdd={({ name, input }) =>
|
||||
onBulkCorrespondentAdd?.({ name, input })
|
||||
}
|
||||
addPlaceholder="Add correspondent to selection"
|
||||
datalistId="correspondent-catalog-bulk"
|
||||
@@ -1742,6 +1300,21 @@ ${trimmedOcr}`;
|
||||
>
|
||||
<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
|
||||
@@ -1770,21 +1343,6 @@ ${trimmedOcr}`;
|
||||
<DownloadIcon />
|
||||
</a>
|
||||
) : null}
|
||||
{singleDoc ? (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button ghost"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onOpenPreview(singleDoc.id);
|
||||
}}
|
||||
aria-label="Open preview"
|
||||
title="Open preview"
|
||||
disabled={!singleHasPreview}
|
||||
>
|
||||
<WindowMaximizeIcon />
|
||||
</button>
|
||||
) : null}
|
||||
{showOcrAction ? (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -25,10 +25,68 @@ const PreviewZoomOverlay = ({
|
||||
const portalTarget = ensureDocumentRoot();
|
||||
const [isNativeScale, setIsNativeScale] = useState(false);
|
||||
const [naturalSize, setNaturalSize] = useState({ width: null, height: null });
|
||||
const [renderBackdrop, setRenderBackdrop] = useState(false);
|
||||
const [isBackdropVisible, setBackdropVisible] = useState(false);
|
||||
const [displaySnapshot, setDisplaySnapshot] = useState(null);
|
||||
const scrollRef = useRef(null);
|
||||
const imageRef = useRef(null);
|
||||
const focusRef = useRef(null);
|
||||
const previouslyFocusedRef = useRef(null);
|
||||
const visibilityTimerRef = useRef(null);
|
||||
const displayTimerRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (display?.url) {
|
||||
setDisplaySnapshot(display);
|
||||
}
|
||||
}, [display]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visibilityTimerRef.current) {
|
||||
clearTimeout(visibilityTimerRef.current);
|
||||
visibilityTimerRef.current = null;
|
||||
}
|
||||
if (displayTimerRef.current) {
|
||||
cancelAnimationFrame(displayTimerRef.current);
|
||||
displayTimerRef.current = null;
|
||||
}
|
||||
|
||||
if (open && display?.url) {
|
||||
setRenderBackdrop(true);
|
||||
displayTimerRef.current = requestAnimationFrame(() => {
|
||||
displayTimerRef.current = requestAnimationFrame(() => {
|
||||
setBackdropVisible(true);
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
if (displayTimerRef.current) {
|
||||
cancelAnimationFrame(displayTimerRef.current);
|
||||
displayTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
setBackdropVisible(false);
|
||||
visibilityTimerRef.current = setTimeout(() => {
|
||||
setRenderBackdrop(false);
|
||||
}, 260);
|
||||
|
||||
return () => {
|
||||
if (visibilityTimerRef.current) {
|
||||
clearTimeout(visibilityTimerRef.current);
|
||||
visibilityTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [open, display?.url]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (visibilityTimerRef.current) {
|
||||
clearTimeout(visibilityTimerRef.current);
|
||||
}
|
||||
if (displayTimerRef.current) {
|
||||
cancelAnimationFrame(displayTimerRef.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setIsNativeScale(false);
|
||||
@@ -75,7 +133,7 @@ const PreviewZoomOverlay = ({
|
||||
previouslyFocusedRef.current.focus();
|
||||
}
|
||||
previouslyFocusedRef.current = null;
|
||||
return undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
@@ -86,24 +144,24 @@ const PreviewZoomOverlay = ({
|
||||
previouslyFocusedRef.current = null;
|
||||
}
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const scrollEl = scrollRef.current;
|
||||
if (!scrollEl) {
|
||||
const activeDisplay = open && display?.url ? display : displaySnapshot;
|
||||
|
||||
useEffect(() => {
|
||||
if (!renderBackdrop || !activeDisplay?.url) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const frame = requestAnimationFrame(() => {
|
||||
scrollEl.focus();
|
||||
const scrollEl = scrollRef.current;
|
||||
if (scrollEl && typeof scrollEl.focus === 'function') {
|
||||
scrollEl.focus({ preventScroll: true });
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
if (previouslyFocusedRef.current && typeof previouslyFocusedRef.current.focus === 'function') {
|
||||
previouslyFocusedRef.current.focus();
|
||||
previouslyFocusedRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [open]);
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [renderBackdrop, activeDisplay?.url]);
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
event.stopPropagation();
|
||||
@@ -119,26 +177,27 @@ const PreviewZoomOverlay = ({
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowLeft') {
|
||||
if (display?.canGoPrev && display?.goPrev) {
|
||||
if (activeDisplay?.canGoPrev && activeDisplay?.goPrev) {
|
||||
event.preventDefault();
|
||||
display.goPrev();
|
||||
activeDisplay.goPrev();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowRight') {
|
||||
if (display?.canGoNext && display?.goNext) {
|
||||
if (activeDisplay?.canGoNext && activeDisplay?.goNext) {
|
||||
event.preventDefault();
|
||||
display.goNext();
|
||||
activeDisplay.goNext();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!open || !display?.url || !portalTarget) {
|
||||
if (!renderBackdrop || !activeDisplay?.url || !portalTarget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const navVisible = Boolean(display?.canGoPrev || display?.canGoNext);
|
||||
const effectiveDisplay = activeDisplay;
|
||||
const navVisible = Boolean(effectiveDisplay?.canGoPrev || effectiveDisplay?.canGoNext);
|
||||
const stageClassName = [
|
||||
'preview-zoom__stage',
|
||||
]
|
||||
@@ -152,6 +211,13 @@ const PreviewZoomOverlay = ({
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
const backdropClassName = [
|
||||
'preview-zoom-backdrop',
|
||||
isBackdropVisible ? 'preview-zoom-backdrop--visible' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
const imageStyle = isNativeScale
|
||||
? {
|
||||
cursor: 'zoom-out',
|
||||
@@ -169,7 +235,7 @@ const PreviewZoomOverlay = ({
|
||||
return createPortal(
|
||||
(
|
||||
<div
|
||||
className="preview-zoom-backdrop"
|
||||
className={backdropClassName}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Enlarged document preview"
|
||||
@@ -186,8 +252,8 @@ const PreviewZoomOverlay = ({
|
||||
tabIndex={-1}
|
||||
>
|
||||
<img
|
||||
src={display.url}
|
||||
alt={display.alt || 'Document preview'}
|
||||
src={effectiveDisplay.url}
|
||||
alt={effectiveDisplay.alt || 'Document preview'}
|
||||
className="preview-zoom__image"
|
||||
ref={imageRef}
|
||||
draggable={false}
|
||||
@@ -227,12 +293,12 @@ const PreviewZoomOverlay = ({
|
||||
className="preview-zoom__nav-button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (display?.canGoPrev && display?.goPrev) {
|
||||
display.goPrev();
|
||||
if (effectiveDisplay?.canGoPrev && effectiveDisplay?.goPrev) {
|
||||
effectiveDisplay.goPrev();
|
||||
}
|
||||
}}
|
||||
aria-label="Previous preview"
|
||||
disabled={!display?.canGoPrev}
|
||||
disabled={!effectiveDisplay?.canGoPrev}
|
||||
>
|
||||
<ArrowLeftIcon />
|
||||
</button>
|
||||
@@ -241,12 +307,12 @@ const PreviewZoomOverlay = ({
|
||||
className="preview-zoom__nav-button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (display?.canGoNext && display?.goNext) {
|
||||
display.goNext();
|
||||
if (effectiveDisplay?.canGoNext && effectiveDisplay?.goNext) {
|
||||
effectiveDisplay.goNext();
|
||||
}
|
||||
}}
|
||||
aria-label="Next preview"
|
||||
disabled={!display?.canGoNext}
|
||||
disabled={!effectiveDisplay?.canGoNext}
|
||||
>
|
||||
<ArrowRightIcon />
|
||||
</button>
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { getAssetFromVersion, resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
|
||||
import { getTagColorStyle } from '../utils/colors';
|
||||
import { DownloadIcon, EditIcon, ViewListIcon, ViewGridIcon, FolderIcon, TrashIcon } from '../ui/icons';
|
||||
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 DEFAULT_GRID_ICON_SIZE = 96;
|
||||
const DEFAULT_GRID_ICON_SIZE = 144;
|
||||
const DEFAULT_GRID_TITLE_SIZE = '11px';
|
||||
const LIST_ICON_SIZE = 48;
|
||||
|
||||
@@ -13,6 +25,39 @@ const getPageCount = (doc) =>
|
||||
? doc.current_version.metadata.page_count
|
||||
: null;
|
||||
|
||||
const resolveCorrespondents = (doc) => {
|
||||
if (!doc || !Array.isArray(doc.correspondents)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seen = new Set();
|
||||
const results = [];
|
||||
|
||||
doc.correspondents.forEach((entry, index) => {
|
||||
if (!entry) return;
|
||||
|
||||
const id = entry.id ?? entry.correspondent_id ?? null;
|
||||
const name = (entry.name || entry.label || entry.slug || '').trim();
|
||||
if (!name) return;
|
||||
|
||||
if (id && seen.has(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (id) {
|
||||
seen.add(id);
|
||||
}
|
||||
|
||||
results.push({
|
||||
id,
|
||||
name,
|
||||
key: id ?? `${name}-${index}`,
|
||||
});
|
||||
});
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
// Detects when an element becomes visible within a scroll container.
|
||||
const useLazyVisibility = (rootRef, resetKey) => {
|
||||
const targetRef = useRef(null);
|
||||
@@ -111,6 +156,25 @@ const DocumentThumbnailImage = ({
|
||||
innerClasses.push('document-thumbnail-inner--multipage');
|
||||
}
|
||||
|
||||
const aspectRatio = useMemo(() => {
|
||||
if (Number.isFinite(assetWidth) && Number.isFinite(assetHeight) && assetWidth > 0 && assetHeight > 0) {
|
||||
return assetWidth / assetHeight;
|
||||
}
|
||||
return null;
|
||||
}, [assetWidth, assetHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
const node = visibilityRef.current;
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
if (aspectRatio) {
|
||||
node.dataset.thumbnailAspect = String(aspectRatio);
|
||||
} else {
|
||||
delete node.dataset.thumbnailAspect;
|
||||
}
|
||||
}, [aspectRatio]);
|
||||
|
||||
return (
|
||||
<div className="document-thumbnail-wrapper" ref={visibilityRef}>
|
||||
<div className={innerClasses.join(' ')} style={innerStyle}>
|
||||
@@ -165,6 +229,7 @@ const DocumentsTable = ({
|
||||
onFolderRename,
|
||||
onDocumentRename,
|
||||
tagLookupById,
|
||||
activeCorrespondentIds = [],
|
||||
onDocumentListFocus,
|
||||
onDocumentListKeyDown,
|
||||
onFocusedRowChange,
|
||||
@@ -172,6 +237,7 @@ const DocumentsTable = ({
|
||||
getDocumentAsset = () => null,
|
||||
getDownloadHref,
|
||||
onTagClick,
|
||||
onCorrespondentClick,
|
||||
isSearchLoading = false,
|
||||
onDocumentTagDrop,
|
||||
viewMode = 'list',
|
||||
@@ -194,7 +260,12 @@ const DocumentsTable = ({
|
||||
() => new Set(draggingDocumentIds || []),
|
||||
[draggingDocumentIds],
|
||||
);
|
||||
const activeCorrespondentIdSet = useMemo(
|
||||
() => new Set(activeCorrespondentIds || []),
|
||||
[activeCorrespondentIds],
|
||||
);
|
||||
const scrollRef = useRef(null);
|
||||
const suppressDocumentClickRef = useRef(false);
|
||||
const [, forceVisibilityTick] = useState(0);
|
||||
const lastScrollNodeRef = useRef(null);
|
||||
const assignScrollRef = useCallback((node) => {
|
||||
@@ -333,6 +404,79 @@ const DocumentsTable = ({
|
||||
[isTagDragEvent, onDocumentTagDrop],
|
||||
);
|
||||
|
||||
const handleDocumentClick = useCallback(
|
||||
(documentId, event) => {
|
||||
if (suppressDocumentClickRef.current) {
|
||||
return;
|
||||
}
|
||||
onDocumentRowClick?.(documentId, event);
|
||||
},
|
||||
[onDocumentRowClick],
|
||||
);
|
||||
|
||||
const handleDocumentDragStartLocal = useCallback(
|
||||
(event, doc) => {
|
||||
suppressDocumentClickRef.current = true;
|
||||
onDocumentDragStart?.(event, doc);
|
||||
},
|
||||
[onDocumentDragStart],
|
||||
);
|
||||
|
||||
const handleDocumentDragEndLocal = useCallback(
|
||||
(event) => {
|
||||
onDocumentDragEnd?.(event);
|
||||
requestAnimationFrame(() => {
|
||||
suppressDocumentClickRef.current = false;
|
||||
});
|
||||
},
|
||||
[onDocumentDragEnd],
|
||||
);
|
||||
|
||||
const renderCorrespondentLinks = useCallback(
|
||||
(correspondents) =>
|
||||
correspondents.map((correspondent, index) => {
|
||||
const isActive = correspondent.id != null && activeCorrespondentIdSet.has(correspondent.id);
|
||||
const hasClickHandler = Boolean(onCorrespondentClick) && correspondent.id != null;
|
||||
const classNames = ['doc-correspondent-link'];
|
||||
if (isActive) classNames.push('is-active');
|
||||
if (!hasClickHandler) classNames.push('is-static');
|
||||
const isLast = index === correspondents.length - 1;
|
||||
const label = isLast
|
||||
? `${correspondent.name}:${String.fromCharCode(160)}`
|
||||
: correspondent.name;
|
||||
return (
|
||||
<React.Fragment key={correspondent.key ?? correspondent.id ?? `${correspondent.name}-${index}`}>
|
||||
<button
|
||||
type="button"
|
||||
className={classNames.join(' ')}
|
||||
aria-disabled={hasClickHandler ? undefined : true}
|
||||
onClick={(event) => {
|
||||
if (!hasClickHandler) {
|
||||
return;
|
||||
}
|
||||
event.stopPropagation();
|
||||
onCorrespondentClick(correspondent.id, correspondent);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (!hasClickHandler) {
|
||||
return;
|
||||
}
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.stopPropagation();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
{!isLast ? (
|
||||
<span className="doc-correspondent-link__separator">, </span>
|
||||
) : null}
|
||||
</React.Fragment>
|
||||
);
|
||||
}),
|
||||
[activeCorrespondentIdSet, onCorrespondentClick],
|
||||
);
|
||||
|
||||
const showDefaultEmptyState = !showingSearchResults && !subfolders.length && rows.length === 0;
|
||||
const showListSearchEmptyState =
|
||||
showingSearchResults && rows.length === 0 && !isGridView && !isSearchLoading;
|
||||
@@ -498,6 +642,7 @@ const DocumentsTable = ({
|
||||
const tagList = Array.isArray(doc.tags) ? doc.tags : [];
|
||||
const visibleTags = tagList.slice(0, 3);
|
||||
const remainingTagCount = tagList.length > 3 ? tagList.length - 3 : 0;
|
||||
const correspondents = resolveCorrespondents(doc);
|
||||
const cardClasses = ['document-card', 'document'];
|
||||
if (isSelected) cardClasses.push('selected');
|
||||
if (isDraggingDoc) cardClasses.push('is-dragging');
|
||||
@@ -508,11 +653,11 @@ const DocumentsTable = ({
|
||||
role="listitem"
|
||||
id={`document-card-${doc.id}`}
|
||||
data-doc-id={doc.id}
|
||||
onClick={(event) => onDocumentRowClick(doc.id, event)}
|
||||
onClick={(event) => handleDocumentClick(doc.id, event)}
|
||||
onDoubleClick={() => onDocumentOpen(doc.id)}
|
||||
draggable
|
||||
onDragStart={(event) => onDocumentDragStart(event, doc)}
|
||||
onDragEnd={onDocumentDragEnd}
|
||||
onDragStart={(event) => handleDocumentDragStartLocal(event, doc)}
|
||||
onDragEnd={handleDocumentDragEndLocal}
|
||||
onDragOver={(event) => handleDocumentTagDragOver(event)}
|
||||
onDragOverCapture={(event) => handleDocumentTagDragOver(event)}
|
||||
onDragLeave={handleDocumentTagDragLeave}
|
||||
@@ -533,7 +678,12 @@ const DocumentsTable = ({
|
||||
className="document-card__title"
|
||||
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>
|
||||
{visibleTags.length > 0 && (
|
||||
<div className="document-card__tags">
|
||||
@@ -606,8 +756,7 @@ const DocumentsTable = ({
|
||||
<tr>
|
||||
<th className="thumb-column">Preview</th>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Updated</th>
|
||||
<th>Issued</th>
|
||||
<th className="actions-column">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -671,7 +820,6 @@ const DocumentsTable = ({
|
||||
<span>{folder.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>Folder</td>
|
||||
<td>—</td>
|
||||
<td className="actions">
|
||||
<div className="action-buttons">
|
||||
@@ -721,6 +869,7 @@ const DocumentsTable = ({
|
||||
if (isSelected) rowClasses.push('selected');
|
||||
if (isDraggingDoc) rowClasses.push('is-dragging');
|
||||
const downloadHref = getDownloadHref?.(doc) || null;
|
||||
const correspondents = resolveCorrespondents(doc);
|
||||
|
||||
return (
|
||||
<tr
|
||||
@@ -728,11 +877,11 @@ const DocumentsTable = ({
|
||||
className={rowClasses.join(' ')}
|
||||
id={`document-row-${doc.id}`}
|
||||
data-doc-id={doc.id}
|
||||
onClick={(event) => onDocumentRowClick(doc.id, event)}
|
||||
onClick={(event) => handleDocumentClick(doc.id, event)}
|
||||
onDoubleClick={() => onDocumentOpen(doc.id)}
|
||||
draggable
|
||||
onDragStart={(event) => onDocumentDragStart(event, doc)}
|
||||
onDragEnd={onDocumentDragEnd}
|
||||
onDragStart={(event) => handleDocumentDragStartLocal(event, doc)}
|
||||
onDragEnd={handleDocumentDragEndLocal}
|
||||
onDragOver={handleDocumentTagDragOver}
|
||||
onDragLeave={handleDocumentTagDragLeave}
|
||||
onDrop={(event) => handleDocumentTagDrop(event, doc.id)}
|
||||
@@ -749,7 +898,14 @@ const DocumentsTable = ({
|
||||
<td className="doc-list__name">
|
||||
<div className="doc-name">
|
||||
<div className="doc-list__name-content">
|
||||
<span className="doc-name__title">{doc.title || doc.original_name}</span>
|
||||
<span className="doc-name__title">
|
||||
{correspondents.length > 0 ? (
|
||||
<span className="doc-correspondents">
|
||||
{renderCorrespondentLinks(correspondents)}
|
||||
</span>
|
||||
) : null}
|
||||
<span>{doc.title || doc.original_name}</span>
|
||||
</span>
|
||||
</div>
|
||||
{(doc.tags || []).length > 0 && (
|
||||
<div className="doc-name__tags">
|
||||
@@ -810,11 +966,18 @@ const DocumentsTable = ({
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td>{doc.content_type || 'Document'}</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 className="actions">
|
||||
<div className="action-buttons">
|
||||
@@ -896,3 +1059,139 @@ const DocumentsTable = ({
|
||||
|
||||
export default DocumentsTable;
|
||||
export { DocumentThumbnailImage };
|
||||
|
||||
export const createDocumentsTableHeaderActions = ({
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
onRequestCreateFolder,
|
||||
creatingFolder,
|
||||
onRefresh,
|
||||
onShowSkeuoWorkspace,
|
||||
}) => {
|
||||
const isGridView = viewMode === 'grid';
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="view-toggle" role="group" aria-label="Change view">
|
||||
<button
|
||||
type="button"
|
||||
className={`view-toggle__button${isGridView ? '' : ' active'}`}
|
||||
onClick={() => onViewModeChange?.('list')}
|
||||
aria-pressed={!isGridView}
|
||||
title="List view"
|
||||
>
|
||||
<ViewListIcon className="view-toggle__icon" size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`view-toggle__button${isGridView ? ' active' : ''}`}
|
||||
onClick={() => onViewModeChange?.('grid')}
|
||||
aria-pressed={isGridView}
|
||||
title="Icons view"
|
||||
>
|
||||
<ViewGridIcon className="view-toggle__icon" size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<span className="main-content__actions-divider" aria-hidden="true">
|
||||
<MinusVerticalIcon />
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button ghost"
|
||||
onClick={onRequestCreateFolder}
|
||||
disabled={creatingFolder}
|
||||
aria-label={creatingFolder ? 'Creating folder…' : 'Create folder'}
|
||||
title={creatingFolder ? 'Creating folder…' : 'Create folder'}
|
||||
>
|
||||
<FolderPlusIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button ghost"
|
||||
onClick={onRefresh}
|
||||
aria-label="Refresh"
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshIcon />
|
||||
</button>
|
||||
<button className="secondary" type="button" onClick={onShowSkeuoWorkspace}>
|
||||
Desk View
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const createDocumentsSurface = ({
|
||||
tableProps,
|
||||
parentBreadcrumb,
|
||||
onNavigateParent,
|
||||
renderSidebarToggle,
|
||||
detailProps,
|
||||
}) => {
|
||||
const {
|
||||
currentFolderName,
|
||||
searchResults,
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
onRequestCreateFolder,
|
||||
creatingFolder,
|
||||
onRefresh,
|
||||
onShowSkeuoWorkspace,
|
||||
} = tableProps;
|
||||
|
||||
const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName;
|
||||
const subtitle = Array.isArray(searchResults)
|
||||
? `${searchResults.length} matching document${searchResults.length === 1 ? '' : 's'}`
|
||||
: null;
|
||||
|
||||
const actions = createDocumentsTableHeaderActions({
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
onRequestCreateFolder,
|
||||
creatingFolder,
|
||||
onRefresh,
|
||||
onShowSkeuoWorkspace,
|
||||
});
|
||||
|
||||
const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null;
|
||||
const parentControl = parentBreadcrumb
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button ghost"
|
||||
onClick={onNavigateParent}
|
||||
aria-label="Go to parent folder"
|
||||
title="Go to parent folder"
|
||||
>
|
||||
<ArrowUpIcon />
|
||||
</button>
|
||||
)
|
||||
: null;
|
||||
const leading = sidebarToggle || parentControl
|
||||
? (
|
||||
<>
|
||||
{sidebarToggle}
|
||||
{parentControl}
|
||||
</>
|
||||
)
|
||||
: null;
|
||||
|
||||
const detail = (() => {
|
||||
if (!detailProps) {
|
||||
return null;
|
||||
}
|
||||
const count = detailProps.selectedDocuments?.length || 0;
|
||||
if (!count) {
|
||||
return null;
|
||||
}
|
||||
return <DetailPanel {...detailProps} />;
|
||||
})();
|
||||
|
||||
return {
|
||||
key: 'documents',
|
||||
variant: 'documents',
|
||||
header: { title, subtitle, leading, actions },
|
||||
content: <DocumentsTable {...tableProps} showHeader={false} />,
|
||||
detail,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
<title>Papercrate</title>
|
||||
</head>
|
||||
<body>
|
||||
<script src="/config.js"></script>
|
||||
<main id="app"></main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+759
-470
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,11 @@
|
||||
import React from 'react';
|
||||
import { DownloadIcon } from '../ui/icons';
|
||||
import React, { useMemo } from 'react';
|
||||
import { resolveDocumentAssetUrl } from '../asset_manager';
|
||||
import { formatFileSize } from '../utils/format';
|
||||
import { DownloadIcon, TextScanIcon, AnalyzeIcon, CloseIcon } from '../ui/icons';
|
||||
|
||||
const PreviewWorkspace = ({
|
||||
document,
|
||||
previewEntry,
|
||||
resolveApiPath,
|
||||
onClose,
|
||||
onRegenerateThumbnails,
|
||||
}) => {
|
||||
if (!document) {
|
||||
return null;
|
||||
@@ -15,59 +13,63 @@ const PreviewWorkspace = ({
|
||||
|
||||
const title = document.title || document.original_name || 'Document';
|
||||
const mime = previewEntry?.contentType || document.content_type || 'application/pdf';
|
||||
const downloadHref = document.current_version?.download_path
|
||||
? resolveApiPath(document.current_version.download_path)
|
||||
: null;
|
||||
const sizeBytes = Number(document.current_version?.size_bytes) || 0;
|
||||
const sizeLabel = sizeBytes > 0 ? formatFileSize(sizeBytes) : null;
|
||||
const metadata =
|
||||
document.metadata && Object.keys(document.metadata).length > 0 ? document.metadata : null;
|
||||
const folderName = document.folder_path || document.folder_name || null;
|
||||
const issuedAt = document.issued_at || document.current_version?.issued_at || null;
|
||||
const createdAt = document.created_at || null;
|
||||
const updatedAt = document.updated_at || null;
|
||||
const tags = Array.isArray(document.tags) ? document.tags : [];
|
||||
const correspondents = Array.isArray(document.correspondents) ? document.correspondents : [];
|
||||
|
||||
const metadataSummary = useMemo(() => {
|
||||
const rows = [];
|
||||
if (mime) rows.push(['Type', mime]);
|
||||
if (sizeLabel) rows.push(['Size', sizeLabel]);
|
||||
if (issuedAt) rows.push(['Issued', new Date(issuedAt).toLocaleString()]);
|
||||
if (createdAt) rows.push(['Created', new Date(createdAt).toLocaleString()]);
|
||||
if (updatedAt) rows.push(['Updated', new Date(updatedAt).toLocaleString()]);
|
||||
if (folderName) rows.push(['Folder', folderName]);
|
||||
if (tags.length) {
|
||||
rows.push(['Tags', tags.map((tag) => tag.label || tag.name || tag.slug).filter(Boolean).join(', ')]);
|
||||
}
|
||||
if (correspondents.length) {
|
||||
rows.push([
|
||||
'Correspondents',
|
||||
correspondents
|
||||
.map((entry) => entry.name || entry.label || entry.slug)
|
||||
.filter(Boolean)
|
||||
.join(', '),
|
||||
]);
|
||||
}
|
||||
return rows;
|
||||
}, [mime, sizeLabel, issuedAt, createdAt, updatedAt, folderName, tags, correspondents]);
|
||||
|
||||
return (
|
||||
<section className="preview-workspace">
|
||||
<header className="preview-workspace__header">
|
||||
<div className="preview-workspace__meta">
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={() => onClose(document.folder_id ?? 'root')}
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<div>
|
||||
<h2>{title}</h2>
|
||||
<span className="meta">
|
||||
{document.content_type || mime}
|
||||
{sizeLabel ? ` · ${sizeLabel}` : ''}
|
||||
</span>
|
||||
<aside className="preview-workspace__sidebar">
|
||||
<div className="preview-workspace__info">
|
||||
{metadataSummary.length ? (
|
||||
<dl className="preview-workspace__summary">
|
||||
{metadataSummary.map(([label, value]) => (
|
||||
<div className="preview-workspace__summary-row" key={label}>
|
||||
<dt>{label}</dt>
|
||||
<dd>{value || '—'}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="preview-workspace__actions">
|
||||
<a
|
||||
className="button-link with-icon"
|
||||
href={downloadHref || '#'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-disabled={!downloadHref}
|
||||
onClick={(event) => {
|
||||
if (!downloadHref) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DownloadIcon className="icon-inline" />
|
||||
<span>Download</span>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={() => onRegenerateThumbnails(document.id)}
|
||||
>
|
||||
Re-run analysis
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="preview-workspace__body">
|
||||
{metadata ? (
|
||||
<section className="preview-workspace__metadata">
|
||||
<h4>Metadata payload</h4>
|
||||
<pre>{JSON.stringify(metadata, null, 2)}</pre>
|
||||
</section>
|
||||
) : null}
|
||||
</aside>
|
||||
<div className="preview-workspace__viewer">
|
||||
{!previewEntry?.url ? (
|
||||
<div className="preview-workspace__message">Loading preview…</div>
|
||||
) : (
|
||||
@@ -78,15 +80,147 @@ const PreviewWorkspace = ({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{metadata && (
|
||||
<section className="preview-workspace__metadata">
|
||||
<h3>Metadata</h3>
|
||||
<pre>{JSON.stringify(metadata, null, 2)}</pre>
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default PreviewWorkspace;
|
||||
|
||||
export const createPreviewWorkspaceHeaderActions = ({
|
||||
document,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
resolveApiPath,
|
||||
notifyApiError,
|
||||
onRegenerate,
|
||||
}) => {
|
||||
if (!document) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const downloadHref = document.current_version?.download_path
|
||||
? resolveApiPath(document.current_version.download_path)
|
||||
: null;
|
||||
|
||||
const ocrAsset = getDocumentAsset(document, 'ocr-text');
|
||||
const hasOcr = Boolean(ocrAsset);
|
||||
|
||||
const handleOcrClick = async () => {
|
||||
if (!ocrAsset) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const ensured = await ensureAssetUrl(document.id, ocrAsset, { force: false });
|
||||
const entry = ensured || ocrAsset;
|
||||
const url = entry?.url
|
||||
|| resolveDocumentAssetUrl(document, 'ocr-text', {
|
||||
ensureAssetUrl,
|
||||
getAsset: getDocumentAsset,
|
||||
});
|
||||
if (!url) {
|
||||
throw new Error('OCR text URL unavailable.');
|
||||
}
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
} catch (error) {
|
||||
notifyApiError(error, 'Unable to open OCR text.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{downloadHref ? (
|
||||
<a
|
||||
className="icon-button"
|
||||
href={downloadHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Download document"
|
||||
title="Download document"
|
||||
>
|
||||
<DownloadIcon />
|
||||
</a>
|
||||
) : null}
|
||||
{hasOcr ? (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button ghost"
|
||||
onClick={handleOcrClick}
|
||||
aria-label="View OCR text"
|
||||
title="View OCR text"
|
||||
>
|
||||
<TextScanIcon />
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button ghost"
|
||||
onClick={() => onRegenerate(document.id)}
|
||||
aria-label="Re-run analysis"
|
||||
title="Re-run analysis"
|
||||
>
|
||||
<AnalyzeIcon />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const createPreviewSurface = ({
|
||||
document,
|
||||
previewEntry,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
resolveApiPath,
|
||||
notifyApiError,
|
||||
onRegenerate,
|
||||
onClose,
|
||||
renderSidebarToggle,
|
||||
}) => {
|
||||
if (!document) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const title = document.title || document.original_name || 'Document preview';
|
||||
const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null;
|
||||
const closeButton = onClose
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button ghost"
|
||||
onClick={() => onClose?.()}
|
||||
aria-label="Close preview"
|
||||
title="Close preview"
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
)
|
||||
: null;
|
||||
const leading = sidebarToggle || closeButton
|
||||
? (
|
||||
<>
|
||||
{sidebarToggle}
|
||||
{closeButton}
|
||||
</>
|
||||
)
|
||||
: null;
|
||||
const header = {
|
||||
title,
|
||||
subtitle: null,
|
||||
leading,
|
||||
actions: createPreviewWorkspaceHeaderActions({
|
||||
document,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
resolveApiPath,
|
||||
notifyApiError,
|
||||
onRegenerate,
|
||||
}),
|
||||
};
|
||||
|
||||
return {
|
||||
key: 'preview',
|
||||
variant: 'preview',
|
||||
header,
|
||||
content: <PreviewWorkspace document={document} previewEntry={previewEntry} />,
|
||||
supportsDetail: false,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
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 PreviewWorkspace from '../preview/PreviewWorkspace';
|
||||
|
||||
const DocumentViewerRoute = () => {
|
||||
const {
|
||||
previewWorkspaceDocument,
|
||||
previewWorkspaceEntry,
|
||||
closeDocumentPreview,
|
||||
handleThumbnailRegeneration,
|
||||
ensurePreviewData,
|
||||
notifyApiError,
|
||||
resolveApiPath,
|
||||
} = useAppShell();
|
||||
const { documentId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
@@ -44,29 +39,15 @@ const DocumentViewerRoute = () => {
|
||||
};
|
||||
}, [documentId, ensurePreviewData, notifyApiError, navigate]);
|
||||
|
||||
const isReady =
|
||||
documentId && previewWorkspaceDocument && previewWorkspaceDocument.id === documentId;
|
||||
|
||||
if (!isReady) {
|
||||
return (
|
||||
<main className="preview-main">
|
||||
<div className="preview-workspace__message">Loading preview…</div>
|
||||
</main>
|
||||
);
|
||||
if (!documentId) {
|
||||
return <Navigate to="/documents" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="preview-main">
|
||||
<PreviewWorkspace
|
||||
document={previewWorkspaceDocument}
|
||||
previewEntry={previewWorkspaceEntry}
|
||||
resolveApiPath={resolveApiPath}
|
||||
onClose={closeDocumentPreview}
|
||||
onRegenerateThumbnails={handleThumbnailRegeneration}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
if (!previewWorkspaceDocument || previewWorkspaceDocument.id !== documentId) {
|
||||
return <div className="preview-workspace__message">Loading preview…</div>;
|
||||
}
|
||||
|
||||
return <Navigate to="/documents" replace />;
|
||||
};
|
||||
|
||||
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 { ChevronIcon, TrashIcon, EditIcon, FolderIcon, ChevronsLeftIcon } from '../ui/icons';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
ChevronIcon,
|
||||
TrashIcon,
|
||||
EditIcon,
|
||||
FolderIcon,
|
||||
ChevronsLeftIcon,
|
||||
LogoutIcon,
|
||||
ChevronDownIcon,
|
||||
SettingsIcon,
|
||||
CheckIcon,
|
||||
} from '../ui/icons';
|
||||
|
||||
import { getTagColorStyle } from '../utils/colors';
|
||||
|
||||
@@ -20,13 +30,13 @@ const FolderNode = ({
|
||||
draggingFolderId,
|
||||
}) => {
|
||||
const isRoot = node.id === 'root';
|
||||
const hasChildren = node.children.length > 0;
|
||||
const canToggle = !isRoot && (hasChildren || !node.loaded);
|
||||
const showChevron = !isRoot && hasChildren;
|
||||
const hasChildren = Boolean(node.hasChildren);
|
||||
const canToggle = hasChildren || !node.loaded;
|
||||
const showChevron = canToggle;
|
||||
const icon = showChevron ? <ChevronIcon className="toggle-icon" /> : null;
|
||||
const canDrag = !isRoot;
|
||||
const isDragging = draggingFolderId === node.id;
|
||||
const isExpanded = isRoot ? true : Boolean(node.expanded);
|
||||
const isExpanded = Boolean(node.expanded);
|
||||
const rowClasses = ['folder-row'];
|
||||
if (isSelected) {
|
||||
rowClasses.push('active');
|
||||
@@ -58,14 +68,12 @@ const FolderNode = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{!isRoot && (
|
||||
<span
|
||||
className={`toggle${showChevron ? '' : ' invisible'}${isExpanded ? ' expanded' : ''}`}
|
||||
onClick={handleToggleClick}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
<span className="name">
|
||||
<FolderIcon className="folder-icon-image" size={16} />
|
||||
{node.name}
|
||||
@@ -149,6 +157,11 @@ const Sidebar = ({
|
||||
onLogout,
|
||||
status,
|
||||
onCollapse,
|
||||
tenantSlug,
|
||||
tenants = [],
|
||||
activeTenantId = null,
|
||||
onSelectTenant,
|
||||
onOpenSettings,
|
||||
}) => {
|
||||
const sortedCorrespondents = useMemo(
|
||||
() =>
|
||||
@@ -181,10 +194,67 @@ const Sidebar = ({
|
||||
const handleSearchClear = useCallback(() => {
|
||||
onSearchClear?.();
|
||||
}, [onSearchClear]);
|
||||
const handleLogoutClick = useCallback(() => {
|
||||
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(
|
||||
(ids, depth) =>
|
||||
ids.map((id) => {
|
||||
@@ -227,17 +297,75 @@ const Sidebar = ({
|
||||
);
|
||||
|
||||
const rootNode = folderNodes.get('root');
|
||||
const hintText = appStatus === 'bootstrapping' && loading
|
||||
? 'Loading your library…'
|
||||
: previewActive
|
||||
? 'Viewing document preview. Press ← Back to return to the library.'
|
||||
: 'Drag files here to upload.';
|
||||
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<div className="panel-header sidebar__header">
|
||||
<div className="panel-actions">
|
||||
<h1 className="sidebar__title">Papercrate</h1>
|
||||
<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
|
||||
@@ -253,7 +381,6 @@ const Sidebar = ({
|
||||
</div>
|
||||
</div>
|
||||
<div className="panel-body sidebar__body">
|
||||
<span className="sidebar__hint">{hintText}</span>
|
||||
{status && (
|
||||
<div className="sidebar__status">
|
||||
<div className={`status-banner ${status.variant}`}>{status.message}</div>
|
||||
@@ -376,11 +503,6 @@ const Sidebar = ({
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="sidebar__footer">
|
||||
<button className="secondary" type="button" onClick={handleLogoutClick}>
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
+600
-152
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,9 @@
|
||||
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,
|
||||
@@ -170,6 +174,14 @@ function TagsPanel({
|
||||
disabled={creating}
|
||||
aria-label="Tag color (optional)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={() => setCreateColor(generateRandomTagColor())}
|
||||
disabled={creating}
|
||||
>
|
||||
Random color
|
||||
</button>
|
||||
{createColor && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -247,6 +259,14 @@ function TagsPanel({
|
||||
disabled={saving || deletingId === tag.id}
|
||||
aria-label="Pick tag color"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={() => setDraftColor(generateRandomTagColor())}
|
||||
disabled={saving || deletingId === tag.id}
|
||||
>
|
||||
Random color
|
||||
</button>
|
||||
{draftColor && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -18,6 +18,11 @@ import {
|
||||
IconFolderPlus,
|
||||
IconRefresh,
|
||||
IconMinusVertical,
|
||||
IconLogout,
|
||||
IconChevronDown,
|
||||
IconX,
|
||||
IconSettings,
|
||||
IconCheck,
|
||||
} from '@tabler/icons-react';
|
||||
import FolderSvg from '../assets/folder.svg';
|
||||
|
||||
@@ -183,6 +188,33 @@ export const MinusVerticalIcon = ({ className, size = '1em', stroke = 1.6, ...re
|
||||
/>
|
||||
);
|
||||
|
||||
export const CloseIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||
<IconX
|
||||
className={composeClassName('icon', className)}
|
||||
size={size}
|
||||
stroke={stroke}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
|
||||
export const 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)}
|
||||
@@ -201,6 +233,24 @@ export const WindowMaximizeIcon = ({ className, size = '1em', stroke = 1.6, ...r
|
||||
/>
|
||||
);
|
||||
|
||||
export const LogoutIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||
<IconLogout
|
||||
className={composeClassName('icon', className)}
|
||||
size={size}
|
||||
stroke={stroke}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
|
||||
export const ChevronDownIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||
<IconChevronDown
|
||||
className={composeClassName('icon', className)}
|
||||
size={size}
|
||||
stroke={stroke}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
|
||||
export default {
|
||||
ChevronIcon,
|
||||
TrashIcon,
|
||||
@@ -219,6 +269,8 @@ export default {
|
||||
RefreshIcon,
|
||||
ArrowUpIcon,
|
||||
MinusVerticalIcon,
|
||||
LogoutIcon,
|
||||
ChevronDownIcon,
|
||||
};
|
||||
|
||||
export const TextScanIcon = ({ className, size = '1em', stroke = 1.6, ...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 clampRange = (value, min, max) => Math.min(max, Math.max(min, value));
|
||||
|
||||
const gammaEncode = (channel) =>
|
||||
channel <= 0.0031308 ? 12.92 * channel : 1.055 * Math.pow(channel, 1 / 2.4) - 0.055;
|
||||
|
||||
@@ -77,6 +79,37 @@ const hslToHex = (h, s, l) => {
|
||||
return `#${((sr << 16) | (sg << 8) | sb).toString(16).padStart(6, '0')}`;
|
||||
};
|
||||
|
||||
const rgbToHsl = ({ r, g, b }) => {
|
||||
const rn = r / 255;
|
||||
const gn = g / 255;
|
||||
const bn = b / 255;
|
||||
|
||||
const max = Math.max(rn, gn, bn);
|
||||
const min = Math.min(rn, gn, bn);
|
||||
const delta = max - min;
|
||||
|
||||
let hue = 0;
|
||||
if (delta !== 0) {
|
||||
if (max === rn) {
|
||||
hue = ((gn - bn) / delta) % 6;
|
||||
} else if (max === gn) {
|
||||
hue = (bn - rn) / delta + 2;
|
||||
} else {
|
||||
hue = (rn - gn) / delta + 4;
|
||||
}
|
||||
hue *= 60;
|
||||
if (hue < 0) hue += 360;
|
||||
}
|
||||
|
||||
const lightness = (max + min) / 2;
|
||||
let saturation = 0;
|
||||
if (delta !== 0) {
|
||||
saturation = delta / (1 - Math.abs(2 * lightness - 1));
|
||||
}
|
||||
|
||||
return { h: hue, s: clamp01(saturation), l: clamp01(lightness) };
|
||||
};
|
||||
|
||||
export const hexToRgb = (input) => {
|
||||
if (!input) return null;
|
||||
const match = HEX_COLOR_PATTERN.exec(input.trim());
|
||||
@@ -102,20 +135,184 @@ export const relativeLuminance = ({ r, g, b }) => {
|
||||
return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
|
||||
};
|
||||
|
||||
export const getReadableTextColor = (hex, { light = '#1f1f1f', dark = '#ffffff' } = {}) => {
|
||||
const rgb = hexToRgb(hex);
|
||||
if (!rgb) return dark;
|
||||
const luminance = relativeLuminance(rgb);
|
||||
return luminance > 0.6 ? light : dark;
|
||||
const contrastRatio = (lumA, lumB) => {
|
||||
const [lighter, darker] = lumA >= lumB ? [lumA, lumB] : [lumB, lumA];
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
};
|
||||
|
||||
const parseCandidateColor = (candidate) => {
|
||||
const rgb = hexToRgb(candidate);
|
||||
if (!rgb) return null;
|
||||
return {
|
||||
hex: rgb.hex,
|
||||
luminance: relativeLuminance(rgb),
|
||||
};
|
||||
};
|
||||
|
||||
const contrastForPair = (backgroundHex, textHex) => {
|
||||
const background = hexToRgb(backgroundHex);
|
||||
const text = hexToRgb(textHex);
|
||||
if (!background || !text) {
|
||||
return 0;
|
||||
}
|
||||
return contrastRatio(relativeLuminance(background), relativeLuminance(text));
|
||||
};
|
||||
|
||||
export const getReadableTextColor = (
|
||||
hex,
|
||||
{ light = '#1f1f1f', dark = '#ffffff', fallback = '#1f1f1f' } = {},
|
||||
) => {
|
||||
const background = hexToRgb(hex);
|
||||
if (!background) return fallback;
|
||||
|
||||
const backgroundLuminance = relativeLuminance(background);
|
||||
const backgroundHsl = rgbToHsl(background);
|
||||
|
||||
const hueShift = 180;
|
||||
const textHue = (backgroundHsl.h + hueShift) % 360;
|
||||
const targetSaturation = clampRange(backgroundHsl.s * 1.15, 0.4, 0.85);
|
||||
const minLightness = 0.05;
|
||||
const maxLightness = 0.95;
|
||||
const sampleCount = 24;
|
||||
|
||||
const buildCandidate = (lightness) => {
|
||||
const light = clampRange(lightness, minLightness, maxLightness);
|
||||
const hexValue = hslToHex(textHue, targetSaturation, light);
|
||||
return parseCandidateColor(hexValue);
|
||||
};
|
||||
|
||||
const candidates = new Map();
|
||||
|
||||
for (let index = 0; index < sampleCount; index += 1) {
|
||||
const t = index / (sampleCount - 1);
|
||||
const candidateLightness = minLightness + t * (maxLightness - minLightness);
|
||||
const candidate = buildCandidate(candidateLightness);
|
||||
if (candidate) {
|
||||
candidates.set(candidate.hex, candidate);
|
||||
}
|
||||
}
|
||||
|
||||
[light, dark].forEach((preset) => {
|
||||
const parsed = parseCandidateColor(preset);
|
||||
if (parsed) {
|
||||
candidates.set(parsed.hex, parsed);
|
||||
}
|
||||
});
|
||||
|
||||
if (candidates.size === 0) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
let best = null;
|
||||
let bestRatio = -Infinity;
|
||||
candidates.forEach((candidate) => {
|
||||
const ratio = contrastRatio(backgroundLuminance, candidate.luminance);
|
||||
if (ratio > bestRatio) {
|
||||
bestRatio = ratio;
|
||||
best = candidate;
|
||||
}
|
||||
});
|
||||
|
||||
const MIN_CONTRAST = 4.5;
|
||||
if (bestRatio < MIN_CONTRAST) {
|
||||
const extremeLight = buildCandidate(maxLightness);
|
||||
const extremeDark = buildCandidate(minLightness);
|
||||
const extremes = [extremeLight, extremeDark].filter(Boolean);
|
||||
extremes.forEach((candidate) => {
|
||||
const ratio = contrastRatio(backgroundLuminance, candidate.luminance);
|
||||
if (ratio > bestRatio) {
|
||||
bestRatio = ratio;
|
||||
best = candidate;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return best?.hex || fallback;
|
||||
};
|
||||
|
||||
export const getTagColorStyle = (hex) => {
|
||||
const rgb = hexToRgb(hex);
|
||||
if (!rgb) return null;
|
||||
const baseHex = rgb.hex;
|
||||
const baseHsl = rgbToHsl(rgb);
|
||||
const baseText = getReadableTextColor(baseHex);
|
||||
const baseRatio = contrastForPair(baseHex, baseText);
|
||||
const TARGET_RATIO = 8;
|
||||
const MIN_LIGHTNESS = 0.12;
|
||||
const MAX_LIGHTNESS = 0.88;
|
||||
const adjustments = [-0.18, -0.12, -0.08, -0.04, 0.04, 0.08, 0.12, 0.18];
|
||||
const seen = new Map();
|
||||
|
||||
const registerCandidate = (lightness) => {
|
||||
const clamped = clampRange(lightness, MIN_LIGHTNESS, MAX_LIGHTNESS);
|
||||
const hexValue = hslToHex(baseHsl.h, baseHsl.s, clamped);
|
||||
if (!seen.has(hexValue)) {
|
||||
seen.set(hexValue, clamped);
|
||||
}
|
||||
};
|
||||
|
||||
registerCandidate(baseHsl.l);
|
||||
adjustments.forEach((delta) => registerCandidate(baseHsl.l + delta));
|
||||
|
||||
let bestBackground = baseHex;
|
||||
let bestText = baseText;
|
||||
let bestRatio = baseRatio;
|
||||
|
||||
if (bestRatio >= TARGET_RATIO) {
|
||||
return {
|
||||
backgroundColor: rgb.hex,
|
||||
borderColor: rgb.hex,
|
||||
color: getReadableTextColor(rgb.hex),
|
||||
backgroundColor: bestBackground,
|
||||
borderColor: bestBackground,
|
||||
color: bestText,
|
||||
};
|
||||
}
|
||||
|
||||
let compliantBackground = null;
|
||||
let compliantText = null;
|
||||
let compliantDelta = Infinity;
|
||||
let compliantRatio = -Infinity;
|
||||
|
||||
seen.forEach((lightness, candidateHex) => {
|
||||
const textHex = getReadableTextColor(candidateHex);
|
||||
const ratio = contrastForPair(candidateHex, textHex);
|
||||
const delta = Math.abs(lightness - baseHsl.l);
|
||||
|
||||
if (ratio > bestRatio) {
|
||||
bestBackground = candidateHex;
|
||||
bestText = textHex;
|
||||
bestRatio = ratio;
|
||||
}
|
||||
|
||||
if (ratio >= TARGET_RATIO) {
|
||||
const deltaEpsilon = 0.0025;
|
||||
const ratioEpsilon = 0.01;
|
||||
const isCloser = delta + deltaEpsilon < compliantDelta;
|
||||
const isSimilarDistance = Math.abs(delta - compliantDelta) <= deltaEpsilon;
|
||||
const improvesRatio = ratio > compliantRatio + ratioEpsilon;
|
||||
if (
|
||||
compliantBackground === null
|
||||
|| isCloser
|
||||
|| (isSimilarDistance && improvesRatio)
|
||||
) {
|
||||
compliantBackground = candidateHex;
|
||||
compliantText = textHex;
|
||||
compliantDelta = delta;
|
||||
compliantRatio = ratio;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (compliantBackground) {
|
||||
return {
|
||||
backgroundColor: compliantBackground,
|
||||
borderColor: compliantBackground,
|
||||
color: compliantText,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
backgroundColor: bestBackground,
|
||||
borderColor: bestBackground,
|
||||
color: bestText,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
const path = require('path');
|
||||
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 = {
|
||||
entry: './src/index.jsx',
|
||||
output: {
|
||||
@@ -46,11 +37,6 @@ module.exports = {
|
||||
template: path.resolve(__dirname, 'src/index.html'),
|
||||
favicon: false,
|
||||
}),
|
||||
new webpack.DefinePlugin({
|
||||
'process.env.API_BASE_URL': JSON.stringify(API_BASE_URL),
|
||||
'process.env.OLLAMA_BASE_URL': JSON.stringify(process.env.OLLAMA_BASE_URL || ''),
|
||||
'process.env.OLLAMA_MODEL': JSON.stringify(process.env.OLLAMA_MODEL || ''),
|
||||
}),
|
||||
],
|
||||
devServer: {
|
||||
static: {
|
||||
@@ -60,6 +46,14 @@ module.exports = {
|
||||
port: 5173,
|
||||
historyApiFallback: true,
|
||||
open: true,
|
||||
proxy: [
|
||||
{
|
||||
context: ['/api'],
|
||||
target: 'http://127.0.0.1:3000',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
devtool: 'source-map',
|
||||
resolve: {
|
||||
|
||||
@@ -36,6 +36,8 @@ spec:
|
||||
containerPort: {{ .Values.frontend.service.port }}
|
||||
protocol: TCP
|
||||
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 }}
|
||||
- name: {{ .name }}
|
||||
value: {{ .value | quote }}
|
||||
|
||||
Reference in New Issue
Block a user