mirror of
https://github.com/napnap75/multiarch-docker-images.git
synced 2026-11-11 00:22:12 +01:00
Multiple corrections
This commit is contained in:
@@ -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."""
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user