Multiple corrections

This commit is contained in:
2026-10-10 22:35:32 +02:00
parent b8a8efcfd6
commit 6ba115059b
11 changed files with 278 additions and 48 deletions
+6
View File
@@ -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."""
+91 -21
View File
@@ -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,
+11 -3
View File
@@ -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.
+1 -1
View File
@@ -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
@@ -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(
+47
View File
@@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Config | Automatic PDF Renamer</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<div class="app-shell">
<aside class="sidebar">
<div class="brand">
<h1>PDF Renamer</h1>
</div>
<nav class="nav">
<a href="/">Documents</a>
<a class="active" href="/config.html">Config</a>
</nav>
</aside>
<main class="main-panel">
<header class="toolbar">
<div>
<p class="eyebrow">Application settings</p>
<h2>Config</h2>
</div>
</header>
<section class="config-card">
<p class="config-description">
Edit the configured JSON or JSONC file. The changes are validated before saving and loaded immediately.
</p>
<form id="config-form">
<label class="visually-hidden" for="config-editor">JSON configuration</label>
<textarea id="config-editor" class="config-editor" spellcheck="false" autocomplete="off" disabled></textarea>
<div class="config-actions">
<button id="save-config-button" class="primary-btn" type="submit" disabled>Save and reload</button>
<p id="config-status" class="action-status" role="status" aria-live="polite">Loading configuration...</p>
</div>
</form>
</section>
</main>
</div>
<script src="/config.js"></script>
</body>
</html>
+51
View File
@@ -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();
+1 -2
View File
@@ -14,8 +14,7 @@
</div>
<nav class="nav">
<a class="active" href="/">Documents</a>
<a href="/">Templates</a>
<a href="/">Consistency</a>
<a href="/config.html">Config</a>
</nav>
</aside>
+13 -5
View File
@@ -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);
+2 -3
View File
@@ -13,9 +13,8 @@
<h1>PDF Renamer</h1>
</div>
<nav class="nav">
<a class="active" href="#">Documents</a>
<a href="#">Templates</a>
<a href="#">Consistency</a>
<a class="active" href="/">Documents</a>
<a href="/config.html">Config</a>
</nav>
</aside>
+39 -1
View File
@@ -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);