a lot of stuff
This commit is contained in:
Generated
+53
-19
@@ -605,11 +605,13 @@ dependencies = [
|
||||
"aws-sdk-s3",
|
||||
"axum",
|
||||
"axum-extra",
|
||||
"base64 0.21.7",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"diesel",
|
||||
"diesel_migrations",
|
||||
"dotenv",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"http-body-util",
|
||||
"hyper 1.7.0",
|
||||
@@ -619,6 +621,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"pdfium-render",
|
||||
"percent-encoding",
|
||||
"quick-xml",
|
||||
"rand 0.8.5",
|
||||
"reqwest",
|
||||
"serde",
|
||||
@@ -628,7 +631,7 @@ dependencies = [
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tower 0.4.13",
|
||||
"tower-http 0.5.2",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
@@ -1269,6 +1272,23 @@ version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.31"
|
||||
@@ -1288,9 +1308,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-macro",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"memchr",
|
||||
"pin-project-lite",
|
||||
"pin-utils",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2354,6 +2379,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "quinn"
|
||||
version = "0.11.9"
|
||||
@@ -2547,6 +2581,7 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http 1.3.1",
|
||||
"http-body 1.0.1",
|
||||
"http-body-util",
|
||||
@@ -2566,12 +2601,14 @@ dependencies = [
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls 0.26.4",
|
||||
"tokio-util",
|
||||
"tower 0.5.2",
|
||||
"tower-http 0.6.6",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
]
|
||||
@@ -3320,23 +3357,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "tower-http"
|
||||
version = "0.6.6"
|
||||
@@ -3353,6 +3373,7 @@ dependencies = [
|
||||
"tower 0.5.2",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3642,6 +3663,19 @@ dependencies = [
|
||||
"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]]
|
||||
name = "web-sys"
|
||||
version = "0.3.81"
|
||||
|
||||
+5
-2
@@ -8,7 +8,7 @@ edition = "2021"
|
||||
axum = { version = "0.7", features = ["multipart"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
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"] }
|
||||
|
||||
# Database
|
||||
@@ -38,8 +38,11 @@ image = { version = "0.25", default-features = false, features = ["png", "jpeg"]
|
||||
pdfium-render = "0.8"
|
||||
mime_guess = "2.0"
|
||||
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"
|
||||
base64 = "0.21"
|
||||
quick-xml = "0.32"
|
||||
futures-util = "0.3"
|
||||
|
||||
# Error handling
|
||||
thiserror = "1.0"
|
||||
|
||||
+2
-1
@@ -20,7 +20,7 @@ COPY migrations ./migrations
|
||||
COPY tests ./tests
|
||||
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
|
||||
|
||||
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/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 migrations ./migrations
|
||||
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 server_host: String,
|
||||
pub server_port: u16,
|
||||
pub webdav_host: String,
|
||||
pub webdav_port: u16,
|
||||
pub jwt_secret: String,
|
||||
pub jwt_issuer: String,
|
||||
pub jwt_audience: String,
|
||||
@@ -34,6 +36,11 @@ impl AppConfig {
|
||||
.unwrap_or_else(|_| "3000".to_string())
|
||||
.parse()
|
||||
.context("SERVER_PORT must be a valid u16")?;
|
||||
let webdav_host = env::var("WEBDAV_HOST").unwrap_or_else(|_| server_host.clone());
|
||||
let webdav_port = env::var("WEBDAV_PORT")
|
||||
.unwrap_or_else(|_| "3001".to_string())
|
||||
.parse()
|
||||
.context("WEBDAV_PORT must be a valid u16")?;
|
||||
let jwt_secret = env::var("JWT_SECRET").context("JWT_SECRET must be set")?;
|
||||
let jwt_issuer = env::var("JWT_ISSUER").unwrap_or_else(|_| "papercrate".to_string());
|
||||
let jwt_audience =
|
||||
@@ -69,6 +76,8 @@ impl AppConfig {
|
||||
database_url,
|
||||
server_host,
|
||||
server_port,
|
||||
webdav_host,
|
||||
webdav_port,
|
||||
jwt_secret,
|
||||
jwt_issuer,
|
||||
jwt_audience,
|
||||
|
||||
+202
-34
@@ -205,6 +205,12 @@ pub struct BulkTagResponse {
|
||||
pub removed: usize,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct BulkCorrespondentResponse {
|
||||
pub assigned: usize,
|
||||
pub removed: usize,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CorrespondentAssignmentInput {
|
||||
pub correspondent_id: Uuid,
|
||||
@@ -218,6 +224,69 @@ pub struct AssignCorrespondentsRequest {
|
||||
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)]
|
||||
pub struct CorrespondentRoleQuery {
|
||||
pub role: String,
|
||||
@@ -850,40 +919,8 @@ pub async fn assign_correspondents(
|
||||
return Err(AppError::bad_request("assignments must not be empty"));
|
||||
}
|
||||
|
||||
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 &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 (normalized_pairs, correspondents_vec, roles_vec) =
|
||||
normalize_correspondent_assignments(&payload.assignments)?;
|
||||
let replace = payload.replace;
|
||||
let user_id = user.user_id;
|
||||
|
||||
@@ -950,6 +987,137 @@ pub async fn assign_correspondents(
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
Path((document_id, correspondent_id)): Path<(Uuid, Uuid)>,
|
||||
|
||||
@@ -11,7 +11,7 @@ use std::collections::{HashMap, HashSet};
|
||||
use uuid::Uuid;
|
||||
|
||||
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::{
|
||||
auth::AuthenticatedUser,
|
||||
@@ -60,6 +60,7 @@ pub struct FolderContentsResponse {
|
||||
pub struct DocumentSearchQuery {
|
||||
pub query: Option<String>,
|
||||
pub tags: Option<String>,
|
||||
pub correspondents: Option<String>,
|
||||
}
|
||||
|
||||
#[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 set.is_empty() {
|
||||
return Ok(Json(vec![]));
|
||||
|
||||
@@ -15,6 +15,7 @@ pub mod documents;
|
||||
pub mod folders;
|
||||
pub mod health;
|
||||
pub mod tags;
|
||||
pub mod webdav;
|
||||
|
||||
pub fn create_router(state: AppState) -> Router<()> {
|
||||
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("/bulk/move", post(documents::bulk_move_documents))
|
||||
.route("/bulk/tags", post(documents::bulk_update_tags))
|
||||
.route(
|
||||
"/bulk/correspondents",
|
||||
post(documents::bulk_assign_correspondents),
|
||||
)
|
||||
.route(
|
||||
"/bulk/reanalyze",
|
||||
post(documents::reanalyze_selected_documents),
|
||||
@@ -125,9 +130,9 @@ pub fn create_router(state: AppState) -> Router<()> {
|
||||
|
||||
Router::new()
|
||||
.merge(download_routes)
|
||||
.merge(protected_routes)
|
||||
.nest("/api/auth", auth_routes)
|
||||
.route("/api/health", get(health::health_check))
|
||||
.merge(protected_routes)
|
||||
.with_state(state)
|
||||
.layer(cors)
|
||||
.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>,
|
||||
tags: Vec<TagSummary>,
|
||||
#[serde(default)]
|
||||
correspondents: Vec<DocumentCorrespondentInfo>,
|
||||
#[serde(default)]
|
||||
current_version: Option<DocumentVersion>,
|
||||
}
|
||||
|
||||
@@ -75,6 +77,23 @@ struct TagSummary {
|
||||
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)]
|
||||
struct AnalyzeJobPayload {
|
||||
document_id: Uuid,
|
||||
@@ -157,12 +176,12 @@ async fn upload_and_list_document() -> Result<()> {
|
||||
assert_eq!(detail.document.deleted_at, None);
|
||||
assert!(detail.document.issued_at.is_none());
|
||||
assert!(detail.document.tags.is_empty());
|
||||
assert!(current_version.download_path.starts_with("/download/"));
|
||||
let current_version = detail
|
||||
.document
|
||||
.current_version
|
||||
.as_ref()
|
||||
.expect("current version detail");
|
||||
assert!(current_version.download_path.starts_with("/download/"));
|
||||
assert_eq!(current_version.version_number, 1);
|
||||
assert_eq!(current_version.size_bytes, file_bytes.len() as i64);
|
||||
assert!(current_version.assets.is_empty());
|
||||
@@ -616,6 +635,234 @@ async fn bulk_update_tags_for_selection() -> Result<()> {
|
||||
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]
|
||||
async fn bulk_reanalyze_selected_documents() -> Result<()> {
|
||||
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/bulk/move - Move multiple documents to a target folder.
|
||||
- POST /api/documents/bulk/tags - Add or remove tags across multiple documents.
|
||||
- POST /api/documents/bulk/correspondents - Bulk correspondent actions. Default `action=add` replaces existing assignments for the provided roles before adding the supplied correspondents; `action=remove` drops the specified correspondent/role pairs.
|
||||
- POST /api/documents/bulk/reanalyze - Queue re-analysis jobs for selected documents.
|
||||
- GET /api/documents/:id - Retrieve metadata and current version details for a document.
|
||||
- PATCH /api/documents/:id - Update document metadata (currently title).
|
||||
|
||||
+329
-200
@@ -7,6 +7,178 @@ import { CORRESPONDENT_ROLES } from '../constants/correspondents';
|
||||
|
||||
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) => {
|
||||
if (index === 0) return 0;
|
||||
let hash = 0;
|
||||
@@ -95,7 +267,6 @@ const DetailPanel = ({
|
||||
selectedDocuments = [],
|
||||
tags = [],
|
||||
tagLookupById = new Map(),
|
||||
tagLookupByLabel = new Map(),
|
||||
onTagAdd,
|
||||
onTagRemove,
|
||||
onRegenerateThumbnails,
|
||||
@@ -103,10 +274,9 @@ const DetailPanel = ({
|
||||
onOpenPreview,
|
||||
onBulkTagAdd,
|
||||
onBulkTagRemove,
|
||||
onBulkMove,
|
||||
onBulkReanalyze,
|
||||
folderOptions = [],
|
||||
defaultMoveTarget = 'root',
|
||||
onBulkCorrespondentAdd,
|
||||
onBulkCorrespondentRemove,
|
||||
onPromoteSelection,
|
||||
activePreviewId = null,
|
||||
onUpdateTitle = async () => false,
|
||||
@@ -257,20 +427,25 @@ const DetailPanel = ({
|
||||
});
|
||||
}, [stackDocuments, ensureAssetUrl, getDocumentAsset]);
|
||||
|
||||
const commonTags = useMemo(() => {
|
||||
if (selectedCount < 2) return [];
|
||||
const tagSets = selectedDocuments.map((doc) => new Set((doc.tags || []).map((tag) => tag.label)));
|
||||
if (!tagSets.length) return [];
|
||||
const intersection = new Set(tagSets[0]);
|
||||
tagSets.slice(1).forEach((set) => {
|
||||
[...intersection].forEach((label) => {
|
||||
if (!set.has(label)) {
|
||||
intersection.delete(label);
|
||||
const bulkTagUnion = useMemo(() => {
|
||||
if (!selectedDocuments.length) return [];
|
||||
const tagMap = new Map();
|
||||
selectedDocuments.forEach((doc) => {
|
||||
(doc.tags || []).forEach((tag) => {
|
||||
const label = (tag?.label || '').trim();
|
||||
if (!label) return;
|
||||
if (!tagMap.has(label)) {
|
||||
const fallback = tagLookupById.get(tag.id) || {};
|
||||
tagMap.set(label, {
|
||||
id: tag.id,
|
||||
label,
|
||||
color: tag.color || fallback.color,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
return [...intersection];
|
||||
}, [selectedDocuments, selectedCount]);
|
||||
return [...tagMap.values()].sort((a, b) => a.label.localeCompare(b.label));
|
||||
}, [selectedDocuments, tagLookupById]);
|
||||
|
||||
const stackTotalSizeBytes = useMemo(() => {
|
||||
if (!stackPreviews.length) return 0;
|
||||
@@ -302,6 +477,87 @@ const DetailPanel = ({
|
||||
});
|
||||
}, [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 = () => {
|
||||
if (!singleDoc) {
|
||||
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 metadata =
|
||||
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 (
|
||||
<>
|
||||
<div>
|
||||
@@ -448,119 +689,39 @@ const DetailPanel = ({
|
||||
Re-run analysis
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Tags</dt>
|
||||
<div className="tag-list">
|
||||
{tagsForDoc.length ? (
|
||||
tagsForDoc.map((tag) => {
|
||||
const colorSource = tag?.color || tagLookupById.get(tag.id)?.color;
|
||||
const style = getTagColorStyle(colorSource);
|
||||
return (
|
||||
<span key={tag.id} className="tag-pill" style={style || undefined}>
|
||||
{tag.label}{' '}
|
||||
<button type="button" onClick={() => onTagRemove(singleDoc.id, tag.id)}>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<span className="meta">No tags yet.</span>
|
||||
)}
|
||||
</div>
|
||||
<form
|
||||
className="inline"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
const input = event.currentTarget.elements.tag;
|
||||
const value = input.value.trim();
|
||||
if (!value) return;
|
||||
onTagAdd(singleDoc, value, input);
|
||||
}}
|
||||
>
|
||||
<input name="tag" placeholder="Add or create tag" list="tag-catalog" />
|
||||
<button type="submit">Add</button>
|
||||
<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>
|
||||
<TagSection
|
||||
title="Tags"
|
||||
tags={tagsForDoc.map((tag) => ({
|
||||
id: tag.id,
|
||||
label: tag.label,
|
||||
color: tag.color || tagLookupById.get(tag.id)?.color,
|
||||
}))}
|
||||
onRemove={(tag) => onTagRemove(singleDoc.id, tag.id)}
|
||||
onAdd={({ value, input }) => onTagAdd(singleDoc, value, input)}
|
||||
datalistId="tag-catalog-single"
|
||||
datalistOptions={tags}
|
||||
/>
|
||||
<CorrespondentSection
|
||||
title="Correspondents"
|
||||
entries={singleCorrespondents}
|
||||
onRemove={(entry) =>
|
||||
onCorrespondentRemove?.({
|
||||
documentId: singleDoc.id,
|
||||
correspondentId: entry.id,
|
||||
role: entry.role,
|
||||
})
|
||||
}
|
||||
onAdd={({ name, role, input }) =>
|
||||
onCorrespondentAdd?.({
|
||||
document: singleDoc,
|
||||
name,
|
||||
role,
|
||||
input,
|
||||
})
|
||||
}
|
||||
datalistId="correspondent-catalog-single"
|
||||
datalistOptions={correspondentOptions}
|
||||
/>
|
||||
{metadata && (
|
||||
<div>
|
||||
<dt>Metadata</dt>
|
||||
@@ -593,64 +754,32 @@ const DetailPanel = ({
|
||||
<div>
|
||||
<strong>Total size (stack):</strong> {sizeLabel}
|
||||
</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>
|
||||
<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
|
||||
type="button"
|
||||
className="secondary"
|
||||
|
||||
@@ -3,60 +3,6 @@ import { resolveDocumentAssetUrl } from '../asset_manager';
|
||||
import { getTagColorStyle } from '../utils/colors';
|
||||
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 url = useMemo(
|
||||
() =>
|
||||
@@ -103,6 +49,8 @@ const DocumentsTable = ({
|
||||
onFolderDragEnd,
|
||||
draggedFolderId,
|
||||
onFolderDelete,
|
||||
selectedFolderIds = [],
|
||||
onFolderRowClick,
|
||||
onDocumentRowClick,
|
||||
onDocumentOpen,
|
||||
selectedDocumentIds,
|
||||
@@ -114,7 +62,6 @@ const DocumentsTable = ({
|
||||
onDocumentDelete,
|
||||
onFolderRename,
|
||||
onDocumentRename,
|
||||
filterBar,
|
||||
tagLookupById,
|
||||
onDocumentListFocus,
|
||||
onDocumentListKeyDown,
|
||||
@@ -131,6 +78,10 @@ const DocumentsTable = ({
|
||||
() => new Set(selectedDocumentIds),
|
||||
[selectedDocumentIds],
|
||||
);
|
||||
const selectedFolderSet = useMemo(
|
||||
() => new Set(selectedFolderIds || []),
|
||||
[selectedFolderIds],
|
||||
);
|
||||
const draggingSet = useMemo(
|
||||
() => new Set(draggingDocumentIds || []),
|
||||
[draggingDocumentIds],
|
||||
@@ -235,7 +186,6 @@ const DocumentsTable = ({
|
||||
</div>
|
||||
</div>
|
||||
<div className="column-body">
|
||||
<div className="column-toolbar">{filterBar}</div>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="documents-scroll"
|
||||
@@ -275,20 +225,33 @@ const DocumentsTable = ({
|
||||
subfolders.map((folder) => {
|
||||
const canDragFolder = folder.id !== 'root';
|
||||
const isDraggingFolder = draggedFolderId === folder.id;
|
||||
const isSelectedFolder = selectedFolderSet.has(folder.id);
|
||||
return (
|
||||
<tr
|
||||
key={folder.id}
|
||||
className={`folder${isDraggingFolder ? ' is-dragging' : ''}${
|
||||
focusedRowKey === `folder:${folder.id}` ? ' focused' : ''
|
||||
}`}
|
||||
}${isSelectedFolder ? ' selected' : ''}`}
|
||||
id={`folder-row-${folder.id}`}
|
||||
onClick={() => {
|
||||
onFolderSelect(folder.id);
|
||||
onClick={(event) => {
|
||||
onFolderRowClick?.(folder.id, event);
|
||||
const shouldNavigate =
|
||||
!event.defaultPrevented &&
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.shiftKey;
|
||||
if (shouldNavigate) {
|
||||
onFolderSelect(folder.id);
|
||||
}
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.focus({ preventScroll: true });
|
||||
}
|
||||
onFocusedRowChange?.(`folder:${folder.id}`);
|
||||
}}
|
||||
onDoubleClick={(event) => {
|
||||
event.preventDefault();
|
||||
onFolderSelect(folder.id);
|
||||
}}
|
||||
onDragOver={(event) => onFolderDragOver(event, folder.id)}
|
||||
onDragLeave={onFolderDragLeave}
|
||||
onDrop={(event) => onFolderDrop(event, folder.id)}
|
||||
@@ -413,36 +376,36 @@ const DocumentsTable = ({
|
||||
{(doc.tags || []).length > 0 && (
|
||||
<div className="doc-name__tags">
|
||||
{(doc.tags || []).map((tag) => {
|
||||
const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color;
|
||||
const style = getTagColorStyle(colorSource);
|
||||
return (
|
||||
<span
|
||||
key={tag.id}
|
||||
className="badge tag-chip"
|
||||
style={style || undefined}
|
||||
title={tag.label}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (onTagClick) {
|
||||
onTagClick(tag.id);
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color;
|
||||
const style = getTagColorStyle(colorSource);
|
||||
return (
|
||||
<span
|
||||
key={tag.id}
|
||||
className="badge tag-chip"
|
||||
style={style || undefined}
|
||||
title={tag.label}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (onTagClick) {
|
||||
onTagClick(tag.id);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{tag.label}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (onTagClick) {
|
||||
onTagClick(tag.id);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{tag.label}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -504,4 +467,4 @@ const 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 {
|
||||
ChevronIcon,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
CorrespondentIcon,
|
||||
EditIcon,
|
||||
} from '../ui/icons';
|
||||
import { getTagColorStyle } from '../utils/colors';
|
||||
|
||||
const FolderNode = ({
|
||||
node,
|
||||
@@ -138,8 +139,12 @@ const Sidebar = ({
|
||||
draggedFolderId,
|
||||
onShowTags,
|
||||
tags = [],
|
||||
activeTagIds = [],
|
||||
onToggleTagFilter,
|
||||
onShowCorrespondents,
|
||||
correspondents = [],
|
||||
activeCorrespondentIds = [],
|
||||
onToggleCorrespondentFilter,
|
||||
}) => {
|
||||
const handleShowTags = onShowTags || (() => {});
|
||||
const tagsRouteMatch = useMatch('/tags');
|
||||
@@ -147,6 +152,19 @@ const Sidebar = ({
|
||||
const handleShowCorrespondents = onShowCorrespondents || (() => {});
|
||||
const correspondentsRouteMatch = useMatch('/correspondents');
|
||||
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(
|
||||
(ids, depth) =>
|
||||
@@ -221,6 +239,30 @@ const Sidebar = ({
|
||||
<TagIcon className="sidebar-item__icon" />
|
||||
<span>All tags</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 className="sidebar-section">
|
||||
<div className="sidebar-section__header">
|
||||
@@ -231,10 +273,14 @@ const Sidebar = ({
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={`sidebar-item${isCorrespondentsRoute ? ' active' : ''}`}
|
||||
onClick={handleShowCorrespondents}
|
||||
onClick={() => {
|
||||
onToggleCorrespondentFilter?.(null);
|
||||
handleShowCorrespondents();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
onToggleCorrespondentFilter?.(null);
|
||||
handleShowCorrespondents();
|
||||
}
|
||||
}}
|
||||
@@ -242,6 +288,39 @@ const Sidebar = ({
|
||||
<CorrespondentIcon className="sidebar-item__icon" />
|
||||
<span>All correspondents</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>
|
||||
</aside>
|
||||
);
|
||||
|
||||
+151
-28
@@ -221,14 +221,16 @@ button.icon-button.ghost:hover:not([disabled]) {
|
||||
|
||||
.app-bar__main {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.app-bar__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.app-bar h1 {
|
||||
@@ -257,6 +259,52 @@ button.icon-button.ghost:hover:not([disabled]) {
|
||||
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 {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
@@ -363,6 +411,34 @@ button.icon-button.ghost:hover:not([disabled]) {
|
||||
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 {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
@@ -852,6 +928,80 @@ button.icon-button.ghost:hover:not([disabled]) {
|
||||
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 {
|
||||
padding: 1.25rem 1.25rem 0 1.25rem;
|
||||
@@ -893,7 +1043,6 @@ button.icon-button.ghost:hover:not([disabled]) {
|
||||
}
|
||||
|
||||
.documents-panel .documents-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
border: none;
|
||||
background: transparent;
|
||||
@@ -1064,32 +1213,6 @@ button.icon-button.ghost:hover:not([disabled]) {
|
||||
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 {
|
||||
display: flex;
|
||||
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) -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "papercrate.webdav.image" -}}
|
||||
{{- include "papercrate.image" (dict "Values" .Values "image" .Values.webdav.image) -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "papercrate.frontend.image" -}}
|
||||
{{- include "papercrate.image" (dict "Values" .Values "image" .Values.frontend.image) -}}
|
||||
{{- 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: []
|
||||
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:
|
||||
replicaCount: 1
|
||||
enabled: true
|
||||
|
||||
Reference in New Issue
Block a user