diff --git a/automatic-pdf-renamer/app/api/models.py b/automatic-pdf-renamer/app/api/models.py index aa61961..11aab04 100644 --- a/automatic-pdf-renamer/app/api/models.py +++ b/automatic-pdf-renamer/app/api/models.py @@ -58,6 +58,12 @@ class DocumentEditRequest(BaseModel): optional_fields: dict[str, Any] = Field(default_factory=dict) +class ConfigUpdateRequest(BaseModel): + """JSONC configuration text submitted by the dashboard.""" + + content: str + + class ValidationResponse(BaseModel): """Validation result payload.""" diff --git a/automatic-pdf-renamer/app/api/routes.py b/automatic-pdf-renamer/app/api/routes.py index f185264..47b05fe 100644 --- a/automatic-pdf-renamer/app/api/routes.py +++ b/automatic-pdf-renamer/app/api/routes.py @@ -1,12 +1,15 @@ from __future__ import annotations import json +import os +import re from datetime import datetime, timezone +from pathlib import Path from typing import Any from fastapi import APIRouter, Depends, HTTPException, Request, Response, status -from app.config import load_templates +from app.config import TemplateRegistry, load_templates from app.db import FileRecord, SQLiteFileRepository from app.period import format_period_from_emission_date from app.storage import StorageBackend @@ -15,6 +18,7 @@ from .deps import get_current_user from .models import ( FileDetail, FileListItem, + ConfigUpdateRequest, DocumentEditRequest, HealthResponse, ValidationRequest, @@ -46,6 +50,15 @@ def _registry(request: Request): return registry +def _template_config_path() -> Path: + configured_path = Path(os.environ.get("TEMPLATE_PATH", "./config.jsonc")) + if configured_path.suffix.lower() not in {".json", ".jsonc"}: + raise HTTPException(status_code=400, detail="The configured template file is not JSON or JSONC.") + if not configured_path.is_file(): + raise HTTPException(status_code=404, detail="The configured template file does not exist.") + return configured_path.resolve() + + def _parse_file_metadata(raw: str | bytes | None) -> dict[str, Any]: if raw is None: return {} @@ -318,6 +331,7 @@ async def edit_file( "period_format": period_format, "template_name": template_name, "emitting_company": emitting_company, + "additional_fields": ", ".join(updated_optional_fields.values()), "original_filename": record.original_key.rsplit("/", 1)[-1], }) for _ in range(20): @@ -517,45 +531,101 @@ async def validate_file( async def document_edit_options( request: Request, current_user: dict[str, str] | None = Depends(get_current_user), -) -> dict[str, list[str]]: +) -> dict[str, Any]: """Return configured templates and existing document values for edit suggestions.""" registry = _registry(request) db = _db(request) records = db.list_files(page_size=10000) - companies = sorted({ + companies = { + str(company).strip() + for company in registry.get_companies() + if str(company).strip() + } | { str(record.emitting_company).strip() for record in records if record.emitting_company and str(record.emitting_company).strip() - }) - document_types = sorted({ + } + document_types = { + str(document_type).strip() + for document_type in registry.get_document_types() + if str(document_type).strip() + } | { str(record.document_type).strip() for record in records if record.document_type and str(record.document_type).strip() - }) - optional_field_names = sorted({ - str(name).strip() - for record in records - for name in (record.optional_fields or {}).keys() - if str(name).strip() - }) - optional_field_values = sorted({ - str(value).strip() - for record in records - for value in (record.optional_fields or {}).values() - if value is not None and str(value).strip() - }) + } + optional_field_values_by_name: dict[str, set[str]] = {} + for name, values in registry.get_additional_fields().items(): + field_name = str(name).strip() + if field_name: + optional_field_values_by_name[field_name] = { + str(value).strip() + for value in values + if value is not None and str(value).strip() + } + optional_field_names = set(optional_field_values_by_name) + for record in records: + for name, value in (record.optional_fields or {}).items(): + field_name = str(name).strip() + field_value = str(value).strip() if value is not None else "" + if field_name: + optional_field_names.add(field_name) + if field_value: + optional_field_values_by_name.setdefault(field_name, set()).add(field_value) + optional_field_values = { + name: sorted(values) + for name, values in sorted(optional_field_values_by_name.items()) + } return { "templates": registry.list_all_templates(), "period_formats": registry.list_period_formats(), - "companies": companies, - "document_types": document_types, - "optional_field_names": optional_field_names, + "companies": sorted(companies), + "document_types": sorted(document_types), + "optional_field_names": sorted(optional_field_names), "optional_field_values": optional_field_values, } +@router.get("/api/config") +async def get_config( + current_user: dict[str, str] | None = Depends(get_current_user), +) -> dict[str, str]: + """Return the configured JSONC file for editing in the dashboard.""" + path = _template_config_path() + try: + return {"content": path.read_text(encoding="utf-8")} + except OSError as error: + raise HTTPException(status_code=500, detail="Unable to read the configured template file.") from error + + +@router.put("/api/config") +async def update_config( + payload: ConfigUpdateRequest, + request: Request, + current_user: dict[str, str] | None = Depends(get_current_user), +) -> dict[str, bool]: + """Validate and save JSONC configuration, then activate it without restarting the process.""" + registry = TemplateRegistry() + try: + registry.load_from_content(payload.content) + except (json.JSONDecodeError, ValueError, TypeError, AttributeError, KeyError, re.error) as error: + raise HTTPException(status_code=422, detail=f"Invalid configuration: {error}") from error + + path = _template_config_path() + try: + with path.open("w", encoding="utf-8") as config_file: + config_file.write(payload.content) + config_file.flush() + os.fsync(config_file.fileno()) + request.app.state.template_registry = registry + except OSError as error: + raise HTTPException(status_code=500, detail="Unable to save the configured template file.") from error + + return {"saved": True, "reloaded": True} + + @router.get("/templates") async def list_templates( request: Request, diff --git a/automatic-pdf-renamer/app/config.py b/automatic-pdf-renamer/app/config.py index 5570dca..fee4b89 100644 --- a/automatic-pdf-renamer/app/config.py +++ b/automatic-pdf-renamer/app/config.py @@ -351,7 +351,11 @@ class TemplateRegistry: with open(path, "r", encoding="utf-8") as f: content = f.read() - + + self.load_from_content(content) + + def load_from_content(self, content: str) -> None: + """Load configuration from JSONC text.""" # Strip comments from JSONC json_content = self._strip_jsonc_comments(content) @@ -359,10 +363,14 @@ class TemplateRegistry: try: data = json.loads(json_content) except json.JSONDecodeError as e: - logger.error(f"Failed to parse JSONC file {path}: {e}") + logger.error(f"Failed to parse JSONC configuration: {e}") raise - + + if not isinstance(data, dict): + raise ValueError("Configuration must be a JSON object.") + self._load_new_format(data) + self._is_new_format = True def _strip_jsonc_comments(self, content: str) -> str: """Strip // line comments and /* */ block comments from JSONC content. diff --git a/automatic-pdf-renamer/arch.md b/automatic-pdf-renamer/arch.md index 54d2d43..82dc90c 100644 --- a/automatic-pdf-renamer/arch.md +++ b/automatic-pdf-renamer/arch.md @@ -136,7 +136,7 @@ A pure function of (emission date, template period rules): granularity in {day, ### 4.7 Frontend -Single-page React app, built with Vite into static assets served by FastAPI. Views: file list (filters, counters, badges), detail page (embedded react-pdf viewer, five common fields, optional fields, template dropdown with candidate scores, live key preview), templates list, retrain and consistency pages. Auth: login form, session cookie, all API routes behind the auth dependency except the PDF stream which shares it. +The dashboard is served as static assets by FastAPI. Views: file list (filters, counters, badges), detail page (PDF preview and editable metadata), and a Config page for editing JSONC configuration. Saving validates the content before updating the configured file and reloading the in-memory template registry. Auth: API routes use the configured dashboard credentials. ## 5. Data Flow: Renaming and Validation diff --git a/automatic-pdf-renamer/scripts/import_paperless.py b/automatic-pdf-renamer/scripts/import_paperless.py index 38db0c0..da97e9f 100755 --- a/automatic-pdf-renamer/scripts/import_paperless.py +++ b/automatic-pdf-renamer/scripts/import_paperless.py @@ -26,6 +26,7 @@ import hashlib import json import logging import os +from pydoc import doc import sys from dataclasses import dataclass, field from pathlib import Path @@ -403,25 +404,22 @@ class DocumentProcessor: self.storage = storage self.pdf_processor = PDFProcessor() - def _unique_target_key(self, target_key: str) -> str: - """Add an incremental suffix when a generated PDF key already exists.""" - pdf_key = f"{target_key}.pdf" + def _unique_target_key(self, pdf_key: str) -> str: + """Add an incremental suffix if the PDF key or its sidecar already exists.""" if not self.storage.object_exists(pdf_key) and not self.storage.object_exists( get_sidecar_key(pdf_key) ): - return target_key + return pdf_key - directory, separator, filename = target_key.rpartition("/") - extension = ".pdf" if filename.lower().endswith(".pdf") else "" - stem = filename[:-len(extension)] if extension else filename + directory, separator, filename = pdf_key.rpartition("/") + stem, extension = os.path.splitext(filename) counter = 2 while True: candidate_filename = f"{stem} ({counter}){extension}" candidate = f"{directory}{separator}{candidate_filename}" - candidate_pdf_key = f"{candidate}.pdf" - if not self.storage.object_exists(candidate_pdf_key) and not self.storage.object_exists( - get_sidecar_key(candidate_pdf_key) + if not self.storage.object_exists(candidate) and not self.storage.object_exists( + get_sidecar_key(candidate) ): return candidate counter += 1 @@ -574,11 +572,13 @@ class DocumentProcessor: errors.extend(tag_errors) if resolved.additional_fields and not assigned_fields: - errors.append("No tags matched expected additional fields") + errors.append("The tags " + ", ".join(doc.tags) + " did not match expected additional fields: " + ", ".join(resolved.additional_fields)) if errors: return self._route_to_pending(doc, errors) + logger.debug(f"Resolved mapping for document {doc.id}: template={resolved.template}, period_format={resolved.period_format}, additional_fields={assigned_fields}") + # Step 5: Calculate period try: period_format_config = self.registry.get_period_format_config(resolved.period_format) @@ -612,6 +612,7 @@ class DocumentProcessor: emission_date=doc.created_date, period=period, original_filename=doc.filename, + additional_fields=", ".join(assigned_fields.values()) if assigned_fields else "", **assigned_fields ) except Exception as e: @@ -619,7 +620,10 @@ class DocumentProcessor: return self._route_to_pending(doc, errors) # Step 7: Build sidecar for files/ - pdf_key = self._unique_target_key(f"files/{final_key}.pdf") + pdf_key = f"files/{final_key}" + if not pdf_key.lower().endswith(".pdf"): + pdf_key += ".pdf" + pdf_key = self._unique_target_key(pdf_key) sha256 = "" # Will be computed when writing sidecar = build_sidecar( diff --git a/automatic-pdf-renamer/web/config.html b/automatic-pdf-renamer/web/config.html new file mode 100644 index 0000000..a71e001 --- /dev/null +++ b/automatic-pdf-renamer/web/config.html @@ -0,0 +1,47 @@ + + + + + + Config | Automatic PDF Renamer + + + +
+ + +
+
+
+

Application settings

+

Config

+
+
+ +
+

+ Edit the configured JSON or JSONC file. The changes are validated before saving and loaded immediately. +

+
+ + +
+ +

Loading configuration...

+
+
+
+
+
+ + + + diff --git a/automatic-pdf-renamer/web/config.js b/automatic-pdf-renamer/web/config.js new file mode 100644 index 0000000..ac2e6b4 --- /dev/null +++ b/automatic-pdf-renamer/web/config.js @@ -0,0 +1,51 @@ +const form = document.getElementById('config-form'); +const editor = document.getElementById('config-editor'); +const saveButton = document.getElementById('save-config-button'); +const status = document.getElementById('config-status'); + +const loadConfig = async () => { + try { + const response = await fetch('/api/config'); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const result = await response.json(); + editor.value = result.content; + editor.disabled = false; + saveButton.disabled = false; + status.textContent = ''; + } catch (error) { + status.textContent = 'Unable to load the configuration file.'; + console.error(error); + } +}; + +form.addEventListener('submit', async (event) => { + event.preventDefault(); + saveButton.disabled = true; + editor.disabled = true; + status.textContent = 'Validating and saving configuration...'; + + try { + const response = await fetch('/api/config', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content: editor.value }), + }); + if (!response.ok) { + const result = await response.json(); + throw new Error(result.detail || `HTTP ${response.status}`); + } + + status.textContent = 'Configuration saved and reloaded. Reloading the page...'; + window.setTimeout(() => window.location.reload(), 600); + } catch (error) { + status.textContent = error.message || 'Unable to save the configuration file.'; + console.error(error); + editor.disabled = false; + saveButton.disabled = false; + } +}); + +loadConfig(); diff --git a/automatic-pdf-renamer/web/document.html b/automatic-pdf-renamer/web/document.html index f8ac070..ebd74f7 100644 --- a/automatic-pdf-renamer/web/document.html +++ b/automatic-pdf-renamer/web/document.html @@ -14,8 +14,7 @@ diff --git a/automatic-pdf-renamer/web/document.js b/automatic-pdf-renamer/web/document.js index e32d734..f738edc 100644 --- a/automatic-pdf-renamer/web/document.js +++ b/automatic-pdf-renamer/web/document.js @@ -24,7 +24,7 @@ const saveButton = document.getElementById('save-document-button'); const editStatus = document.getElementById('edit-status'); saveButton.disabled = true; let currentFileId = null; -let fieldSuggestionState = { names: [], values: [] }; +let fieldSuggestionState = { names: [], values: {} }; let initialDocumentState = null; const canonicalizeState = (value) => { @@ -150,6 +150,11 @@ const addMetadata = (target, label, value) => { target.append(term, description); }; +const getFieldValueSuggestions = (suggestions, label) => { + const values = suggestions.values?.[label]; + return Array.isArray(values) ? values : []; +}; + const buildAdditionalFieldRow = (label = '', value = '', suggestions = {}) => { const row = document.createElement('div'); row.className = 'additional-field-row'; @@ -163,8 +168,11 @@ const buildAdditionalFieldRow = (label = '', value = '', suggestions = {}) => { const valueSelect = document.createElement('select'); valueSelect.className = 'additional-field-value'; valueSelect.setAttribute('aria-label', 'Additional field value'); - const valueValues = [...new Set([...(suggestions.values || []), value].filter((item) => item !== null && item !== undefined && String(item).trim() !== ''))]; + const valueValues = [...new Set([...getFieldValueSuggestions(suggestions, labelSelect.value), value].filter((item) => item !== null && item !== undefined && String(item).trim() !== ''))]; setSelectOptions(valueSelect, valueValues, value); + labelSelect.addEventListener('change', () => { + setSelectOptions(valueSelect, getFieldValueSuggestions(suggestions, labelSelect.value), ''); + }); const removeButton = document.createElement('button'); removeButton.type = 'button'; @@ -207,7 +215,7 @@ const updateDocumentSuggestions = (options = {}) => { const suggestionState = { names: options.optional_field_names || [], - values: options.optional_field_values || [], + values: options.optional_field_values || {}, }; additionalFields.querySelectorAll('.additional-field-row').forEach((row) => { @@ -219,7 +227,7 @@ const updateDocumentSuggestions = (options = {}) => { } if (valueSelect) { const current = valueSelect.value || ''; - setSelectOptions(valueSelect, suggestionState.values, current); + setSelectOptions(valueSelect, getFieldValueSuggestions(suggestionState, labelSelect?.value || ''), current); } }); @@ -408,7 +416,7 @@ const loadDocument = async () => { ]); fieldSuggestionState = { names: options.optional_field_names || [], - values: options.optional_field_values || [], + values: options.optional_field_values || {}, }; setSelectOptions(templateSelect, options.templates || [], documentData.template_name); setSelectOptions(periodFormatSelect, options.period_formats || [], documentData.period_format); diff --git a/automatic-pdf-renamer/web/index.html b/automatic-pdf-renamer/web/index.html index 0b50f12..417f572 100644 --- a/automatic-pdf-renamer/web/index.html +++ b/automatic-pdf-renamer/web/index.html @@ -13,9 +13,8 @@

PDF Renamer

diff --git a/automatic-pdf-renamer/web/styles.css b/automatic-pdf-renamer/web/styles.css index d41e4de..6eb48b3 100644 --- a/automatic-pdf-renamer/web/styles.css +++ b/automatic-pdf-renamer/web/styles.css @@ -160,13 +160,51 @@ body { } .filters-card, -.table-card { +.table-card, +.config-card { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; box-shadow: 0 3px 10px rgba(26, 44, 64, 0.03); } +.config-card { + padding: 18px; +} + +.config-description { + margin: 0 0 14px; + color: var(--muted); +} + +.config-editor { + display: block; + width: 100%; + min-height: 65vh; + resize: vertical; + border: 1px solid var(--border); + border-radius: 8px; + padding: 14px; + background: #fbfcfe; + color: var(--text); + font: 0.86rem/1.5 "SFMono-Regular", Consolas, monospace; + tab-size: 2; +} + +.config-actions { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + margin-top: 14px; +} + +.config-actions .action-status { + flex: 1 1 240px; + min-height: 0; + margin: 0; +} + .filters-card { display: grid; grid-template-columns: minmax(220px, 1fr);