patch tags

This commit is contained in:
2025-10-12 01:52:30 +02:00
parent 1b950a8f9a
commit f30e455c2d
20 changed files with 1306 additions and 161 deletions
+27
View File
@@ -25,3 +25,30 @@ The compose service uses tmpfs storage, giving each test run a clean database.
- `ocrmypdf` (optional but recommended): Used by the OCR worker to extract text from PDFs when no embedded text layer is available. Ensure it is installed and available on the worker hosts if OCR is desired.
- Quickwit (optional): The Quickwit indexer is used to ingest extracted text for search. Set `QUICKWIT_ENDPOINT` and `QUICKWIT_INDEX` in the environment when running workers if you want indexing jobs to run. The local compose file starts a Quickwit instance on `http://localhost:7280` and seeds the `documents` index automatically.
## Running Migrations in Kubernetes
The backend container image ships the `diesel` CLI, so schema migrations can be executed as a short-lived Job (or Helm hook) before rolling out new pods. Example manifest:
```yaml
apiVersion: batch/v1
kind: Job
metadata:
name: paperless-migrate
spec:
template:
spec:
restartPolicy: OnFailure
containers:
- name: migrate
image: ghcr.io/example/paperless-backend:<TAG>
command: ["/usr/local/bin/diesel", "migration", "run"]
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: paperless-db
key: DATABASE_URL
```
Run the Job manually (`kubectl apply -f migrate-job.yaml`) or configure it as a Helm pre-install/pre-upgrade hook so migrations run automatically on each deployment. Once the Job succeeds, deploy/update the backend `Deployment` as usual.
+1
View File
@@ -2132,6 +2132,7 @@ dependencies = [
"mime_guess",
"once_cell",
"pdfium-render",
"percent-encoding",
"rand 0.8.5",
"reqwest",
"serde",
+1
View File
@@ -39,6 +39,7 @@ pdfium-render = "0.8"
mime_guess = "2.0"
tempfile = "3.10"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
percent-encoding = "2.3"
# Error handling
thiserror = "1.0"
+37
View File
@@ -13,6 +13,8 @@ pub struct JwtService {
issuer: String,
audience: String,
expiry: Duration,
download_audience: String,
download_expiry: Duration,
}
impl JwtService {
@@ -23,6 +25,8 @@ impl JwtService {
issuer: config.jwt_issuer.clone(),
audience: config.jwt_audience.clone(),
expiry: Duration::minutes(config.jwt_expiry_minutes),
download_audience: config.download_token_audience.clone(),
download_expiry: Duration::minutes(config.download_token_expiry_minutes),
})
}
@@ -49,6 +53,29 @@ impl JwtService {
let data = decode::<Claims>(token, &self.decoding, &validation)?;
Ok(data.claims)
}
pub fn generate_download_token(&self, document_id: Uuid, user_id: Uuid) -> Result<String> {
let now = Utc::now();
let exp = now + self.download_expiry;
let claims = DownloadClaims {
doc_id: document_id,
user_id,
iss: self.issuer.clone(),
aud: self.download_audience.clone(),
iat: now.timestamp() as usize,
exp: exp.timestamp() as usize,
};
Ok(encode(&Header::default(), &claims, &self.encoding)?)
}
pub fn verify_download_token(&self, token: &str) -> Result<DownloadClaims> {
let mut validation = Validation::default();
validation.set_audience(&[self.download_audience.clone()]);
validation.set_issuer(&[self.issuer.clone()]);
let data = decode::<DownloadClaims>(token, &self.decoding, &validation)?;
Ok(data.claims)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -61,3 +88,13 @@ pub struct Claims {
pub iat: usize,
pub exp: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DownloadClaims {
pub doc_id: Uuid,
pub user_id: Uuid,
pub iss: String,
pub aud: String,
pub iat: usize,
pub exp: usize,
}
+10
View File
@@ -11,6 +11,8 @@ pub struct AppConfig {
pub jwt_issuer: String,
pub jwt_audience: String,
pub jwt_expiry_minutes: i64,
pub download_token_audience: String,
pub download_token_expiry_minutes: i64,
pub refresh_token_expiry_days: i64,
pub refresh_cookie_secure: bool,
pub refresh_cookie_domain: Option<String>,
@@ -40,6 +42,12 @@ impl AppConfig {
.unwrap_or_else(|_| "60".to_string())
.parse()
.context("JWT_EXPIRY_MINUTES must be an integer")?;
let download_token_audience = env::var("DOWNLOAD_TOKEN_AUDIENCE")
.unwrap_or_else(|_| "paperless-neo-download".to_string());
let download_token_expiry_minutes = env::var("DOWNLOAD_TOKEN_EXPIRY_MINUTES")
.unwrap_or_else(|_| "60".to_string())
.parse()
.context("DOWNLOAD_TOKEN_EXPIRY_MINUTES must be an integer")?;
let refresh_token_expiry_days = env::var("REFRESH_TOKEN_EXPIRY_DAYS")
.unwrap_or_else(|_| "30".to_string())
.parse()
@@ -65,6 +73,8 @@ impl AppConfig {
jwt_issuer,
jwt_audience,
jwt_expiry_minutes,
download_token_audience,
download_token_expiry_minutes,
refresh_token_expiry_days,
refresh_cookie_secure,
refresh_cookie_domain,
+1
View File
@@ -9,5 +9,6 @@ pub mod s3;
pub mod schema;
pub mod state;
pub mod storage;
pub mod utils;
pub mod workers;
pub use workers::{default_handlers, Worker};
+129 -20
View File
@@ -5,7 +5,7 @@ use axum::http::StatusCode;
use axum::response::IntoResponse;
use chrono::{DateTime, NaiveDateTime, Utc};
use diesel::dsl::exists;
use diesel::{prelude::*, PgConnection};
use diesel::{prelude::*, select, PgConnection};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
@@ -18,11 +18,35 @@ use crate::jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT};
use crate::models::{
Document, DocumentAsset, DocumentVersion, NewDocument, NewDocumentTag, NewDocumentVersion, Tag,
};
use crate::schema::{document_assets, document_tags, document_versions, documents, folders, tags};
use crate::schema::{
document_assets, document_tags, document_versions, documents, folders,
refresh_tokens::dsl as refresh_dsl, tags,
};
use crate::state::AppState;
const PRESIGNED_URL_EXPIRY_SECONDS: u64 = 300;
fn inline_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
))
}
#[derive(Deserialize)]
pub struct DocumentListQuery {
pub folder_id: Option<Uuid>,
@@ -69,6 +93,7 @@ pub struct DocumentResponse {
pub metadata: Value,
pub tags: Vec<TagResponse>,
pub thumbnail: Option<DocumentAssetResponse>,
pub download_path: String,
}
#[derive(Serialize, Clone)]
@@ -182,6 +207,7 @@ pub struct AssignTagsRequest {
pub async fn list_documents(
State(state): State<AppState>,
Query(query): Query<DocumentListQuery>,
user: AuthenticatedUser,
) -> AppResult<Json<Vec<DocumentResponse>>> {
let mut conn = state.db()?;
@@ -210,14 +236,18 @@ pub async fn list_documents(
let thumbnails = load_primary_thumbnails(&state, &docs).await?;
let response = docs
.into_iter()
.map(|doc| {
let tags = tags_map.get(&doc.id).cloned();
let thumbnail = thumbnails.get(&doc.id).cloned();
to_document_response(doc, tags, thumbnail)
})
.collect();
let mut response = Vec::with_capacity(doc_ids.len());
for doc in docs {
let tags = tags_map.get(&doc.id).cloned();
let thumbnail = thumbnails.get(&doc.id).cloned();
response.push(to_document_response(
&state,
user.user_id,
doc,
tags,
thumbnail,
)?);
}
Ok(Json(response))
}
@@ -225,6 +255,7 @@ pub async fn list_documents(
pub async fn get_document(
State(state): State<AppState>,
Path(document_id): Path<Uuid>,
user: AuthenticatedUser,
) -> AppResult<Json<DocumentDetailResponse>> {
let mut conn = state.db()?;
@@ -249,7 +280,13 @@ pub async fn get_document(
.cloned();
Ok(Json(DocumentDetailResponse {
document: to_document_response(doc, tags_map.get(&document_id).cloned(), thumbnail),
document: to_document_response(
&state,
user.user_id,
doc,
tags_map.get(&document_id).cloned(),
thumbnail,
)?,
current_version: to_version_response(current_version),
assets,
}))
@@ -257,7 +294,7 @@ pub async fn get_document(
pub async fn upload_document(
State(state): State<AppState>,
_user: AuthenticatedUser,
user: AuthenticatedUser,
mut multipart: Multipart,
) -> AppResult<(StatusCode, Json<DocumentDetailResponse>)> {
let mut file_bytes: Option<Vec<u8>> = None;
@@ -335,7 +372,7 @@ pub async fn upload_document(
metadata,
};
let outcome = match process_upload(&state, request).await {
let outcome = match process_upload(&state, request, user.user_id).await {
Ok(outcome) => {
info!(
document_id = %outcome.detail.document.id,
@@ -527,6 +564,54 @@ pub async fn download_document(
}))
}
pub async fn download_with_token(
State(state): State<AppState>,
Path(token): Path<String>,
) -> AppResult<impl IntoResponse> {
let claims = state
.jwt
.verify_download_token(&token)
.map_err(|_| AppError::unauthorized())?;
let mut conn = state.db()?;
let doc: Document = documents::table.find(claims.doc_id).first(&mut conn)?;
if doc.deleted_at.is_some() {
return Err(AppError::not_found());
}
let version: DocumentVersion = document_versions::table
.filter(document_versions::document_id.eq(claims.doc_id))
.filter(document_versions::version_number.eq(doc.current_version))
.first(&mut conn)?;
let now = Utc::now().naive_utc();
let has_active_refresh: bool = select(exists(
refresh_dsl::refresh_tokens
.filter(refresh_dsl::user_id.eq(claims.user_id))
.filter(refresh_dsl::revoked_at.is_null())
.filter(refresh_dsl::expires_at.gt(now)),
))
.get_result(&mut conn)?;
if !has_active_refresh {
return Err(AppError::unauthorized());
}
drop(conn);
let presigned_url = state
.storage
.presign_get_object(
&version.s3_key,
Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS),
)
.await
.map_err(|err| AppError::internal(format!("failed to generate download URL: {err}")))?;
Ok(axum::response::Redirect::temporary(&presigned_url))
}
pub async fn delete_document(
State(state): State<AppState>,
Path(document_id): Path<Uuid>,
@@ -757,7 +842,11 @@ pub async fn remove_tag(
Ok(StatusCode::NO_CONTENT)
}
async fn process_upload(state: &AppState, request: UploadRequest) -> AppResult<UploadOutcome> {
async fn process_upload(
state: &AppState,
request: UploadRequest,
user_id: Uuid,
) -> AppResult<UploadOutcome> {
let UploadRequest {
bytes,
original_name,
@@ -824,7 +913,7 @@ async fn process_upload(state: &AppState, request: UploadRequest) -> AppResult<U
return Ok(UploadOutcome {
detail: DocumentDetailResponse {
document: to_document_response(document, tags, thumbnail),
document: to_document_response(state, user_id, document, tags, thumbnail)?,
current_version: to_version_response(version),
assets,
},
@@ -833,9 +922,16 @@ async fn process_upload(state: &AppState, request: UploadRequest) -> AppResult<U
}
}
let content_disposition = inline_content_disposition(&original_name);
state
.storage
.put_object(&s3_key, bytes.clone(), content_type.clone())
.put_object(
&s3_key,
bytes.clone(),
content_type.clone(),
content_disposition.clone(),
)
.await
.map_err(|err| {
error!(error = %err, key = %s3_key, "failed to store document");
@@ -888,7 +984,7 @@ async fn process_upload(state: &AppState, request: UploadRequest) -> AppResult<U
};
let detail = DocumentDetailResponse {
document: to_document_response(document, None, None),
document: to_document_response(state, user_id, document, None, None)?,
current_version: to_version_response(version.clone()),
assets: Vec::new(),
};
@@ -1019,11 +1115,15 @@ pub(crate) async fn load_primary_thumbnails(
}
pub(crate) fn to_document_response(
state: &AppState,
user_id: Uuid,
doc: Document,
tags: Option<Vec<Tag>>,
thumbnail: Option<DocumentAssetResponse>,
) -> DocumentResponse {
DocumentResponse {
) -> AppResult<DocumentResponse> {
let download_path = build_download_path(state, doc.id, user_id)?;
Ok(DocumentResponse {
id: doc.id,
filename: doc.filename,
title: doc.title,
@@ -1042,7 +1142,16 @@ pub(crate) fn to_document_response(
.map(TagResponse::from)
.collect(),
thumbnail,
}
download_path,
})
}
fn build_download_path(state: &AppState, document_id: Uuid, user_id: Uuid) -> AppResult<String> {
state
.jwt
.generate_download_token(document_id, user_id)
.map(|token| format!("/download/{token}"))
.map_err(|err| AppError::internal(format!("failed to generate download token: {err}")))
}
fn to_version_response(version: DocumentVersion) -> DocumentVersionResponse {
+30 -17
View File
@@ -10,10 +10,13 @@ use serde_json::{json, Value};
use std::collections::{HashMap, HashSet};
use uuid::Uuid;
use crate::error::{AppError, AppResult};
use crate::models::{Document, Folder, NewFolder};
use crate::schema::{document_tags, documents, folders};
use crate::state::AppState;
use crate::{
auth::AuthenticatedUser,
error::{AppError, AppResult},
};
use super::documents::{
load_primary_thumbnails, load_tags_for_documents, to_document_response, to_iso,
@@ -162,6 +165,7 @@ pub async fn create_folder(
pub async fn list_folder_contents(
State(state): State<AppState>,
Path(folder_identifier): Path<String>,
user: AuthenticatedUser,
) -> AppResult<Json<FolderContentsResponse>> {
let mut conn = state.db()?;
@@ -214,14 +218,18 @@ pub async fn list_folder_contents(
let thumbnails = load_primary_thumbnails(&state, &docs).await?;
let documents = docs
.into_iter()
.map(|doc| {
let tags = tags_map.get(&doc.id).cloned();
let thumbnail = thumbnails.get(&doc.id).cloned();
to_document_response(doc, tags, thumbnail)
})
.collect();
let mut documents = Vec::with_capacity(doc_ids.len());
for doc in docs {
let tags = tags_map.get(&doc.id).cloned();
let thumbnail = thumbnails.get(&doc.id).cloned();
documents.push(to_document_response(
&state,
user.user_id,
doc,
tags,
thumbnail,
)?);
}
Ok(Json(FolderContentsResponse {
folder,
@@ -234,6 +242,7 @@ pub async fn search_documents(
State(state): State<AppState>,
Path(folder_identifier): Path<String>,
Query(params): Query<DocumentSearchQuery>,
user: AuthenticatedUser,
) -> AppResult<Json<Vec<DocumentResponse>>> {
let mut conn = state.db()?;
@@ -392,14 +401,18 @@ pub async fn search_documents(
drop(conn);
let thumbnails = load_primary_thumbnails(&state, &docs).await?;
let response = docs
.into_iter()
.map(|doc| {
let tags = tags_map.get(&doc.id).cloned();
let thumbnail = thumbnails.get(&doc.id).cloned();
to_document_response(doc, tags, thumbnail)
})
.collect();
let mut response = Vec::with_capacity(doc_ids.len());
for doc in docs {
let tags = tags_map.get(&doc.id).cloned();
let thumbnail = thumbnails.get(&doc.id).cloned();
response.push(to_document_response(
&state,
user.user_id,
doc,
tags,
thumbnail,
)?);
}
Ok(Json(response))
}
+7 -1
View File
@@ -75,6 +75,9 @@ pub fn create_router(state: AppState) -> Router<()> {
.route("/:id/tags", post(documents::assign_tags))
.route("/:id/tags/:tag_id", delete(documents::remove_tag));
let download_routes =
Router::new().route("/download/:token", get(documents::download_with_token));
let folders_routes = Router::new()
.route("/", post(folders::create_folder))
.route("/path", post(folders::ensure_folder_path))
@@ -85,7 +88,9 @@ pub fn create_router(state: AppState) -> Router<()> {
.route("/:id/contents", get(folders::list_folder_contents))
.route("/:id/documents", get(folders::search_documents));
let tags_routes = Router::new().route("/", get(tags::list_tags).post(tags::create_tag));
let tags_routes = Router::new()
.route("/", get(tags::list_tags).post(tags::create_tag))
.route("/:id", patch(tags::update_tag));
let protected_state = state.clone();
let protected_routes = Router::new()
@@ -96,6 +101,7 @@ pub fn create_router(state: AppState) -> Router<()> {
.layer(middleware::from_extractor_with_state::<AuthenticatedUser, _>(protected_state));
Router::new()
.merge(download_routes)
.nest("/api/auth", auth_routes)
.merge(protected_routes)
.with_state(state)
+144 -10
View File
@@ -1,32 +1,69 @@
use axum::{extract::State, Json};
use diesel::prelude::*;
use serde::Deserialize;
use crate::utils::json::{classify_nullable, NullableValue};
use axum::{
extract::{Path, State},
Json,
};
use diesel::{dsl::count_star, prelude::*};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use uuid::Uuid;
use crate::error::{AppError, AppResult};
use crate::models::{NewTag, Tag};
use crate::schema::tags;
use crate::schema::{document_tags, tags};
use crate::state::AppState;
use super::documents::TagResponse;
#[derive(Deserialize)]
pub struct CreateTagRequest {
pub label: String,
pub color: Option<String>,
}
pub async fn list_tags(State(state): State<AppState>) -> AppResult<Json<Vec<TagResponse>>> {
#[derive(AsChangeset, Default)]
#[diesel(table_name = tags)]
struct UpdateTagChangeset<'a> {
label: Option<&'a str>,
color: Option<Option<&'a str>>,
}
#[derive(Serialize)]
pub struct TagCatalogEntry {
pub id: Uuid,
pub label: String,
pub color: Option<String>,
pub usage_count: i64,
}
pub async fn list_tags(State(state): State<AppState>) -> AppResult<Json<Vec<TagCatalogEntry>>> {
let mut conn = state.db()?;
let tag_list: Vec<Tag> = tags::table.order(tags::label.asc()).load(&mut conn)?;
let response = tag_list.into_iter().map(TagResponse::from).collect();
let usage_rows: Vec<(Uuid, i64)> = document_tags::table
.group_by(document_tags::tag_id)
.select((document_tags::tag_id, count_star()))
.load(&mut conn)?;
let usage_map: HashMap<Uuid, i64> = usage_rows.into_iter().collect();
let response = tag_list
.into_iter()
.map(|tag| TagCatalogEntry {
id: tag.id,
label: tag.label,
color: tag.color,
usage_count: *usage_map.get(&tag.id).unwrap_or(&0),
})
.collect();
Ok(Json(response))
}
pub async fn create_tag(
State(state): State<AppState>,
Json(payload): Json<CreateTagRequest>,
) -> AppResult<Json<TagResponse>> {
) -> AppResult<Json<TagCatalogEntry>> {
if payload.label.trim().is_empty() {
return Err(AppError::bad_request("label must not be empty"));
}
@@ -53,5 +90,102 @@ pub async fn create_tag(
}
let tag: Tag = tags::table.find(new_tag.id).first(&mut conn)?;
Ok(Json(TagResponse::from(tag)))
Ok(Json(TagCatalogEntry {
id: tag.id,
label: tag.label,
color: tag.color,
usage_count: 0,
}))
}
pub async fn update_tag(
State(state): State<AppState>,
Path(tag_id): Path<Uuid>,
Json(body): Json<Value>,
) -> AppResult<Json<TagCatalogEntry>> {
let mut conn = state.db()?;
let existing: Tag = tags::table.find(tag_id).first(&mut conn)?;
let label_class = classify_nullable(body.get("label")).map_err(AppError::bad_request)?;
let color_class = classify_nullable(body.get("color")).map_err(AppError::bad_request)?;
if matches!(label_class, NullableValue::Omitted)
&& matches!(color_class, NullableValue::Omitted)
{
return Err(AppError::bad_request("no changes supplied"));
}
let mut new_label: Option<String> = None;
let mut label_changed = false;
match label_class {
NullableValue::Omitted => {}
NullableValue::Null => {
return Err(AppError::bad_request("label cannot be null"));
}
NullableValue::String(value) => {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(AppError::bad_request("label must not be empty"));
}
if trimmed != existing.label {
let duplicate = tags::table
.filter(tags::label.eq(trimmed))
.filter(tags::id.ne(tag_id))
.first::<Tag>(&mut conn)
.optional()?;
if duplicate.is_some() {
return Err(AppError::bad_request("tag label already exists"));
}
new_label = Some(trimmed.to_string());
label_changed = true;
}
}
}
let mut color_change: Option<Option<String>> = None;
let mut color_changed = false;
match color_class {
NullableValue::Omitted => {}
NullableValue::Null => {
color_change = Some(None);
color_changed = true;
}
NullableValue::String(value) => {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(AppError::bad_request("color must not be empty"));
}
if existing.color.as_deref() != Some(trimmed) {
color_change = Some(Some(trimmed.to_string()));
color_changed = true;
}
}
}
if !label_changed && !color_changed {
return Err(AppError::bad_request("no changes supplied"));
}
let changeset = UpdateTagChangeset {
label: new_label.as_deref(),
color: color_change
.as_ref()
.map(|opt| opt.as_ref().map(|value| value.as_str())),
};
diesel::update(tags::table.find(tag_id))
.set(&changeset)
.execute(&mut conn)?;
let updated: Tag = tags::table.find(tag_id).first(&mut conn)?;
let usage_count: i64 = document_tags::table
.filter(document_tags::tag_id.eq(tag_id))
.select(count_star())
.first(&mut conn)?;
Ok(Json(TagCatalogEntry {
id: updated.id,
label: updated.label,
color: updated.color,
usage_count,
}))
}
+6
View File
@@ -13,6 +13,7 @@ pub trait ObjectStorage: Send + Sync + 'static {
key: &str,
bytes: Vec<u8>,
content_type: Option<String>,
content_disposition: Option<String>,
) -> Result<()>;
async fn presign_get_object(&self, key: &str, expires_in: Duration) -> Result<String>;
@@ -43,6 +44,7 @@ impl ObjectStorage for S3Storage {
key: &str,
bytes: Vec<u8>,
content_type: Option<String>,
content_disposition: Option<String>,
) -> Result<()> {
let mut request = self
.client
@@ -55,6 +57,10 @@ impl ObjectStorage for S3Storage {
request = request.content_type(content_type);
}
if let Some(content_disposition) = content_disposition {
request = request.content_disposition(content_disposition);
}
request
.send()
.await
+16
View File
@@ -0,0 +1,16 @@
use serde_json::Value;
pub enum NullableValue {
Omitted,
Null,
String(String),
}
pub fn classify_nullable(optional_value: Option<&Value>) -> Result<NullableValue, String> {
match optional_value {
None => Ok(NullableValue::Omitted),
Some(Value::Null) => Ok(NullableValue::Null),
Some(Value::String(s)) => Ok(NullableValue::String(s.to_owned())),
Some(other) => Err(format!("expected string or null, got {other}")),
}
}
+1
View File
@@ -0,0 +1 @@
pub mod json;
+1
View File
@@ -140,6 +140,7 @@ impl JobHandler for GenerateOcrTextJob {
&s3_key,
generation.text.into_bytes(),
Some("text/plain".into()),
None,
)
.await
{
+1
View File
@@ -118,6 +118,7 @@ impl JobHandler for GenerateThumbnailsJob {
&s3_key,
generation.image_bytes.clone(),
Some("image/png".into()),
None,
)
.await
{
+5
View File
@@ -37,6 +37,7 @@ pub struct StoredObject {
pub key: String,
pub bytes: Vec<u8>,
pub content_type: Option<String>,
pub content_disposition: Option<String>,
}
#[derive(Default)]
@@ -51,11 +52,13 @@ impl ObjectStorage for FakeStorage {
key: &str,
bytes: Vec<u8>,
content_type: Option<String>,
content_disposition: Option<String>,
) -> Result<()> {
let stored = StoredObject {
key: key.to_string(),
bytes,
content_type,
content_disposition,
};
let mut guard = self.objects.lock().await;
guard.insert(stored.key.clone(), stored);
@@ -119,6 +122,8 @@ impl TestApp {
jwt_issuer: "test-issuer".to_string(),
jwt_audience: "test-audience".to_string(),
jwt_expiry_minutes: 60,
download_token_audience: "test-download".to_string(),
download_token_expiry_minutes: 60,
refresh_token_expiry_days: 30,
refresh_cookie_secure: false,
refresh_cookie_domain: None,
+13
View File
@@ -22,6 +22,7 @@ struct DocumentInfo {
deleted_at: Option<String>,
issued_at: Option<String>,
tags: Vec<TagSummary>,
download_path: String,
}
#[derive(Deserialize)]
@@ -42,6 +43,7 @@ struct DocumentAssetInfo {
struct DocumentListItem {
id: Uuid,
current_version: i32,
download_path: String,
}
#[derive(Deserialize)]
@@ -154,6 +156,7 @@ 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!(detail.document.download_path.starts_with("/download/"));
assert_eq!(detail.current_version.size_bytes, file_bytes.len() as i64);
assert!(detail.assets.is_empty());
@@ -173,6 +176,7 @@ async fn upload_and_list_document() -> Result<()> {
let item = list.pop().unwrap();
assert_eq!(item.id, detail.document.id);
assert_eq!(item.current_version, 1);
assert!(item.download_path.starts_with("/download/"));
let download = app
.get(
@@ -186,6 +190,15 @@ async fn upload_and_list_document() -> Result<()> {
assert!(download_info.url.contains(&detail.current_version.s3_key));
assert_eq!(download_info.filename, "doc.txt");
let redirect = app.get(&detail.document.download_path, None).await?;
assert_eq!(redirect.status(), StatusCode::TEMPORARY_REDIRECT);
let location = redirect
.headers()
.get("location")
.expect("redirect location header");
let location = location.to_str().expect("location header utf8");
assert!(location.contains(&detail.current_version.s3_key));
app.cleanup().await?;
Ok(())
}
+53 -1
View File
@@ -21,11 +21,16 @@ struct DocumentInfo {
#[derive(Deserialize)]
struct TagInfo {
label: String,
#[allow(dead_code)]
color: Option<String>,
}
#[derive(Deserialize)]
struct TagResponse {
id: Uuid,
label: String,
color: Option<String>,
usage_count: i64,
}
#[derive(Serialize)]
@@ -75,6 +80,53 @@ async fn tag_assignment_flow() -> Result<()> {
assert_eq!(create_tag.status(), StatusCode::OK);
let body = body_to_vec(create_tag.into_body()).await?;
let tag: TagResponse = serde_json::from_slice(&body)?;
assert_eq!(tag.label, "Important");
assert_eq!(tag.color.as_deref(), Some("#FF0000"));
assert_eq!(tag.usage_count, 0);
let update = app
.patch_json(
&format!("/api/tags/{}", tag.id),
&serde_json::json!({
"label": "Critical",
"color": "#00FF00"
}),
Some(&token),
)
.await?;
let updated_status = update.status();
let updated_body = body_to_vec(update.into_body()).await?;
if updated_status != StatusCode::OK {
panic!(
"update tag failed: {}",
String::from_utf8_lossy(&updated_body)
);
}
let updated: TagResponse = serde_json::from_slice(&updated_body)?;
assert_eq!(updated.label, "Critical");
assert_eq!(updated.color.as_deref(), Some("#00FF00"));
assert_eq!(updated.usage_count, 0);
let clear_color = app
.patch_json(
&format!("/api/tags/{}", tag.id),
&serde_json::json!({
"color": null
}),
Some(&token),
)
.await?;
let cleared_status = clear_color.status();
let cleared_body = body_to_vec(clear_color.into_body()).await?;
if cleared_status != StatusCode::OK {
panic!(
"clear color failed: {}",
String::from_utf8_lossy(&cleared_body)
);
}
let cleared: TagResponse = serde_json::from_slice(&cleared_body)?;
assert_eq!(cleared.color, None);
assert_eq!(cleared.usage_count, 0);
let assign = app
.post_json(
@@ -97,7 +149,7 @@ async fn tag_assignment_flow() -> Result<()> {
let refreshed_body = body_to_vec(refreshed.into_body()).await?;
let refreshed_detail: DocumentDetail = serde_json::from_slice(&refreshed_body)?;
assert_eq!(refreshed_detail.document.tags.len(), 1);
assert_eq!(refreshed_detail.document.tags[0].label, "Important");
assert_eq!(refreshed_detail.document.tags[0].label, "Critical");
let remove = app
.delete(
+643 -105
View File
@@ -24,11 +24,56 @@ const api = axios.create({
withCredentials: true,
});
const STORED_TOKEN = window.localStorage.getItem('paperless_token') || '';
if (STORED_TOKEN) {
api.defaults.headers.common.Authorization = `Bearer ${STORED_TOKEN}`;
}
const DEFAULT_FOLDER_NAME = 'All Documents';
const resolveApiPath = (path = '') => (API_ROOT ? `${API_ROOT}${path}` : path);
const hasFiles = (event) =>
Array.from(event.dataTransfer?.types || []).includes('Files');
const HEX_COLOR_PATTERN = /^#?([0-9a-fA-F]{6})$/;
const hexToRgb = (input) => {
if (!input) return null;
const match = HEX_COLOR_PATTERN.exec(input.trim());
if (!match) return null;
const value = parseInt(match[1], 16);
return {
r: (value >> 16) & 0xff,
g: (value >> 8) & 0xff,
b: value & 0xff,
hex: `#${match[1].toLowerCase()}`,
};
};
const relativeLuminance = ({ r, g, b }) => {
const transform = (channel) => {
const normalized = channel / 255;
return normalized <= 0.03928
? normalized / 12.92
: ((normalized + 0.055) / 1.055) ** 2.4;
};
const [red, green, blue] = [transform(r), transform(g), transform(b)];
return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
};
const getTagColorStyle = (hex) => {
const rgb = hexToRgb(hex);
if (!rgb) return null;
const luminance = relativeLuminance(rgb);
const textColor = luminance > 0.6 ? '#1f1f1f' : '#ffffff';
return {
backgroundColor: rgb.hex,
borderColor: rgb.hex,
color: textColor,
};
};
const IconChevronRight = ({ className }) => (
<svg
className={className ? `icon ${className}` : 'icon'}
@@ -292,16 +337,28 @@ const FilterBar = ({
/>
<div className="tag-filters">
{tags.length ? (
tags.map((tag) => (
<button
key={tag.id}
type="button"
className={`tag-filter${activeTagIds.includes(tag.id) ? ' active' : ''}`}
onClick={() => onToggleTag(tag.id)}
>
{tag.label}
</button>
))
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 rgba(0, 0, 0, 0.18)' : 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>
)}
@@ -339,8 +396,9 @@ const DocumentsTable = ({
draggingDocumentIds = [],
onDocumentDragStart,
onDocumentDragEnd,
onDownload,
onDocumentDelete,
filterBar,
tagLookupById,
}) => {
const showingSearchResults = searchResults !== null;
const rows = showingSearchResults ? searchResults : documents;
@@ -441,7 +499,6 @@ const DocumentsTable = ({
<td>{folder.name}</td>
<td>Folder</td>
<td></td>
<td></td>
<td className="actions">
<button
type="button"
@@ -498,11 +555,20 @@ const DocumentsTable = ({
<span className="doc-name__title">{doc.title || doc.original_name}</span>
{(doc.tags || []).length > 0 && (
<div className="doc-name__tags">
{(doc.tags || []).map((tag) => (
<span key={tag.id} className="badge">
{tag.label}
</span>
))}
{(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}
>
{tag.label}
</span>
);
})}
</div>
)}
</div>
@@ -514,17 +580,34 @@ const DocumentsTable = ({
: '—'
}</td>
<td className="actions">
<button
type="button"
className="with-icon"
onClick={(event) => {
event.stopPropagation();
onDownload(doc.id);
}}
>
<IconDownload className="icon-inline" />
<span>Download</span>
</button>
<div className="action-buttons">
{doc.download_path ? (
<a
className="button-link with-icon"
href={resolveApiPath(doc.download_path)}
target="_blank"
rel="noopener noreferrer"
onClick={(event) => event.stopPropagation()}
onAuxClick={(event) => event.stopPropagation()}
onContextMenu={(event) => event.stopPropagation()}
>
<IconDownload className="icon-inline" />
<span>Download</span>
</a>
) : (
<span className="meta">No download</span>
)}
<button
type="button"
className="danger"
onClick={(event) => {
event.stopPropagation();
onDocumentDelete?.(doc.id);
}}
>
Delete
</button>
</div>
</td>
</tr>
);
@@ -639,7 +722,8 @@ const DetailPanel = ({
selectedDocuments = [],
detailMap = new Map(),
tags = [],
onDownload,
tagLookupById = new Map(),
tagLookupByLabel = new Map(),
onTagAdd,
onTagRemove,
onRegenerateThumbnails,
@@ -746,6 +830,9 @@ const DetailPanel = ({
}
const displayName = singleDoc.title || singleDoc.original_name;
const downloadHref = singleDoc.download_path
? resolveApiPath(singleDoc.download_path)
: null;
return (
<>
@@ -788,10 +875,21 @@ const DetailPanel = ({
</div>
</div>
<div className="detail-actions">
<button type="button" className="with-icon" onClick={() => onDownload(singleDoc.id)}>
<a
className="button-link with-icon"
href={downloadHref || '#'}
target="_blank"
rel="noopener noreferrer"
aria-disabled={!downloadHref}
onClick={(event) => {
if (!downloadHref) {
event.preventDefault();
}
}}
>
<IconDownload className="icon-inline" />
<span>Download</span>
</button>
</a>
<button
type="button"
className="secondary"
@@ -811,14 +909,18 @@ const DetailPanel = ({
<dt>Tags</dt>
<div className="tag-list">
{detail.document.tags?.length ? (
detail.document.tags.map((tag) => (
<span key={tag.id} className="tag-pill">
{tag.label}{' '}
<button type="button" onClick={() => onTagRemove(singleDoc.id, tag.id)}>
×
</button>
</span>
))
detail.document.tags.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>
)}
@@ -884,11 +986,16 @@ const DetailPanel = ({
</div>
{commonTags.length > 0 && (
<div className="bulk-tags">
{commonTags.map((label) => (
<span key={label} className="tag-pill">
{label}
</span>
))}
{commonTags.map((label) => {
const key = typeof label === 'string' ? label.toLowerCase() : '';
const tagInfo = key ? tagLookupByLabel.get(key) : null;
const style = getTagColorStyle(tagInfo?.color);
return (
<span key={label} className="tag-pill" style={style || undefined}>
{label}
</span>
);
})}
</div>
)}
<div className="bulk-detail-actions">
@@ -971,7 +1078,6 @@ const PreviewWorkspace = ({
detail,
previewEntry,
onClose,
onDownload,
onRegenerateThumbnails,
}) => {
if (!document) {
@@ -984,6 +1090,9 @@ const PreviewWorkspace = ({
detail?.document?.original_name ||
document.original_name;
const mime = previewEntry?.contentType || document.content_type || 'application/pdf';
const downloadHref = document.download_path
? resolveApiPath(document.download_path)
: null;
return (
<section className="preview-workspace">
@@ -1007,10 +1116,21 @@ const PreviewWorkspace = ({
</div>
</div>
<div className="preview-workspace__actions">
<button type="button" className="with-icon" onClick={() => onDownload(document.id)}>
<a
className="button-link with-icon"
href={downloadHref || '#'}
target="_blank"
rel="noopener noreferrer"
aria-disabled={!downloadHref}
onClick={(event) => {
if (!downloadHref) {
event.preventDefault();
}
}}
>
<IconDownload className="icon-inline" />
<span>Download</span>
</button>
</a>
<button
type="button"
className="secondary"
@@ -1122,12 +1242,279 @@ const Sidebar = ({
{rootNode && renderNodes([rootNode.id], 0)}
</ul>
</div>
<div className="sidebar-section">
<div className="sidebar-section__header">
<h3>Tags</h3>
<span className="meta">{tags.length}</span>
</div>
<button
type="button"
className={`sidebar-link${isTagsView ? ' active' : ''}`}
onClick={onShowTags}
>
All tags
</button>
</div>
</div>
</aside>
);
};
function App({ routeFolderId = null, routeDocumentId = null, navigate }) {
const TagsPanel = ({ tags, onRefresh, onUpdateTag }) => {
const [editingId, setEditingId] = useState(null);
const [draftLabel, setDraftLabel] = useState('');
const [draftColor, setDraftColor] = useState('');
const [error, setError] = useState(null);
const [saving, setSaving] = useState(false);
const startEdit = useCallback((tag) => {
setEditingId(tag.id);
setDraftLabel(tag.label || '');
setDraftColor(tag.color || '');
setError(null);
}, []);
const cancelEdit = useCallback(() => {
setEditingId(null);
setDraftLabel('');
setDraftColor('');
setSaving(false);
setError(null);
}, []);
const handleSave = useCallback(async () => {
if (!editingId) return;
const trimmedLabel = draftLabel.trim();
if (!trimmedLabel) {
setError('Tag label cannot be empty.');
return;
}
const trimmedColor = draftColor.trim();
const colorPattern = /^#([0-9a-fA-F]{6})$/;
if (trimmedColor && !colorPattern.test(trimmedColor)) {
setError('Colors must use the #RRGGBB format.');
return;
}
setSaving(true);
setError(null);
try {
await onUpdateTag(editingId, {
label: trimmedLabel,
color: trimmedColor ? trimmedColor : null,
});
cancelEdit();
} catch (updateError) {
const message = updateError?.message || 'Failed to update tag.';
setError(message);
} finally {
setSaving(false);
}
}, [editingId, draftLabel, draftColor, onUpdateTag, cancelEdit]);
const handleKeyDown = useCallback(
(event) => {
if (event.key === 'Enter') {
event.preventDefault();
handleSave();
} else if (event.key === 'Escape') {
event.preventDefault();
cancelEdit();
}
},
[handleSave, cancelEdit],
);
return (
<section className="tags-panel column">
<div className="column-header">
<div className="column-header__titles">
<h2>Tags</h2>
<div className="column-subtitle">{tags.length} total</div>
</div>
<div className="header-actions">
<button className="secondary" type="button" onClick={onRefresh} disabled={saving}>
Refresh
</button>
</div>
</div>
<div className="column-body tags-panel__body">
{error && (
<div className="tags-panel__error" role="alert">
{error}
</div>
)}
{tags.length === 0 ? (
<div className="empty-state">No tags created yet.</div>
) : (
<div className="tags-table">
<table>
<thead>
<tr>
<th scope="col">Tag</th>
<th scope="col">Color</th>
<th scope="col" className="numeric">
Documents
</th>
<th scope="col" className="actions">
Actions
</th>
</tr>
</thead>
<tbody>
{tags.map((tag) => {
const isEditing = editingId === tag.id;
return (
<tr key={tag.id} className={isEditing ? 'editing' : ''}>
<td className="tags-table__label">
{isEditing ? (
<input
className="tags-table__label-input"
value={draftLabel}
onChange={(event) => setDraftLabel(event.target.value)}
onKeyDown={handleKeyDown}
disabled={saving}
autoFocus
/>
) : (
<span
className="badge tag-chip"
style={getTagColorStyle(tag.color) || undefined}
>
{tag.label}
</span>
)}
</td>
<td>
{isEditing ? (
<div className="tags-table__color-editor">
<input
className="tags-table__color-field"
value={draftColor}
onChange={(event) => setDraftColor(event.target.value)}
onKeyDown={handleKeyDown}
placeholder="#3366ff"
spellCheck="false"
disabled={saving}
/>
{draftColor && (
<button
type="button"
className="secondary"
onClick={() => setDraftColor('')}
disabled={saving}
>
Clear
</button>
)}
</div>
) : tag.color ? (
<span
className="tags-table__swatch"
style={{ backgroundColor: tag.color }}
aria-label={`Tag color ${tag.color}`}
/>
) : (
<span className="meta"></span>
)}
</td>
<td className="numeric">{tag.usage_count ?? 0}</td>
<td className="actions">
{isEditing ? (
<div className="tags-table__edit-controls">
<button
type="button"
className="secondary"
onClick={handleSave}
disabled={saving}
>
Save
</button>
<button
type="button"
className="secondary"
onClick={cancelEdit}
disabled={saving}
>
Cancel
</button>
</div>
) : (
<button
type="button"
className="secondary"
onClick={() => startEdit(tag)}
>
Edit
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</section>
);
};
const MainLayout = ({ sidebarProps, children, className = 'app-main' }) => (
<main className={className}>
<Sidebar {...sidebarProps} />
{children}
</main>
);
const DocumentsWorkspace = ({
sidebarProps,
previewActive,
previewWorkspaceDocument,
previewWorkspaceDetail,
previewWorkspaceEntry,
closeDocumentPreview,
handleThumbnailRegeneration,
documentsTableProps,
detailPanelProps,
}) => {
if (previewActive && previewWorkspaceDocument) {
return (
<main className="preview-main">
<PreviewWorkspace
document={previewWorkspaceDocument}
detail={previewWorkspaceDetail}
previewEntry={previewWorkspaceEntry}
onClose={closeDocumentPreview}
onRegenerateThumbnails={handleThumbnailRegeneration}
/>
</main>
);
}
return (
<MainLayout sidebarProps={sidebarProps}>
<DocumentsTable {...documentsTableProps} />
<DetailPanel {...detailPanelProps} />
</MainLayout>
);
};
const TagsWorkspace = ({ sidebarProps, tags, onRefresh, onUpdateTag }) => (
<MainLayout sidebarProps={sidebarProps} className="tags-main">
<TagsPanel tags={tags} onRefresh={onRefresh} onUpdateTag={onUpdateTag} />
</MainLayout>
);
function App({
routeFolderId = null,
routeDocumentId = null,
navigate,
mode = 'documents',
}) {
const [token, setToken] = useState(() => window.localStorage.getItem('paperless_token') || '');
const [status, setStatus] = useState(null);
const setStatusMessage = useCallback((message, variant = 'info') => {
@@ -1186,6 +1573,26 @@ function App({ routeFolderId = null, routeDocumentId = null, navigate }) {
const selectionAnchorRef = useRef(routeDocumentId);
const selectionOrderRef = useRef(initialSelection);
const tagLookupById = useMemo(() => {
const map = new Map();
tags.forEach((tag) => {
if (tag?.id) {
map.set(tag.id, tag);
}
});
return map;
}, [tags]);
const tagLookupByLabel = useMemo(() => {
const map = new Map();
tags.forEach((tag) => {
if (tag?.label) {
map.set(tag.label.toLowerCase(), tag);
}
});
return map;
}, [tags]);
const updateSelectionOrder = useCallback((nextSelection, interactedIds = []) => {
const nextSet = new Set(nextSelection);
const previousOrder = selectionOrderRef.current.filter((id) => nextSet.has(id));
@@ -1559,6 +1966,40 @@ function App({ routeFolderId = null, routeDocumentId = null, navigate }) {
],
);
const handleDocumentDragStart = useCallback(
(event, documentId) => {
const selection = selectedDocumentIds.includes(documentId)
? selectedDocumentIds
: [documentId];
if (!selectedDocumentIds.includes(documentId)) {
applySelection([documentId], {
anchor: documentId,
interactedIds: [documentId],
});
}
setDraggedDocumentIds(selection);
event.dataTransfer.effectAllowed = 'move';
try {
event.dataTransfer.setData(
'application/x-paperless-doc-list',
JSON.stringify(selection),
);
} catch (
// eslint-disable-next-line no-empty
error
) {}
event.currentTarget.classList.add('dragging');
},
[selectedDocumentIds, applySelection],
);
const handleDocumentDragEnd = useCallback((event) => {
setDraggedDocumentIds([]);
event.currentTarget.classList.remove('dragging');
}, []);
const ensureFolderData = useCallback(
async (folderId, { force = false } = {}) => {
if (!force && folderContents.has(folderId)) {
@@ -1795,6 +2236,39 @@ function App({ routeFolderId = null, routeDocumentId = null, navigate }) {
}
}, [setStatusMessage]);
const handleTagUpdate = useCallback(
async (tagId, changes) => {
if (!tagId) {
throw new Error('Missing tag identifier.');
}
const payload = {};
if (typeof changes.label === 'string') {
payload.label = changes.label;
}
if (Object.prototype.hasOwnProperty.call(changes, 'color')) {
payload.color = changes.color;
}
if (Object.keys(payload).length === 0) {
return false;
}
try {
await api.patch(`/tags/${tagId}`, payload);
await refreshTags();
setStatusMessage('Tag updated.', 'success');
return true;
} catch (error) {
console.error(error);
const message = error.response?.data?.error || 'Failed to update tag.';
setStatusMessage(message, 'error');
throw new Error(message);
}
},
[api, refreshTags, setStatusMessage],
);
const loadFolder = useCallback(
async (folderId, { showLoading = true } = {}) => {
const targetId = folderId || 'root';
@@ -2474,23 +2948,6 @@ function App({ routeFolderId = null, routeDocumentId = null, navigate }) {
[selectedDocumentIds, moveDocumentsToFolder, setStatusMessage],
);
const handleDownload = useCallback(
async (documentId) => {
try {
setLoading(true);
const { data } = await api.get(`/documents/${documentId}/download`);
window.open(data.url, '_blank');
setStatusMessage('Download link opened in a new tab.', 'success');
} catch (error) {
console.error(error);
setStatusMessage('Unable to fetch download link.', 'error');
} finally {
setLoading(false);
}
},
[setStatusMessage],
);
const handleThumbnailRegeneration = useCallback(
async (documentId) => {
if (!token) {
@@ -3332,7 +3789,81 @@ const handleDownload = useCallback(
return pool.find((doc) => doc.id === previewDocumentId) || null;
}, [previewDocumentId, previewWorkspaceDetail, searchResults, documents]);
const previewActive = Boolean(previewDocumentId && previewWorkspaceDocument);
const isTagsView = mode === 'tags';
const previewActive = !isTagsView && Boolean(previewDocumentId && previewWorkspaceDocument);
const goToTagsView = useCallback(() => {
if (!navigate || isTagsView) {
return;
}
navigate('/tags');
}, [navigate, isTagsView]);
const sidebarProps = {
folderNodes,
onToggle: folderClickHandlers.onToggle,
onSelect: folderClickHandlers.onSelect,
onDrop: folderClickHandlers.onDrop,
onDragOver: folderClickHandlers.onDragOver,
onDragLeave: folderClickHandlers.onDragLeave,
onCreateFolder: handleFolderCreate,
onDeleteFolder: handleFolderDelete,
selectedFolder,
onFolderDragStart: handleFolderDragStart,
onFolderDragEnd: handleFolderDragEnd,
draggedFolderId,
onShowTags: goToTagsView,
isTagsView,
tags,
};
const documentsTableProps = {
currentFolderName,
breadcrumbs,
onRefresh: refreshCurrentFolder,
subfolders: currentSubfolders,
documents,
searchResults,
isFilterActive,
onFolderSelect: selectFolder,
onFolderDrop: folderClickHandlers.onDrop,
onFolderDragOver: folderClickHandlers.onDragOver,
onFolderDragLeave: folderClickHandlers.onDragLeave,
onFolderDragStart: handleFolderDragStart,
onFolderDragEnd: handleFolderDragEnd,
draggedFolderId,
onFolderDelete: handleFolderDelete,
onDocumentRowClick: handleDocumentRowClick,
onDocumentOpen: openDocumentPreview,
selectedDocumentIds,
focusedDocumentId,
draggingDocumentIds: draggedDocumentIds,
onDocumentDragStart: handleDocumentDragStart,
onDocumentDragEnd: handleDocumentDragEnd,
filterBar,
tagLookupById,
};
const detailPanelProps = {
selectedDocuments: orderedSelectedDocuments,
detailMap: documentDetails,
tags,
tagLookupById,
tagLookupByLabel,
onTagAdd: handleTagAdd,
onTagRemove: handleTagRemove,
onRegenerateThumbnails: handleThumbnailRegeneration,
previewEntry: selectedPreviewEntry,
onOpenPreview: openDocumentPreview,
onBulkTagAdd: handleBulkTagAddFromDetail,
onBulkTagRemove: handleBulkTagRemoveFromDetail,
onBulkMove: handleBulkMoveFromDetail,
onBulkReanalyze: handleBulkSelectionReanalyze,
folderOptions,
defaultMoveTarget,
onPromoteSelection: promoteSelectionOrder,
activePreviewId,
};
return (
<div className="app-shell">
@@ -3378,7 +3909,6 @@ const handleDownload = useCallback(
detail={previewWorkspaceDetail}
previewEntry={previewWorkspaceEntry}
onClose={closeDocumentPreview}
onDownload={handleDownload}
onRegenerateThumbnails={handleThumbnailRegeneration}
/>
) : (
@@ -3419,40 +3949,38 @@ const handleDownload = useCallback(
focusedDocumentId={focusedDocumentId}
draggingDocumentIds={draggedDocumentIds}
onDocumentDragStart={(event, documentId) => {
const selection = selectedDocumentIds.includes(documentId)
? selectedDocumentIds
: [documentId];
if (!selectedDocumentIds.includes(documentId)) {
applySelection([documentId], {
anchor: documentId,
interactedIds: [documentId],
});
}
setDraggedDocumentIds(selection);
event.dataTransfer.effectAllowed = 'move';
try {
event.dataTransfer.setData(
'application/x-paperless-doc-list',
JSON.stringify(selection),
);
} catch (
// eslint-disable-next-line no-empty
error
) {}
event.currentTarget.classList.add('dragging');
}}
onDocumentDragEnd={(event) => {
setDraggedDocumentIds([]);
event.currentTarget.classList.remove('dragging');
}}
onDownload={handleDownload}
filterBar={filterBar}
/>
<DetailPanel
selectedDocuments={orderedSelectedDocuments}
detailMap={documentDetails}
const selection = selectedDocumentIds.includes(documentId)
? selectedDocumentIds
: [documentId];
if (!selectedDocumentIds.includes(documentId)) {
applySelection([documentId], {
anchor: documentId,
interactedIds: [documentId],
});
}
setDraggedDocumentIds(selection);
event.dataTransfer.effectAllowed = 'move';
try {
event.dataTransfer.setData(
'application/x-paperless-doc-list',
JSON.stringify(selection),
);
} catch (
// eslint-disable-next-line no-empty
error
) {}
event.currentTarget.classList.add('dragging');
}}
onDocumentDragEnd={(event) => {
setDraggedDocumentIds([]);
event.currentTarget.classList.remove('dragging');
}}
filterBar={filterBar}
/>
<DetailPanel
selectedDocuments={orderedSelectedDocuments}
detailMap={documentDetails}
tags={tags}
onDownload={handleDownload}
onTagAdd={handleTagAdd}
onTagRemove={handleTagRemove}
onRegenerateThumbnails={handleThumbnailRegeneration}
@@ -3478,7 +4006,7 @@ const handleDownload = useCallback(
);
}
const RoutedApp = () => {
const RoutedDocumentsApp = () => {
const navigate = useNavigate();
const params = useParams();
const folderId = params.folderId ?? null;
@@ -3486,6 +4014,7 @@ const RoutedApp = () => {
return (
<App
mode="documents"
routeFolderId={folderId}
routeDocumentId={documentId}
navigate={navigate}
@@ -3493,13 +4022,22 @@ const RoutedApp = () => {
);
};
const RoutedTagsApp = () => {
const navigate = useNavigate();
return <App mode="tags" navigate={navigate} />;
};
const AppRouter = () => (
<Routes>
<Route path="/" element={<Navigate to="/folders" replace />} />
<Route path="/folders" element={<RoutedApp />} />
<Route path="/folders/:folderId" element={<RoutedApp />} />
<Route path="/documents/:documentId" element={<RoutedApp />} />
<Route path="/folders/:folderId/documents/:documentId" element={<RoutedApp />} />
<Route path="/folders" element={<RoutedDocumentsApp />} />
<Route path="/folders/:folderId" element={<RoutedDocumentsApp />} />
<Route path="/documents/:documentId" element={<RoutedDocumentsApp />} />
<Route
path="/folders/:folderId/documents/:documentId"
element={<RoutedDocumentsApp />}
/>
<Route path="/tags" element={<RoutedTagsApp />} />
<Route path="*" element={<Navigate to="/folders" replace />} />
</Routes>
);
+180 -7
View File
@@ -52,6 +52,30 @@ button:hover:not([disabled]) {
background: #2047d9;
}
a.button-link {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font: inherit;
border-radius: 2px;
border: 1px solid transparent;
padding: 0.35rem 0.85rem;
background: var(--accent);
color: #fff;
text-decoration: none;
font-weight: 500;
transition: background 0.15s ease, border-color 0.15s ease;
}
a.button-link[aria-disabled='true'] {
opacity: 0.55;
pointer-events: none;
}
a.button-link:hover:not([aria-disabled='true']) {
background: #2047d9;
}
button.secondary {
background: transparent;
color: var(--fg);
@@ -194,6 +218,16 @@ button.icon-button.ghost:hover:not([disabled]) {
overflow: hidden;
}
.tags-main {
flex: 1;
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
gap: 1.5rem;
padding: 0.75rem 1.5rem 1.25rem;
min-height: 0;
overflow: hidden;
}
.preview-main {
flex: 1;
display: flex;
@@ -254,6 +288,90 @@ button.icon-button.ghost:hover:not([disabled]) {
border: none;
}
.tags-panel__body {
overflow-y: auto;
}
.tags-table {
width: 100%;
overflow: auto;
}
.tags-table table {
width: 100%;
border-collapse: collapse;
min-width: 320px;
}
.tags-table th,
.tags-table td {
padding: 0.45rem 0.6rem;
text-align: left;
border-bottom: 1px solid var(--divider);
font-size: 0.85rem;
}
.tags-table th.numeric,
.tags-table td.numeric {
text-align: right;
}
.tags-table th.actions,
.tags-table td.actions {
text-align: right;
width: 0;
}
.tags-table tbody tr:hover {
background: var(--surface-subtle);
}
.tags-table tr.editing {
background: rgba(43, 92, 255, 0.08);
}
.tags-table__label {
max-width: 24rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tags-table__swatch {
display: inline-block;
width: 1rem;
height: 1rem;
border-radius: 2px;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1);
}
.tags-panel__error {
margin: 0.5rem 0;
color: var(--danger);
font-size: 0.8rem;
}
.tags-table__label-input {
width: 100%;
}
.tags-table__color-editor {
display: flex;
align-items: center;
gap: 0.4rem;
}
.tags-table__color-field {
width: 7rem;
font-family: var(--monospace, 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace);
}
.tags-table__edit-controls {
display: flex;
justify-content: flex-end;
gap: 0.4rem;
}
.preview-workspace__message {
color: var(--muted);
font-size: 0.95rem;
@@ -441,6 +559,52 @@ button.icon-button.ghost:hover:not([disabled]) {
padding: 0;
}
.sidebar-section {
margin-top: 1rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.sidebar-section__header {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 0.75rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.sidebar-section__header h3 {
margin: 0;
font-size: 0.75rem;
font-weight: 600;
color: inherit;
}
.sidebar-link {
border: none;
background: transparent;
padding: 0.3rem 0.3rem;
text-align: left;
font-size: 0.82rem;
color: var(--fg);
border-radius: 3px;
cursor: pointer;
transition: background 0.12s ease, color 0.12s ease;
}
.sidebar-link:hover {
background: var(--surface-subtle);
}
.sidebar-link.active {
background: rgba(43, 92, 255, 0.12);
color: var(--accent);
font-weight: 600;
}
.folder-row.is-drop-target {
outline: 2px dashed var(--accent);
outline-offset: 2px;
@@ -631,22 +795,22 @@ button.icon-button.ghost:hover:not([disabled]) {
.tag-filter {
border: 1px solid var(--border);
background: transparent;
background: var(--surface-subtle);
color: var(--fg);
padding: 0.25rem 0.6rem;
border-radius: 2px;
cursor: pointer;
font-size: 0.85rem;
transition: background 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
transition: transform 0.12s ease, box-shadow 0.12s ease, filter 0.12s ease;
}
.tag-filter:hover {
background: var(--surface-subtle);
filter: brightness(0.97);
}
.tag-filter.active {
background: var(--accent);
color: white;
box-shadow: 0 0 0 1px currentColor inset;
transform: translateY(-1px);
}
.filter-actions {
@@ -668,7 +832,11 @@ button.icon-button.ghost:hover:not([disabled]) {
background: var(--surface-subtle);
color: var(--fg);
font-size: 0.74rem;
margin-right: 0.25rem;
border: 1px solid transparent;
}
.tag-chip {
gap: 0.25rem;
}
.empty-state {
@@ -866,10 +1034,15 @@ button.icon-button.ghost:hover:not([disabled]) {
.tag-pill button {
background: none;
border: none;
color: var(--muted);
color: inherit;
padding: 0;
cursor: pointer;
font-size: 0.85rem;
opacity: 0.8;
}
.tag-pill button:hover {
opacity: 1;
}
input,