a lot of stuff
This commit is contained in:
Generated
+53
-19
@@ -605,11 +605,13 @@ dependencies = [
|
|||||||
"aws-sdk-s3",
|
"aws-sdk-s3",
|
||||||
"axum",
|
"axum",
|
||||||
"axum-extra",
|
"axum-extra",
|
||||||
|
"base64 0.21.7",
|
||||||
"bytes",
|
"bytes",
|
||||||
"chrono",
|
"chrono",
|
||||||
"diesel",
|
"diesel",
|
||||||
"diesel_migrations",
|
"diesel_migrations",
|
||||||
"dotenv",
|
"dotenv",
|
||||||
|
"futures-util",
|
||||||
"hex",
|
"hex",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper 1.7.0",
|
"hyper 1.7.0",
|
||||||
@@ -619,6 +621,7 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
"pdfium-render",
|
"pdfium-render",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
|
"quick-xml",
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -628,7 +631,7 @@ dependencies = [
|
|||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower 0.4.13",
|
"tower 0.4.13",
|
||||||
"tower-http 0.5.2",
|
"tower-http",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"uuid",
|
"uuid",
|
||||||
@@ -1269,6 +1272,23 @@ version = "0.3.31"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
|
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-io"
|
||||||
|
version = "0.3.31"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-macro"
|
||||||
|
version = "0.3.31"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-sink"
|
name = "futures-sink"
|
||||||
version = "0.3.31"
|
version = "0.3.31"
|
||||||
@@ -1288,9 +1308,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
|
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"futures-io",
|
||||||
|
"futures-macro",
|
||||||
|
"futures-sink",
|
||||||
"futures-task",
|
"futures-task",
|
||||||
|
"memchr",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"pin-utils",
|
"pin-utils",
|
||||||
|
"slab",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2354,6 +2379,15 @@ dependencies = [
|
|||||||
"num-traits",
|
"num-traits",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quick-xml"
|
||||||
|
version = "0.32.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1d3a6e5838b60e0e8fa7a43f22ade549a37d61f8bdbe636d0d7816191de969c2"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quinn"
|
name = "quinn"
|
||||||
version = "0.11.9"
|
version = "0.11.9"
|
||||||
@@ -2547,6 +2581,7 @@ dependencies = [
|
|||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"futures-util",
|
||||||
"http 1.3.1",
|
"http 1.3.1",
|
||||||
"http-body 1.0.1",
|
"http-body 1.0.1",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
@@ -2566,12 +2601,14 @@ dependencies = [
|
|||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls 0.26.4",
|
"tokio-rustls 0.26.4",
|
||||||
|
"tokio-util",
|
||||||
"tower 0.5.2",
|
"tower 0.5.2",
|
||||||
"tower-http 0.6.6",
|
"tower-http",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"url",
|
"url",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
|
"wasm-streams",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
"webpki-roots",
|
"webpki-roots",
|
||||||
]
|
]
|
||||||
@@ -3320,23 +3357,6 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tower-http"
|
|
||||||
version = "0.5.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags",
|
|
||||||
"bytes",
|
|
||||||
"http 1.3.1",
|
|
||||||
"http-body 1.0.1",
|
|
||||||
"http-body-util",
|
|
||||||
"pin-project-lite",
|
|
||||||
"tower-layer",
|
|
||||||
"tower-service",
|
|
||||||
"tracing",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower-http"
|
name = "tower-http"
|
||||||
version = "0.6.6"
|
version = "0.6.6"
|
||||||
@@ -3353,6 +3373,7 @@ dependencies = [
|
|||||||
"tower 0.5.2",
|
"tower 0.5.2",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3642,6 +3663,19 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-streams"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
|
||||||
|
dependencies = [
|
||||||
|
"futures-util",
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
"web-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "web-sys"
|
name = "web-sys"
|
||||||
version = "0.3.81"
|
version = "0.3.81"
|
||||||
|
|||||||
+5
-2
@@ -8,7 +8,7 @@ edition = "2021"
|
|||||||
axum = { version = "0.7", features = ["multipart"] }
|
axum = { version = "0.7", features = ["multipart"] }
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
tower = { version = "0.4", features = ["make", "util"] }
|
tower = { version = "0.4", features = ["make", "util"] }
|
||||||
tower-http = { version = "0.5", features = ["cors", "trace"] }
|
tower-http = { version = "0.6", features = ["cors", "trace"] }
|
||||||
axum-extra = { version = "0.9", features = ["typed-header"] }
|
axum-extra = { version = "0.9", features = ["typed-header"] }
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
@@ -38,8 +38,11 @@ image = { version = "0.25", default-features = false, features = ["png", "jpeg"]
|
|||||||
pdfium-render = "0.8"
|
pdfium-render = "0.8"
|
||||||
mime_guess = "2.0"
|
mime_guess = "2.0"
|
||||||
tempfile = "3.10"
|
tempfile = "3.10"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
|
||||||
percent-encoding = "2.3"
|
percent-encoding = "2.3"
|
||||||
|
base64 = "0.21"
|
||||||
|
quick-xml = "0.32"
|
||||||
|
futures-util = "0.3"
|
||||||
|
|
||||||
# Error handling
|
# Error handling
|
||||||
thiserror = "1.0"
|
thiserror = "1.0"
|
||||||
|
|||||||
+2
-1
@@ -20,7 +20,7 @@ COPY migrations ./migrations
|
|||||||
COPY tests ./tests
|
COPY tests ./tests
|
||||||
COPY diesel.toml ./
|
COPY diesel.toml ./
|
||||||
|
|
||||||
RUN cargo build --release --bin backend --bin worker
|
RUN cargo build --release --bin backend --bin worker --bin webdav
|
||||||
RUN cargo install diesel_cli --no-default-features --features postgres
|
RUN cargo install diesel_cli --no-default-features --features postgres
|
||||||
|
|
||||||
FROM debian:trixie-slim AS runtime
|
FROM debian:trixie-slim AS runtime
|
||||||
@@ -52,6 +52,7 @@ RUN apt-get update \
|
|||||||
|
|
||||||
COPY --from=builder /app/target/release/backend /usr/local/bin/papercrate-backend
|
COPY --from=builder /app/target/release/backend /usr/local/bin/papercrate-backend
|
||||||
COPY --from=builder /app/target/release/worker /usr/local/bin/papercrate-worker
|
COPY --from=builder /app/target/release/worker /usr/local/bin/papercrate-worker
|
||||||
|
COPY --from=builder /app/target/release/webdav /usr/local/bin/papercrate-webdav
|
||||||
COPY --from=builder /usr/local/cargo/bin/diesel /usr/local/bin/diesel
|
COPY --from=builder /usr/local/cargo/bin/diesel /usr/local/bin/diesel
|
||||||
COPY migrations ./migrations
|
COPY migrations ./migrations
|
||||||
COPY diesel.toml ./
|
COPY diesel.toml ./
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
use tower::make::Shared;
|
||||||
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
|
use backend::auth::jwt::JwtService;
|
||||||
|
use backend::config::AppConfig;
|
||||||
|
use backend::db;
|
||||||
|
use backend::routes::webdav;
|
||||||
|
use backend::s3::build_client;
|
||||||
|
use backend::state::AppState;
|
||||||
|
use backend::storage::S3Storage;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
dotenv::dotenv().ok();
|
||||||
|
init_tracing();
|
||||||
|
|
||||||
|
let config = AppConfig::from_env()?;
|
||||||
|
let pool = db::init_pool(&config.database_url)?;
|
||||||
|
let s3_client = build_client(&config).await?;
|
||||||
|
let storage = Arc::new(S3Storage::new(s3_client, config.s3_bucket.clone()));
|
||||||
|
let jwt = JwtService::from_config(&config)?;
|
||||||
|
|
||||||
|
let state = AppState::new(pool, config, storage, jwt);
|
||||||
|
let listen_addr: SocketAddr = {
|
||||||
|
let config = state.config.clone();
|
||||||
|
format!("{}:{}", config.webdav_host, config.webdav_port).parse()?
|
||||||
|
};
|
||||||
|
let router = webdav::create_router().with_state(state);
|
||||||
|
|
||||||
|
let listener = TcpListener::bind(listen_addr).await?;
|
||||||
|
tracing::info!("listening for WebDAV on {}", listen_addr);
|
||||||
|
|
||||||
|
axum::serve(listener, Shared::new(router)).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_tracing() {
|
||||||
|
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(filter)
|
||||||
|
.with_target(false)
|
||||||
|
.compact()
|
||||||
|
.init();
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ pub struct AppConfig {
|
|||||||
pub database_url: String,
|
pub database_url: String,
|
||||||
pub server_host: String,
|
pub server_host: String,
|
||||||
pub server_port: u16,
|
pub server_port: u16,
|
||||||
|
pub webdav_host: String,
|
||||||
|
pub webdav_port: u16,
|
||||||
pub jwt_secret: String,
|
pub jwt_secret: String,
|
||||||
pub jwt_issuer: String,
|
pub jwt_issuer: String,
|
||||||
pub jwt_audience: String,
|
pub jwt_audience: String,
|
||||||
@@ -34,6 +36,11 @@ impl AppConfig {
|
|||||||
.unwrap_or_else(|_| "3000".to_string())
|
.unwrap_or_else(|_| "3000".to_string())
|
||||||
.parse()
|
.parse()
|
||||||
.context("SERVER_PORT must be a valid u16")?;
|
.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_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_issuer = env::var("JWT_ISSUER").unwrap_or_else(|_| "papercrate".to_string());
|
||||||
let jwt_audience =
|
let jwt_audience =
|
||||||
@@ -69,6 +76,8 @@ impl AppConfig {
|
|||||||
database_url,
|
database_url,
|
||||||
server_host,
|
server_host,
|
||||||
server_port,
|
server_port,
|
||||||
|
webdav_host,
|
||||||
|
webdav_port,
|
||||||
jwt_secret,
|
jwt_secret,
|
||||||
jwt_issuer,
|
jwt_issuer,
|
||||||
jwt_audience,
|
jwt_audience,
|
||||||
|
|||||||
+202
-34
@@ -205,6 +205,12 @@ pub struct BulkTagResponse {
|
|||||||
pub removed: usize,
|
pub removed: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct BulkCorrespondentResponse {
|
||||||
|
pub assigned: usize,
|
||||||
|
pub removed: usize,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct CorrespondentAssignmentInput {
|
pub struct CorrespondentAssignmentInput {
|
||||||
pub correspondent_id: Uuid,
|
pub correspondent_id: Uuid,
|
||||||
@@ -218,6 +224,69 @@ pub struct AssignCorrespondentsRequest {
|
|||||||
pub replace: bool,
|
pub replace: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Copy, Clone, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum BulkCorrespondentAction {
|
||||||
|
Add,
|
||||||
|
Remove,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_bulk_correspondent_action() -> BulkCorrespondentAction {
|
||||||
|
BulkCorrespondentAction::Add
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct BulkCorrespondentsRequest {
|
||||||
|
pub document_ids: Vec<Uuid>,
|
||||||
|
pub assignments: Vec<CorrespondentAssignmentInput>,
|
||||||
|
#[serde(default = "default_bulk_correspondent_action")]
|
||||||
|
pub action: BulkCorrespondentAction,
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct CorrespondentRoleQuery {
|
pub struct CorrespondentRoleQuery {
|
||||||
pub role: String,
|
pub role: String,
|
||||||
@@ -850,40 +919,8 @@ pub async fn assign_correspondents(
|
|||||||
return Err(AppError::bad_request("assignments must not be empty"));
|
return Err(AppError::bad_request("assignments must not be empty"));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut unique_pairs: HashSet<(Uuid, String)> = HashSet::new();
|
let (normalized_pairs, correspondents_vec, roles_vec) =
|
||||||
let mut normalized_pairs: Vec<(Uuid, String)> = Vec::new();
|
normalize_correspondent_assignments(&payload.assignments)?;
|
||||||
let mut role_set: HashSet<String> = HashSet::new();
|
|
||||||
let mut correspondent_ids: HashSet<Uuid> = HashSet::new();
|
|
||||||
|
|
||||||
for assignment in &payload.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 correspondents_vec: Vec<Uuid> = correspondent_ids.into_iter().collect();
|
|
||||||
let roles_vec: Vec<String> = role_set.into_iter().collect();
|
|
||||||
let replace = payload.replace;
|
let replace = payload.replace;
|
||||||
let user_id = user.user_id;
|
let user_id = user.user_id;
|
||||||
|
|
||||||
@@ -950,6 +987,137 @@ pub async fn assign_correspondents(
|
|||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn bulk_assign_correspondents(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
Json(payload): Json<BulkCorrespondentsRequest>,
|
||||||
|
) -> AppResult<(StatusCode, Json<BulkCorrespondentResponse>)> {
|
||||||
|
if payload.document_ids.is_empty() {
|
||||||
|
return Err(AppError::bad_request("document_ids must not be empty"));
|
||||||
|
}
|
||||||
|
if payload.assignments.is_empty() {
|
||||||
|
return Err(AppError::bad_request("assignments must not be empty"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut document_ids = payload.document_ids;
|
||||||
|
document_ids.sort();
|
||||||
|
document_ids.dedup();
|
||||||
|
|
||||||
|
let (normalized_pairs, correspondents_vec, roles_vec) =
|
||||||
|
normalize_correspondent_assignments(&payload.assignments)?;
|
||||||
|
let action = payload.action;
|
||||||
|
let user_id = user.user_id;
|
||||||
|
|
||||||
|
let mut conn = state.db()?;
|
||||||
|
let (assigned, removed) = conn.transaction::<(usize, usize), AppError, _>(|conn| {
|
||||||
|
let docs: Vec<(Uuid, Option<NaiveDateTime>)> = documents::table
|
||||||
|
.filter(documents::id.eq_any(&document_ids))
|
||||||
|
.select((documents::id, documents::deleted_at))
|
||||||
|
.load(conn)?;
|
||||||
|
|
||||||
|
if docs.len() != document_ids.len() {
|
||||||
|
return Err(AppError::bad_request(
|
||||||
|
"one or more documents do not exist or are inaccessible",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if docs.iter().any(|(_, deleted)| deleted.is_some()) {
|
||||||
|
return Err(AppError::bad_request(
|
||||||
|
"cannot assign correspondents to deleted documents",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !correspondents_vec.is_empty() {
|
||||||
|
let existing: Vec<Correspondent> = correspondents::table
|
||||||
|
.filter(correspondents::id.eq_any(&correspondents_vec))
|
||||||
|
.load(conn)?;
|
||||||
|
if existing.len() != correspondents_vec.len() {
|
||||||
|
return Err(AppError::bad_request(
|
||||||
|
"one or more correspondents do not exist",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match action {
|
||||||
|
BulkCorrespondentAction::Add => {
|
||||||
|
let mut removed = 0;
|
||||||
|
if !roles_vec.is_empty() {
|
||||||
|
removed = diesel::delete(
|
||||||
|
document_correspondents::table
|
||||||
|
.filter(document_correspondents::document_id.eq_any(&document_ids))
|
||||||
|
.filter(document_correspondents::role.eq_any(&roles_vec)),
|
||||||
|
)
|
||||||
|
.execute(conn)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut new_rows = Vec::with_capacity(document_ids.len() * normalized_pairs.len());
|
||||||
|
for doc_id in &document_ids {
|
||||||
|
for (correspondent_id, role) in &normalized_pairs {
|
||||||
|
new_rows.push(NewDocumentCorrespondent {
|
||||||
|
document_id: *doc_id,
|
||||||
|
correspondent_id: *correspondent_id,
|
||||||
|
role: role.clone(),
|
||||||
|
assigned_by: Some(user_id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let assigned = if new_rows.is_empty() {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
diesel::insert_into(document_correspondents::table)
|
||||||
|
.values(&new_rows)
|
||||||
|
.on_conflict_do_nothing()
|
||||||
|
.execute(conn)?
|
||||||
|
};
|
||||||
|
|
||||||
|
if assigned > 0 || removed > 0 {
|
||||||
|
diesel::update(documents::table.filter(documents::id.eq_any(&document_ids)))
|
||||||
|
.set(documents::updated_at.eq(Utc::now().naive_utc()))
|
||||||
|
.execute(conn)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((assigned, removed))
|
||||||
|
}
|
||||||
|
BulkCorrespondentAction::Remove => {
|
||||||
|
let mut removed = 0;
|
||||||
|
if !normalized_pairs.is_empty() {
|
||||||
|
let mut grouped: HashMap<String, Vec<Uuid>> = HashMap::new();
|
||||||
|
for (correspondent_id, role) in &normalized_pairs {
|
||||||
|
grouped
|
||||||
|
.entry(role.clone())
|
||||||
|
.or_default()
|
||||||
|
.push(*correspondent_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (role, ids) in grouped {
|
||||||
|
removed += diesel::delete(
|
||||||
|
document_correspondents::table
|
||||||
|
.filter(document_correspondents::document_id.eq_any(&document_ids))
|
||||||
|
.filter(document_correspondents::role.eq(role.as_str()))
|
||||||
|
.filter(document_correspondents::correspondent_id.eq_any(&ids)),
|
||||||
|
)
|
||||||
|
.execute(conn)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if removed > 0 {
|
||||||
|
diesel::update(documents::table.filter(documents::id.eq_any(&document_ids)))
|
||||||
|
.set(documents::updated_at.eq(Utc::now().naive_utc()))
|
||||||
|
.execute(conn)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((0, removed))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(BulkCorrespondentResponse { assigned, removed }),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn remove_correspondent(
|
pub async fn remove_correspondent(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path((document_id, correspondent_id)): Path<(Uuid, Uuid)>,
|
Path((document_id, correspondent_id)): Path<(Uuid, Uuid)>,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use std::collections::{HashMap, HashSet};
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::models::{Document, Folder, NewFolder};
|
use crate::models::{Document, Folder, NewFolder};
|
||||||
use crate::schema::{document_tags, documents, folders};
|
use crate::schema::{document_correspondents, document_tags, documents, folders};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
use crate::{
|
use crate::{
|
||||||
auth::AuthenticatedUser,
|
auth::AuthenticatedUser,
|
||||||
@@ -60,6 +60,7 @@ pub struct FolderContentsResponse {
|
|||||||
pub struct DocumentSearchQuery {
|
pub struct DocumentSearchQuery {
|
||||||
pub query: Option<String>,
|
pub query: Option<String>,
|
||||||
pub tags: Option<String>,
|
pub tags: Option<String>,
|
||||||
|
pub correspondents: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -353,6 +354,55 @@ pub async fn search_documents(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(correspondents_param) = params
|
||||||
|
.correspondents
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.trim())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
{
|
||||||
|
let correspondent_ids: Result<Vec<Uuid>, _> = correspondents_param
|
||||||
|
.split(',')
|
||||||
|
.map(|s| Uuid::parse_str(s.trim()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if let Ok(ids) = correspondent_ids {
|
||||||
|
if !ids.is_empty() {
|
||||||
|
let mut doc_id_set: Option<HashSet<Uuid>> = None;
|
||||||
|
for correspondent_id in &ids {
|
||||||
|
let docs_for_correspondent: Vec<Uuid> = document_correspondents::table
|
||||||
|
.filter(document_correspondents::correspondent_id.eq(*correspondent_id))
|
||||||
|
.select(document_correspondents::document_id)
|
||||||
|
.load(&mut conn)?;
|
||||||
|
|
||||||
|
let docs_set: HashSet<Uuid> = docs_for_correspondent.into_iter().collect();
|
||||||
|
doc_id_set = Some(match doc_id_set {
|
||||||
|
Some(existing) => existing.intersection(&docs_set).cloned().collect(),
|
||||||
|
None => docs_set,
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(ref set) = doc_id_set {
|
||||||
|
if set.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let matching_doc_ids: HashSet<Uuid> = doc_id_set.unwrap_or_default();
|
||||||
|
|
||||||
|
if matching_doc_ids.is_empty() {
|
||||||
|
return Ok(Json(vec![]));
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_filter = match &filter_ids {
|
||||||
|
Some(existing) => existing.intersection(&matching_doc_ids).copied().collect(),
|
||||||
|
None => matching_doc_ids.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
filter_ids = Some(new_filter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(ref set) = filter_ids {
|
if let Some(ref set) = filter_ids {
|
||||||
if set.is_empty() {
|
if set.is_empty() {
|
||||||
return Ok(Json(vec![]));
|
return Ok(Json(vec![]));
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ pub mod documents;
|
|||||||
pub mod folders;
|
pub mod folders;
|
||||||
pub mod health;
|
pub mod health;
|
||||||
pub mod tags;
|
pub mod tags;
|
||||||
|
pub mod webdav;
|
||||||
|
|
||||||
pub fn create_router(state: AppState) -> Router<()> {
|
pub fn create_router(state: AppState) -> Router<()> {
|
||||||
let cors = if let Some(origins) = state.config.cors_allowed_origin.as_ref() {
|
let cors = if let Some(origins) = state.config.cors_allowed_origin.as_ref() {
|
||||||
@@ -59,6 +60,10 @@ pub fn create_router(state: AppState) -> Router<()> {
|
|||||||
.route("/reanalyze", post(documents::reanalyze_all_documents))
|
.route("/reanalyze", post(documents::reanalyze_all_documents))
|
||||||
.route("/bulk/move", post(documents::bulk_move_documents))
|
.route("/bulk/move", post(documents::bulk_move_documents))
|
||||||
.route("/bulk/tags", post(documents::bulk_update_tags))
|
.route("/bulk/tags", post(documents::bulk_update_tags))
|
||||||
|
.route(
|
||||||
|
"/bulk/correspondents",
|
||||||
|
post(documents::bulk_assign_correspondents),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/bulk/reanalyze",
|
"/bulk/reanalyze",
|
||||||
post(documents::reanalyze_selected_documents),
|
post(documents::reanalyze_selected_documents),
|
||||||
@@ -125,9 +130,9 @@ pub fn create_router(state: AppState) -> Router<()> {
|
|||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
.merge(download_routes)
|
.merge(download_routes)
|
||||||
|
.merge(protected_routes)
|
||||||
.nest("/api/auth", auth_routes)
|
.nest("/api/auth", auth_routes)
|
||||||
.route("/api/health", get(health::health_check))
|
.route("/api/health", get(health::health_check))
|
||||||
.merge(protected_routes)
|
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
.layer(cors)
|
.layer(cors)
|
||||||
.layer(DefaultBodyLimit::max(1024 * 1024 * 512))
|
.layer(DefaultBodyLimit::max(1024 * 1024 * 512))
|
||||||
|
|||||||
@@ -0,0 +1,808 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::http::{header, HeaderMap, Method, StatusCode};
|
||||||
|
use axum::response::Response;
|
||||||
|
use axum::Router;
|
||||||
|
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||||
|
use base64::Engine;
|
||||||
|
use diesel::prelude::*;
|
||||||
|
use diesel::PgConnection;
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
use percent_encoding::{percent_decode_str, utf8_percent_encode, NON_ALPHANUMERIC};
|
||||||
|
use quick_xml::events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event};
|
||||||
|
use quick_xml::Writer;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::auth::password;
|
||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
use crate::models::{Document, DocumentVersion, Folder, User};
|
||||||
|
use crate::schema::{
|
||||||
|
document_versions::dsl as document_versions_dsl, documents::dsl as documents_dsl,
|
||||||
|
folders::dsl as folders_dsl, users::dsl as users_dsl,
|
||||||
|
};
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
const REALM: &str = "Papercrate WebDAV";
|
||||||
|
const DOWNLOAD_URL_TTL_SECONDS: u64 = 300;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct WebDavUser {
|
||||||
|
_user_id: Uuid,
|
||||||
|
_username: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_router() -> Router<AppState> {
|
||||||
|
Router::new().fallback(webdav_entrypoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn webdav_entrypoint(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
req: axum::http::Request<axum::body::Body>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
let method = req.method().clone();
|
||||||
|
let headers = req.headers().clone();
|
||||||
|
let path = req.uri().path().trim_start_matches('/').to_string();
|
||||||
|
|
||||||
|
tracing::debug!(method = %method, %path, "webdav entrypoint" );
|
||||||
|
|
||||||
|
match method {
|
||||||
|
ref m if m == Method::OPTIONS => Ok(handle_options()),
|
||||||
|
ref m if m == Method::GET => handle_get_or_head(&state, &path, headers, Method::GET).await,
|
||||||
|
ref m if m == Method::HEAD => {
|
||||||
|
handle_get_or_head(&state, &path, headers, Method::HEAD).await
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
if method.as_str() == "PROPFIND" {
|
||||||
|
handle_propfind(&state, &path, headers).await
|
||||||
|
} else {
|
||||||
|
Ok(method_not_allowed())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_propfind(
|
||||||
|
state: &AppState,
|
||||||
|
path: &str,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
let _user = match authenticate(state, &headers)? {
|
||||||
|
Some(user) => user,
|
||||||
|
None => return Ok(unauthorized_response()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let depth = match parse_depth(&headers) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(response) => return Ok(response),
|
||||||
|
};
|
||||||
|
|
||||||
|
let segments = parse_segments(path)?;
|
||||||
|
let resolution = match resolve_path(state, &segments)? {
|
||||||
|
Some(resolved) => resolved,
|
||||||
|
None => return Ok(not_found_response()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let resources = match resolution {
|
||||||
|
ResolvedPath::Root => {
|
||||||
|
let contents = fetch_folder_contents(state, None)?;
|
||||||
|
build_resources_for_folder(None, &[], &contents, depth)
|
||||||
|
}
|
||||||
|
ResolvedPath::Folder { folder, chain } => {
|
||||||
|
let contents = fetch_folder_contents(state, Some(folder.id))?;
|
||||||
|
build_resources_for_folder(Some(&folder), &chain, &contents, depth)
|
||||||
|
}
|
||||||
|
ResolvedPath::Document {
|
||||||
|
document,
|
||||||
|
version,
|
||||||
|
chain,
|
||||||
|
} => build_resources_for_document(&chain, &document, &version),
|
||||||
|
};
|
||||||
|
|
||||||
|
let body = render_multistatus(&resources)
|
||||||
|
.map_err(|err| AppError::internal(format!("failed to render WebDAV response: {err}")))?;
|
||||||
|
|
||||||
|
let response = Response::builder()
|
||||||
|
.status(multi_status())
|
||||||
|
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
|
||||||
|
.body(Body::from(body))
|
||||||
|
.expect("valid response");
|
||||||
|
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_get_or_head(
|
||||||
|
state: &AppState,
|
||||||
|
path: &str,
|
||||||
|
headers: HeaderMap,
|
||||||
|
method: Method,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
let _user = match authenticate(state, &headers)? {
|
||||||
|
Some(user) => user,
|
||||||
|
None => return Ok(unauthorized_response()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let segments = parse_segments(path)?;
|
||||||
|
let resolution = match resolve_path(state, &segments)? {
|
||||||
|
Some(resolved) => resolved,
|
||||||
|
None => return Ok(not_found_response()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let (document, version, chain) = match resolution {
|
||||||
|
ResolvedPath::Document {
|
||||||
|
document,
|
||||||
|
version,
|
||||||
|
chain,
|
||||||
|
} => (document, version, chain),
|
||||||
|
_ => return Ok(method_not_allowed()),
|
||||||
|
};
|
||||||
|
|
||||||
|
stream_document(state, &document, &version, &chain, headers, method).await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_options() -> Response {
|
||||||
|
Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header("DAV", "1,2")
|
||||||
|
.header(header::ALLOW, "OPTIONS, PROPFIND, GET, HEAD")
|
||||||
|
.header("Accept-Ranges", "bytes")
|
||||||
|
.body(Body::empty())
|
||||||
|
.expect("valid OPTIONS response")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn method_not_allowed() -> Response {
|
||||||
|
Response::builder()
|
||||||
|
.status(StatusCode::METHOD_NOT_ALLOWED)
|
||||||
|
.body(Body::empty())
|
||||||
|
.expect("valid response")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn not_found_response() -> Response {
|
||||||
|
Response::builder()
|
||||||
|
.status(StatusCode::NOT_FOUND)
|
||||||
|
.body(Body::empty())
|
||||||
|
.expect("valid response")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unauthorized_response() -> Response {
|
||||||
|
Response::builder()
|
||||||
|
.status(StatusCode::UNAUTHORIZED)
|
||||||
|
.header(
|
||||||
|
header::WWW_AUTHENTICATE,
|
||||||
|
format!("Basic realm=\"{REALM}\", charset=\"UTF-8\""),
|
||||||
|
)
|
||||||
|
.body(Body::empty())
|
||||||
|
.expect("valid response")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn multi_status() -> StatusCode {
|
||||||
|
StatusCode::from_u16(207).expect("valid multi-status")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_depth(headers: &HeaderMap) -> Result<u8, Response> {
|
||||||
|
match headers.get("Depth") {
|
||||||
|
None => Ok(1),
|
||||||
|
Some(value) => match value.to_str() {
|
||||||
|
Ok("0") => Ok(0),
|
||||||
|
Ok("1") => Ok(1),
|
||||||
|
Ok("infinity") => Err(Response::builder()
|
||||||
|
.status(StatusCode::FORBIDDEN)
|
||||||
|
.body(Body::empty())
|
||||||
|
.expect("valid response")),
|
||||||
|
_ => Err(Response::builder()
|
||||||
|
.status(StatusCode::BAD_REQUEST)
|
||||||
|
.body(Body::empty())
|
||||||
|
.expect("valid response")),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_segments(path: &str) -> AppResult<Vec<String>> {
|
||||||
|
if path.trim_matches('/').is_empty() {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let segments = path
|
||||||
|
.split('/')
|
||||||
|
.filter(|segment| !segment.is_empty())
|
||||||
|
.map(|segment| {
|
||||||
|
percent_decode_str(segment)
|
||||||
|
.decode_utf8()
|
||||||
|
.map(|cow| cow.into_owned())
|
||||||
|
.map_err(|_| AppError::bad_request("invalid UTF-8 in path"))
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
|
Ok(segments)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetch_folder_contents(
|
||||||
|
state: &AppState,
|
||||||
|
folder_id: Option<Uuid>,
|
||||||
|
) -> AppResult<WebDavFolderContents> {
|
||||||
|
let mut conn = state.db()?;
|
||||||
|
|
||||||
|
let folder = match folder_id {
|
||||||
|
Some(id) => Some(folders_dsl::folders.find(id).first::<Folder>(&mut conn)?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let subfolders: Vec<Folder> = match folder_id {
|
||||||
|
Some(id) => folders_dsl::folders
|
||||||
|
.filter(folders_dsl::parent_id.eq(Some(id)))
|
||||||
|
.order(folders_dsl::name.asc())
|
||||||
|
.load(&mut conn)?,
|
||||||
|
None => folders_dsl::folders
|
||||||
|
.filter(folders_dsl::parent_id.is_null())
|
||||||
|
.order(folders_dsl::name.asc())
|
||||||
|
.load(&mut conn)?,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut docs_query = documents_dsl::documents
|
||||||
|
.filter(documents_dsl::deleted_at.is_null())
|
||||||
|
.into_boxed();
|
||||||
|
|
||||||
|
docs_query = match folder_id {
|
||||||
|
Some(id) => docs_query.filter(documents_dsl::folder_id.eq(Some(id))),
|
||||||
|
None => docs_query.filter(documents_dsl::folder_id.is_null()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let documents: Vec<Document> = docs_query
|
||||||
|
.order(documents_dsl::uploaded_at.desc())
|
||||||
|
.load(&mut conn)?;
|
||||||
|
|
||||||
|
let version_ids: Vec<Uuid> = documents.iter().map(|doc| doc.current_version_id).collect();
|
||||||
|
let versions: Vec<DocumentVersion> = if version_ids.is_empty() {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
document_versions_dsl::document_versions
|
||||||
|
.filter(document_versions_dsl::id.eq_any(&version_ids))
|
||||||
|
.load(&mut conn)?
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut version_map = versions
|
||||||
|
.into_iter()
|
||||||
|
.map(|version| (version.id, version))
|
||||||
|
.collect::<std::collections::HashMap<_, _>>();
|
||||||
|
|
||||||
|
let mut entries = Vec::with_capacity(documents.len());
|
||||||
|
for document in documents {
|
||||||
|
if let Some(version) = version_map.remove(&document.current_version_id) {
|
||||||
|
entries.push(DocumentEntry { document, version });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(WebDavFolderContents {
|
||||||
|
_folder: folder,
|
||||||
|
subfolders,
|
||||||
|
documents: entries,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stream_document(
|
||||||
|
state: &AppState,
|
||||||
|
document: &Document,
|
||||||
|
version: &DocumentVersion,
|
||||||
|
_chain: &[String],
|
||||||
|
headers: HeaderMap,
|
||||||
|
method: Method,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
let range_header = headers.get(header::RANGE).cloned();
|
||||||
|
|
||||||
|
let url = state
|
||||||
|
.storage
|
||||||
|
.presign_get_object(
|
||||||
|
&version.s3_key,
|
||||||
|
Duration::from_secs(DOWNLOAD_URL_TTL_SECONDS),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::internal(format!("failed to presign document download: {err}")))?;
|
||||||
|
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let mut request = client.request(method.clone(), url.clone());
|
||||||
|
|
||||||
|
if let Some(range) = range_header.clone() {
|
||||||
|
request = request.header(header::RANGE, range.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let upstream = request
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::internal(format!("failed to fetch document stream: {err}")))?;
|
||||||
|
|
||||||
|
let status =
|
||||||
|
StatusCode::from_u16(upstream.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
|
||||||
|
|
||||||
|
if !(status.is_success() || status == StatusCode::PARTIAL_CONTENT) {
|
||||||
|
return Err(AppError::internal(format!(
|
||||||
|
"upstream download returned status {status}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut builder = Response::builder().status(status);
|
||||||
|
|
||||||
|
if let Some(content_type) = upstream.headers().get(header::CONTENT_TYPE) {
|
||||||
|
builder = builder.header(header::CONTENT_TYPE, content_type);
|
||||||
|
} else if let Some(ref typ) = document.content_type {
|
||||||
|
builder = builder.header(header::CONTENT_TYPE, typ);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(content_length) = upstream.headers().get(header::CONTENT_LENGTH) {
|
||||||
|
builder = builder.header(header::CONTENT_LENGTH, content_length);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(range) = upstream.headers().get(header::CONTENT_RANGE) {
|
||||||
|
builder = builder.header(header::CONTENT_RANGE, range);
|
||||||
|
}
|
||||||
|
|
||||||
|
builder = builder.header("Accept-Ranges", "bytes");
|
||||||
|
|
||||||
|
if let Some(disposition) = content_disposition(&document.filename) {
|
||||||
|
builder = builder.header(header::CONTENT_DISPOSITION, disposition);
|
||||||
|
}
|
||||||
|
|
||||||
|
builder = builder.header(header::ETAG, format!("\"{}\"", version.id));
|
||||||
|
|
||||||
|
if method == Method::HEAD {
|
||||||
|
return builder
|
||||||
|
.body(Body::empty())
|
||||||
|
.map_err(|err| AppError::internal(format!("failed to build response: {err}")));
|
||||||
|
}
|
||||||
|
|
||||||
|
let stream = upstream
|
||||||
|
.bytes_stream()
|
||||||
|
.map(|chunk| chunk.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err)));
|
||||||
|
let body = Body::from_stream(stream);
|
||||||
|
|
||||||
|
builder
|
||||||
|
.body(body)
|
||||||
|
.map_err(|err| AppError::internal(format!("failed to build response: {err}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavUser>, AppError> {
|
||||||
|
tracing::debug!("webdav authenticate invoked");
|
||||||
|
let authorization = match headers.get(header::AUTHORIZATION) {
|
||||||
|
Some(value) => match value.to_str() {
|
||||||
|
Ok(header) if header.starts_with("Basic ") => {
|
||||||
|
tracing::debug!("authorization header present");
|
||||||
|
&header[6..]
|
||||||
|
}
|
||||||
|
Ok(other) => {
|
||||||
|
tracing::warn!(header = %other, "non-basic authorization header");
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
tracing::warn!(error = %err, "invalid authorization header");
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => {
|
||||||
|
tracing::debug!("no authorization header");
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let decoded = match BASE64.decode(authorization) {
|
||||||
|
Ok(bytes) => bytes,
|
||||||
|
Err(err) => {
|
||||||
|
tracing::warn!(error = %err, "failed to decode basic credentials");
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let credential_str = match String::from_utf8(decoded) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(err) => {
|
||||||
|
tracing::warn!(error = %err, "invalid utf-8 basic credentials");
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (username, password) = match credential_str.split_once(':') {
|
||||||
|
Some((username, password)) if !username.is_empty() => (username, password),
|
||||||
|
_ => return Ok(None),
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing::debug!(%username, "attempting webdav login");
|
||||||
|
let mut conn = state.db()?;
|
||||||
|
|
||||||
|
let user: User = match users_dsl::users
|
||||||
|
.filter(users_dsl::username.eq(username))
|
||||||
|
.first(&mut conn)
|
||||||
|
{
|
||||||
|
Ok(user) => user,
|
||||||
|
Err(diesel::result::Error::NotFound) => {
|
||||||
|
tracing::warn!(%username, "webdav user not found");
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
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");
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::debug!(%username, "webdav login success");
|
||||||
|
Ok(Some(WebDavUser {
|
||||||
|
_user_id: user.id,
|
||||||
|
_username: user.username,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_resources_for_folder(
|
||||||
|
folder: Option<&Folder>,
|
||||||
|
chain: &[String],
|
||||||
|
contents: &WebDavFolderContents,
|
||||||
|
depth: u8,
|
||||||
|
) -> Vec<DavResource> {
|
||||||
|
let mut resources = Vec::new();
|
||||||
|
|
||||||
|
let display_name = folder
|
||||||
|
.map(|folder| folder.name.clone())
|
||||||
|
.unwrap_or_else(|| "/".to_string());
|
||||||
|
|
||||||
|
let href = build_href(chain, true);
|
||||||
|
let last_modified = folder.map(|folder| format_http_date(folder.updated_at));
|
||||||
|
|
||||||
|
resources.push(DavResource {
|
||||||
|
href,
|
||||||
|
display_name,
|
||||||
|
is_collection: true,
|
||||||
|
content_length: None,
|
||||||
|
content_type: None,
|
||||||
|
last_modified,
|
||||||
|
});
|
||||||
|
|
||||||
|
if depth == 0 {
|
||||||
|
return resources;
|
||||||
|
}
|
||||||
|
|
||||||
|
for subfolder in &contents.subfolders {
|
||||||
|
let mut child_chain = chain.to_vec();
|
||||||
|
child_chain.push(subfolder.name.clone());
|
||||||
|
resources.push(DavResource {
|
||||||
|
href: build_href(&child_chain, true),
|
||||||
|
display_name: subfolder.name.clone(),
|
||||||
|
is_collection: true,
|
||||||
|
content_length: None,
|
||||||
|
content_type: None,
|
||||||
|
last_modified: Some(format_http_date(subfolder.updated_at)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for entry in &contents.documents {
|
||||||
|
let mut child_chain = chain.to_vec();
|
||||||
|
child_chain.push(entry.document.filename.clone());
|
||||||
|
resources.push(document_to_resource(
|
||||||
|
&child_chain,
|
||||||
|
&entry.document,
|
||||||
|
&entry.version,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
resources
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_resources_for_document(
|
||||||
|
chain: &[String],
|
||||||
|
document: &Document,
|
||||||
|
version: &DocumentVersion,
|
||||||
|
) -> Vec<DavResource> {
|
||||||
|
vec![document_to_resource(chain, document, version)]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn document_to_resource(
|
||||||
|
chain: &[String],
|
||||||
|
document: &Document,
|
||||||
|
version: &DocumentVersion,
|
||||||
|
) -> DavResource {
|
||||||
|
let href = build_href(chain, false);
|
||||||
|
|
||||||
|
DavResource {
|
||||||
|
href,
|
||||||
|
display_name: document.title.clone(),
|
||||||
|
is_collection: false,
|
||||||
|
content_length: Some(version.size_bytes),
|
||||||
|
content_type: document.content_type.clone(),
|
||||||
|
last_modified: Some(format_http_date(document.updated_at)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_href(names: &[String], is_collection: bool) -> String {
|
||||||
|
if names.is_empty() {
|
||||||
|
return "/".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let encoded = names
|
||||||
|
.iter()
|
||||||
|
.map(|name| utf8_percent_encode(name, NON_ALPHANUMERIC).to_string())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let mut path = format!("/{}", encoded.join("/"));
|
||||||
|
if is_collection && !path.ends_with('/') {
|
||||||
|
path.push('/');
|
||||||
|
}
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_multistatus(resources: &[DavResource]) -> Result<Vec<u8>, quick_xml::Error> {
|
||||||
|
let mut writer = Writer::new(Vec::new());
|
||||||
|
writer.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?;
|
||||||
|
|
||||||
|
let mut multistatus = BytesStart::new("D:multistatus");
|
||||||
|
multistatus.push_attribute(("xmlns:D", "DAV:"));
|
||||||
|
writer.write_event(Event::Start(multistatus))?;
|
||||||
|
|
||||||
|
for resource in resources {
|
||||||
|
writer.write_event(Event::Start(BytesStart::new("D:response")))?;
|
||||||
|
|
||||||
|
writer.write_event(Event::Start(BytesStart::new("D:href")))?;
|
||||||
|
writer.write_event(Event::Text(BytesText::new(&resource.href)))?;
|
||||||
|
writer.write_event(Event::End(BytesEnd::new("D:href")))?;
|
||||||
|
|
||||||
|
writer.write_event(Event::Start(BytesStart::new("D:propstat")))?;
|
||||||
|
writer.write_event(Event::Start(BytesStart::new("D:prop")))?;
|
||||||
|
|
||||||
|
writer.write_event(Event::Start(BytesStart::new("D:displayname")))?;
|
||||||
|
writer.write_event(Event::Text(BytesText::new(&resource.display_name)))?;
|
||||||
|
writer.write_event(Event::End(BytesEnd::new("D:displayname")))?;
|
||||||
|
|
||||||
|
writer.write_event(Event::Start(BytesStart::new("D:resourcetype")))?;
|
||||||
|
if resource.is_collection {
|
||||||
|
writer.write_event(Event::Empty(BytesStart::new("D:collection")))?;
|
||||||
|
}
|
||||||
|
writer.write_event(Event::End(BytesEnd::new("D:resourcetype")))?;
|
||||||
|
|
||||||
|
if let Some(length) = resource.content_length {
|
||||||
|
writer.write_event(Event::Start(BytesStart::new("D:getcontentlength")))?;
|
||||||
|
writer.write_event(Event::Text(BytesText::new(&length.to_string())))?;
|
||||||
|
writer.write_event(Event::End(BytesEnd::new("D:getcontentlength")))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(content_type) = &resource.content_type {
|
||||||
|
writer.write_event(Event::Start(BytesStart::new("D:getcontenttype")))?;
|
||||||
|
writer.write_event(Event::Text(BytesText::new(content_type)))?;
|
||||||
|
writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(last_modified) = &resource.last_modified {
|
||||||
|
writer.write_event(Event::Start(BytesStart::new("D:getlastmodified")))?;
|
||||||
|
writer.write_event(Event::Text(BytesText::new(last_modified)))?;
|
||||||
|
writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.write_event(Event::End(BytesEnd::new("D:prop")))?;
|
||||||
|
|
||||||
|
writer.write_event(Event::Start(BytesStart::new("D:status")))?;
|
||||||
|
writer.write_event(Event::Text(BytesText::new("HTTP/1.1 200 OK")))?;
|
||||||
|
writer.write_event(Event::End(BytesEnd::new("D:status")))?;
|
||||||
|
|
||||||
|
writer.write_event(Event::End(BytesEnd::new("D:propstat")))?;
|
||||||
|
writer.write_event(Event::End(BytesEnd::new("D:response")))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.write_event(Event::End(BytesEnd::new("D:multistatus")))?;
|
||||||
|
Ok(writer.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_http_date(value: chrono::NaiveDateTime) -> String {
|
||||||
|
let datetime = chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(value, chrono::Utc);
|
||||||
|
datetime.format("%a, %d %b %Y %H:%M:%S GMT").to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn content_disposition(filename: &str) -> Option<String> {
|
||||||
|
if filename.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let sanitized: String = filename
|
||||||
|
.chars()
|
||||||
|
.map(|ch| match ch {
|
||||||
|
'"' | '\\' => '_',
|
||||||
|
_ => ch,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let encoded =
|
||||||
|
percent_encoding::utf8_percent_encode(&sanitized, percent_encoding::NON_ALPHANUMERIC);
|
||||||
|
Some(format!(
|
||||||
|
"inline; filename=\"{}\"; filename*=UTF-8''{}",
|
||||||
|
sanitized, encoded
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WebDavFolderContents {
|
||||||
|
_folder: Option<Folder>,
|
||||||
|
subfolders: Vec<Folder>,
|
||||||
|
documents: Vec<DocumentEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DocumentEntry {
|
||||||
|
document: Document,
|
||||||
|
version: DocumentVersion,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DavResource {
|
||||||
|
href: String,
|
||||||
|
display_name: String,
|
||||||
|
is_collection: bool,
|
||||||
|
content_length: Option<i64>,
|
||||||
|
content_type: Option<String>,
|
||||||
|
last_modified: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ResolvedPath {
|
||||||
|
Root,
|
||||||
|
Folder {
|
||||||
|
folder: Folder,
|
||||||
|
chain: Vec<String>,
|
||||||
|
},
|
||||||
|
Document {
|
||||||
|
document: Document,
|
||||||
|
version: DocumentVersion,
|
||||||
|
chain: Vec<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_path(state: &AppState, segments: &[String]) -> AppResult<Option<ResolvedPath>> {
|
||||||
|
if segments.is_empty() {
|
||||||
|
return Ok(Some(ResolvedPath::Root));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut conn = state.db()?;
|
||||||
|
let mut parent_id: Option<Uuid> = None;
|
||||||
|
let mut chain: Vec<String> = Vec::new();
|
||||||
|
let mut current_folder: Option<Folder> = None;
|
||||||
|
|
||||||
|
for (index, segment) in segments.iter().enumerate() {
|
||||||
|
let is_last = index == segments.len() - 1;
|
||||||
|
|
||||||
|
match find_folder_by_name(&mut conn, parent_id, segment)? {
|
||||||
|
Some(folder) => {
|
||||||
|
if is_last {
|
||||||
|
chain.push(folder.name.clone());
|
||||||
|
return Ok(Some(ResolvedPath::Folder { folder, chain }));
|
||||||
|
}
|
||||||
|
|
||||||
|
parent_id = Some(folder.id);
|
||||||
|
chain.push(folder.name.clone());
|
||||||
|
current_folder = Some(folder);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_last {
|
||||||
|
if let Some((document, version)) =
|
||||||
|
find_document_by_filename(&mut conn, parent_id, segment)?
|
||||||
|
{
|
||||||
|
chain.push(document.filename.clone());
|
||||||
|
return Ok(Some(ResolvedPath::Document {
|
||||||
|
document,
|
||||||
|
version,
|
||||||
|
chain,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(uuid) = Uuid::parse_str(segment) {
|
||||||
|
if let Some(folder) = folders_dsl::folders
|
||||||
|
.find(uuid)
|
||||||
|
.first::<Folder>(&mut conn)
|
||||||
|
.optional()?
|
||||||
|
{
|
||||||
|
if folder.parent_id != parent_id {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
if !is_last {
|
||||||
|
parent_id = Some(folder.id);
|
||||||
|
chain.push(folder.name.clone());
|
||||||
|
current_folder = Some(folder);
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
chain.push(folder.name.clone());
|
||||||
|
return Ok(Some(ResolvedPath::Folder { folder, chain }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some((document, version)) = find_document_by_id(&mut conn, uuid)? {
|
||||||
|
if document.folder_id != parent_id {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
chain.push(document.filename.clone());
|
||||||
|
return Ok(Some(ResolvedPath::Document {
|
||||||
|
document,
|
||||||
|
version,
|
||||||
|
chain,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_last {
|
||||||
|
if let Some((document, version)) =
|
||||||
|
find_document_by_filename(&mut conn, parent_id, segment)?
|
||||||
|
{
|
||||||
|
chain.push(document.filename.clone());
|
||||||
|
return Ok(Some(ResolvedPath::Document {
|
||||||
|
document,
|
||||||
|
version,
|
||||||
|
chain,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(current_folder.map(|folder| ResolvedPath::Folder { folder, chain }))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_folder_by_name(
|
||||||
|
conn: &mut PgConnection,
|
||||||
|
parent_id: Option<Uuid>,
|
||||||
|
name: &str,
|
||||||
|
) -> AppResult<Option<Folder>> {
|
||||||
|
let result = match parent_id {
|
||||||
|
Some(parent) => folders_dsl::folders
|
||||||
|
.filter(folders_dsl::parent_id.eq(Some(parent)))
|
||||||
|
.filter(folders_dsl::name.eq(name))
|
||||||
|
.first::<Folder>(conn)
|
||||||
|
.optional()?,
|
||||||
|
None => folders_dsl::folders
|
||||||
|
.filter(folders_dsl::parent_id.is_null())
|
||||||
|
.filter(folders_dsl::name.eq(name))
|
||||||
|
.first::<Folder>(conn)
|
||||||
|
.optional()?,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_document_by_filename(
|
||||||
|
conn: &mut PgConnection,
|
||||||
|
parent_id: Option<Uuid>,
|
||||||
|
filename: &str,
|
||||||
|
) -> AppResult<Option<(Document, DocumentVersion)>> {
|
||||||
|
let mut query = documents_dsl::documents
|
||||||
|
.filter(documents_dsl::deleted_at.is_null())
|
||||||
|
.filter(documents_dsl::filename.eq(filename))
|
||||||
|
.into_boxed();
|
||||||
|
|
||||||
|
query = match parent_id {
|
||||||
|
Some(parent) => query.filter(documents_dsl::folder_id.eq(Some(parent))),
|
||||||
|
None => query.filter(documents_dsl::folder_id.is_null()),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(document) = query.first::<Document>(conn).optional()? {
|
||||||
|
let version = document_versions_dsl::document_versions
|
||||||
|
.find(document.current_version_id)
|
||||||
|
.first::<DocumentVersion>(conn)?;
|
||||||
|
return Ok(Some((document, version)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_document_by_id(
|
||||||
|
conn: &mut PgConnection,
|
||||||
|
document_id: Uuid,
|
||||||
|
) -> AppResult<Option<(Document, DocumentVersion)>> {
|
||||||
|
if let Some(document) = documents_dsl::documents
|
||||||
|
.filter(documents_dsl::deleted_at.is_null())
|
||||||
|
.find(document_id)
|
||||||
|
.first::<Document>(conn)
|
||||||
|
.optional()?
|
||||||
|
{
|
||||||
|
let version = document_versions_dsl::document_versions
|
||||||
|
.find(document.current_version_id)
|
||||||
|
.first::<DocumentVersion>(conn)?;
|
||||||
|
return Ok(Some((document, version)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
@@ -20,6 +20,8 @@ struct DocumentInfo {
|
|||||||
issued_at: Option<String>,
|
issued_at: Option<String>,
|
||||||
tags: Vec<TagSummary>,
|
tags: Vec<TagSummary>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
correspondents: Vec<DocumentCorrespondentInfo>,
|
||||||
|
#[serde(default)]
|
||||||
current_version: Option<DocumentVersion>,
|
current_version: Option<DocumentVersion>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +77,23 @@ struct TagSummary {
|
|||||||
label: String,
|
label: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct DocumentCorrespondentInfo {
|
||||||
|
name: String,
|
||||||
|
role: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct CorrespondentSummary {
|
||||||
|
id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct BulkCorrespondentResult {
|
||||||
|
assigned: usize,
|
||||||
|
removed: usize,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct AnalyzeJobPayload {
|
struct AnalyzeJobPayload {
|
||||||
document_id: Uuid,
|
document_id: Uuid,
|
||||||
@@ -157,12 +176,12 @@ async fn upload_and_list_document() -> Result<()> {
|
|||||||
assert_eq!(detail.document.deleted_at, None);
|
assert_eq!(detail.document.deleted_at, None);
|
||||||
assert!(detail.document.issued_at.is_none());
|
assert!(detail.document.issued_at.is_none());
|
||||||
assert!(detail.document.tags.is_empty());
|
assert!(detail.document.tags.is_empty());
|
||||||
assert!(current_version.download_path.starts_with("/download/"));
|
|
||||||
let current_version = detail
|
let current_version = detail
|
||||||
.document
|
.document
|
||||||
.current_version
|
.current_version
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.expect("current version detail");
|
.expect("current version detail");
|
||||||
|
assert!(current_version.download_path.starts_with("/download/"));
|
||||||
assert_eq!(current_version.version_number, 1);
|
assert_eq!(current_version.version_number, 1);
|
||||||
assert_eq!(current_version.size_bytes, file_bytes.len() as i64);
|
assert_eq!(current_version.size_bytes, file_bytes.len() as i64);
|
||||||
assert!(current_version.assets.is_empty());
|
assert!(current_version.assets.is_empty());
|
||||||
@@ -616,6 +635,234 @@ async fn bulk_update_tags_for_selection() -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn bulk_assign_correspondents_to_selection() -> Result<()> {
|
||||||
|
let _lock = acquire_db_lock().await;
|
||||||
|
let app = TestApp::new().await?;
|
||||||
|
|
||||||
|
let password = "bulkcorresp";
|
||||||
|
app.insert_user("corra", password, "admin").await?;
|
||||||
|
let token = app.login_token("corra", password).await?;
|
||||||
|
|
||||||
|
let first = app
|
||||||
|
.upload_document(
|
||||||
|
"/api/documents",
|
||||||
|
"letter-one.txt",
|
||||||
|
"text/plain",
|
||||||
|
b"letter one",
|
||||||
|
None,
|
||||||
|
&token,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(first.status(), StatusCode::CREATED);
|
||||||
|
let first_body = body_to_vec(first.into_body()).await?;
|
||||||
|
let first_detail: DocumentDetail = serde_json::from_slice(&first_body)?;
|
||||||
|
|
||||||
|
let second = app
|
||||||
|
.upload_document(
|
||||||
|
"/api/documents",
|
||||||
|
"letter-two.txt",
|
||||||
|
"text/plain",
|
||||||
|
b"letter two",
|
||||||
|
None,
|
||||||
|
&token,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(second.status(), StatusCode::CREATED);
|
||||||
|
let second_body = body_to_vec(second.into_body()).await?;
|
||||||
|
let second_detail: DocumentDetail = serde_json::from_slice(&second_body)?;
|
||||||
|
|
||||||
|
let sender = app
|
||||||
|
.post_json(
|
||||||
|
"/api/correspondents",
|
||||||
|
&serde_json::json!({ "name": "Acme Corp" }),
|
||||||
|
Some(&token),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(sender.status(), StatusCode::OK);
|
||||||
|
let sender_body = body_to_vec(sender.into_body()).await?;
|
||||||
|
let sender_summary: CorrespondentSummary = serde_json::from_slice(&sender_body)?;
|
||||||
|
|
||||||
|
let receiver = app
|
||||||
|
.post_json(
|
||||||
|
"/api/correspondents",
|
||||||
|
&serde_json::json!({ "name": "Bank Ltd" }),
|
||||||
|
Some(&token),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(receiver.status(), StatusCode::OK);
|
||||||
|
let receiver_body = body_to_vec(receiver.into_body()).await?;
|
||||||
|
let receiver_summary: CorrespondentSummary = serde_json::from_slice(&receiver_body)?;
|
||||||
|
|
||||||
|
let assign_payload = serde_json::json!({
|
||||||
|
"document_ids": [
|
||||||
|
first_detail.document.id,
|
||||||
|
second_detail.document.id
|
||||||
|
],
|
||||||
|
"assignments": [
|
||||||
|
{
|
||||||
|
"correspondent_id": sender_summary.id,
|
||||||
|
"role": "sender"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"correspondent_id": receiver_summary.id,
|
||||||
|
"role": "receiver"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
let assign_resp = app
|
||||||
|
.post_json(
|
||||||
|
"/api/documents/bulk/correspondents",
|
||||||
|
&assign_payload,
|
||||||
|
Some(&token),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(assign_resp.status(), StatusCode::OK);
|
||||||
|
let assign_body = body_to_vec(assign_resp.into_body()).await?;
|
||||||
|
let assign_result: BulkCorrespondentResult = serde_json::from_slice(&assign_body)?;
|
||||||
|
assert_eq!(assign_result.assigned, 4);
|
||||||
|
assert_eq!(assign_result.removed, 0);
|
||||||
|
|
||||||
|
for doc_id in [first_detail.document.id, second_detail.document.id] {
|
||||||
|
let refreshed = app
|
||||||
|
.get(&format!("/api/documents/{doc_id}"), Some(&token))
|
||||||
|
.await?;
|
||||||
|
assert_eq!(refreshed.status(), StatusCode::OK);
|
||||||
|
let refreshed_body = body_to_vec(refreshed.into_body()).await?;
|
||||||
|
let detail: DocumentDetail = serde_json::from_slice(&refreshed_body)?;
|
||||||
|
assert_eq!(detail.document.correspondents.len(), 2);
|
||||||
|
assert!(detail
|
||||||
|
.document
|
||||||
|
.correspondents
|
||||||
|
.iter()
|
||||||
|
.any(|entry| entry.role == "sender" && entry.name == "Acme Corp"));
|
||||||
|
assert!(detail
|
||||||
|
.document
|
||||||
|
.correspondents
|
||||||
|
.iter()
|
||||||
|
.any(|entry| entry.role == "receiver" && entry.name == "Bank Ltd"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let duplicate_resp = app
|
||||||
|
.post_json(
|
||||||
|
"/api/documents/bulk/correspondents",
|
||||||
|
&assign_payload,
|
||||||
|
Some(&token),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(duplicate_resp.status(), StatusCode::OK);
|
||||||
|
let duplicate_body = body_to_vec(duplicate_resp.into_body()).await?;
|
||||||
|
let duplicate_result: BulkCorrespondentResult = serde_json::from_slice(&duplicate_body)?;
|
||||||
|
assert_eq!(duplicate_result.assigned, 0);
|
||||||
|
assert_eq!(duplicate_result.removed, 0);
|
||||||
|
|
||||||
|
let replacement = app
|
||||||
|
.post_json(
|
||||||
|
"/api/correspondents",
|
||||||
|
&serde_json::json!({ "name": "Charlie" }),
|
||||||
|
Some(&token),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(replacement.status(), StatusCode::OK);
|
||||||
|
let replacement_body = body_to_vec(replacement.into_body()).await?;
|
||||||
|
let replacement_summary: CorrespondentSummary = serde_json::from_slice(&replacement_body)?;
|
||||||
|
|
||||||
|
let replace_payload = serde_json::json!({
|
||||||
|
"document_ids": [
|
||||||
|
first_detail.document.id,
|
||||||
|
second_detail.document.id
|
||||||
|
],
|
||||||
|
"assignments": [
|
||||||
|
{
|
||||||
|
"correspondent_id": replacement_summary.id,
|
||||||
|
"role": "sender"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
let replace_resp = app
|
||||||
|
.post_json(
|
||||||
|
"/api/documents/bulk/correspondents",
|
||||||
|
&replace_payload,
|
||||||
|
Some(&token),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(replace_resp.status(), StatusCode::OK);
|
||||||
|
let replace_body = body_to_vec(replace_resp.into_body()).await?;
|
||||||
|
let replace_result: BulkCorrespondentResult = serde_json::from_slice(&replace_body)?;
|
||||||
|
assert_eq!(replace_result.assigned, 2);
|
||||||
|
assert_eq!(replace_result.removed, 2);
|
||||||
|
|
||||||
|
for doc_id in [first_detail.document.id, second_detail.document.id] {
|
||||||
|
let refreshed = app
|
||||||
|
.get(&format!("/api/documents/{doc_id}"), Some(&token))
|
||||||
|
.await?;
|
||||||
|
let refreshed_body = body_to_vec(refreshed.into_body()).await?;
|
||||||
|
let detail: DocumentDetail = serde_json::from_slice(&refreshed_body)?;
|
||||||
|
assert_eq!(detail.document.correspondents.len(), 2);
|
||||||
|
assert!(detail
|
||||||
|
.document
|
||||||
|
.correspondents
|
||||||
|
.iter()
|
||||||
|
.any(|entry| entry.role == "sender" && entry.name == "Charlie"));
|
||||||
|
assert!(detail
|
||||||
|
.document
|
||||||
|
.correspondents
|
||||||
|
.iter()
|
||||||
|
.any(|entry| entry.role == "receiver" && entry.name == "Bank Ltd"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let remove_payload = serde_json::json!({
|
||||||
|
"document_ids": [
|
||||||
|
first_detail.document.id,
|
||||||
|
second_detail.document.id
|
||||||
|
],
|
||||||
|
"assignments": [
|
||||||
|
{
|
||||||
|
"correspondent_id": receiver_summary.id,
|
||||||
|
"role": "receiver"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"action": "remove"
|
||||||
|
});
|
||||||
|
|
||||||
|
let remove_resp = app
|
||||||
|
.post_json(
|
||||||
|
"/api/documents/bulk/correspondents",
|
||||||
|
&remove_payload,
|
||||||
|
Some(&token),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(remove_resp.status(), StatusCode::OK);
|
||||||
|
let remove_body = body_to_vec(remove_resp.into_body()).await?;
|
||||||
|
let remove_result: BulkCorrespondentResult = serde_json::from_slice(&remove_body)?;
|
||||||
|
assert_eq!(remove_result.assigned, 0);
|
||||||
|
assert_eq!(remove_result.removed, 2);
|
||||||
|
|
||||||
|
for doc_id in [first_detail.document.id, second_detail.document.id] {
|
||||||
|
let refreshed = app
|
||||||
|
.get(&format!("/api/documents/{doc_id}"), Some(&token))
|
||||||
|
.await?;
|
||||||
|
let refreshed_body = body_to_vec(refreshed.into_body()).await?;
|
||||||
|
let detail: DocumentDetail = serde_json::from_slice(&refreshed_body)?;
|
||||||
|
assert_eq!(detail.document.correspondents.len(), 1);
|
||||||
|
assert!(detail
|
||||||
|
.document
|
||||||
|
.correspondents
|
||||||
|
.iter()
|
||||||
|
.any(|entry| entry.role == "sender" && entry.name == "Charlie"));
|
||||||
|
assert!(!detail
|
||||||
|
.document
|
||||||
|
.correspondents
|
||||||
|
.iter()
|
||||||
|
.any(|entry| entry.role == "receiver"));
|
||||||
|
}
|
||||||
|
|
||||||
|
app.cleanup().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn bulk_reanalyze_selected_documents() -> Result<()> {
|
async fn bulk_reanalyze_selected_documents() -> Result<()> {
|
||||||
let _lock = acquire_db_lock().await;
|
let _lock = acquire_db_lock().await;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ Documents
|
|||||||
- POST /api/documents/reanalyze - Queue re-analysis for every non-deleted document.
|
- POST /api/documents/reanalyze - Queue re-analysis for every non-deleted document.
|
||||||
- POST /api/documents/bulk/move - Move multiple documents to a target folder.
|
- POST /api/documents/bulk/move - Move multiple documents to a target folder.
|
||||||
- POST /api/documents/bulk/tags - Add or remove tags across multiple documents.
|
- POST /api/documents/bulk/tags - Add or remove tags across multiple documents.
|
||||||
|
- POST /api/documents/bulk/correspondents - Bulk correspondent actions. Default `action=add` replaces existing assignments for the provided roles before adding the supplied correspondents; `action=remove` drops the specified correspondent/role pairs.
|
||||||
- POST /api/documents/bulk/reanalyze - Queue re-analysis jobs for selected documents.
|
- POST /api/documents/bulk/reanalyze - Queue re-analysis jobs for selected documents.
|
||||||
- GET /api/documents/:id - Retrieve metadata and current version details for a document.
|
- GET /api/documents/:id - Retrieve metadata and current version details for a document.
|
||||||
- PATCH /api/documents/:id - Update document metadata (currently title).
|
- PATCH /api/documents/:id - Update document metadata (currently title).
|
||||||
|
|||||||
+329
-200
@@ -7,6 +7,178 @@ import { CORRESPONDENT_ROLES } from '../constants/correspondents';
|
|||||||
|
|
||||||
const MAX_PREVIEW_STACK_ITEMS = 15;
|
const MAX_PREVIEW_STACK_ITEMS = 15;
|
||||||
|
|
||||||
|
const normalizeRole = (role) => (role || '').toLowerCase();
|
||||||
|
|
||||||
|
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),
|
||||||
|
}))
|
||||||
|
.sort(compareCorrespondents);
|
||||||
|
|
||||||
|
const CorrespondentPills = ({ entries = [], onRemove, showCount = false }) => (
|
||||||
|
<div className="correspondent-list">
|
||||||
|
{entries.length ? (
|
||||||
|
entries.map((entry) => (
|
||||||
|
<span key={`${entry.id}:${entry.role}`} className="correspondent-pill">
|
||||||
|
<span className="correspondent-pill__label">
|
||||||
|
<strong>{formatRoleLabel(entry.role)}</strong>
|
||||||
|
<span>
|
||||||
|
{entry.name}
|
||||||
|
{showCount && entry.count ? ` (${entry.count})` : ''}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{onRemove ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="correspondent-pill__remove"
|
||||||
|
onClick={() => onRemove(entry)}
|
||||||
|
aria-label={`Remove ${entry.name} as ${formatRoleLabel(entry.role)}`}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="meta">No correspondents yet.</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const TagSection = ({
|
||||||
|
title,
|
||||||
|
tags = [],
|
||||||
|
onRemove,
|
||||||
|
onAdd,
|
||||||
|
emptyMessage = 'No tags yet.',
|
||||||
|
addPlaceholder = 'Add or create tag',
|
||||||
|
addButtonLabel = 'Add',
|
||||||
|
datalistId,
|
||||||
|
datalistOptions = [],
|
||||||
|
className,
|
||||||
|
}) => (
|
||||||
|
<div className={className}>
|
||||||
|
<dt>{title}</dt>
|
||||||
|
<div className="tag-list">
|
||||||
|
{tags.length ? (
|
||||||
|
tags.map((tag) => {
|
||||||
|
const key = tag.id ?? tag.label;
|
||||||
|
const style = getTagColorStyle(tag.color);
|
||||||
|
return (
|
||||||
|
<span key={key} className="tag-pill" style={style || undefined}>
|
||||||
|
{tag.label}{' '}
|
||||||
|
{onRemove ? (
|
||||||
|
<button type="button" onClick={() => onRemove(tag)}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<span className="meta">{emptyMessage}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{onAdd ? (
|
||||||
|
<form
|
||||||
|
className="inline"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const input = event.currentTarget.elements.tag;
|
||||||
|
const value = input.value.trim();
|
||||||
|
if (!value) return;
|
||||||
|
onAdd({ value, input });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input name="tag" placeholder={addPlaceholder} list={datalistId} />
|
||||||
|
<button type="submit">{addButtonLabel}</button>
|
||||||
|
{datalistId ? (
|
||||||
|
<datalist id={datalistId}>
|
||||||
|
{datalistOptions.map((option) => (
|
||||||
|
<option key={option.id || option.label || option} value={option.label || option} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
) : null}
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CorrespondentSection = ({
|
||||||
|
title,
|
||||||
|
entries = [],
|
||||||
|
onRemove,
|
||||||
|
onAdd,
|
||||||
|
showCount = false,
|
||||||
|
addPlaceholder = 'Add or create correspondent',
|
||||||
|
addButtonLabel = 'Add',
|
||||||
|
datalistId,
|
||||||
|
datalistOptions = [],
|
||||||
|
className,
|
||||||
|
}) => (
|
||||||
|
<div className={className}>
|
||||||
|
<dt>{title}</dt>
|
||||||
|
<CorrespondentPills entries={entries} onRemove={onRemove} showCount={showCount} />
|
||||||
|
{onAdd ? (
|
||||||
|
<form
|
||||||
|
className="correspondent-form"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
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 });
|
||||||
|
form.reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
name="correspondent"
|
||||||
|
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}>
|
||||||
|
{datalistOptions.map((name) => (
|
||||||
|
<option key={name} value={name} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
) : null}
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
const computeStackAngle = (docId, index) => {
|
const computeStackAngle = (docId, index) => {
|
||||||
if (index === 0) return 0;
|
if (index === 0) return 0;
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
@@ -95,7 +267,6 @@ const DetailPanel = ({
|
|||||||
selectedDocuments = [],
|
selectedDocuments = [],
|
||||||
tags = [],
|
tags = [],
|
||||||
tagLookupById = new Map(),
|
tagLookupById = new Map(),
|
||||||
tagLookupByLabel = new Map(),
|
|
||||||
onTagAdd,
|
onTagAdd,
|
||||||
onTagRemove,
|
onTagRemove,
|
||||||
onRegenerateThumbnails,
|
onRegenerateThumbnails,
|
||||||
@@ -103,10 +274,9 @@ const DetailPanel = ({
|
|||||||
onOpenPreview,
|
onOpenPreview,
|
||||||
onBulkTagAdd,
|
onBulkTagAdd,
|
||||||
onBulkTagRemove,
|
onBulkTagRemove,
|
||||||
onBulkMove,
|
|
||||||
onBulkReanalyze,
|
onBulkReanalyze,
|
||||||
folderOptions = [],
|
onBulkCorrespondentAdd,
|
||||||
defaultMoveTarget = 'root',
|
onBulkCorrespondentRemove,
|
||||||
onPromoteSelection,
|
onPromoteSelection,
|
||||||
activePreviewId = null,
|
activePreviewId = null,
|
||||||
onUpdateTitle = async () => false,
|
onUpdateTitle = async () => false,
|
||||||
@@ -257,20 +427,25 @@ const DetailPanel = ({
|
|||||||
});
|
});
|
||||||
}, [stackDocuments, ensureAssetUrl, getDocumentAsset]);
|
}, [stackDocuments, ensureAssetUrl, getDocumentAsset]);
|
||||||
|
|
||||||
const commonTags = useMemo(() => {
|
const bulkTagUnion = useMemo(() => {
|
||||||
if (selectedCount < 2) return [];
|
if (!selectedDocuments.length) return [];
|
||||||
const tagSets = selectedDocuments.map((doc) => new Set((doc.tags || []).map((tag) => tag.label)));
|
const tagMap = new Map();
|
||||||
if (!tagSets.length) return [];
|
selectedDocuments.forEach((doc) => {
|
||||||
const intersection = new Set(tagSets[0]);
|
(doc.tags || []).forEach((tag) => {
|
||||||
tagSets.slice(1).forEach((set) => {
|
const label = (tag?.label || '').trim();
|
||||||
[...intersection].forEach((label) => {
|
if (!label) return;
|
||||||
if (!set.has(label)) {
|
if (!tagMap.has(label)) {
|
||||||
intersection.delete(label);
|
const fallback = tagLookupById.get(tag.id) || {};
|
||||||
|
tagMap.set(label, {
|
||||||
|
id: tag.id,
|
||||||
|
label,
|
||||||
|
color: tag.color || fallback.color,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return [...intersection];
|
return [...tagMap.values()].sort((a, b) => a.label.localeCompare(b.label));
|
||||||
}, [selectedDocuments, selectedCount]);
|
}, [selectedDocuments, tagLookupById]);
|
||||||
|
|
||||||
const stackTotalSizeBytes = useMemo(() => {
|
const stackTotalSizeBytes = useMemo(() => {
|
||||||
if (!stackPreviews.length) return 0;
|
if (!stackPreviews.length) return 0;
|
||||||
@@ -302,6 +477,87 @@ const DetailPanel = ({
|
|||||||
});
|
});
|
||||||
}, [availableCorrespondents]);
|
}, [availableCorrespondents]);
|
||||||
|
|
||||||
|
const singleCorrespondents = useMemo(() => {
|
||||||
|
if (!singleDoc) return [];
|
||||||
|
return sortCorrespondents(singleDoc.correspondents || []);
|
||||||
|
}, [singleDoc]);
|
||||||
|
|
||||||
|
const bulkCorrespondents = useMemo(() => {
|
||||||
|
if (selectedDocuments.length <= 1) {
|
||||||
|
const doc = selectedDocuments[0];
|
||||||
|
return doc ? sortCorrespondents(doc.correspondents || []) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const map = new Map();
|
||||||
|
selectedDocuments.forEach((doc) => {
|
||||||
|
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, {
|
||||||
|
id: entry.id,
|
||||||
|
name: entry.name || '',
|
||||||
|
role: normalizedRole,
|
||||||
|
documentIds: new Set(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
map.get(key).documentIds.add(doc.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...map.values()]
|
||||||
|
.map((entry) => ({
|
||||||
|
id: entry.id,
|
||||||
|
name: entry.name,
|
||||||
|
role: entry.role,
|
||||||
|
documentIds: [...entry.documentIds],
|
||||||
|
count: entry.documentIds.size,
|
||||||
|
}))
|
||||||
|
.sort(compareCorrespondents);
|
||||||
|
}, [selectedDocuments]);
|
||||||
|
|
||||||
|
const handleBulkCorrespondentRemove = useCallback(
|
||||||
|
(entry) => {
|
||||||
|
if (!entry?.id) return;
|
||||||
|
const normalizedRole = normalizeRole(entry.role);
|
||||||
|
if (onBulkCorrespondentRemove) {
|
||||||
|
return onBulkCorrespondentRemove({
|
||||||
|
assignments: [
|
||||||
|
{
|
||||||
|
correspondent_id: entry.id,
|
||||||
|
role: normalizedRole,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
documentIds: entry.documentIds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!onCorrespondentRemove) return;
|
||||||
|
const targets = entry.documentIds && entry.documentIds.length
|
||||||
|
? entry.documentIds
|
||||||
|
: selectedDocuments
|
||||||
|
.filter((doc) =>
|
||||||
|
(doc.correspondents || []).some(
|
||||||
|
(item) => item.id === entry.id && normalizeRole(item.role) === normalizedRole,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.map((doc) => doc.id);
|
||||||
|
|
||||||
|
return Promise.all(
|
||||||
|
targets.map((documentId) =>
|
||||||
|
onCorrespondentRemove({
|
||||||
|
documentId,
|
||||||
|
correspondentId: entry.id,
|
||||||
|
role: normalizedRole,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).catch(() => {});
|
||||||
|
},
|
||||||
|
[bulkCorrespondents, onBulkCorrespondentRemove, onCorrespondentRemove, selectedDocuments],
|
||||||
|
);
|
||||||
|
|
||||||
const renderSingle = () => {
|
const renderSingle = () => {
|
||||||
if (!singleDoc) {
|
if (!singleDoc) {
|
||||||
return <p className="meta">Select a document to view metadata, tags and actions.</p>;
|
return <p className="meta">Select a document to view metadata, tags and actions.</p>;
|
||||||
@@ -320,21 +576,6 @@ const DetailPanel = ({
|
|||||||
const tagsForDoc = Array.isArray(singleDoc.tags) ? singleDoc.tags : [];
|
const tagsForDoc = Array.isArray(singleDoc.tags) ? singleDoc.tags : [];
|
||||||
const metadata =
|
const metadata =
|
||||||
singleDoc.metadata && Object.keys(singleDoc.metadata).length > 0 ? singleDoc.metadata : null;
|
singleDoc.metadata && Object.keys(singleDoc.metadata).length > 0 ? singleDoc.metadata : null;
|
||||||
const docCorrespondents = Array.isArray(singleDoc.correspondents)
|
|
||||||
? singleDoc.correspondents
|
|
||||||
: [];
|
|
||||||
const sortedCorrespondents = docCorrespondents.slice().sort((a, b) => {
|
|
||||||
const roleA = (a.role || '').toLowerCase();
|
|
||||||
const roleB = (b.role || '').toLowerCase();
|
|
||||||
const indexA = CORRESPONDENT_ROLES.indexOf(roleA);
|
|
||||||
const indexB = CORRESPONDENT_ROLES.indexOf(roleB);
|
|
||||||
if (indexA !== indexB) {
|
|
||||||
return (indexA === -1 ? Number.MAX_SAFE_INTEGER : indexA) -
|
|
||||||
(indexB === -1 ? Number.MAX_SAFE_INTEGER : indexB);
|
|
||||||
}
|
|
||||||
return (a.name || '').localeCompare(b.name || '');
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
@@ -448,119 +689,39 @@ const DetailPanel = ({
|
|||||||
Re-run analysis
|
Re-run analysis
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<TagSection
|
||||||
<dt>Tags</dt>
|
title="Tags"
|
||||||
<div className="tag-list">
|
tags={tagsForDoc.map((tag) => ({
|
||||||
{tagsForDoc.length ? (
|
id: tag.id,
|
||||||
tagsForDoc.map((tag) => {
|
label: tag.label,
|
||||||
const colorSource = tag?.color || tagLookupById.get(tag.id)?.color;
|
color: tag.color || tagLookupById.get(tag.id)?.color,
|
||||||
const style = getTagColorStyle(colorSource);
|
}))}
|
||||||
return (
|
onRemove={(tag) => onTagRemove(singleDoc.id, tag.id)}
|
||||||
<span key={tag.id} className="tag-pill" style={style || undefined}>
|
onAdd={({ value, input }) => onTagAdd(singleDoc, value, input)}
|
||||||
{tag.label}{' '}
|
datalistId="tag-catalog-single"
|
||||||
<button type="button" onClick={() => onTagRemove(singleDoc.id, tag.id)}>
|
datalistOptions={tags}
|
||||||
×
|
/>
|
||||||
</button>
|
<CorrespondentSection
|
||||||
</span>
|
title="Correspondents"
|
||||||
);
|
entries={singleCorrespondents}
|
||||||
})
|
onRemove={(entry) =>
|
||||||
) : (
|
onCorrespondentRemove?.({
|
||||||
<span className="meta">No tags yet.</span>
|
documentId: singleDoc.id,
|
||||||
)}
|
correspondentId: entry.id,
|
||||||
</div>
|
role: entry.role,
|
||||||
<form
|
})
|
||||||
className="inline"
|
}
|
||||||
onSubmit={(event) => {
|
onAdd={({ name, role, input }) =>
|
||||||
event.preventDefault();
|
onCorrespondentAdd?.({
|
||||||
const input = event.currentTarget.elements.tag;
|
document: singleDoc,
|
||||||
const value = input.value.trim();
|
name,
|
||||||
if (!value) return;
|
role,
|
||||||
onTagAdd(singleDoc, value, input);
|
input,
|
||||||
}}
|
})
|
||||||
>
|
}
|
||||||
<input name="tag" placeholder="Add or create tag" list="tag-catalog" />
|
datalistId="correspondent-catalog-single"
|
||||||
<button type="submit">Add</button>
|
datalistOptions={correspondentOptions}
|
||||||
<datalist id="tag-catalog">
|
/>
|
||||||
{tags.map((tag) => (
|
|
||||||
<option key={tag.id} value={tag.label} />
|
|
||||||
))}
|
|
||||||
</datalist>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Correspondents</dt>
|
|
||||||
<div className="correspondent-list">
|
|
||||||
{sortedCorrespondents.length ? (
|
|
||||||
sortedCorrespondents.map((entry) => {
|
|
||||||
const roleLabel = entry.role
|
|
||||||
? entry.role.charAt(0).toUpperCase() + entry.role.slice(1)
|
|
||||||
: 'Other';
|
|
||||||
return (
|
|
||||||
<span key={`${entry.id}:${entry.role}`} className="correspondent-pill">
|
|
||||||
<span className="correspondent-pill__label">
|
|
||||||
<strong>{roleLabel}</strong>
|
|
||||||
<span>{entry.name}</span>
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="correspondent-pill__remove"
|
|
||||||
onClick={() =>
|
|
||||||
onCorrespondentRemove?.({
|
|
||||||
documentId: singleDoc.id,
|
|
||||||
correspondentId: entry.id,
|
|
||||||
role: entry.role,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
aria-label={`Remove ${entry.name} as ${roleLabel}`}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<span className="meta">No correspondents yet.</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<form
|
|
||||||
className="correspondent-form"
|
|
||||||
onSubmit={(event) => {
|
|
||||||
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;
|
|
||||||
onCorrespondentAdd?.({
|
|
||||||
document: singleDoc,
|
|
||||||
name: value,
|
|
||||||
role,
|
|
||||||
input: nameInput,
|
|
||||||
});
|
|
||||||
form.reset();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
name="correspondent"
|
|
||||||
placeholder="Add or create correspondent"
|
|
||||||
list="correspondent-catalog"
|
|
||||||
/>
|
|
||||||
<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">Add</button>
|
|
||||||
<datalist id="correspondent-catalog">
|
|
||||||
{correspondentOptions.map((name) => (
|
|
||||||
<option key={name} value={name} />
|
|
||||||
))}
|
|
||||||
</datalist>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{metadata && (
|
{metadata && (
|
||||||
<div>
|
<div>
|
||||||
<dt>Metadata</dt>
|
<dt>Metadata</dt>
|
||||||
@@ -593,64 +754,32 @@ const DetailPanel = ({
|
|||||||
<div>
|
<div>
|
||||||
<strong>Total size (stack):</strong> {sizeLabel}
|
<strong>Total size (stack):</strong> {sizeLabel}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<strong>Common tags:</strong>{' '}
|
|
||||||
{commonTags.length ? commonTags.join(', ') : 'None'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bulk-tags">
|
|
||||||
<strong>Bulk tag operations</strong>
|
|
||||||
<form
|
|
||||||
className="inline"
|
|
||||||
onSubmit={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const input = event.currentTarget.elements.tag;
|
|
||||||
const value = input.value.trim();
|
|
||||||
if (!value) return;
|
|
||||||
onBulkTagAdd?.({ label: value, input });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input name="tag" placeholder="Add tag to selection" list="tag-catalog" />
|
|
||||||
<button type="submit">Add tag</button>
|
|
||||||
</form>
|
|
||||||
<form
|
|
||||||
className="inline"
|
|
||||||
onSubmit={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const input = event.currentTarget.elements.tag;
|
|
||||||
const value = input.value.trim();
|
|
||||||
if (!value) return;
|
|
||||||
onBulkTagRemove?.({ label: value, input });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input name="tag" placeholder="Remove tag from selection" list="tag-catalog" />
|
|
||||||
<button type="submit" className="secondary">
|
|
||||||
Remove tag
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<datalist id="tag-catalog">
|
|
||||||
{tags.map((tag) => (
|
|
||||||
<option key={tag.id} value={tag.label} />
|
|
||||||
))}
|
|
||||||
</datalist>
|
|
||||||
<div className="bulk-move">
|
|
||||||
<label htmlFor="detail-bulk-move" className="meta">
|
|
||||||
Move selection to folder
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="detail-bulk-move"
|
|
||||||
name="target"
|
|
||||||
defaultValue={defaultMoveTarget || 'root'}
|
|
||||||
onChange={(event) => onBulkMove?.({ target: event.target.value })}
|
|
||||||
>
|
|
||||||
{folderOptions.map((option) => (
|
|
||||||
<option key={option.id} value={option.id}>
|
|
||||||
{option.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
<TagSection
|
||||||
|
title="Tags"
|
||||||
|
tags={bulkTagUnion}
|
||||||
|
emptyMessage="No tags assigned."
|
||||||
|
onRemove={(tag) => onBulkTagRemove?.({ label: tag.label })}
|
||||||
|
onAdd={({ value, input }) => onBulkTagAdd?.({ label: value, input })}
|
||||||
|
addPlaceholder="Add tag to selection"
|
||||||
|
addButtonLabel="Add tag"
|
||||||
|
datalistId="tag-catalog-bulk"
|
||||||
|
datalistOptions={tags}
|
||||||
|
className="bulk-tags"
|
||||||
|
/>
|
||||||
|
<CorrespondentSection
|
||||||
|
title="Correspondents"
|
||||||
|
entries={bulkCorrespondents}
|
||||||
|
onRemove={handleBulkCorrespondentRemove}
|
||||||
|
onAdd={({ name, role, input }) =>
|
||||||
|
onBulkCorrespondentAdd?.({ name, role, input })
|
||||||
|
}
|
||||||
|
addPlaceholder="Add correspondent to selection"
|
||||||
|
datalistId="correspondent-catalog-bulk"
|
||||||
|
datalistOptions={correspondentOptions}
|
||||||
|
showCount
|
||||||
|
className="bulk-correspondents"
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="secondary"
|
className="secondary"
|
||||||
|
|||||||
@@ -3,60 +3,6 @@ import { resolveDocumentAssetUrl } from '../asset_manager';
|
|||||||
import { getTagColorStyle } from '../utils/colors';
|
import { getTagColorStyle } from '../utils/colors';
|
||||||
import { DownloadIcon, FolderIcon, EditIcon } from '../ui/icons';
|
import { DownloadIcon, FolderIcon, EditIcon } from '../ui/icons';
|
||||||
|
|
||||||
const FilterBar = ({
|
|
||||||
query,
|
|
||||||
onQueryChange,
|
|
||||||
tags,
|
|
||||||
activeTagIds,
|
|
||||||
onToggleTag,
|
|
||||||
onClear,
|
|
||||||
hasFilters,
|
|
||||||
}) => (
|
|
||||||
<div className="filter-bar">
|
|
||||||
<input
|
|
||||||
type="search"
|
|
||||||
placeholder="Search documents"
|
|
||||||
value={query}
|
|
||||||
onChange={(event) => onQueryChange(event.target.value)}
|
|
||||||
/>
|
|
||||||
<div className="tag-filters">
|
|
||||||
{tags.length ? (
|
|
||||||
tags.map((tag) => {
|
|
||||||
const isActive = activeTagIds.includes(tag.id);
|
|
||||||
const style = getTagColorStyle(tag.color);
|
|
||||||
const buttonStyle = style
|
|
||||||
? {
|
|
||||||
...style,
|
|
||||||
opacity: isActive ? 1 : 0.95,
|
|
||||||
boxShadow: isActive ? '0 0 0 1px var(--shadow-soft)' : undefined,
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={tag.id}
|
|
||||||
type="button"
|
|
||||||
className={`tag-filter${isActive ? ' active' : ''}`}
|
|
||||||
onClick={() => onToggleTag(tag.id)}
|
|
||||||
style={buttonStyle}
|
|
||||||
>
|
|
||||||
{tag.label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<span className="meta">No tags yet</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="filter-actions">
|
|
||||||
{hasFilters && (
|
|
||||||
<button type="button" className="secondary" onClick={onClear}>
|
|
||||||
Clear
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const DocumentThumbnailImage = ({ document, ensureAssetUrl, getDocumentAsset, alt }) => {
|
const DocumentThumbnailImage = ({ document, ensureAssetUrl, getDocumentAsset, alt }) => {
|
||||||
const url = useMemo(
|
const url = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -103,6 +49,8 @@ const DocumentsTable = ({
|
|||||||
onFolderDragEnd,
|
onFolderDragEnd,
|
||||||
draggedFolderId,
|
draggedFolderId,
|
||||||
onFolderDelete,
|
onFolderDelete,
|
||||||
|
selectedFolderIds = [],
|
||||||
|
onFolderRowClick,
|
||||||
onDocumentRowClick,
|
onDocumentRowClick,
|
||||||
onDocumentOpen,
|
onDocumentOpen,
|
||||||
selectedDocumentIds,
|
selectedDocumentIds,
|
||||||
@@ -114,7 +62,6 @@ const DocumentsTable = ({
|
|||||||
onDocumentDelete,
|
onDocumentDelete,
|
||||||
onFolderRename,
|
onFolderRename,
|
||||||
onDocumentRename,
|
onDocumentRename,
|
||||||
filterBar,
|
|
||||||
tagLookupById,
|
tagLookupById,
|
||||||
onDocumentListFocus,
|
onDocumentListFocus,
|
||||||
onDocumentListKeyDown,
|
onDocumentListKeyDown,
|
||||||
@@ -131,6 +78,10 @@ const DocumentsTable = ({
|
|||||||
() => new Set(selectedDocumentIds),
|
() => new Set(selectedDocumentIds),
|
||||||
[selectedDocumentIds],
|
[selectedDocumentIds],
|
||||||
);
|
);
|
||||||
|
const selectedFolderSet = useMemo(
|
||||||
|
() => new Set(selectedFolderIds || []),
|
||||||
|
[selectedFolderIds],
|
||||||
|
);
|
||||||
const draggingSet = useMemo(
|
const draggingSet = useMemo(
|
||||||
() => new Set(draggingDocumentIds || []),
|
() => new Set(draggingDocumentIds || []),
|
||||||
[draggingDocumentIds],
|
[draggingDocumentIds],
|
||||||
@@ -235,7 +186,6 @@ const DocumentsTable = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="column-body">
|
<div className="column-body">
|
||||||
<div className="column-toolbar">{filterBar}</div>
|
|
||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
className="documents-scroll"
|
className="documents-scroll"
|
||||||
@@ -275,20 +225,33 @@ const DocumentsTable = ({
|
|||||||
subfolders.map((folder) => {
|
subfolders.map((folder) => {
|
||||||
const canDragFolder = folder.id !== 'root';
|
const canDragFolder = folder.id !== 'root';
|
||||||
const isDraggingFolder = draggedFolderId === folder.id;
|
const isDraggingFolder = draggedFolderId === folder.id;
|
||||||
|
const isSelectedFolder = selectedFolderSet.has(folder.id);
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={folder.id}
|
key={folder.id}
|
||||||
className={`folder${isDraggingFolder ? ' is-dragging' : ''}${
|
className={`folder${isDraggingFolder ? ' is-dragging' : ''}${
|
||||||
focusedRowKey === `folder:${folder.id}` ? ' focused' : ''
|
focusedRowKey === `folder:${folder.id}` ? ' focused' : ''
|
||||||
}`}
|
}${isSelectedFolder ? ' selected' : ''}`}
|
||||||
id={`folder-row-${folder.id}`}
|
id={`folder-row-${folder.id}`}
|
||||||
onClick={() => {
|
onClick={(event) => {
|
||||||
onFolderSelect(folder.id);
|
onFolderRowClick?.(folder.id, event);
|
||||||
|
const shouldNavigate =
|
||||||
|
!event.defaultPrevented &&
|
||||||
|
!event.metaKey &&
|
||||||
|
!event.ctrlKey &&
|
||||||
|
!event.shiftKey;
|
||||||
|
if (shouldNavigate) {
|
||||||
|
onFolderSelect(folder.id);
|
||||||
|
}
|
||||||
if (scrollRef.current) {
|
if (scrollRef.current) {
|
||||||
scrollRef.current.focus({ preventScroll: true });
|
scrollRef.current.focus({ preventScroll: true });
|
||||||
}
|
}
|
||||||
onFocusedRowChange?.(`folder:${folder.id}`);
|
onFocusedRowChange?.(`folder:${folder.id}`);
|
||||||
}}
|
}}
|
||||||
|
onDoubleClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onFolderSelect(folder.id);
|
||||||
|
}}
|
||||||
onDragOver={(event) => onFolderDragOver(event, folder.id)}
|
onDragOver={(event) => onFolderDragOver(event, folder.id)}
|
||||||
onDragLeave={onFolderDragLeave}
|
onDragLeave={onFolderDragLeave}
|
||||||
onDrop={(event) => onFolderDrop(event, folder.id)}
|
onDrop={(event) => onFolderDrop(event, folder.id)}
|
||||||
@@ -413,36 +376,36 @@ const DocumentsTable = ({
|
|||||||
{(doc.tags || []).length > 0 && (
|
{(doc.tags || []).length > 0 && (
|
||||||
<div className="doc-name__tags">
|
<div className="doc-name__tags">
|
||||||
{(doc.tags || []).map((tag) => {
|
{(doc.tags || []).map((tag) => {
|
||||||
const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color;
|
const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color;
|
||||||
const style = getTagColorStyle(colorSource);
|
const style = getTagColorStyle(colorSource);
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
key={tag.id}
|
key={tag.id}
|
||||||
className="badge tag-chip"
|
className="badge tag-chip"
|
||||||
style={style || undefined}
|
style={style || undefined}
|
||||||
title={tag.label}
|
title={tag.label}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
|
||||||
if (onTagClick) {
|
|
||||||
onTagClick(tag.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (onTagClick) {
|
if (onTagClick) {
|
||||||
onTagClick(tag.id);
|
onTagClick(tag.id);
|
||||||
}
|
}
|
||||||
}
|
}}
|
||||||
}}
|
role="button"
|
||||||
>
|
tabIndex={0}
|
||||||
{tag.label}
|
onKeyDown={(event) => {
|
||||||
</span>
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
);
|
event.preventDefault();
|
||||||
})}
|
event.stopPropagation();
|
||||||
|
if (onTagClick) {
|
||||||
|
onTagClick(tag.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tag.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -504,4 +467,4 @@ const DocumentsTable = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default DocumentsTable;
|
export default DocumentsTable;
|
||||||
export { FilterBar, DocumentThumbnailImage };
|
export { DocumentThumbnailImage };
|
||||||
|
|||||||
+784
-361
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback } from 'react';
|
import React, { useCallback, useMemo } from 'react';
|
||||||
import { useMatch } from 'react-router-dom';
|
import { useMatch } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
ChevronIcon,
|
ChevronIcon,
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
CorrespondentIcon,
|
CorrespondentIcon,
|
||||||
EditIcon,
|
EditIcon,
|
||||||
} from '../ui/icons';
|
} from '../ui/icons';
|
||||||
|
import { getTagColorStyle } from '../utils/colors';
|
||||||
|
|
||||||
const FolderNode = ({
|
const FolderNode = ({
|
||||||
node,
|
node,
|
||||||
@@ -138,8 +139,12 @@ const Sidebar = ({
|
|||||||
draggedFolderId,
|
draggedFolderId,
|
||||||
onShowTags,
|
onShowTags,
|
||||||
tags = [],
|
tags = [],
|
||||||
|
activeTagIds = [],
|
||||||
|
onToggleTagFilter,
|
||||||
onShowCorrespondents,
|
onShowCorrespondents,
|
||||||
correspondents = [],
|
correspondents = [],
|
||||||
|
activeCorrespondentIds = [],
|
||||||
|
onToggleCorrespondentFilter,
|
||||||
}) => {
|
}) => {
|
||||||
const handleShowTags = onShowTags || (() => {});
|
const handleShowTags = onShowTags || (() => {});
|
||||||
const tagsRouteMatch = useMatch('/tags');
|
const tagsRouteMatch = useMatch('/tags');
|
||||||
@@ -147,6 +152,19 @@ const Sidebar = ({
|
|||||||
const handleShowCorrespondents = onShowCorrespondents || (() => {});
|
const handleShowCorrespondents = onShowCorrespondents || (() => {});
|
||||||
const correspondentsRouteMatch = useMatch('/correspondents');
|
const correspondentsRouteMatch = useMatch('/correspondents');
|
||||||
const isCorrespondentsRoute = Boolean(correspondentsRouteMatch);
|
const isCorrespondentsRoute = Boolean(correspondentsRouteMatch);
|
||||||
|
const sortedCorrespondents = useMemo(
|
||||||
|
() =>
|
||||||
|
[...correspondents].sort((a, b) =>
|
||||||
|
(a?.name || '').localeCompare(b?.name || '', undefined, { sensitivity: 'base' }),
|
||||||
|
),
|
||||||
|
[correspondents],
|
||||||
|
);
|
||||||
|
const activeCorrespondentSet = useMemo(
|
||||||
|
() => new Set(activeCorrespondentIds || []),
|
||||||
|
[activeCorrespondentIds],
|
||||||
|
);
|
||||||
|
const handleToggleTag = onToggleTagFilter || (() => {});
|
||||||
|
const activeTagSet = new Set(activeTagIds);
|
||||||
|
|
||||||
const renderNodes = useCallback(
|
const renderNodes = useCallback(
|
||||||
(ids, depth) =>
|
(ids, depth) =>
|
||||||
@@ -221,6 +239,30 @@ const Sidebar = ({
|
|||||||
<TagIcon className="sidebar-item__icon" />
|
<TagIcon className="sidebar-item__icon" />
|
||||||
<span>All tags</span>
|
<span>All tags</span>
|
||||||
</span>
|
</span>
|
||||||
|
<div className="sidebar-tag-cloud" role="list">
|
||||||
|
{tags.length ? (
|
||||||
|
tags.map((tag) => {
|
||||||
|
const isActive = activeTagSet.has(tag.id);
|
||||||
|
const style = getTagColorStyle(tag.color);
|
||||||
|
const className = `sidebar-tag-pill${isActive ? ' active' : ''}`;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tag.id}
|
||||||
|
type="button"
|
||||||
|
role="listitem"
|
||||||
|
className={className}
|
||||||
|
style={style || undefined}
|
||||||
|
onClick={() => handleToggleTag(tag.id)}
|
||||||
|
aria-pressed={isActive}
|
||||||
|
>
|
||||||
|
{tag.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<span className="meta">No tags yet</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="sidebar-section">
|
<div className="sidebar-section">
|
||||||
<div className="sidebar-section__header">
|
<div className="sidebar-section__header">
|
||||||
@@ -231,10 +273,14 @@ const Sidebar = ({
|
|||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
className={`sidebar-item${isCorrespondentsRoute ? ' active' : ''}`}
|
className={`sidebar-item${isCorrespondentsRoute ? ' active' : ''}`}
|
||||||
onClick={handleShowCorrespondents}
|
onClick={() => {
|
||||||
|
onToggleCorrespondentFilter?.(null);
|
||||||
|
handleShowCorrespondents();
|
||||||
|
}}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
onToggleCorrespondentFilter?.(null);
|
||||||
handleShowCorrespondents();
|
handleShowCorrespondents();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -242,6 +288,39 @@ const Sidebar = ({
|
|||||||
<CorrespondentIcon className="sidebar-item__icon" />
|
<CorrespondentIcon className="sidebar-item__icon" />
|
||||||
<span>All correspondents</span>
|
<span>All correspondents</span>
|
||||||
</span>
|
</span>
|
||||||
|
<ul className="sidebar-correspondent-list">
|
||||||
|
{sortedCorrespondents.length ? (
|
||||||
|
sortedCorrespondents.map((correspondent) => {
|
||||||
|
const isActive = activeCorrespondentSet.has(correspondent.id);
|
||||||
|
const className = `sidebar-correspondent-item${isActive ? ' active' : ''}`;
|
||||||
|
const label = correspondent.name || 'Unnamed';
|
||||||
|
const handleSelect = () => {
|
||||||
|
const nextId = isActive ? null : correspondent.id;
|
||||||
|
onToggleCorrespondentFilter?.(nextId);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<li key={correspondent.id}>
|
||||||
|
<span
|
||||||
|
className={className}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={handleSelect}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
handleSelect();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<li className="meta">No correspondents yet</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
+151
-28
@@ -221,14 +221,16 @@ button.icon-button.ghost:hover:not([disabled]) {
|
|||||||
|
|
||||||
.app-bar__main {
|
.app-bar__main {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-bar__meta {
|
.app-bar__meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.2rem;
|
gap: 0.2rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-bar h1 {
|
.app-bar h1 {
|
||||||
@@ -257,6 +259,52 @@ button.icon-button.ghost:hover:not([disabled]) {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-bar__search {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex: 1 1 320px;
|
||||||
|
max-width: 420px;
|
||||||
|
min-width: 240px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-bar__search input[type='search'] {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 180px;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface-subtle);
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-bar__search input[type='search']:focus {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-bar__search-clear {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.2rem 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-bar__search-clear:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-bar__right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.app-main {
|
.app-main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -363,6 +411,34 @@ button.icon-button.ghost:hover:not([disabled]) {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.document-drag-preview__folder-thumb {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-drag-preview__folder-thumb svg {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
color: var(--accent-strong, var(--accent));
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-drag-preview__folder-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
.document-drag-preview__count {
|
.document-drag-preview__count {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 4px;
|
bottom: 4px;
|
||||||
@@ -852,6 +928,80 @@ button.icon-button.ghost:hover:not([disabled]) {
|
|||||||
color: currentColor;
|
color: currentColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-tag-cloud {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.25rem 0 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tag-pill {
|
||||||
|
border: 1px solid var(--sidebar-pill-border, rgba(15, 23, 42, 0.08));
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--surface-soft);
|
||||||
|
color: var(--fg);
|
||||||
|
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
|
||||||
|
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tag-pill:hover {
|
||||||
|
transform: none;
|
||||||
|
box-shadow: 0 2px 6px rgba(15, 23, 42, 0.16);
|
||||||
|
border-color: var(--accent-soft, rgba(59, 130, 246, 0.35));
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tag-pill:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tag-pill.active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 1px var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-correspondent-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0.4rem 0 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-correspondent-item {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
text-align: left;
|
||||||
|
color: var(--fg);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
transition: background 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-correspondent-item:hover {
|
||||||
|
background: var(--sidebar-hover-bg);
|
||||||
|
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-correspondent-item:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-correspondent-item.active {
|
||||||
|
background: var(--accent-soft, rgba(59, 130, 246, 0.18));
|
||||||
|
box-shadow: 0 0 0 1px var(--accent-soft, rgba(59, 130, 246, 0.24));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.documents-panel {
|
.documents-panel {
|
||||||
padding: 1.25rem 1.25rem 0 1.25rem;
|
padding: 1.25rem 1.25rem 0 1.25rem;
|
||||||
@@ -893,7 +1043,6 @@ button.icon-button.ghost:hover:not([disabled]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.documents-panel .documents-scroll {
|
.documents-panel .documents-scroll {
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -1064,32 +1213,6 @@ button.icon-button.ghost:hover:not([disabled]) {
|
|||||||
min-width: 180px;
|
min-width: 180px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag-filters {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.32rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-filter {
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
background: var(--surface-subtle);
|
|
||||||
color: var(--fg);
|
|
||||||
padding: 0.25rem 0.6rem;
|
|
||||||
border-radius: 2px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
transition: transform 0.12s ease, box-shadow 0.12s ease, filter 0.12s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-filter:hover {
|
|
||||||
filter: brightness(0.97);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-filter.active {
|
|
||||||
box-shadow: 0 0 0 1px currentColor inset;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-actions {
|
.filter-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.32rem;
|
gap: 0.32rem;
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { generateRandomTagColor } from './utils/colors';
|
||||||
|
|
||||||
|
class TagManager {
|
||||||
|
constructor({ colorGenerator = generateRandomTagColor } = {}) {
|
||||||
|
this.colorGenerator = typeof colorGenerator === 'function' ? colorGenerator : generateRandomTagColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizeLabel(label) {
|
||||||
|
if (typeof label !== 'string') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return label.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
buildPayload({ label, color } = {}) {
|
||||||
|
const normalizedLabel = this.normalizeLabel(label);
|
||||||
|
if (!normalizedLabel) {
|
||||||
|
throw new Error('Tag label is required.');
|
||||||
|
}
|
||||||
|
const payload = { label: normalizedLabel };
|
||||||
|
const trimmedColor = typeof color === 'string' && color.trim().length ? color.trim() : null;
|
||||||
|
payload.color = trimmedColor || this.colorGenerator();
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TagManager;
|
||||||
@@ -46,6 +46,10 @@ app.kubernetes.io/instance: {{ .Release.Name }}
|
|||||||
{{- include "papercrate.image" (dict "Values" .Values "image" .Values.backend.image) -}}
|
{{- include "papercrate.image" (dict "Values" .Values "image" .Values.backend.image) -}}
|
||||||
{{- end -}}
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- define "papercrate.webdav.image" -}}
|
||||||
|
{{- include "papercrate.image" (dict "Values" .Values "image" .Values.webdav.image) -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
{{- define "papercrate.frontend.image" -}}
|
{{- define "papercrate.frontend.image" -}}
|
||||||
{{- include "papercrate.image" (dict "Values" .Values "image" .Values.frontend.image) -}}
|
{{- include "papercrate.image" (dict "Values" .Values "image" .Values.frontend.image) -}}
|
||||||
{{- end -}}
|
{{- end -}}
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
{{- if .Values.webdav.enabled }}
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: {{ include "papercrate.fullname" . }}-webdav
|
||||||
|
labels:
|
||||||
|
{{- include "papercrate.labels" . | nindent 4 }}
|
||||||
|
app.kubernetes.io/component: webdav
|
||||||
|
spec:
|
||||||
|
replicas: {{ .Values.webdav.replicaCount }}
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
{{- include "papercrate.selectorLabels" . | nindent 6 }}
|
||||||
|
app.kubernetes.io/component: webdav
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
{{- include "papercrate.selectorLabels" . | nindent 8 }}
|
||||||
|
app.kubernetes.io/component: webdav
|
||||||
|
spec:
|
||||||
|
{{- if or .Values.global.imagePullSecrets .Values.imagePullSecrets }}
|
||||||
|
imagePullSecrets:
|
||||||
|
{{- range .Values.global.imagePullSecrets }}
|
||||||
|
- name: {{ . }}
|
||||||
|
{{- end }}
|
||||||
|
{{- range .Values.imagePullSecrets }}
|
||||||
|
- name: {{ . }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
containers:
|
||||||
|
- name: webdav
|
||||||
|
image: {{ include "papercrate.webdav.image" . }}:{{ include "papercrate.image.tag" (dict "image" .Values.webdav.image "Chart" .Chart) }}
|
||||||
|
imagePullPolicy: {{ .Values.webdav.image.pullPolicy }}
|
||||||
|
command:
|
||||||
|
- /usr/local/bin/papercrate-webdav
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
containerPort: {{ .Values.webdav.service.port }}
|
||||||
|
protocol: TCP
|
||||||
|
env:
|
||||||
|
- name: SERVER_HOST
|
||||||
|
value: 0.0.0.0
|
||||||
|
- name: SERVER_PORT
|
||||||
|
value: {{ .Values.webdav.service.port | quote }}
|
||||||
|
- name: WEBDAV_HOST
|
||||||
|
value: 0.0.0.0
|
||||||
|
- name: WEBDAV_PORT
|
||||||
|
value: {{ .Values.webdav.service.port | quote }}
|
||||||
|
{{- with .Values.global.s3.endpoint }}
|
||||||
|
- name: AWS_ENDPOINT_URL
|
||||||
|
value: {{ . | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.global.s3.region }}
|
||||||
|
- name: AWS_REGION
|
||||||
|
value: {{ . | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.global.s3.bucket }}
|
||||||
|
- name: S3_BUCKET
|
||||||
|
value: {{ . | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- range .Values.webdav.env.extra }}
|
||||||
|
- name: {{ .name }}
|
||||||
|
value: {{ .value | quote }}
|
||||||
|
{{- end }}
|
||||||
|
envFrom:
|
||||||
|
- secretRef:
|
||||||
|
name: {{ if .Values.webdav.env.existingSecret }}{{ .Values.webdav.env.existingSecret }}{{ else }}{{ include "papercrate.fullname" . }}-backend-env{{ end }}
|
||||||
|
{{- with .Values.global.s3.credentialsSecret }}
|
||||||
|
- secretRef:
|
||||||
|
name: {{ . }}
|
||||||
|
{{- end }}
|
||||||
|
{{- range .Values.webdav.envFrom }}
|
||||||
|
- {{ toYaml . | nindent 12 | trim }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.webdav.resources }}
|
||||||
|
resources:
|
||||||
|
{{- toYaml . | nindent 12 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.webdav.nodeSelector }}
|
||||||
|
nodeSelector:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.webdav.affinity }}
|
||||||
|
affinity:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.webdav.tolerations }}
|
||||||
|
tolerations:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{{- if .Values.webdav.enabled }}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: {{ include "papercrate.fullname" . }}-webdav
|
||||||
|
labels:
|
||||||
|
{{- include "papercrate.labels" . | nindent 4 }}
|
||||||
|
app.kubernetes.io/component: webdav
|
||||||
|
spec:
|
||||||
|
type: {{ .Values.webdav.service.type }}
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
port: {{ .Values.webdav.service.port }}
|
||||||
|
targetPort: http
|
||||||
|
protocol: TCP
|
||||||
|
selector:
|
||||||
|
{{- include "papercrate.selectorLabels" . | nindent 4 }}
|
||||||
|
app.kubernetes.io/component: webdav
|
||||||
|
{{- end }}
|
||||||
@@ -28,6 +28,25 @@ backend:
|
|||||||
tolerations: []
|
tolerations: []
|
||||||
affinity: {}
|
affinity: {}
|
||||||
|
|
||||||
|
webdav:
|
||||||
|
enabled: true
|
||||||
|
replicaCount: 1
|
||||||
|
image:
|
||||||
|
repository: registry.example.com/papercrate-backend
|
||||||
|
tag: ""
|
||||||
|
pullPolicy: IfNotPresent
|
||||||
|
service:
|
||||||
|
type: ClusterIP
|
||||||
|
port: 3001
|
||||||
|
env:
|
||||||
|
existingSecret: ""
|
||||||
|
extra: []
|
||||||
|
envFrom: []
|
||||||
|
resources: {}
|
||||||
|
nodeSelector: {}
|
||||||
|
tolerations: []
|
||||||
|
affinity: {}
|
||||||
|
|
||||||
worker:
|
worker:
|
||||||
replicaCount: 1
|
replicaCount: 1
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|||||||
Reference in New Issue
Block a user