43 Commits
Author SHA1 Message Date
nils 6a04d29eed metadata 2025-10-27 00:17:15 +01:00
nils 10e28cc5e2 previewzoomoverlay 2025-10-27 00:14:10 +01:00
nils 970678986a sidebar shadow 2025-10-26 23:15:12 +01:00
nils 40e45f3a01 previewzoomoverlay 2025-10-26 21:01:48 +01:00
nils 518b79bec6 stuff 2025-10-26 20:29:43 +01:00
nils 01fb4e308a cleanup 2025-10-26 19:46:32 +01:00
nils b6300ffa30 panel actions 2025-10-26 19:14:35 +01:00
nils b163a07e84 lazy-thumbnails 2025-10-26 10:36:47 +01:00
nils 42a3a53314 panning 2025-10-26 02:52:26 +02:00
nils 4a06fa82eb cache warming 2025-10-26 02:19:19 +02:00
nils 80238bb7a1 zoom 2025-10-26 02:05:31 +02:00
nils dcbd46531e preview zoom 2025-10-26 01:45:37 +02:00
nils 0864b39336 panel actions 2025-10-26 01:04:21 +02:00
nils 2a0c96bb4c detail panel 2025-10-26 00:28:40 +02:00
nils c53213a357 cleanup 2025-10-25 23:49:51 +02:00
nils dc633783e0 frontend 2025-10-24 15:13:37 +02:00
nils e33ab71fac move to sidebar 2025-10-24 14:07:09 +02:00
nils f7a3e3f0f8 compose 2025-10-24 02:08:25 +02:00
nils 72df3dec3b reanalyze removal 2025-10-24 01:17:39 +02:00
nils b80ec5c6ac admin 2025-10-24 00:43:11 +02:00
nils fbd3aff6d8 user tenant_id 2025-10-23 16:58:52 +02:00
nils 0ad79c9bc1 more multi-tenancy 2025-10-23 16:06:56 +02:00
nils e175d28c2c multi tenancy part 1 2025-10-22 22:52:59 +02:00
nils 8e3f09774a frontend: remove tabindex=0 (confuses password managers) 2025-10-22 18:16:09 +02:00
nils dbc54032f7 backend: storage_paths.rs 2025-10-21 21:24:04 +02:00
nils 047b99e2aa frontend: multi-page assets
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 26s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 56s
2025-10-20 21:59:58 +02:00
nils 1859078cf4 backend: generate thumbnails and previews for all pages
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 15m27s
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 15m30s
2025-10-20 16:54:56 +02:00
nils c44ba91ec7 backend: split assets into objects 2025-10-20 16:28:57 +02:00
nils 96a6d0ee5d backend: reduce assets detail in DocumentVersionResponse 2025-10-20 14:26:11 +02:00
nils 5619bb27a1 frontend: styling
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 24s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 50s
2025-10-20 13:51:43 +02:00
nils dfc99c7d15 frontend: rounded tags 2025-10-20 11:32:46 +02:00
nils fb82f505b6 frontend: tweak documents table
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 23s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 51s
2025-10-20 10:45:01 +02:00
nils 5970340a17 frontend: fix regression when pointer leaves document while dragging
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 20s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 46s
2025-10-19 23:26:27 +02:00
nils 9a77e76ff4 frontend: make skeuomorphic workspace compatible with sidebar
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 14m32s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 14m31s
2025-10-19 23:05:38 +02:00
nils 3591415d46 frontend: refactor preview 2025-10-19 21:23:35 +02:00
nils ab06205744 backend: split document assets into assets and objects 2025-10-19 19:46:16 +02:00
nils 0689e680fa style fix 2025-10-18 09:22:38 +02:00
nils 297d5aca1f frontend: papercorner for multi-page documents 2025-10-18 01:33:12 +02:00
nils 0a023c1556 frontend: show page count in DetailPanel 2025-10-17 23:19:09 +02:00
nils aa600af7b2 frontend: fix drop shadow for thumbnails 2025-10-17 23:18:18 +02:00
nils fc4aae8c0c frontend: fix folder.svg usage 2025-10-17 23:14:44 +02:00
nils 24c0b5fa52 add tmux workflow for local dev
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 19s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 20s
2025-10-17 21:56:54 +02:00
nils 2af2af3460 Initial commit
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 21s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 23s
2025-10-17 20:19:47 +02:00
101 changed files with 8039 additions and 3172 deletions
+10
View File
@@ -1,5 +1,15 @@
# Papercrate # Papercrate
## Local Development
Use the provided `papercrate.tmux` to spin up the full stack in one tmux session:
```bash
tmux -f papercrate.tmux attach
```
This creates windows for the compose stack, frontend dev server, backend API, and background worker using the repository-relative paths defined in the tmux file. Detach with `Ctrl+b d` and reattach later with the same command.
## Backend Integration Tests ## Backend Integration Tests
Integration tests require a running Postgres instance (and, optionally, Quickwit for OCR indexing). The repository includes a lightweight compose file for local runs: Integration tests require a running Postgres instance (and, optionally, Quickwit for OCR indexing). The repository includes a lightweight compose file for local runs:
+1
View File
@@ -44,6 +44,7 @@ base64 = "0.21"
quick-xml = "0.32" quick-xml = "0.32"
futures-util = "0.3" futures-util = "0.3"
url = "2.5" url = "2.5"
once_cell = "1.19"
# Error handling # Error handling
thiserror = "1.0" thiserror = "1.0"
+2 -1
View File
@@ -20,7 +20,7 @@ COPY migrations ./migrations
COPY tests ./tests COPY tests ./tests
COPY diesel.toml ./ COPY diesel.toml ./
RUN cargo build --release --bin backend --bin worker --bin webdav RUN cargo build --release --bin backend --bin worker --bin webdav --bin admin
RUN cargo install diesel_cli --no-default-features --features postgres RUN cargo install diesel_cli --no-default-features --features postgres
FROM debian:trixie-slim AS runtime FROM debian:trixie-slim AS runtime
@@ -53,6 +53,7 @@ RUN apt-get update \
COPY --from=builder /app/target/release/backend /usr/local/bin/papercrate-backend COPY --from=builder /app/target/release/backend /usr/local/bin/papercrate-backend
COPY --from=builder /app/target/release/worker /usr/local/bin/papercrate-worker COPY --from=builder /app/target/release/worker /usr/local/bin/papercrate-worker
COPY --from=builder /app/target/release/webdav /usr/local/bin/papercrate-webdav COPY --from=builder /app/target/release/webdav /usr/local/bin/papercrate-webdav
COPY --from=builder /app/target/release/admin /usr/local/bin/papercrate-admin
COPY --from=builder /usr/local/cargo/bin/diesel /usr/local/bin/diesel COPY --from=builder /usr/local/cargo/bin/diesel /usr/local/bin/diesel
COPY migrations ./migrations COPY migrations ./migrations
COPY diesel.toml ./ COPY diesel.toml ./
-19
View File
@@ -1,19 +0,0 @@
use argon2::{
password_hash::{PasswordHasher, SaltString},
Argon2,
};
use rand::thread_rng;
use std::env;
fn main() {
let password = env::args()
.nth(1)
.expect("Usage: cargo run --example hash_password <password>");
let salt = SaltString::generate(&mut thread_rng());
let argon2 = Argon2::default();
let hash = argon2
.hash_password(password.as_bytes(), &salt)
.expect("hashing failed")
.to_string();
println!("{}", hash);
}
@@ -1,11 +0,0 @@
DROP INDEX IF EXISTS idx_document_tags_tag;
DROP TABLE IF EXISTS document_tags;
DROP INDEX IF EXISTS idx_document_versions_document;
DROP TABLE IF EXISTS document_versions;
DROP INDEX IF EXISTS idx_documents_deleted_at;
DROP INDEX IF EXISTS idx_documents_folder;
DROP TABLE IF EXISTS documents;
DROP INDEX IF EXISTS idx_folders_parent;
DROP TABLE IF EXISTS folders;
DROP TABLE IF EXISTS tags;
DROP TABLE IF EXISTS users;
@@ -1,77 +0,0 @@
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE TABLE users (
id UUID PRIMARY KEY,
username VARCHAR(100) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(16) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE folders (
id UUID PRIMARY KEY,
name VARCHAR(255) NOT NULL,
parent_id UUID REFERENCES folders(id) ON DELETE SET NULL,
path_cache VARCHAR(1000),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT folders_parent_name_unique UNIQUE (parent_id, name)
);
CREATE INDEX idx_folders_parent ON folders(parent_id);
CREATE TABLE documents (
id UUID PRIMARY KEY,
filename VARCHAR(255) NOT NULL,
original_name VARCHAR(255) NOT NULL,
content_type VARCHAR(100),
folder_id UUID REFERENCES folders(id) ON DELETE SET NULL,
current_version INTEGER NOT NULL,
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE INDEX idx_documents_folder ON documents(folder_id);
CREATE INDEX idx_documents_deleted_at ON documents(deleted_at);
CREATE TABLE document_versions (
id UUID PRIMARY KEY,
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
version_number INTEGER NOT NULL,
s3_key VARCHAR(500) NOT NULL,
size_bytes BIGINT NOT NULL,
checksum VARCHAR(64) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
operations_summary JSONB NOT NULL DEFAULT '{}'::jsonb,
CONSTRAINT document_versions_unique_version UNIQUE (document_id, version_number)
);
CREATE INDEX idx_document_versions_document ON document_versions(document_id);
CREATE TABLE tags (
id UUID PRIMARY KEY,
label VARCHAR(100) NOT NULL UNIQUE,
color VARCHAR(7),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE document_tags (
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(),
assigned_by UUID REFERENCES users(id),
PRIMARY KEY (document_id, tag_id)
);
CREATE INDEX idx_document_tags_tag ON document_tags(tag_id);
INSERT INTO users (id, username, password_hash, role)
VALUES (
gen_random_uuid(),
'admin',
'$argon2id$v=19$m=19456,t=2,p=1$UMkfsNut028fmZupy9JoQg$/YFvGQoEZ2hhMiDCyv68ZROF97GcwAxxRwRgwSbpX5U',
'admin'
);
@@ -1,5 +0,0 @@
DROP TRIGGER IF EXISTS trg_jobs_updated_at ON jobs;
DROP FUNCTION IF EXISTS touch_jobs_updated_at;
DROP INDEX IF EXISTS idx_jobs_job_type;
DROP INDEX IF EXISTS idx_jobs_status_run_after;
DROP TABLE IF EXISTS jobs;
@@ -1,28 +0,0 @@
CREATE TABLE jobs (
id UUID PRIMARY KEY,
job_type TEXT NOT NULL,
payload JSONB NOT NULL,
status TEXT NOT NULL DEFAULT 'queued',
attempts INTEGER NOT NULL DEFAULT 0,
run_after TIMESTAMPTZ NOT NULL DEFAULT now(),
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT jobs_status_check CHECK (status IN ('queued', 'processing', 'succeeded', 'failed'))
);
CREATE INDEX idx_jobs_status_run_after ON jobs (status, run_after);
CREATE INDEX idx_jobs_job_type ON jobs (job_type);
CREATE OR REPLACE FUNCTION touch_jobs_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_jobs_updated_at
BEFORE UPDATE ON jobs
FOR EACH ROW
EXECUTE FUNCTION touch_jobs_updated_at();
@@ -1,3 +0,0 @@
DROP INDEX IF EXISTS idx_document_assets_type;
DROP INDEX IF EXISTS idx_document_assets_version;
DROP TABLE IF EXISTS document_assets;
@@ -1,15 +0,0 @@
CREATE TABLE document_assets (
id UUID PRIMARY KEY,
document_version_id UUID NOT NULL REFERENCES document_versions(id) ON DELETE CASCADE,
asset_type TEXT NOT NULL,
s3_key TEXT NOT NULL,
mime_type TEXT NOT NULL,
width INTEGER,
height INTEGER,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT document_assets_unique UNIQUE (document_version_id, asset_type)
);
CREATE INDEX idx_document_assets_version ON document_assets(document_version_id);
CREATE INDEX idx_document_assets_type ON document_assets(asset_type);
@@ -1,4 +0,0 @@
DROP INDEX IF EXISTS folders_parent_name_unique_idx;
ALTER TABLE folders
ADD CONSTRAINT folders_parent_name_unique UNIQUE (parent_id, name);
@@ -1,5 +0,0 @@
ALTER TABLE folders
DROP CONSTRAINT IF EXISTS folders_parent_name_unique;
CREATE UNIQUE INDEX folders_parent_name_unique_idx
ON folders (COALESCE(parent_id, '00000000-0000-0000-0000-000000000000'::uuid), name);
@@ -1,2 +0,0 @@
ALTER TABLE documents
DROP COLUMN issued_at;
@@ -1,2 +0,0 @@
ALTER TABLE documents
ADD COLUMN issued_at TIMESTAMPTZ;
@@ -1,2 +0,0 @@
ALTER TABLE documents
DROP COLUMN name;
@@ -1,11 +0,0 @@
ALTER TABLE documents
ADD COLUMN name VARCHAR(255);
UPDATE documents
SET name = CASE
WHEN filename ~ '\\.[^./]+$' THEN regexp_replace(filename, '\\.[^./]+$', '')
ELSE filename
END;
ALTER TABLE documents
ALTER COLUMN name SET NOT NULL;
@@ -1,2 +0,0 @@
ALTER TABLE documents
RENAME COLUMN title TO name;
@@ -1,2 +0,0 @@
ALTER TABLE documents
RENAME COLUMN name TO title;
@@ -1 +0,0 @@
DROP TABLE refresh_tokens;
@@ -1,13 +0,0 @@
CREATE TABLE refresh_tokens (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL,
issued_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id);
CREATE INDEX idx_refresh_tokens_token_hash ON refresh_tokens(token_hash);
@@ -1,13 +0,0 @@
-- Restore width/height columns and repopulate from metadata where available.
ALTER TABLE document_assets
ADD COLUMN width INTEGER,
ADD COLUMN height INTEGER;
UPDATE document_assets
SET width = (metadata->>'width')::INTEGER
WHERE metadata ? 'width';
UPDATE document_assets
SET height = (metadata->>'height')::INTEGER
WHERE metadata ? 'height';
@@ -1,15 +0,0 @@
-- Backfill existing width/height values into metadata then drop the columns.
UPDATE document_assets
SET metadata = metadata || jsonb_build_object('width', width)
WHERE width IS NOT NULL
AND NOT (metadata ? 'width');
UPDATE document_assets
SET metadata = metadata || jsonb_build_object('height', height)
WHERE height IS NOT NULL
AND NOT (metadata ? 'height');
ALTER TABLE document_assets
DROP COLUMN width,
DROP COLUMN height;
@@ -1,17 +0,0 @@
ALTER TABLE documents ADD COLUMN current_version INT4;
UPDATE documents AS d
SET current_version = dv.version_number
FROM document_versions AS dv
WHERE dv.id = d.current_version_id;
ALTER TABLE documents
ALTER COLUMN current_version SET NOT NULL;
DROP INDEX IF EXISTS idx_documents_current_version_id;
ALTER TABLE documents
DROP CONSTRAINT IF EXISTS documents_current_version_fk;
ALTER TABLE documents
DROP COLUMN current_version_id;
@@ -1,21 +0,0 @@
ALTER TABLE documents ADD COLUMN current_version_id UUID;
UPDATE documents AS d
SET current_version_id = dv.id
FROM document_versions AS dv
WHERE dv.document_id = d.id
AND dv.version_number = d.current_version;
ALTER TABLE documents
ALTER COLUMN current_version_id SET NOT NULL;
ALTER TABLE documents
ADD CONSTRAINT documents_current_version_fk
FOREIGN KEY (current_version_id)
REFERENCES document_versions(id)
DEFERRABLE INITIALLY DEFERRED;
CREATE INDEX idx_documents_current_version_id
ON documents(current_version_id);
ALTER TABLE documents DROP COLUMN current_version;
@@ -1,5 +0,0 @@
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;
@@ -1,27 +0,0 @@
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);
@@ -1,2 +0,0 @@
DROP INDEX IF EXISTS idx_documents_folder_filename;
DROP INDEX IF EXISTS idx_documents_folder_title;
@@ -1,13 +0,0 @@
CREATE INDEX idx_documents_folder_title
ON documents (
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
title
)
WHERE deleted_at IS NULL;
CREATE INDEX idx_documents_folder_filename
ON documents (
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
filename
)
WHERE deleted_at IS NULL;
@@ -1,16 +0,0 @@
DROP INDEX IF EXISTS documents_unique_folder_filename;
DROP INDEX IF EXISTS idx_documents_folder_title;
CREATE INDEX idx_documents_folder_title
ON documents (
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
title
)
WHERE deleted_at IS NULL;
CREATE INDEX idx_documents_folder_filename
ON documents (
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
filename
)
WHERE deleted_at IS NULL;
@@ -1,18 +0,0 @@
DROP INDEX IF EXISTS idx_documents_folder_title;
DROP INDEX IF EXISTS idx_documents_folder_filename;
DROP INDEX IF EXISTS documents_unique_folder_title;
DROP INDEX IF EXISTS documents_unique_folder_filename;
CREATE INDEX idx_documents_folder_title
ON documents (
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
title
)
WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX documents_unique_folder_filename
ON documents (
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
filename
)
WHERE deleted_at IS NULL;
@@ -1,2 +0,0 @@
ALTER TABLE document_versions
DROP COLUMN metadata;
@@ -1,2 +0,0 @@
ALTER TABLE document_versions
ADD COLUMN metadata JSONB NOT NULL DEFAULT '{}'::jsonb;
@@ -1,2 +0,0 @@
ALTER TABLE folders
ADD COLUMN path_cache VARCHAR(1000);
@@ -1,2 +0,0 @@
ALTER TABLE folders
DROP COLUMN path_cache;
@@ -0,0 +1,21 @@
DROP TRIGGER IF EXISTS trg_jobs_updated_at ON jobs;
DROP FUNCTION IF EXISTS touch_jobs_updated_at();
ALTER TABLE documents DROP CONSTRAINT IF EXISTS documents_current_version_fk;
DROP TABLE IF EXISTS document_asset_objects;
DROP TABLE IF EXISTS document_assets;
DROP TABLE IF EXISTS document_versions;
DROP TABLE IF EXISTS document_tags;
DROP TABLE IF EXISTS document_correspondents;
DROP TABLE IF EXISTS correspondents;
DROP TABLE IF EXISTS documents;
DROP TABLE IF EXISTS folders;
DROP TABLE IF EXISTS tags;
DROP TABLE IF EXISTS jobs;
DROP TABLE IF EXISTS refresh_tokens;
DROP TABLE IF EXISTS user_memberships;
DROP TABLE IF EXISTS users;
DROP TABLE IF EXISTS tenants;
DROP EXTENSION IF EXISTS "pgcrypto";
@@ -0,0 +1,244 @@
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE TABLE tenants (
id UUID PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
storage_root TEXT,
quickwit_index TEXT,
status TEXT NOT NULL DEFAULT 'active',
config JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX tenants_storage_root_unique
ON tenants (storage_root)
WHERE storage_root IS NOT NULL;
CREATE UNIQUE INDEX tenants_quickwit_index_unique
ON tenants (quickwit_index)
WHERE quickwit_index IS NOT NULL;
CREATE TABLE users (
id UUID PRIMARY KEY,
username VARCHAR(100) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE user_memberships (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
role TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, tenant_id)
);
CREATE INDEX user_memberships_tenant_id_idx ON user_memberships (tenant_id);
CREATE INDEX user_memberships_user_id_idx ON user_memberships (user_id);
CREATE TABLE folders (
id UUID PRIMARY KEY,
name VARCHAR(255) NOT NULL,
parent_id UUID REFERENCES folders(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
tenant_id UUID NOT NULL REFERENCES tenants(id)
);
CREATE INDEX idx_folders_parent ON folders(parent_id);
CREATE INDEX folders_tenant_id_idx ON folders(tenant_id);
CREATE UNIQUE INDEX folders_parent_name_unique_idx
ON folders (COALESCE(parent_id, '00000000-0000-0000-0000-000000000000'::uuid), name);
CREATE TABLE documents (
id UUID PRIMARY KEY,
filename VARCHAR(255) NOT NULL,
original_name VARCHAR(255) NOT NULL,
content_type VARCHAR(100),
folder_id UUID REFERENCES folders(id) ON DELETE SET NULL,
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
issued_at TIMESTAMPTZ,
title VARCHAR(255) NOT NULL,
current_version_id UUID NOT NULL,
tenant_id UUID NOT NULL REFERENCES tenants(id)
);
CREATE INDEX idx_documents_folder ON documents (folder_id);
CREATE INDEX idx_documents_deleted_at ON documents (deleted_at);
CREATE INDEX documents_tenant_id_idx ON documents (tenant_id);
CREATE INDEX idx_documents_current_version_id ON documents (current_version_id);
CREATE INDEX idx_documents_folder_title
ON documents (
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
title
)
WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX documents_unique_folder_filename
ON documents (
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
filename
)
WHERE deleted_at IS NULL;
CREATE TABLE document_versions (
id UUID PRIMARY KEY,
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
version_number INT NOT NULL,
s3_key VARCHAR(500) NOT NULL,
size_bytes BIGINT NOT NULL,
checksum VARCHAR(64) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
operations_summary JSONB NOT NULL DEFAULT '{}'::jsonb,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
tenant_id UUID NOT NULL REFERENCES tenants(id),
CONSTRAINT document_versions_unique_version UNIQUE (document_id, version_number)
);
CREATE INDEX idx_document_versions_document ON document_versions (document_id);
CREATE INDEX document_versions_tenant_id_idx ON document_versions (tenant_id);
ALTER TABLE documents
ADD CONSTRAINT documents_current_version_fk
FOREIGN KEY (current_version_id)
REFERENCES document_versions(id)
DEFERRABLE INITIALLY DEFERRED;
CREATE TABLE tags (
id UUID PRIMARY KEY,
label VARCHAR(100) NOT NULL UNIQUE,
color VARCHAR(7),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
tenant_id UUID NOT NULL REFERENCES tenants(id)
);
CREATE INDEX tags_tenant_id_idx ON tags (tenant_id);
CREATE TABLE document_tags (
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
assigned_by UUID REFERENCES users(id),
tenant_id UUID NOT NULL REFERENCES tenants(id),
PRIMARY KEY (document_id, tag_id)
);
CREATE INDEX idx_document_tags_tag ON document_tags (tag_id);
CREATE INDEX document_tags_tenant_id_idx ON document_tags (tenant_id);
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(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
CONSTRAINT correspondents_name_unique UNIQUE (name)
);
CREATE INDEX correspondents_tenant_id_idx ON correspondents (tenant_id);
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),
tenant_id UUID NOT NULL REFERENCES tenants(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);
CREATE INDEX document_correspondents_tenant_id_idx ON document_correspondents (tenant_id);
CREATE TABLE document_assets (
id UUID PRIMARY KEY,
document_version_id UUID NOT NULL REFERENCES document_versions(id) ON DELETE CASCADE,
asset_type TEXT NOT NULL,
mime_type TEXT NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
cardinality INTEGER,
tenant_id UUID NOT NULL REFERENCES tenants(id),
CONSTRAINT document_assets_unique UNIQUE (document_version_id, asset_type),
CONSTRAINT document_assets_cardinality_positive CHECK (cardinality IS NULL OR cardinality >= 1)
);
CREATE INDEX idx_document_assets_version ON document_assets (document_version_id);
CREATE INDEX idx_document_assets_type ON document_assets (asset_type);
CREATE INDEX document_assets_tenant_id_idx ON document_assets (tenant_id);
CREATE TABLE document_asset_objects (
id UUID PRIMARY KEY,
asset_id UUID NOT NULL REFERENCES document_assets(id) ON DELETE CASCADE,
ordinal INTEGER NOT NULL,
s3_key TEXT NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
tenant_id UUID NOT NULL REFERENCES tenants(id),
CONSTRAINT document_asset_objects_ordinal_positive CHECK (ordinal >= 1),
CONSTRAINT document_asset_objects_asset_ordinal_unique UNIQUE (asset_id, ordinal)
);
CREATE INDEX idx_document_asset_objects_asset_ordinal
ON document_asset_objects (asset_id, ordinal);
CREATE INDEX document_asset_objects_tenant_id_idx
ON document_asset_objects (tenant_id);
CREATE TABLE jobs (
id UUID PRIMARY KEY,
job_type TEXT NOT NULL,
payload JSONB NOT NULL,
status TEXT NOT NULL DEFAULT 'queued',
attempts INTEGER NOT NULL DEFAULT 0,
run_after TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
CONSTRAINT jobs_status_check CHECK (status IN ('queued', 'processing', 'succeeded', 'failed'))
);
CREATE INDEX idx_jobs_status_run_after ON jobs (status, run_after);
CREATE INDEX idx_jobs_job_type ON jobs (job_type);
CREATE INDEX jobs_tenant_id_idx ON jobs (tenant_id);
CREATE OR REPLACE FUNCTION touch_jobs_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_jobs_updated_at
BEFORE UPDATE ON jobs
FOR EACH ROW
EXECUTE FUNCTION touch_jobs_updated_at();
CREATE TABLE refresh_tokens (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL,
issued_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
tenant_id UUID NOT NULL REFERENCES tenants(id)
);
CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens (user_id);
CREATE INDEX idx_refresh_tokens_token_hash ON refresh_tokens (token_hash);
CREATE INDEX refresh_tokens_tenant_id_idx ON refresh_tokens (tenant_id);
+46 -4
View File
@@ -15,6 +15,8 @@ pub struct JwtService {
expiry: Duration, expiry: Duration,
download_audience: String, download_audience: String,
download_expiry: Duration, download_expiry: Duration,
selector_audience: String,
selector_expiry: Duration,
} }
impl JwtService { impl JwtService {
@@ -27,16 +29,18 @@ impl JwtService {
expiry: Duration::minutes(config.jwt_expiry_minutes), expiry: Duration::minutes(config.jwt_expiry_minutes),
download_audience: config.download_token_audience.clone(), download_audience: config.download_token_audience.clone(),
download_expiry: Duration::minutes(config.download_token_expiry_minutes), download_expiry: Duration::minutes(config.download_token_expiry_minutes),
selector_audience: format!("{}:tenant-selector", config.jwt_audience),
selector_expiry: Duration::minutes(15),
}) })
} }
pub fn generate_token(&self, user_id: Uuid, username: &str, role: &str) -> Result<String> { pub fn generate_token(&self, user_id: Uuid, tenant_id: Uuid, username: &str) -> Result<String> {
let now = Utc::now(); let now = Utc::now();
let exp = now + self.expiry; let exp = now + self.expiry;
let claims = Claims { let claims = Claims {
sub: user_id, sub: user_id,
tenant_id,
username: username.to_owned(), username: username.to_owned(),
role: role.to_owned(),
iss: self.issuer.clone(), iss: self.issuer.clone(),
aud: self.audience.clone(), aud: self.audience.clone(),
iat: now.timestamp() as usize, iat: now.timestamp() as usize,
@@ -54,12 +58,18 @@ impl JwtService {
Ok(data.claims) Ok(data.claims)
} }
pub fn generate_download_token(&self, document_id: Uuid, user_id: Uuid) -> Result<String> { pub fn generate_download_token(
&self,
document_id: Uuid,
user_id: Uuid,
tenant_id: Uuid,
) -> Result<String> {
let now = Utc::now(); let now = Utc::now();
let exp = now + self.download_expiry; let exp = now + self.download_expiry;
let claims = DownloadClaims { let claims = DownloadClaims {
doc_id: document_id, doc_id: document_id,
user_id, user_id,
tenant_id,
iss: self.issuer.clone(), iss: self.issuer.clone(),
aud: self.download_audience.clone(), aud: self.download_audience.clone(),
iat: now.timestamp() as usize, iat: now.timestamp() as usize,
@@ -76,13 +86,35 @@ impl JwtService {
let data = decode::<DownloadClaims>(token, &self.decoding, &validation)?; let data = decode::<DownloadClaims>(token, &self.decoding, &validation)?;
Ok(data.claims) Ok(data.claims)
} }
pub fn generate_tenant_selector_token(&self, user_id: Uuid) -> Result<String> {
let now = Utc::now();
let exp = now + self.selector_expiry;
let claims = TenantSelectionClaims {
sub: user_id,
iss: self.issuer.clone(),
aud: self.selector_audience.clone(),
iat: now.timestamp() as usize,
exp: exp.timestamp() as usize,
};
Ok(encode(&Header::default(), &claims, &self.encoding)?)
}
pub fn verify_tenant_selector_token(&self, token: &str) -> Result<TenantSelectionClaims> {
let mut validation = Validation::default();
validation.set_audience(&[self.selector_audience.clone()]);
validation.set_issuer(&[self.issuer.clone()]);
let data = decode::<TenantSelectionClaims>(token, &self.decoding, &validation)?;
Ok(data.claims)
}
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Claims { pub struct Claims {
pub sub: Uuid, pub sub: Uuid,
pub tenant_id: Uuid,
pub username: String, pub username: String,
pub role: String,
pub iss: String, pub iss: String,
pub aud: String, pub aud: String,
pub iat: usize, pub iat: usize,
@@ -93,6 +125,16 @@ pub struct Claims {
pub struct DownloadClaims { pub struct DownloadClaims {
pub doc_id: Uuid, pub doc_id: Uuid,
pub user_id: Uuid, pub user_id: Uuid,
pub tenant_id: Uuid,
pub iss: String,
pub aud: String,
pub iat: usize,
pub exp: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TenantSelectionClaims {
pub sub: Uuid,
pub iss: String, pub iss: String,
pub aud: String, pub aud: String,
pub iat: usize, pub iat: usize,
+50 -4
View File
@@ -6,13 +6,17 @@ use axum_extra::headers::{authorization::Bearer, Authorization};
use axum_extra::TypedHeader; use axum_extra::TypedHeader;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{error::AppError, state::AppState}; use crate::{
error::AppError,
state::{AppState, PgPooledConnection},
};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthenticatedUser { pub struct AuthenticatedUser {
pub user_id: uuid::Uuid, pub user_id: uuid::Uuid,
pub username: String, pub username: String,
pub role: String, pub tenant_id: uuid::Uuid,
} }
#[async_trait] #[async_trait]
@@ -23,6 +27,10 @@ impl FromRequestParts<AppState> for AuthenticatedUser {
parts: &mut Parts, parts: &mut Parts,
state: &AppState, state: &AppState,
) -> Result<Self, Self::Rejection> { ) -> Result<Self, Self::Rejection> {
if let Some(user) = parts.extensions.get::<AuthenticatedUser>() {
return Ok(user.clone());
}
let TypedHeader(Authorization(bearer)) = let TypedHeader(Authorization(bearer)) =
TypedHeader::<Authorization<Bearer>>::from_request_parts(parts, state) TypedHeader::<Authorization<Bearer>>::from_request_parts(parts, state)
.await .await
@@ -33,10 +41,48 @@ impl FromRequestParts<AppState> for AuthenticatedUser {
.verify_token(bearer.token()) .verify_token(bearer.token())
.map_err(|_| AppError::unauthorized())?; .map_err(|_| AppError::unauthorized())?;
Ok(AuthenticatedUser { let user = AuthenticatedUser {
user_id: claims.sub, user_id: claims.sub,
username: claims.username, username: claims.username,
role: claims.role, tenant_id: claims.tenant_id,
};
parts.extensions.insert(user.clone());
Ok(user)
}
}
pub struct TenantScopedConn {
pub conn: PgPooledConnection,
pub tenant_id: Uuid,
pub user_id: Uuid,
pub user: AuthenticatedUser,
}
impl TenantScopedConn {
pub fn conn(&mut self) -> &mut PgPooledConnection {
&mut self.conn
}
}
#[async_trait]
impl FromRequestParts<AppState> for TenantScopedConn {
type Rejection = AppError;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let user = AuthenticatedUser::from_request_parts(parts, state).await?;
let tenant_id = user.tenant_id;
let conn = state.db_for_tenant(tenant_id)?;
Ok(Self {
conn,
tenant_id,
user_id: user.user_id,
user,
}) })
} }
} }
+707
View File
@@ -0,0 +1,707 @@
use std::env;
use std::sync::Arc;
use anyhow::{anyhow, bail, Context, Result};
use argon2::{
password_hash::{PasswordHasher, SaltString},
Argon2,
};
use diesel::{dsl::exists, prelude::*, select};
use once_cell::sync::Lazy;
use reqwest::{Client, Method, StatusCode};
use serde_json::json;
use uuid::Uuid;
use backend::{
config::AppConfig,
db::{self, PgPool},
jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT},
models::{DocumentAsset, DocumentAssetObject, NewUser, NewUserMembership, Tenant, User},
s3,
schema::{
document_asset_objects, document_assets, documents, tenants, user_memberships, users,
},
storage::{ObjectStorage, S3Storage, TenantStorage},
utils::tracing::init_tracing,
};
use rand::rngs::OsRng;
static QUICKWIT_INDEX_TEMPLATE: Lazy<serde_json::Value> = Lazy::new(|| {
json!({
"version": "0.8",
"index_id": "documents",
"doc_mapping": {
"tokenizers": [
{
"name": "substring",
"type": "ngram",
"min_gram": 2,
"max_gram": 20,
"prefix_only": false
}
],
"field_mappings": [
{ "name": "tenant_id", "type": "text", "stored": true },
{ "name": "document_id", "type": "text", "stored": true },
{ "name": "version_id", "type": "text", "stored": true },
{ "name": "title", "type": "text", "tokenizer": "substring", "stored": true },
{ "name": "text", "type": "text", "tokenizer": "substring", "record": "position" }
]
},
"search_settings": {
"default_search_fields": ["title", "text"]
}
})
});
#[derive(Debug)]
enum Command {
CreateUser {
username: String,
password: String,
},
SetPassword {
username: String,
password: String,
},
ListUsers,
DeleteUser {
username: String,
},
CreateTenant {
slug: String,
storage_root: Option<String>,
quickwit_index: Option<String>,
},
DeleteTenant {
slug: String,
},
AddUserToTenant {
username: String,
slug: String,
role: Option<String>,
},
RemoveUserFromTenant {
username: String,
slug: String,
},
ReanalyzeDocuments {
slug: String,
},
ListTenants,
DeleteAssets(String),
QuickwitCreate(String),
QuickwitDelete(String),
}
impl Command {
fn usage() -> &'static str {
"Usage: admin\n\
create-user <username> <password>\n\
set-password <username> <password>\n\
list-users\n\
delete-user <username>\n\
create-tenant <slug> [storage_root] [quickwit_index]\n\
delete-tenant <slug>\n\
add-user-to-tenant <username> <slug> [role]\n\
remove-user-from-tenant <username> <slug>\n\
reanalyze-documents <slug>\n\
list-tenants\n\
delete-assets <slug>\n\
quickwit-create-index <slug>\n\
quickwit-delete-index <slug>"
}
fn parse() -> Result<Self> {
let mut args = env::args().skip(1);
match args.next().as_deref() {
Some("create-user") => Ok(Self::CreateUser {
username: args.next().ok_or_else(|| anyhow!("username required"))?,
password: args.next().ok_or_else(|| anyhow!("password required"))?,
}),
Some("set-password") => Ok(Self::SetPassword {
username: args.next().ok_or_else(|| anyhow!("username required"))?,
password: args.next().ok_or_else(|| anyhow!("password required"))?,
}),
Some("list-users") => Ok(Self::ListUsers),
Some("delete-user") => Ok(Self::DeleteUser {
username: args.next().ok_or_else(|| anyhow!("username required"))?,
}),
Some("create-tenant") => Ok(Self::CreateTenant {
slug: args.next().ok_or_else(|| anyhow!("tenant slug required"))?,
storage_root: args.next(),
quickwit_index: args.next(),
}),
Some("delete-tenant") => Ok(Self::DeleteTenant {
slug: args.next().ok_or_else(|| anyhow!("tenant slug required"))?,
}),
Some("add-user-to-tenant") => Ok(Self::AddUserToTenant {
username: args.next().ok_or_else(|| anyhow!("username required"))?,
slug: args.next().ok_or_else(|| anyhow!("tenant slug required"))?,
role: args.next(),
}),
Some("remove-user-from-tenant") => Ok(Self::RemoveUserFromTenant {
username: args.next().ok_or_else(|| anyhow!("username required"))?,
slug: args.next().ok_or_else(|| anyhow!("tenant slug required"))?,
}),
Some("reanalyze-documents") => Ok(Self::ReanalyzeDocuments {
slug: args.next().ok_or_else(|| anyhow!("tenant slug required"))?,
}),
Some("list-tenants") => Ok(Self::ListTenants),
Some("delete-assets") => Ok(Self::DeleteAssets(
args.next().ok_or_else(|| anyhow!("tenant slug required"))?,
)),
Some("quickwit-create-index") => Ok(Self::QuickwitCreate(
args.next().ok_or_else(|| anyhow!("tenant slug required"))?,
)),
Some("quickwit-delete-index") => Ok(Self::QuickwitDelete(
args.next().ok_or_else(|| anyhow!("tenant slug required"))?,
)),
_ => Err(anyhow!(Self::usage())),
}
}
}
#[tokio::main]
async fn main() -> Result<()> {
init_tracing("info");
let command = Command::parse()?;
let config = AppConfig::load_and_log("admin")?;
let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?;
match command {
Command::CreateUser { username, password } => create_user(&pool, &username, &password)?,
Command::SetPassword { username, password } => set_password(&pool, &username, &password)?,
Command::ListUsers => list_users(&pool)?,
Command::DeleteUser { username } => delete_user(&pool, &username)?,
Command::CreateTenant {
slug,
storage_root,
quickwit_index,
} => create_tenant(&pool, &slug, storage_root, quickwit_index)?,
Command::DeleteTenant { slug } => delete_tenant(&pool, &slug)?,
Command::AddUserToTenant {
username,
slug,
role,
} => add_user_to_tenant(&pool, &username, &slug, role.as_deref())?,
Command::RemoveUserFromTenant { username, slug } => {
remove_user_from_tenant(&pool, &username, &slug)?
}
Command::ReanalyzeDocuments { slug } => reanalyze_documents(&pool, &slug)?,
Command::ListTenants => list_tenants(&pool)?,
Command::DeleteAssets(slug) => delete_assets_for_tenant(&config, &pool, &slug).await?,
Command::QuickwitCreate(slug) => {
quickwit_index(&config, &pool, &slug, Method::POST).await?
}
Command::QuickwitDelete(slug) => {
quickwit_index(&config, &pool, &slug, Method::DELETE).await?
}
}
Ok(())
}
fn create_user(pool: &PgPool, username: &str, password: &str) -> Result<()> {
if username.trim().is_empty() {
bail!("username must not be empty");
}
if password.is_empty() {
bail!("password must not be empty");
}
let mut conn = pool.get().context("failed to get database connection")?;
let exists: bool =
select(exists(users::table.filter(users::username.eq(username)))).get_result(&mut conn)?;
if exists {
bail!("user '{}' already exists", username);
}
let password_hash = hash_password(password)?;
let new_user = NewUser {
id: Uuid::new_v4(),
username: username.to_string(),
password_hash,
};
diesel::insert_into(users::table)
.values(&new_user)
.execute(&mut conn)?;
println!("created user '{}' (id: {})", username, new_user.id);
Ok(())
}
fn set_password(pool: &PgPool, username: &str, password: &str) -> Result<()> {
if password.is_empty() {
bail!("password must not be empty");
}
let mut conn = pool.get().context("failed to get database connection")?;
let password_hash = hash_password(password)?;
let updated = diesel::update(users::table.filter(users::username.eq(username)))
.set(users::password_hash.eq(password_hash))
.execute(&mut conn)?;
if updated == 0 {
bail!("user '{}' not found", username);
}
println!("updated password for '{}'", username);
Ok(())
}
fn hash_password(password: &str) -> Result<String> {
let salt = SaltString::generate(&mut OsRng);
let hash = Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map_err(|err| anyhow!(err))?;
Ok(hash.to_string())
}
fn list_users(pool: &PgPool) -> Result<()> {
let mut conn = pool.get().context("failed to get database connection")?;
let users_list: Vec<User> = users::table.order(users::username.asc()).load(&mut conn)?;
if users_list.is_empty() {
println!("No users found.");
return Ok(());
}
for user in users_list {
let memberships: Vec<(Uuid, String, String)> = user_memberships::table
.inner_join(tenants::table)
.filter(user_memberships::user_id.eq(user.id))
.select((tenants::id, tenants::slug, user_memberships::role))
.order((tenants::slug.asc(), user_memberships::role.asc()))
.load(&mut conn)?;
if memberships.is_empty() {
println!("{} ({})", user.username, user.id);
} else {
let details: Vec<String> = memberships
.into_iter()
.map(|(_, slug, role)| format!("{}: {}", slug, role))
.collect();
println!("{} ({}) -> {}", user.username, user.id, details.join(", "));
}
}
Ok(())
}
fn delete_user(pool: &PgPool, username: &str) -> Result<()> {
let mut conn = pool.get().context("failed to get database connection")?;
let user: User = users::table
.filter(users::username.eq(username))
.first(&mut conn)
.optional()?
.ok_or_else(|| anyhow!("user '{}' not found", username))?;
diesel::delete(user_memberships::table.filter(user_memberships::user_id.eq(user.id)))
.execute(&mut conn)?;
diesel::delete(users::table.filter(users::id.eq(user.id))).execute(&mut conn)?;
println!("deleted user '{}'", username);
Ok(())
}
fn create_tenant(
pool: &PgPool,
slug: &str,
storage_root_arg: Option<String>,
quickwit_index_arg: Option<String>,
) -> Result<()> {
if slug.trim().is_empty() {
bail!("tenant slug must not be empty");
}
let mut conn = pool.get().context("failed to get database connection")?;
let exists: bool =
select(exists(tenants::table.filter(tenants::slug.eq(slug)))).get_result(&mut conn)?;
if exists {
bail!("tenant '{}' already exists", slug);
}
let id = Uuid::new_v4();
let storage_root = storage_root_arg
.map(|mut s| {
if s.is_empty() {
format!("tenants/{}/", id)
} else {
if !s.ends_with('/') {
s.push('/');
}
s
}
})
.unwrap_or_else(|| format!("tenants/{}/", id));
let quickwit_index = quickwit_index_arg.unwrap_or_else(|| format!("documents-{}", id));
diesel::insert_into(tenants::table)
.values((
tenants::id.eq(id),
tenants::slug.eq(slug),
tenants::storage_root.eq(Some(storage_root.clone())),
tenants::quickwit_index.eq(Some(quickwit_index.clone())),
tenants::status.eq("active"),
tenants::config.eq(serde_json::json!({})),
))
.execute(&mut conn)?;
println!(
"created tenant '{}' with id {}, storage_root '{}', quickwit_index '{}'",
slug, id, storage_root, quickwit_index
);
Ok(())
}
fn delete_tenant(pool: &PgPool, slug: &str) -> Result<()> {
let mut conn = pool.get().context("failed to get database connection")?;
let tenant: Tenant = tenants::table
.filter(tenants::slug.eq(slug))
.first(&mut conn)
.optional()?
.ok_or_else(|| anyhow!("tenant '{}' not found", slug))?;
let member_exists: bool = select(exists(
user_memberships::table.filter(user_memberships::tenant_id.eq(tenant.id)),
))
.get_result(&mut conn)?;
if member_exists {
bail!("tenant '{}' still has user memberships", slug);
}
diesel::delete(tenants::table.filter(tenants::id.eq(tenant.id))).execute(&mut conn)?;
println!("deleted tenant '{}'", slug);
Ok(())
}
fn add_user_to_tenant(pool: &PgPool, username: &str, slug: &str, role: Option<&str>) -> Result<()> {
let mut conn = pool.get().context("failed to get database connection")?;
let user: User = users::table
.filter(users::username.eq(username))
.first(&mut conn)
.optional()?
.ok_or_else(|| anyhow!("user '{}' not found", username))?;
let tenant: Tenant = tenants::table
.filter(tenants::slug.eq(slug))
.first(&mut conn)
.optional()?
.ok_or_else(|| anyhow!("tenant '{}' not found", slug))?;
let membership = NewUserMembership {
id: Uuid::new_v4(),
user_id: user.id,
tenant_id: tenant.id,
role: role.unwrap_or("user").to_string(),
};
diesel::insert_into(user_memberships::table)
.values(&membership)
.on_conflict((user_memberships::user_id, user_memberships::tenant_id))
.do_update()
.set(user_memberships::role.eq(&membership.role))
.execute(&mut conn)?;
println!(
"added user '{}' to tenant '{}' with role '{}'",
username, slug, membership.role
);
Ok(())
}
fn remove_user_from_tenant(pool: &PgPool, username: &str, slug: &str) -> Result<()> {
let mut conn = pool.get().context("failed to get database connection")?;
let user: User = users::table
.filter(users::username.eq(username))
.first(&mut conn)
.optional()?
.ok_or_else(|| anyhow!("user '{}' not found", username))?;
let tenant: Tenant = tenants::table
.filter(tenants::slug.eq(slug))
.first(&mut conn)
.optional()?
.ok_or_else(|| anyhow!("tenant '{}' not found", slug))?;
let removed = diesel::delete(
user_memberships::table
.filter(user_memberships::user_id.eq(user.id))
.filter(user_memberships::tenant_id.eq(tenant.id)),
)
.execute(&mut conn)?;
if removed == 0 {
println!("user '{}' was not a member of tenant '{}'", username, slug);
} else {
println!("removed user '{}' from tenant '{}'", username, slug);
}
Ok(())
}
fn reanalyze_documents(pool: &PgPool, slug: &str) -> Result<()> {
let mut conn = pool.get().context("failed to get database connection")?;
let tenant: Tenant = tenants::table
.filter(tenants::slug.eq(slug))
.first(&mut conn)
.optional()?
.ok_or_else(|| anyhow!("tenant '{}' not found", slug))?;
let targets: Vec<(Uuid, Uuid)> = documents::table
.filter(documents::tenant_id.eq(tenant.id))
.filter(documents::deleted_at.is_null())
.select((documents::id, documents::current_version_id))
.load(&mut conn)?;
if targets.is_empty() {
println!("tenant '{}' has no active documents", slug);
return Ok(());
}
let mut queued = 0usize;
for (document_id, version_id) in targets {
enqueue_job(
&mut conn,
tenant.id,
JOB_ANALYZE_DOCUMENT,
serde_json::json!({
"document_id": document_id,
"document_version_id": version_id,
"force": true,
}),
None,
)
.map_err(|err| anyhow!("failed to enqueue analyze job: {}", err))?;
queued += 1;
}
println!(
"queued {} documents for re-analysis in tenant '{}'",
queued, slug
);
Ok(())
}
fn list_tenants(pool: &PgPool) -> Result<()> {
let mut conn = pool.get().context("failed to get database connection")?;
let tenants: Vec<Tenant> = tenants::table
.order(tenants::slug.asc())
.load(&mut conn)
.context("failed to load tenants")?;
if tenants.is_empty() {
println!("No tenants found.");
return Ok(());
}
for tenant in tenants {
println!("{} ({})", tenant.slug, tenant.id);
}
Ok(())
}
async fn delete_assets_for_tenant(
config: &AppConfig,
pool: &PgPool,
tenant_slug: &str,
) -> Result<()> {
let s3_client = s3::build_client(config).await?;
let storage: Arc<dyn ObjectStorage> =
Arc::new(S3Storage::new(s3_client, config.s3_bucket.clone()));
let mut conn = pool.get().context("failed to get database connection")?;
let tenant: Tenant = tenants::table
.filter(tenants::slug.eq(tenant_slug))
.first(&mut conn)
.optional()
.context("failed to load tenant")?
.ok_or_else(|| anyhow!("tenant '{}' not found", tenant_slug))?;
let tenant_storage = TenantStorage::new(Arc::clone(&storage), &tenant)
.with_context(|| format!("missing storage root for tenant {}", tenant.slug))?;
let assets: Vec<DocumentAsset> = document_assets::table
.filter(document_assets::tenant_id.eq(tenant.id))
.load(&mut conn)
.with_context(|| format!("failed to load assets for tenant {}", tenant.slug))?;
if assets.is_empty() {
println!("Tenant {}: no assets", tenant.slug);
return Ok(());
}
println!(
"Tenant {} ({}): deleting {} assets…",
tenant.slug,
tenant.id,
assets.len()
);
let asset_ids: Vec<Uuid> = assets.iter().map(|asset| asset.id).collect();
let objects: Vec<DocumentAssetObject> = document_asset_objects::table
.filter(document_asset_objects::tenant_id.eq(tenant.id))
.filter(document_asset_objects::asset_id.eq_any(&asset_ids))
.load(&mut conn)
.with_context(|| format!("failed to load asset objects for tenant {}", tenant.slug))?;
for object in &objects {
if let Err(err) = tenant_storage.delete_object(&object.s3_key).await {
eprintln!(
"Failed to delete object {} (tenant {}): {err}",
object.s3_key, tenant.slug
);
}
}
diesel::delete(
document_asset_objects::table
.filter(document_asset_objects::tenant_id.eq(tenant.id))
.filter(document_asset_objects::asset_id.eq_any(&asset_ids)),
)
.execute(&mut conn)
.with_context(|| format!("failed to remove asset objects for tenant {}", tenant.slug))?;
diesel::delete(document_assets::table.filter(document_assets::tenant_id.eq(tenant.id)))
.execute(&mut conn)
.with_context(|| format!("failed to remove asset records for tenant {}", tenant.slug))?;
println!("Tenant {}: asset records deleted.", tenant.slug);
Ok(())
}
async fn quickwit_index(
config: &AppConfig,
pool: &PgPool,
slug: &str,
method: Method,
) -> Result<()> {
let endpoint = config
.quickwit_endpoint
.as_ref()
.ok_or_else(|| anyhow!("quickwit endpoint not configured"))?;
let mut conn = pool.get().context("failed to get database connection")?;
let tenant: Tenant = tenants::table
.filter(tenants::slug.eq(slug))
.first(&mut conn)
.optional()
.context("failed to query tenants")?
.ok_or_else(|| anyhow!("tenant '{}' not found", slug))?;
let client = Client::new();
let index_id = format!("documents-{}", tenant.id);
let base_endpoint = endpoint.trim_end_matches('/');
match method {
Method::POST => {
let payload = render_index_template(&index_id);
let response = client
.post(format!("{}/api/v1/indexes", base_endpoint))
.header("content-type", "application/json")
.body(payload)
.send()
.await
.context("failed to send create index request")?;
match response.status() {
status if status.is_success() => {
diesel::update(tenants::table.filter(tenants::id.eq(tenant.id)))
.set(tenants::quickwit_index.eq(Some(index_id.clone())))
.execute(&mut conn)
.context("failed to update tenant quickwit_index")?;
println!(
"Tenant '{}' quickwit index set to '{}'.",
tenant.slug, index_id
);
}
StatusCode::CONFLICT => {
let lookup = client
.get(format!("{}/api/v1/indexes/{}", base_endpoint, index_id))
.send()
.await
.context("failed to verify existing quickwit index")?;
let lookup_status = lookup.status();
if !lookup_status.is_success() {
let body = lookup.text().await.unwrap_or_default();
bail!(
"quickwit reported conflict but index lookup failed with status {}: {}",
lookup_status,
body
);
}
diesel::update(tenants::table.filter(tenants::id.eq(tenant.id)))
.set(tenants::quickwit_index.eq(Some(index_id.clone())))
.execute(&mut conn)
.context("failed to update tenant quickwit_index")?;
println!(
"Tenant '{}' quickwit index set to '{}'.",
tenant.slug, index_id
);
}
status => {
let body = response.text().await.unwrap_or_default();
bail!(
"quickwit create index failed with status {}: {}",
status,
body
);
}
}
}
Method::DELETE => {
let response = client
.delete(format!("{}/api/v1/indexes/{}", base_endpoint, index_id))
.send()
.await
.context("failed to send delete index request")?;
match response.status() {
status if status.is_success() || status == StatusCode::NOT_FOUND => {
diesel::update(tenants::table.filter(tenants::id.eq(tenant.id)))
.set(tenants::quickwit_index.eq::<Option<String>>(None))
.execute(&mut conn)
.context("failed to clear tenant quickwit_index")?;
println!("Tenant '{}' quickwit index cleared.", tenant.slug);
}
status => {
let body = response.text().await.unwrap_or_default();
bail!(
"quickwit delete index failed with status {}: {}",
status,
body
);
}
}
}
_ => unreachable!(),
}
Ok(())
}
fn render_index_template(index_id: &str) -> String {
let mut template = QUICKWIT_INDEX_TEMPLATE.clone();
if let Some(obj) = template.as_object_mut() {
obj.insert(
"index_id".to_string(),
serde_json::Value::String(index_id.to_string()),
);
}
template.to_string()
}
-75
View File
@@ -1,75 +0,0 @@
use std::env;
use anyhow::{Context, Result};
use diesel::prelude::*;
use backend::{
config::AppConfig,
db,
models::DocumentAsset,
s3,
schema::document_assets,
storage::{ObjectStorage, S3Storage},
};
#[tokio::main]
async fn main() -> Result<()> {
let mut args = env::args().skip(1);
match args.next().as_deref() {
Some("delete-assets") => delete_all_assets().await?,
Some(cmd) => {
eprintln!("Unknown command: {cmd}\nUsage: maintenance delete-assets");
std::process::exit(1);
}
None => {
eprintln!("Usage: maintenance delete-assets");
std::process::exit(1);
}
}
Ok(())
}
async fn delete_all_assets() -> Result<()> {
let config = AppConfig::from_env()?;
tracing::info!(
component = "maintenance",
database_url = %config.redacted_database_url(),
pool_size = config.database_max_pool_size,
s3_bucket = %config.s3_bucket,
"loaded backend configuration"
);
let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?;
let s3_client = s3::build_client(&config).await?;
let storage = S3Storage::new(s3_client, config.s3_bucket.clone());
let mut conn = pool.get().context("failed to get database connection")?;
let assets: Vec<DocumentAsset> = document_assets::table
.load(&mut conn)
.context("failed to load document assets")?;
if assets.is_empty() {
println!("No assets found.");
return Ok(());
}
println!("Deleting {} assets…", assets.len());
for asset in &assets {
if let Err(err) = storage.delete_object(&asset.s3_key).await {
eprintln!(
"Failed to delete object {} from storage: {err}",
asset.s3_key
);
}
}
diesel::delete(document_assets::table)
.execute(&mut conn)
.context("failed to remove asset records")?;
println!("Asset records deleted.");
Ok(())
}
+9 -41
View File
@@ -1,47 +1,24 @@
use std::net::SocketAddr; use std::net::SocketAddr;
use std::sync::Arc;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tower::make::Shared; use tower::make::Shared;
use tracing_subscriber::EnvFilter;
use backend::auth::jwt::JwtService; use backend::{routes::webdav, utils::bootstrap::init_component};
use backend::config::AppConfig;
use backend::db;
use backend::routes::webdav;
use backend::s3::build_client;
use backend::state::AppState;
use backend::storage::S3Storage;
#[tokio::main] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
dotenv::dotenv().ok(); let state = init_component("webdav", None).await?;
init_tracing(); let webdav_host = state.config.webdav_host.clone();
let webdav_port = state.config.webdav_port;
let config = AppConfig::from_env()?;
tracing::info!( tracing::info!(
component = "webdav", component = "webdav",
database_url = %config.redacted_database_url(), webdav_host = %webdav_host,
pool_size = config.database_max_pool_size, webdav_port,
server_host = %config.server_host, "starting webdav server"
server_port = config.server_port,
webdav_host = %config.webdav_host,
webdav_port = config.webdav_port,
quickwit_enabled = config.quickwit_endpoint.is_some(),
s3_bucket = %config.s3_bucket,
"loaded backend configuration"
); );
let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?;
let s3_client = build_client(&config).await?;
let storage = Arc::new(S3Storage::new(s3_client, config.s3_bucket.clone()));
let jwt = JwtService::from_config(&config)?;
let state = AppState::new(pool, config, storage, jwt); let listen_addr: SocketAddr = format!("{}:{}", webdav_host, webdav_port).parse()?;
let listen_addr: SocketAddr = { let router = webdav::create_router().with_state(state.as_ref().clone());
let config = state.config.clone();
format!("{}:{}", config.webdav_host, config.webdav_port).parse()?
};
let router = webdav::create_router().with_state(state);
let listener = TcpListener::bind(listen_addr).await?; let listener = TcpListener::bind(listen_addr).await?;
tracing::info!("listening for WebDAV on {}", listen_addr); tracing::info!("listening for WebDAV on {}", listen_addr);
@@ -49,12 +26,3 @@ async fn main() -> anyhow::Result<()> {
axum::serve(listener, Shared::new(router)).await?; axum::serve(listener, Shared::new(router)).await?;
Ok(()) Ok(())
} }
fn init_tracing() {
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
tracing_subscriber::fmt()
.with_env_filter(filter)
.with_target(false)
.compact()
.init();
}
+4 -33
View File
@@ -1,33 +1,13 @@
use std::{sync::Arc, time::Duration}; use std::time::Duration;
use tokio::signal; use tokio::signal;
use tracing_subscriber::EnvFilter;
use backend::{ use backend::{default_handlers, utils::bootstrap::init_component, Worker};
auth::jwt::JwtService, config::AppConfig, db, default_handlers, s3::build_client,
state::AppState, storage::S3Storage, Worker,
};
#[tokio::main] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
dotenv::dotenv().ok(); let state = init_component("worker", Some(1)).await?;
init_tracing(); tracing::info!(component = "worker", "starting worker process");
let config = AppConfig::from_env()?;
tracing::info!(
component = "worker",
database_url = %config.redacted_database_url(),
pool_size = 1,
quickwit_enabled = config.quickwit_endpoint.is_some(),
s3_bucket = %config.s3_bucket,
"loaded backend configuration"
);
let pool = db::init_pool_with_size(&config.database_url, 1)?;
let s3_client = build_client(&config).await?;
let storage = Arc::new(S3Storage::new(s3_client, config.s3_bucket.clone()));
let jwt = JwtService::from_config(&config)?;
let state = Arc::new(AppState::new(pool, config, storage, jwt));
let worker = Worker::new(state, default_handlers(), Duration::from_secs(2)); let worker = Worker::new(state, default_handlers(), Duration::from_secs(2));
tokio::select! { tokio::select! {
@@ -39,12 +19,3 @@ async fn main() -> anyhow::Result<()> {
Ok(()) Ok(())
} }
fn init_tracing() {
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
tracing_subscriber::fmt()
.with_env_filter(filter)
.with_target(false)
.compact()
.init();
}
+18
View File
@@ -30,9 +30,24 @@ pub struct AppConfig {
pub s3_bucket: String, pub s3_bucket: String,
pub quickwit_endpoint: Option<String>, pub quickwit_endpoint: Option<String>,
pub quickwit_index: Option<String>, pub quickwit_index: Option<String>,
pub default_tenant_slug: String,
} }
impl AppConfig { impl AppConfig {
pub fn load_and_log(component: &str) -> Result<Self> {
dotenv::dotenv().ok();
let config = Self::from_env()?;
tracing::info!(
component,
database_url = %config.redacted_database_url(),
pool_size = config.database_max_pool_size,
quickwit_enabled = config.quickwit_endpoint.is_some(),
s3_bucket = %config.s3_bucket,
"loaded backend configuration"
);
Ok(config)
}
pub fn from_env() -> Result<Self> { pub fn from_env() -> Result<Self> {
let database_url = env::var("DATABASE_URL").context("DATABASE_URL must be set")?; let database_url = env::var("DATABASE_URL").context("DATABASE_URL must be set")?;
let database_max_pool_size = env::var("DATABASE_MAX_POOL_SIZE") let database_max_pool_size = env::var("DATABASE_MAX_POOL_SIZE")
@@ -79,6 +94,8 @@ impl AppConfig {
let s3_bucket = env::var("S3_BUCKET").context("S3_BUCKET must be set")?; let s3_bucket = env::var("S3_BUCKET").context("S3_BUCKET must be set")?;
let quickwit_endpoint = env::var("QUICKWIT_ENDPOINT").ok(); let quickwit_endpoint = env::var("QUICKWIT_ENDPOINT").ok();
let quickwit_index = env::var("QUICKWIT_INDEX").ok(); let quickwit_index = env::var("QUICKWIT_INDEX").ok();
let default_tenant_slug =
env::var("DEFAULT_TENANT_SLUG").unwrap_or_else(|_| "admin".to_string());
Ok(Self { Ok(Self {
database_url, database_url,
@@ -104,6 +121,7 @@ impl AppConfig {
s3_bucket, s3_bucket,
quickwit_endpoint, quickwit_endpoint,
quickwit_index, quickwit_index,
default_tenant_slug,
}) })
} }
+2
View File
@@ -30,6 +30,7 @@ pub type JobQueueResult<T> = Result<T, JobQueueError>;
pub fn enqueue_job( pub fn enqueue_job(
conn: &mut PgConnection, conn: &mut PgConnection,
tenant_id: Uuid,
job_type: &str, job_type: &str,
payload: Value, payload: Value,
run_after: Option<NaiveDateTime>, run_after: Option<NaiveDateTime>,
@@ -40,6 +41,7 @@ pub fn enqueue_job(
payload, payload,
status: STATUS_QUEUED.to_string(), status: STATUS_QUEUED.to_string(),
run_after: run_after.unwrap_or_else(|| Utc::now().naive_utc()), run_after: run_after.unwrap_or_else(|| Utc::now().naive_utc()),
tenant_id,
}; };
diesel::insert_into(jobs::table) diesel::insert_into(jobs::table)
+1
View File
@@ -9,6 +9,7 @@ pub mod s3;
pub mod schema; pub mod schema;
pub mod state; pub mod state;
pub mod storage; pub mod storage;
pub mod tenants;
pub mod utils; pub mod utils;
pub mod workers; pub mod workers;
pub use workers::{default_handlers, Worker}; pub use workers::{default_handlers, Worker};
+9 -38
View File
@@ -1,57 +1,28 @@
use std::net::SocketAddr; use std::net::SocketAddr;
use std::sync::Arc;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tower::make::Shared; use tower::make::Shared;
use tracing_subscriber::EnvFilter;
use backend::auth::jwt::JwtService; use backend::{routes, utils::bootstrap::init_component};
use backend::config::AppConfig;
use backend::db;
use backend::routes;
use backend::s3::build_client;
use backend::state::AppState;
use backend::storage::S3Storage;
#[tokio::main] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
dotenv::dotenv().ok(); let state = init_component("api", None).await?;
init_tracing(); let server_host = state.config.server_host.clone();
let server_port = state.config.server_port;
let config = AppConfig::from_env()?;
tracing::info!( tracing::info!(
component = "api", component = "api",
database_url = %config.redacted_database_url(), server_host = %server_host,
pool_size = config.database_max_pool_size, server_port,
server_host = %config.server_host, "starting api server"
server_port = config.server_port,
quickwit_enabled = config.quickwit_endpoint.is_some(),
s3_bucket = %config.s3_bucket,
"loaded backend configuration"
); );
let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?;
let s3_client = build_client(&config).await?;
let storage = Arc::new(S3Storage::new(s3_client, config.s3_bucket.clone()));
let jwt = JwtService::from_config(&config)?;
let state = AppState::new(pool, config, storage, jwt); let router = routes::create_router(state.as_ref().clone());
let router = routes::create_router(state.clone()); let addr: SocketAddr = format!("{}:{}", server_host, server_port).parse()?;
let addr: SocketAddr =
format!("{}:{}", state.config.server_host, state.config.server_port).parse()?;
let listener = TcpListener::bind(addr).await?; let listener = TcpListener::bind(addr).await?;
tracing::info!("listening on {}", addr); tracing::info!("listening on {}", addr);
axum::serve(listener, Shared::new(router)).await?; axum::serve(listener, Shared::new(router)).await?;
Ok(()) Ok(())
} }
fn init_tracing() {
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
tracing_subscriber::fmt()
.with_env_filter(filter)
.with_target(false)
.compact()
.init();
}
+81 -4
View File
@@ -4,13 +4,48 @@ use uuid::Uuid;
use crate::schema::*; use crate::schema::*;
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
#[diesel(table_name = user_memberships)]
#[diesel(belongs_to(User, foreign_key = user_id))]
#[diesel(belongs_to(Tenant, foreign_key = tenant_id))]
pub struct UserMembership {
pub id: Uuid,
pub user_id: Uuid,
pub tenant_id: Uuid,
pub role: String,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
}
#[derive(Debug, Insertable)]
#[diesel(table_name = user_memberships)]
pub struct NewUserMembership {
pub id: Uuid,
pub user_id: Uuid,
pub tenant_id: Uuid,
pub role: String,
}
#[derive(Debug, Clone, Queryable, Identifiable)]
#[diesel(table_name = tenants)]
#[diesel(primary_key(id))]
pub struct Tenant {
pub id: Uuid,
pub slug: String,
pub storage_root: Option<String>,
pub quickwit_index: Option<String>,
pub status: String,
pub config: serde_json::Value,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
}
#[derive(Debug, Clone, Queryable, Identifiable)] #[derive(Debug, Clone, Queryable, Identifiable)]
#[diesel(table_name = users)] #[diesel(table_name = users)]
pub struct User { pub struct User {
pub id: Uuid, pub id: Uuid,
pub username: String, pub username: String,
pub password_hash: String, pub password_hash: String,
pub role: String,
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime, pub updated_at: NaiveDateTime,
} }
@@ -21,7 +56,6 @@ pub struct NewUser {
pub id: Uuid, pub id: Uuid,
pub username: String, pub username: String,
pub password_hash: String, pub password_hash: String,
pub role: String,
} }
#[derive(Debug, Clone, Queryable, Identifiable)] #[derive(Debug, Clone, Queryable, Identifiable)]
@@ -32,6 +66,7 @@ pub struct Folder {
pub parent_id: Option<Uuid>, pub parent_id: Option<Uuid>,
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime, pub updated_at: NaiveDateTime,
pub tenant_id: Uuid,
} }
#[derive(Debug, Insertable)] #[derive(Debug, Insertable)]
@@ -40,6 +75,7 @@ pub struct NewFolder {
pub id: Uuid, pub id: Uuid,
pub name: String, pub name: String,
pub parent_id: Option<Uuid>, pub parent_id: Option<Uuid>,
pub tenant_id: Uuid,
} }
#[derive(Debug, Clone, Queryable, Identifiable, Associations)] #[derive(Debug, Clone, Queryable, Identifiable, Associations)]
@@ -58,6 +94,7 @@ pub struct Document {
pub issued_at: Option<NaiveDateTime>, pub issued_at: Option<NaiveDateTime>,
pub title: String, pub title: String,
pub current_version_id: Uuid, pub current_version_id: Uuid,
pub tenant_id: Uuid,
} }
#[derive(Debug, Insertable)] #[derive(Debug, Insertable)]
@@ -72,6 +109,7 @@ pub struct NewDocument {
pub metadata: serde_json::Value, pub metadata: serde_json::Value,
pub issued_at: Option<NaiveDateTime>, pub issued_at: Option<NaiveDateTime>,
pub title: String, pub title: String,
pub tenant_id: Uuid,
} }
#[derive(Debug, Clone, Queryable, Identifiable, Associations)] #[derive(Debug, Clone, Queryable, Identifiable, Associations)]
@@ -87,6 +125,7 @@ pub struct DocumentVersion {
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub operations_summary: serde_json::Value, pub operations_summary: serde_json::Value,
pub metadata: serde_json::Value, pub metadata: serde_json::Value,
pub tenant_id: Uuid,
} }
#[derive(Debug, Insertable)] #[derive(Debug, Insertable)]
@@ -100,6 +139,7 @@ pub struct NewDocumentVersion {
pub checksum: String, pub checksum: String,
pub operations_summary: serde_json::Value, pub operations_summary: serde_json::Value,
pub metadata: serde_json::Value, pub metadata: serde_json::Value,
pub tenant_id: Uuid,
} }
#[derive(Debug, Clone, Queryable, Identifiable, Associations)] #[derive(Debug, Clone, Queryable, Identifiable, Associations)]
@@ -109,10 +149,11 @@ pub struct DocumentAsset {
pub id: Uuid, pub id: Uuid,
pub document_version_id: Uuid, pub document_version_id: Uuid,
pub asset_type: String, pub asset_type: String,
pub s3_key: String,
pub mime_type: String, pub mime_type: String,
pub metadata: serde_json::Value, pub metadata: serde_json::Value,
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub cardinality: Option<i32>,
pub tenant_id: Uuid,
} }
#[derive(Debug, Insertable)] #[derive(Debug, Insertable)]
@@ -121,9 +162,33 @@ pub struct NewDocumentAsset {
pub id: Uuid, pub id: Uuid,
pub document_version_id: Uuid, pub document_version_id: Uuid,
pub asset_type: String, pub asset_type: String,
pub s3_key: String,
pub mime_type: String, pub mime_type: String,
pub metadata: serde_json::Value, pub metadata: serde_json::Value,
pub cardinality: Option<i32>,
pub tenant_id: Uuid,
}
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
#[diesel(table_name = document_asset_objects)]
#[diesel(belongs_to(DocumentAsset, foreign_key = asset_id))]
pub struct DocumentAssetObject {
pub id: Uuid,
pub asset_id: Uuid,
pub ordinal: i32,
pub s3_key: String,
pub metadata: serde_json::Value,
pub tenant_id: Uuid,
}
#[derive(Debug, Insertable)]
#[diesel(table_name = document_asset_objects)]
pub struct NewDocumentAssetObject {
pub id: Uuid,
pub asset_id: Uuid,
pub ordinal: i32,
pub s3_key: String,
pub metadata: serde_json::Value,
pub tenant_id: Uuid,
} }
#[derive(Debug, Clone, Queryable, Identifiable)] #[derive(Debug, Clone, Queryable, Identifiable)]
@@ -138,6 +203,7 @@ pub struct Job {
pub last_error: Option<String>, pub last_error: Option<String>,
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime, pub updated_at: NaiveDateTime,
pub tenant_id: Uuid,
} }
#[derive(Debug, Insertable)] #[derive(Debug, Insertable)]
@@ -148,6 +214,7 @@ pub struct NewJob {
pub payload: serde_json::Value, pub payload: serde_json::Value,
pub status: String, pub status: String,
pub run_after: NaiveDateTime, pub run_after: NaiveDateTime,
pub tenant_id: Uuid,
} }
#[derive(Debug, Clone, Queryable, Identifiable)] #[derive(Debug, Clone, Queryable, Identifiable)]
@@ -157,6 +224,7 @@ pub struct Tag {
pub label: String, pub label: String,
pub color: Option<String>, pub color: Option<String>,
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub tenant_id: Uuid,
} }
#[derive(Debug, Insertable)] #[derive(Debug, Insertable)]
@@ -165,6 +233,7 @@ pub struct NewTag {
pub id: Uuid, pub id: Uuid,
pub label: String, pub label: String,
pub color: Option<String>, pub color: Option<String>,
pub tenant_id: Uuid,
} }
#[allow(dead_code)] #[allow(dead_code)]
@@ -178,6 +247,7 @@ pub struct DocumentTag {
pub tag_id: Uuid, pub tag_id: Uuid,
pub assigned_at: NaiveDateTime, pub assigned_at: NaiveDateTime,
pub assigned_by: Option<Uuid>, pub assigned_by: Option<Uuid>,
pub tenant_id: Uuid,
} }
#[derive(Debug, Insertable)] #[derive(Debug, Insertable)]
@@ -186,6 +256,7 @@ pub struct NewDocumentTag {
pub document_id: Uuid, pub document_id: Uuid,
pub tag_id: Uuid, pub tag_id: Uuid,
pub assigned_by: Option<Uuid>, pub assigned_by: Option<Uuid>,
pub tenant_id: Uuid,
} }
#[derive(Debug, Clone, Queryable, Identifiable)] #[derive(Debug, Clone, Queryable, Identifiable)]
@@ -196,6 +267,7 @@ pub struct Correspondent {
pub metadata: serde_json::Value, pub metadata: serde_json::Value,
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime, pub updated_at: NaiveDateTime,
pub tenant_id: Uuid,
} }
#[derive(Debug, Insertable)] #[derive(Debug, Insertable)]
@@ -204,6 +276,7 @@ pub struct NewCorrespondent {
pub id: Uuid, pub id: Uuid,
pub name: String, pub name: String,
pub metadata: serde_json::Value, pub metadata: serde_json::Value,
pub tenant_id: Uuid,
} }
#[derive(Debug, Clone, Queryable, Associations)] #[derive(Debug, Clone, Queryable, Associations)]
@@ -217,6 +290,7 @@ pub struct DocumentCorrespondent {
pub role: String, pub role: String,
pub assigned_at: NaiveDateTime, pub assigned_at: NaiveDateTime,
pub assigned_by: Option<Uuid>, pub assigned_by: Option<Uuid>,
pub tenant_id: Uuid,
} }
#[derive(Debug, Insertable)] #[derive(Debug, Insertable)]
@@ -226,6 +300,7 @@ pub struct NewDocumentCorrespondent {
pub correspondent_id: Uuid, pub correspondent_id: Uuid,
pub role: String, pub role: String,
pub assigned_by: Option<Uuid>, pub assigned_by: Option<Uuid>,
pub tenant_id: Uuid,
} }
#[derive(Debug, Clone, Queryable, Identifiable, Associations)] #[derive(Debug, Clone, Queryable, Identifiable, Associations)]
@@ -240,6 +315,7 @@ pub struct RefreshToken {
pub revoked_at: Option<NaiveDateTime>, pub revoked_at: Option<NaiveDateTime>,
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime, pub updated_at: NaiveDateTime,
pub tenant_id: Uuid,
} }
#[derive(Debug, Insertable)] #[derive(Debug, Insertable)]
@@ -250,4 +326,5 @@ pub struct NewRefreshToken {
pub token_hash: String, pub token_hash: String,
pub issued_at: NaiveDateTime, pub issued_at: NaiveDateTime,
pub expires_at: NaiveDateTime, pub expires_at: NaiveDateTime,
pub tenant_id: Uuid,
} }
+152 -72
View File
@@ -1,11 +1,15 @@
use axum::{ use axum::{
extract::State, extract::State,
http::{header::SET_COOKIE, HeaderMap, HeaderValue, StatusCode}, http::{header::SET_COOKIE, HeaderMap, HeaderValue, StatusCode},
response::{IntoResponse, Response},
Json, Json,
}; };
use axum_extra::{headers::Cookie, typed_header::TypedHeader}; use axum_extra::{
headers::{authorization::Bearer, Authorization, Cookie},
typed_header::TypedHeader,
};
use chrono::{Duration as ChronoDuration, Utc}; use chrono::{Duration as ChronoDuration, Utc};
use diesel::prelude::*; use diesel::{pg::PgConnection, prelude::*};
use rand::{rngs::OsRng, RngCore}; use rand::{rngs::OsRng, RngCore};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
@@ -14,8 +18,11 @@ use uuid::Uuid;
use crate::{ use crate::{
auth::{password, AuthenticatedUser}, auth::{password, AuthenticatedUser},
error::{AppError, AppResult}, error::{AppError, AppResult},
models::{NewRefreshToken, RefreshToken, User}, models::{NewRefreshToken, RefreshToken, Tenant, User, UserMembership},
schema::{refresh_tokens, users::dsl}, schema::{
refresh_tokens, tenants::dsl as tenant_dsl, user_memberships::dsl as memberships_dsl,
users::dsl,
},
state::AppState, state::AppState,
}; };
@@ -27,6 +34,8 @@ const REFRESH_COOKIE_NAME: &str = "refresh_token";
pub struct LoginRequest { pub struct LoginRequest {
pub username: String, pub username: String,
pub password: String, pub password: String,
#[serde(default)]
pub preferred_tenant_slug: Option<String>,
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -36,11 +45,28 @@ pub struct LoginResponse {
pub expires_in: i64, pub expires_in: i64,
} }
#[derive(Serialize)]
pub struct TenantSummary {
pub tenant_id: Uuid,
pub slug: String,
}
#[derive(Serialize)]
pub struct TenantSelectionResponse {
pub selection_token: String,
pub tenants: Vec<TenantSummary>,
}
#[derive(Deserialize)]
pub struct TenantSelectionRequest {
pub tenant_id: Uuid,
}
pub async fn login( pub async fn login(
State(state): State<AppState>, State(state): State<AppState>,
Json(payload): Json<LoginRequest>, Json(payload): Json<LoginRequest>,
) -> AppResult<(HeaderMap, Json<LoginResponse>)> { ) -> AppResult<Response> {
let mut conn = state.db()?; let mut conn = state.db_unscoped()?;
let user: User = dsl::users let user: User = dsl::users
.filter(dsl::username.eq(&payload.username)) .filter(dsl::username.eq(&payload.username))
@@ -53,55 +79,67 @@ pub async fn login(
return Err(AppError::unauthorized()); return Err(AppError::unauthorized());
} }
let access_token = state let memberships: Vec<(UserMembership, Tenant)> = memberships_dsl::user_memberships
.inner_join(tenant_dsl::tenants)
.filter(memberships_dsl::user_id.eq(user.id))
.load(&mut conn)?;
if memberships.is_empty() {
return Err(AppError::unauthorized());
}
let preferred_slug = payload
.preferred_tenant_slug
.as_ref()
.map(|slug| slug.trim().to_string())
.filter(|slug| !slug.is_empty());
if let Some(tenant) = preferred_slug.as_ref().and_then(|slug| {
memberships
.iter()
.find(|(_, tenant)| tenant.slug.eq_ignore_ascii_case(slug))
}) {
return issue_session(&state, &mut conn, &user, tenant.1.id);
}
if memberships.len() == 1 {
let tenant_id = memberships[0].1.id;
return issue_session(&state, &mut conn, &user, tenant_id);
}
let selection_token = state
.jwt .jwt
.generate_token(user.id, &user.username, &user.role) .generate_tenant_selector_token(user.id)
.map_err(AppError::from)?; .map_err(AppError::from)?;
let now = Utc::now(); let tenants = memberships
let refresh_value = generate_refresh_token(); .into_iter()
let refresh_hash = hash_refresh_token(&refresh_value); .map(|(_, tenant)| TenantSummary {
let refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days); tenant_id: tenant.id,
slug: tenant.slug,
})
.collect();
let new_refresh = NewRefreshToken { let response = Json(TenantSelectionResponse {
id: Uuid::new_v4(), selection_token,
user_id: user.id, tenants,
token_hash: refresh_hash, })
issued_at: now.naive_utc(), .into_response();
expires_at: refresh_expires_at.naive_utc(),
};
diesel::insert_into(refresh_tokens::table) Ok(response)
.values(&new_refresh)
.execute(&mut conn)?;
let mut headers = HeaderMap::new();
headers.insert(
SET_COOKIE,
build_refresh_cookie(&state, &refresh_value, refresh_expires_at),
);
Ok((
headers,
Json(LoginResponse {
access_token,
token_type: "Bearer".to_string(),
expires_in: state.config.jwt_expiry_minutes * 60,
}),
))
} }
pub async fn refresh( pub async fn refresh(
State(state): State<AppState>, State(state): State<AppState>,
jar: Option<TypedHeader<Cookie>>, jar: Option<TypedHeader<Cookie>>,
) -> AppResult<(HeaderMap, Json<LoginResponse>)> { ) -> AppResult<Response> {
let cookies = jar.ok_or_else(AppError::unauthorized)?; let cookies = jar.ok_or_else(AppError::unauthorized)?;
let refresh_value = cookies let refresh_value = cookies
.get(REFRESH_COOKIE_NAME) .get(REFRESH_COOKIE_NAME)
.ok_or_else(AppError::unauthorized)?; .ok_or_else(AppError::unauthorized)?;
let hashed = hash_refresh_token(refresh_value); let hashed = hash_refresh_token(refresh_value);
let mut conn = state.db()?; let mut conn = state.db_unscoped()?;
let now = Utc::now(); let now = Utc::now();
let now_naive = now.naive_utc(); let now_naive = now.naive_utc();
@@ -128,41 +166,39 @@ pub async fn refresh(
.first(&mut conn) .first(&mut conn)
.map_err(AppError::from)?; .map_err(AppError::from)?;
let access_token = state issue_session(&state, &mut conn, &user, token.tenant_id)
}
pub async fn select_tenant(
State(state): State<AppState>,
TypedHeader(Authorization(bearer)): TypedHeader<Authorization<Bearer>>,
Json(payload): Json<TenantSelectionRequest>,
) -> AppResult<Response> {
let claims = state
.jwt .jwt
.generate_token(user.id, &user.username, &user.role) .verify_tenant_selector_token(bearer.token())
.map_err(|_| AppError::unauthorized())?;
let mut conn = state.db_unscoped()?;
let membership_exists = memberships_dsl::user_memberships
.filter(memberships_dsl::user_id.eq(claims.sub))
.filter(memberships_dsl::tenant_id.eq(payload.tenant_id))
.inner_join(tenant_dsl::tenants)
.select(memberships_dsl::id)
.first::<Uuid>(&mut conn)
.optional()?;
if membership_exists.is_none() {
return Err(AppError::unauthorized());
}
let user: User = dsl::users
.find(claims.sub)
.first(&mut conn)
.map_err(AppError::from)?; .map_err(AppError::from)?;
let new_refresh_value = generate_refresh_token(); issue_session(&state, &mut conn, &user, payload.tenant_id)
let new_refresh_hash = hash_refresh_token(&new_refresh_value);
let new_refresh_expires = now + ChronoDuration::days(state.config.refresh_token_expiry_days);
let new_refresh = NewRefreshToken {
id: Uuid::new_v4(),
user_id: user.id,
token_hash: new_refresh_hash,
issued_at: now_naive,
expires_at: new_refresh_expires.naive_utc(),
};
diesel::insert_into(refresh_tokens::table)
.values(&new_refresh)
.execute(&mut conn)?;
let mut headers = HeaderMap::new();
headers.insert(
SET_COOKIE,
build_refresh_cookie(&state, &new_refresh_value, new_refresh_expires),
);
Ok((
headers,
Json(LoginResponse {
access_token,
token_type: "Bearer".to_string(),
expires_in: state.config.jwt_expiry_minutes * 60,
}),
))
} }
pub async fn logout( pub async fn logout(
@@ -170,7 +206,7 @@ pub async fn logout(
user: AuthenticatedUser, user: AuthenticatedUser,
jar: Option<TypedHeader<Cookie>>, jar: Option<TypedHeader<Cookie>>,
) -> AppResult<(HeaderMap, StatusCode)> { ) -> AppResult<(HeaderMap, StatusCode)> {
let mut conn = state.db()?; let mut conn = state.db_unscoped()?;
let now = Utc::now().naive_utc(); let now = Utc::now().naive_utc();
let mut rows_affected = 0; let mut rows_affected = 0;
@@ -214,6 +250,50 @@ pub async fn me(user: AuthenticatedUser) -> Json<AuthenticatedUser> {
Json(user) Json(user)
} }
fn issue_session(
state: &AppState,
conn: &mut PgConnection,
user: &User,
tenant_id: Uuid,
) -> AppResult<Response> {
let now = Utc::now();
let access_token = state
.jwt
.generate_token(user.id, tenant_id, &user.username)
.map_err(AppError::from)?;
let refresh_value = generate_refresh_token();
let refresh_hash = hash_refresh_token(&refresh_value);
let refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days);
let new_refresh = NewRefreshToken {
id: Uuid::new_v4(),
user_id: user.id,
token_hash: refresh_hash,
issued_at: now.naive_utc(),
expires_at: refresh_expires_at.naive_utc(),
tenant_id,
};
diesel::insert_into(refresh_tokens::table)
.values(&new_refresh)
.execute(conn)?;
let mut response = Json(LoginResponse {
access_token,
token_type: "Bearer".to_string(),
expires_in: state.config.jwt_expiry_minutes * 60,
})
.into_response();
response.headers_mut().insert(
SET_COOKIE,
build_refresh_cookie(state, &refresh_value, refresh_expires_at),
);
Ok(response)
}
fn hash_refresh_token(token: &str) -> String { fn hash_refresh_token(token: &str) -> String {
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
hasher.update(token.as_bytes()); hasher.update(token.as_bytes());
+64 -33
View File
@@ -1,11 +1,6 @@
use std::collections::{BTreeMap, HashMap}; use std::collections::{BTreeMap, HashMap};
use axum::{ use axum::{extract::Path, http::StatusCode, Json};
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
Json,
};
use chrono::Utc; use chrono::Utc;
use diesel::{dsl::count_star, prelude::*, result::DatabaseErrorKind, PgConnection}; use diesel::{dsl::count_star, prelude::*, result::DatabaseErrorKind, PgConnection};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -13,14 +8,16 @@ use serde_json::Value;
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
auth::TenantScopedConn,
error::{AppError, AppResult}, error::{AppError, AppResult},
models::{Correspondent, NewCorrespondent}, models::{Correspondent, NewCorrespondent},
schema::{correspondents, document_correspondents}, schema::{correspondents, document_correspondents},
state::AppState, utils::{
db::{no_content, EnsureEntity, IntoJsonResponse},
time::to_iso,
},
}; };
use super::documents::to_iso;
#[derive(Serialize)] #[derive(Serialize)]
pub struct CorrespondentUsage { pub struct CorrespondentUsage {
pub total: i64, pub total: i64,
@@ -59,15 +56,19 @@ struct CorrespondentChangeset<'a> {
} }
pub async fn list_correspondents( pub async fn list_correspondents(
State(state): State<AppState>, TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<Json<Vec<CorrespondentSummary>>> { ) -> AppResult<Json<Vec<CorrespondentSummary>>> {
let mut conn = state.db()?;
let correspondents_list: Vec<Correspondent> = correspondents::table let correspondents_list: Vec<Correspondent> = correspondents::table
.filter(correspondents::tenant_id.eq(tenant_id))
.order(correspondents::name.asc()) .order(correspondents::name.asc())
.load(&mut conn)?; .load(&mut conn)?;
let usage_rows: Vec<(Uuid, String, i64)> = document_correspondents::table let usage_rows: Vec<(Uuid, String, i64)> = document_correspondents::table
.filter(document_correspondents::tenant_id.eq(tenant_id))
.group_by(( .group_by((
document_correspondents::correspondent_id, document_correspondents::correspondent_id,
document_correspondents::role, document_correspondents::role,
@@ -93,11 +94,15 @@ pub async fn list_correspondents(
response.push(build_summary(correspondent, role_counts)); response.push(build_summary(correspondent, role_counts));
} }
Ok(Json(response)) response.into_json()
} }
pub async fn create_correspondent( pub async fn create_correspondent(
State(state): State<AppState>, TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
Json(payload): Json<CreateCorrespondentRequest>, Json(payload): Json<CreateCorrespondentRequest>,
) -> AppResult<Json<CorrespondentSummary>> { ) -> AppResult<Json<CorrespondentSummary>> {
let name = payload.name.trim(); let name = payload.name.trim();
@@ -111,9 +116,9 @@ pub async fn create_correspondent(
id: new_id, id: new_id,
name: name.to_string(), name: name.to_string(),
metadata: metadata_value, metadata: metadata_value,
tenant_id,
}; };
let mut conn = state.db()?;
match diesel::insert_into(correspondents::table) match diesel::insert_into(correspondents::table)
.values(&new_correspondent) .values(&new_correspondent)
.execute(&mut conn) .execute(&mut conn)
@@ -125,19 +130,29 @@ pub async fn create_correspondent(
Err(err) => return Err(AppError::from(err)), Err(err) => return Err(AppError::from(err)),
} }
let correspondent: Correspondent = correspondents::table.find(new_id).first(&mut conn)?; let correspondent: Correspondent = correspondents::table
Ok(Json(build_summary(correspondent, BTreeMap::new()))) .find(new_id)
.filter(correspondents::tenant_id.eq(tenant_id))
.first(&mut conn)
.one()?;
build_summary(correspondent, BTreeMap::new()).into_json()
} }
pub async fn update_correspondent( pub async fn update_correspondent(
State(state): State<AppState>,
Path(correspondent_id): Path<Uuid>, Path(correspondent_id): Path<Uuid>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
Json(payload): Json<UpdateCorrespondentRequest>, Json(payload): Json<UpdateCorrespondentRequest>,
) -> AppResult<Json<CorrespondentSummary>> { ) -> AppResult<Json<CorrespondentSummary>> {
let mut conn = state.db()?;
let existing: Correspondent = correspondents::table let existing: Correspondent = correspondents::table
.find(correspondent_id) .find(correspondent_id)
.first(&mut conn)?; .filter(correspondents::tenant_id.eq(tenant_id))
.first(&mut conn)
.one()?;
let mut new_name: Option<String> = None; let mut new_name: Option<String> = None;
if let Some(ref candidate) = payload.name { if let Some(ref candidate) = payload.name {
@@ -149,6 +164,7 @@ pub async fn update_correspondent(
let duplicate = correspondents::table let duplicate = correspondents::table
.filter(correspondents::name.eq(trimmed)) .filter(correspondents::name.eq(trimmed))
.filter(correspondents::id.ne(correspondent_id)) .filter(correspondents::id.ne(correspondent_id))
.filter(correspondents::tenant_id.eq(tenant_id))
.first::<Correspondent>(&mut conn) .first::<Correspondent>(&mut conn)
.optional()?; .optional()?;
if duplicate.is_some() { if duplicate.is_some() {
@@ -167,8 +183,8 @@ pub async fn update_correspondent(
} }
if new_name.is_none() && new_metadata.is_none() { if new_name.is_none() && new_metadata.is_none() {
let usage = load_usage_for_correspondent(&mut conn, correspondent_id)?; let usage = load_usage_for_correspondent(&mut conn, tenant_id, correspondent_id)?;
return Ok(Json(build_summary(existing.clone(), usage))); return build_summary(existing.clone(), usage).into_json();
} }
let mut changeset = CorrespondentChangeset::default(); let mut changeset = CorrespondentChangeset::default();
@@ -180,24 +196,33 @@ pub async fn update_correspondent(
} }
let now = Utc::now().naive_utc(); let now = Utc::now().naive_utc();
diesel::update(correspondents::table.find(correspondent_id)) diesel::update(
correspondents::table
.find(correspondent_id)
.filter(correspondents::tenant_id.eq(tenant_id)),
)
.set((&changeset, correspondents::updated_at.eq(now))) .set((&changeset, correspondents::updated_at.eq(now)))
.execute(&mut conn)?; .execute(&mut conn)?;
let updated: Correspondent = correspondents::table let updated: Correspondent = correspondents::table
.find(correspondent_id) .find(correspondent_id)
.first(&mut conn)?; .filter(correspondents::tenant_id.eq(tenant_id))
let usage = load_usage_for_correspondent(&mut conn, correspondent_id)?; .first(&mut conn)
Ok(Json(build_summary(updated, usage))) .one()?;
let usage = load_usage_for_correspondent(&mut conn, tenant_id, correspondent_id)?;
build_summary(updated, usage).into_json()
} }
pub async fn delete_correspondent( pub async fn delete_correspondent(
State(state): State<AppState>,
Path(correspondent_id): Path<Uuid>, Path(correspondent_id): Path<Uuid>,
) -> AppResult<impl IntoResponse> { TenantScopedConn {
let mut conn = state.db()?; mut conn,
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<StatusCode> {
let usage: i64 = document_correspondents::table let usage: i64 = document_correspondents::table
.filter(document_correspondents::tenant_id.eq(tenant_id))
.filter(document_correspondents::correspondent_id.eq(correspondent_id)) .filter(document_correspondents::correspondent_id.eq(correspondent_id))
.select(count_star()) .select(count_star())
.first(&mut conn)?; .first(&mut conn)?;
@@ -208,12 +233,16 @@ pub async fn delete_correspondent(
)); ));
} }
let deleted = let deleted = diesel::delete(
diesel::delete(correspondents::table.find(correspondent_id)).execute(&mut conn)?; correspondents::table
.filter(correspondents::id.eq(correspondent_id))
.filter(correspondents::tenant_id.eq(tenant_id)),
)
.execute(&mut conn)?;
if deleted == 0 { if deleted == 0 {
return Err(AppError::not_found()); return Err(AppError::not_found());
} }
Ok(StatusCode::NO_CONTENT) no_content()
} }
fn build_summary( fn build_summary(
@@ -243,10 +272,12 @@ fn normalize_metadata(input: Option<Value>) -> Value {
fn load_usage_for_correspondent( fn load_usage_for_correspondent(
conn: &mut PgConnection, conn: &mut PgConnection,
tenant_id: Uuid,
correspondent_id: Uuid, correspondent_id: Uuid,
) -> AppResult<BTreeMap<String, i64>> { ) -> AppResult<BTreeMap<String, i64>> {
let rows: Vec<(String, i64)> = document_correspondents::table let rows: Vec<(String, i64)> = document_correspondents::table
.filter(document_correspondents::correspondent_id.eq(correspondent_id)) .filter(document_correspondents::correspondent_id.eq(correspondent_id))
.filter(document_correspondents::tenant_id.eq(tenant_id))
.group_by(document_correspondents::role) .group_by(document_correspondents::role)
.select((document_correspondents::role, count_star())) .select((document_correspondents::role, count_star()))
.load(conn)?; .load(conn)?;
File diff suppressed because it is too large Load Diff
+74 -27
View File
@@ -10,14 +10,15 @@ use crate::models::{Document, Folder, NewFolder};
use crate::schema::{documents, folders}; use crate::schema::{documents, folders};
use crate::state::AppState; use crate::state::AppState;
use crate::{ use crate::{
auth::AuthenticatedUser, auth::TenantScopedConn,
error::{AppError, AppResult}, error::{AppError, AppResult},
}; };
use super::documents::{ use super::documents::{
load_correspondents_for_documents, load_primary_assets, load_tags_for_documents, load_correspondents_for_documents, load_primary_assets, load_tags_for_documents,
to_document_response, to_iso, DocumentResponse, to_document_response, DocumentResponse,
}; };
use crate::utils::time::to_iso;
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct CreateFolderRequest { pub struct CreateFolderRequest {
@@ -70,15 +71,17 @@ pub struct FolderInfo {
} }
pub async fn ensure_folder_path( pub async fn ensure_folder_path(
State(state): State<AppState>, TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
Json(payload): Json<EnsureFolderPathRequest>, Json(payload): Json<EnsureFolderPathRequest>,
) -> AppResult<Json<FolderResponse>> { ) -> AppResult<Json<FolderResponse>> {
if payload.segments.is_empty() { if payload.segments.is_empty() {
return Err(AppError::bad_request("segments must not be empty")); return Err(AppError::bad_request("segments must not be empty"));
} }
let mut conn = state.db()?;
let target_folder = conn.transaction::<Folder, AppError, _>(|conn| { let target_folder = conn.transaction::<Folder, AppError, _>(|conn| {
let mut current_parent = payload.parent_id; let mut current_parent = payload.parent_id;
let mut last_folder: Option<Folder> = None; let mut last_folder: Option<Folder> = None;
@@ -93,12 +96,14 @@ pub async fn ensure_folder_path(
folders::table folders::table
.filter(folders::parent_id.eq(Some(parent_id))) .filter(folders::parent_id.eq(Some(parent_id)))
.filter(folders::name.eq(name)) .filter(folders::name.eq(name))
.filter(folders::tenant_id.eq(tenant_id))
.first(conn) .first(conn)
.optional()? .optional()?
} else { } else {
folders::table folders::table
.filter(folders::parent_id.is_null()) .filter(folders::parent_id.is_null())
.filter(folders::name.eq(name)) .filter(folders::name.eq(name))
.filter(folders::tenant_id.eq(tenant_id))
.first(conn) .first(conn)
.optional()? .optional()?
}; };
@@ -110,6 +115,7 @@ pub async fn ensure_folder_path(
id: Uuid::new_v4(), id: Uuid::new_v4(),
name: name.to_string(), name: name.to_string(),
parent_id: current_parent, parent_id: current_parent,
tenant_id,
}; };
diesel::insert_into(folders::table) diesel::insert_into(folders::table)
@@ -132,19 +138,22 @@ pub async fn ensure_folder_path(
} }
pub async fn create_folder( pub async fn create_folder(
State(state): State<AppState>, TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
Json(payload): Json<CreateFolderRequest>, Json(payload): Json<CreateFolderRequest>,
) -> AppResult<Json<FolderResponse>> { ) -> AppResult<Json<FolderResponse>> {
if payload.name.trim().is_empty() { if payload.name.trim().is_empty() {
return Err(AppError::bad_request("name must not be empty")); return Err(AppError::bad_request("name must not be empty"));
} }
let mut conn = state.db()?;
let new_folder = NewFolder { let new_folder = NewFolder {
id: Uuid::new_v4(), id: Uuid::new_v4(),
name: payload.name.trim().to_string(), name: payload.name.trim().to_string(),
parent_id: payload.parent_id, parent_id: payload.parent_id,
tenant_id,
}; };
diesel::insert_into(folders::table) diesel::insert_into(folders::table)
@@ -161,10 +170,13 @@ pub async fn list_folder_contents(
State(state): State<AppState>, State(state): State<AppState>,
Path(folder_identifier): Path<String>, Path(folder_identifier): Path<String>,
Query(query): Query<FolderContentsQuery>, Query(query): Query<FolderContentsQuery>,
user: AuthenticatedUser, TenantScopedConn {
mut conn,
tenant_id,
user_id,
..
}: TenantScopedConn,
) -> AppResult<Json<FolderContentsResponse>> { ) -> AppResult<Json<FolderContentsResponse>> {
let mut conn = state.db()?;
let folder_id = if folder_identifier.eq_ignore_ascii_case("root") { let folder_id = if folder_identifier.eq_ignore_ascii_case("root") {
None None
} else { } else {
@@ -176,7 +188,10 @@ pub async fn list_folder_contents(
let folder = match folder_id { let folder = match folder_id {
Some(id) => Some(folder_to_info( Some(id) => Some(folder_to_info(
folders::table.find(id).first::<Folder>(&mut conn)?, folders::table
.find(id)
.filter(folders::tenant_id.eq(tenant_id))
.first::<Folder>(&mut conn)?,
)), )),
None => None, None => None,
}; };
@@ -184,11 +199,13 @@ pub async fn list_folder_contents(
let child_folders: Vec<Folder> = if let Some(parent_id) = folder_id { let child_folders: Vec<Folder> = if let Some(parent_id) = folder_id {
folders::table folders::table
.filter(folders::parent_id.eq(parent_id)) .filter(folders::parent_id.eq(parent_id))
.filter(folders::tenant_id.eq(tenant_id))
.order(folders::name.asc()) .order(folders::name.asc())
.load(&mut conn)? .load(&mut conn)?
} else { } else {
folders::table folders::table
.filter(folders::parent_id.is_null()) .filter(folders::parent_id.is_null())
.filter(folders::tenant_id.eq(tenant_id))
.order(folders::name.asc()) .order(folders::name.asc())
.load(&mut conn)? .load(&mut conn)?
}; };
@@ -197,6 +214,7 @@ pub async fn list_folder_contents(
let documents = if query.include_documents { let documents = if query.include_documents {
let docs_query = documents::table let docs_query = documents::table
.filter(documents::deleted_at.is_null()) .filter(documents::deleted_at.is_null())
.filter(documents::tenant_id.eq(tenant_id))
.order(documents::uploaded_at.desc()); .order(documents::uploaded_at.desc());
let docs: Vec<Document> = if let Some(current_folder) = folder_id { let docs: Vec<Document> = if let Some(current_folder) = folder_id {
@@ -214,7 +232,7 @@ pub async fn list_folder_contents(
let mut correspondents_map = load_correspondents_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, tenant_id, &docs).await?;
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 {
@@ -223,7 +241,7 @@ pub async fn list_folder_contents(
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_id,
doc, doc,
tags, tags,
correspondents, correspondents,
@@ -244,16 +262,23 @@ pub async fn list_folder_contents(
} }
pub async fn delete_folder( pub async fn delete_folder(
State(state): State<AppState>,
Path(folder_id): Path<Uuid>, Path(folder_id): Path<Uuid>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<StatusCode> { ) -> AppResult<StatusCode> {
let mut conn = state.db()?;
conn.transaction::<_, AppError, _>(|conn| { conn.transaction::<_, AppError, _>(|conn| {
folders::table.find(folder_id).first::<Folder>(conn)?; folders::table
.find(folder_id)
.filter(folders::tenant_id.eq(tenant_id))
.first::<Folder>(conn)?;
let has_child_folders: bool = diesel::select(exists( let has_child_folders: bool = diesel::select(exists(
folders::table.filter(folders::parent_id.eq(Some(folder_id))), folders::table
.filter(folders::parent_id.eq(Some(folder_id)))
.filter(folders::tenant_id.eq(tenant_id)),
)) ))
.get_result(conn)?; .get_result(conn)?;
@@ -266,6 +291,7 @@ pub async fn delete_folder(
let has_documents: bool = diesel::select(exists( let has_documents: bool = diesel::select(exists(
documents::table documents::table
.filter(documents::folder_id.eq(Some(folder_id))) .filter(documents::folder_id.eq(Some(folder_id)))
.filter(documents::tenant_id.eq(tenant_id))
.filter(documents::deleted_at.is_null()), .filter(documents::deleted_at.is_null()),
)) ))
.get_result(conn)?; .get_result(conn)?;
@@ -276,7 +302,12 @@ pub async fn delete_folder(
)); ));
} }
diesel::delete(folders::table.find(folder_id)).execute(conn)?; diesel::delete(
folders::table
.filter(folders::id.eq(folder_id))
.filter(folders::tenant_id.eq(tenant_id)),
)
.execute(conn)?;
Ok(()) Ok(())
})?; })?;
@@ -285,14 +316,19 @@ pub async fn delete_folder(
} }
pub async fn update_folder( pub async fn update_folder(
State(state): State<AppState>,
Path(folder_id): Path<Uuid>, Path(folder_id): Path<Uuid>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
Json(payload): Json<UpdateFolderRequest>, Json(payload): Json<UpdateFolderRequest>,
) -> AppResult<StatusCode> { ) -> AppResult<StatusCode> {
let mut conn = state.db()?;
conn.transaction::<(), AppError, _>(|conn| { conn.transaction::<(), AppError, _>(|conn| {
let folder: Folder = folders::table.find(folder_id).first(conn)?; let folder: Folder = folders::table
.find(folder_id)
.filter(folders::tenant_id.eq(tenant_id))
.first(conn)?;
let mut next_parent = folder.parent_id; let mut next_parent = folder.parent_id;
let mut parent_changed = false; let mut parent_changed = false;
@@ -303,9 +339,12 @@ pub async fn update_folder(
} }
if let Some(parent_id) = parent_request { if let Some(parent_id) = parent_request {
let _parent: Folder = folders::table.find(parent_id).first(conn)?; let _parent: Folder = folders::table
.find(parent_id)
.filter(folders::tenant_id.eq(tenant_id))
.first(conn)?;
let descendant_ids = gather_descendant_folder_ids(conn, folder_id)?; let descendant_ids = gather_descendant_folder_ids(conn, tenant_id, folder_id)?;
if descendant_ids.contains(&parent_id) { if descendant_ids.contains(&parent_id) {
return Err(AppError::bad_request( return Err(AppError::bad_request(
"cannot move folder into itself or a descendant", "cannot move folder into itself or a descendant",
@@ -341,6 +380,7 @@ pub async fn update_folder(
.filter(folders::parent_id.eq(Some(parent_id))) .filter(folders::parent_id.eq(Some(parent_id)))
.filter(folders::name.eq(&new_name)) .filter(folders::name.eq(&new_name))
.filter(folders::id.ne(folder_id)) .filter(folders::id.ne(folder_id))
.filter(folders::tenant_id.eq(tenant_id))
.first::<Folder>(conn) .first::<Folder>(conn)
.optional()? .optional()?
} else { } else {
@@ -348,6 +388,7 @@ pub async fn update_folder(
.filter(folders::parent_id.is_null()) .filter(folders::parent_id.is_null())
.filter(folders::name.eq(&new_name)) .filter(folders::name.eq(&new_name))
.filter(folders::id.ne(folder_id)) .filter(folders::id.ne(folder_id))
.filter(folders::tenant_id.eq(tenant_id))
.first::<Folder>(conn) .first::<Folder>(conn)
.optional()? .optional()?
}; };
@@ -358,7 +399,11 @@ pub async fn update_folder(
)); ));
} }
diesel::update(folders::table.find(folder_id)) diesel::update(
folders::table
.find(folder_id)
.filter(folders::tenant_id.eq(tenant_id)),
)
.set(( .set((
folders::parent_id.eq(next_parent), folders::parent_id.eq(next_parent),
folders::name.eq(&new_name), folders::name.eq(&new_name),
@@ -383,6 +428,7 @@ fn folder_to_info(folder: Folder) -> FolderInfo {
pub(super) fn gather_descendant_folder_ids( pub(super) fn gather_descendant_folder_ids(
conn: &mut PgConnection, conn: &mut PgConnection,
tenant_id: Uuid,
folder_id: Uuid, folder_id: Uuid,
) -> AppResult<Vec<Uuid>> { ) -> AppResult<Vec<Uuid>> {
let mut ids = vec![folder_id]; let mut ids = vec![folder_id];
@@ -391,6 +437,7 @@ pub(super) fn gather_descendant_folder_ids(
while let Some(current) = queue.pop() { while let Some(current) = queue.pop() {
let child_ids: Vec<Uuid> = folders::table let child_ids: Vec<Uuid> = folders::table
.filter(folders::parent_id.eq(Some(current))) .filter(folders::parent_id.eq(Some(current)))
.filter(folders::tenant_id.eq(tenant_id))
.select(folders::id) .select(folders::id)
.load(conn)?; .load(conn)?;
queue.extend(child_ids.iter().copied()); queue.extend(child_ids.iter().copied());
+4 -2
View File
@@ -50,6 +50,7 @@ pub fn create_router(state: AppState) -> Router<()> {
.route("/login", post(auth::login)) .route("/login", post(auth::login))
.route("/refresh", post(auth::refresh)) .route("/refresh", post(auth::refresh))
.route("/logout", post(auth::logout)) .route("/logout", post(auth::logout))
.route("/select-tenant", post(auth::select_tenant))
.route("/me", get(auth::me)); .route("/me", get(auth::me));
let documents_routes = Router::new() let documents_routes = Router::new()
@@ -57,7 +58,6 @@ pub fn create_router(state: AppState) -> Router<()> {
"/", "/",
get(documents::list_documents).post(documents::upload_document), get(documents::list_documents).post(documents::upload_document),
) )
.route("/reanalyze", post(documents::reanalyze_all_documents))
.route("/bulk/move", post(documents::bulk_move_documents)) .route("/bulk/move", post(documents::bulk_move_documents))
.route("/bulk/tags", post(documents::bulk_update_tags)) .route("/bulk/tags", post(documents::bulk_update_tags))
.route( .route(
@@ -75,7 +75,6 @@ pub fn create_router(state: AppState) -> Router<()> {
.patch(documents::update_document), .patch(documents::update_document),
) )
.route("/:id/download", get(documents::download_document)) .route("/:id/download", get(documents::download_document))
.route("/:id/assets/:asset_id", get(documents::get_document_asset))
.route( .route(
"/:id/assets", "/:id/assets",
get(documents::list_document_assets).post(documents::request_document_assets), get(documents::list_document_assets).post(documents::request_document_assets),
@@ -120,11 +119,14 @@ pub fn create_router(state: AppState) -> Router<()> {
); );
let protected_state = state.clone(); let protected_state = state.clone();
let assets_routes = Router::new().route("/:asset_id", get(documents::get_document_asset));
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)
.nest("/api/correspondents", correspondents_routes) .nest("/api/correspondents", correspondents_routes)
.nest("/api/assets", assets_routes)
.layer(middleware::from_extractor_with_state::<AuthenticatedUser, _>(protected_state)); .layer(middleware::from_extractor_with_state::<AuthenticatedUser, _>(protected_state));
Router::new() Router::new()
+75 -32
View File
@@ -1,19 +1,16 @@
use crate::utils::json::{classify_nullable, NullableValue}; use crate::utils::json::{classify_nullable, NullableValue};
use axum::{ use axum::{extract::Path, http::StatusCode, Json};
extract::{Path, State},
http::StatusCode,
Json,
};
use diesel::{dsl::count_star, prelude::*}; use diesel::{dsl::count_star, prelude::*};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use std::collections::HashMap; use std::collections::HashMap;
use uuid::Uuid; use uuid::Uuid;
use crate::auth::TenantScopedConn;
use crate::error::{AppError, AppResult}; use crate::error::{AppError, AppResult};
use crate::models::{NewTag, Tag}; use crate::models::{NewTag, Tag};
use crate::schema::{document_tags, tags}; use crate::schema::{document_tags, tags};
use crate::state::AppState; use crate::utils::db::{no_content, EnsureEntity, IntoJsonResponse};
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct CreateTagRequest { pub struct CreateTagRequest {
@@ -36,19 +33,27 @@ pub struct TagCatalogEntry {
pub usage_count: i64, pub usage_count: i64,
} }
pub async fn list_tags(State(state): State<AppState>) -> AppResult<Json<Vec<TagCatalogEntry>>> { pub async fn list_tags(
let mut conn = state.db()?; TenantScopedConn {
mut conn,
let tag_list: Vec<Tag> = tags::table.order(tags::label.asc()).load(&mut conn)?; tenant_id,
..
}: TenantScopedConn,
) -> AppResult<Json<Vec<TagCatalogEntry>>> {
let tag_list: Vec<Tag> = tags::table
.filter(tags::tenant_id.eq(tenant_id))
.order(tags::label.asc())
.load(&mut conn)?;
let usage_rows: Vec<(Uuid, i64)> = document_tags::table let usage_rows: Vec<(Uuid, i64)> = document_tags::table
.filter(document_tags::tenant_id.eq(tenant_id))
.group_by(document_tags::tag_id) .group_by(document_tags::tag_id)
.select((document_tags::tag_id, count_star())) .select((document_tags::tag_id, count_star()))
.load(&mut conn)?; .load(&mut conn)?;
let usage_map: HashMap<Uuid, i64> = usage_rows.into_iter().collect(); let usage_map: HashMap<Uuid, i64> = usage_rows.into_iter().collect();
let response = tag_list let response: Vec<TagCatalogEntry> = tag_list
.into_iter() .into_iter()
.map(|tag| TagCatalogEntry { .map(|tag| TagCatalogEntry {
id: tag.id, id: tag.id,
@@ -58,22 +63,26 @@ pub async fn list_tags(State(state): State<AppState>) -> AppResult<Json<Vec<TagC
}) })
.collect(); .collect();
Ok(Json(response)) response.into_json()
} }
pub async fn create_tag( pub async fn create_tag(
State(state): State<AppState>, TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
Json(payload): Json<CreateTagRequest>, Json(payload): Json<CreateTagRequest>,
) -> AppResult<Json<TagCatalogEntry>> { ) -> AppResult<Json<TagCatalogEntry>> {
if payload.label.trim().is_empty() { if payload.label.trim().is_empty() {
return Err(AppError::bad_request("label must not be empty")); return Err(AppError::bad_request("label must not be empty"));
} }
let mut conn = state.db()?;
let new_tag = NewTag { let new_tag = NewTag {
id: Uuid::new_v4(), id: Uuid::new_v4(),
label: payload.label.trim().to_string(), label: payload.label.trim().to_string(),
color: payload.color, color: payload.color,
tenant_id,
}; };
match diesel::insert_into(tags::table) match diesel::insert_into(tags::table)
@@ -90,22 +99,35 @@ pub async fn create_tag(
Err(err) => return Err(AppError::from(err)), Err(err) => return Err(AppError::from(err)),
} }
let tag: Tag = tags::table.find(new_tag.id).first(&mut conn)?; let tag: Tag = tags::table
Ok(Json(TagCatalogEntry { .find(new_tag.id)
.filter(tags::tenant_id.eq(tenant_id))
.first(&mut conn)
.one()?;
TagCatalogEntry {
id: tag.id, id: tag.id,
label: tag.label, label: tag.label,
color: tag.color, color: tag.color,
usage_count: 0, usage_count: 0,
})) }
.into_json()
} }
pub async fn update_tag( pub async fn update_tag(
State(state): State<AppState>,
Path(tag_id): Path<Uuid>, Path(tag_id): Path<Uuid>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
Json(body): Json<Value>, Json(body): Json<Value>,
) -> AppResult<Json<TagCatalogEntry>> { ) -> AppResult<Json<TagCatalogEntry>> {
let mut conn = state.db()?; let existing: Tag = tags::table
let existing: Tag = tags::table.find(tag_id).first(&mut conn)?; .find(tag_id)
.filter(tags::tenant_id.eq(tenant_id))
.first(&mut conn)
.one()?;
let label_class = classify_nullable(body.get("label")).map_err(AppError::bad_request)?; 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)?; let color_class = classify_nullable(body.get("color")).map_err(AppError::bad_request)?;
@@ -116,12 +138,13 @@ pub async fn update_tag(
.filter(document_tags::tag_id.eq(tag_id)) .filter(document_tags::tag_id.eq(tag_id))
.select(count_star()) .select(count_star())
.first(&mut conn)?; .first(&mut conn)?;
return Ok(Json(TagCatalogEntry { return TagCatalogEntry {
id: existing.id, id: existing.id,
label: existing.label.clone(), label: existing.label.clone(),
color: existing.color.clone(), color: existing.color.clone(),
usage_count, usage_count,
})); }
.into_json();
} }
let mut new_label: Option<String> = None; let mut new_label: Option<String> = None;
@@ -140,6 +163,7 @@ pub async fn update_tag(
let duplicate = tags::table let duplicate = tags::table
.filter(tags::label.eq(trimmed)) .filter(tags::label.eq(trimmed))
.filter(tags::id.ne(tag_id)) .filter(tags::id.ne(tag_id))
.filter(tags::tenant_id.eq(tenant_id))
.first::<Tag>(&mut conn) .first::<Tag>(&mut conn)
.optional()?; .optional()?;
if duplicate.is_some() { if duplicate.is_some() {
@@ -174,6 +198,7 @@ pub async fn update_tag(
if !label_changed && !color_changed { if !label_changed && !color_changed {
let usage_count: i64 = document_tags::table let usage_count: i64 = document_tags::table
.filter(document_tags::tag_id.eq(tag_id)) .filter(document_tags::tag_id.eq(tag_id))
.filter(document_tags::tenant_id.eq(tenant_id))
.select(count_star()) .select(count_star())
.first(&mut conn)?; .first(&mut conn)?;
return Ok(Json(TagCatalogEntry { return Ok(Json(TagCatalogEntry {
@@ -191,32 +216,45 @@ pub async fn update_tag(
.map(|opt| opt.as_ref().map(|value| value.as_str())), .map(|opt| opt.as_ref().map(|value| value.as_str())),
}; };
diesel::update(tags::table.find(tag_id)) diesel::update(
tags::table
.find(tag_id)
.filter(tags::tenant_id.eq(tenant_id)),
)
.set(&changeset) .set(&changeset)
.execute(&mut conn)?; .execute(&mut conn)?;
let updated: Tag = tags::table.find(tag_id).first(&mut conn)?; let updated: Tag = tags::table
.find(tag_id)
.filter(tags::tenant_id.eq(tenant_id))
.first(&mut conn)
.one()?;
let usage_count: i64 = document_tags::table let usage_count: i64 = document_tags::table
.filter(document_tags::tag_id.eq(tag_id)) .filter(document_tags::tag_id.eq(tag_id))
.filter(document_tags::tenant_id.eq(tenant_id))
.select(count_star()) .select(count_star())
.first(&mut conn)?; .first(&mut conn)?;
Ok(Json(TagCatalogEntry { TagCatalogEntry {
id: updated.id, id: updated.id,
label: updated.label, label: updated.label,
color: updated.color, color: updated.color,
usage_count, usage_count,
})) }
.into_json()
} }
pub async fn delete_tag( pub async fn delete_tag(
State(state): State<AppState>,
Path(tag_id): Path<Uuid>, Path(tag_id): Path<Uuid>,
) -> AppResult<impl axum::response::IntoResponse> { TenantScopedConn {
let mut conn = state.db()?; mut conn,
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<StatusCode> {
let usage: i64 = document_tags::table let usage: i64 = document_tags::table
.filter(document_tags::tag_id.eq(tag_id)) .filter(document_tags::tag_id.eq(tag_id))
.filter(document_tags::tenant_id.eq(tenant_id))
.select(count_star()) .select(count_star())
.first(&mut conn)?; .first(&mut conn)?;
@@ -226,10 +264,15 @@ pub async fn delete_tag(
)); ));
} }
let deleted = diesel::delete(tags::table.find(tag_id)).execute(&mut conn)?; let deleted = diesel::delete(
tags::table
.find(tag_id)
.filter(tags::tenant_id.eq(tenant_id)),
)
.execute(&mut conn)?;
if deleted == 0 { if deleted == 0 {
return Err(AppError::not_found()); return Err(AppError::not_found());
} }
Ok(StatusCode::NO_CONTENT) no_content()
} }
+185 -104
View File
@@ -20,17 +20,26 @@ use crate::error::{AppError, AppResult};
use crate::models::{Document, DocumentVersion, Folder, User}; use crate::models::{Document, DocumentVersion, Folder, User};
use crate::schema::{ use crate::schema::{
document_versions::dsl as document_versions_dsl, documents::dsl as documents_dsl, document_versions::dsl as document_versions_dsl, documents::dsl as documents_dsl,
folders::dsl as folders_dsl, users::dsl as users_dsl, folders::dsl as folders_dsl, tenants::dsl as tenant_dsl,
user_memberships::dsl as memberships_dsl, users::dsl as users_dsl,
}; };
use crate::state::AppState; use crate::state::AppState;
use crate::utils::{http::inline_content_disposition, time::to_http_date};
const REALM: &str = "Papercrate WebDAV"; const REALM: &str = "Papercrate WebDAV";
const DOWNLOAD_URL_TTL_SECONDS: u64 = 300; const DOWNLOAD_URL_TTL_SECONDS: u64 = 300;
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
struct WebDavUser { struct TenantEntry {
tenant_id: Uuid,
slug: String,
}
#[derive(Clone, Debug)]
struct WebDavContext {
_user_id: Uuid, _user_id: Uuid,
_username: String, _username: String,
tenants: Vec<TenantEntry>,
} }
pub fn create_router() -> Router<AppState> { pub fn create_router() -> Router<AppState> {
@@ -68,7 +77,7 @@ async fn handle_propfind(
path: &str, path: &str,
headers: HeaderMap, headers: HeaderMap,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
let _user = match authenticate(state, &headers)? { let context = match authenticate(state, &headers)? {
Some(user) => user, Some(user) => user,
None => return Ok(unauthorized_response()), None => return Ok(unauthorized_response()),
}; };
@@ -79,18 +88,36 @@ async fn handle_propfind(
}; };
let segments = parse_segments(path)?; let segments = parse_segments(path)?;
let resolution = match resolve_path(state, &segments)? {
let resources = if segments.is_empty() {
build_account_root_resources(&context.tenants, depth)
} else {
let (requested_slug, remainder) = segments.split_first().unwrap();
let tenant_entry = match context
.tenants
.iter()
.find(|entry| entry.slug.eq_ignore_ascii_case(requested_slug))
{
Some(entry) => TenantEntry {
tenant_id: entry.tenant_id,
slug: entry.slug.clone(),
},
None => return Ok(not_found_response()),
};
let resolution = match resolve_path(state, &tenant_entry, remainder)? {
Some(resolved) => resolved, Some(resolved) => resolved,
None => return Ok(not_found_response()), None => return Ok(not_found_response()),
}; };
let resources = match resolution { match resolution {
ResolvedPath::Root => { ResolvedPath::TenantRoot { chain } => {
let contents = fetch_folder_contents(state, None)?; let contents = fetch_folder_contents(state, tenant_entry.tenant_id, None)?;
build_resources_for_folder(None, &[], &contents, depth) build_resources_for_folder(None, &chain, &contents, depth)
} }
ResolvedPath::Folder { folder, chain } => { ResolvedPath::Folder { folder, chain } => {
let contents = fetch_folder_contents(state, Some(folder.id))?; let contents =
fetch_folder_contents(state, tenant_entry.tenant_id, Some(folder.id))?;
build_resources_for_folder(Some(&folder), &chain, &contents, depth) build_resources_for_folder(Some(&folder), &chain, &contents, depth)
} }
ResolvedPath::Document { ResolvedPath::Document {
@@ -98,6 +125,7 @@ async fn handle_propfind(
version, version,
chain, chain,
} => build_resources_for_document(&chain, &document, &version), } => build_resources_for_document(&chain, &document, &version),
}
}; };
let body = render_multistatus(&resources) let body = render_multistatus(&resources)
@@ -118,13 +146,34 @@ async fn handle_get_or_head(
headers: HeaderMap, headers: HeaderMap,
method: Method, method: Method,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
let _user = match authenticate(state, &headers)? { let context = match authenticate(state, &headers)? {
Some(user) => user, Some(user) => user,
None => return Ok(unauthorized_response()), None => return Ok(unauthorized_response()),
}; };
let segments = parse_segments(path)?; let segments = parse_segments(path)?;
let resolution = match resolve_path(state, &segments)? { let (requested_slug, remainder) = match segments.split_first() {
Some(values) => values,
None => return Ok(method_not_allowed()),
};
let tenant_entry = match context
.tenants
.iter()
.find(|entry| entry.slug.eq_ignore_ascii_case(requested_slug))
{
Some(entry) => TenantEntry {
tenant_id: entry.tenant_id,
slug: entry.slug.clone(),
},
None => return Ok(not_found_response()),
};
if remainder.is_empty() {
return Ok(method_not_allowed());
}
let resolution = match resolve_path(state, &tenant_entry, remainder)? {
Some(resolved) => resolved, Some(resolved) => resolved,
None => return Ok(not_found_response()), None => return Ok(not_found_response()),
}; };
@@ -219,21 +268,29 @@ fn parse_segments(path: &str) -> AppResult<Vec<String>> {
fn fetch_folder_contents( fn fetch_folder_contents(
state: &AppState, state: &AppState,
tenant_id: Uuid,
folder_id: Option<Uuid>, folder_id: Option<Uuid>,
) -> AppResult<WebDavFolderContents> { ) -> AppResult<WebDavFolderContents> {
let mut conn = state.db()?; let mut conn = state.db_for_tenant(tenant_id)?;
let folder = match folder_id { let folder = match folder_id {
Some(id) => Some(folders_dsl::folders.find(id).first::<Folder>(&mut conn)?), Some(id) => Some(
folders_dsl::folders
.filter(folders_dsl::tenant_id.eq(tenant_id))
.find(id)
.first::<Folder>(&mut conn)?,
),
None => None, None => None,
}; };
let subfolders: Vec<Folder> = match folder_id { let subfolders: Vec<Folder> = match folder_id {
Some(id) => folders_dsl::folders Some(id) => folders_dsl::folders
.filter(folders_dsl::tenant_id.eq(tenant_id))
.filter(folders_dsl::parent_id.eq(Some(id))) .filter(folders_dsl::parent_id.eq(Some(id)))
.order(folders_dsl::name.asc()) .order(folders_dsl::name.asc())
.load(&mut conn)?, .load(&mut conn)?,
None => folders_dsl::folders None => folders_dsl::folders
.filter(folders_dsl::tenant_id.eq(tenant_id))
.filter(folders_dsl::parent_id.is_null()) .filter(folders_dsl::parent_id.is_null())
.order(folders_dsl::name.asc()) .order(folders_dsl::name.asc())
.load(&mut conn)?, .load(&mut conn)?,
@@ -241,6 +298,7 @@ fn fetch_folder_contents(
let mut docs_query = documents_dsl::documents let mut docs_query = documents_dsl::documents
.filter(documents_dsl::deleted_at.is_null()) .filter(documents_dsl::deleted_at.is_null())
.filter(documents_dsl::tenant_id.eq(tenant_id))
.into_boxed(); .into_boxed();
docs_query = match folder_id { docs_query = match folder_id {
@@ -290,8 +348,9 @@ async fn stream_document(
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
let range_header = headers.get(header::RANGE).cloned(); let range_header = headers.get(header::RANGE).cloned();
let url = state let storage = state.storage_for_tenant(document.tenant_id)?;
.storage
let url = storage
.presign_get_object( .presign_get_object(
&version.s3_key, &version.s3_key,
Duration::from_secs(DOWNLOAD_URL_TTL_SECONDS), Duration::from_secs(DOWNLOAD_URL_TTL_SECONDS),
@@ -338,7 +397,7 @@ async fn stream_document(
builder = builder.header("Accept-Ranges", "bytes"); builder = builder.header("Accept-Ranges", "bytes");
if let Some(disposition) = content_disposition(&document.filename) { if let Some(disposition) = inline_content_disposition(&document.filename) {
builder = builder.header(header::CONTENT_DISPOSITION, disposition); builder = builder.header(header::CONTENT_DISPOSITION, disposition);
} }
@@ -360,7 +419,7 @@ async fn stream_document(
.map_err(|err| AppError::internal(format!("failed to build response: {err}"))) .map_err(|err| AppError::internal(format!("failed to build response: {err}")))
} }
fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavUser>, AppError> { fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavContext>, AppError> {
tracing::debug!("webdav authenticate invoked"); tracing::debug!("webdav authenticate invoked");
let authorization = match headers.get(header::AUTHORIZATION) { let authorization = match headers.get(header::AUTHORIZATION) {
Some(value) => match value.to_str() { Some(value) => match value.to_str() {
@@ -405,7 +464,7 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavUs
}; };
tracing::debug!(%username, "attempting webdav login"); tracing::debug!(%username, "attempting webdav login");
let mut conn = state.db()?; let mut conn = state.db_unscoped()?;
let user: User = match users_dsl::users let user: User = match users_dsl::users
.filter(users_dsl::username.eq(username)) .filter(users_dsl::username.eq(username))
@@ -427,10 +486,27 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavUs
return Ok(None); return Ok(None);
} }
tracing::debug!(%username, "webdav login success"); let tenant_rows: Vec<(Uuid, String)> = memberships_dsl::user_memberships
Ok(Some(WebDavUser { .inner_join(tenant_dsl::tenants)
.filter(memberships_dsl::user_id.eq(user.id))
.select((tenant_dsl::id, tenant_dsl::slug))
.load(&mut conn)?;
if tenant_rows.is_empty() {
tracing::warn!(%username, "webdav user has no tenant memberships");
return Ok(None);
}
let tenants: Vec<TenantEntry> = tenant_rows
.into_iter()
.map(|(tenant_id, slug)| TenantEntry { tenant_id, slug })
.collect();
tracing::debug!(%username, tenant_count = tenants.len(), "webdav login success");
Ok(Some(WebDavContext {
_user_id: user.id, _user_id: user.id,
_username: user.username, _username: user.username,
tenants,
})) }))
} }
@@ -444,10 +520,10 @@ fn build_resources_for_folder(
let display_name = folder let display_name = folder
.map(|folder| folder.name.clone()) .map(|folder| folder.name.clone())
.unwrap_or_else(|| "/".to_string()); .unwrap_or_else(|| chain.last().cloned().unwrap_or_else(|| "/".to_string()));
let href = build_href(chain, true); let href = build_href(chain, true);
let last_modified = folder.map(|folder| format_http_date(folder.updated_at)); let last_modified = folder.map(|folder| to_http_date(folder.updated_at));
resources.push(DavResource { resources.push(DavResource {
href, href,
@@ -471,7 +547,7 @@ fn build_resources_for_folder(
is_collection: true, is_collection: true,
content_length: None, content_length: None,
content_type: None, content_type: None,
last_modified: Some(format_http_date(subfolder.updated_at)), last_modified: Some(to_http_date(subfolder.updated_at)),
}); });
} }
@@ -488,6 +564,37 @@ fn build_resources_for_folder(
resources resources
} }
fn build_account_root_resources(tenants: &[TenantEntry], depth: u8) -> Vec<DavResource> {
let mut resources = Vec::new();
resources.push(DavResource {
href: "/".to_string(),
display_name: "/".to_string(),
is_collection: true,
content_length: None,
content_type: None,
last_modified: None,
});
if depth == 0 {
return resources;
}
for tenant in tenants {
let href = build_href(&[tenant.slug.clone()], true);
resources.push(DavResource {
href,
display_name: tenant.slug.clone(),
is_collection: true,
content_length: None,
content_type: None,
last_modified: None,
});
}
resources
}
fn build_resources_for_document( fn build_resources_for_document(
chain: &[String], chain: &[String],
document: &Document, document: &Document,
@@ -509,7 +616,7 @@ fn document_to_resource(
is_collection: false, is_collection: false,
content_length: Some(version.size_bytes), content_length: Some(version.size_bytes),
content_type: document.content_type.clone(), content_type: document.content_type.clone(),
last_modified: Some(format_http_date(document.updated_at)), last_modified: Some(to_http_date(document.updated_at)),
} }
} }
@@ -590,32 +697,6 @@ fn render_multistatus(resources: &[DavResource]) -> Result<Vec<u8>, quick_xml::E
Ok(writer.into_inner()) Ok(writer.into_inner())
} }
fn format_http_date(value: chrono::NaiveDateTime) -> String {
let datetime = chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(value, chrono::Utc);
datetime.format("%a, %d %b %Y %H:%M:%S GMT").to_string()
}
fn content_disposition(filename: &str) -> Option<String> {
if filename.is_empty() {
return None;
}
let sanitized: String = filename
.chars()
.map(|ch| match ch {
'"' | '\\' => '_',
_ => ch,
})
.collect();
let encoded =
percent_encoding::utf8_percent_encode(&sanitized, percent_encoding::NON_ALPHANUMERIC);
Some(format!(
"inline; filename=\"{}\"; filename*=UTF-8''{}",
sanitized, encoded
))
}
struct WebDavFolderContents { struct WebDavFolderContents {
_folder: Option<Folder>, _folder: Option<Folder>,
subfolders: Vec<Folder>, subfolders: Vec<Folder>,
@@ -635,9 +716,10 @@ struct DavResource {
content_type: Option<String>, content_type: Option<String>,
last_modified: Option<String>, last_modified: Option<String>,
} }
enum ResolvedPath { enum ResolvedPath {
Root, TenantRoot {
chain: Vec<String>,
},
Folder { Folder {
folder: Folder, folder: Folder,
chain: Vec<String>, chain: Vec<String>,
@@ -649,37 +731,37 @@ enum ResolvedPath {
}, },
} }
fn resolve_path(state: &AppState, segments: &[String]) -> AppResult<Option<ResolvedPath>> { fn resolve_path(
if segments.is_empty() { state: &AppState,
return Ok(Some(ResolvedPath::Root)); tenant: &TenantEntry,
} segments: &[String],
) -> AppResult<Option<ResolvedPath>> {
let mut conn = state.db()?; let mut conn = state.db_for_tenant(tenant.tenant_id)?;
let mut parent_id: Option<Uuid> = None; let mut parent_id: Option<Uuid> = None;
let mut chain: Vec<String> = Vec::new(); let mut chain: Vec<String> = vec![tenant.slug.clone()];
let mut current_folder: Option<Folder> = None; let mut current_folder: Option<Folder> = None;
if segments.is_empty() {
return Ok(Some(ResolvedPath::TenantRoot { chain }));
}
for (index, segment) in segments.iter().enumerate() { for (index, segment) in segments.iter().enumerate() {
let is_last = index == segments.len() - 1; let is_last = index == segments.len() - 1;
match find_folder_by_name(&mut conn, parent_id, segment)? { if let Some(folder) = find_folder_by_name(&mut conn, tenant.tenant_id, parent_id, segment)?
Some(folder) => { {
if is_last {
chain.push(folder.name.clone()); chain.push(folder.name.clone());
if is_last {
return Ok(Some(ResolvedPath::Folder { folder, chain })); return Ok(Some(ResolvedPath::Folder { folder, chain }));
} }
parent_id = Some(folder.id); parent_id = Some(folder.id);
chain.push(folder.name.clone());
current_folder = Some(folder); current_folder = Some(folder);
continue; continue;
} }
None => {}
}
if is_last { if is_last {
if let Some((document, version)) = if let Some((document, version)) =
find_document_by_filename(&mut conn, parent_id, segment)? find_document_by_filename(&mut conn, tenant.tenant_id, parent_id, segment)?
{ {
chain.push(document.filename.clone()); chain.push(document.filename.clone());
return Ok(Some(ResolvedPath::Document { return Ok(Some(ResolvedPath::Document {
@@ -691,26 +773,22 @@ fn resolve_path(state: &AppState, segments: &[String]) -> AppResult<Option<Resol
} }
if let Ok(uuid) = Uuid::parse_str(segment) { if let Ok(uuid) = Uuid::parse_str(segment) {
if let Some(folder) = folders_dsl::folders if let Some(folder) = find_folder_by_id(&mut conn, tenant.tenant_id, uuid)? {
.find(uuid)
.first::<Folder>(&mut conn)
.optional()?
{
if folder.parent_id != parent_id { if folder.parent_id != parent_id {
return Ok(None); return Ok(None);
} }
if !is_last {
parent_id = Some(folder.id);
chain.push(folder.name.clone());
current_folder = Some(folder);
continue;
} else {
chain.push(folder.name.clone()); chain.push(folder.name.clone());
if is_last {
return Ok(Some(ResolvedPath::Folder { folder, chain })); return Ok(Some(ResolvedPath::Folder { folder, chain }));
} }
parent_id = Some(folder.id);
current_folder = Some(folder);
continue;
} }
if let Some((document, version)) = find_document_by_id(&mut conn, uuid)? { if let Some((document, version)) =
find_document_by_id(&mut conn, tenant.tenant_id, uuid)?
{
if document.folder_id != parent_id { if document.folder_id != parent_id {
return Ok(None); return Ok(None);
} }
@@ -723,19 +801,6 @@ fn resolve_path(state: &AppState, segments: &[String]) -> AppResult<Option<Resol
} }
} }
if is_last {
if let Some((document, version)) =
find_document_by_filename(&mut conn, parent_id, segment)?
{
chain.push(document.filename.clone());
return Ok(Some(ResolvedPath::Document {
document,
version,
chain,
}));
}
}
return Ok(None); return Ok(None);
} }
@@ -744,32 +809,46 @@ fn resolve_path(state: &AppState, segments: &[String]) -> AppResult<Option<Resol
fn find_folder_by_name( fn find_folder_by_name(
conn: &mut PgConnection, conn: &mut PgConnection,
tenant_id: Uuid,
parent_id: Option<Uuid>, parent_id: Option<Uuid>,
name: &str, name: &str,
) -> AppResult<Option<Folder>> { ) -> AppResult<Option<Folder>> {
let result = match parent_id { let mut query = folders_dsl::folders
Some(parent) => folders_dsl::folders .filter(folders_dsl::tenant_id.eq(tenant_id))
.filter(folders_dsl::parent_id.eq(Some(parent))) .into_boxed();
.filter(folders_dsl::name.eq(name))
.first::<Folder>(conn) query = match parent_id {
.optional()?, Some(parent) => query.filter(folders_dsl::parent_id.eq(Some(parent))),
None => folders_dsl::folders None => query.filter(folders_dsl::parent_id.is_null()),
.filter(folders_dsl::parent_id.is_null())
.filter(folders_dsl::name.eq(name))
.first::<Folder>(conn)
.optional()?,
}; };
Ok(result) Ok(query
.filter(folders_dsl::name.eq(name))
.first::<Folder>(conn)
.optional()?)
}
fn find_folder_by_id(
conn: &mut PgConnection,
tenant_id: Uuid,
folder_id: Uuid,
) -> AppResult<Option<Folder>> {
Ok(folders_dsl::folders
.filter(folders_dsl::tenant_id.eq(tenant_id))
.find(folder_id)
.first::<Folder>(conn)
.optional()?)
} }
fn find_document_by_filename( fn find_document_by_filename(
conn: &mut PgConnection, conn: &mut PgConnection,
tenant_id: Uuid,
parent_id: Option<Uuid>, parent_id: Option<Uuid>,
filename: &str, filename: &str,
) -> AppResult<Option<(Document, DocumentVersion)>> { ) -> AppResult<Option<(Document, DocumentVersion)>> {
let mut query = documents_dsl::documents let mut query = documents_dsl::documents
.filter(documents_dsl::deleted_at.is_null()) .filter(documents_dsl::deleted_at.is_null())
.filter(documents_dsl::tenant_id.eq(tenant_id))
.filter(documents_dsl::filename.eq(filename)) .filter(documents_dsl::filename.eq(filename))
.into_boxed(); .into_boxed();
@@ -790,10 +869,12 @@ fn find_document_by_filename(
fn find_document_by_id( fn find_document_by_id(
conn: &mut PgConnection, conn: &mut PgConnection,
tenant_id: Uuid,
document_id: Uuid, document_id: Uuid,
) -> AppResult<Option<(Document, DocumentVersion)>> { ) -> AppResult<Option<(Document, DocumentVersion)>> {
if let Some(document) = documents_dsl::documents if let Some(document) = documents_dsl::documents
.filter(documents_dsl::deleted_at.is_null()) .filter(documents_dsl::deleted_at.is_null())
.filter(documents_dsl::tenant_id.eq(tenant_id))
.find(document_id) .find(document_id)
.first::<Document>(conn) .first::<Document>(conn)
.optional()? .optional()?
+63 -3
View File
@@ -8,6 +8,18 @@ diesel::table! {
metadata -> Jsonb, metadata -> Jsonb,
created_at -> Timestamptz, created_at -> Timestamptz,
updated_at -> Timestamptz, updated_at -> Timestamptz,
tenant_id -> Uuid,
}
}
diesel::table! {
document_asset_objects (id) {
id -> Uuid,
asset_id -> Uuid,
ordinal -> Int4,
s3_key -> Text,
metadata -> Jsonb,
tenant_id -> Uuid,
} }
} }
@@ -16,10 +28,11 @@ diesel::table! {
id -> Uuid, id -> Uuid,
document_version_id -> Uuid, document_version_id -> Uuid,
asset_type -> Text, asset_type -> Text,
s3_key -> Text,
mime_type -> Text, mime_type -> Text,
metadata -> Jsonb, metadata -> Jsonb,
created_at -> Timestamptz, created_at -> Timestamptz,
cardinality -> Nullable<Int4>,
tenant_id -> Uuid,
} }
} }
@@ -31,6 +44,7 @@ diesel::table! {
role -> Varchar, role -> Varchar,
assigned_at -> Timestamptz, assigned_at -> Timestamptz,
assigned_by -> Nullable<Uuid>, assigned_by -> Nullable<Uuid>,
tenant_id -> Uuid,
} }
} }
@@ -40,6 +54,7 @@ diesel::table! {
tag_id -> Uuid, tag_id -> Uuid,
assigned_at -> Timestamptz, assigned_at -> Timestamptz,
assigned_by -> Nullable<Uuid>, assigned_by -> Nullable<Uuid>,
tenant_id -> Uuid,
} }
} }
@@ -56,6 +71,7 @@ diesel::table! {
created_at -> Timestamptz, created_at -> Timestamptz,
operations_summary -> Jsonb, operations_summary -> Jsonb,
metadata -> Jsonb, metadata -> Jsonb,
tenant_id -> Uuid,
} }
} }
@@ -77,6 +93,7 @@ diesel::table! {
#[max_length = 255] #[max_length = 255]
title -> Varchar, title -> Varchar,
current_version_id -> Uuid, current_version_id -> Uuid,
tenant_id -> Uuid,
} }
} }
@@ -88,6 +105,7 @@ diesel::table! {
parent_id -> Nullable<Uuid>, parent_id -> Nullable<Uuid>,
created_at -> Timestamptz, created_at -> Timestamptz,
updated_at -> Timestamptz, updated_at -> Timestamptz,
tenant_id -> Uuid,
} }
} }
@@ -102,6 +120,7 @@ diesel::table! {
last_error -> Nullable<Text>, last_error -> Nullable<Text>,
created_at -> Timestamptz, created_at -> Timestamptz,
updated_at -> Timestamptz, updated_at -> Timestamptz,
tenant_id -> Uuid,
} }
} }
@@ -115,6 +134,7 @@ diesel::table! {
revoked_at -> Nullable<Timestamptz>, revoked_at -> Nullable<Timestamptz>,
created_at -> Timestamptz, created_at -> Timestamptz,
updated_at -> Timestamptz, updated_at -> Timestamptz,
tenant_id -> Uuid,
} }
} }
@@ -126,6 +146,31 @@ diesel::table! {
#[max_length = 7] #[max_length = 7]
color -> Nullable<Varchar>, color -> Nullable<Varchar>,
created_at -> Timestamptz, created_at -> Timestamptz,
tenant_id -> Uuid,
}
}
diesel::table! {
tenants (id) {
id -> Uuid,
slug -> Text,
storage_root -> Nullable<Text>,
quickwit_index -> Nullable<Text>,
status -> Text,
config -> Jsonb,
created_at -> Timestamptz,
updated_at -> Timestamptz,
}
}
diesel::table! {
user_memberships (id) {
id -> Uuid,
user_id -> Uuid,
tenant_id -> Uuid,
role -> Text,
created_at -> Timestamptz,
updated_at -> Timestamptz,
} }
} }
@@ -136,25 +181,38 @@ diesel::table! {
username -> Varchar, username -> Varchar,
#[max_length = 255] #[max_length = 255]
password_hash -> Varchar, password_hash -> Varchar,
#[max_length = 16]
role -> Varchar,
created_at -> Timestamptz, created_at -> Timestamptz,
updated_at -> Timestamptz, updated_at -> Timestamptz,
} }
} }
diesel::joinable!(correspondents -> tenants (tenant_id));
diesel::joinable!(document_asset_objects -> document_assets (asset_id));
diesel::joinable!(document_asset_objects -> tenants (tenant_id));
diesel::joinable!(document_assets -> document_versions (document_version_id)); diesel::joinable!(document_assets -> document_versions (document_version_id));
diesel::joinable!(document_assets -> tenants (tenant_id));
diesel::joinable!(document_correspondents -> correspondents (correspondent_id)); diesel::joinable!(document_correspondents -> correspondents (correspondent_id));
diesel::joinable!(document_correspondents -> documents (document_id)); diesel::joinable!(document_correspondents -> documents (document_id));
diesel::joinable!(document_correspondents -> tenants (tenant_id));
diesel::joinable!(document_correspondents -> users (assigned_by)); 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 -> tenants (tenant_id));
diesel::joinable!(document_tags -> users (assigned_by)); diesel::joinable!(document_tags -> users (assigned_by));
diesel::joinable!(document_versions -> tenants (tenant_id));
diesel::joinable!(documents -> folders (folder_id)); diesel::joinable!(documents -> folders (folder_id));
diesel::joinable!(documents -> tenants (tenant_id));
diesel::joinable!(folders -> tenants (tenant_id));
diesel::joinable!(jobs -> tenants (tenant_id));
diesel::joinable!(refresh_tokens -> tenants (tenant_id));
diesel::joinable!(refresh_tokens -> users (user_id)); diesel::joinable!(refresh_tokens -> users (user_id));
diesel::joinable!(tags -> tenants (tenant_id));
diesel::joinable!(user_memberships -> tenants (tenant_id));
diesel::joinable!(user_memberships -> users (user_id));
diesel::allow_tables_to_appear_in_same_query!( diesel::allow_tables_to_appear_in_same_query!(
correspondents, correspondents,
document_asset_objects,
document_assets, document_assets,
document_correspondents, document_correspondents,
document_tags, document_tags,
@@ -164,5 +222,7 @@ diesel::allow_tables_to_appear_in_same_query!(
jobs, jobs,
refresh_tokens, refresh_tokens,
tags, tags,
tenants,
user_memberships,
users, users,
); );
+41 -5
View File
@@ -4,43 +4,79 @@ use diesel::{
pg::PgConnection, pg::PgConnection,
r2d2::{ConnectionManager, PooledConnection}, r2d2::{ConnectionManager, PooledConnection},
}; };
use uuid::Uuid;
use crate::{ use crate::{
auth::jwt::JwtService, auth::jwt::JwtService,
config::AppConfig, config::AppConfig,
db::PgPool, db::PgPool,
error::{AppError, AppResult}, error::{AppError, AppResult},
storage::ObjectStorage, storage::{ObjectStorage, TenantStorage},
tenants::{apply_tenant_guc, TenantService},
}; };
type PgPooledConnection = PooledConnection<ConnectionManager<PgConnection>>; pub type PgPooledConnection = PooledConnection<ConnectionManager<PgConnection>>;
#[derive(Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
pub pool: PgPool, pub pool: PgPool,
pub config: Arc<AppConfig>, pub config: Arc<AppConfig>,
pub storage: Arc<dyn ObjectStorage>, storage: Arc<dyn ObjectStorage>,
pub jwt: JwtService, pub jwt: JwtService,
pub tenants: TenantService,
} }
impl AppState { impl AppState {
pub async fn initialize(
config: AppConfig,
pool_size_override: Option<u32>,
) -> anyhow::Result<Self> {
let pool_size = pool_size_override.unwrap_or(config.database_max_pool_size);
let pool = crate::db::init_pool_with_size(&config.database_url, pool_size)?;
let s3_client = crate::s3::build_client(&config).await?;
let storage = Arc::new(crate::storage::S3Storage::new(
s3_client,
config.s3_bucket.clone(),
));
let jwt = crate::auth::jwt::JwtService::from_config(&config)?;
Ok(Self::new(pool, config, storage, jwt))
}
pub fn new( pub fn new(
pool: PgPool, pool: PgPool,
config: AppConfig, config: AppConfig,
storage: Arc<dyn ObjectStorage>, storage: Arc<dyn ObjectStorage>,
jwt: JwtService, jwt: JwtService,
) -> Self { ) -> Self {
let config = Arc::new(config);
let tenants = TenantService::new(pool.clone());
Self { Self {
pool, pool,
config: Arc::new(config), config,
storage, storage,
jwt, jwt,
tenants,
} }
} }
pub fn db(&self) -> AppResult<PgPooledConnection> { pub fn db_for_tenant(&self, tenant_id: Uuid) -> AppResult<PgPooledConnection> {
debug_assert!(!tenant_id.is_nil(), "nil tenant_id passed to db_for_tenant");
let mut conn = self.db_unscoped()?;
apply_tenant_guc(&mut conn, tenant_id)?;
Ok(conn)
}
pub(crate) fn db_unscoped(&self) -> AppResult<PgPooledConnection> {
self.pool self.pool
.get() .get()
.map_err(|err| AppError::internal(format!("database pool error: {err}"))) .map_err(|err| AppError::internal(format!("database pool error: {err}")))
} }
pub fn storage_for_tenant(&self, tenant_id: Uuid) -> AppResult<TenantStorage> {
let tenant = self.tenants.get_by_id(tenant_id)?;
TenantStorage::new(self.storage.clone(), &tenant)
.map_err(|err| AppError::internal(format!("tenant storage error: {err}")))
}
} }
+54 -1
View File
@@ -1,11 +1,15 @@
use std::time::Duration; use std::time::Duration;
use anyhow::{Context, Result}; use std::sync::Arc;
use anyhow::{anyhow, Context, Result};
use async_trait::async_trait; use async_trait::async_trait;
use aws_sdk_s3::presigning::PresigningConfig; use aws_sdk_s3::presigning::PresigningConfig;
use aws_sdk_s3::primitives::ByteStream; use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::Client as S3Client; use aws_sdk_s3::Client as S3Client;
use crate::models::Tenant;
#[async_trait] #[async_trait]
pub trait ObjectStorage: Send + Sync + 'static { pub trait ObjectStorage: Send + Sync + 'static {
async fn put_object( async fn put_object(
@@ -119,3 +123,52 @@ impl ObjectStorage for S3Storage {
Ok(()) Ok(())
} }
} }
#[derive(Clone)]
pub struct TenantStorage {
inner: Arc<dyn ObjectStorage>,
root: String,
}
impl TenantStorage {
pub fn new(inner: Arc<dyn ObjectStorage>, tenant: &Tenant) -> Result<Self> {
let root = tenant
.storage_root
.as_ref()
.ok_or_else(|| anyhow!("tenant {} missing storage_root", tenant.id))?
.to_owned();
Ok(Self { inner, root })
}
fn qualify(&self, key: &str) -> String {
format!("{}{}", self.root, key)
}
pub async fn put_object(
&self,
key: &str,
bytes: Vec<u8>,
content_type: Option<String>,
content_disposition: Option<String>,
) -> Result<()> {
let qualified = self.qualify(key);
self.inner
.put_object(&qualified, bytes, content_type, content_disposition)
.await
}
pub async fn presign_get_object(&self, key: &str, expires_in: Duration) -> Result<String> {
let qualified = self.qualify(key);
self.inner.presign_get_object(&qualified, expires_in).await
}
pub async fn get_object(&self, key: &str) -> Result<Vec<u8>> {
let qualified = self.qualify(key);
self.inner.get_object(&qualified).await
}
pub async fn delete_object(&self, key: &str) -> Result<()> {
let qualified = self.qualify(key);
self.inner.delete_object(&qualified).await
}
}
+91
View File
@@ -0,0 +1,91 @@
use axum::{async_trait, extract::FromRequestParts, http::request::Parts};
use diesel::{pg::PgConnection, prelude::*, sql_types::Text};
use uuid::Uuid;
use crate::{
db::PgPool,
error::{AppError, AppResult},
models::Tenant,
schema::tenants::dsl,
state::AppState,
};
pub struct TenantRepository;
impl TenantRepository {
pub fn get_by_id(conn: &mut PgConnection, tenant_id: Uuid) -> AppResult<Tenant> {
dsl::tenants.find(tenant_id).first(conn).map_err(Into::into)
}
pub fn get_by_slug(conn: &mut PgConnection, slug: &str) -> AppResult<Tenant> {
dsl::tenants
.filter(dsl::slug.eq(slug))
.first(conn)
.map_err(Into::into)
}
}
#[derive(Clone)]
pub struct TenantService {
pool: PgPool,
}
impl TenantService {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
pub fn get_by_id(&self, tenant_id: Uuid) -> AppResult<Tenant> {
let tenant = self.load(|conn| TenantRepository::get_by_id(conn, tenant_id))?;
Ok(tenant)
}
pub fn get_by_slug(&self, slug: &str) -> AppResult<Tenant> {
let slug_owned = slug.to_owned();
let tenant = self.load(|conn| TenantRepository::get_by_slug(conn, &slug_owned))?;
Ok(tenant)
}
pub fn tenant_id_for_slug(&self, slug: &str) -> AppResult<Uuid> {
Ok(self.get_by_slug(slug)?.id)
}
fn load<F>(&self, loader: F) -> AppResult<Tenant>
where
F: FnOnce(&mut PgConnection) -> AppResult<Tenant>,
{
let mut conn = self
.pool
.get()
.map_err(|err| AppError::internal(format!("database pool error: {err}")))?;
let tenant = loader(&mut conn)?;
Ok(tenant)
}
}
pub fn apply_tenant_guc(conn: &mut PgConnection, tenant_id: Uuid) -> AppResult<()> {
diesel::sql_query("SELECT set_config('papercrate.tenant_id', $1, true)")
.bind::<Text, _>(tenant_id.to_string())
.execute(conn)
.map(|_| ())
.map_err(AppError::from)
}
pub struct TenantContext {
pub tenant: Tenant,
}
#[async_trait]
impl FromRequestParts<AppState> for TenantContext {
type Rejection = AppError;
async fn from_request_parts(
_parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let tenant = state
.tenants
.get_by_slug(&state.config.default_tenant_slug)?;
Ok(Self { tenant })
}
}
+14
View File
@@ -0,0 +1,14 @@
use std::sync::Arc;
use anyhow::Result;
use crate::{config::AppConfig, state::AppState, utils::tracing::init_tracing};
/// Initialize tracing, load configuration, and build the shared `AppState`.
/// Optionally override the connection pool size for lightweight components.
pub async fn init_component(name: &str, pool_override: Option<u32>) -> Result<Arc<AppState>> {
init_tracing("info");
let config = AppConfig::load_and_log(name)?;
let state = AppState::initialize(config, pool_override).await?;
Ok(Arc::new(state))
}
+59
View File
@@ -0,0 +1,59 @@
use diesel::{pg::PgConnection, result::Error as DieselError};
use uuid::Uuid;
use crate::{
error::{AppError, AppResult},
state::AppState,
};
pub trait EnsureEntity<T> {
fn one(self) -> AppResult<T>;
fn maybe(self) -> AppResult<Option<T>>;
}
impl<T> EnsureEntity<T> for Result<T, DieselError> {
fn one(self) -> AppResult<T> {
self.map_err(AppError::from)
}
fn maybe(self) -> AppResult<Option<T>> {
match self {
Ok(value) => Ok(Some(value)),
Err(DieselError::NotFound) => Ok(None),
Err(err) => Err(AppError::from(err)),
}
}
}
impl AppState {
pub fn with_tenant_conn<F, T>(&self, tenant_id: Uuid, f: F) -> AppResult<T>
where
F: FnOnce(&mut PgConnection) -> AppResult<T>,
{
let mut conn = self.db_for_tenant(tenant_id)?;
f(&mut conn)
}
}
pub fn validate_bulk_ids(ids: &mut Vec<Uuid>, label: &str) -> AppResult<()> {
if ids.is_empty() {
return Err(AppError::bad_request(format!("{label} must not be empty")));
}
ids.sort_unstable();
ids.dedup();
Ok(())
}
pub trait IntoJsonResponse<T> {
fn into_json(self) -> AppResult<axum::Json<T>>;
}
impl<T> IntoJsonResponse<T> for T {
fn into_json(self) -> AppResult<axum::Json<T>> {
Ok(axum::Json(self))
}
}
pub fn no_content() -> AppResult<axum::http::StatusCode> {
Ok(axum::http::StatusCode::NO_CONTENT)
}
+22
View File
@@ -0,0 +1,22 @@
use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
/// Build an inline `Content-Disposition` header value for a given filename.
pub 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 = utf8_percent_encode(&sanitized, NON_ALPHANUMERIC);
Some(format!(
"inline; filename=\"{}\"; filename*=UTF-8''{}",
sanitized, encoded
))
}
+7
View File
@@ -1 +1,8 @@
pub mod bootstrap;
pub mod db;
pub mod http;
pub mod json; pub mod json;
pub mod storage_paths;
pub mod time;
pub mod tracing;
pub mod validation;
+115
View File
@@ -0,0 +1,115 @@
//! Document storage path helpers.
//!
//! NOTE: The path layout produced here is part of the durable storage contract.
//! External systems (presigned URLs, lifecycle jobs, migrations) expect the
//! `documents/{document_id}/...` structure to remain stable. Coordinate before
//! changing any of these helpers to avoid breaking compatibility with existing
//! objects.
use uuid::Uuid;
const DOCUMENTS_PREFIX: &str = "documents";
/// Returns the root prefix for all objects belonging to a document.
pub fn document_prefix(document_id: Uuid) -> String {
format!("{DOCUMENTS_PREFIX}/{document_id}")
}
/// Returns the prefix for a specific document version (without the object id).
pub fn document_version_prefix(document_id: Uuid, version_number: i32) -> String {
format!("{}/v{}", document_prefix(document_id), version_number)
}
/// Returns the storage key for a stored document version blob.
pub fn document_version_object_key(
document_id: Uuid,
version_number: i32,
version_id: Uuid,
) -> String {
format!(
"{}/{}",
document_version_prefix(document_id, version_number),
version_id
)
}
fn document_asset_prefix(document_id: Uuid, version_number: i32) -> String {
format!(
"{}/assets",
document_version_prefix(document_id, version_number)
)
}
fn document_asset_type_prefix(document_id: Uuid, version_number: i32, asset_type: &str) -> String {
format!(
"{}/{}",
document_asset_prefix(document_id, version_number),
asset_type
)
}
/// Returns the storage prefix under which the asset objects for a type/id pair live.
pub fn document_asset_object_prefix(
document_id: Uuid,
version_number: i32,
asset_type: &str,
asset_id: Uuid,
) -> String {
format!(
"{}/{}",
document_asset_type_prefix(document_id, version_number, asset_type),
asset_id
)
}
/// Returns the full storage key for a specific asset object (ordinal).
pub fn document_asset_object_key(
document_id: Uuid,
version_number: i32,
asset_type: &str,
asset_id: Uuid,
ordinal: i32,
) -> String {
format!(
"{}/{}",
document_asset_object_prefix(document_id, version_number, asset_type, asset_id),
ordinal
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generates_expected_paths() {
let document_id = Uuid::nil();
let version_id = Uuid::nil();
let asset_id = Uuid::nil();
assert_eq!(
document_prefix(document_id),
format!("documents/{document_id}")
);
assert_eq!(
document_version_prefix(document_id, 3),
format!("documents/{document_id}/v3")
);
assert_eq!(
document_version_object_key(document_id, 3, version_id),
format!("documents/{document_id}/v3/{version_id}")
);
assert_eq!(
document_asset_object_prefix(document_id, 3, "preview", asset_id),
format!("documents/{document_id}/v3/assets/preview/{asset_id}")
);
assert_eq!(
document_asset_object_key(document_id, 3, "preview", asset_id, 2),
format!("documents/{document_id}/v3/assets/preview/{asset_id}/2")
);
}
}
+13
View File
@@ -0,0 +1,13 @@
use chrono::{DateTime, NaiveDateTime, Utc};
/// Format a timestamp as RFC3339 using UTC.
pub fn to_iso(dt: NaiveDateTime) -> String {
DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc).to_rfc3339()
}
/// Format a timestamp for HTTP headers (RFC 7231 date).
pub fn to_http_date(dt: NaiveDateTime) -> String {
DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc)
.format("%a, %d %b %Y %H:%M:%S GMT")
.to_string()
}
+14
View File
@@ -0,0 +1,14 @@
use tracing_subscriber::EnvFilter;
/// Initialize tracing with an optional default level.
///
/// Falls back to `default_level` when `RUST_LOG` is not provided.
pub fn init_tracing(default_level: &str) {
let filter =
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default_level));
tracing_subscriber::fmt()
.with_env_filter(filter)
.with_target(false)
.compact()
.init();
}
+10
View File
@@ -0,0 +1,10 @@
use crate::error::{AppError, AppResult};
/// Ensure an entity exists, returning a bad request error when it does not.
pub fn ensure_exists(exists: bool, entity: &str) -> AppResult<()> {
if exists {
Ok(())
} else {
Err(AppError::bad_request(format!("{entity} does not exist")))
}
}
+23 -4
View File
@@ -14,6 +14,7 @@ use crate::{
models::{Document, DocumentAsset, DocumentVersion}, models::{Document, DocumentAsset, DocumentVersion},
schema::{document_assets, document_versions, documents}, schema::{document_assets, document_versions, documents},
state::AppState, state::AppState,
storage::TenantStorage,
}; };
use super::{JobExecution, JobHandler}; use super::{JobExecution, JobHandler};
@@ -40,7 +41,12 @@ impl JobHandler for AnalyzeDocumentJob {
JOB_ANALYZE_DOCUMENT JOB_ANALYZE_DOCUMENT
} }
async fn handle(&self, state: Arc<AppState>, job: crate::models::Job) -> JobExecution { async fn handle(
&self,
state: Arc<AppState>,
job: crate::models::Job,
_storage: TenantStorage,
) -> JobExecution {
let payload: AnalyzePayload = match serde_json::from_value(job.payload.clone()) { let payload: AnalyzePayload = match serde_json::from_value(job.payload.clone()) {
Ok(payload) => payload, Ok(payload) => payload,
Err(err) => { Err(err) => {
@@ -51,7 +57,9 @@ impl JobHandler for AnalyzeDocumentJob {
}; };
let state_clone = state.clone(); let state_clone = state.clone();
match task::spawn_blocking(move || analyze_document(state_clone, payload)).await { let tenant_id = job.tenant_id;
match task::spawn_blocking(move || analyze_document(state_clone, tenant_id, payload)).await
{
Ok(Ok(execution)) => execution, Ok(Ok(execution)) => execution,
Ok(Err(err)) => { Ok(Err(err)) => {
warn!(job_id = %job.id, error = %err, "analyze job will retry"); warn!(job_id = %job.id, error = %err, "analyze job will retry");
@@ -71,8 +79,14 @@ impl JobHandler for AnalyzeDocumentJob {
} }
} }
fn analyze_document(state: Arc<AppState>, payload: AnalyzePayload) -> Result<JobExecution, String> { fn analyze_document(
let mut conn = state.db().map_err(|err| format!("{err:?}"))?; state: Arc<AppState>,
tenant_id: Uuid,
payload: AnalyzePayload,
) -> Result<JobExecution, String> {
let mut conn = state
.db_for_tenant(tenant_id)
.map_err(|err| format!("{err:?}"))?;
let version: DocumentVersion = document_versions::table let version: DocumentVersion = document_versions::table
.find(payload.document_version_id) .find(payload.document_version_id)
@@ -88,12 +102,15 @@ fn analyze_document(state: Arc<AppState>, payload: AnalyzePayload) -> Result<Job
.first(&mut conn) .first(&mut conn)
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
let tenant_id = document.tenant_id;
let (supported, reason) = determine_thumbnail_support(&document); let (supported, reason) = determine_thumbnail_support(&document);
let ocr_supported = document_is_pdf(&document); let ocr_supported = document_is_pdf(&document);
let existing_ocr: Option<DocumentAsset> = document_assets::table let existing_ocr: Option<DocumentAsset> = document_assets::table
.filter(document_assets::document_version_id.eq(payload.document_version_id)) .filter(document_assets::document_version_id.eq(payload.document_version_id))
.filter(document_assets::asset_type.eq(OCR_TEXT_ASSET_TYPE)) .filter(document_assets::asset_type.eq(OCR_TEXT_ASSET_TYPE))
.filter(document_assets::tenant_id.eq(tenant_id))
.first(&mut conn) .first(&mut conn)
.optional() .optional()
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
@@ -129,6 +146,7 @@ fn analyze_document(state: Arc<AppState>, payload: AnalyzePayload) -> Result<Job
if supported { if supported {
let enqueue_result = enqueue_job( let enqueue_result = enqueue_job(
&mut conn, &mut conn,
tenant_id,
JOB_GENERATE_THUMBNAILS, JOB_GENERATE_THUMBNAILS,
json!({ json!({
"document_id": payload.document_id, "document_id": payload.document_id,
@@ -146,6 +164,7 @@ fn analyze_document(state: Arc<AppState>, payload: AnalyzePayload) -> Result<Job
if ocr_supported && !skip_ocr { if ocr_supported && !skip_ocr {
let enqueue_result = enqueue_job( let enqueue_result = enqueue_job(
&mut conn, &mut conn,
tenant_id,
JOB_GENERATE_OCR_TEXT, JOB_GENERATE_OCR_TEXT,
json!({ json!({
"document_id": payload.document_id, "document_id": payload.document_id,
+52 -18
View File
@@ -12,9 +12,10 @@ use uuid::Uuid;
use crate::{ use crate::{
jobs::JOB_INDEX_DOCUMENT_TEXT, jobs::JOB_INDEX_DOCUMENT_TEXT,
models::{Document, DocumentAsset, DocumentVersion}, models::{Document, DocumentVersion},
schema::{document_assets, document_versions, documents}, schema::{document_asset_objects, document_assets, document_versions, documents},
state::AppState, state::AppState,
storage::TenantStorage,
}; };
use super::{ocr::OCR_TEXT_ASSET_TYPE, JobExecution, JobHandler}; use super::{ocr::OCR_TEXT_ASSET_TYPE, JobExecution, JobHandler};
@@ -39,7 +40,12 @@ impl JobHandler for IndexDocumentTextJob {
JOB_INDEX_DOCUMENT_TEXT JOB_INDEX_DOCUMENT_TEXT
} }
async fn handle(&self, state: Arc<AppState>, job: crate::models::Job) -> JobExecution { async fn handle(
&self,
state: Arc<AppState>,
job: crate::models::Job,
storage: TenantStorage,
) -> JobExecution {
let payload: IndexPayload = match serde_json::from_value(job.payload.clone()) { let payload: IndexPayload = match serde_json::from_value(job.payload.clone()) {
Ok(payload) => payload, Ok(payload) => payload,
Err(err) => { Err(err) => {
@@ -52,16 +58,29 @@ impl JobHandler for IndexDocumentTextJob {
let quickwit_endpoint = match &state.config.quickwit_endpoint { let quickwit_endpoint = match &state.config.quickwit_endpoint {
Some(endpoint) => endpoint.clone(), Some(endpoint) => endpoint.clone(),
None => { None => {
warn!("quickwit endpoint missing; skipping indexing"); return JobExecution::Failed {
return JobExecution::Success; error: "quickwit endpoint missing".into(),
};
} }
}; };
let quickwit_index = match &state.config.quickwit_index { let tenant = match state.tenants.get_by_id(job.tenant_id) {
Some(index) => index.clone(), Ok(tenant) => tenant,
Err(err) => {
warn!(job_id = %job.id, error = ?err, "failed to load tenant for indexing");
return JobExecution::Retry {
delay: Duration::from_secs(30),
error: format!("failed to load tenant: {err:?}"),
};
}
};
let quickwit_index = match tenant.quickwit_index.clone() {
Some(index) => index,
None => { None => {
warn!("quickwit index missing; skipping indexing"); return JobExecution::Failed {
return JobExecution::Success; error: "tenant quickwit index not configured".into(),
};
} }
}; };
@@ -87,15 +106,15 @@ impl JobHandler for IndexDocumentTextJob {
} }
}; };
if context.text_asset.is_none() { if context.text_s3_key.is_none() {
warn!(job_id = %job.id, "missing OCR text asset; failing indexing job"); warn!(job_id = %job.id, "missing OCR text asset; failing indexing job");
return JobExecution::Failed { return JobExecution::Failed {
error: "missing OCR text asset".into(), error: "missing OCR text asset".into(),
}; };
} }
let asset = context.text_asset.unwrap(); let s3_key = context.text_s3_key.unwrap();
let text = match state.storage.get_object(&asset.s3_key).await { let text = match storage.get_object(&s3_key).await {
Ok(bytes) => match String::from_utf8(bytes) { Ok(bytes) => match String::from_utf8(bytes) {
Ok(text) => text, Ok(text) => text,
Err(err) => { Err(err) => {
@@ -129,6 +148,7 @@ impl JobHandler for IndexDocumentTextJob {
let payload = json!({ let payload = json!({
"document_id": context.document.id, "document_id": context.document.id,
"version_id": context.version.id, "version_id": context.version.id,
"tenant_id": job.tenant_id,
"title": context.document.title.to_lowercase(), "title": context.document.title.to_lowercase(),
"text": text.to_lowercase() "text": text.to_lowercase()
}); });
@@ -169,15 +189,15 @@ impl JobHandler for IndexDocumentTextJob {
struct IndexContext { struct IndexContext {
document: Document, document: Document,
version: DocumentVersion, version: DocumentVersion,
text_asset: Option<DocumentAsset>, text_s3_key: Option<String>,
} }
fn load_context(state: Arc<AppState>, payload: &IndexPayload) -> Result<IndexContext, String> { fn load_context(state: Arc<AppState>, payload: &IndexPayload) -> Result<IndexContext, String> {
let mut conn = state.db().map_err(|err| format!("{err:?}"))?; let mut base_conn = state.db_unscoped().map_err(|err| format!("{err:?}"))?;
let version: DocumentVersion = document_versions::table let version: DocumentVersion = document_versions::table
.find(payload.document_version_id) .find(payload.document_version_id)
.first(&mut conn) .first(&mut base_conn)
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
if version.document_id != payload.document_id { if version.document_id != payload.document_id {
@@ -186,12 +206,26 @@ fn load_context(state: Arc<AppState>, payload: &IndexPayload) -> Result<IndexCon
let document: Document = documents::table let document: Document = documents::table
.find(payload.document_id) .find(payload.document_id)
.first(&mut conn) .first(&mut base_conn)
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
let text_asset: Option<DocumentAsset> = document_assets::table let tenant_id = document.tenant_id;
drop(base_conn);
let mut conn = state
.db_for_tenant(tenant_id)
.map_err(|err| format!("{err:?}"))?;
let text_s3_key: Option<String> = document_asset_objects::table
.inner_join(
document_assets::table.on(document_asset_objects::asset_id.eq(document_assets::id)),
)
.filter(document_assets::document_version_id.eq(payload.document_version_id)) .filter(document_assets::document_version_id.eq(payload.document_version_id))
.filter(document_assets::asset_type.eq(OCR_TEXT_ASSET_TYPE)) .filter(document_assets::asset_type.eq(OCR_TEXT_ASSET_TYPE))
.filter(document_assets::tenant_id.eq(tenant_id))
.filter(document_asset_objects::ordinal.eq(1))
.filter(document_asset_objects::tenant_id.eq(tenant_id))
.select(document_asset_objects::s3_key)
.first(&mut conn) .first(&mut conn)
.optional() .optional()
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
@@ -199,6 +233,6 @@ fn load_context(state: Arc<AppState>, payload: &IndexPayload) -> Result<IndexCon
Ok(IndexContext { Ok(IndexContext {
document, document,
version, version,
text_asset, text_s3_key,
}) })
} }
+21 -8
View File
@@ -8,6 +8,7 @@ use crate::{
jobs::{mark_job_failed, mark_job_succeeded, reserve_job, retry_job_after, JobQueueError}, jobs::{mark_job_failed, mark_job_succeeded, reserve_job, retry_job_after, JobQueueError},
models::Job, models::Job,
state::AppState, state::AppState,
storage::TenantStorage,
}; };
pub mod analyze; pub mod analyze;
@@ -25,7 +26,7 @@ pub enum JobExecution {
#[async_trait] #[async_trait]
pub trait JobHandler: Send + Sync { pub trait JobHandler: Send + Sync {
fn job_type(&self) -> &'static str; fn job_type(&self) -> &'static str;
async fn handle(&self, state: Arc<AppState>, job: Job) -> JobExecution; async fn handle(&self, state: Arc<AppState>, job: Job, storage: TenantStorage) -> JobExecution;
} }
pub struct Worker { pub struct Worker {
@@ -71,7 +72,7 @@ impl Worker {
return Ok(false); return Ok(false);
} }
let mut conn = match self.state.db() { let mut conn = match self.state.db_unscoped() {
Ok(conn) => conn, Ok(conn) => conn,
Err(err) => { Err(err) => {
error!(?err, "failed to obtain database connection in worker"); error!(?err, "failed to obtain database connection in worker");
@@ -84,10 +85,22 @@ impl Worker {
if let Some(job) = job_opt { if let Some(job) = job_opt {
if let Some(handler) = self.handlers.get(job.job_type.as_str()) { if let Some(handler) = self.handlers.get(job.job_type.as_str()) {
let result = handler.handle(self.state.clone(), job.clone()).await; let execution = match self.state.storage_for_tenant(job.tenant_id) {
match result { Ok(storage) => {
handler
.handle(self.state.clone(), job.clone(), storage)
.await
}
Err(err) => {
error!(job_id = %job.id, error = ?err, "failed to load tenant storage for job");
JobExecution::Failed {
error: format!("tenant storage unavailable: {err:?}"),
}
}
};
match execution {
JobExecution::Success => { JobExecution::Success => {
if let Ok(mut conn) = self.state.db() { if let Ok(mut conn) = self.state.db_unscoped() {
mark_job_succeeded(&mut conn, job.id)?; mark_job_succeeded(&mut conn, job.id)?;
info!(job_id = %job.id, job_type = %job.job_type, "job completed successfully"); info!(job_id = %job.id, job_type = %job.job_type, "job completed successfully");
} else { } else {
@@ -96,7 +109,7 @@ impl Worker {
} }
JobExecution::Retry { delay, error } => { JobExecution::Retry { delay, error } => {
warn!(job_id = %job.id, job_type = %job.job_type, %error, "job will retry"); warn!(job_id = %job.id, job_type = %job.job_type, %error, "job will retry");
if let Ok(mut conn) = self.state.db() { if let Ok(mut conn) = self.state.db_unscoped() {
retry_job_after(&mut conn, job.id, delay, &error)?; retry_job_after(&mut conn, job.id, delay, &error)?;
} else { } else {
error!("failed to requeue job for retry due to pool error"); error!("failed to requeue job for retry due to pool error");
@@ -104,7 +117,7 @@ impl Worker {
} }
JobExecution::Failed { error } => { JobExecution::Failed { error } => {
error!(job_id = %job.id, job_type = %job.job_type, %error, "job failed"); error!(job_id = %job.id, job_type = %job.job_type, %error, "job failed");
if let Ok(mut conn) = self.state.db() { if let Ok(mut conn) = self.state.db_unscoped() {
mark_job_failed(&mut conn, job.id, &error)?; mark_job_failed(&mut conn, job.id, &error)?;
} else { } else {
error!("failed to mark job failed due to pool error"); error!("failed to mark job failed due to pool error");
@@ -113,7 +126,7 @@ impl Worker {
} }
} else { } else {
error!(job_type = %job.job_type, "no handler registered for job type"); error!(job_type = %job.job_type, "no handler registered for job type");
if let Ok(mut conn) = self.state.db() { if let Ok(mut conn) = self.state.db_unscoped() {
mark_job_failed(&mut conn, job.id, "no handler registered")?; mark_job_failed(&mut conn, job.id, "no handler registered")?;
} else { } else {
error!("failed to mark job failed for missing handler due to pool error"); error!("failed to mark job failed for missing handler due to pool error");
+117 -28
View File
@@ -19,9 +19,14 @@ use uuid::Uuid;
use crate::{ use crate::{
jobs::{enqueue_job, JOB_GENERATE_OCR_TEXT, JOB_INDEX_DOCUMENT_TEXT}, jobs::{enqueue_job, JOB_GENERATE_OCR_TEXT, JOB_INDEX_DOCUMENT_TEXT},
models::{Document, DocumentAsset, DocumentVersion, NewDocumentAsset}, models::{
schema::{document_assets, document_versions, documents}, Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocumentAsset,
NewDocumentAssetObject,
},
schema::{document_asset_objects, document_assets, document_versions, documents},
state::AppState, state::AppState,
storage::TenantStorage,
utils::storage_paths::document_asset_object_prefix,
}; };
use super::{JobExecution, JobHandler}; use super::{JobExecution, JobHandler};
@@ -51,7 +56,12 @@ impl JobHandler for GenerateOcrTextJob {
JOB_GENERATE_OCR_TEXT JOB_GENERATE_OCR_TEXT
} }
async fn handle(&self, state: Arc<AppState>, job: crate::models::Job) -> JobExecution { async fn handle(
&self,
state: Arc<AppState>,
job: crate::models::Job,
storage: TenantStorage,
) -> JobExecution {
let payload: OcrPayload = match serde_json::from_value(job.payload.clone()) { let payload: OcrPayload = match serde_json::from_value(job.payload.clone()) {
Ok(payload) => payload, Ok(payload) => payload,
Err(err) => { Err(err) => {
@@ -88,7 +98,7 @@ impl JobHandler for GenerateOcrTextJob {
return JobExecution::Success; return JobExecution::Success;
} }
let bytes = match state.storage.get_object(&context.version.s3_key).await { let bytes = match storage.get_object(&context.version.s3_key).await {
Ok(bytes) => bytes, Ok(bytes) => bytes,
Err(err) => { Err(err) => {
warn!(job_id = %job.id, error = %err, "failed to fetch document for ocr"); warn!(job_id = %job.id, error = %err, "failed to fetch document for ocr");
@@ -123,19 +133,24 @@ impl JobHandler for GenerateOcrTextJob {
}; };
}; };
let asset_id = context if context.existing_asset.is_some() {
.existing_asset for object in &context.existing_objects {
.as_ref() if let Err(err) = storage.delete_object(&object.s3_key).await {
.map(|asset| asset.id) warn!(job_id = %job.id, error = %err, s3_key = %object.s3_key, "failed to delete existing ocr asset object");
.unwrap_or_else(Uuid::new_v4); }
}
}
let s3_key = format!( let asset_id = Uuid::new_v4();
"documents/{}/v{}/assets/{}/{}",
context.document.id, context.version.version_number, OCR_TEXT_ASSET_TYPE, asset_id let s3_key = document_asset_object_prefix(
context.document.id,
context.version.version_number,
OCR_TEXT_ASSET_TYPE,
asset_id,
); );
if let Err(err) = state if let Err(err) = storage
.storage
.put_object( .put_object(
&s3_key, &s3_key,
generation.text.into_bytes(), generation.text.into_bytes(),
@@ -158,12 +173,9 @@ impl JobHandler for GenerateOcrTextJob {
.await .await
{ {
Ok(Ok(())) => { Ok(Ok(())) => {
if state.config.quickwit_endpoint.is_some() && state.config.quickwit_index.is_some()
{
if let Err(err) = enqueue_index_job(&state, &payload) { if let Err(err) = enqueue_index_job(&state, &payload) {
warn!(job_id = %job.id, error = %err, "failed to enqueue index job"); warn!(job_id = %job.id, error = %err, "failed to enqueue index job");
} }
}
JobExecution::Success JobExecution::Success
} }
Ok(Err(err)) => { Ok(Err(err)) => {
@@ -193,6 +205,7 @@ struct OcrContext {
document: Document, document: Document,
version: DocumentVersion, version: DocumentVersion,
existing_asset: Option<DocumentAsset>, existing_asset: Option<DocumentAsset>,
existing_objects: Vec<DocumentAssetObject>,
skip: bool, skip: bool,
} }
@@ -202,11 +215,11 @@ struct OcrGeneration {
} }
fn load_ocr_context(state: Arc<AppState>, payload: &OcrPayload) -> Result<OcrContext, String> { fn load_ocr_context(state: Arc<AppState>, payload: &OcrPayload) -> Result<OcrContext, String> {
let mut conn = state.db().map_err(|err| format!("{err:?}"))?; let mut base_conn = state.db_unscoped().map_err(|err| format!("{err:?}"))?;
let version: DocumentVersion = document_versions::table let version: DocumentVersion = document_versions::table
.find(payload.document_version_id) .find(payload.document_version_id)
.first(&mut conn) .first(&mut base_conn)
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
if version.document_id != payload.document_id { if version.document_id != payload.document_id {
@@ -215,32 +228,53 @@ fn load_ocr_context(state: Arc<AppState>, payload: &OcrPayload) -> Result<OcrCon
let document: Document = documents::table let document: Document = documents::table
.find(payload.document_id) .find(payload.document_id)
.first(&mut conn) .first(&mut base_conn)
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
let existing: Option<DocumentAsset> = document_assets::table let tenant_id = document.tenant_id;
drop(base_conn);
let mut conn = state
.db_for_tenant(tenant_id)
.map_err(|err| format!("{err:?}"))?;
let existing_asset: Option<DocumentAsset> = document_assets::table
.filter(document_assets::document_version_id.eq(payload.document_version_id)) .filter(document_assets::document_version_id.eq(payload.document_version_id))
.filter(document_assets::asset_type.eq(OCR_TEXT_ASSET_TYPE)) .filter(document_assets::asset_type.eq(OCR_TEXT_ASSET_TYPE))
.filter(document_assets::tenant_id.eq(tenant_id))
.first(&mut conn) .first(&mut conn)
.optional() .optional()
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
let existing_objects: Vec<DocumentAssetObject> = if let Some(asset) = &existing_asset {
document_asset_objects::table
.filter(document_asset_objects::asset_id.eq(asset.id))
.filter(document_asset_objects::tenant_id.eq(tenant_id))
.order(document_asset_objects::ordinal.asc())
.load(&mut conn)
.map_err(|err| format!("{err:?}"))?
} else {
Vec::new()
};
let is_pdf = document_is_pdf(&document); let is_pdf = document_is_pdf(&document);
if !is_pdf { if !is_pdf {
return Ok(OcrContext { return Ok(OcrContext {
document, document,
version, version,
existing_asset: existing, existing_asset: existing_asset,
existing_objects,
skip: true, skip: true,
}); });
} }
let skip = existing.is_some() && !payload.force; let skip = existing_asset.is_some() && !payload.force;
Ok(OcrContext { Ok(OcrContext {
document, document,
version, version,
existing_asset: existing, existing_asset,
existing_objects,
skip, skip,
}) })
} }
@@ -369,18 +403,28 @@ fn persist_ocr_metadata(
s3_key: &str, s3_key: &str,
source: &'static str, source: &'static str,
) -> Result<(), String> { ) -> Result<(), String> {
let mut conn = state.db().map_err(|err| format!("{err:?}"))?; let tenant_id = context.document.tenant_id;
let mut conn = state
.db_for_tenant(tenant_id)
.map_err(|err| format!("{err:?}"))?;
if let Some(existing_asset) = &context.existing_asset {
diesel::delete(document_assets::table.filter(document_assets::id.eq(existing_asset.id)))
.execute(&mut conn)
.map_err(|err| format!("{err:?}"))?;
}
let new_asset = NewDocumentAsset { let new_asset = NewDocumentAsset {
id: asset_id, id: asset_id,
document_version_id: context.version.id, document_version_id: context.version.id,
asset_type: OCR_TEXT_ASSET_TYPE.to_string(), asset_type: OCR_TEXT_ASSET_TYPE.to_string(),
s3_key: s3_key.to_string(),
mime_type: "text/plain".to_string(), mime_type: "text/plain".to_string(),
metadata: json!({ metadata: json!({
"generated_at": Utc::now().to_rfc3339(), "generated_at": Utc::now().to_rfc3339(),
"source": source, "source": source,
}), }),
cardinality: Some(1),
tenant_id,
}; };
diesel::insert_into(document_assets::table) diesel::insert_into(document_assets::table)
@@ -391,9 +435,43 @@ fn persist_ocr_metadata(
)) ))
.do_update() .do_update()
.set(( .set((
document_assets::s3_key.eq(excluded(document_assets::s3_key)),
document_assets::mime_type.eq(excluded(document_assets::mime_type)), document_assets::mime_type.eq(excluded(document_assets::mime_type)),
document_assets::metadata.eq(excluded(document_assets::metadata)), document_assets::metadata.eq(excluded(document_assets::metadata)),
document_assets::cardinality.eq(excluded(document_assets::cardinality)),
))
.execute(&mut conn)
.map_err(|err| format!("{err:?}"))?;
let existing_object_id: Option<Uuid> = document_asset_objects::table
.filter(document_asset_objects::asset_id.eq(asset_id))
.filter(document_asset_objects::ordinal.eq(1))
.filter(document_asset_objects::tenant_id.eq(tenant_id))
.select(document_asset_objects::id)
.first(&mut conn)
.optional()
.map_err(|err| format!("{err:?}"))?;
let object_id = existing_object_id.unwrap_or_else(Uuid::new_v4);
let new_object = NewDocumentAssetObject {
id: object_id,
asset_id,
ordinal: 1,
s3_key: s3_key.to_string(),
metadata: json!({}),
tenant_id,
};
diesel::insert_into(document_asset_objects::table)
.values(&new_object)
.on_conflict((
document_asset_objects::asset_id,
document_asset_objects::ordinal,
))
.do_update()
.set((
document_asset_objects::s3_key.eq(excluded(document_asset_objects::s3_key)),
document_asset_objects::metadata.eq(excluded(document_asset_objects::metadata)),
)) ))
.execute(&mut conn) .execute(&mut conn)
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
@@ -402,9 +480,20 @@ fn persist_ocr_metadata(
} }
fn enqueue_index_job(state: &AppState, payload: &OcrPayload) -> Result<(), String> { fn enqueue_index_job(state: &AppState, payload: &OcrPayload) -> Result<(), String> {
let mut conn = state.db().map_err(|err| format!("{err:?}"))?; let mut base_conn = state.db_unscoped().map_err(|err| format!("{err:?}"))?;
let tenant_id: Uuid = documents::table
.find(payload.document_id)
.select(documents::tenant_id)
.first(&mut base_conn)
.map_err(|err| format!("{err:?}"))?;
drop(base_conn);
let mut conn = state
.db_for_tenant(tenant_id)
.map_err(|err| format!("{err:?}"))?;
enqueue_job( enqueue_job(
&mut conn, &mut conn,
tenant_id,
JOB_INDEX_DOCUMENT_TEXT, JOB_INDEX_DOCUMENT_TEXT,
json!({ json!({
"document_id": payload.document_id, "document_id": payload.document_id,
+311 -90
View File
@@ -13,9 +13,14 @@ use uuid::Uuid;
use crate::{ use crate::{
jobs::JOB_GENERATE_THUMBNAILS, jobs::JOB_GENERATE_THUMBNAILS,
models::{Document, DocumentAsset, DocumentVersion, NewDocumentAsset}, models::{
schema::{document_assets, document_versions, documents}, Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocumentAsset,
NewDocumentAssetObject,
},
schema::{document_asset_objects, document_assets, document_versions, documents},
state::AppState, state::AppState,
storage::TenantStorage,
utils::storage_paths::document_asset_object_key,
}; };
use super::{analyze::determine_thumbnail_support, JobExecution, JobHandler}; use super::{analyze::determine_thumbnail_support, JobExecution, JobHandler};
@@ -49,7 +54,12 @@ impl JobHandler for GenerateThumbnailsJob {
JOB_GENERATE_THUMBNAILS JOB_GENERATE_THUMBNAILS
} }
async fn handle(&self, state: Arc<AppState>, job: crate::models::Job) -> JobExecution { async fn handle(
&self,
state: Arc<AppState>,
job: crate::models::Job,
storage: TenantStorage,
) -> JobExecution {
let payload: ThumbnailPayload = match serde_json::from_value(job.payload.clone()) { let payload: ThumbnailPayload = match serde_json::from_value(job.payload.clone()) {
Ok(p) => p, Ok(p) => p,
Err(err) => { Err(err) => {
@@ -85,7 +95,7 @@ impl JobHandler for GenerateThumbnailsJob {
return JobExecution::Success; return JobExecution::Success;
} }
let bytes = match state.storage.get_object(&initial.version.s3_key).await { let bytes = match storage.get_object(&initial.version.s3_key).await {
Ok(bytes) => bytes, Ok(bytes) => bytes,
Err(err) => { Err(err) => {
warn!(job_id = %job.id, error = %err, "thumbnail fetch failed; will retry"); warn!(job_id = %job.id, error = %err, "thumbnail fetch failed; will retry");
@@ -142,86 +152,134 @@ impl JobHandler for GenerateThumbnailsJob {
} }
} }
let thumbnail_asset_id = initial if initial.existing_preview.is_some() {
.existing_thumbnail for object in &initial.existing_preview_objects {
.as_ref() if let Err(err) = storage.delete_object(&object.s3_key).await {
.map(|asset| asset.id) warn!(
.unwrap_or_else(Uuid::new_v4); job_id = %job.id,
let thumbnail_s3_key = format!( error = %err,
"documents/{}/v{}/assets/{}/{}", s3_key = %object.s3_key,
initial.document.id, "failed to delete existing preview object"
initial.version.version_number,
THUMBNAIL_ASSET_TYPE,
thumbnail_asset_id
); );
}
}
}
let preview_asset_id = initial if initial.existing_thumbnail.is_some() {
.existing_preview for object in &initial.existing_thumbnail_objects {
.as_ref() if let Err(err) = storage.delete_object(&object.s3_key).await {
.map(|asset| asset.id) warn!(
.unwrap_or_else(Uuid::new_v4); job_id = %job.id,
let preview_s3_key = format!( error = %err,
"documents/{}/v{}/assets/{}/{}", s3_key = %object.s3_key,
"failed to delete existing thumbnail object"
);
}
}
}
let preview_asset_id = Uuid::new_v4();
let thumbnail_asset_id = Uuid::new_v4();
let mut preview_objects: Vec<AssetObjectPersistence> =
Vec::with_capacity(generation.preview.objects.len());
for (index, image) in generation.preview.objects.iter().enumerate() {
if index + 1 > i32::MAX as usize {
return JobExecution::Failed {
error: "too many preview objects".to_string(),
};
}
let ordinal = (index + 1) as i32;
let s3_key = document_asset_object_key(
initial.document.id, initial.document.id,
initial.version.version_number, initial.version.version_number,
PREVIEW_ASSET_TYPE, PREVIEW_ASSET_TYPE,
preview_asset_id preview_asset_id,
ordinal,
); );
if let Err(err) = state if let Err(err) = storage
.storage
.put_object( .put_object(
&preview_s3_key, &s3_key,
generation.preview.image_bytes.clone(), image.image_bytes.clone(),
Some("image/png".into()), Some("image/png".into()),
None, None,
) )
.await .await
{ {
warn!(job_id = %job.id, error = %err, "failed to upload preview; retrying"); warn!(job_id = %job.id, error = %err, ordinal, "failed to upload preview; retrying");
return JobExecution::Retry { return JobExecution::Retry {
delay: Duration::from_secs(30), delay: Duration::from_secs(30),
error: err.to_string(), error: err.to_string(),
}; };
} }
if let Err(err) = state preview_objects.push(AssetObjectPersistence {
.storage ordinal,
s3_key,
width: image.width,
height: image.height,
});
}
let mut thumbnail_objects: Vec<AssetObjectPersistence> =
Vec::with_capacity(generation.thumbnail.objects.len());
for (index, image) in generation.thumbnail.objects.iter().enumerate() {
if index + 1 > i32::MAX as usize {
return JobExecution::Failed {
error: "too many thumbnail objects".to_string(),
};
}
let ordinal = (index + 1) as i32;
let s3_key = document_asset_object_key(
initial.document.id,
initial.version.version_number,
THUMBNAIL_ASSET_TYPE,
thumbnail_asset_id,
ordinal,
);
if let Err(err) = storage
.put_object( .put_object(
&thumbnail_s3_key, &s3_key,
generation.thumbnail.image_bytes.clone(), image.image_bytes.clone(),
Some("image/png".into()), Some("image/png".into()),
None, None,
) )
.await .await
{ {
warn!(job_id = %job.id, error = %err, "failed to upload thumbnail; retrying"); warn!(job_id = %job.id, error = %err, ordinal, "failed to upload thumbnail; retrying");
return JobExecution::Retry { return JobExecution::Retry {
delay: Duration::from_secs(30), delay: Duration::from_secs(30),
error: err.to_string(), error: err.to_string(),
}; };
} }
let state_clone = state.clone(); thumbnail_objects.push(AssetObjectPersistence {
match task::spawn_blocking(move || { ordinal,
persist_assets_metadata( s3_key,
state_clone, width: image.width,
&initial, height: image.height,
&[ });
}
let asset_persistences = vec![
AssetPersistence { AssetPersistence {
asset_type: PREVIEW_ASSET_TYPE, asset_type: PREVIEW_ASSET_TYPE,
asset_id: preview_asset_id, asset_id: preview_asset_id,
s3_key: &preview_s3_key, objects: preview_objects,
generated: &generation.preview,
}, },
AssetPersistence { AssetPersistence {
asset_type: THUMBNAIL_ASSET_TYPE, asset_type: THUMBNAIL_ASSET_TYPE,
asset_id: thumbnail_asset_id, asset_id: thumbnail_asset_id,
s3_key: &thumbnail_s3_key, objects: thumbnail_objects,
generated: &generation.thumbnail,
}, },
], ];
)
let state_clone = state.clone();
match task::spawn_blocking(move || {
persist_assets_metadata(state_clone, &initial, &asset_persistences)
}) })
.await .await
{ {
@@ -250,7 +308,9 @@ struct ThumbnailContext {
document: Document, document: Document,
version: DocumentVersion, version: DocumentVersion,
existing_thumbnail: Option<DocumentAsset>, existing_thumbnail: Option<DocumentAsset>,
existing_thumbnail_objects: Vec<DocumentAssetObject>,
existing_preview: Option<DocumentAsset>, existing_preview: Option<DocumentAsset>,
existing_preview_objects: Vec<DocumentAssetObject>,
skip: bool, skip: bool,
} }
@@ -260,24 +320,34 @@ struct GeneratedImage {
height: Option<i32>, height: Option<i32>,
} }
struct GeneratedAsset {
objects: Vec<GeneratedImage>,
}
struct GeneratedAssets { struct GeneratedAssets {
thumbnail: GeneratedImage, thumbnail: GeneratedAsset,
preview: GeneratedImage, preview: GeneratedAsset,
page_count: Option<u32>, page_count: Option<u32>,
} }
struct AssetPersistence<'a> { struct AssetObjectPersistence {
ordinal: i32,
s3_key: String,
width: Option<i32>,
height: Option<i32>,
}
struct AssetPersistence {
asset_type: &'static str, asset_type: &'static str,
asset_id: Uuid, asset_id: Uuid,
s3_key: &'a str, objects: Vec<AssetObjectPersistence>,
generated: &'a GeneratedImage,
} }
fn load_thumbnail_context( fn load_thumbnail_context(
state: Arc<AppState>, state: Arc<AppState>,
payload: &ThumbnailPayload, payload: &ThumbnailPayload,
) -> Result<ThumbnailContext, String> { ) -> Result<ThumbnailContext, String> {
let mut conn = state.db().map_err(|err| format!("{err:?}"))?; let mut conn = state.db_unscoped().map_err(|err| format!("{err:?}"))?;
let version: DocumentVersion = document_versions::table let version: DocumentVersion = document_versions::table
.find(payload.document_version_id) .find(payload.document_version_id)
@@ -293,21 +363,42 @@ fn load_thumbnail_context(
.first(&mut conn) .first(&mut conn)
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
let tenant_id = document.tenant_id;
let existing_assets: Vec<DocumentAsset> = document_assets::table let existing_assets: Vec<DocumentAsset> = document_assets::table
.filter(document_assets::document_version_id.eq(payload.document_version_id)) .filter(document_assets::document_version_id.eq(payload.document_version_id))
.filter(document_assets::asset_type.eq_any(vec![ .filter(document_assets::asset_type.eq_any(vec![
THUMBNAIL_ASSET_TYPE.to_string(), THUMBNAIL_ASSET_TYPE.to_string(),
PREVIEW_ASSET_TYPE.to_string(), PREVIEW_ASSET_TYPE.to_string(),
])) ]))
.filter(document_assets::tenant_id.eq(tenant_id))
.load(&mut conn) .load(&mut conn)
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
let mut existing_thumbnail = None; let mut existing_thumbnail = None;
let mut existing_thumbnail_objects: Vec<DocumentAssetObject> = Vec::new();
let mut existing_preview = None; let mut existing_preview = None;
let mut existing_preview_objects: Vec<DocumentAssetObject> = Vec::new();
for asset in existing_assets { for asset in existing_assets {
match asset.asset_type.as_str() { match asset.asset_type.as_str() {
THUMBNAIL_ASSET_TYPE => existing_thumbnail = Some(asset), THUMBNAIL_ASSET_TYPE => {
PREVIEW_ASSET_TYPE => existing_preview = Some(asset), existing_thumbnail_objects = document_asset_objects::table
.filter(document_asset_objects::asset_id.eq(asset.id))
.filter(document_asset_objects::tenant_id.eq(tenant_id))
.order(document_asset_objects::ordinal.asc())
.load(&mut conn)
.map_err(|err| format!("{err:?}"))?;
existing_thumbnail = Some(asset);
}
PREVIEW_ASSET_TYPE => {
existing_preview_objects = document_asset_objects::table
.filter(document_asset_objects::asset_id.eq(asset.id))
.filter(document_asset_objects::tenant_id.eq(tenant_id))
.order(document_asset_objects::ordinal.asc())
.load(&mut conn)
.map_err(|err| format!("{err:?}"))?;
existing_preview = Some(asset);
}
_ => {} _ => {}
} }
} }
@@ -317,13 +408,33 @@ fn load_thumbnail_context(
return Err("thumbnail generation not supported for this document".into()); return Err("thumbnail generation not supported for this document".into());
} }
let skip = existing_thumbnail.is_some() && existing_preview.is_some() && !payload.force; let expected_cardinality = expected_asset_cardinality(&document, &version);
let preview_cardinality = existing_preview
.as_ref()
.and_then(|asset| asset.cardinality)
.unwrap_or_else(|| existing_preview_objects.len() as i32);
let thumbnail_cardinality = existing_thumbnail
.as_ref()
.and_then(|asset| asset.cardinality)
.unwrap_or_else(|| existing_thumbnail_objects.len() as i32);
let needs_regeneration = preview_cardinality < expected_cardinality
|| thumbnail_cardinality < expected_cardinality
|| (existing_preview_objects.len() as i32) < expected_cardinality
|| (existing_thumbnail_objects.len() as i32) < expected_cardinality;
let skip = existing_thumbnail.is_some()
&& existing_preview.is_some()
&& !payload.force
&& !needs_regeneration;
Ok(ThumbnailContext { Ok(ThumbnailContext {
document, document,
version, version,
existing_thumbnail, existing_thumbnail,
existing_thumbnail_objects,
existing_preview, existing_preview,
existing_preview_objects,
skip, skip,
}) })
} }
@@ -332,18 +443,7 @@ fn generate_preview_and_thumbnail(
document: &Document, document: &Document,
bytes: &[u8], bytes: &[u8],
) -> Result<GeneratedAssets, String> { ) -> Result<GeneratedAssets, String> {
let is_pdf = document let is_pdf = document_is_pdf(document);
.content_type
.as_deref()
.map(|mime| mime == "application/pdf")
.unwrap_or_else(|| {
document
.original_name
.rsplit('.')
.next()
.map(|ext| ext.eq_ignore_ascii_case("pdf"))
.unwrap_or(false)
});
if is_pdf { if is_pdf {
let pdf_assets = generate_pdf_assets(bytes)?; let pdf_assets = generate_pdf_assets(bytes)?;
@@ -362,7 +462,7 @@ fn generate_preview_and_thumbnail(
} }
} }
fn generate_image_assets(bytes: &[u8]) -> Result<(GeneratedImage, GeneratedImage), String> { fn generate_image_assets(bytes: &[u8]) -> Result<(GeneratedAsset, GeneratedAsset), String> {
let reader = ImageReader::new(Cursor::new(bytes)) let reader = ImageReader::new(Cursor::new(bytes))
.with_guessed_format() .with_guessed_format()
.map_err(|err| err.to_string())?; .map_err(|err| err.to_string())?;
@@ -384,12 +484,19 @@ fn generate_image_assets(bytes: &[u8]) -> Result<(GeneratedImage, GeneratedImage
let preview = encode_dynamic_image(preview_image)?; let preview = encode_dynamic_image(preview_image)?;
let thumbnail = encode_dynamic_image(thumbnail_image)?; let thumbnail = encode_dynamic_image(thumbnail_image)?;
Ok((preview, thumbnail)) Ok((
GeneratedAsset {
objects: vec![preview],
},
GeneratedAsset {
objects: vec![thumbnail],
},
))
} }
struct PdfGeneratedAssets { struct PdfGeneratedAssets {
preview: GeneratedImage, preview: GeneratedAsset,
thumbnail: GeneratedImage, thumbnail: GeneratedAsset,
page_count: u32, page_count: u32,
} }
@@ -402,11 +509,7 @@ fn generate_pdf_assets(bytes: &[u8]) -> Result<PdfGeneratedAssets, String> {
.map_err(|err| format!("load pdf: {err}"))?; .map_err(|err| format!("load pdf: {err}"))?;
let pages = document.pages(); let pages = document.pages();
let total_pages = pages.len(); let total_pages = pages.len() as usize;
let page = pages
.get(0)
.map_err(|err| format!("load first page: {err}"))?;
let render_config = PdfRenderConfig::new() let render_config = PdfRenderConfig::new()
.set_target_width(PREVIEW_WIDTH as i32) .set_target_width(PREVIEW_WIDTH as i32)
@@ -414,30 +517,44 @@ fn generate_pdf_assets(bytes: &[u8]) -> Result<PdfGeneratedAssets, String> {
.render_form_data(true) .render_form_data(true)
.rotate_if_landscape(PdfPageRenderRotation::None, true); .rotate_if_landscape(PdfPageRenderRotation::None, true);
let mut preview_objects: Vec<GeneratedImage> = Vec::with_capacity(total_pages);
let mut thumbnail_objects: Vec<GeneratedImage> = Vec::with_capacity(total_pages);
for page_index in 0..total_pages {
let page = pages
.get(u16::try_from(page_index).map_err(|_| "page index overflow".to_string())?)
.map_err(|err| format!("load page {page_index}: {err}"))?;
let bitmap = page let bitmap = page
.render_with_config(&render_config) .render_with_config(&render_config)
.map_err(|err| format!("render pdf page: {err}"))?; .map_err(|err| format!("render pdf page {page_index}: {err}"))?;
let preview_buffer = bitmap.as_image().to_rgb8(); let preview_buffer = bitmap.as_image().to_rgb8();
let preview_image = image::DynamicImage::ImageRgb8(preview_buffer); let preview_image = image::DynamicImage::ImageRgb8(preview_buffer);
let thumbnail_image = let thumbnail_image = if preview_image.width() > THUMBNAIL_WIDTH
if preview_image.width() > THUMBNAIL_WIDTH || preview_image.height() > THUMBNAIL_HEIGHT { || preview_image.height() > THUMBNAIL_HEIGHT
{
preview_image.thumbnail(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT) preview_image.thumbnail(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT)
} else { } else {
preview_image.clone() preview_image.clone()
}; };
let preview = encode_dynamic_image(preview_image)?; preview_objects.push(encode_dynamic_image(preview_image)?);
let thumbnail = encode_dynamic_image(thumbnail_image)?; thumbnail_objects.push(encode_dynamic_image(thumbnail_image)?);
}
let page_count: u32 = total_pages let page_count: u32 = total_pages
.try_into() .try_into()
.map_err(|_| "page count exceeds supported range".to_string())?; .map_err(|_| "page count exceeds supported range".to_string())?;
Ok(PdfGeneratedAssets { Ok(PdfGeneratedAssets {
preview, preview: GeneratedAsset {
thumbnail, objects: preview_objects,
},
thumbnail: GeneratedAsset {
objects: thumbnail_objects,
},
page_count, page_count,
}) })
} }
@@ -458,22 +575,51 @@ fn encode_dynamic_image(image: image::DynamicImage) -> Result<GeneratedImage, St
fn persist_assets_metadata( fn persist_assets_metadata(
state: Arc<AppState>, state: Arc<AppState>,
context: &ThumbnailContext, context: &ThumbnailContext,
assets: &[AssetPersistence<'_>], assets: &[AssetPersistence],
) -> Result<(), String> { ) -> Result<(), String> {
let mut conn = state.db().map_err(|err| format!("{err:?}"))?; let tenant_id = context.document.tenant_id;
let mut conn = state
.db_for_tenant(tenant_id)
.map_err(|err| format!("{err:?}"))?;
if let Some(existing_preview) = &context.existing_preview {
diesel::delete(document_assets::table.filter(document_assets::id.eq(existing_preview.id)))
.execute(&mut conn)
.map_err(|err| format!("{err:?}"))?;
}
if let Some(existing_thumbnail) = &context.existing_thumbnail {
diesel::delete(
document_assets::table.filter(document_assets::id.eq(existing_thumbnail.id)),
)
.execute(&mut conn)
.map_err(|err| format!("{err:?}"))?;
}
for asset in assets { for asset in assets {
if asset.objects.is_empty() {
return Err(format!(
"asset {} has no generated objects",
asset.asset_type
));
}
let object_count: i32 = asset
.objects
.len()
.try_into()
.map_err(|_| "asset contains too many objects".to_string())?;
let new_asset = NewDocumentAsset { let new_asset = NewDocumentAsset {
id: asset.asset_id, id: asset.asset_id,
document_version_id: context.version.id, document_version_id: context.version.id,
asset_type: asset.asset_type.to_string(), asset_type: asset.asset_type.to_string(),
s3_key: asset.s3_key.to_string(),
mime_type: "image/png".to_string(), mime_type: "image/png".to_string(),
metadata: json!({ metadata: json!({
"generated_at": Utc::now().to_rfc3339(), "generated_at": Utc::now().to_rfc3339(),
"width": asset.generated.width,
"height": asset.generated.height,
}), }),
cardinality: Some(object_count),
tenant_id,
}; };
diesel::insert_into(document_assets::table) diesel::insert_into(document_assets::table)
@@ -484,12 +630,46 @@ fn persist_assets_metadata(
)) ))
.do_update() .do_update()
.set(( .set((
document_assets::s3_key.eq(excluded(document_assets::s3_key)),
document_assets::mime_type.eq(excluded(document_assets::mime_type)), document_assets::mime_type.eq(excluded(document_assets::mime_type)),
document_assets::metadata.eq(excluded(document_assets::metadata)), document_assets::metadata.eq(excluded(document_assets::metadata)),
document_assets::cardinality.eq(excluded(document_assets::cardinality)),
)) ))
.execute(&mut conn) .execute(&mut conn)
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
diesel::delete(
document_asset_objects::table
.filter(document_asset_objects::asset_id.eq(asset.asset_id))
.filter(document_asset_objects::tenant_id.eq(tenant_id)),
)
.execute(&mut conn)
.map_err(|err| format!("{err:?}"))?;
for object in &asset.objects {
let mut metadata_map = Map::new();
if let Some(width) = object.width {
metadata_map.insert("width".to_string(), Value::from(width));
}
if let Some(height) = object.height {
metadata_map.insert("height".to_string(), Value::from(height));
}
let object_metadata = Value::Object(metadata_map);
let new_object = NewDocumentAssetObject {
id: Uuid::new_v4(),
asset_id: asset.asset_id,
ordinal: object.ordinal,
s3_key: object.s3_key.clone(),
metadata: object_metadata,
tenant_id,
};
diesel::insert_into(document_asset_objects::table)
.values(&new_object)
.execute(&mut conn)
.map_err(|err| format!("{err:?}"))?;
}
} }
Ok(()) Ok(())
@@ -501,11 +681,17 @@ fn persist_document_page_count(
document_version_id: Uuid, document_version_id: Uuid,
page_count: u32, page_count: u32,
) -> Result<(), String> { ) -> Result<(), String> {
let mut conn = state.db().map_err(|err| format!("{err:?}"))?; let mut conn = state.db_unscoped().map_err(|err| format!("{err:?}"))?;
let tenant_id: Uuid = documents::table
.find(document_id)
.select(documents::tenant_id)
.first(&mut conn)
.map_err(|err| format!("{err:?}"))?;
let existing_metadata: Value = document_versions::table let existing_metadata: Value = document_versions::table
.filter(document_versions::id.eq(document_version_id)) .filter(document_versions::id.eq(document_version_id))
.filter(document_versions::document_id.eq(document_id)) .filter(document_versions::document_id.eq(document_id))
.filter(document_versions::tenant_id.eq(tenant_id))
.select(document_versions::metadata) .select(document_versions::metadata)
.first(&mut conn) .first(&mut conn)
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
@@ -525,7 +711,8 @@ fn persist_document_page_count(
diesel::update( diesel::update(
document_versions::table document_versions::table
.filter(document_versions::id.eq(document_version_id)) .filter(document_versions::id.eq(document_version_id))
.filter(document_versions::document_id.eq(document_id)), .filter(document_versions::document_id.eq(document_id))
.filter(document_versions::tenant_id.eq(tenant_id)),
) )
.set(document_versions::metadata.eq(updated)) .set(document_versions::metadata.eq(updated))
.execute(&mut conn) .execute(&mut conn)
@@ -533,3 +720,37 @@ fn persist_document_page_count(
Ok(()) Ok(())
} }
fn document_is_pdf(document: &Document) -> bool {
document
.content_type
.as_deref()
.map(|mime| mime.eq_ignore_ascii_case("application/pdf"))
.unwrap_or_else(|| {
document
.original_name
.rsplit('.')
.next()
.map(|ext| ext.eq_ignore_ascii_case("pdf"))
.unwrap_or(false)
})
}
fn expected_asset_cardinality(document: &Document, version: &DocumentVersion) -> i32 {
if let Value::Object(map) = &version.metadata {
if let Some(count) = map.get("page_count").and_then(|v| v.as_i64()) {
if count > 0 {
return count
.min(i64::from(i32::MAX))
.try_into()
.unwrap_or(i32::MAX);
}
}
}
if document_is_pdf(document) {
1
} else {
1
}
}
-2
View File
@@ -8,7 +8,6 @@ use serde::Deserialize;
#[derive(Deserialize)] #[derive(Deserialize)]
struct AuthenticatedUser { struct AuthenticatedUser {
username: String, username: String,
role: String,
} }
#[tokio::test] #[tokio::test]
@@ -27,7 +26,6 @@ async fn login_and_me_roundtrip() -> Result<()> {
let user: AuthenticatedUser = serde_json::from_slice(&body)?; let user: AuthenticatedUser = serde_json::from_slice(&body)?;
assert_eq!(user.username, "alice"); assert_eq!(user.username, "alice");
assert_eq!(user.role, "admin");
app.cleanup().await?; app.cleanup().await?;
Ok(()) Ok(())
+69 -5
View File
@@ -11,7 +11,7 @@ use axum::Router;
use backend::auth::jwt::JwtService; use backend::auth::jwt::JwtService;
use backend::config::AppConfig; use backend::config::AppConfig;
use backend::db::{self, PgPool}; use backend::db::{self, PgPool};
use backend::models::{Job, NewUser}; use backend::models::{Job, NewUser, NewUserMembership};
use backend::routes; use backend::routes;
use backend::state::AppState; use backend::state::AppState;
use backend::storage::ObjectStorage; use backend::storage::ObjectStorage;
@@ -22,7 +22,8 @@ use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
use http_body_util::BodyExt; use http_body_util::BodyExt;
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use rand::rngs::OsRng; use rand::rngs::OsRng;
use serde::Serialize; use serde::{Deserialize, Serialize};
use serde_json;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tower::util::ServiceExt; use tower::util::ServiceExt;
use uuid::Uuid; use uuid::Uuid;
@@ -138,6 +139,7 @@ impl TestApp {
s3_bucket: "test-bucket".to_string(), s3_bucket: "test-bucket".to_string(),
quickwit_endpoint: None, quickwit_endpoint: None,
quickwit_index: None, quickwit_index: None,
default_tenant_slug: "admin".to_string(),
}; };
let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?; let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?;
@@ -178,18 +180,34 @@ impl TestApp {
let username = username.to_string(); let username = username.to_string();
let password = password.to_string(); let password = password.to_string();
let role = role.to_string(); let role = role.to_string();
let tenant_id = self
.state
.tenants
.tenant_id_for_slug(&self.state.config.default_tenant_slug)
.context("default tenant not found")?;
self.with_conn(move |conn| { self.with_conn(move |conn| {
let password_hash = hash_password(&password)?; let password_hash = hash_password(&password)?;
let user = NewUser { let user = NewUser {
id: Uuid::new_v4(), id: Uuid::new_v4(),
username, username,
password_hash, password_hash,
role,
}; };
diesel::insert_into(backend::schema::users::table) diesel::insert_into(backend::schema::users::table)
.values(&user) .values(&user)
.execute(conn) .execute(conn)
.context("failed to insert user")?; .context("failed to insert user")?;
let membership = NewUserMembership {
id: Uuid::new_v4(),
user_id: user.id,
tenant_id,
role,
};
diesel::insert_into(backend::schema::user_memberships::table)
.values(&membership)
.execute(conn)
.context("failed to insert user membership")?;
Ok(user.id) Ok(user.id)
}) })
.await .await
@@ -217,11 +235,57 @@ impl TestApp {
); );
let body = body_to_vec(response.into_body()).await?; let body = body_to_vec(response.into_body()).await?;
#[derive(serde::Deserialize)] #[derive(Deserialize)]
struct LoginResponse { struct LoginResponse {
access_token: String, access_token: String,
} }
let parsed: LoginResponse = serde_json::from_slice(&body)?;
if let Ok(parsed) = serde_json::from_slice::<LoginResponse>(&body) {
return Ok(parsed.access_token);
}
#[derive(Deserialize)]
struct TenantSummary {
tenant_id: Uuid,
slug: String,
}
#[derive(Deserialize)]
struct TenantSelectionResponse {
selection_token: String,
tenants: Vec<TenantSummary>,
}
let selection: TenantSelectionResponse = serde_json::from_slice(&body)?;
ensure!(
!selection.tenants.is_empty(),
"login returned no tenant options",
);
#[derive(Serialize)]
struct SelectTenantPayload {
tenant_id: Uuid,
}
let target_tenant = selection.tenants[0].tenant_id;
let select_response = self
.post_json(
"/api/auth/select-tenant",
&SelectTenantPayload {
tenant_id: target_tenant,
},
Some(&selection.selection_token),
)
.await?;
ensure!(
select_response.status() == StatusCode::OK,
"tenant selection failed with status {}",
select_response.status()
);
let select_body = body_to_vec(select_response.into_body()).await?;
let parsed: LoginResponse = serde_json::from_slice(&select_body)?;
Ok(parsed.access_token) Ok(parsed.access_token)
} }
+2 -93
View File
@@ -56,12 +56,12 @@ struct DocumentDownload {
filename: String, filename: String,
} }
#[derive(Deserialize)]
#[derive(Deserialize)] #[derive(Deserialize)]
struct BulkReanalyze { struct BulkReanalyze {
queued: usize, queued: usize,
} }
#[derive(Deserialize)]
struct BulkMoveResult { struct BulkMoveResult {
updated: usize, updated: usize,
} }
@@ -318,97 +318,6 @@ async fn duplicate_and_restore_document() -> Result<()> {
Ok(()) Ok(())
} }
#[tokio::test]
async fn bulk_reanalyze_documents() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "bulkpass";
app.insert_user("alex", password, "admin").await?;
let token = app.login_token("alex", password).await?;
app.clear_jobs().await?;
let first_bytes = b"first doc";
let first = app
.upload_document(
"/api/documents",
"first.txt",
"text/plain",
first_bytes,
None,
&token,
)
.await?;
assert_eq!(first.status(), StatusCode::CREATED);
let first_body = body_to_vec(first.into_body()).await?;
let first_detail: DocumentDetail = serde_json::from_slice(&first_body)?;
let second_bytes = b"second doc";
let second = app
.upload_document(
"/api/documents",
"second.txt",
"text/plain",
second_bytes,
None,
&token,
)
.await?;
assert_eq!(second.status(), StatusCode::CREATED);
let second_body = body_to_vec(second.into_body()).await?;
let second_detail: DocumentDetail = serde_json::from_slice(&second_body)?;
app.clear_jobs().await?;
let response = app
.post_json(
"/api/documents/reanalyze",
&serde_json::json!({}),
Some(&token),
)
.await?;
assert_eq!(response.status(), StatusCode::ACCEPTED);
let body = body_to_vec(response.into_body()).await?;
let bulk: BulkReanalyze = serde_json::from_slice(&body)?;
assert_eq!(bulk.queued, 2);
let jobs = app.jobs_by_type("analyze-document").await?;
assert_eq!(jobs.len(), 2);
let mut payload_docs = Vec::new();
for job in jobs {
let payload: AnalyzeJobPayload = serde_json::from_value(job.payload)?;
assert!(payload.force);
payload_docs.push((payload.document_id, payload.document_version_id));
}
let mut expected = vec![
(
first_detail.document.id,
first_detail
.document
.current_version
.as_ref()
.expect("first current version")
.id,
),
(
second_detail.document.id,
second_detail
.document
.current_version
.as_ref()
.expect("second current version")
.id,
),
];
payload_docs.sort();
expected.sort();
assert_eq!(payload_docs, expected);
app.cleanup().await?;
Ok(())
}
#[tokio::test] #[tokio::test]
async fn bulk_move_documents_to_folder() -> Result<()> { async fn bulk_move_documents_to_folder() -> Result<()> {
+33 -14
View File
@@ -10,19 +10,19 @@ services:
tmpfs: tmpfs:
- /var/lib/postgresql/data - /var/lib/postgresql/data
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U papercrate"] test: ["CMD-SHELL", "pg_isready -U papercrate -d papercrate_test"]
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 5 retries: 5
quickwit: quickwit-test:
image: quickwit/quickwit:0.8.2 image: quickwit/quickwit:0.8.2
command: ["run"] command: ["run"]
environment: environment:
QW_ENABLE_API_AUTH: "false" QW_ENABLE_API_AUTH: "false"
QW_DATA_DIR: /quickwit/data QW_DATA_DIR: /quickwit/data
ports: ports:
- "7280:7280" - "7281:7280"
volumes: volumes:
- quickwit_test_data:/quickwit/data - quickwit_test_data:/quickwit/data
healthcheck: healthcheck:
@@ -31,21 +31,40 @@ services:
timeout: 5s timeout: 5s
retries: 5 retries: 5
quickwit-create-index: admin-bootstrap:
image: quickwit/quickwit:0.8.2 build:
context: ./backend
depends_on: depends_on:
quickwit: postgres-test:
condition: service_healthy
quickwit-test:
condition: service_healthy condition: service_healthy
volumes:
- quickwit_test_data:/quickwit/data
- ./quickwit/documents-index.yaml:/tmp/documents-index.yaml:ro
environment: environment:
QW_DATA_DIR: /quickwit/data DATABASE_URL: postgres://papercrate:papercrate_test@postgres-test:5432/papercrate_test
QW_NODE_URI: http://quickwit:7280 DATABASE_MAX_POOL_SIZE: 1
entrypoint: ["/bin/sh", "-c"] S3_BUCKET: documents
JWT_SECRET: change-me-super-secret
QUICKWIT_ENDPOINT: http://quickwit-test:7280
DEFAULT_TENANT_SLUG: admin
entrypoint: []
command: > command: >
quickwit index list --node-uri http://quickwit:7280 2>/dev/null | grep -q '"documents"' \ /bin/sh -c "
|| quickwit index create --index-config /tmp/documents-index.yaml --node-uri http://quickwit:7280 echo 'Running database migrations' &&
diesel migration run &&
echo 'Ensuring tenant admin exists' &&
if papercrate-admin list-tenants | grep -q '^admin '; then
echo 'tenant admin already exists';
else
papercrate-admin create-tenant admin;
fi &&
echo 'Ensuring demo user credentials' &&
(papercrate-admin create-user admin adminadmin || papercrate-admin set-password admin adminadmin) &&
echo 'Ensuring demo membership' &&
papercrate-admin add-user-to-tenant admin admin admin &&
echo 'Ensuring Quickwit index for admin tenant' &&
papercrate-admin quickwit-create-index admin
"
user: root
restart: "no" restart: "no"
volumes: volumes:
+35 -6
View File
@@ -62,15 +62,44 @@ services:
timeout: 5s timeout: 5s
retries: 5 retries: 5
quickwit-create-index: admin-bootstrap:
image: quickwit/quickwit:0.8.2 build:
context: ./backend
depends_on: depends_on:
postgres:
condition: service_healthy
quickwit: quickwit:
condition: service_healthy condition: service_healthy
volumes: environment:
- quickwit_data:/quickwit/data DATABASE_URL: postgres://papercrate:papercrate_dev@postgres:5432/papercrate
- ./quickwit/documents-index.yaml:/tmp/documents-index.yaml:ro DATABASE_MAX_POOL_SIZE: 2
command: index create --endpoint=http://quickwit:7280 --index-config /tmp/documents-index.yaml AWS_ENDPOINT_URL: http://minio:9000
AWS_ACCESS_KEY_ID: minioadmin
AWS_SECRET_ACCESS_KEY: minioadmin
AWS_REGION: us-east-1
S3_BUCKET: documents
JWT_SECRET: change-me-super-secret
QUICKWIT_ENDPOINT: http://quickwit:7280
DEFAULT_TENANT_SLUG: admin
entrypoint: []
command: >
/bin/sh -c "
echo 'Running database migrations' &&
diesel migration run &&
echo 'Ensuring tenant admin exists' &&
if papercrate-admin list-tenants | grep -q '^admin '; then
echo 'tenant admin already exists';
else
papercrate-admin create-tenant admin;
fi &&
echo 'Ensuring demo user credentials' &&
(papercrate-admin create-user admin adminadmin || papercrate-admin set-password admin adminadmin) &&
echo 'Ensuring demo membership' &&
papercrate-admin add-user-to-tenant admin admin admin &&
echo 'Ensuring Quickwit index for admin tenant' &&
papercrate-admin quickwit-create-index admin
"
user: root
restart: "no" restart: "no"
volumes: volumes:
+1 -2
View File
@@ -18,7 +18,6 @@ Documents
--------- ---------
- GET /api/documents - List or search documents. Optional filters: `folder_id` (defaults to root when omitted), `include_deleted`, `include_descendants` (defaults to true when a `folder_id` is provided and no other override is supplied), `query` (Quickwit full-text), `tags` (comma-separated tag UUIDs), and `correspondents` (comma-separated correspondent UUIDs). Each entry includes tags, correspondent assignments, and current version info. - GET /api/documents - List or search documents. Optional filters: `folder_id` (defaults to root when omitted), `include_deleted`, `include_descendants` (defaults to true when a `folder_id` is provided and no other override is supplied), `query` (Quickwit full-text), `tags` (comma-separated tag UUIDs), and `correspondents` (comma-separated correspondent UUIDs). Each entry includes tags, correspondent assignments, and current version info.
- POST /api/documents - Upload a document via multipart form-data (`file`, optional metadata/folder fields). - 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/move - Move multiple documents to a target folder.
- POST /api/documents/bulk/tags - Add or remove tags across multiple documents. - POST /api/documents/bulk/tags - Add or remove tags across multiple documents.
- POST /api/documents/bulk/correspondents - Bulk correspondent actions. Default `action=add` replaces existing assignments for the provided roles before adding the supplied correspondents; `action=remove` drops the specified correspondent/role pairs. - POST /api/documents/bulk/correspondents - Bulk correspondent actions. Default `action=add` replaces existing assignments for the provided roles before adding the supplied correspondents; `action=remove` drops the specified correspondent/role pairs.
@@ -37,7 +36,7 @@ Document Assets
--------------- ---------------
- GET /api/documents/:id/assets - List generated assets for the current version. - 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. - 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. - GET /api/assets/:asset_id - Fetch asset metadata plus a presigned URL for a range of objects (query params: `start` and `limit`, defaulting to the first object).
Downloads Downloads
--------- ---------
+1 -1
View File
@@ -44,4 +44,4 @@ npm run build
## Assets ## Assets
- The folder icon (`public/folder.svg`) is derived from the Adwaita icon theme by the [GNOME Project](http://www.gnome.org/). - The folder icon (`src/assets/folder.svg`) is derived from the Adwaita icon theme by the [GNOME Project](http://www.gnome.org/).
+737
View File
@@ -8,6 +8,7 @@
"name": "papercrate-frontend", "name": "papercrate-frontend",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@fontsource/inter": "^5.2.8",
"@tabler/icons-react": "3.11.0", "@tabler/icons-react": "3.11.0",
"axios": "1.7.7", "axios": "1.7.7",
"react": "18.3.1", "react": "18.3.1",
@@ -18,6 +19,7 @@
"@babel/core": "7.26.0", "@babel/core": "7.26.0",
"@babel/preset-env": "7.26.0", "@babel/preset-env": "7.26.0",
"@babel/preset-react": "7.26.3", "@babel/preset-react": "7.26.3",
"@svgr/webpack": "8.1.0",
"babel-loader": "9.2.1", "babel-loader": "9.2.1",
"css-loader": "7.1.2", "css-loader": "7.1.2",
"dotenv": "16.4.5", "dotenv": "16.4.5",
@@ -641,6 +643,22 @@
"@babel/core": "^7.0.0-0" "@babel/core": "^7.0.0-0"
} }
}, },
"node_modules/@babel/plugin-syntax-typescript": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz",
"integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/plugin-syntax-unicode-sets-regex": { "node_modules/@babel/plugin-syntax-unicode-sets-regex": {
"version": "7.18.6", "version": "7.18.6",
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz",
@@ -1300,6 +1318,22 @@
"@babel/core": "^7.0.0-0" "@babel/core": "^7.0.0-0"
} }
}, },
"node_modules/@babel/plugin-transform-react-constant-elements": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-constant-elements/-/plugin-transform-react-constant-elements-7.27.1.tgz",
"integrity": "sha512-edoidOjl/ZxvYo4lSBOQGDSyToYVkTAwyVoa2tkuYTSmjrB1+uAedoL5iROVLXkxH+vRgA7uP4tMg2pUJpZ3Ug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/plugin-transform-react-display-name": { "node_modules/@babel/plugin-transform-react-display-name": {
"version": "7.28.0", "version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.28.0.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.28.0.tgz",
@@ -1499,6 +1533,26 @@
"@babel/core": "^7.0.0-0" "@babel/core": "^7.0.0-0"
} }
}, },
"node_modules/@babel/plugin-transform-typescript": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.0.tgz",
"integrity": "sha512-4AEiDEBPIZvLQaWlc9liCavE0xRM0dNca41WtBeM3jgFptfUOSG9z0uteLhq6+3rq+WB6jIvUwKDTpXEHPJ2Vg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-annotate-as-pure": "^7.27.3",
"@babel/helper-create-class-features-plugin": "^7.27.1",
"@babel/helper-plugin-utils": "^7.27.1",
"@babel/helper-skip-transparent-expression-wrappers": "^7.27.1",
"@babel/plugin-syntax-typescript": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/plugin-transform-unicode-escapes": { "node_modules/@babel/plugin-transform-unicode-escapes": {
"version": "7.27.1", "version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz",
@@ -1696,6 +1750,26 @@
"@babel/core": "^7.0.0-0" "@babel/core": "^7.0.0-0"
} }
}, },
"node_modules/@babel/preset-typescript": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.27.1.tgz",
"integrity": "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1",
"@babel/helper-validator-option": "^7.27.1",
"@babel/plugin-syntax-jsx": "^7.27.1",
"@babel/plugin-transform-modules-commonjs": "^7.27.1",
"@babel/plugin-transform-typescript": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/template": { "node_modules/@babel/template": {
"version": "7.27.2", "version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
@@ -1779,6 +1853,15 @@
"node": ">=10.0.0" "node": ">=10.0.0"
} }
}, },
"node_modules/@fontsource/inter": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz",
"integrity": "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@jridgewell/gen-mapping": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -1964,6 +2047,290 @@
"node": ">=14.0.0" "node": ">=14.0.0"
} }
}, },
"node_modules/@svgr/babel-plugin-add-jsx-attribute": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz",
"integrity": "sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/gregberge"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@svgr/babel-plugin-remove-jsx-attribute": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-8.0.0.tgz",
"integrity": "sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/gregberge"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@svgr/babel-plugin-remove-jsx-empty-expression": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-8.0.0.tgz",
"integrity": "sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/gregberge"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@svgr/babel-plugin-replace-jsx-attribute-value": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-8.0.0.tgz",
"integrity": "sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/gregberge"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@svgr/babel-plugin-svg-dynamic-title": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-8.0.0.tgz",
"integrity": "sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/gregberge"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@svgr/babel-plugin-svg-em-dimensions": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-8.0.0.tgz",
"integrity": "sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/gregberge"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@svgr/babel-plugin-transform-react-native-svg": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-8.1.0.tgz",
"integrity": "sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/gregberge"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@svgr/babel-plugin-transform-svg-component": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-8.0.0.tgz",
"integrity": "sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/gregberge"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@svgr/babel-preset": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-8.1.0.tgz",
"integrity": "sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@svgr/babel-plugin-add-jsx-attribute": "8.0.0",
"@svgr/babel-plugin-remove-jsx-attribute": "8.0.0",
"@svgr/babel-plugin-remove-jsx-empty-expression": "8.0.0",
"@svgr/babel-plugin-replace-jsx-attribute-value": "8.0.0",
"@svgr/babel-plugin-svg-dynamic-title": "8.0.0",
"@svgr/babel-plugin-svg-em-dimensions": "8.0.0",
"@svgr/babel-plugin-transform-react-native-svg": "8.1.0",
"@svgr/babel-plugin-transform-svg-component": "8.0.0"
},
"engines": {
"node": ">=14"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/gregberge"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@svgr/core": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz",
"integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/core": "^7.21.3",
"@svgr/babel-preset": "8.1.0",
"camelcase": "^6.2.0",
"cosmiconfig": "^8.1.3",
"snake-case": "^3.0.4"
},
"engines": {
"node": ">=14"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/gregberge"
}
},
"node_modules/@svgr/hast-util-to-babel-ast": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-8.0.0.tgz",
"integrity": "sha512-EbDKwO9GpfWP4jN9sGdYwPBU0kdomaPIL2Eu4YwmgP+sJeXT+L7bMwJUBnhzfH8Q2qMBqZ4fJwpCyYsAN3mt2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.21.3",
"entities": "^4.4.0"
},
"engines": {
"node": ">=14"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/gregberge"
}
},
"node_modules/@svgr/hast-util-to-babel-ast/node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/@svgr/plugin-jsx": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-8.1.0.tgz",
"integrity": "sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/core": "^7.21.3",
"@svgr/babel-preset": "8.1.0",
"@svgr/hast-util-to-babel-ast": "8.0.0",
"svg-parser": "^2.0.4"
},
"engines": {
"node": ">=14"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/gregberge"
},
"peerDependencies": {
"@svgr/core": "*"
}
},
"node_modules/@svgr/plugin-svgo": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/@svgr/plugin-svgo/-/plugin-svgo-8.1.0.tgz",
"integrity": "sha512-Ywtl837OGO9pTLIN/onoWLmDQ4zFUycI1g76vuKGEz6evR/ZTJlJuz3G/fIkb6OVBJ2g0o6CGJzaEjfmEo3AHA==",
"dev": true,
"license": "MIT",
"dependencies": {
"cosmiconfig": "^8.1.3",
"deepmerge": "^4.3.1",
"svgo": "^3.0.2"
},
"engines": {
"node": ">=14"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/gregberge"
},
"peerDependencies": {
"@svgr/core": "*"
}
},
"node_modules/@svgr/webpack": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/@svgr/webpack/-/webpack-8.1.0.tgz",
"integrity": "sha512-LnhVjMWyMQV9ZmeEy26maJk+8HTIbd59cH4F2MJ439k9DqejRisfFNGAPvRYlKETuh9LrImlS8aKsBgKjMA8WA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/core": "^7.21.3",
"@babel/plugin-transform-react-constant-elements": "^7.21.3",
"@babel/preset-env": "^7.20.2",
"@babel/preset-react": "^7.18.6",
"@babel/preset-typescript": "^7.21.0",
"@svgr/core": "8.1.0",
"@svgr/plugin-jsx": "8.1.0",
"@svgr/plugin-svgo": "8.1.0"
},
"engines": {
"node": ">=14"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/gregberge"
}
},
"node_modules/@tabler/icons": { "node_modules/@tabler/icons": {
"version": "3.11.0", "version": "3.11.0",
"resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.11.0.tgz", "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.11.0.tgz",
@@ -1990,6 +2357,16 @@
"react": ">= 16" "react": ">= 16"
} }
}, },
"node_modules/@trysound/sax": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz",
"integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/@types/body-parser": { "node_modules/@types/body-parser": {
"version": "1.19.6", "version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
@@ -2595,6 +2972,13 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0"
},
"node_modules/array-flatten": { "node_modules/array-flatten": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -2929,6 +3313,16 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
"integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/camel-case": { "node_modules/camel-case": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz", "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz",
@@ -2940,6 +3334,19 @@
"tslib": "^2.0.3" "tslib": "^2.0.3"
} }
}, },
"node_modules/camelcase": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
"integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001749", "version": "1.0.30001749",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001749.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001749.tgz",
@@ -3170,6 +3577,33 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cosmiconfig": {
"version": "8.3.6",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz",
"integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==",
"dev": true,
"license": "MIT",
"dependencies": {
"import-fresh": "^3.3.0",
"js-yaml": "^4.1.0",
"parse-json": "^5.2.0",
"path-type": "^4.0.0"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/d-fischer"
},
"peerDependencies": {
"typescript": ">=4.9.5"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -3238,6 +3672,20 @@
"url": "https://github.com/sponsors/fb55" "url": "https://github.com/sponsors/fb55"
} }
}, },
"node_modules/css-tree": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz",
"integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==",
"dev": true,
"license": "MIT",
"dependencies": {
"mdn-data": "2.0.30",
"source-map-js": "^1.0.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/css-what": { "node_modules/css-what": {
"version": "6.2.2", "version": "6.2.2",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
@@ -3264,6 +3712,42 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/csso": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz",
"integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"css-tree": "~2.2.0"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0",
"npm": ">=7.0.0"
}
},
"node_modules/csso/node_modules/css-tree": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz",
"integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==",
"dev": true,
"license": "MIT",
"dependencies": {
"mdn-data": "2.0.28",
"source-map-js": "^1.0.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0",
"npm": ">=7.0.0"
}
},
"node_modules/csso/node_modules/mdn-data": {
"version": "2.0.28",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz",
"integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/debug": { "node_modules/debug": {
"version": "2.6.9", "version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -3274,6 +3758,16 @@
"ms": "2.0.0" "ms": "2.0.0"
} }
}, },
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/default-browser": { "node_modules/default-browser": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz", "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz",
@@ -3535,6 +4029,16 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/error-ex": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
"integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-arrayish": "^0.2.1"
}
},
"node_modules/es-define-property": { "node_modules/es-define-property": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -4447,6 +4951,33 @@
"postcss": "^8.1.0" "postcss": "^8.1.0"
} }
}, },
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
"integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"parent-module": "^1.0.0",
"resolve-from": "^4.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/import-fresh/node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
"integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/import-local": { "node_modules/import-local": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz",
@@ -4494,6 +5025,13 @@
"node": ">= 10" "node": ">= 10"
} }
}, },
"node_modules/is-arrayish": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
"integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
"dev": true,
"license": "MIT"
},
"node_modules/is-binary-path": { "node_modules/is-binary-path": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@@ -4691,6 +5229,19 @@
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/jsesc": { "node_modules/jsesc": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -4752,6 +5303,13 @@
"shell-quote": "^1.8.3" "shell-quote": "^1.8.3"
} }
}, },
"node_modules/lines-and-columns": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"dev": true,
"license": "MIT"
},
"node_modules/loader-runner": { "node_modules/loader-runner": {
"version": "4.3.1", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz",
@@ -4834,6 +5392,13 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/mdn-data": {
"version": "2.0.30",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz",
"integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/media-typer": { "node_modules/media-typer": {
"version": "0.3.0", "version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
@@ -5183,6 +5748,38 @@
"tslib": "^2.0.3" "tslib": "^2.0.3"
} }
}, },
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
"integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
"dev": true,
"license": "MIT",
"dependencies": {
"callsites": "^3.0.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/parse-json": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
"integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.0.0",
"error-ex": "^1.3.1",
"json-parse-even-better-errors": "^2.3.0",
"lines-and-columns": "^1.1.6"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/parseurl": { "node_modules/parseurl": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -5238,6 +5835,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/path-type": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
"integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -6117,6 +6724,17 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/snake-case": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz",
"integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==",
"dev": true,
"license": "MIT",
"dependencies": {
"dot-case": "^3.0.4",
"tslib": "^2.0.3"
}
},
"node_modules/sockjs": { "node_modules/sockjs": {
"version": "0.3.24", "version": "0.3.24",
"resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz",
@@ -6321,6 +6939,125 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/svg-parser": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz",
"integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==",
"dev": true,
"license": "MIT"
},
"node_modules/svgo": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.2.tgz",
"integrity": "sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@trysound/sax": "0.2.0",
"commander": "^7.2.0",
"css-select": "^5.1.0",
"css-tree": "^2.3.1",
"css-what": "^6.1.0",
"csso": "^5.0.5",
"picocolors": "^1.0.0"
},
"bin": {
"svgo": "bin/svgo"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/svgo"
}
},
"node_modules/svgo/node_modules/commander": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 10"
}
},
"node_modules/svgo/node_modules/css-select": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
"css-what": "^6.1.0",
"domhandler": "^5.0.2",
"domutils": "^3.0.1",
"nth-check": "^2.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/svgo/node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"dev": true,
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/svgo/node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/svgo/node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/svgo/node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/tapable": { "node_modules/tapable": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
+2
View File
@@ -9,6 +9,7 @@
"lint": "echo \"No linting configured\"" "lint": "echo \"No linting configured\""
}, },
"dependencies": { "dependencies": {
"@fontsource/inter": "^5.2.8",
"@tabler/icons-react": "3.11.0", "@tabler/icons-react": "3.11.0",
"axios": "1.7.7", "axios": "1.7.7",
"react": "18.3.1", "react": "18.3.1",
@@ -19,6 +20,7 @@
"@babel/core": "7.26.0", "@babel/core": "7.26.0",
"@babel/preset-env": "7.26.0", "@babel/preset-env": "7.26.0",
"@babel/preset-react": "7.26.3", "@babel/preset-react": "7.26.3",
"@svgr/webpack": "8.1.0",
"babel-loader": "9.2.1", "babel-loader": "9.2.1",
"css-loader": "7.1.2", "css-loader": "7.1.2",
"dotenv": "16.4.5", "dotenv": "16.4.5",
+12
View File
@@ -0,0 +1,12 @@
import React from 'react';
export const AppShellContext = React.createContext(null);
export const useAppShell = () => {
const context = React.useContext(AppShellContext);
if (!context) {
throw new Error('AppShellContext not found. Ensure routes are nested under AppLayout.');
}
return context;
};
+214 -21
View File
@@ -17,7 +17,116 @@ export const getAssetFromVersion = (currentVersion, assetType) => {
return getAssetFromGroup(currentVersion.assets, assetType); return getAssetFromGroup(currentVersion.assets, assetType);
}; };
export const resolveDocumentAssetUrl = (doc, type, { ensureAssetUrl, getAsset, ensureOptions } = {}) => { const normalizeAssetObjects = (objects) => {
if (!Array.isArray(objects)) {
return [];
}
return objects
.filter((entry) => Number.isInteger(entry?.ordinal))
.slice()
.sort((a, b) => a.ordinal - b.ordinal);
};
const mergeAssetObjects = (existingObjects, incomingObjects) => {
const merged = new Map();
normalizeAssetObjects(existingObjects).forEach((entry) => {
merged.set(entry.ordinal, { ...entry });
});
normalizeAssetObjects(incomingObjects).forEach((entry) => {
const current = merged.get(entry.ordinal) || {};
merged.set(entry.ordinal, { ...current, ...entry });
});
return [...merged.entries()]
.sort((a, b) => a[0] - b[0])
.map(([, value]) => value);
};
export class AssetView {
constructor(asset) {
this.asset = asset || null;
this._objectsRef = null;
this._sortedObjects = [];
}
getCardinality() {
if (!this.asset) {
return 0;
}
const reported = Number(this.asset.cardinality);
if (Number.isFinite(reported) && reported > 0) {
return reported;
}
const objectsCount = this.getObjects().length;
if (objectsCount > 0) {
return objectsCount;
}
return this.asset.metadata ? 1 : 0;
}
getObjects() {
if (!this.asset || !Array.isArray(this.asset.objects) || this.asset.objects.length === 0) {
return [];
}
if (this._objectsRef === this.asset.objects) {
return this._sortedObjects;
}
this._objectsRef = this.asset.objects;
this._sortedObjects = normalizeAssetObjects(this.asset.objects);
return this._sortedObjects;
}
getObject(ordinal = 1) {
const fromObjects = this.getObjects().find((entry) => entry.ordinal === ordinal);
if (fromObjects) {
return fromObjects;
}
if (ordinal === 1 && this.asset) {
if (this.asset.url || this.asset.metadata) {
return {
ordinal: 1,
url: this.asset.url || null,
metadata: this.asset.metadata || null,
expires_at: this.asset.expiresAt ?? null,
};
}
}
return null;
}
getPrimaryObject() {
return this.getObject(1);
}
getPrimaryMetadata() {
return this.getPrimaryObject()?.metadata || null;
}
getPrimaryUrl() {
return this.getPrimaryObject()?.url || null;
}
hasObject(ordinal) {
return Boolean(this.getObject(ordinal));
}
}
export const createAssetView = (asset) => new AssetView(asset);
export const resolveDocumentAssetUrl = (
doc,
type,
{ ensureAssetUrl, getAsset, ensureOptions, objectOrdinal = 1 } = {},
) => {
if (!doc || !type) { if (!doc || !type) {
return null; return null;
} }
@@ -25,15 +134,32 @@ export const resolveDocumentAssetUrl = (doc, type, { ensureAssetUrl, getAsset, e
if (!asset) { if (!asset) {
return null; return null;
} }
const view = createAssetView(asset);
const object = view.getObject(objectOrdinal);
const url = object?.url || (objectOrdinal === 1 ? view.getPrimaryUrl() : null);
const expiresAt = typeof object?.expires_at === 'number'
? object.expires_at
: objectOrdinal === 1 && typeof asset.expiresAt === 'number'
? asset.expiresAt
: null;
const now = Date.now(); const now = Date.now();
const expiresAt = typeof asset.expiresAt === 'number' ? asset.expiresAt : null; if (url && (!expiresAt || expiresAt > now)) {
const hasFreshUrl = asset.url && (!expiresAt || expiresAt > now); return url;
if (hasFreshUrl) {
return asset.url;
} }
if (doc.id && asset.id && typeof ensureAssetUrl === 'function') { if (doc.id && asset.id && typeof ensureAssetUrl === 'function') {
const force = Boolean(asset.url && expiresAt && expiresAt <= now); const force = Boolean(url && expiresAt && expiresAt <= now);
const options = ensureOptions ? { ...ensureOptions, force } : { force }; const options = {
force,
start: objectOrdinal,
limit: 1,
...(ensureOptions || {}),
};
if (!options.start) {
options.start = objectOrdinal;
}
if (!options.limit) {
options.limit = 1;
}
ensureAssetUrl(doc.id, asset, options).catch(() => {}); ensureAssetUrl(doc.id, asset, options).catch(() => {});
} }
return null; return null;
@@ -63,6 +189,10 @@ class AssetManager {
} }
const cached = this.assetCache.get(asset.id); const cached = this.assetCache.get(asset.id);
if (!cached) { if (!cached) {
const normalized = mergeAssetObjects(null, asset.objects);
if (normalized.length) {
return { ...asset, objects: normalized };
}
return asset; return asset;
} }
const merged = { ...cached, ...asset }; const merged = { ...cached, ...asset };
@@ -76,6 +206,10 @@ class AssetManager {
merged.expiresAt = cachedExpires; merged.expiresAt = cachedExpires;
} }
} }
const mergedObjects = mergeAssetObjects(cached.objects, asset.objects);
if (mergedObjects.length) {
merged.objects = mergedObjects;
}
return merged; return merged;
} }
@@ -172,24 +306,53 @@ class AssetManager {
return next; return next;
} }
ensureAsset(documentId, asset, { force = false } = {}) { ensureAsset(documentId, asset, { force = false, start = null, limit = null } = {}) {
if (!documentId || !asset?.id) { if (!documentId || !asset?.id) {
return Promise.resolve(asset || null); return Promise.resolve(asset || null);
} }
const assetExpiresAt = typeof asset.expiresAt === 'number' ? asset.expiresAt : null; const requestedStart = Number.isInteger(start) && start > 0 ? start : 1;
if (!force && asset?.url && (!assetExpiresAt || assetExpiresAt > Date.now())) { const requestedLimit = Number.isInteger(limit) && limit > 0 ? limit : 1;
this.rememberAsset(asset); const requestedEnd = requestedStart + requestedLimit - 1;
return Promise.resolve(asset);
}
const cached = this.assetCache.get(asset.id); const baseAsset = this.assetCache.get(asset.id) || asset;
const view = createAssetView(baseAsset);
const assetExpiresAt = typeof baseAsset.expiresAt === 'number' ? baseAsset.expiresAt : null;
const now = Date.now(); const now = Date.now();
if (!force && cached && cached.expiresAt && cached.expiresAt > now && cached.url) {
return Promise.resolve({ ...asset, ...cached }); const isOrdinalSatisfied = (ordinal) => {
const object = view.getObject(ordinal);
if (!object) {
return false;
}
if (!object.url) {
return false;
}
if (typeof object.expires_at === 'number') {
return object.expires_at > now;
}
if (ordinal === 1 && baseAsset.url && (!assetExpiresAt || assetExpiresAt > now)) {
return true;
}
return true;
};
let needsFetch = force;
if (!needsFetch) {
for (let ordinal = requestedStart; ordinal <= requestedEnd; ordinal += 1) {
if (!isOrdinalSatisfied(ordinal)) {
needsFetch = true;
break;
}
}
} }
const inflightKey = `${documentId}:${asset.id}`; if (!needsFetch) {
this.rememberAsset(baseAsset);
return Promise.resolve(baseAsset);
}
const inflightKey = `${documentId}:${asset.id}:${start ?? 'd'}:${limit ?? 'd'}`;
if (!force && this.assetInflight.has(inflightKey)) { if (!force && this.assetInflight.has(inflightKey)) {
return this.assetInflight.get(inflightKey); return this.assetInflight.get(inflightKey);
} }
@@ -198,14 +361,44 @@ class AssetManager {
return Promise.reject(new Error('AssetManager API client is not configured.')); return Promise.reject(new Error('AssetManager API client is not configured.'));
} }
const params = {};
if (Number.isInteger(start) && start > 0) {
params.start = start;
}
if (Number.isInteger(limit) && limit > 0) {
params.limit = limit;
}
const requestConfig = Object.keys(params).length ? { params } : undefined;
const request = this.api const request = this.api
.get(`/documents/${documentId}/assets/${asset.id}`) .get(`/assets/${asset.id}`, requestConfig)
.then(({ data }) => { .then(({ data }) => {
const incomingObjects = Array.isArray(data.objects) ? data.objects : [];
const cachedEntry = this.assetCache.get(asset.id) || baseAsset;
const mergedObjects = mergeAssetObjects(cachedEntry?.objects, incomingObjects);
const combined = { ...cachedEntry, ...asset, ...data, objects: mergedObjects };
const view = createAssetView(combined);
const primaryObject = view.getPrimaryObject();
const expiresAt = typeof primaryObject?.expires_at === 'number'
? primaryObject.expires_at
: Date.now() + this.assetPresignTtlMs;
const cardinality = (() => {
const reported = Number(data.cardinality ?? asset.cardinality ?? cachedEntry?.cardinality);
const objectsCount = mergedObjects.length;
if (Number.isFinite(reported) && reported > 0) {
return Math.max(reported, objectsCount) || null;
}
return objectsCount || null;
})();
const entry = { const entry = {
...asset, ...combined,
...data, cardinality,
expiresAt: Date.now() + this.assetPresignTtlMs, url: view.getPrimaryUrl(),
expiresAt,
}; };
this.rememberAsset(entry); this.rememberAsset(entry);
return entry; return entry;
}) })
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<svg height="128px" viewBox="0 0 128 128" width="128px" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <svg viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<linearGradient id="a" gradientTransform="matrix(0.45451 0 0 0.455522 -1210.292114 616.172607)" gradientUnits="userSpaceOnUse" x1="2689.251953" x2="2918.069824" y1="-1106.802979" y2="-1106.802979"> <linearGradient id="a" gradientTransform="matrix(0.45451 0 0 0.455522 -1210.292114 616.172607)" gradientUnits="userSpaceOnUse" x1="2689.251953" x2="2918.069824" y1="-1106.802979" y2="-1106.802979">
<stop offset="0" stop-color="#62a0ea"/> <stop offset="0" stop-color="#62a0ea"/>
<stop offset="0.0576991" stop-color="#afd4ff"/> <stop offset="0.0576991" stop-color="#afd4ff"/>

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

+19
View File
@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 128 128" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<defs>
<filter id="cornerShadow" x="-20%" y="-20%" width="200%" height="200%">
<feDropShadow dx="-4" dy="4" stdDeviation="8" flood-opacity="0.24"/>
</filter>
<linearGradient id="cornerGradient" gradientUnits="userSpaceOnUse" x1="32" y1="0" x2="0" y2="64">
<stop offset="0%" stop-color="#ffffff"/>
<stop offset="100%" stop-color="#f3f3f3"/>
</linearGradient>
</defs>
<g filter="url(#cornerShadow)">
<g transform="matrix(-1.81626,-1.81626,0.460023,-0.460023,180.663,203.387)">
<path d="M70.488,10.11L89.954,86.966L51.022,86.966L70.488,10.11Z" fill="url(#cornerGradient)"/>
</g>
<g transform="matrix(1.81626,1.81626,-0.460023,0.460023,4.6259,-132.676)">
<path d="M70.488,10.11L89.954,86.966L51.022,86.966L70.488,10.11Z" fill="#fdfdfd"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -118,11 +118,11 @@ function CorrespondentsPanel({
}, []); }, []);
return ( return (
<section className="correspondents-panel column"> <section className="correspondents-panel">
<div className="column-header"> <div className="panel-section__header">
<div className="column-header__titles"> <div className="panel-section__titles">
<h2>Correspondents</h2> <h2>Correspondents</h2>
<div className="column-subtitle">{correspondents.length} total</div> <div className="panel-section__subtitle">{correspondents.length} total</div>
</div> </div>
<div className="header-actions correspondents-actions"> <div className="header-actions correspondents-actions">
<form className="correspondents-actions__form" onSubmit={handleCreate}> <form className="correspondents-actions__form" onSubmit={handleCreate}>
@@ -147,7 +147,7 @@ function CorrespondentsPanel({
</button> </button>
</div> </div>
</div> </div>
<div className="column-body tags-panel__body"> <div className="panel-section__body tags-panel__body">
{correspondents.length === 0 ? ( {correspondents.length === 0 ? (
<div className="empty-state">No correspondents created yet.</div> <div className="empty-state">No correspondents created yet.</div>
) : ( ) : (
+524 -92
View File
@@ -1,15 +1,34 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom'; import {
import { DownloadIcon, EditIcon } from '../ui/icons'; DownloadIcon,
EditIcon,
ArrowLeftIcon,
ArrowRightIcon,
ChevronsRightIcon,
AnalyzeIcon,
WindowMaximizeIcon,
TextScanIcon,
} from '../ui/icons';
import { getTagColorStyle } from '../utils/colors'; import { getTagColorStyle } from '../utils/colors';
import { formatFileSize } from '../utils/format'; import { formatFileSize } from '../utils/format';
import { resolveDocumentAssetUrl } from '../asset_manager'; import { resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
import { useAssetNavigator } from '../hooks/useAssetNavigator';
import { CORRESPONDENT_ROLES } from '../constants/correspondents'; import { CORRESPONDENT_ROLES } from '../constants/correspondents';
import PreviewZoomOverlay from './PreviewZoomOverlay';
const MAX_PREVIEW_STACK_ITEMS = 15; const MAX_PREVIEW_STACK_ITEMS = 15;
const normalizeRole = (role) => (role || '').toLowerCase(); const normalizeRole = (role) => (role || '').toLowerCase();
const derivePreviewOrientation = (metadata) => {
const width = Number(metadata?.width);
const height = Number(metadata?.height);
if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) {
return width >= height ? 'landscape' : 'portrait';
}
return 'landscape';
};
const formatRoleLabel = (role) => { const formatRoleLabel = (role) => {
const normalized = normalizeRole(role); const normalized = normalizeRole(role);
if (!normalized) return 'Other'; if (!normalized) return 'Other';
@@ -198,6 +217,7 @@ const PreviewStack = ({
emptyMessage = 'Preview unavailable', emptyMessage = 'Preview unavailable',
onItemActivate, onItemActivate,
onOpenPreview, onOpenPreview,
onZoomPreview,
activeItemId = null, activeItemId = null,
}) => { }) => {
if (!items.length) { if (!items.length) {
@@ -230,7 +250,11 @@ const PreviewStack = ({
zIndex: preparedItems.length - index, zIndex: preparedItems.length - index,
transform, transform,
}} }}
aria-hidden={hasMultiple && !onItemActivate && !onOpenPreview ? 'true' : undefined} aria-hidden={
hasMultiple && !onItemActivate && !onOpenPreview && !onZoomPreview
? 'true'
: undefined
}
> >
<img <img
src={entry.url} src={entry.url}
@@ -238,22 +262,34 @@ const PreviewStack = ({
className="preview-stack__image" className="preview-stack__image"
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
if (isFront && onOpenPreview) { if (isFront) {
if (onZoomPreview) {
onZoomPreview(entry);
} else if (onOpenPreview) {
onOpenPreview(entry.id); onOpenPreview(entry.id);
} else if (onItemActivate) { } else if (onItemActivate) {
onItemActivate(entry.id); onItemActivate(entry.id);
} }
} else if (onItemActivate) {
onItemActivate(entry.id);
}
}} }}
onKeyDown={(event) => { onKeyDown={(event) => {
if (!onItemActivate && !onOpenPreview) return; if (!onItemActivate && !onOpenPreview && !onZoomPreview) return;
if (event.key === 'Enter' || event.key === ' ') { if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
if (isFront && onOpenPreview) { if (isFront) {
if (onZoomPreview) {
onZoomPreview(entry);
} else if (onOpenPreview) {
onOpenPreview(entry.id); onOpenPreview(entry.id);
} else { } else {
onItemActivate?.(entry.id); onItemActivate?.(entry.id);
} }
} else {
onItemActivate?.(entry.id);
}
} }
}} }}
/> />
@@ -287,9 +323,24 @@ const DetailPanel = ({
onCorrespondentAdd, onCorrespondentAdd,
onCorrespondentRemove, onCorrespondentRemove,
resolveApiPath, resolveApiPath,
onClose = () => {},
}) => { }) => {
const selectedCount = selectedDocuments.length; const selectedCount = selectedDocuments.length;
const singleDoc = selectedCount === 1 ? selectedDocuments[0] : null; const singleDoc = selectedCount === 1 ? selectedDocuments[0] : null;
const singleDocId = singleDoc?.id || null;
const selectionKey = useMemo(
() => selectedDocuments.map((doc) => doc?.id ?? '').join('|'),
[selectedDocuments],
);
const singleDownloadHref = useMemo(() => {
if (!singleDoc) return null;
const downloadPath = singleDoc.current_version?.download_path;
if (!downloadPath || !resolveApiPath) {
return null;
}
return resolveApiPath(downloadPath);
}, [singleDoc, resolveApiPath]);
const [titleEditDocId, setTitleEditDocId] = useState(null); const [titleEditDocId, setTitleEditDocId] = useState(null);
const [titleDraft, setTitleDraft] = useState(''); const [titleDraft, setTitleDraft] = useState('');
@@ -299,6 +350,7 @@ const DetailPanel = ({
const [ocrUrl, setOcrUrl] = useState(null); const [ocrUrl, setOcrUrl] = useState(null);
const [ocrLoading, setOcrLoading] = useState(false); const [ocrLoading, setOcrLoading] = useState(false);
const [ocrError, setOcrError] = useState(null); const [ocrError, setOcrError] = useState(null);
const [zoomedPreview, setZoomedPreview] = useState(null);
useEffect(() => { useEffect(() => {
if (!singleDoc) { if (!singleDoc) {
@@ -328,6 +380,10 @@ const DetailPanel = ({
setOcrUrl(null); setOcrUrl(null);
}, [singleDoc?.id]); }, [singleDoc?.id]);
useEffect(() => {
setZoomedPreview(null);
}, [selectionKey]);
const startTitleEdit = useCallback(() => { const startTitleEdit = useCallback(() => {
if (!singleDoc) return; if (!singleDoc) return;
setTitleEditDocId(singleDoc.id); setTitleEditDocId(singleDoc.id);
@@ -435,20 +491,34 @@ const DetailPanel = ({
setOcrOpen(false); setOcrOpen(false);
}, []); }, []);
const makePreviewItem = useCallback( const singlePreviewNavigator = useAssetNavigator({
(doc) => { document: singleDoc,
if (!doc) return null; assetType: 'preview',
const url = resolveDocumentAssetUrl(doc, 'preview', {
ensureAssetUrl, ensureAssetUrl,
getAsset: getDocumentAsset, getAsset: getDocumentAsset,
prefetch: 3,
}); });
const makePreviewItem = useCallback(
(doc, ordinal = 1) => {
if (!doc) return null;
const asset = getDocumentAsset(doc, 'preview');
const assetView = createAssetView(asset);
const object = assetView.getObject(ordinal);
let url = object?.url || null;
if (!url) {
url = resolveDocumentAssetUrl(doc, 'preview', {
ensureAssetUrl,
getAsset: getDocumentAsset,
ensureOptions: { start: ordinal, limit: 1 },
objectOrdinal: ordinal,
});
}
if (!url) { if (!url) {
return null; return null;
} }
const asset = getDocumentAsset(doc, 'preview'); const metadata = object?.metadata || assetView.getPrimaryMetadata() || {};
const width = Number(asset?.metadata?.width) || 0; const orientation = derivePreviewOrientation(metadata);
const height = Number(asset?.metadata?.height) || 0;
const orientation = width > 0 && height > 0 ? (width >= height ? 'landscape' : 'portrait') : 'landscape';
return { return {
id: doc.id, id: doc.id,
url, url,
@@ -475,32 +545,75 @@ const DetailPanel = ({
return ordered; return ordered;
}, [selectedDocuments]); }, [selectedDocuments]);
const singlePreviewItems = useMemo(() => { const stackTopDocument = stackDocuments[0] || null;
if (!singleDoc) return []; const stackTopDocId = stackTopDocument?.id || null;
const item = makePreviewItem(singleDoc); const stackPreviewNavigator = useAssetNavigator({
return item ? [item] : []; document: stackTopDocument,
}, [singleDoc, makePreviewItem]); assetType: 'preview',
const stackPreviews = useMemo(
() =>
stackDocuments
.map((doc) => makePreviewItem(doc))
.filter(Boolean),
[stackDocuments, makePreviewItem],
);
useEffect(() => {
if (!ensureAssetUrl) {
return;
}
stackDocuments.forEach((doc) => {
resolveDocumentAssetUrl(doc, 'preview', {
ensureAssetUrl, ensureAssetUrl,
getAsset: getDocumentAsset, getAsset: getDocumentAsset,
prefetch: 3,
}); });
});
}, [stackDocuments, ensureAssetUrl, getDocumentAsset]); const singlePreviewItems = useMemo(() => {
if (!singleDoc) return [];
const url = singlePreviewNavigator.currentUrl;
if (!url) {
return [];
}
const orientation = derivePreviewOrientation(singlePreviewNavigator.currentMetadata);
return [
{
id: singleDoc.id,
url,
orientation,
alt: singleDoc.title || singleDoc.original_name || 'Document preview',
},
];
}, [singleDoc, singlePreviewNavigator.currentUrl, singlePreviewNavigator.currentMetadata]);
const stackPreviews = useMemo(() => {
if (!stackDocuments.length) {
return [];
}
return stackDocuments
.map((doc) => {
if (!doc) return null;
if (stackTopDocument && doc.id === stackTopDocument.id) {
const url = stackPreviewNavigator.currentUrl;
if (!url) {
return null;
}
const orientation = derivePreviewOrientation(stackPreviewNavigator.currentMetadata);
return {
id: doc.id,
url,
orientation,
alt: doc.title || doc.original_name || 'Document preview',
};
}
return makePreviewItem(doc, 1);
})
.filter(Boolean);
}, [
stackDocuments,
stackTopDocument,
stackPreviewNavigator.currentUrl,
stackPreviewNavigator.currentMetadata,
makePreviewItem,
]);
const singleCardinality = singlePreviewNavigator.cardinality;
const singleEffectiveCardinality = singleCardinality || (singlePreviewNavigator.currentUrl ? 1 : 0);
const singleHasPreview = Boolean(singlePreviewNavigator.currentUrl);
const topDocId = stackTopDocument?.id || null;
const topCardinality = stackPreviewNavigator.cardinality;
const topEffectiveCardinality = topCardinality || (stackPreviewNavigator.currentUrl ? 1 : 0);
const topHasPreview = Boolean(stackPreviewNavigator.currentUrl);
const topOrdinal = stackPreviewNavigator.ordinal;
const topCanGoPrev = stackPreviewNavigator.canGoPrev;
const topCanGoNext = stackPreviewNavigator.canGoNext;
const bulkTagUnion = useMemo(() => { const bulkTagUnion = useMemo(() => {
if (!selectedDocuments.length) return []; if (!selectedDocuments.length) return [];
@@ -633,15 +746,178 @@ const DetailPanel = ({
[bulkCorrespondents, onBulkCorrespondentRemove, onCorrespondentRemove, selectedDocuments], [bulkCorrespondents, onBulkCorrespondentRemove, onCorrespondentRemove, selectedDocuments],
); );
const openZoomPreview = useCallback((config) => {
if (!config) return;
setZoomedPreview({
mode: config.mode,
docId: config.docId ?? null,
});
}, []);
const closeZoomPreview = useCallback(() => {
setZoomedPreview(null);
}, []);
const handleSingleZoom = useCallback(
(entry) => {
if (!singleHasPreview) return;
const targetId = entry?.id ?? singleDocId;
if (!targetId) return;
openZoomPreview({ mode: 'single', docId: targetId });
},
[openZoomPreview, singleHasPreview, singleDocId],
);
const handleStackZoom = useCallback(
(entry) => {
if (!stackTopDocId || entry?.id !== stackTopDocId) return;
if (!topHasPreview) return;
openZoomPreview({ mode: 'stack', docId: stackTopDocId });
},
[openZoomPreview, stackTopDocId, topHasPreview],
);
const zoomDisplay = useMemo(() => {
if (!zoomedPreview) {
return null;
}
if (
zoomedPreview.mode === 'single' &&
singleDocId &&
singleDoc &&
singleHasPreview &&
zoomedPreview.docId === singleDocId
) {
return {
url: singlePreviewNavigator.currentUrl,
alt: singleDoc.title || singleDoc.original_name || 'Document preview',
canGoPrev:
singleEffectiveCardinality > 1 && Boolean(singlePreviewNavigator.canGoPrev),
canGoNext:
singleEffectiveCardinality > 1 && Boolean(singlePreviewNavigator.canGoNext),
goPrev: singlePreviewNavigator.goPrev,
goNext: singlePreviewNavigator.goNext,
};
}
if (
zoomedPreview.mode === 'stack' &&
stackTopDocId &&
stackTopDocument &&
zoomedPreview.docId === stackTopDocId &&
topHasPreview
) {
return {
url: stackPreviewNavigator.currentUrl,
alt: stackTopDocument.title || stackTopDocument.original_name || 'Document preview',
canGoPrev:
topEffectiveCardinality > 1 && Boolean(stackPreviewNavigator.canGoPrev),
canGoNext:
topEffectiveCardinality > 1 && Boolean(stackPreviewNavigator.canGoNext),
goPrev: stackPreviewNavigator.goPrev,
goNext: stackPreviewNavigator.goNext,
};
}
return null;
}, [
zoomedPreview,
singleDoc,
singleDocId,
singleHasPreview,
singlePreviewNavigator.currentUrl,
singlePreviewNavigator.canGoPrev,
singlePreviewNavigator.canGoNext,
singlePreviewNavigator.goPrev,
singlePreviewNavigator.goNext,
singleEffectiveCardinality,
stackTopDocument,
stackTopDocId,
topHasPreview,
stackPreviewNavigator.currentUrl,
stackPreviewNavigator.canGoPrev,
stackPreviewNavigator.canGoNext,
stackPreviewNavigator.goPrev,
stackPreviewNavigator.goNext,
topEffectiveCardinality,
]);
useEffect(() => {
if (zoomedPreview && !zoomDisplay) {
setZoomedPreview(null);
}
}, [zoomedPreview, zoomDisplay]);
useEffect(() => {
if (typeof ensureAssetUrl !== 'function') {
return;
}
const warmNavigator = (navigator) => {
const {
documentId,
asset,
ordinal,
canGoPrev,
canGoNext,
cardinality,
} = navigator;
if (!documentId || !asset || !Number.isFinite(ordinal)) {
return;
}
const requests = [];
if (canGoPrev) {
const prevOrdinal = Math.max(1, ordinal - 1);
if (!cardinality || prevOrdinal <= cardinality) {
requests.push(
ensureAssetUrl(documentId, asset, {
start: prevOrdinal,
limit: 1,
objectOrdinal: prevOrdinal,
}),
);
}
}
if (canGoNext) {
const nextOrdinal = ordinal + 1;
if (!cardinality || nextOrdinal <= cardinality) {
requests.push(
ensureAssetUrl(documentId, asset, {
start: nextOrdinal,
limit: 1,
objectOrdinal: nextOrdinal,
}),
);
}
}
requests.forEach((promise) => promise?.catch?.(() => {}));
};
warmNavigator(singlePreviewNavigator);
warmNavigator(stackPreviewNavigator);
}, [
ensureAssetUrl,
singlePreviewNavigator.documentId,
singlePreviewNavigator.asset,
singlePreviewNavigator.ordinal,
singlePreviewNavigator.canGoPrev,
singlePreviewNavigator.canGoNext,
stackPreviewNavigator.documentId,
stackPreviewNavigator.asset,
stackPreviewNavigator.ordinal,
stackPreviewNavigator.canGoPrev,
stackPreviewNavigator.canGoNext,
]);
const renderSingle = () => { const renderSingle = () => {
if (!singleDoc) { if (!singleDoc) {
return <p className="meta">Select a document to view metadata, tags and actions.</p>; return <p className="meta">Select a document to view metadata, tags and actions.</p>;
} }
const displayName = singleDoc.title || singleDoc.original_name; const displayName = singleDoc.title || singleDoc.original_name;
const downloadHref = singleDoc.current_version?.download_path
? resolveApiPath?.(singleDoc.current_version.download_path)
: null;
const isEditingTitle = titleEditDocId === singleDoc.id; const isEditingTitle = titleEditDocId === singleDoc.id;
const sizeBytes = Number(singleDoc.current_version?.size_bytes) || 0; const sizeBytes = Number(singleDoc.current_version?.size_bytes) || 0;
const sizeLabel = sizeBytes > 0 ? formatFileSize(sizeBytes) : '—'; const sizeLabel = sizeBytes > 0 ? formatFileSize(sizeBytes) : '—';
@@ -649,11 +925,27 @@ const DetailPanel = ({
? new Date(singleDoc.issued_at).toLocaleString() ? new Date(singleDoc.issued_at).toLocaleString()
: '—'; : '—';
const tagsForDoc = Array.isArray(singleDoc.tags) ? singleDoc.tags : []; const tagsForDoc = Array.isArray(singleDoc.tags) ? singleDoc.tags : [];
const pageCountRaw = singleDoc.current_version?.metadata?.page_count;
const pageCountValue =
typeof pageCountRaw === 'number'
? pageCountRaw
: pageCountRaw != null && pageCountRaw !== ''
? Number.parseInt(pageCountRaw, 10)
: null;
const hasPageCount = Number.isFinite(pageCountValue) && pageCountValue >= 0;
const metadata = const metadata =
singleDoc.metadata && Object.keys(singleDoc.metadata).length > 0 ? singleDoc.metadata : null; singleDoc.metadata && Object.keys(singleDoc.metadata).length > 0 ? singleDoc.metadata : null;
const effectiveCardinality = singleEffectiveCardinality;
const canGoPrev = singlePreviewNavigator.canGoPrev;
const canGoNext = singlePreviewNavigator.canGoNext;
const hasPreviewImage = singleHasPreview;
const interceptNavPointer = (event) => {
event.preventDefault();
event.stopPropagation();
};
return ( return (
<> <>
<div>
<div className="preview-pane preview-pane--stack"> <div className="preview-pane preview-pane--stack">
<PreviewStack <PreviewStack
items={singlePreviewItems} items={singlePreviewItems}
@@ -661,9 +953,47 @@ const DetailPanel = ({
emptyMessage="Preview loading…" emptyMessage="Preview loading…"
onItemActivate={handlePreviewActivate} onItemActivate={handlePreviewActivate}
onOpenPreview={onOpenPreview} onOpenPreview={onOpenPreview}
onZoomPreview={handleSingleZoom}
activeItemId={activePreviewId} activeItemId={activePreviewId}
/> />
{hasPreviewImage && (effectiveCardinality > 1 || canGoPrev || canGoNext) ? (
<div className="preview-pane__nav preview-pane__nav--overlay">
<button
type="button"
className="preview-pane__nav-button preview-pane__nav-button--prev"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
singlePreviewNavigator.goPrev();
}}
onPointerDown={interceptNavPointer}
onPointerUp={interceptNavPointer}
onMouseDown={interceptNavPointer}
onMouseUp={interceptNavPointer}
disabled={!canGoPrev}
aria-label="Previous preview"
>
<ArrowLeftIcon />
</button>
<button
type="button"
className="preview-pane__nav-button preview-pane__nav-button--next"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
singlePreviewNavigator.goNext();
}}
onPointerDown={interceptNavPointer}
onPointerUp={interceptNavPointer}
onMouseDown={interceptNavPointer}
onMouseUp={interceptNavPointer}
disabled={!canGoNext}
aria-label="Next preview"
>
<ArrowRightIcon />
</button>
</div> </div>
) : null}
</div> </div>
<div className="doc-title-row"> <div className="doc-title-row">
{isEditingTitle ? ( {isEditingTitle ? (
@@ -728,49 +1058,16 @@ const DetailPanel = ({
<div> <div>
<strong>Issued:</strong> {issuedAt} <strong>Issued:</strong> {issuedAt}
</div> </div>
{hasPageCount ? (
<div>
<strong>Pages:</strong> {pageCountValue}
</div>
) : null}
<div> <div>
<strong>Original filename:</strong>{' '} <strong>Original filename:</strong>{' '}
{singleDoc.original_name} {singleDoc.original_name}
</div> </div>
</div> </div>
<div className="detail-actions">
<a
className="button-link with-icon"
href={downloadHref || '#'}
target="_blank"
rel="noopener noreferrer"
aria-disabled={!downloadHref}
onClick={(event) => {
if (!downloadHref) {
event.preventDefault();
}
}}
>
<DownloadIcon className="icon-inline" />
<span>Download</span>
</a>
<button
type="button"
className="secondary"
onClick={() => onOpenPreview(singleDoc.id)}
>
Open preview
</button>
<button
type="button"
className="secondary"
onClick={() => onRegenerateThumbnails(singleDoc.id)}
>
Re-run analysis
</button>
</div>
{hasOcrAsset ? (
<div className="detail-ocr-trigger">
<button type="button" className="secondary" onClick={openOcrModal}>
View OCR text
</button>
</div>
) : null}
<TagSection <TagSection
title="Tags" title="Tags"
tags={tagsForDoc.map((tag) => ({ tags={tagsForDoc.map((tag) => ({
@@ -807,7 +1104,7 @@ const DetailPanel = ({
{metadata && ( {metadata && (
<div> <div>
<dt>Metadata</dt> <dt>Metadata</dt>
<pre>{JSON.stringify(metadata, null, 2)}</pre> <pre className="detail-metadata__block">{JSON.stringify(metadata, null, 2)}</pre>
</div> </div>
)} )}
{hasOcrAsset && ocrOpen {hasOcrAsset && ocrOpen
@@ -858,19 +1155,67 @@ const DetailPanel = ({
const renderBulk = () => { const renderBulk = () => {
const countLabel = `${selectedCount} document${selectedCount === 1 ? '' : 's'}`; const countLabel = `${selectedCount} document${selectedCount === 1 ? '' : 's'}`;
const sizeLabel = stackTotalSizeBytes ? formatFileSize(stackTotalSizeBytes) : '—'; const sizeLabel = stackTotalSizeBytes ? formatFileSize(stackTotalSizeBytes) : '—';
const topDoc = stackTopDocument;
const topDocIdLocal = topDocId;
const topOrdinal = stackPreviewNavigator.ordinal;
const topCardinalityLocal = topEffectiveCardinality;
const topHasPreview = Boolean(stackPreviewNavigator.currentUrl);
const topCanGoPrev = stackPreviewNavigator.canGoPrev;
const topCanGoNext = stackPreviewNavigator.canGoNext;
const interceptTopNavPointer = (event) => {
event.preventDefault();
event.stopPropagation();
};
return ( return (
<> <>
<div>
<div className="preview-pane preview-pane--stack"> <div className="preview-pane preview-pane--stack">
<PreviewStack <PreviewStack
items={stackPreviews} items={stackPreviews}
emptyMessage="No previews available." emptyMessage="No previews available."
onItemActivate={handlePreviewActivate} onItemActivate={handlePreviewActivate}
onOpenPreview={onOpenPreview} onOpenPreview={onOpenPreview}
onZoomPreview={handleStackZoom}
activeItemId={activePreviewId} activeItemId={activePreviewId}
/> />
{topDocIdLocal && topHasPreview && (topCardinalityLocal > 1 || topCanGoPrev || topCanGoNext) ? (
<div className="preview-pane__nav preview-pane__nav--overlay">
<button
type="button"
className="preview-pane__nav-button preview-pane__nav-button--prev"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
stackPreviewNavigator.goPrev();
}}
onPointerDown={interceptTopNavPointer}
onPointerUp={interceptTopNavPointer}
onMouseDown={interceptTopNavPointer}
onMouseUp={interceptTopNavPointer}
disabled={!topCanGoPrev}
aria-label="Previous preview"
>
<ArrowLeftIcon />
</button>
<button
type="button"
className="preview-pane__nav-button preview-pane__nav-button--next"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
stackPreviewNavigator.goNext();
}}
onPointerDown={interceptTopNavPointer}
onPointerUp={interceptTopNavPointer}
onMouseDown={interceptTopNavPointer}
onMouseUp={interceptTopNavPointer}
disabled={!topCanGoNext}
aria-label="Next preview"
>
<ArrowRightIcon />
</button>
</div> </div>
) : null}
</div> </div>
<h3 style={{ margin: 0 }}>{countLabel}</h3> <h3 style={{ margin: 0 }}>{countLabel}</h3>
<div className="meta"> <div className="meta">
@@ -903,23 +1248,110 @@ const DetailPanel = ({
showCount showCount
className="bulk-correspondents" className="bulk-correspondents"
/> />
<button
type="button"
className="secondary"
onClick={() => onBulkReanalyze?.()}
>
Re-analyze selection
</button>
</> </>
); );
}; };
const isBulkSelection = selectedCount > 1;
const showOcrAction = Boolean(singleDoc && hasOcrAsset);
return ( return (
<aside className="detail-panel column"> <>
<div className="column-body scrollable"> <aside className="detail-panel panel">
<div className="panel-header">
<div className="panel-actions">
<button
type="button"
className="icon-button ghost"
onClick={onClose}
aria-label="Close detail panel"
title="Close detail panel"
>
<ChevronsRightIcon />
</button>
<div className="spacer" />
{isBulkSelection && onBulkReanalyze ? (
<button
type="button"
className="icon-button ghost"
onClick={(event) => {
event.stopPropagation();
onBulkReanalyze();
}}
aria-label="Re-run analysis for selection"
title="Re-run analysis for selection"
>
<AnalyzeIcon />
</button>
) : null}
{singleDoc && singleDownloadHref ? (
<a
className="icon-button"
href={singleDownloadHref}
target="_blank"
rel="noopener noreferrer"
aria-label="Download document"
title="Download document"
onClick={(event) => event.stopPropagation()}
>
<DownloadIcon />
</a>
) : null}
{singleDoc ? (
<button
type="button"
className="icon-button ghost"
onClick={(event) => {
event.stopPropagation();
onOpenPreview(singleDoc.id);
}}
aria-label="Open preview"
title="Open preview"
disabled={!singleHasPreview}
>
<WindowMaximizeIcon />
</button>
) : null}
{showOcrAction ? (
<button
type="button"
className="icon-button ghost"
onClick={(event) => {
event.stopPropagation();
openOcrModal();
}}
aria-label="View OCR text"
title="View OCR text"
>
<TextScanIcon />
</button>
) : null}
{singleDoc ? (
<button
type="button"
className="icon-button ghost"
onClick={(event) => {
event.stopPropagation();
onRegenerateThumbnails(singleDoc.id);
}}
aria-label="Re-run analysis"
title="Re-run analysis"
>
<AnalyzeIcon />
</button>
) : null}
</div>
</div>
<div className="panel-body">
{selectedCount <= 1 ? renderSingle() : renderBulk()} {selectedCount <= 1 ? renderSingle() : renderBulk()}
</div> </div>
</aside> </aside>
<PreviewZoomOverlay
open={Boolean(zoomDisplay?.url)}
display={zoomDisplay}
onClose={closeZoomPreview}
/>
</>
); );
}; };
+262
View File
@@ -0,0 +1,262 @@
import React, { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { ArrowLeftIcon, ArrowRightIcon } from '../ui/icons';
const noop = () => {};
const clamp = (value, min, max) => {
if (value < min) return min;
if (value > max) return max;
return value;
};
const ensureDocumentRoot = () => {
if (typeof document === 'undefined') {
return null;
}
return document.body;
};
const PreviewZoomOverlay = ({
open = false,
display = null,
onClose = noop,
}) => {
const portalTarget = ensureDocumentRoot();
const [isNativeScale, setIsNativeScale] = useState(false);
const [naturalSize, setNaturalSize] = useState({ width: null, height: null });
const scrollRef = useRef(null);
const imageRef = useRef(null);
const focusRef = useRef(null);
const previouslyFocusedRef = useRef(null);
useEffect(() => {
setIsNativeScale(false);
setNaturalSize({ width: null, height: null });
focusRef.current = null;
const scrollEl = scrollRef.current;
if (scrollEl) {
scrollEl.scrollLeft = 0;
scrollEl.scrollTop = 0;
}
}, [open]);
useEffect(() => {
if (!open || !isNativeScale) {
return;
}
const scrollEl = scrollRef.current;
const imageEl = imageRef.current;
if (!scrollEl || !imageEl) {
return;
}
const imageWidth = imageEl.naturalWidth || imageEl.clientWidth;
const imageHeight = imageEl.naturalHeight || imageEl.clientHeight;
if (!(imageWidth > 0 && imageHeight > 0)) {
return;
}
const target = focusRef.current || { xRatio: 0.5, yRatio: 0.5 };
const maxScrollLeft = Math.max(0, imageWidth - scrollEl.clientWidth);
const maxScrollTop = Math.max(0, imageHeight - scrollEl.clientHeight);
const desiredLeft = target.xRatio * imageWidth - scrollEl.clientWidth / 2;
const desiredTop = target.yRatio * imageHeight - scrollEl.clientHeight / 2;
scrollEl.scrollLeft = clamp(desiredLeft, 0, maxScrollLeft);
scrollEl.scrollTop = clamp(desiredTop, 0, maxScrollTop);
}, [open, isNativeScale, naturalSize.width, naturalSize.height]);
useEffect(() => {
if (!open) {
if (previouslyFocusedRef.current && typeof previouslyFocusedRef.current.focus === 'function') {
previouslyFocusedRef.current.focus();
}
previouslyFocusedRef.current = null;
return undefined;
}
if (typeof document !== 'undefined') {
const active = document.activeElement;
if (active && typeof active.focus === 'function') {
previouslyFocusedRef.current = active;
} else {
previouslyFocusedRef.current = null;
}
}
const scrollEl = scrollRef.current;
if (!scrollEl) {
return undefined;
}
const frame = requestAnimationFrame(() => {
scrollEl.focus();
});
return () => {
cancelAnimationFrame(frame);
if (previouslyFocusedRef.current && typeof previouslyFocusedRef.current.focus === 'function') {
previouslyFocusedRef.current.focus();
previouslyFocusedRef.current = null;
}
};
}, [open]);
const handleKeyDown = (event) => {
event.stopPropagation();
if (!open) {
return;
}
if (event.key === 'Escape') {
event.preventDefault();
onClose();
return;
}
if (event.key === 'ArrowLeft') {
if (display?.canGoPrev && display?.goPrev) {
event.preventDefault();
display.goPrev();
}
return;
}
if (event.key === 'ArrowRight') {
if (display?.canGoNext && display?.goNext) {
event.preventDefault();
display.goNext();
}
}
};
if (!open || !display?.url || !portalTarget) {
return null;
}
const navVisible = Boolean(display?.canGoPrev || display?.canGoNext);
const stageClassName = [
'preview-zoom__stage',
]
.filter(Boolean)
.join(' ');
const containerClassName = [
'preview-zoom__scroll',
isNativeScale ? 'preview-zoom__scroll--native' : '',
]
.filter(Boolean)
.join(' ');
const imageStyle = isNativeScale
? {
cursor: 'zoom-out',
width: naturalSize.width ? `${naturalSize.width}px` : 'auto',
height: naturalSize.height ? `${naturalSize.height}px` : 'auto',
maxWidth: 'none',
maxHeight: 'none',
}
: {
cursor: 'zoom-in',
maxWidth: '95vw',
maxHeight: '95vh',
};
return createPortal(
(
<div
className="preview-zoom-backdrop"
role="dialog"
aria-modal="true"
aria-label="Enlarged document preview"
onClick={onClose}
>
<div
className={stageClassName}
onClick={(event) => event.stopPropagation()}
onKeyDown={handleKeyDown}
>
<div
className={containerClassName}
ref={scrollRef}
tabIndex={-1}
>
<img
src={display.url}
alt={display.alt || 'Document preview'}
className="preview-zoom__image"
ref={imageRef}
draggable={false}
onLoad={(event) => {
setNaturalSize({
width: event.currentTarget.naturalWidth || null,
height: event.currentTarget.naturalHeight || null,
});
}}
onClick={(event) => {
event.stopPropagation();
if (!isNativeScale) {
const img = imageRef.current;
if (img) {
const rect = img.getBoundingClientRect();
const xRatio = rect.width > 0 ? (event.clientX - rect.left) / rect.width : 0.5;
const yRatio = rect.height > 0 ? (event.clientY - rect.top) / rect.height : 0.5;
focusRef.current = {
xRatio: clamp(xRatio, 0, 1),
yRatio: clamp(yRatio, 0, 1),
};
} else {
focusRef.current = null;
}
} else {
focusRef.current = null;
}
setIsNativeScale((current) => !current);
}}
style={imageStyle}
/>
</div>
{navVisible ? (
<div className="preview-zoom__nav">
<button
type="button"
className="preview-zoom__nav-button"
onClick={(event) => {
event.stopPropagation();
if (display?.canGoPrev && display?.goPrev) {
display.goPrev();
}
}}
aria-label="Previous preview"
disabled={!display?.canGoPrev}
>
<ArrowLeftIcon />
</button>
<button
type="button"
className="preview-zoom__nav-button"
onClick={(event) => {
event.stopPropagation();
if (display?.canGoNext && display?.goNext) {
display.goNext();
}
}}
aria-label="Next preview"
disabled={!display?.canGoNext}
>
<ArrowRightIcon />
</button>
</div>
) : null}
</div>
</div>
),
portalTarget,
);
};
export default PreviewZoomOverlay;
+226 -114
View File
@@ -1,29 +1,126 @@
import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { resolveDocumentAssetUrl } from '../asset_manager'; import { getAssetFromVersion, resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
import { getTagColorStyle } from '../utils/colors'; import { getTagColorStyle } from '../utils/colors';
import { DownloadIcon, EditIcon, ViewListIcon, ViewGridIcon } from '../ui/icons'; import { DownloadIcon, EditIcon, ViewListIcon, ViewGridIcon, FolderIcon, TrashIcon } from '../ui/icons';
const FOLDER_ICON_SRC = '/folder.svg';
const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag']; const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
const DEFAULT_GRID_ICON_SIZE = 96;
const DEFAULT_GRID_TITLE_SIZE = '11px';
const LIST_ICON_SIZE = 48;
const DocumentThumbnailImage = ({ document, ensureAssetUrl, getDocumentAsset, alt }) => { const getPageCount = (doc) =>
const url = useMemo( Number.isFinite(doc?.current_version?.metadata?.page_count)
() => ? doc.current_version.metadata.page_count
resolveDocumentAssetUrl(document, 'thumbnail', { : null;
ensureAssetUrl,
getAsset: getDocumentAsset, // Detects when an element becomes visible within a scroll container.
}), const useLazyVisibility = (rootRef, resetKey) => {
[document, ensureAssetUrl, getDocumentAsset], const targetRef = useRef(null);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
setIsVisible(false);
}, [resetKey]);
const rootNode = rootRef?.current || null;
useEffect(() => {
if (isVisible) {
return undefined;
}
const element = targetRef.current;
if (!element) {
return undefined;
}
if (typeof window === 'undefined' || typeof IntersectionObserver === 'undefined') {
setIsVisible(true);
return undefined;
}
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setIsVisible(true);
observer.disconnect();
}
});
},
{
root: rootNode,
rootMargin: '200px 0px',
threshold: 0.01,
},
); );
observer.observe(element);
return () => observer.disconnect();
}, [isVisible, rootNode, resetKey]);
return { ref: targetRef, isVisible };
};
const DocumentThumbnailImage = ({
document,
ensureAssetUrl,
getDocumentAsset,
alt,
maxSize = LIST_ICON_SIZE,
scrollRootRef = null,
}) => {
const { ref: visibilityRef, isVisible } = useLazyVisibility(scrollRootRef, document?.id);
const resolvedMaxSize = Math.max(1, Math.round(maxSize || 1));
const thumbnailAsset = useMemo(
() => getAssetFromVersion(document?.current_version, 'thumbnail'),
[document?.current_version],
);
const thumbnailView = useMemo(() => createAssetView(thumbnailAsset), [thumbnailAsset]);
const primaryMetadata = thumbnailView.getPrimaryMetadata() || {};
const assetWidth = Number(primaryMetadata?.width);
const assetHeight = Number(primaryMetadata?.height);
const dimensions = useMemo(() => {
if (!Number.isFinite(assetWidth) || assetWidth <= 0 || !Number.isFinite(assetHeight) || assetHeight <= 0) {
return { width: resolvedMaxSize, height: resolvedMaxSize };
}
const scale = Math.min(1, resolvedMaxSize / assetWidth, resolvedMaxSize / assetHeight);
return {
width: Math.max(1, Math.round(assetWidth * scale)),
height: Math.max(1, Math.round(assetHeight * scale)),
};
}, [assetWidth, assetHeight, resolvedMaxSize]);
const innerStyle = useMemo(
() => ({ width: `${dimensions.width}px`, height: `${dimensions.height}px` }),
[dimensions.height, dimensions.width],
);
const url = useMemo(() => {
if (!isVisible) {
return null;
}
return resolveDocumentAssetUrl(document, 'thumbnail', {
ensureAssetUrl,
getAsset: getDocumentAsset,
});
}, [document, ensureAssetUrl, getDocumentAsset, isVisible]);
const pageCount = getPageCount(document);
const showMultiPageBadge = Number.isFinite(pageCount) && pageCount > 1;
const innerClasses = ['document-thumbnail-inner'];
if (showMultiPageBadge) {
innerClasses.push('document-thumbnail-inner--multipage');
}
return ( return (
<div className="document-thumbnail-wrapper"> <div className="document-thumbnail-wrapper" ref={visibilityRef}>
<div className={innerClasses.join(' ')} style={innerStyle}>
{url ? ( {url ? (
<img <img
src={url} src={url}
alt={alt || ''} alt={alt || ''}
className="document-thumbnail" className="document-thumbnail"
loading="lazy"
decoding="async"
draggable={false} draggable={false}
onDragStart={(event) => event.preventDefault()} onDragStart={(event) => event.preventDefault()}
/> />
@@ -31,6 +128,7 @@ const DocumentThumbnailImage = ({ document, ensureAssetUrl, getDocumentAsset, al
<div className="thumb-placeholder">DOC</div> <div className="thumb-placeholder">DOC</div>
)} )}
</div> </div>
</div>
); );
}; };
@@ -79,6 +177,7 @@ const DocumentsTable = ({
viewMode = 'list', viewMode = 'list',
onViewModeChange, onViewModeChange,
onClearSelection, onClearSelection,
showHeader = true,
}) => { }) => {
const showingSearchResults = searchResults !== null; const showingSearchResults = searchResults !== null;
const rows = showingSearchResults ? searchResults : documents; const rows = showingSearchResults ? searchResults : documents;
@@ -96,7 +195,20 @@ const DocumentsTable = ({
[draggingDocumentIds], [draggingDocumentIds],
); );
const scrollRef = useRef(null); const scrollRef = useRef(null);
const [, forceVisibilityTick] = useState(0);
const lastScrollNodeRef = useRef(null);
const assignScrollRef = useCallback((node) => {
if (lastScrollNodeRef.current === node) {
return;
}
lastScrollNodeRef.current = node;
scrollRef.current = node;
if (node) {
forceVisibilityTick((value) => value + 1);
}
}, []);
const isGridView = viewMode === 'grid'; const isGridView = viewMode === 'grid';
const gridIconSize = DEFAULT_GRID_ICON_SIZE;
const handleSetViewMode = useCallback( const handleSetViewMode = useCallback(
(nextMode) => { (nextMode) => {
if (!onViewModeChange) { if (!onViewModeChange) {
@@ -109,6 +221,11 @@ const DocumentsTable = ({
}, },
[onViewModeChange], [onViewModeChange],
); );
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = 0;
}
}, [viewMode]);
const isTagDragEvent = useCallback((event) => { const isTagDragEvent = useCallback((event) => {
const types = Array.from(event.dataTransfer?.types || []); const types = Array.from(event.dataTransfer?.types || []);
return TAG_MIME_TYPES.some((type) => types.includes(type)); return TAG_MIME_TYPES.some((type) => types.includes(type));
@@ -216,47 +333,23 @@ const DocumentsTable = ({
[isTagDragEvent, onDocumentTagDrop], [isTagDragEvent, onDocumentTagDrop],
); );
const handleGridBackgroundClick = useCallback( const showDefaultEmptyState = !showingSearchResults && !subfolders.length && rows.length === 0;
(event) => { const showListSearchEmptyState =
if (event.target !== event.currentTarget) { showingSearchResults && rows.length === 0 && !isGridView && !isSearchLoading;
return; const showGridSearchEmptyState =
} isGridView && showingSearchResults && rows.length === 0 && !isSearchLoading;
onClearSelection?.(); const showSearchHint = showingSearchResults && rows.length > 0;
},
[onClearSelection],
);
return ( return (
<section <section
className={`documents-panel column documents-panel--view-${isGridView ? 'grid' : 'list'}`} className={`documents-panel documents-panel--view-${isGridView ? 'grid' : 'list'}`}
> >
<div className="column-header"> {showHeader ? (
<div className="column-header__titles"> <div className="panel-section__header">
<nav className="breadcrumb" aria-label="Folder breadcrumbs"> <div className="panel-section__titles">
{breadcrumbs.map((crumb, index) => { <h2>{currentFolderName}</h2>
const isLast = index === breadcrumbs.length - 1;
return (
<span key={crumb.id} className="breadcrumb-item">
{isLast ? (
<span className="breadcrumb-current">{crumb.name}</span>
) : (
<a
href="#"
onClick={(event) => {
event.preventDefault();
onFolderSelect(crumb.id);
}}
>
{crumb.name}
</a>
)}
{!isLast && <span className="breadcrumb-separator"></span>}
</span>
);
})}
</nav>
{showingSearchResults && ( {showingSearchResults && (
<div className="column-subtitle">Search results</div> <div className="panel-section__subtitle">Search results</div>
)} )}
</div> </div>
<div className="header-actions"> <div className="header-actions">
@@ -295,11 +388,21 @@ const DocumentsTable = ({
</button> </button>
</div> </div>
</div> </div>
<div className="column-body"> ) : null}
{showDefaultEmptyState && (
<div className="empty-state">
Drop files anywhere or onto a folder to upload documents.
</div>
)}
{showGridSearchEmptyState && (
<div className="empty-state empty-state--global">
No documents match the current filters.
</div>
)}
<div className="panel-section__body">
<div <div
ref={scrollRef} ref={assignScrollRef}
className="documents-scroll" className="documents-scroll"
tabIndex={0}
onFocus={(event) => { onFocus={(event) => {
if (event.target === scrollRef.current) { if (event.target === scrollRef.current) {
onDocumentListFocus?.(); onDocumentListFocus?.();
@@ -313,17 +416,23 @@ const DocumentsTable = ({
onDocumentListKeyDown(event); onDocumentListKeyDown(event);
} }
}} }}
onClick={(event) => {
if (event.target === event.currentTarget) {
onClearSelection?.();
}
}}
aria-activedescendant={isGridView ? undefined : activeDescendantId} aria-activedescendant={isGridView ? undefined : activeDescendantId}
> >
{!showingSearchResults && !subfolders.length && rows.length === 0 ? ( {showDefaultEmptyState ? null : isGridView ? (
<div className="empty-state">
Drop files anywhere or onto a folder to upload documents.
</div>
) : isGridView ? (
<div <div
className="documents-grid" className="documents-grid"
role="list" role="list"
onClick={handleGridBackgroundClick} onClick={(event) => {
if (event.target === event.currentTarget) {
onClearSelection?.();
}
}}
style={{ '--documents-grid-icon-size': `${gridIconSize}px` }}
> >
{!showingSearchResults && {!showingSearchResults &&
subfolders.map((folder) => { subfolders.map((folder) => {
@@ -370,10 +479,9 @@ const DocumentsTable = ({
}} }}
> >
<div className="folder-card__icon"> <div className="folder-card__icon">
<img <FolderIcon
src={FOLDER_ICON_SRC}
alt="Folder"
className="folder-card__icon-svg" className="folder-card__icon-svg"
size={gridIconSize}
/> />
</div> </div>
<div className="folder-card__meta"> <div className="folder-card__meta">
@@ -399,6 +507,7 @@ const DocumentsTable = ({
className={cardClasses.join(' ')} className={cardClasses.join(' ')}
role="listitem" role="listitem"
id={`document-card-${doc.id}`} id={`document-card-${doc.id}`}
data-doc-id={doc.id}
onClick={(event) => onDocumentRowClick(doc.id, event)} onClick={(event) => onDocumentRowClick(doc.id, event)}
onDoubleClick={() => onDocumentOpen(doc.id)} onDoubleClick={() => onDocumentOpen(doc.id)}
draggable draggable
@@ -416,6 +525,8 @@ const DocumentsTable = ({
ensureAssetUrl={ensureAssetUrl} ensureAssetUrl={ensureAssetUrl}
getDocumentAsset={getDocumentAsset} getDocumentAsset={getDocumentAsset}
alt={`Thumbnail for ${doc.title || doc.original_name}`} alt={`Thumbnail for ${doc.title || doc.original_name}`}
maxSize={gridIconSize}
scrollRootRef={scrollRef}
/> />
<div className="document-card__meta"> <div className="document-card__meta">
<div <div
@@ -442,7 +553,6 @@ const DocumentsTable = ({
} }
}} }}
role="button" role="button"
tabIndex={0}
draggable draggable
onDragStart={(event) => { onDragStart={(event) => {
event.stopPropagation(); event.stopPropagation();
@@ -550,23 +660,29 @@ const DocumentsTable = ({
> >
<td className="thumb-cell"> <td className="thumb-cell">
<div className="thumb-icon"> <div className="thumb-icon">
<img <FolderIcon
src={FOLDER_ICON_SRC}
alt="Folder"
className="thumb-icon__image" className="thumb-icon__image"
size={32}
/> />
</div> </div>
</td> </td>
<td className="doc-list__name"> <td className="doc-list__name">
<div className="doc-list__name-content"> <div className="doc-list__name-content">
<span>{folder.name}</span> <span>{folder.name}</span>
{folder.id !== 'root' && ( </div>
</td>
<td>Folder</td>
<td></td>
<td className="actions">
<div className="action-buttons">
{folder.id !== 'root' && onFolderRename && (
<button <button
type="button" type="button"
className="icon-button ghost doc-list__icon-button" className="icon-button ghost"
title="Rename"
aria-label={`Rename folder ${folder.name}`}
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
if (!onFolderRename) return;
const nextName = window.prompt('Rename folder', folder.name || ''); const nextName = window.prompt('Rename folder', folder.name || '');
if (!nextName) { if (!nextName) {
return; return;
@@ -577,27 +693,23 @@ const DocumentsTable = ({
} }
onFolderRename(folder.id, trimmed); onFolderRename(folder.id, trimmed);
}} }}
title="Rename folder"
aria-label={`Rename folder ${folder.name}`}
> >
<EditIcon className="doc-list__icon" size={16} /> <EditIcon className="icon-inline" />
</button> </button>
)} )}
</div>
</td>
<td>Folder</td>
<td></td>
<td className="actions">
<button <button
type="button" type="button"
className="icon-button danger" className="icon-button danger"
title="Delete"
aria-label={`Delete folder ${folder.name}`}
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
onFolderDelete(folder.id); onFolderDelete(folder.id);
}} }}
> >
Delete <TrashIcon className="icon-inline" />
</button> </button>
</div>
</td> </td>
</tr> </tr>
); );
@@ -615,6 +727,7 @@ const DocumentsTable = ({
key={doc.id} key={doc.id}
className={rowClasses.join(' ')} className={rowClasses.join(' ')}
id={`document-row-${doc.id}`} id={`document-row-${doc.id}`}
data-doc-id={doc.id}
onClick={(event) => onDocumentRowClick(doc.id, event)} onClick={(event) => onDocumentRowClick(doc.id, event)}
onDoubleClick={() => onDocumentOpen(doc.id)} onDoubleClick={() => onDocumentOpen(doc.id)}
draggable draggable
@@ -630,36 +743,13 @@ const DocumentsTable = ({
ensureAssetUrl={ensureAssetUrl} ensureAssetUrl={ensureAssetUrl}
getDocumentAsset={getDocumentAsset} getDocumentAsset={getDocumentAsset}
alt={`Thumbnail for ${doc.title || doc.original_name}`} alt={`Thumbnail for ${doc.title || doc.original_name}`}
scrollRootRef={scrollRef}
/> />
</td> </td>
<td className="doc-list__name"> <td className="doc-list__name">
<div className="doc-name"> <div className="doc-name">
<div className="doc-list__name-content"> <div className="doc-list__name-content">
<span className="doc-name__title">{doc.title || doc.original_name}</span> <span className="doc-name__title">{doc.title || doc.original_name}</span>
<button
type="button"
className="icon-button ghost doc-list__icon-button"
onClick={(event) => {
event.stopPropagation();
if (!onDocumentRename) return;
const nextName = window.prompt(
'Rename document',
doc.title || doc.original_name || '',
);
if (!nextName) {
return;
}
const trimmed = nextName.trim();
if (!trimmed || trimmed === (doc.title || doc.original_name)) {
return;
}
onDocumentRename(doc.id, trimmed);
}}
title="Rename document"
aria-label={`Rename document ${doc.title || doc.original_name}`}
>
<EditIcon className="doc-list__icon" size={16} />
</button>
</div> </div>
{(doc.tags || []).length > 0 && ( {(doc.tags || []).length > 0 && (
<div className="doc-name__tags"> <div className="doc-name__tags">
@@ -679,7 +769,6 @@ const DocumentsTable = ({
} }
}} }}
role="button" role="button"
tabIndex={0}
draggable draggable
onDragStart={(event) => { onDragStart={(event) => {
event.stopPropagation(); event.stopPropagation();
@@ -729,31 +818,59 @@ const DocumentsTable = ({
</td> </td>
<td className="actions"> <td className="actions">
<div className="action-buttons"> <div className="action-buttons">
{onDocumentRename && (
<button
type="button"
className="icon-button ghost"
title="Rename"
aria-label={`Rename document ${doc.title || doc.original_name}`}
onClick={(event) => {
event.stopPropagation();
const nextName = window.prompt(
'Rename document',
doc.title || doc.original_name || '',
);
if (!nextName) {
return;
}
const trimmed = nextName.trim();
if (!trimmed || trimmed === (doc.title || doc.original_name)) {
return;
}
onDocumentRename(doc.id, trimmed);
}}
>
<EditIcon className="icon-inline" />
</button>
)}
{downloadHref ? ( {downloadHref ? (
<a <a
className="button-link with-icon" className="icon-button"
href={downloadHref} href={downloadHref}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
title="Download"
aria-label="Download document"
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
onAuxClick={(event) => event.stopPropagation()} onAuxClick={(event) => event.stopPropagation()}
onContextMenu={(event) => event.stopPropagation()} onContextMenu={(event) => event.stopPropagation()}
> >
<DownloadIcon className="icon-inline" /> <DownloadIcon className="icon-inline" />
<span>Download</span>
</a> </a>
) : ( ) : (
<span className="meta">No download</span> <span className="meta">No download</span>
)} )}
<button <button
type="button" type="button"
className="danger" className="icon-button danger"
title="Delete"
aria-label="Delete document"
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
onDocumentDelete?.(doc.id); onDocumentDelete?.(doc.id);
}} }}
> >
Delete <TrashIcon className="icon-inline" />
</button> </button>
</div> </div>
</td> </td>
@@ -764,20 +881,15 @@ const DocumentsTable = ({
</table> </table>
)} )}
</div> </div>
{rows.length === 0 && showingSearchResults && !isGridView && !isSearchLoading && ( {showListSearchEmptyState && (
<div className="empty-state">No documents match the current filters.</div> <div className="empty-state">No documents match the current filters.</div>
)} )}
{showingSearchResults && rows.length > 0 && ( {showSearchHint && (
<div className="search-hint"> <div className="search-hint">
Showing {rows.length} document{rows.length === 1 ? '' : 's'} in {currentFolderName} and subfolders. Showing {rows.length} document{rows.length === 1 ? '' : 's'} in {currentFolderName} and subfolders.
</div> </div>
)} )}
</div> </div>
{isGridView && showingSearchResults && rows.length === 0 && !isSearchLoading && (
<div className="empty-state empty-state--global">
No documents match the current filters.
</div>
)}
</section> </section>
); );
}; };
+133
View File
@@ -0,0 +1,133 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { createAssetView } from '../asset_manager';
const clampOrdinalValue = (value, cardinality, defaultOrdinal) => {
const raw = Number.isFinite(value) ? value : defaultOrdinal;
let next = Math.max(1, Math.floor(raw));
if (cardinality && cardinality > 0) {
next = Math.min(next, cardinality);
}
return next;
};
export const useAssetNavigator = ({
document,
assetType,
ensureAssetUrl,
getAsset,
prefetch = 2,
defaultOrdinal = 1,
}) => {
const documentId = document?.id || null;
const asset = useMemo(() => {
if (!document || typeof getAsset !== 'function') {
return null;
}
return getAsset(document, assetType);
}, [document, assetType, getAsset]);
const view = useMemo(() => createAssetView(asset), [asset]);
const cardinality = view.getCardinality();
const [ordinal, setOrdinalInternal] = useState(defaultOrdinal);
useEffect(() => {
setOrdinalInternal(defaultOrdinal);
}, [documentId, assetType, defaultOrdinal]);
const setOrdinal = useCallback(
(next) => {
setOrdinalInternal((prev) => {
const target = typeof next === 'function' ? next(prev) : next;
return clampOrdinalValue(target, cardinality, defaultOrdinal);
});
},
[cardinality, defaultOrdinal],
);
const goPrev = useCallback(() => setOrdinal((value) => value - 1), [setOrdinal]);
const goNext = useCallback(() => setOrdinal((value) => value + 1), [setOrdinal]);
const objects = view.getObjects();
const currentObject = view.getObject(ordinal);
const currentUrl = currentObject?.url || view.getPrimaryUrl();
const currentMetadata = currentObject?.metadata || view.getPrimaryMetadata() || null;
const canGoPrev = ordinal > 1;
const canGoNext = cardinality ? ordinal < cardinality : true;
const ordinalsNeedingLoad = useMemo(() => {
const missing = [];
if (!asset) {
return missing;
}
const maxOrdinal = cardinality && cardinality > 0
? Math.min(cardinality, ordinal + Math.max(1, prefetch) - 1)
: ordinal + Math.max(1, prefetch) - 1;
for (let ord = ordinal; ord <= maxOrdinal; ord += 1) {
const object = view.getObject(ord);
if (!object?.url) {
missing.push(ord);
}
}
return missing;
}, [asset, view, ordinal, prefetch, cardinality]);
const fetchStart = ordinalsNeedingLoad.length ? ordinalsNeedingLoad[0] : null;
const fetchEnd = ordinalsNeedingLoad.length ? ordinalsNeedingLoad[ordinalsNeedingLoad.length - 1] : null;
const fetchLimit = fetchStart && fetchEnd ? fetchEnd - fetchStart + 1 : null;
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!documentId || !asset || !ensureAssetUrl) {
setLoading(false);
return;
}
if (!fetchStart || !fetchLimit) {
setLoading(false);
return;
}
let cancelled = false;
setLoading(true);
ensureAssetUrl(documentId, asset, {
start: fetchStart,
limit: fetchLimit,
})
.catch(() => {})
.finally(() => {
if (!cancelled) {
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [documentId, asset, ensureAssetUrl, fetchStart, fetchLimit]);
return {
document,
documentId,
asset,
assetType,
ordinal,
setOrdinal,
goPrev,
goNext,
canGoPrev,
canGoNext,
cardinality,
currentObject,
currentUrl,
currentMetadata,
objects,
isLoading: loading,
};
};
export default useAssetNavigator;
+667 -460
View File
File diff suppressed because it is too large Load Diff
+92
View File
@@ -0,0 +1,92 @@
import React from 'react';
import { DownloadIcon } from '../ui/icons';
import { formatFileSize } from '../utils/format';
const PreviewWorkspace = ({
document,
previewEntry,
resolveApiPath,
onClose,
onRegenerateThumbnails,
}) => {
if (!document) {
return null;
}
const title = document.title || document.original_name || 'Document';
const mime = previewEntry?.contentType || document.content_type || 'application/pdf';
const downloadHref = document.current_version?.download_path
? resolveApiPath(document.current_version.download_path)
: null;
const sizeBytes = Number(document.current_version?.size_bytes) || 0;
const sizeLabel = sizeBytes > 0 ? formatFileSize(sizeBytes) : null;
const metadata =
document.metadata && Object.keys(document.metadata).length > 0 ? document.metadata : null;
return (
<section className="preview-workspace">
<header className="preview-workspace__header">
<div className="preview-workspace__meta">
<button
type="button"
className="secondary"
onClick={() => onClose(document.folder_id ?? 'root')}
>
Back
</button>
<div>
<h2>{title}</h2>
<span className="meta">
{document.content_type || mime}
{sizeLabel ? ` · ${sizeLabel}` : ''}
</span>
</div>
</div>
<div className="preview-workspace__actions">
<a
className="button-link with-icon"
href={downloadHref || '#'}
target="_blank"
rel="noopener noreferrer"
aria-disabled={!downloadHref}
onClick={(event) => {
if (!downloadHref) {
event.preventDefault();
}
}}
>
<DownloadIcon className="icon-inline" />
<span>Download</span>
</a>
<button
type="button"
className="secondary"
onClick={() => onRegenerateThumbnails(document.id)}
>
Re-run analysis
</button>
</div>
</header>
<div className="preview-workspace__body">
{!previewEntry?.url ? (
<div className="preview-workspace__message">Loading preview</div>
) : (
<iframe
src={previewEntry.url}
title={`Preview of ${title}`}
className="preview-workspace__object"
/>
)}
</div>
{metadata && (
<section className="preview-workspace__metadata">
<h3>Metadata</h3>
<pre>{JSON.stringify(metadata, null, 2)}</pre>
</section>
)}
</section>
);
};
export default PreviewWorkspace;
@@ -0,0 +1,72 @@
import React, { useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useAppShell } from '../appShellContext';
import PreviewWorkspace from '../preview/PreviewWorkspace';
const DocumentViewerRoute = () => {
const {
previewWorkspaceDocument,
previewWorkspaceEntry,
closeDocumentPreview,
handleThumbnailRegeneration,
ensurePreviewData,
notifyApiError,
resolveApiPath,
} = useAppShell();
const { documentId } = useParams();
const navigate = useNavigate();
useEffect(() => {
if (!documentId) {
navigate('/documents', { replace: true });
return;
}
let cancelled = false;
const hydrate = async () => {
try {
await ensurePreviewData(documentId);
} catch (error) {
if (cancelled) {
return;
}
notifyApiError(error, 'Failed to open document preview.');
navigate('/documents', { replace: true });
}
};
hydrate();
return () => {
cancelled = true;
};
}, [documentId, ensurePreviewData, notifyApiError, navigate]);
const isReady =
documentId && previewWorkspaceDocument && previewWorkspaceDocument.id === documentId;
if (!isReady) {
return (
<main className="preview-main">
<div className="preview-workspace__message">Loading preview</div>
</main>
);
}
return (
<main className="preview-main">
<PreviewWorkspace
document={previewWorkspaceDocument}
previewEntry={previewWorkspaceEntry}
resolveApiPath={resolveApiPath}
onClose={closeDocumentPreview}
onRegenerateThumbnails={handleThumbnailRegeneration}
/>
</main>
);
};
export default DocumentViewerRoute;
+104 -17
View File
@@ -1,7 +1,6 @@
import React, { useCallback, useMemo } from 'react'; import React, { useCallback, useMemo } from 'react';
import { ChevronIcon, TrashIcon, EditIcon } from '../ui/icons'; import { ChevronIcon, TrashIcon, EditIcon, FolderIcon, ChevronsLeftIcon } from '../ui/icons';
const FOLDER_ICON_SRC = '/folder.svg';
import { getTagColorStyle } from '../utils/colors'; import { getTagColorStyle } from '../utils/colors';
const FolderNode = ({ const FolderNode = ({
@@ -68,7 +67,7 @@ const FolderNode = ({
</span> </span>
)} )}
<span className="name"> <span className="name">
<img src={FOLDER_ICON_SRC} alt="Folder" className="folder-icon-image" /> <FolderIcon className="folder-icon-image" size={16} />
{node.name} {node.name}
</span> </span>
{node.id !== 'root' && ( {node.id !== 'root' && (
@@ -137,6 +136,19 @@ const Sidebar = ({
correspondents = [], correspondents = [],
activeCorrespondentIds = [], activeCorrespondentIds = [],
onToggleCorrespondentFilter, onToggleCorrespondentFilter,
onManageTags,
onManageCorrespondents,
searchQuery = '',
onSearchChange,
onSearchSubmit,
onSearchClear,
isFilterActive,
appStatus,
loading,
previewActive,
onLogout,
status,
onCollapse,
}) => { }) => {
const sortedCorrespondents = useMemo( const sortedCorrespondents = useMemo(
() => () =>
@@ -151,6 +163,27 @@ const Sidebar = ({
); );
const handleToggleTag = onToggleTagFilter || (() => {}); const handleToggleTag = onToggleTagFilter || (() => {});
const activeTagSet = new Set(activeTagIds); const activeTagSet = new Set(activeTagIds);
const handleManageTags = onManageTags || (() => {});
const handleManageCorrespondents = onManageCorrespondents || (() => {});
const handleSearchInputChange = useCallback(
(event) => {
onSearchChange?.(event.target.value);
},
[onSearchChange],
);
const handleSearchFormSubmit = useCallback(
(event) => {
event.preventDefault();
onSearchSubmit?.();
},
[onSearchSubmit],
);
const handleSearchClear = useCallback(() => {
onSearchClear?.();
}, [onSearchClear]);
const handleLogoutClick = useCallback(() => {
onLogout?.();
}, [onLogout]);
const renderNodes = useCallback( const renderNodes = useCallback(
(ids, depth) => (ids, depth) =>
@@ -194,9 +227,54 @@ const Sidebar = ({
); );
const rootNode = folderNodes.get('root'); const rootNode = folderNodes.get('root');
const hintText = appStatus === 'bootstrapping' && loading
? 'Loading your library…'
: previewActive
? 'Viewing document preview. Press ← Back to return to the library.'
: 'Drag files here to upload.';
return ( return (
<aside className="sidebar column"> <aside className="sidebar">
<div className="panel-header sidebar__header">
<div className="panel-actions">
<h1 className="sidebar__title">Papercrate</h1>
<div className="spacer" />
{onCollapse ? (
<button
type="button"
className="icon-button ghost"
onClick={onCollapse}
aria-label="Collapse sidebar"
title="Collapse sidebar"
>
<ChevronsLeftIcon />
</button>
) : null}
</div>
</div>
<div className="panel-body sidebar__body">
<span className="sidebar__hint">{hintText}</span>
{status && (
<div className="sidebar__status">
<div className={`status-banner ${status.variant}`}>{status.message}</div>
</div>
)}
{onSearchChange && (
<form className="sidebar__search" onSubmit={handleSearchFormSubmit}>
<input
type="search"
value={searchQuery}
onChange={handleSearchInputChange}
placeholder="Search documents"
aria-label="Search documents"
/>
{isFilterActive && (
<button type="button" onClick={handleSearchClear}>
Clear
</button>
)}
</form>
)}
<div className="sidebar-section sidebar-section--folders"> <div className="sidebar-section sidebar-section--folders">
<div className="sidebar-section__header"> <div className="sidebar-section__header">
<h3>Folders</h3> <h3>Folders</h3>
@@ -207,8 +285,14 @@ const Sidebar = ({
</div> </div>
<div className="sidebar-section"> <div className="sidebar-section">
<div className="sidebar-section__header"> <div className="sidebar-section__header">
<button
type="button"
className="sidebar-section__title"
onClick={handleManageTags}
>
<h3>Tags</h3> <h3>Tags</h3>
<span className="meta">{tags.length}</span> <span className="meta">{tags.length}</span>
</button>
</div> </div>
<div <div
className={`sidebar-tag-cloud${ className={`sidebar-tag-cloud${
@@ -216,8 +300,7 @@ const Sidebar = ({
}`} }`}
role="list" role="list"
> >
{tags.length ? ( {tags.map((tag) => {
tags.map((tag) => {
const isActive = activeTagSet.has(tag.id); const isActive = activeTagSet.has(tag.id);
const style = getTagColorStyle(tag.color); const style = getTagColorStyle(tag.color);
const className = `sidebar-tag-pill${isActive ? ' active' : ''}`; const className = `sidebar-tag-pill${isActive ? ' active' : ''}`;
@@ -250,20 +333,22 @@ const Sidebar = ({
{tag.label} {tag.label}
</button> </button>
); );
}) })}
) : (
<span className="meta">No tags yet</span>
)}
</div> </div>
</div> </div>
<div className="sidebar-section"> <div className="sidebar-section">
<div className="sidebar-section__header"> <div className="sidebar-section__header">
<button
type="button"
className="sidebar-section__title"
onClick={handleManageCorrespondents}
>
<h3>Correspondents</h3> <h3>Correspondents</h3>
<span className="meta">{correspondents.length}</span> <span className="meta">{correspondents.length}</span>
</button>
</div> </div>
<ul className="sidebar-correspondent-list"> <ul className="sidebar-correspondent-list">
{sortedCorrespondents.length ? ( {sortedCorrespondents.map((correspondent) => {
sortedCorrespondents.map((correspondent) => {
const isActive = activeCorrespondentSet.has(correspondent.id); const isActive = activeCorrespondentSet.has(correspondent.id);
const className = `sidebar-correspondent-item${isActive ? ' active' : ''}`; const className = `sidebar-correspondent-item${isActive ? ' active' : ''}`;
const label = correspondent.name || 'Unnamed'; const label = correspondent.name || 'Unnamed';
@@ -276,7 +361,6 @@ const Sidebar = ({
<span <span
className={className} className={className}
role="button" role="button"
tabIndex={0}
onClick={handleSelect} onClick={handleSelect}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') { if (event.key === 'Enter' || event.key === ' ') {
@@ -289,12 +373,15 @@ const Sidebar = ({
</span> </span>
</li> </li>
); );
}) })}
) : (
<li className="meta">No correspondents yet</li>
)}
</ul> </ul>
</div> </div>
<div className="sidebar__footer">
<button className="secondary" type="button" onClick={handleLogoutClick}>
Log out
</button>
</div>
</div>
</aside> </aside>
); );
}; };
+40 -102
View File
@@ -10,59 +10,9 @@
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
grid-column: 2 / -1;
min-height: 0; min-height: 0;
position: relative; position: relative;
background-color: var(--surface-subtle);
}
.skeuo-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 1rem 0.5rem;
background-color: var(--surface-subtle);
}
.skeuo-header__meta {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.skeuo-header__meta h2 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
}
.skeuo-breadcrumbs {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
font-size: 0.85rem;
color: var(--muted);
}
.skeuo-crumb {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.skeuo-crumb.is-current {
color: var(--fg);
font-weight: 600;
}
.skeuo-crumb-separator {
opacity: 0.4;
}
.skeuo-header__actions {
display: flex;
align-items: center;
gap: 0.5rem;
} }
.skeuo-canvas { .skeuo-canvas {
@@ -118,10 +68,6 @@
transition: none; transition: none;
} }
.skeuo-item.is-zoomed {
cursor: pointer;
}
.skeuo-item.is-tag-target .skeuo-item__card { .skeuo-item.is-tag-target .skeuo-item__card {
outline: 1em dashed var(--accent); outline: 1em dashed var(--accent);
outline-offset: 1.41em; outline-offset: 1.41em;
@@ -158,7 +104,7 @@
align-items: center; align-items: center;
justify-content: flex-start; justify-content: flex-start;
padding: 0.28rem 0.7rem; padding: 0.28rem 0.7rem;
border-radius: 1px; border-radius: 1rem;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.04), rgba(0, 0, 0, 0) 70%), var(--surface); background: linear-gradient(180deg, rgba(0, 0, 0, 0.04), rgba(0, 0, 0, 0) 70%), var(--surface);
color: var(--fg); color: var(--fg);
font-size: 1rem; font-size: 1rem;
@@ -188,72 +134,63 @@
user-select: none; user-select: none;
} }
.skeuo-item__tags .skeuo-tag.is-tear-pending {
opacity: 0.4;
}
.skeuo-tag-shelf { .skeuo-card__nav {
position: absolute; position: absolute;
top: 2.25rem; bottom: calc(3em * 0.707 * var(--nav-scale));
right: 1rem; left: 50%;
transform: translateX(-50%) scale(calc(0.707 * var(--nav-scale, 1)));
transform-origin: center;
display: flex; display: flex;
flex-direction: column; gap: 2rem;
gap: 0.6rem; opacity: 0;
align-items: flex-end; pointer-events: none;
max-height: calc(100% - 4rem); transition: opacity 0.2s ease;
overflow: auto;
padding: 0;
z-index: 100000;
} }
.skeuo-tag-shelf .skeuo-tag { .skeuo-item__card:hover .skeuo-card__nav {
cursor: grab;
}
.skeuo-tag-shelf .skeuo-tag:active {
cursor: grabbing;
}
.skeuo-tag-shelf .skeuo-tag.is-inactive {
opacity: 0.25;
}
.skeuo-tag-shelf .skeuo-tag.is-active {
opacity: 1; opacity: 1;
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.08); pointer-events: auto;
} }
.skeuo-tag-add { .skeuo-card__nav-button {
width: 2.4rem; display: inline-flex;
height: 2.4rem;
border-radius: 50%;
border: 1px solid rgba(0, 0, 0, 0.15);
background: rgba(255, 255, 255, 0.8);
color: var(--fg);
font-size: 1.4rem;
line-height: 1;
display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 3.3em;
height: 3.3em;
padding: 0.36em;
border-radius: 3.3em;
border: none;
background: rgba(0, 0, 0, 0.55);
color: #fff;
cursor: pointer; cursor: pointer;
margin-top: 0.4rem; transition: background 0.15s ease, opacity 0.15s ease;
transition: background 0.2s ease, transform 0.2s ease;
} }
.skeuo-tag-add:hover { .skeuo-card__nav-button:hover:not([disabled]) {
background: rgba(255, 255, 255, 1); background: rgba(0, 0, 0, 0.75);
transform: translateY(-1px);
} }
.skeuo-tag-add:active { .skeuo-card__nav-button:disabled {
transform: translateY(0); opacity: 0.4;
cursor: default;
} }
.skeuo-tag-add:focus-visible { .skeuo-card__nav-button:focus-visible {
outline: 2px solid var(--accent); outline: 2px solid var(--accent, #2684ff);
outline-offset: 2px; outline-offset: 2px;
} }
.skeuo-card__nav-button svg {
width: 100%;
height: 100%;
}
.skeuo-item__tags .skeuo-tag.is-tear-pending {
opacity: 0.4;
}
body.skeuo-cursor-remove, body.skeuo-cursor-remove,
body.skeuo-cursor-remove * { body.skeuo-cursor-remove * {
cursor: not-allowed !important; cursor: not-allowed !important;
@@ -271,6 +208,7 @@ body.skeuo-cursor-remove * {
justify-content: center; justify-content: center;
box-shadow: 0 24px 72px rgba(0, 0, 0, 0.24); box-shadow: 0 24px 72px rgba(0, 0, 0, 0.24);
overflow: hidden; overflow: hidden;
--nav-scale: 1;
} }
.skeuo-item__card--empty { .skeuo-item__card--empty {
+323 -353
View File
@@ -6,8 +6,11 @@ import React, {
useRef, useRef,
useState, useState,
} from 'react'; } from 'react';
import { resolveDocumentAssetUrl } from './asset_manager'; import { resolveDocumentAssetUrl, createAssetView } from './asset_manager';
import { generateRandomTagColor, getReadableTextColor } from './utils/colors'; import { useAssetNavigator } from './hooks/useAssetNavigator';
import { ArrowLeftIcon, ArrowRightIcon } from './ui/icons';
import PreviewZoomOverlay from './detail/PreviewZoomOverlay';
import { getReadableTextColor } from './utils/colors';
import './skeuomorphic_ws.css'; import './skeuomorphic_ws.css';
const ITEM_WIDTH = 220; const ITEM_WIDTH = 220;
@@ -26,12 +29,8 @@ const resolveSizeKey = (doc) =>
const CARD_MIN = 240; const CARD_MIN = 240;
const CARD_MAX = 340; const CARD_MAX = 340;
const EMPTY_CARD_ASPECT = 1.4; const EMPTY_CARD_ASPECT = 1.4;
const ZOOM_FILL_RATIO = 0.99;
const ZOOM_MIN_SCALE = 1.05;
const ZOOM_MAX_SCALE = 5;
const TAG_REMOVE_DISTANCE = 160; const TAG_REMOVE_DISTANCE = 160;
const DRAG_PREVIEW_KEY = Symbol('dragPreview');
const DEBUG_DRAG = false; const DEBUG_DRAG = false;
const DEBUG_FOCUS = true; const DEBUG_FOCUS = true;
const DEBUG_DROP = true; const DEBUG_DROP = true;
@@ -44,6 +43,134 @@ const resolveTagKey = (tag) => {
return key != null ? String(key) : null; return key != null ? String(key) : null;
}; };
const SkeuoPreviewCard = ({
doc,
title,
ensureAssetUrl,
getDocumentAsset,
navScale = 1,
prefetch = 3,
onNavigatorSnapshot,
}) => {
const navigator = useAssetNavigator({
document: doc,
assetType: 'preview',
ensureAssetUrl,
getAsset: getDocumentAsset,
prefetch,
});
const { currentUrl, cardinality, canGoPrev, canGoNext } = navigator;
const docId = doc?.id ?? null;
useEffect(() => {
if (!onNavigatorSnapshot || !docId) {
return undefined;
}
const snapshot = {
url: currentUrl || null,
alt: title,
canGoPrev,
canGoNext,
goPrev: navigator.goPrev,
goNext: navigator.goNext,
};
onNavigatorSnapshot(docId, snapshot);
return () => onNavigatorSnapshot(docId, null);
}, [
docId,
currentUrl,
title,
canGoPrev,
canGoNext,
navigator.goPrev,
navigator.goNext,
onNavigatorSnapshot,
]);
const hasPreview = Boolean(currentUrl);
const cardClasses = ['skeuo-item__card'];
if (!hasPreview) cardClasses.push('skeuo-item__card--empty');
const showNav = hasPreview && (cardinality > 1 || canGoPrev || canGoNext);
const navStyle = useMemo(() => ({ '--nav-scale': navScale }), [navScale]);
return (
<div className={cardClasses.join(' ')}>
{hasPreview ? (
<img src={currentUrl} alt={title} />
) : (
<div className="skeuo-item__empty">
<div className="skeuo-item__placeholder">DOC</div>
<div className="skeuo-item__title" title={title}>
{title}
</div>
</div>
)}
{showNav ? (
<div className="skeuo-card__nav" style={navStyle}>
<button
type="button"
className="skeuo-card__nav-button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
navigator.goPrev();
}}
onPointerDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onPointerUp={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onMouseUp={(event) => {
event.preventDefault();
event.stopPropagation();
}}
disabled={!canGoPrev}
aria-label="Previous preview"
>
<ArrowLeftIcon />
</button>
<button
type="button"
className="skeuo-card__nav-button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
navigator.goNext();
}}
onPointerDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onPointerUp={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onMouseUp={(event) => {
event.preventDefault();
event.stopPropagation();
}}
disabled={!canGoNext}
aria-label="Next preview"
>
<ArrowRightIcon />
</button>
</div>
) : null}
</div>
);
};
const generateInitialLayout = ( const generateInitialLayout = (
entries, entries,
{ {
@@ -201,7 +328,7 @@ const useDocumentDrag = ({
setDraggingId, setDraggingId,
syncLayoutSnapshot, syncLayoutSnapshot,
canvasSize, canvasSize,
toggleZoom, openOverlayForDoc,
}) => { }) => {
const dragStateRef = useRef(null); const dragStateRef = useRef(null);
@@ -211,6 +338,15 @@ const useDocumentDrag = ({
if (!state || state.pointerId !== pointerId) { if (!state || state.pointerId !== pointerId) {
return; return;
} }
const capturedTarget = state.capturedTarget;
if (capturedTarget && typeof capturedTarget.releasePointerCapture === 'function') {
try {
capturedTarget.releasePointerCapture(pointerId);
} catch (
// eslint-disable-next-line no-empty
error
) {}
}
dragStateRef.current = null; dragStateRef.current = null;
setDraggingId((current) => (current === state.docId ? null : current)); setDraggingId((current) => (current === state.docId ? null : current));
syncLayoutSnapshot(); syncLayoutSnapshot();
@@ -219,7 +355,7 @@ const useDocumentDrag = ({
); );
const handlePointerDown = useCallback( const handlePointerDown = useCallback(
(event, docId, { lockWhenZoomed = false } = {}) => { (event, docId) => {
if (DEBUG_DRAG) { if (DEBUG_DRAG) {
console.log( console.log(
'[skeuo] handlePointerDown fired for doc', '[skeuo] handlePointerDown fired for doc',
@@ -247,6 +383,15 @@ const useDocumentDrag = ({
} }
bringToFront(docId); bringToFront(docId);
const capturedTarget = event.currentTarget instanceof HTMLElement ? event.currentTarget : null;
if (capturedTarget && typeof capturedTarget.setPointerCapture === 'function') {
try {
capturedTarget.setPointerCapture(event.pointerId);
} catch (
// eslint-disable-next-line no-empty
error
) {}
}
dragStateRef.current = { dragStateRef.current = {
docId, docId,
pointerId: event.pointerId, pointerId: event.pointerId,
@@ -256,10 +401,11 @@ const useDocumentDrag = ({
startY: event.clientY, startY: event.clientY,
rotation: entry?.rotation ?? 0, rotation: entry?.rotation ?? 0,
moved: false, moved: false,
locked: Boolean(lockWhenZoomed), locked: false,
width: docWidth, width: docWidth,
height: docHeight, height: docHeight,
scale: baseScale, scale: baseScale,
capturedTarget,
}; };
setDraggingId(docId); setDraggingId(docId);
}, },
@@ -354,13 +500,19 @@ const useDocumentDrag = ({
const docId = state.docId; const docId = state.docId;
finishDrag(event.pointerId); finishDrag(event.pointerId);
if (!moved) { if (!moved) {
toggleZoom(docId); const originInfo = {
rotation: state.rotation || 0,
scale: state.scale || 1,
width: state.width,
height: state.height,
};
openOverlayForDoc(docId, originInfo);
} }
return; return;
} }
finishDrag(event.pointerId); finishDrag(event.pointerId);
}, },
[finishDrag, toggleZoom], [finishDrag, openOverlayForDoc],
); );
const handlePointerCancel = useCallback( const handlePointerCancel = useCallback(
@@ -426,26 +578,18 @@ const getContrastingTextColor = (hex) => getReadableTextColor(hex, { light: '#1f
const SkeuomorphicWorkspace = ({ const SkeuomorphicWorkspace = ({
documents = [], documents = [],
searchResults = null, searchResults = null,
breadcrumbs = [],
currentFolderName = 'Folder',
onExit,
onRefresh,
onDocumentOpen, onDocumentOpen,
resolveThumbnailUrl, resolveThumbnailUrl,
availableTags = [],
onCreateTag = null,
onAssignTagToDocument = null, onAssignTagToDocument = null,
onRemoveTagFromDocument = null, onRemoveTagFromDocument = null,
ensureAssetUrl = null, ensureAssetUrl = null,
getDocumentAsset = () => null, getDocumentAsset = () => null,
prepareTagPayload = null, activeTagIds = [],
}) => { }) => {
const items = useMemo(() => (searchResults ? searchResults : documents), [documents, searchResults]); const items = useMemo(() => (searchResults ? searchResults : documents), [documents, searchResults]);
const showingSearchResults = searchResults !== null;
const containerRef = useRef(null); const containerRef = useRef(null);
const tagShelfRef = useRef(null);
const layoutRef = useRef(new Map()); const layoutRef = useRef(new Map());
const itemRefs = useRef(new Map()); const itemRefs = useRef(new Map());
const zCounterRef = useRef(10); const zCounterRef = useRef(10);
@@ -453,17 +597,60 @@ const SkeuomorphicWorkspace = ({
const [layoutSnapshot, setLayoutSnapshot] = useState(() => new Map()); const [layoutSnapshot, setLayoutSnapshot] = useState(() => new Map());
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 }); const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 });
const [tagShelfWidth, setTagShelfWidth] = useState(0);
const [draggingId, setDraggingId] = useState(null); const [draggingId, setDraggingId] = useState(null);
const [zoomedId, setZoomedId] = useState(null); const [overlayDocId, setOverlayDocId] = useState(null);
const [overlayOriginRect, setOverlayOriginRect] = useState(null);
const [overlayOriginTransform, setOverlayOriginTransform] = useState(null);
const [previewSnapshots, setPreviewSnapshots] = useState(() => new Map());
const [tagDropTargetId, setTagDropTargetId] = useState(null); const [tagDropTargetId, setTagDropTargetId] = useState(null);
const [pendingTagDocId, setPendingTagDocId] = useState(null); const [pendingTagDocId, setPendingTagDocId] = useState(null);
const [pendingRemovalTag, setPendingRemovalTag] = useState(null); const [pendingRemovalTag, setPendingRemovalTag] = useState(null);
const [activeShelfTagId, setActiveShelfTagId] = useState(null);
const draggingTagRef = useRef(null); const draggingTagRef = useRef(null);
const pendingDocTagDragRef = useRef(null); const pendingDocTagDragRef = useRef(null);
const docSizeMapRef = useRef(new Map()); const docSizeMapRef = useRef(new Map());
const removalCursorActiveRef = useRef(false); const removalCursorActiveRef = useRef(false);
const handleNavigatorSnapshot = useCallback((docId, snapshot) => {
if (!docId) {
return;
}
setPreviewSnapshots((previous) => {
const prevSnapshot = previous.get(docId);
if (!snapshot) {
if (!previous.has(docId)) {
return previous;
}
const next = new Map(previous);
next.delete(docId);
return next;
}
const next = new Map(previous);
const sameSnapshot =
prevSnapshot &&
prevSnapshot.url === snapshot.url &&
prevSnapshot.alt === snapshot.alt &&
prevSnapshot.canGoPrev === snapshot.canGoPrev &&
prevSnapshot.canGoNext === snapshot.canGoNext &&
prevSnapshot.goPrev === snapshot.goPrev &&
prevSnapshot.goNext === snapshot.goNext;
if (sameSnapshot) {
return previous;
}
next.set(docId, snapshot);
return next;
});
}, []);
const activeTagSet = useMemo(() => {
if (!Array.isArray(activeTagIds) || activeTagIds.length === 0) {
return new Set();
}
const set = new Set();
activeTagIds.forEach((id) => {
if (id != null) {
set.add(String(id));
}
});
return set;
}, [activeTagIds]);
const resolvePreviewAsset = useCallback( const resolvePreviewAsset = useCallback(
(doc) => { (doc) => {
@@ -477,8 +664,10 @@ const SkeuomorphicWorkspace = ({
(doc) => { (doc) => {
if (!doc) return null; if (!doc) return null;
const asset = resolvePreviewAsset(doc); const asset = resolvePreviewAsset(doc);
const width = asset?.metadata?.width; const view = createAssetView(asset);
const height = asset?.metadata?.height; const metadata = view.getPrimaryMetadata() || {};
const width = metadata?.width;
const height = metadata?.height;
if (typeof width === 'number' && typeof height === 'number') { if (typeof width === 'number' && typeof height === 'number') {
return { width, height }; return { width, height };
} }
@@ -487,17 +676,6 @@ const SkeuomorphicWorkspace = ({
[resolvePreviewAsset], [resolvePreviewAsset],
); );
const resolvePreviewUrl = useCallback(
(doc) => {
if (!doc) return null;
return resolveDocumentAssetUrl(doc, 'preview', {
ensureAssetUrl,
getAsset: getDocumentAsset,
});
},
[ensureAssetUrl, getDocumentAsset],
);
useEffect(() => { useEffect(() => {
if (!ensureAssetUrl) { if (!ensureAssetUrl) {
return; return;
@@ -591,72 +769,11 @@ const SkeuomorphicWorkspace = ({
); );
}, []); }, []);
const handleShelfTagDragStart = useCallback((event, tag) => {
if (!tag) return;
try {
event.dataTransfer.effectAllowed = 'copy';
const payload = JSON.stringify({ id: tag.id, label: tag.label });
event.dataTransfer.setData('application/x-papercrate-tag', payload);
event.dataTransfer.setData('text/papercrate-tag', payload);
event.dataTransfer.setData('text/plain', tag.label || 'Tag');
} catch (error) {
console.warn('Failed to initiate tag drag', error);
}
const node = event.currentTarget;
const hideNode = () => {
if (node instanceof HTMLElement) {
node.classList.add('is-drag-hidden');
}
};
if (node instanceof HTMLElement) {
if (node[DRAG_PREVIEW_KEY]) {
cleanupPreview(node[DRAG_PREVIEW_KEY]);
delete node[DRAG_PREVIEW_KEY];
}
const preview = createDragPreview(node, event.clientX, event.clientY);
if (preview && event.dataTransfer) {
try {
event.dataTransfer.setDragImage(preview.clone, preview.offsetX, preview.offsetY);
node[DRAG_PREVIEW_KEY] = preview.clone;
} catch (
// eslint-disable-next-line no-empty
error
) {}
}
}
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
window.requestAnimationFrame(hideNode);
} else {
setTimeout(hideNode, 0);
}
}, []);
const handleTagDragEnd = useCallback(() => { const handleTagDragEnd = useCallback(() => {
updateRemovalCursor(false); updateRemovalCursor(false);
setTagDropTargetId(null); setTagDropTargetId(null);
}, [updateRemovalCursor]); }, [updateRemovalCursor]);
const handleShelfTagDragEndWithReset = useCallback((event) => {
if (event?.currentTarget instanceof HTMLElement) {
const target = event.currentTarget;
const showNode = () => {
if (target instanceof HTMLElement) {
target.classList.remove('is-drag-hidden');
const stored = target[DRAG_PREVIEW_KEY];
cleanupPreview(stored);
delete target[DRAG_PREVIEW_KEY];
}
};
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
window.requestAnimationFrame(showNode);
} else {
setTimeout(showNode, 0);
}
}
handleTagDragEnd();
queueFocusCanvas();
}, [handleTagDragEnd, queueFocusCanvas]);
const ensureDocumentSize = useCallback((doc) => { const ensureDocumentSize = useCallback((doc) => {
const key = resolveSizeKey(doc); const key = resolveSizeKey(doc);
const cache = docSizeMapRef.current.get(key); const cache = docSizeMapRef.current.get(key);
@@ -721,81 +838,39 @@ const SkeuomorphicWorkspace = ({
docSizeMapRef.current = new Map(); docSizeMapRef.current = new Map();
}, [items]); }, [items]);
useEffect(() => { const overlayDisplay = useMemo(() => {
if (!activeShelfTagId) { if (!overlayDocId) {
return; return null;
} }
const stillExists = availableTags.some((tag) => resolveTagKey(tag) === activeShelfTagId); const snapshot = previewSnapshots.get(overlayDocId);
if (!stillExists) { if (!snapshot || !snapshot.url) {
setActiveShelfTagId(null); return null;
} }
}, [activeShelfTagId, availableTags]); const doc = documentLookup.get(overlayDocId);
const alt = snapshot.alt || doc?.title || doc?.original_name || 'Document preview';
const resolveZoomMetrics = useCallback(
(doc, cardWidth, cardHeight) => {
const canvasWidth = canvasSize.width || DEFAULT_CANVAS_WIDTH;
const canvasHeight = canvasSize.height || DEFAULT_CANVAS_HEIGHT;
const safeCardWidth = cardWidth || CARD_MIN;
const safeCardHeight = cardHeight || CARD_MIN;
const usableWidth = Math.max(canvasWidth - CANVAS_PADDING * 2, safeCardWidth);
const usableHeight = Math.max(canvasHeight - CANVAS_PADDING * 2, safeCardHeight);
const viewportTargetWidth = usableWidth * ZOOM_FILL_RATIO;
const viewportTargetHeight = usableHeight * ZOOM_FILL_RATIO;
let zoomWidth = safeCardWidth;
let zoomHeight = safeCardHeight;
const previewDims = doc ? resolvePreviewDimensions(doc) : null;
if (previewDims?.width && previewDims?.height) {
const previewWidth = previewDims.width;
const previewHeight = previewDims.height;
const widthScaleLimit = previewWidth > 0 ? viewportTargetWidth / previewWidth : 1;
const heightScaleLimit = previewHeight > 0 ? viewportTargetHeight / previewHeight : 1;
const scaleToFit = Math.min(1, widthScaleLimit || 1, heightScaleLimit || 1);
zoomWidth = previewWidth * scaleToFit;
zoomHeight = previewHeight * scaleToFit;
} else {
const rawScale = Math.min(
viewportTargetWidth / safeCardWidth,
viewportTargetHeight / safeCardHeight,
);
const boundedScale =
rawScale >= 1
? clamp(Math.max(rawScale, ZOOM_MIN_SCALE), ZOOM_MIN_SCALE, ZOOM_MAX_SCALE)
: rawScale;
zoomWidth = safeCardWidth * boundedScale;
zoomHeight = safeCardHeight * boundedScale;
}
if (!Number.isFinite(zoomWidth) || zoomWidth <= 0) {
zoomWidth = safeCardWidth;
}
if (!Number.isFinite(zoomHeight) || zoomHeight <= 0) {
zoomHeight = safeCardHeight;
}
zoomWidth = Math.max(zoomWidth, safeCardWidth);
zoomHeight = Math.max(zoomHeight, safeCardHeight);
const zoomTargetX = (canvasWidth - zoomWidth) / 2;
const zoomTargetY = (canvasHeight - zoomHeight) / 2;
const maxX = Math.max(CANVAS_PADDING, canvasWidth - zoomWidth - CANVAS_PADDING);
const maxY = Math.max(CANVAS_PADDING, canvasHeight - zoomHeight - CANVAS_PADDING);
const clampedX = clamp(zoomTargetX, CANVAS_PADDING, maxX);
const clampedY = clamp(zoomTargetY, CANVAS_PADDING, maxY);
const zoomCenterX = clampedX + zoomWidth / 2;
const zoomCenterY = clampedY + zoomHeight / 2;
return { return {
zoomWidth, url: snapshot.url,
zoomHeight, alt,
zoomCenterX, canGoPrev: snapshot.canGoPrev,
zoomCenterY, canGoNext: snapshot.canGoNext,
goPrev: snapshot.goPrev,
goNext: snapshot.goNext,
}; };
}, }, [overlayDocId, previewSnapshots, documentLookup]);
[canvasSize.width, canvasSize.height, resolvePreviewDimensions],
); const closeOverlay = useCallback(() => {
setOverlayDocId(null);
setOverlayOriginRect(null);
setOverlayOriginTransform(null);
}, []);
useEffect(() => {
if (overlayDocId && !documentLookup.has(overlayDocId)) {
setOverlayDocId(null);
setOverlayOriginRect(null);
setOverlayOriginTransform(null);
}
}, [overlayDocId, documentLookup]);
const resolveBaseMetrics = useCallback( const resolveBaseMetrics = useCallback(
(doc, cardWidth, cardHeight) => { (doc, cardWidth, cardHeight) => {
@@ -821,30 +896,6 @@ const SkeuomorphicWorkspace = ({
[resolvePreviewDimensions], [resolvePreviewDimensions],
); );
useLayoutEffect(() => {
const shelfNode = tagShelfRef.current;
if (!shelfNode || !availableTags.length) {
setTagShelfWidth(0);
return () => {};
}
const measure = () => {
const rect = shelfNode.getBoundingClientRect();
setTagShelfWidth(Math.ceil(rect.width));
};
measure();
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', measure);
return () => window.removeEventListener('resize', measure);
}
const observer = new ResizeObserver(() => measure());
observer.observe(shelfNode);
return () => observer.disconnect();
}, [availableTags.length]);
const syncLayoutSnapshot = useCallback(() => { const syncLayoutSnapshot = useCallback(() => {
setLayoutSnapshot(new Map(layoutRef.current)); setLayoutSnapshot(new Map(layoutRef.current));
}, []); }, []);
@@ -950,7 +1001,7 @@ const SkeuomorphicWorkspace = ({
startZ: maxZ, startZ: maxZ,
rotationRange: ROTATION_RANGE, rotationRange: ROTATION_RANGE,
minSpacing: 48, minSpacing: 48,
shelfWidth: tagShelfWidth > 0 ? tagShelfWidth + CANVAS_PADDING : 0, shelfWidth: 0,
}, },
); );
generatedLayout.forEach((entry, docId) => { generatedLayout.forEach((entry, docId) => {
@@ -971,7 +1022,6 @@ const SkeuomorphicWorkspace = ({
canvasSize.height, canvasSize.height,
ensureDocumentSize, ensureDocumentSize,
syncLayoutSnapshot, syncLayoutSnapshot,
tagShelfWidth,
]); ]);
useEffect(() => { useEffect(() => {
@@ -992,17 +1042,62 @@ const SkeuomorphicWorkspace = ({
[syncLayoutSnapshot], [syncLayoutSnapshot],
); );
const toggleZoom = useCallback( const openOverlayForDoc = useCallback(
(docId) => { (docId, originInfo = null) => {
setZoomedId((current) => { if (!docId) {
if (current === docId) { return;
return null; }
const snapshot = previewSnapshots.get(docId);
if (!snapshot || !snapshot.url) {
return;
}
const container = itemRefs.current.get(docId);
const imageNode = container?.querySelector?.('.skeuo-item__card img');
if (!container || !imageNode) {
return;
}
const rect = imageNode.getBoundingClientRect();
let originTransform = null;
if (originInfo) {
const { rotation = 0, scale = 1, width: originWidth, height: originHeight } = originInfo;
originTransform = {
rotation,
scaleX: scale,
scaleY: scale,
baseWidth: originWidth,
baseHeight: originHeight,
};
}
if (!originTransform) {
const entry = layoutRef.current.get(docId) || null;
const doc = documentLookup.get(docId) || null;
const { width: cardWidth, height: cardHeight } = ensureDocumentSize(doc);
const { baseWidth, baseHeight, baseScale } = resolveBaseMetrics(doc, cardWidth, cardHeight);
const effectiveWidth = baseWidth * baseScale;
const effectiveHeight = baseHeight * baseScale;
originTransform = {
rotation: entry?.rotation ?? 0,
scaleX: baseScale,
scaleY: baseScale,
baseWidth: Number.isFinite(effectiveWidth) && effectiveWidth > 0 ? effectiveWidth : cardWidth,
baseHeight: Number.isFinite(effectiveHeight) && effectiveHeight > 0 ? effectiveHeight : cardHeight,
};
} }
bringToFront(docId); bringToFront(docId);
return docId; setOverlayOriginRect(rect);
}); setOverlayOriginTransform(originTransform);
setOverlayDocId(docId);
}, },
[bringToFront], [
bringToFront,
previewSnapshots,
itemRefs,
setOverlayOriginTransform,
ensureDocumentSize,
resolveBaseMetrics,
documentLookup,
layoutRef,
],
); );
const { handlePointerDown, handlePointerMove, handlePointerUp, handlePointerCancel } = useDocumentDrag({ const { handlePointerDown, handlePointerMove, handlePointerUp, handlePointerCancel } = useDocumentDrag({
layoutRef, layoutRef,
@@ -1014,7 +1109,7 @@ const SkeuomorphicWorkspace = ({
setDraggingId, setDraggingId,
syncLayoutSnapshot, syncLayoutSnapshot,
canvasSize, canvasSize,
toggleZoom, openOverlayForDoc,
}); });
const handleTagDragEnterDoc = useCallback( const handleTagDragEnterDoc = useCallback(
@@ -1150,7 +1245,7 @@ const SkeuomorphicWorkspace = ({
setPendingTagDocId(doc.id); setPendingTagDocId(doc.id);
try { try {
await onAssignTagToDocument({ documentId: doc.id, tagId }); await onAssignTagToDocument({ documentId: doc.id, tagId, tag: payload });
markActiveTagDropHandled(tagId, sourceDocId); markActiveTagDropHandled(tagId, sourceDocId);
if (DEBUG_DROP) { if (DEBUG_DROP) {
console.log('[skeuo] handleTagDropOnDoc: assigned tag', tagId, 'to doc', doc.id); console.log('[skeuo] handleTagDropOnDoc: assigned tag', tagId, 'to doc', doc.id);
@@ -1467,44 +1562,12 @@ const SkeuomorphicWorkspace = ({
[queueFocusCanvas, handleTagDragEnd, onRemoveTagFromDocument, updateRemovalCursor], [queueFocusCanvas, handleTagDragEnd, onRemoveTagFromDocument, updateRemovalCursor],
); );
const renderBreadcrumbs = () => {
if (!breadcrumbs.length) return null;
return (
<nav className="skeuo-breadcrumbs" aria-label="Folder breadcrumbs">
{breadcrumbs.map((crumb, index) => {
const isLast = index === breadcrumbs.length - 1;
return (
<span key={crumb.id} className={`skeuo-crumb${isLast ? ' is-current' : ''}`}>
{crumb.name}
{!isLast && <span className="skeuo-crumb-separator"></span>}
</span>
);
})}
</nav>
);
};
return ( return (
<>
<div className="skeuo-shell"> <div className="skeuo-shell">
<header className="skeuo-header">
<div className="skeuo-header__meta">
<h2>{currentFolderName}</h2>
{renderBreadcrumbs()}
{showingSearchResults && <span className="meta">Showing search results</span>}
</div>
<div className="skeuo-header__actions">
<button type="button" className="secondary" onClick={onRefresh}>
Refresh
</button>
<button type="button" onClick={onExit}>
Back to List
</button>
</div>
</header>
<div <div
className="skeuo-canvas" className="skeuo-canvas"
ref={containerRef} ref={containerRef}
data-active-tag={activeShelfTagId || undefined}
onDragOver={handleCanvasDragOver} onDragOver={handleCanvasDragOver}
onDragLeave={handleCanvasDragLeave} onDragLeave={handleCanvasDragLeave}
onDrop={handleCanvasDrop} onDrop={handleCanvasDrop}
@@ -1529,31 +1592,17 @@ const SkeuomorphicWorkspace = ({
typeof layout.centerX === 'number' ? layout.centerX : fallbackLayout.centerX; typeof layout.centerX === 'number' ? layout.centerX : fallbackLayout.centerX;
const layoutCenterY = const layoutCenterY =
typeof layout.centerY === 'number' ? layout.centerY : fallbackLayout.centerY; typeof layout.centerY === 'number' ? layout.centerY : fallbackLayout.centerY;
const isZoomed = zoomedId === doc.id; const rotation = layout.rotation || 0;
const { zoomWidth, zoomHeight, zoomCenterX, zoomCenterY } = resolveZoomMetrics( const zoomScale = baseScale;
doc,
cardWidth,
cardHeight,
);
const targetCenterX = isZoomed ? zoomCenterX : layoutCenterX;
const targetCenterY = isZoomed ? zoomCenterY : layoutCenterY;
const rotation = isZoomed ? 0 : layout.rotation || 0;
const zoomScale = isZoomed
? Math.min(
1,
Number.isFinite(zoomWidth / baseWidth) ? zoomWidth / baseWidth : 1,
Number.isFinite(zoomHeight / baseHeight) ? zoomHeight / baseHeight : 1,
)
: baseScale;
const transform = formatTransform( const transform = formatTransform(
Math.round(targetCenterX), Math.round(layoutCenterX),
Math.round(targetCenterY), Math.round(layoutCenterY),
rotation, rotation,
zoomScale, zoomScale,
); );
const style = { const style = {
transform, transform,
zIndex: isZoomed ? 9999 : layout.z ?? 1, zIndex: layout.z ?? 1,
}; };
const bodyStyle = { const bodyStyle = {
width: Math.round(baseWidth), width: Math.round(baseWidth),
@@ -1562,9 +1611,6 @@ const SkeuomorphicWorkspace = ({
const totalScale = zoomScale > 0 ? zoomScale : 1; const totalScale = zoomScale > 0 ? zoomScale : 1;
const inverseTagScale = totalScale > 0 ? 1 / totalScale : 1; const inverseTagScale = totalScale > 0 ? 1 / totalScale : 1;
const tagsStyle = { '--tag-scale': inverseTagScale }; const tagsStyle = { '--tag-scale': inverseTagScale };
const previewUrl = resolvePreviewUrl(doc);
const imageUrl = previewUrl;
const hasPreview = Boolean(imageUrl);
const title = doc.title || doc.original_name || 'Document'; const title = doc.title || doc.original_name || 'Document';
const dragging = draggingId === doc.id; const dragging = draggingId === doc.id;
const tags = Array.isArray(doc.tags) ? doc.tags : []; const tags = Array.isArray(doc.tags) ? doc.tags : [];
@@ -1572,17 +1618,14 @@ const SkeuomorphicWorkspace = ({
.map((tag) => resolveTagKey(tag)) .map((tag) => resolveTagKey(tag))
.filter(Boolean); .filter(Boolean);
const matchesFilter = const matchesFilter =
!activeShelfTagId || docTagKeys.includes(activeShelfTagId); activeTagSet.size === 0 || docTagKeys.some((key) => activeTagSet.has(key));
const dropActive = tagDropTargetId === doc.id; const dropActive = tagDropTargetId === doc.id;
const dropPending = pendingTagDocId === doc.id; const dropPending = pendingTagDocId === doc.id;
const itemClasses = ['skeuo-item']; const itemClasses = ['skeuo-item'];
if (dragging) itemClasses.push('is-dragging'); if (dragging) itemClasses.push('is-dragging');
if (isZoomed) itemClasses.push('is-zoomed');
if (dropActive) itemClasses.push('is-tag-target'); if (dropActive) itemClasses.push('is-tag-target');
if (dropPending) itemClasses.push('is-tag-pending'); if (dropPending) itemClasses.push('is-tag-pending');
if (!matchesFilter) itemClasses.push('is-filtered-out'); if (!matchesFilter) itemClasses.push('is-filtered-out');
const cardClasses = ['skeuo-item__card'];
if (!hasPreview) cardClasses.push('skeuo-item__card--empty');
const docTagTokens = docTagKeys.join(' '); const docTagTokens = docTagKeys.join(' ');
return ( return (
<div <div
@@ -1600,9 +1643,7 @@ const SkeuomorphicWorkspace = ({
itemRefs.current.delete(doc.id); itemRefs.current.delete(doc.id);
} }
}} }}
onPointerDown={(event) => onPointerDown={(event) => handlePointerDown(event, doc.id)}
handlePointerDown(event, doc.id, { lockWhenZoomed: isZoomed })
}
onPointerMove={handlePointerMove} onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp} onPointerUp={handlePointerUp}
onPointerCancel={handlePointerCancel} onPointerCancel={handlePointerCancel}
@@ -1618,18 +1659,14 @@ const SkeuomorphicWorkspace = ({
}} }}
> >
<div className="skeuo-item__body" style={bodyStyle}> <div className="skeuo-item__body" style={bodyStyle}>
<div className={cardClasses.join(' ')}> <SkeuoPreviewCard
{hasPreview ? ( doc={doc}
<img src={imageUrl} alt={title} /> title={title}
) : ( ensureAssetUrl={ensureAssetUrl}
<div className="skeuo-item__empty"> getDocumentAsset={getDocumentAsset}
<div className="skeuo-item__placeholder">DOC</div> navScale={inverseTagScale}
<div className="skeuo-item__title" title={title}> onNavigatorSnapshot={handleNavigatorSnapshot}
{title} />
</div>
</div>
)}
</div>
{tags.length > 0 && ( {tags.length > 0 && (
<div className="skeuo-item__tags" aria-hidden="true" style={tagsStyle}> <div className="skeuo-item__tags" aria-hidden="true" style={tagsStyle}>
{tags.map((tag) => { {tags.map((tag) => {
@@ -1674,83 +1711,16 @@ const SkeuomorphicWorkspace = ({
); );
}) })
)} )}
{(availableTags.length > 0 || onCreateTag) && (
<div
className="skeuo-tag-shelf"
aria-label="Available tags"
ref={tagShelfRef}
>
{availableTags.map((tag) => {
const colorValue = normalizeColor(tag.color);
const foreground = getContrastingTextColor(colorValue || '#1b1f24');
const tagStyle = colorValue
? { backgroundColor: colorValue, color: foreground }
: undefined;
const tagKey = resolveTagKey(tag);
const isSelected = activeShelfTagId === tagKey;
const shelfTagClasses = ['skeuo-tag'];
if (isSelected) {
shelfTagClasses.push('is-active');
} else if (activeShelfTagId) {
shelfTagClasses.push('is-inactive');
}
return (
<div
key={tag.id || tag.label}
className={shelfTagClasses.join(' ')}
style={tagStyle}
title={tag.label}
draggable
data-tag-id={tagKey || undefined}
onDragStart={(event) => handleShelfTagDragStart(event, tag)}
onDragEnd={handleShelfTagDragEndWithReset}
role="button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setActiveShelfTagId((current) => (current === tagKey ? null : tagKey));
}}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
setActiveShelfTagId((current) => (current === tagKey ? null : tagKey));
}
}}
>
<span>{tag.label}</span>
</div>
);
})}
{typeof onCreateTag === 'function' && (
<button
type="button"
className="skeuo-tag-add"
aria-label="Add tag"
onClick={async () => {
const labelInput = window.prompt('New tag name?');
const label = labelInput ? labelInput.trim() : '';
if (!label) {
return;
}
try {
const payload =
typeof prepareTagPayload === 'function'
? prepareTagPayload({ label })
: { label, color: generateRandomTagColor() };
await onCreateTag(payload);
setActiveShelfTagId(null);
} catch (error) {
console.error('Failed to create tag', error);
}
}}
>
+
</button>
)}
</div>
)}
</div> </div>
</div> </div>
<PreviewZoomOverlay
open={Boolean(overlayDisplay?.url)}
display={overlayDisplay}
onClose={closeOverlay}
originRect={overlayOriginRect}
originTransform={overlayOriginTransform}
/>
</>
); );
}; };
+752 -382
View File
File diff suppressed because it is too large Load Diff
+86 -8
View File
@@ -1,12 +1,22 @@
import React, { useCallback, useMemo, useState } from 'react'; import React, { useCallback, useMemo, useState } from 'react';
import { getTagColorStyle, HEX_COLOR_PATTERN } from '../utils/colors'; import { getTagColorStyle, HEX_COLOR_PATTERN } from '../utils/colors';
function TagsPanel({ tags, onRefresh, onUpdateTag, onDeleteTag, onNotify }) { function TagsPanel({
tags,
onRefresh,
onCreateTag,
onUpdateTag,
onDeleteTag,
onNotify,
}) {
const [editingId, setEditingId] = useState(null); const [editingId, setEditingId] = useState(null);
const [draftLabel, setDraftLabel] = useState(''); const [draftLabel, setDraftLabel] = useState('');
const [draftColor, setDraftColor] = useState(''); const [draftColor, setDraftColor] = useState('');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [deletingId, setDeletingId] = useState(null); const [deletingId, setDeletingId] = useState(null);
const [createLabel, setCreateLabel] = useState('');
const [createColor, setCreateColor] = useState('');
const [creating, setCreating] = useState(false);
const startEdit = useCallback((tag) => { const startEdit = useCallback((tag) => {
setEditingId(tag.id); setEditingId(tag.id);
@@ -98,25 +108,93 @@ function TagsPanel({ tags, onRefresh, onUpdateTag, onDeleteTag, onNotify }) {
[onDeleteTag, editingId, cancelEdit, onNotify], [onDeleteTag, editingId, cancelEdit, onNotify],
); );
const handleCreate = useCallback(
async (event) => {
event.preventDefault();
if (typeof onCreateTag !== 'function') {
return;
}
const trimmedLabel = createLabel.trim();
if (!trimmedLabel) {
onNotify?.('Tag label cannot be empty.', 'error');
return;
}
const trimmedColor = createColor.trim();
const colorPattern = /^#([0-9a-fA-F]{6})$/;
if (trimmedColor && !colorPattern.test(trimmedColor)) {
onNotify?.('Colors must use the #RRGGBB format.', 'error');
return;
}
setCreating(true);
try {
await onCreateTag({
label: trimmedLabel,
color: trimmedColor ? trimmedColor : null,
});
setCreateLabel('');
setCreateColor('');
} catch (createError) {
const message = createError?.message || 'Failed to create tag.';
onNotify?.(message, 'error');
} finally {
setCreating(false);
}
},
[createLabel, createColor, onCreateTag, onNotify],
);
return ( return (
<section className="tags-panel column"> <section className="tags-panel">
<div className="column-header"> <div className="panel-section__header">
<div className="column-header__titles"> <div className="panel-section__titles">
<h2>Tags</h2> <h2>Tags</h2>
<div className="column-subtitle">{tags.length} total</div> <div className="panel-section__subtitle">{tags.length} total</div>
</div> </div>
<div className="header-actions"> <div className="header-actions tags-actions">
<form className="tags-actions__form" onSubmit={handleCreate}>
<input
type="text"
placeholder="New tag label"
value={createLabel}
onChange={(event) => setCreateLabel(event.target.value)}
disabled={creating}
/>
<input
type="color"
className="tags-table__color-picker"
value={createColor || '#3366ff'}
onChange={(event) => setCreateColor(event.target.value)}
disabled={creating}
aria-label="Tag color (optional)"
/>
{createColor && (
<button
type="button"
className="secondary"
onClick={() => setCreateColor('')}
disabled={creating}
>
Clear
</button>
)}
<button type="submit" disabled={creating || !createLabel.trim()}>
{creating ? 'Creating…' : 'Create'}
</button>
</form>
<button <button
className="secondary" className="secondary"
type="button" type="button"
onClick={onRefresh} onClick={onRefresh}
disabled={saving || Boolean(deletingId)} disabled={saving || creating || Boolean(deletingId)}
> >
Refresh Refresh
</button> </button>
</div> </div>
</div> </div>
<div className="column-body tags-panel__body"> <div className="panel-section__body tags-panel__body">
{tags.length === 0 ? ( {tags.length === 0 ? (
<div className="empty-state">No tags created yet.</div> <div className="empty-state">No tags created yet.</div>
) : ( ) : (
+131 -6
View File
@@ -1,14 +1,25 @@
import { import {
IconChevronRight as TablerChevronRight, IconChevronRight as TablerChevronRight,
IconDownload as TablerDownload, IconDownload as TablerDownload,
IconFolderFilled,
IconPencil, IconPencil,
IconTagFilled, IconTagFilled,
IconUserFilled, IconUserFilled,
IconTrash, IconTrash,
IconLayoutList, IconLayoutList,
IconLayoutGrid, IconLayoutGrid,
IconArrowLeft,
IconArrowRight,
IconArrowUp,
IconChevronsRight,
IconChevronsLeft,
IconAnalyze,
IconWindowMaximize,
IconTextScan2,
IconFolderPlus,
IconRefresh,
IconMinusVertical,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import FolderSvg from '../assets/folder.svg';
const composeClassName = (base, extra) => (extra ? `${base} ${extra}` : base); const composeClassName = (base, extra) => (extra ? `${base} ${extra}` : base);
@@ -39,14 +50,21 @@ export const EditIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) =>
/> />
); );
export const FolderIcon = ({ className, size = '1em', stroke = 0, ...rest }) => ( export const FolderIcon = ({ className, size = 16, title, ...rest }) => {
<IconFolderFilled const dimensionProps = typeof size === 'number' ? { width: size, height: size } : {};
className={composeClassName('icon icon--fill', className)}
size={size} return (
stroke={stroke} <FolderSvg
className={composeClassName('folder-icon', className)}
{...dimensionProps}
role={title ? 'img' : 'presentation'}
aria-hidden={title ? undefined : true}
focusable="false"
title={title}
{...rest} {...rest}
/> />
); );
};
export const TagIcon = ({ className, size = '1em', stroke = 0, ...rest }) => ( export const TagIcon = ({ className, size = '1em', stroke = 0, ...rest }) => (
<IconTagFilled <IconTagFilled
@@ -93,6 +111,96 @@ export const ViewGridIcon = ({ className, size = '1em', stroke = 1.6, ...rest })
/> />
); );
export const ArrowLeftIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconArrowLeft
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export const ArrowRightIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconArrowRight
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export const ChevronsLeftIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconChevronsLeft
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export const ChevronsRightIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconChevronsRight
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export const FolderPlusIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconFolderPlus
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export const RefreshIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconRefresh
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export const ArrowUpIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconArrowUp
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export const MinusVerticalIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconMinusVertical
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export const AnalyzeIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconAnalyze
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export const WindowMaximizeIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconWindowMaximize
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export default { export default {
ChevronIcon, ChevronIcon,
TrashIcon, TrashIcon,
@@ -103,4 +211,21 @@ export default {
DownloadIcon, DownloadIcon,
ViewListIcon, ViewListIcon,
ViewGridIcon, ViewGridIcon,
ChevronsLeftIcon,
ChevronsRightIcon,
AnalyzeIcon,
WindowMaximizeIcon,
FolderPlusIcon,
RefreshIcon,
ArrowUpIcon,
MinusVerticalIcon,
}; };
export const TextScanIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconTextScan2
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
+10
View File
@@ -29,6 +29,16 @@ module.exports = {
test: /\.css$/i, test: /\.css$/i,
use: ['style-loader', 'css-loader'], use: ['style-loader', 'css-loader'],
}, },
{
test: /\.svg$/i,
issuer: /\.[jt]sx?$/,
use: ['@svgr/webpack'],
},
{
test: /\.svg$/i,
type: 'asset/resource',
issuer: { not: [/\.[jt]sx?$/] },
},
], ],
}, },
plugins: [ plugins: [
+11
View File
@@ -0,0 +1,11 @@
# tmux session for Papercrate dev stack
new-session -d -s papercrate -n docker -c . 'docker compose up'
new-window -t papercrate:1 -n frontend -c ./frontend 'npm run dev'
new-window -t papercrate:2 -n backend -c ./backend 'cargo run --bin backend'
new-window -t papercrate:3 -n worker -c ./backend 'cargo run --bin worker'
select-window -t papercrate:0
attach-session -t papercrate

Some files were not shown because too many files have changed in this diff Show More