Application settings
+Config
++ Edit the configured JSON or JSONC file. The changes are validated before saving and loaded immediately. +
+ +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 @@ + + +
+ + +Application settings
++ Edit the configured JSON or JSONC file. The changes are validated before saving and loaded immediately. +
+ +