6 Commits
Author SHA1 Message Date
nils 5c8cd98aac correspondents
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 14m8s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 14m7s
2025-10-15 00:20:54 +02:00
nils 3db221cce4 tag deletion 2025-10-14 23:35:29 +02:00
nils 60939306e7 frontend: size format 2025-10-14 23:23:26 +02:00
nils 80161fe86e frontend 2025-10-14 23:20:42 +02:00
nils 4c36ea2e9a backend: correspondents 2025-10-14 23:20:42 +02:00
nils 32432026db backend: health without auth 2025-10-14 22:37:20 +02:00
18 changed files with 1861 additions and 411 deletions
@@ -0,0 +1,5 @@
DROP INDEX IF EXISTS idx_document_correspondents_role;
DROP INDEX IF EXISTS idx_document_correspondents_correspondent;
DROP INDEX IF EXISTS idx_document_correspondents_document;
DROP TABLE IF EXISTS document_correspondents;
DROP TABLE IF EXISTS correspondents;
@@ -0,0 +1,27 @@
CREATE TABLE correspondents (
id UUID PRIMARY KEY,
name VARCHAR(255) NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT correspondents_name_unique UNIQUE (name)
);
CREATE TABLE document_correspondents (
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
correspondent_id UUID NOT NULL REFERENCES correspondents(id) ON DELETE CASCADE,
role VARCHAR(32) NOT NULL,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(),
assigned_by UUID REFERENCES users(id),
PRIMARY KEY (document_id, correspondent_id, role),
CONSTRAINT document_correspondents_role_check CHECK (role IN ('sender', 'receiver', 'other'))
);
CREATE INDEX idx_document_correspondents_document
ON document_correspondents(document_id);
CREATE INDEX idx_document_correspondents_correspondent
ON document_correspondents(correspondent_id);
CREATE INDEX idx_document_correspondents_role
ON document_correspondents(role);
+40
View File
@@ -188,6 +188,46 @@ pub struct NewDocumentTag {
pub assigned_by: Option<Uuid>, pub assigned_by: Option<Uuid>,
} }
#[derive(Debug, Clone, Queryable, Identifiable)]
#[diesel(table_name = correspondents)]
pub struct Correspondent {
pub id: Uuid,
pub name: String,
pub metadata: serde_json::Value,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
}
#[derive(Debug, Insertable)]
#[diesel(table_name = correspondents)]
pub struct NewCorrespondent {
pub id: Uuid,
pub name: String,
pub metadata: serde_json::Value,
}
#[derive(Debug, Clone, Queryable, Associations)]
#[diesel(table_name = document_correspondents)]
#[diesel(belongs_to(Document))]
#[diesel(belongs_to(Correspondent))]
#[diesel(primary_key(document_id, correspondent_id, role))]
pub struct DocumentCorrespondent {
pub document_id: Uuid,
pub correspondent_id: Uuid,
pub role: String,
pub assigned_at: NaiveDateTime,
pub assigned_by: Option<Uuid>,
}
#[derive(Debug, Insertable)]
#[diesel(table_name = document_correspondents)]
pub struct NewDocumentCorrespondent {
pub document_id: Uuid,
pub correspondent_id: Uuid,
pub role: String,
pub assigned_by: Option<Uuid>,
}
#[derive(Debug, Clone, Queryable, Identifiable, Associations)] #[derive(Debug, Clone, Queryable, Identifiable, Associations)]
#[diesel(table_name = refresh_tokens)] #[diesel(table_name = refresh_tokens)]
#[diesel(belongs_to(User))] #[diesel(belongs_to(User))]
+258
View File
@@ -0,0 +1,258 @@
use std::collections::{BTreeMap, HashMap};
use axum::{
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
Json,
};
use chrono::Utc;
use diesel::{dsl::count_star, prelude::*, result::DatabaseErrorKind, PgConnection};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;
use crate::{
error::{AppError, AppResult},
models::{Correspondent, NewCorrespondent},
schema::{correspondents, document_correspondents},
state::AppState,
};
use super::documents::to_iso;
#[derive(Serialize)]
pub struct CorrespondentUsage {
pub total: i64,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub by_role: BTreeMap<String, i64>,
}
#[derive(Serialize)]
pub struct CorrespondentSummary {
pub id: Uuid,
pub name: String,
pub metadata: Value,
pub created_at: String,
pub updated_at: String,
pub usage: CorrespondentUsage,
}
#[derive(Deserialize)]
pub struct CreateCorrespondentRequest {
pub name: String,
#[serde(default)]
pub metadata: Option<Value>,
}
#[derive(Deserialize)]
pub struct UpdateCorrespondentRequest {
pub name: Option<String>,
pub metadata: Option<Value>,
}
#[derive(AsChangeset, Default)]
#[diesel(table_name = correspondents)]
struct CorrespondentChangeset<'a> {
name: Option<&'a str>,
metadata: Option<&'a Value>,
}
pub async fn list_correspondents(
State(state): State<AppState>,
) -> AppResult<Json<Vec<CorrespondentSummary>>> {
let mut conn = state.db()?;
let correspondents_list: Vec<Correspondent> = correspondents::table
.order(correspondents::name.asc())
.load(&mut conn)?;
let usage_rows: Vec<(Uuid, String, i64)> = document_correspondents::table
.group_by((
document_correspondents::correspondent_id,
document_correspondents::role,
))
.select((
document_correspondents::correspondent_id,
document_correspondents::role,
count_star(),
))
.load(&mut conn)?;
let mut usage_map: HashMap<Uuid, BTreeMap<String, i64>> = HashMap::new();
for (correspondent_id, role, count) in usage_rows {
usage_map
.entry(correspondent_id)
.or_default()
.insert(role, count);
}
let mut response = Vec::with_capacity(correspondents_list.len());
for correspondent in correspondents_list {
let role_counts = usage_map.remove(&correspondent.id).unwrap_or_default();
response.push(build_summary(correspondent, role_counts));
}
Ok(Json(response))
}
pub async fn create_correspondent(
State(state): State<AppState>,
Json(payload): Json<CreateCorrespondentRequest>,
) -> AppResult<Json<CorrespondentSummary>> {
let name = payload.name.trim();
if name.is_empty() {
return Err(AppError::bad_request("name must not be empty"));
}
let metadata_value = normalize_metadata(payload.metadata);
let new_id = Uuid::new_v4();
let new_correspondent = NewCorrespondent {
id: new_id,
name: name.to_string(),
metadata: metadata_value,
};
let mut conn = state.db()?;
match diesel::insert_into(correspondents::table)
.values(&new_correspondent)
.execute(&mut conn)
{
Ok(_) => {}
Err(diesel::result::Error::DatabaseError(DatabaseErrorKind::UniqueViolation, _)) => {
return Err(AppError::bad_request("correspondent name already exists"));
}
Err(err) => return Err(AppError::from(err)),
}
let correspondent: Correspondent = correspondents::table.find(new_id).first(&mut conn)?;
Ok(Json(build_summary(correspondent, BTreeMap::new())))
}
pub async fn update_correspondent(
State(state): State<AppState>,
Path(correspondent_id): Path<Uuid>,
Json(payload): Json<UpdateCorrespondentRequest>,
) -> AppResult<Json<CorrespondentSummary>> {
let mut conn = state.db()?;
let existing: Correspondent = correspondents::table
.find(correspondent_id)
.first(&mut conn)?;
let mut new_name: Option<String> = None;
if let Some(ref candidate) = payload.name {
let trimmed = candidate.trim();
if trimmed.is_empty() {
return Err(AppError::bad_request("name must not be empty"));
}
if trimmed != existing.name {
let duplicate = correspondents::table
.filter(correspondents::name.eq(trimmed))
.filter(correspondents::id.ne(correspondent_id))
.first::<Correspondent>(&mut conn)
.optional()?;
if duplicate.is_some() {
return Err(AppError::bad_request("correspondent name already exists"));
}
new_name = Some(trimmed.to_string());
}
}
let mut new_metadata: Option<Value> = None;
if let Some(metadata) = payload.metadata.clone() {
let candidate = normalize_metadata(Some(metadata));
if candidate != existing.metadata {
new_metadata = Some(candidate);
}
}
if new_name.is_none() && new_metadata.is_none() {
return Err(AppError::bad_request("no changes supplied"));
}
let mut changeset = CorrespondentChangeset::default();
if let Some(ref name) = new_name {
changeset.name = Some(name.as_str());
}
if let Some(ref metadata) = new_metadata {
changeset.metadata = Some(metadata);
}
let now = Utc::now().naive_utc();
diesel::update(correspondents::table.find(correspondent_id))
.set((&changeset, correspondents::updated_at.eq(now)))
.execute(&mut conn)?;
let updated: Correspondent = correspondents::table
.find(correspondent_id)
.first(&mut conn)?;
let usage = load_usage_for_correspondent(&mut conn, correspondent_id)?;
Ok(Json(build_summary(updated, usage)))
}
pub async fn delete_correspondent(
State(state): State<AppState>,
Path(correspondent_id): Path<Uuid>,
) -> AppResult<impl IntoResponse> {
let mut conn = state.db()?;
let usage: i64 = document_correspondents::table
.filter(document_correspondents::correspondent_id.eq(correspondent_id))
.select(count_star())
.first(&mut conn)?;
if usage > 0 {
return Err(AppError::bad_request(
"cannot delete correspondent that is still assigned to documents",
));
}
let deleted =
diesel::delete(correspondents::table.find(correspondent_id)).execute(&mut conn)?;
if deleted == 0 {
return Err(AppError::not_found());
}
Ok(StatusCode::NO_CONTENT)
}
fn build_summary(
correspondent: Correspondent,
role_counts: BTreeMap<String, i64>,
) -> CorrespondentSummary {
let total = role_counts.values().copied().sum();
CorrespondentSummary {
id: correspondent.id,
name: correspondent.name,
metadata: correspondent.metadata,
created_at: to_iso(correspondent.created_at),
updated_at: to_iso(correspondent.updated_at),
usage: CorrespondentUsage {
total,
by_role: role_counts,
},
}
}
fn normalize_metadata(input: Option<Value>) -> Value {
match input {
None | Some(Value::Null) => Value::Object(Default::default()),
Some(value) => value,
}
}
fn load_usage_for_correspondent(
conn: &mut PgConnection,
correspondent_id: Uuid,
) -> AppResult<BTreeMap<String, i64>> {
let rows: Vec<(String, i64)> = document_correspondents::table
.filter(document_correspondents::correspondent_id.eq(correspondent_id))
.group_by(document_correspondents::role)
.select((document_correspondents::role, count_star()))
.load(conn)?;
let mut map = BTreeMap::new();
for (role, count) in rows {
map.insert(role, count);
}
Ok(map)
}
+246 -4
View File
@@ -1,4 +1,8 @@
use std::{collections::HashMap, path::Path as FsPath, time::Duration}; use std::{
collections::{HashMap, HashSet},
path::Path as FsPath,
time::Duration,
};
use axum::extract::{Json, Multipart, Path, Query, State}; use axum::extract::{Json, Multipart, Path, Query, State};
use axum::http::StatusCode; use axum::http::StatusCode;
@@ -16,15 +20,25 @@ use crate::auth::AuthenticatedUser;
use crate::error::{AppError, AppResult}; use crate::error::{AppError, AppResult};
use crate::jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT}; use crate::jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT};
use crate::models::{ use crate::models::{
Document, DocumentAsset, DocumentVersion, NewDocument, NewDocumentTag, NewDocumentVersion, Tag, Correspondent, Document, DocumentAsset, DocumentCorrespondent, DocumentVersion, NewDocument,
NewDocumentCorrespondent, NewDocumentTag, NewDocumentVersion, Tag,
}; };
use crate::schema::{ use crate::schema::{
document_assets, document_tags, document_versions, documents, folders, correspondents, document_assets, document_correspondents, document_tags, document_versions,
refresh_tokens::dsl as refresh_dsl, tags, documents, folders, refresh_tokens::dsl as refresh_dsl, tags,
}; };
use crate::state::AppState; use crate::state::AppState;
const PRESIGNED_URL_EXPIRY_SECONDS: u64 = 300; const PRESIGNED_URL_EXPIRY_SECONDS: u64 = 300;
pub const CORRESPONDENT_ROLES: &[&str] = &["sender", "receiver", "other"];
fn normalize_role(value: &str) -> String {
value.trim().to_lowercase()
}
fn is_valid_correspondent_role(role: &str) -> bool {
CORRESPONDENT_ROLES.iter().any(|allowed| *allowed == role)
}
fn inline_content_disposition(filename: &str) -> Option<String> { fn inline_content_disposition(filename: &str) -> Option<String> {
if filename.is_empty() { if filename.is_empty() {
@@ -108,6 +122,15 @@ pub struct DocumentCurrentVersionResponse {
pub download_path: String, pub download_path: String,
} }
#[derive(Serialize, Clone)]
pub struct DocumentCorrespondentResponse {
pub id: Uuid,
pub name: String,
pub role: String,
pub metadata: Value,
pub assigned_at: String,
}
#[derive(Serialize)] #[derive(Serialize)]
pub struct DocumentResponse { pub struct DocumentResponse {
pub id: Uuid, pub id: Uuid,
@@ -122,6 +145,8 @@ pub struct DocumentResponse {
pub issued_at: Option<String>, pub issued_at: Option<String>,
pub metadata: Value, pub metadata: Value,
pub tags: Vec<TagResponse>, pub tags: Vec<TagResponse>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub correspondents: Vec<DocumentCorrespondentResponse>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub current_version: Option<DocumentCurrentVersionResponse>, pub current_version: Option<DocumentCurrentVersionResponse>,
} }
@@ -180,6 +205,24 @@ pub struct BulkTagResponse {
pub removed: usize, pub removed: usize,
} }
#[derive(Deserialize)]
pub struct CorrespondentAssignmentInput {
pub correspondent_id: Uuid,
pub role: String,
}
#[derive(Deserialize)]
pub struct AssignCorrespondentsRequest {
pub assignments: Vec<CorrespondentAssignmentInput>,
#[serde(default)]
pub replace: bool,
}
#[derive(Deserialize)]
pub struct CorrespondentRoleQuery {
pub role: String,
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct BulkReanalyzeSelectionRequest { pub struct BulkReanalyzeSelectionRequest {
pub document_ids: Vec<Uuid>, pub document_ids: Vec<Uuid>,
@@ -242,6 +285,7 @@ pub async fn list_documents(
let doc_ids: Vec<Uuid> = docs.iter().map(|doc| doc.id).collect(); let doc_ids: Vec<Uuid> = docs.iter().map(|doc| doc.id).collect();
let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?; let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?;
let mut correspondents_map = load_correspondents_for_documents(&mut conn, &doc_ids)?;
drop(conn); drop(conn);
let primary_versions = load_primary_assets(&state, &docs).await?; let primary_versions = load_primary_assets(&state, &docs).await?;
@@ -249,12 +293,14 @@ pub async fn list_documents(
let mut response = Vec::with_capacity(doc_ids.len()); let mut response = Vec::with_capacity(doc_ids.len());
for doc in docs { for doc in docs {
let tags = tags_map.get(&doc.id).cloned(); let tags = tags_map.get(&doc.id).cloned();
let correspondents = correspondents_map.remove(&doc.id).unwrap_or_default();
let current_version = primary_versions.get(&doc.id).cloned(); let current_version = primary_versions.get(&doc.id).cloned();
response.push(to_document_response( response.push(to_document_response(
&state, &state,
user.user_id, user.user_id,
doc, doc,
tags, tags,
correspondents,
current_version, current_version,
)?); )?);
} }
@@ -279,6 +325,7 @@ pub async fn get_document(
.first(&mut conn)?; .first(&mut conn)?;
let tags_map = load_tags_for_documents(&mut conn, &[document_id])?; let tags_map = load_tags_for_documents(&mut conn, &[document_id])?;
let mut correspondents_map = load_correspondents_for_documents(&mut conn, &[document_id])?;
let version_id = current_version.id; let version_id = current_version.id;
drop(conn); drop(conn);
@@ -291,6 +338,7 @@ pub async fn get_document(
user.user_id, user.user_id,
doc, doc,
tags_map.get(&document_id).cloned(), tags_map.get(&document_id).cloned(),
correspondents_map.remove(&document_id).unwrap_or_default(),
Some((version_response, assets)), Some((version_response, assets)),
)?, )?,
})) }))
@@ -689,6 +737,7 @@ pub async fn update_document(
.first(&mut conn)?; .first(&mut conn)?;
let tags_map = load_tags_for_documents(&mut conn, &[document_id])?; let tags_map = load_tags_for_documents(&mut conn, &[document_id])?;
let mut correspondents_map = load_correspondents_for_documents(&mut conn, &[document_id])?;
let version_id = current_version.id; let version_id = current_version.id;
drop(conn); drop(conn);
@@ -701,6 +750,7 @@ pub async fn update_document(
user.user_id, user.user_id,
document, document,
tags_map.get(&document_id).cloned(), tags_map.get(&document_id).cloned(),
correspondents_map.remove(&document_id).unwrap_or_default(),
Some((version_response, assets)), Some((version_response, assets)),
)?, )?,
})) }))
@@ -775,6 +825,157 @@ pub async fn bulk_move_documents(
Ok((StatusCode::OK, Json(BulkMoveResponse { updated }))) Ok((StatusCode::OK, Json(BulkMoveResponse { updated })))
} }
pub async fn assign_correspondents(
State(state): State<AppState>,
Path(document_id): Path<Uuid>,
user: AuthenticatedUser,
Json(payload): Json<AssignCorrespondentsRequest>,
) -> AppResult<impl IntoResponse> {
if payload.assignments.is_empty() {
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 replace = payload.replace;
let user_id = user.user_id;
let mut conn = state.db()?;
conn.transaction::<(), AppError, _>(|conn| {
let document: Document = documents::table.find(document_id).first(conn)?;
if document.deleted_at.is_some() {
return Err(AppError::not_found());
}
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",
));
}
}
let mut changed = false;
if replace {
let deleted = diesel::delete(
document_correspondents::table
.filter(document_correspondents::document_id.eq(document_id))
.filter(document_correspondents::role.eq_any(&roles_vec)),
)
.execute(conn)?;
if deleted > 0 {
changed = true;
}
}
let new_rows: Vec<NewDocumentCorrespondent> = normalized_pairs
.iter()
.map(|(correspondent_id, role)| NewDocumentCorrespondent {
document_id,
correspondent_id: *correspondent_id,
role: role.clone(),
assigned_by: Some(user_id),
})
.collect();
if !new_rows.is_empty() {
let inserted = diesel::insert_into(document_correspondents::table)
.values(&new_rows)
.on_conflict_do_nothing()
.execute(conn)?;
if inserted > 0 {
changed = true;
}
}
if changed {
diesel::update(documents::table.find(document_id))
.set(documents::updated_at.eq(Utc::now().naive_utc()))
.execute(conn)?;
}
Ok(())
})?;
Ok(StatusCode::NO_CONTENT)
}
pub async fn remove_correspondent(
State(state): State<AppState>,
Path((document_id, correspondent_id)): Path<(Uuid, Uuid)>,
Query(query): Query<CorrespondentRoleQuery>,
) -> AppResult<impl IntoResponse> {
let role = normalize_role(&query.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(", ")
)));
}
let mut conn = state.db()?;
let document: Document = documents::table.find(document_id).first(&mut conn)?;
if document.deleted_at.is_some() {
return Err(AppError::not_found());
}
let deleted = diesel::delete(
document_correspondents::table
.filter(document_correspondents::document_id.eq(document_id))
.filter(document_correspondents::correspondent_id.eq(correspondent_id))
.filter(document_correspondents::role.eq(&role)),
)
.execute(&mut conn)?;
if deleted == 0 {
return Err(AppError::not_found());
}
diesel::update(documents::table.find(document_id))
.set(documents::updated_at.eq(Utc::now().naive_utc()))
.execute(&mut conn)?;
Ok(StatusCode::NO_CONTENT)
}
pub async fn assign_tags( pub async fn assign_tags(
State(state): State<AppState>, State(state): State<AppState>,
Path(document_id): Path<Uuid>, Path(document_id): Path<Uuid>,
@@ -975,7 +1176,10 @@ async fn process_upload(
} }
let tags_map = load_tags_for_documents(&mut conn, &[document.id])?; let tags_map = load_tags_for_documents(&mut conn, &[document.id])?;
let mut correspondents_map =
load_correspondents_for_documents(&mut conn, &[document.id])?;
let tags = tags_map.get(&document.id).cloned(); let tags = tags_map.get(&document.id).cloned();
let correspondents = correspondents_map.remove(&document.id).unwrap_or_default();
drop(conn); drop(conn);
let assets = load_asset_responses(state, version.id).await?; let assets = load_asset_responses(state, version.id).await?;
let version_response = to_version_response(version.clone()); let version_response = to_version_response(version.clone());
@@ -993,6 +1197,7 @@ async fn process_upload(
user_id, user_id,
document, document,
tags, tags,
correspondents,
Some((version_response, assets)), Some((version_response, assets)),
)?, )?,
}, },
@@ -1068,6 +1273,7 @@ async fn process_upload(
user_id, user_id,
document, document,
None, None,
Vec::new(),
Some((to_version_response(version.clone()), Vec::new())), Some((to_version_response(version.clone()), Vec::new())),
)?, )?,
}; };
@@ -1126,6 +1332,40 @@ pub(crate) fn load_tags_for_documents(
Ok(map) Ok(map)
} }
pub(crate) fn load_correspondents_for_documents(
conn: &mut PgConnection,
document_ids: &[Uuid],
) -> AppResult<HashMap<Uuid, Vec<DocumentCorrespondentResponse>>> {
if document_ids.is_empty() {
return Ok(HashMap::new());
}
let rows: Vec<(DocumentCorrespondent, Correspondent)> = document_correspondents::table
.inner_join(correspondents::table)
.filter(document_correspondents::document_id.eq_any(document_ids))
.order((
document_correspondents::document_id.asc(),
document_correspondents::role.asc(),
document_correspondents::assigned_at.asc(),
))
.load(conn)?;
let mut map: HashMap<Uuid, Vec<DocumentCorrespondentResponse>> = HashMap::new();
for (assignment, correspondent) in rows {
map.entry(assignment.document_id)
.or_default()
.push(DocumentCorrespondentResponse {
id: correspondent.id,
name: correspondent.name,
role: assignment.role,
metadata: correspondent.metadata,
assigned_at: to_iso(assignment.assigned_at),
});
}
Ok(map)
}
pub(crate) async fn load_primary_assets( pub(crate) async fn load_primary_assets(
state: &AppState, state: &AppState,
documents: &[Document], documents: &[Document],
@@ -1191,6 +1431,7 @@ pub(crate) fn to_document_response(
user_id: Uuid, user_id: Uuid,
doc: Document, doc: Document,
tags: Option<Vec<Tag>>, tags: Option<Vec<Tag>>,
correspondents: Vec<DocumentCorrespondentResponse>,
current_version: Option<(DocumentVersionResponse, Vec<DocumentAssetResponse>)>, current_version: Option<(DocumentVersionResponse, Vec<DocumentAssetResponse>)>,
) -> AppResult<DocumentResponse> { ) -> AppResult<DocumentResponse> {
let current_version = if let Some((version, assets)) = current_version { let current_version = if let Some((version, assets)) = current_version {
@@ -1221,6 +1462,7 @@ pub(crate) fn to_document_response(
.into_iter() .into_iter()
.map(TagResponse::from) .map(TagResponse::from)
.collect(), .collect(),
correspondents,
current_version, current_version,
}) })
} }
+8 -1
View File
@@ -19,7 +19,8 @@ use crate::{
}; };
use super::documents::{ use super::documents::{
load_primary_assets, load_tags_for_documents, to_document_response, to_iso, DocumentResponse, load_correspondents_for_documents, load_primary_assets, load_tags_for_documents,
to_document_response, to_iso, DocumentResponse,
}; };
const QUICKWIT_MAX_HITS: usize = 200; const QUICKWIT_MAX_HITS: usize = 200;
@@ -213,6 +214,7 @@ pub async fn list_folder_contents(
let doc_ids: Vec<Uuid> = docs.iter().map(|doc| doc.id).collect(); let doc_ids: Vec<Uuid> = docs.iter().map(|doc| doc.id).collect();
let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?; let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?;
let mut correspondents_map = load_correspondents_for_documents(&mut conn, &doc_ids)?;
drop(conn); drop(conn);
let primary_versions = load_primary_assets(&state, &docs).await?; let primary_versions = load_primary_assets(&state, &docs).await?;
@@ -220,12 +222,14 @@ pub async fn list_folder_contents(
let mut documents = Vec::with_capacity(doc_ids.len()); let mut documents = Vec::with_capacity(doc_ids.len());
for doc in docs { for doc in docs {
let tags = tags_map.get(&doc.id).cloned(); let tags = tags_map.get(&doc.id).cloned();
let correspondents = correspondents_map.remove(&doc.id).unwrap_or_default();
let current_version = primary_versions.get(&doc.id).cloned(); let current_version = primary_versions.get(&doc.id).cloned();
documents.push(to_document_response( documents.push(to_document_response(
&state, &state,
user.user_id, user.user_id,
doc, doc,
tags, tags,
correspondents,
current_version, current_version,
)?); )?);
} }
@@ -397,18 +401,21 @@ pub async fn search_documents(
let doc_ids: Vec<Uuid> = docs.iter().map(|doc| doc.id).collect(); let doc_ids: Vec<Uuid> = docs.iter().map(|doc| doc.id).collect();
let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?; let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?;
let mut correspondents_map = load_correspondents_for_documents(&mut conn, &doc_ids)?;
drop(conn); drop(conn);
let primary_versions = load_primary_assets(&state, &docs).await?; let primary_versions = load_primary_assets(&state, &docs).await?;
let mut response = Vec::with_capacity(doc_ids.len()); let mut response = Vec::with_capacity(doc_ids.len());
for doc in docs { for doc in docs {
let tags = tags_map.get(&doc.id).cloned(); let tags = tags_map.get(&doc.id).cloned();
let correspondents = correspondents_map.remove(&doc.id).unwrap_or_default();
let current_version = primary_versions.get(&doc.id).cloned(); let current_version = primary_versions.get(&doc.id).cloned();
response.push(to_document_response( response.push(to_document_response(
&state, &state,
user.user_id, user.user_id,
doc, doc,
tags, tags,
correspondents,
current_version, current_version,
)?); )?);
} }
+24 -3
View File
@@ -10,6 +10,7 @@ use tower_http::cors::{AllowOrigin, CorsLayer};
use crate::{auth::AuthenticatedUser, state::AppState}; use crate::{auth::AuthenticatedUser, state::AppState};
pub mod auth; pub mod auth;
pub mod correspondents;
pub mod documents; pub mod documents;
pub mod folders; pub mod folders;
pub mod health; pub mod health;
@@ -76,7 +77,15 @@ pub fn create_router(state: AppState) -> Router<()> {
) )
.route("/:id/folder", patch(documents::move_document)) .route("/:id/folder", patch(documents::move_document))
.route("/:id/tags", post(documents::assign_tags)) .route("/:id/tags", post(documents::assign_tags))
.route("/:id/tags/:tag_id", delete(documents::remove_tag)); .route("/:id/tags/:tag_id", delete(documents::remove_tag))
.route(
"/:id/correspondents",
post(documents::assign_correspondents),
)
.route(
"/:id/correspondents/:correspondent_id",
delete(documents::remove_correspondent),
);
let download_routes = let download_routes =
Router::new().route("/download/:token", get(documents::download_with_token)); Router::new().route("/download/:token", get(documents::download_with_token));
@@ -93,19 +102,31 @@ pub fn create_router(state: AppState) -> Router<()> {
let tags_routes = Router::new() let tags_routes = Router::new()
.route("/", get(tags::list_tags).post(tags::create_tag)) .route("/", get(tags::list_tags).post(tags::create_tag))
.route("/:id", patch(tags::update_tag)); .route("/:id", patch(tags::update_tag).delete(tags::delete_tag));
let correspondents_routes = Router::new()
.route(
"/",
get(correspondents::list_correspondents).post(correspondents::create_correspondent),
)
.route(
"/:id",
patch(correspondents::update_correspondent)
.delete(correspondents::delete_correspondent),
);
let protected_state = state.clone(); let protected_state = state.clone();
let protected_routes = Router::new() let protected_routes = Router::new()
.nest("/api/documents", documents_routes) .nest("/api/documents", documents_routes)
.nest("/api/folders", folders_routes) .nest("/api/folders", folders_routes)
.nest("/api/tags", tags_routes) .nest("/api/tags", tags_routes)
.route("/api/health", get(health::health_check)) .nest("/api/correspondents", correspondents_routes)
.layer(middleware::from_extractor_with_state::<AuthenticatedUser, _>(protected_state)); .layer(middleware::from_extractor_with_state::<AuthenticatedUser, _>(protected_state));
Router::new() Router::new()
.merge(download_routes) .merge(download_routes)
.nest("/api/auth", auth_routes) .nest("/api/auth", auth_routes)
.route("/api/health", get(health::health_check))
.merge(protected_routes) .merge(protected_routes)
.with_state(state) .with_state(state)
.layer(cors) .layer(cors)
+26
View File
@@ -1,6 +1,7 @@
use crate::utils::json::{classify_nullable, NullableValue}; use crate::utils::json::{classify_nullable, NullableValue};
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, State},
http::StatusCode,
Json, Json,
}; };
use diesel::{dsl::count_star, prelude::*}; use diesel::{dsl::count_star, prelude::*};
@@ -189,3 +190,28 @@ pub async fn update_tag(
usage_count, usage_count,
})) }))
} }
pub async fn delete_tag(
State(state): State<AppState>,
Path(tag_id): Path<Uuid>,
) -> AppResult<impl axum::response::IntoResponse> {
let mut conn = state.db()?;
let usage: i64 = document_tags::table
.filter(document_tags::tag_id.eq(tag_id))
.select(count_star())
.first(&mut conn)?;
if usage > 0 {
return Err(AppError::bad_request(
"cannot delete tag that is still assigned to documents",
));
}
let deleted = diesel::delete(tags::table.find(tag_id)).execute(&mut conn)?;
if deleted == 0 {
return Err(AppError::not_found());
}
Ok(StatusCode::NO_CONTENT)
}
+27
View File
@@ -1,5 +1,16 @@
// @generated automatically by Diesel CLI. // @generated automatically by Diesel CLI.
diesel::table! {
correspondents (id) {
id -> Uuid,
#[max_length = 255]
name -> Varchar,
metadata -> Jsonb,
created_at -> Timestamptz,
updated_at -> Timestamptz,
}
}
diesel::table! { diesel::table! {
document_assets (id) { document_assets (id) {
id -> Uuid, id -> Uuid,
@@ -12,6 +23,17 @@ diesel::table! {
} }
} }
diesel::table! {
document_correspondents (document_id, correspondent_id, role) {
document_id -> Uuid,
correspondent_id -> Uuid,
#[max_length = 32]
role -> Varchar,
assigned_at -> Timestamptz,
assigned_by -> Nullable<Uuid>,
}
}
diesel::table! { diesel::table! {
document_tags (document_id, tag_id) { document_tags (document_id, tag_id) {
document_id -> Uuid, document_id -> Uuid,
@@ -123,6 +145,9 @@ diesel::table! {
} }
diesel::joinable!(document_assets -> document_versions (document_version_id)); diesel::joinable!(document_assets -> document_versions (document_version_id));
diesel::joinable!(document_correspondents -> correspondents (correspondent_id));
diesel::joinable!(document_correspondents -> documents (document_id));
diesel::joinable!(document_correspondents -> users (assigned_by));
diesel::joinable!(document_tags -> documents (document_id)); diesel::joinable!(document_tags -> documents (document_id));
diesel::joinable!(document_tags -> tags (tag_id)); diesel::joinable!(document_tags -> tags (tag_id));
diesel::joinable!(document_tags -> users (assigned_by)); diesel::joinable!(document_tags -> users (assigned_by));
@@ -130,7 +155,9 @@ diesel::joinable!(documents -> folders (folder_id));
diesel::joinable!(refresh_tokens -> users (user_id)); diesel::joinable!(refresh_tokens -> users (user_id));
diesel::allow_tables_to_appear_in_same_query!( diesel::allow_tables_to_appear_in_same_query!(
correspondents,
document_assets, document_assets,
document_correspondents,
document_tags, document_tags,
document_versions, document_versions,
documents, documents,
+66
View File
@@ -0,0 +1,66 @@
Papercrate REST API
===================
Unless noted otherwise, endpoints below require a valid `Authorization: Bearer <token>` header.
Authentication
--------------
- POST /api/auth/login - Exchange username/password for an access token and refresh cookie (public).
- POST /api/auth/refresh - Rotate the refresh cookie and return a new access token (public, requires refresh cookie).
- POST /api/auth/logout - Revoke the caller's refresh tokens and clear the cookie.
- GET /api/auth/me - Return the authenticated principal payload.
Health
------
- GET /api/health - Lightweight liveness probe (no authentication required).
Documents
---------
- GET /api/documents - List documents, optionally filtered by `folder_id` and `include_deleted`; each entry includes tags, correspondent assignments, and current version info.
- POST /api/documents - Upload a document via multipart form-data (`file`, optional metadata/folder fields).
- 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/reanalyze - Queue re-analysis jobs for selected documents.
- GET /api/documents/:id - Retrieve metadata and current version details for a document.
- PATCH /api/documents/:id - Update document metadata (currently title).
- DELETE /api/documents/:id - Soft-delete a document.
- GET /api/documents/:id/download - Create a pre-signed download URL for the current version.
- PATCH /api/documents/:id/folder - Move a document to another folder.
- POST /api/documents/:id/tags - Assign one or more tags to a document.
- DELETE /api/documents/:id/tags/:tag_id - Remove a single tag from a document.
- POST /api/documents/:id/correspondents - Assign correspondents to roles (`assignments[]` with `correspondent_id` and `role`; optional `replace=true` overwrites existing assignments for those roles). Valid roles: `sender`, `receiver`, `other`.
- DELETE /api/documents/:id/correspondents/:correspondent_id - Remove a correspondent assignment (requires `role` query string).
Document Assets
---------------
- GET /api/documents/:id/assets - List generated assets for the current version.
- POST /api/documents/:id/assets - Request (re)generation of document assets; accepts optional `force` query flag.
- GET /api/documents/:id/assets/:asset_id - Fetch metadata and a pre-signed URL for a specific asset.
Downloads
---------
- GET /download/:token - Follow a one-time download token; redirects to a pre-signed URL (public token required).
Folders
-------
- POST /api/folders - Create a folder (optionally under a parent).
- POST /api/folders/path - Ensure a nested folder path exists, creating missing segments.
- GET /api/folders/:id/contents - List subfolders and documents inside a folder; use `root` for the workspace root.
- GET /api/folders/:id/documents - Search within a folder tree with optional `query` and `tags` filters.
- DELETE /api/folders/:id - Soft-delete a folder.
- PATCH /api/folders/:id - Change a folder's parent.
Tags
----
- GET /api/tags - List all tags with usage counts.
- POST /api/tags - Create a new tag.
- PATCH /api/tags/:id - Update a tag's label or color.
- DELETE /api/tags/:id - Remove a tag; fails with 400 if still assigned to any document.
Correspondents
--------------
- GET /api/correspondents - List correspondents with usage totals and per-role counts (roles: `sender`, `receiver`, `other`).
- POST /api/correspondents - Create a correspondent (name + optional metadata JSON).
- PATCH /api/correspondents/:id - Update name and/or metadata.
- DELETE /api/correspondents/:id - Remove a correspondent; fails with 400 if referenced by any document.
+7 -1
View File
@@ -275,7 +275,13 @@ const DocumentsTable = ({
focusedRowKey === `folder:${folder.id}` ? ' focused' : '' focusedRowKey === `folder:${folder.id}` ? ' focused' : ''
}`} }`}
id={`folder-row-${folder.id}`} id={`folder-row-${folder.id}`}
onClick={() => onFolderSelect(folder.id)} onClick={() => {
onFolderSelect(folder.id);
if (scrollRef.current) {
scrollRef.current.focus({ preventScroll: true });
}
onFocusedRowChange?.(`folder:${folder.id}`);
}}
onDragOver={(event) => onFolderDragOver(event, folder.id)} onDragOver={(event) => onFolderDragOver(event, folder.id)}
onDragLeave={onFolderDragLeave} onDragLeave={onFolderDragLeave}
onDrop={(event) => onFolderDrop(event, folder.id)} onDrop={(event) => onFolderDrop(event, folder.id)}
+880 -338
View File
File diff suppressed because it is too large Load Diff
+35 -1
View File
@@ -1,6 +1,12 @@
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { useMatch } from 'react-router-dom'; import { useMatch } from 'react-router-dom';
import { ChevronIcon, FolderIcon, TagIcon, TrashIcon } from '../ui/icons'; import {
ChevronIcon,
FolderIcon,
TagIcon,
TrashIcon,
CorrespondentIcon,
} from '../ui/icons';
const FolderNode = ({ const FolderNode = ({
node, node,
@@ -56,12 +62,14 @@ const FolderNode = ({
} }
}} }}
> >
{!isRoot && (
<span <span
className={`toggle${showChevron ? '' : ' invisible'}${isExpanded ? ' expanded' : ''}`} className={`toggle${showChevron ? '' : ' invisible'}${isExpanded ? ' expanded' : ''}`}
onClick={handleToggleClick} onClick={handleToggleClick}
> >
{icon} {icon}
</span> </span>
)}
<span className="name"> <span className="name">
<FolderIcon className="folder-icon" /> <FolderIcon className="folder-icon" />
{node.name} {node.name}
@@ -104,10 +112,15 @@ const Sidebar = ({
draggedFolderId, draggedFolderId,
onShowTags, onShowTags,
tags = [], tags = [],
onShowCorrespondents,
correspondents = [],
}) => { }) => {
const handleShowTags = onShowTags || (() => {}); const handleShowTags = onShowTags || (() => {});
const tagsRouteMatch = useMatch('/tags'); const tagsRouteMatch = useMatch('/tags');
const isTagsRoute = Boolean(tagsRouteMatch); const isTagsRoute = Boolean(tagsRouteMatch);
const handleShowCorrespondents = onShowCorrespondents || (() => {});
const correspondentsRouteMatch = useMatch('/correspondents');
const isCorrespondentsRoute = Boolean(correspondentsRouteMatch);
const renderNodes = useCallback( const renderNodes = useCallback(
(ids, depth) => (ids, depth) =>
@@ -181,6 +194,27 @@ const Sidebar = ({
<span>All tags</span> <span>All tags</span>
</span> </span>
</div> </div>
<div className="sidebar-section">
<div className="sidebar-section__header">
<h3>Correspondents</h3>
<span className="meta">{correspondents.length}</span>
</div>
<span
role="button"
tabIndex={0}
className={`sidebar-item${isCorrespondentsRoute ? ' active' : ''}`}
onClick={handleShowCorrespondents}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleShowCorrespondents();
}
}}
>
<CorrespondentIcon className="sidebar-item__icon" />
<span>All correspondents</span>
</span>
</div>
</aside> </aside>
); );
}; };
+3 -13
View File
@@ -7,6 +7,7 @@ import React, {
useState, useState,
} from 'react'; } from 'react';
import { resolveDocumentAssetUrl } from './asset_manager'; import { resolveDocumentAssetUrl } from './asset_manager';
import { generateRandomTagColor, getReadableTextColor } from './utils/colors';
import './skeuomorphic_ws.css'; import './skeuomorphic_ws.css';
const ITEM_WIDTH = 220; const ITEM_WIDTH = 220;
@@ -299,15 +300,7 @@ const normalizeColor = (input) => {
return null; return null;
}; };
const getContrastingTextColor = (hex) => { const getContrastingTextColor = (hex) => getReadableTextColor(hex, { light: '#1f2125' });
if (!hex) return '#1b1f24';
const value = parseInt(hex.slice(1), 16);
const r = (value >> 16) & 0xff;
const g = (value >> 8) & 0xff;
const b = value & 0xff;
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance > 0.6 ? '#1b1f24' : '#ffffff';
};
const SkeuomorphicWorkspace = ({ const SkeuomorphicWorkspace = ({
documents = [], documents = [],
@@ -1555,11 +1548,8 @@ const SkeuomorphicWorkspace = ({
if (!label) { if (!label) {
return; return;
} }
const randomColor = `#${Math.floor(Math.random() * 0xffffff)
.toString(16)
.padStart(6, '0')}`;
try { try {
await onCreateTag({ label, color: randomColor }); await onCreateTag({ label, color: generateRandomTagColor() });
setActiveShelfTagId(null); setActiveShelfTagId(null);
} catch (error) { } catch (error) {
console.error('Failed to create tag', error); console.error('Failed to create tag', error);
+77 -1
View File
@@ -13,6 +13,8 @@
--overlay-backdrop: rgba(30, 33, 39, 0.45); --overlay-backdrop: rgba(30, 33, 39, 0.45);
--overlay-shadow: rgba(30, 33, 39, 0.18); --overlay-shadow: rgba(30, 33, 39, 0.18);
--app-bar-background: var(--surface-subtle);
/* Accent (primary action, selection, focus) */ /* Accent (primary action, selection, focus) */
--accent: #3f6ad8; --accent: #3f6ad8;
--accent-hover: #365cb9; --accent-hover: #365cb9;
@@ -209,7 +211,7 @@ button.icon-button.ghost:hover:not([disabled]) {
flex-direction: column; flex-direction: column;
gap: 0.4rem; gap: 0.4rem;
padding: 0.6rem 1.5rem 0.4rem; padding: 0.6rem 1.5rem 0.4rem;
background: var(--surface); background: var(--app-bar-background);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
position: sticky; position: sticky;
top: 0; top: 0;
@@ -422,6 +424,80 @@ button.icon-button.ghost:hover:not([disabled]) {
gap: 0.4rem; gap: 0.4rem;
} }
.tags-table__row-actions {
display: flex;
justify-content: flex-end;
gap: 0.4rem;
}
.correspondents-panel .header-actions {
gap: 0.5rem;
}
.correspondents-actions__form {
display: flex;
gap: 0.4rem;
}
.correspondents-actions__form input {
min-width: 14rem;
}
.correspondent-list {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-bottom: 0.5rem;
}
.correspondent-pill {
display: inline-flex;
align-items: center;
gap: 0.35rem;
background: var(--surface-subtle);
border: 1px solid var(--border-subtle);
border-radius: 999px;
padding: 0.15rem 0.5rem;
font-size: 0.9rem;
}
.correspondent-pill__label {
display: flex;
flex-direction: column;
line-height: 1.2;
}
.correspondent-pill__label strong {
font-size: 0.8rem;
text-transform: capitalize;
color: var(--muted);
}
.correspondent-pill__remove {
border: none;
background: none;
cursor: pointer;
font-size: 1rem;
line-height: 1;
color: var(--muted);
padding: 0;
}
.correspondent-pill__remove:hover {
color: var(--danger);
}
.correspondent-form {
display: flex;
gap: 0.4rem;
align-items: center;
}
.correspondent-form input,
.correspondent-form select {
min-height: 32px;
}
.preview-workspace__message { .preview-workspace__message {
color: var(--muted); color: var(--muted);
font-size: 0.95rem; font-size: 0.95rem;
+11
View File
@@ -4,6 +4,7 @@ import {
IconFolderFilled, IconFolderFilled,
IconPencil, IconPencil,
IconTagFilled, IconTagFilled,
IconUserFilled,
IconTrash, IconTrash,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
@@ -54,6 +55,15 @@ export const TagIcon = ({ className, size = '1em', stroke = 0, ...rest }) => (
/> />
); );
export const CorrespondentIcon = ({ className, size = '1em', stroke = 0, ...rest }) => (
<IconUserFilled
className={composeClassName('icon icon--fill', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export const DownloadIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => ( export const DownloadIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<TablerDownload <TablerDownload
className={composeClassName('icon', className)} className={composeClassName('icon', className)}
@@ -69,5 +79,6 @@ export default {
EditIcon, EditIcon,
FolderIcon, FolderIcon,
TagIcon, TagIcon,
CorrespondentIcon,
DownloadIcon, DownloadIcon,
}; };
+60 -6
View File
@@ -1,5 +1,38 @@
const HEX_COLOR_PATTERN = /^#?([0-9a-fA-F]{6})$/; const HEX_COLOR_PATTERN = /^#?([0-9a-fA-F]{6})$/;
const clamp01 = (value) => Math.min(1, Math.max(0, value));
const gammaEncode = (channel) =>
channel <= 0.0031308 ? 12.92 * channel : 1.055 * Math.pow(channel, 1 / 2.4) - 0.055;
const oklchToHex = (l, c, h) => {
const hr = (h * Math.PI) / 180;
const a = Math.cos(hr) * c;
const b = Math.sin(hr) * c;
const l1 = l + 0.3963377774 * a + 0.2158037573 * b;
const m1 = l - 0.1055613458 * a - 0.0638541728 * b;
const s1 = l - 0.0894841775 * a - 1.291485548 * b;
const l3 = l1 ** 3;
const m3 = m1 ** 3;
const s3 = s1 ** 3;
const r = 4.0767416621 * l3 - 3.3077115913 * m3 + 0.2309699292 * s3;
const g = -1.2684380046 * l3 + 2.6097574011 * m3 - 0.3413193965 * s3;
const bLin = -0.0041960863 * l3 - 0.7034186147 * m3 + 1.707614701 * s3;
if ([r, g, bLin].some((channel) => channel < 0 || channel > 1)) {
return null;
}
const sr = Math.round(clamp01(gammaEncode(r)) * 255);
const sg = Math.round(clamp01(gammaEncode(g)) * 255);
const sb = Math.round(clamp01(gammaEncode(bLin)) * 255);
return `#${((sr << 16) | (sg << 8) | sb).toString(16).padStart(6, '0')}`;
};
export const hexToRgb = (input) => { export const hexToRgb = (input) => {
if (!input) return null; if (!input) return null;
const match = HEX_COLOR_PATTERN.exec(input.trim()); const match = HEX_COLOR_PATTERN.exec(input.trim());
@@ -13,28 +46,49 @@ export const hexToRgb = (input) => {
}; };
}; };
const relativeLuminance = ({ r, g, b }) => { export const relativeLuminance = ({ r, g, b }) => {
const transform = (channel) => { const toLinear = (channel) => {
const normalized = channel / 255; const normalized = channel / 255;
return normalized <= 0.03928 return normalized <= 0.03928
? normalized / 12.92 ? normalized / 12.92
: ((normalized + 0.055) / 1.055) ** 2.4; : ((normalized + 0.055) / 1.055) ** 2.4;
}; };
const [red, green, blue] = [transform(r), transform(g), transform(b)]; const [red, green, blue] = [toLinear(r), toLinear(g), toLinear(b)];
return 0.2126 * red + 0.7152 * green + 0.0722 * blue; return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
}; };
export const getReadableTextColor = (hex, { light = '#1f1f1f', dark = '#ffffff' } = {}) => {
const rgb = hexToRgb(hex);
if (!rgb) return dark;
const luminance = relativeLuminance(rgb);
return luminance > 0.6 ? light : dark;
};
export const getTagColorStyle = (hex) => { export const getTagColorStyle = (hex) => {
const rgb = hexToRgb(hex); const rgb = hexToRgb(hex);
if (!rgb) return null; if (!rgb) return null;
const luminance = relativeLuminance(rgb);
const textColor = luminance > 0.6 ? '#1f1f1f' : '#ffffff';
return { return {
backgroundColor: rgb.hex, backgroundColor: rgb.hex,
borderColor: rgb.hex, borderColor: rgb.hex,
color: textColor, color: getReadableTextColor(rgb.hex),
}; };
}; };
export const generateRandomTagColor = () => {
const lightness = 0.72 + (Math.random() - 0.5) * 0.08;
let chroma = 0.8;
const hue = Math.random() * 360;
for (let attempt = 0; attempt < 5; attempt += 1) {
const hex = oklchToHex(lightness, chroma, hue);
if (hex) {
return hex;
}
chroma *= 0.82;
}
return '#8c8982';
};
export { HEX_COLOR_PATTERN }; export { HEX_COLOR_PATTERN };
+18
View File
@@ -0,0 +1,18 @@
export const formatFileSize = (value) => {
const bytes = Number(value);
if (!Number.isFinite(bytes) || bytes <= 0) {
return '0 B';
}
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB'];
let index = 0;
let amount = bytes;
while (amount >= 1024 && index < units.length - 1) {
amount /= 1024;
index += 1;
}
return `${amount.toFixed(2)} ${units[index]}`;
};