mirror of
https://github.com/napnap75/multiarch-docker-images.git
synced 2026-11-11 00:22:12 +01:00
feat: enhance PDF text extraction and add maintenance features
- Refactor text extraction functions to extract text from all pages instead of just the first page. - Implement logging for better debugging during file operations. - Add a new maintenance page with options to rebuild the database and run consistency checks. - Update styles for maintenance actions and improve UI elements. - Remove obsolete rebuild database functionality from the main interface.
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -27,6 +28,8 @@ from .models import (
|
|||||||
|
|
||||||
router = APIRouter(tags=["files"])
|
router = APIRouter(tags=["files"])
|
||||||
|
|
||||||
|
logger = logging.getLogger('uvicorn.error')
|
||||||
|
|
||||||
|
|
||||||
def _storage(request: Request) -> StorageBackend:
|
def _storage(request: Request) -> StorageBackend:
|
||||||
storage = getattr(request.app.state, "storage", None)
|
storage = getattr(request.app.state, "storage", None)
|
||||||
@@ -129,6 +132,7 @@ async def list_files(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if db.count_files() == 0:
|
if db.count_files() == 0:
|
||||||
|
logger.debug("SQLite is empty, rebuilding from sidecar metadata")
|
||||||
for key in sorted(storage.list_objects(prefix="")):
|
for key in sorted(storage.list_objects(prefix="")):
|
||||||
if not key.endswith(".meta.json"):
|
if not key.endswith(".meta.json"):
|
||||||
continue
|
continue
|
||||||
@@ -177,6 +181,7 @@ async def get_file(
|
|||||||
record = db.get_by_id(file_id)
|
record = db.get_by_id(file_id)
|
||||||
|
|
||||||
if record is None:
|
if record is None:
|
||||||
|
logger.debug(f"File '{file_id}' not found in SQLite, searching storage sidecars")
|
||||||
keys = storage.list_objects(prefix="")
|
keys = storage.list_objects(prefix="")
|
||||||
for key in keys:
|
for key in keys:
|
||||||
if not key.endswith(".meta.json"):
|
if not key.endswith(".meta.json"):
|
||||||
@@ -193,8 +198,9 @@ async def get_file(
|
|||||||
|
|
||||||
sidecar_metadata: dict[str, Any] = {}
|
sidecar_metadata: dict[str, Any] = {}
|
||||||
if record.current_key:
|
if record.current_key:
|
||||||
|
metadata_key = f"{record.current_key.split('.')[0]}.meta.json" if record.current_key.endswith(".pdf") else f"{record.current_key}.meta.json"
|
||||||
sidecar_metadata = _parse_file_metadata(
|
sidecar_metadata = _parse_file_metadata(
|
||||||
storage.get_object(f"{record.current_key}.meta.json")
|
storage.get_object(metadata_key)
|
||||||
)
|
)
|
||||||
if not sidecar_metadata or sidecar_metadata.get("sha256") not in {
|
if not sidecar_metadata or sidecar_metadata.get("sha256") not in {
|
||||||
record.sha256,
|
record.sha256,
|
||||||
@@ -257,8 +263,19 @@ async def get_file_pdf(
|
|||||||
) -> Response:
|
) -> Response:
|
||||||
"""Stream a PDF by key from storage when present."""
|
"""Stream a PDF by key from storage when present."""
|
||||||
storage = _storage(request)
|
storage = _storage(request)
|
||||||
keys = storage.list_objects(prefix="")
|
db = _db(request)
|
||||||
|
record = db.get_by_id(file_id)
|
||||||
|
|
||||||
|
key = record.current_key if record else None
|
||||||
|
if key and storage.object_exists(key):
|
||||||
|
payload = storage.get_object(key)
|
||||||
|
if payload is not None:
|
||||||
|
logger.debug(f"Streaming PDF for file_id={file_id} from key={key}")
|
||||||
|
return Response(content=payload, media_type="application/pdf")
|
||||||
|
raise HTTPException(status_code=404, detail="PDF bytes not found for the file")
|
||||||
|
|
||||||
|
logger.debug(f"PDF for file_id={file_id} not found in SQLite, searching storage sidecars")
|
||||||
|
keys = storage.list_objects(prefix="")
|
||||||
for key in keys:
|
for key in keys:
|
||||||
if key.endswith(".meta.json"):
|
if key.endswith(".meta.json"):
|
||||||
metadata = _parse_file_metadata(storage.get_object(key))
|
metadata = _parse_file_metadata(storage.get_object(key))
|
||||||
@@ -476,6 +493,7 @@ async def validate_file(
|
|||||||
record = db.get_by_id(file_id)
|
record = db.get_by_id(file_id)
|
||||||
|
|
||||||
if record is None:
|
if record is None:
|
||||||
|
logger.debug(f"File '{file_id}' not found in SQLite, searching storage sidecars")
|
||||||
keys = storage.list_objects(prefix="")
|
keys = storage.list_objects(prefix="")
|
||||||
for key in keys:
|
for key in keys:
|
||||||
if key.endswith(".meta.json"):
|
if key.endswith(".meta.json"):
|
||||||
|
|||||||
@@ -277,10 +277,10 @@ class SQLiteFileRepository:
|
|||||||
params.append(f"%{confidence_filter.lower()}%")
|
params.append(f"%{confidence_filter.lower()}%")
|
||||||
if q:
|
if q:
|
||||||
conditions.append(
|
conditions.append(
|
||||||
"(LOWER(current_key) LIKE ? OR LOWER(original_key) LIKE ? OR LOWER(template_name) LIKE ? OR LOWER(document_type) LIKE ? OR LOWER(emitting_company) LIKE ? OR LOWER(period) LIKE ? OR LOWER(emission_date) LIKE ? OR LOWER(period_format) LIKE ?)"
|
"(LOWER(current_key) LIKE ? OR LOWER(original_key) LIKE ? OR LOWER(template_name) LIKE ? OR LOWER(document_type) LIKE ? OR LOWER(emitting_company) LIKE ? OR LOWER(period) LIKE ? OR LOWER(emission_date) LIKE ? OR LOWER(period_format) LIKE ? OR LOWER(extracted_text) LIKE ?)"
|
||||||
)
|
)
|
||||||
like_value = f"%{q.lower()}%"
|
like_value = f"%{q.lower()}%"
|
||||||
params.extend([like_value] * 8)
|
params.extend([like_value] * 9)
|
||||||
|
|
||||||
return conditions, params
|
return conditions, params
|
||||||
|
|
||||||
|
|||||||
@@ -19,17 +19,16 @@ class TextExtractionError(Exception):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def extract_first_page_text(pdf_bytes: bytes) -> str:
|
def extract_document_text(pdf_bytes: bytes) -> str:
|
||||||
"""Extract text from the first page of a PDF.
|
"""Extract text from every page of a PDF.
|
||||||
|
|
||||||
Uses PyMuPDF for fast text extraction (PRD stack decision).
|
Uses PyMuPDF for fast text extraction (PRD stack decision).
|
||||||
Extracts only the first page as per FR2.2.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
pdf_bytes: The PDF file content as bytes.
|
pdf_bytes: The PDF file content as bytes.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The extracted text from the first page.
|
The extracted text from the complete document, in page order.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
TextExtractionError: If extraction fails or PDF is invalid.
|
TextExtractionError: If extraction fails or PDF is invalid.
|
||||||
@@ -42,33 +41,34 @@ def extract_first_page_text(pdf_bytes: bytes) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Open PDF from bytes
|
|
||||||
doc = fitz.open(stream=pdf_bytes)
|
doc = fitz.open(stream=pdf_bytes)
|
||||||
|
try:
|
||||||
|
if len(doc) == 0:
|
||||||
|
logger.warning("PDF has no pages")
|
||||||
|
return ""
|
||||||
|
|
||||||
if len(doc) == 0:
|
return "\n".join(page.get_text() for page in doc)
|
||||||
logger.warning("PDF has no pages")
|
finally:
|
||||||
return ""
|
doc.close()
|
||||||
|
|
||||||
# Extract text from first page only (FR2.2)
|
|
||||||
first_page = doc[0]
|
|
||||||
text = first_page.get_text()
|
|
||||||
|
|
||||||
doc.close()
|
|
||||||
return text
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to extract text from PDF: {e}")
|
logger.error(f"Failed to extract text from PDF: {e}")
|
||||||
raise TextExtractionError(f"Text extraction failed: {e}") from e
|
raise TextExtractionError(f"Text extraction failed: {e}") from e
|
||||||
|
|
||||||
|
|
||||||
def extract_first_page_text_from_file(filepath: str) -> str:
|
def extract_first_page_text(pdf_bytes: bytes) -> str:
|
||||||
"""Extract text from the first page of a PDF file.
|
"""Compatibility wrapper for extract_document_text."""
|
||||||
|
return extract_document_text(pdf_bytes)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_document_text_from_file(filepath: str) -> str:
|
||||||
|
"""Extract text from every page of a PDF file.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
filepath: Path to the PDF file.
|
filepath: Path to the PDF file.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The extracted text from the first page.
|
The extracted text from the complete document.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
TextExtractionError: If extraction fails or PDF is invalid.
|
TextExtractionError: If extraction fails or PDF is invalid.
|
||||||
@@ -80,7 +80,12 @@ def extract_first_page_text_from_file(filepath: str) -> str:
|
|||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
raise FileNotFoundError(f"PDF file not found: {filepath}")
|
raise FileNotFoundError(f"PDF file not found: {filepath}")
|
||||||
|
|
||||||
return extract_first_page_text(pdf_bytes)
|
return extract_document_text(pdf_bytes)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_first_page_text_from_file(filepath: str) -> str:
|
||||||
|
"""Compatibility wrapper for extract_document_text_from_file."""
|
||||||
|
return extract_document_text_from_file(filepath)
|
||||||
|
|
||||||
|
|
||||||
def is_text_empty(text: str, min_length: int = MIN_TEXT_LENGTH) -> bool:
|
def is_text_empty(text: str, min_length: int = MIN_TEXT_LENGTH) -> bool:
|
||||||
@@ -108,7 +113,7 @@ def is_text_empty(text: str, min_length: int = MIN_TEXT_LENGTH) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def extract_and_validate(pdf_bytes: bytes, min_length: int = MIN_TEXT_LENGTH) -> tuple[str, bool]:
|
def extract_and_validate(pdf_bytes: bytes, min_length: int = MIN_TEXT_LENGTH) -> tuple[str, bool]:
|
||||||
"""Extract first page text and check if it's valid.
|
"""Extract document text and check if it's valid.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
pdf_bytes: The PDF file content as bytes.
|
pdf_bytes: The PDF file content as bytes.
|
||||||
@@ -121,7 +126,7 @@ def extract_and_validate(pdf_bytes: bytes, min_length: int = MIN_TEXT_LENGTH) ->
|
|||||||
Raises:
|
Raises:
|
||||||
TextExtractionError: If extraction fails.
|
TextExtractionError: If extraction fails.
|
||||||
"""
|
"""
|
||||||
text = extract_first_page_text(pdf_bytes)
|
text = extract_document_text(pdf_bytes)
|
||||||
is_valid = not is_text_empty(text, min_length)
|
is_valid = not is_text_empty(text, min_length)
|
||||||
return (text, is_valid)
|
return (text, is_valid)
|
||||||
|
|
||||||
@@ -142,7 +147,7 @@ class TextExtractor:
|
|||||||
self.min_length = min_length
|
self.min_length = min_length
|
||||||
|
|
||||||
def extract(self, pdf_bytes: bytes) -> str:
|
def extract(self, pdf_bytes: bytes) -> str:
|
||||||
"""Extract first page text from PDF bytes.
|
"""Extract text from every page of a PDF.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
pdf_bytes: The PDF file content as bytes.
|
pdf_bytes: The PDF file content as bytes.
|
||||||
@@ -150,7 +155,7 @@ class TextExtractor:
|
|||||||
Returns:
|
Returns:
|
||||||
The extracted text.
|
The extracted text.
|
||||||
"""
|
"""
|
||||||
return extract_first_page_text(pdf_bytes)
|
return extract_document_text(pdf_bytes)
|
||||||
|
|
||||||
def extract_and_validate(self, pdf_bytes: bytes) -> tuple[str, bool]:
|
def extract_and_validate(self, pdf_bytes: bytes) -> tuple[str, bool]:
|
||||||
"""Extract and validate text.
|
"""Extract and validate text.
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ from .config import (
|
|||||||
MappingRule,
|
MappingRule,
|
||||||
PeriodFormatConfig,
|
PeriodFormatConfig,
|
||||||
)
|
)
|
||||||
from .extractor import extract_first_page_text, is_text_empty, TextExtractionError
|
from .extractor import extract_document_text, is_text_empty, TextExtractionError
|
||||||
from .period import (
|
from .period import (
|
||||||
format_period_from_emission_date,
|
format_period_from_emission_date,
|
||||||
)
|
)
|
||||||
@@ -99,7 +99,7 @@ class DocumentProcessor:
|
|||||||
result = ProcessingResult(success=False)
|
result = ProcessingResult(success=False)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Step 1: Extract first page text
|
# Step 1: Extract document text
|
||||||
if extracted_text is None:
|
if extracted_text is None:
|
||||||
extracted_text = self._extract_text(pdf_bytes)
|
extracted_text = self._extract_text(pdf_bytes)
|
||||||
|
|
||||||
@@ -293,7 +293,7 @@ class DocumentProcessor:
|
|||||||
Extracted text, or None if extraction fails.
|
Extracted text, or None if extraction fails.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
text = extract_first_page_text(pdf_bytes)
|
text = extract_document_text(pdf_bytes)
|
||||||
if is_text_empty(text, self.min_text_length):
|
if is_text_empty(text, self.min_text_length):
|
||||||
return None
|
return None
|
||||||
return text
|
return text
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ import requests
|
|||||||
# Add parent directory to path for imports
|
# Add parent directory to path for imports
|
||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
from app.extractor import extract_first_page_text
|
from app.extractor import extract_document_text
|
||||||
from app.sidecar import (
|
from app.sidecar import (
|
||||||
Sidecar,
|
Sidecar,
|
||||||
SidecarStatus,
|
SidecarStatus,
|
||||||
@@ -826,7 +826,7 @@ class Importer:
|
|||||||
|
|
||||||
# Update sidecar
|
# Update sidecar
|
||||||
sidecar.sha256 = sha256
|
sidecar.sha256 = sha256
|
||||||
sidecar.extracted_text = extract_first_page_text(pdf_bytes)
|
sidecar.extracted_text = extract_document_text(pdf_bytes)
|
||||||
|
|
||||||
# Write sidecar
|
# Write sidecar
|
||||||
sidecar_key = get_sidecar_key(target_key)
|
sidecar_key = get_sidecar_key(target_key)
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ const searchFilter = document.getElementById('search-filter');
|
|||||||
const body = document.getElementById('documents-body');
|
const body = document.getElementById('documents-body');
|
||||||
const summaryCards = document.getElementById('summary-cards');
|
const summaryCards = document.getElementById('summary-cards');
|
||||||
const refreshButton = document.getElementById('refresh-btn');
|
const refreshButton = document.getElementById('refresh-btn');
|
||||||
const rebuildButton = document.getElementById('rebuild-btn');
|
|
||||||
const rebuildStatus = document.getElementById('rebuild-status');
|
|
||||||
const listStatus = document.getElementById('list-status');
|
const listStatus = document.getElementById('list-status');
|
||||||
const loadMoreButton = document.getElementById('load-more-btn');
|
const loadMoreButton = document.getElementById('load-more-btn');
|
||||||
const loadSentinel = document.getElementById('load-sentinel');
|
const loadSentinel = document.getElementById('load-sentinel');
|
||||||
@@ -183,32 +181,9 @@ const updateSortIndicators = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const rebuildDatabase = async () => {
|
|
||||||
if (!window.confirm('Rebuild the database from sidecar files? Existing database records will be replaced.')) return;
|
|
||||||
|
|
||||||
rebuildButton.disabled = true;
|
|
||||||
rebuildStatus.textContent = 'Rebuilding database from sidecar files…';
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/consistency/rebuild', { method: 'POST' });
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
rebuildStatus.textContent = `Database rebuilt from ${result.count} sidecar file${result.count === 1 ? '' : 's'}.`;
|
|
||||||
await loadDocuments();
|
|
||||||
} catch (error) {
|
|
||||||
rebuildStatus.textContent = 'Unable to rebuild the database. Check the API server and storage backend.';
|
|
||||||
console.error(error);
|
|
||||||
} finally {
|
|
||||||
rebuildButton.disabled = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
columnFilters.forEach((filter) => filter.addEventListener('input', loadDocuments));
|
columnFilters.forEach((filter) => filter.addEventListener('input', loadDocuments));
|
||||||
searchFilter.addEventListener('input', loadDocuments);
|
searchFilter.addEventListener('input', loadDocuments);
|
||||||
refreshButton.addEventListener('click', loadDocuments);
|
refreshButton.addEventListener('click', loadDocuments);
|
||||||
rebuildButton.addEventListener('click', rebuildDatabase);
|
|
||||||
loadMoreButton.addEventListener('click', () => loadDocuments({ append: true }));
|
loadMoreButton.addEventListener('click', () => loadDocuments({ append: true }));
|
||||||
sortButtons.forEach((button) => {
|
sortButtons.forEach((button) => {
|
||||||
button.addEventListener('click', () => {
|
button.addEventListener('click', () => {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
<nav class="nav">
|
<nav class="nav">
|
||||||
<a href="/">Documents</a>
|
<a href="/">Documents</a>
|
||||||
<a class="active" href="/config.html">Config</a>
|
<a class="active" href="/config.html">Config</a>
|
||||||
|
<a href="/maintenance.html">Maintenance</a>
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
<nav class="nav">
|
<nav class="nav">
|
||||||
<a class="active" href="/">Documents</a>
|
<a class="active" href="/">Documents</a>
|
||||||
<a href="/config.html">Config</a>
|
<a href="/config.html">Config</a>
|
||||||
|
<a href="/maintenance.html">Maintenance</a>
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|||||||
@@ -159,15 +159,25 @@ const buildAdditionalFieldRow = (label = '', value = '', suggestions = {}) => {
|
|||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'additional-field-row';
|
row.className = 'additional-field-row';
|
||||||
|
|
||||||
|
const labelGroup = document.createElement('div');
|
||||||
|
labelGroup.className = 'field-group';
|
||||||
|
const labelCaption = document.createElement('label');
|
||||||
|
labelCaption.textContent = 'Field name';
|
||||||
const labelSelect = document.createElement('select');
|
const labelSelect = document.createElement('select');
|
||||||
labelSelect.className = 'additional-field-label';
|
labelSelect.className = 'additional-field-label';
|
||||||
labelSelect.setAttribute('aria-label', 'Additional field name');
|
labelSelect.setAttribute('aria-label', 'Additional field name');
|
||||||
|
labelGroup.append(labelCaption, labelSelect);
|
||||||
const labelValues = [...new Set([...(suggestions.names || []), label].filter((item) => item !== null && item !== undefined && String(item).trim() !== ''))];
|
const labelValues = [...new Set([...(suggestions.names || []), label].filter((item) => item !== null && item !== undefined && String(item).trim() !== ''))];
|
||||||
setSelectOptions(labelSelect, labelValues, label);
|
setSelectOptions(labelSelect, labelValues, label);
|
||||||
|
|
||||||
|
const valueGroup = document.createElement('div');
|
||||||
|
valueGroup.className = 'field-group';
|
||||||
|
const valueCaption = document.createElement('label');
|
||||||
|
valueCaption.textContent = 'Value';
|
||||||
const valueSelect = document.createElement('select');
|
const valueSelect = document.createElement('select');
|
||||||
valueSelect.className = 'additional-field-value';
|
valueSelect.className = 'additional-field-value';
|
||||||
valueSelect.setAttribute('aria-label', 'Additional field value');
|
valueSelect.setAttribute('aria-label', 'Additional field value');
|
||||||
|
valueGroup.append(valueCaption, valueSelect);
|
||||||
const valueValues = [...new Set([...getFieldValueSuggestions(suggestions, labelSelect.value), 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);
|
setSelectOptions(valueSelect, valueValues, value);
|
||||||
labelSelect.addEventListener('change', () => {
|
labelSelect.addEventListener('change', () => {
|
||||||
@@ -189,7 +199,7 @@ const buildAdditionalFieldRow = (label = '', value = '', suggestions = {}) => {
|
|||||||
updateSaveButtonState();
|
updateSaveButtonState();
|
||||||
});
|
});
|
||||||
|
|
||||||
row.append(labelSelect, valueSelect, removeButton);
|
row.append(labelGroup, valueGroup, removeButton);
|
||||||
return row;
|
return row;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
<nav class="nav">
|
<nav class="nav">
|
||||||
<a class="active" href="/">Documents</a>
|
<a class="active" href="/">Documents</a>
|
||||||
<a href="/config.html">Config</a>
|
<a href="/config.html">Config</a>
|
||||||
|
<a href="/maintenance.html">Maintenance</a>
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -26,18 +27,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="toolbar-actions">
|
<div class="toolbar-actions">
|
||||||
<button id="refresh-btn" class="primary-btn">Refresh</button>
|
<button id="refresh-btn" class="primary-btn">Refresh</button>
|
||||||
<button id="rebuild-btn" class="secondary-btn" type="button">Rebuild database</button>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<p id="rebuild-status" class="action-status" role="status" aria-live="polite"></p>
|
|
||||||
|
|
||||||
<section class="summary-cards" id="summary-cards"></section>
|
<section class="summary-cards" id="summary-cards"></section>
|
||||||
|
|
||||||
<section class="filters-card">
|
<section class="filters-card">
|
||||||
<div class="field-group">
|
<div class="field-group">
|
||||||
<label for="search-filter">Search all fields</label>
|
<label for="search-filter">Global search</label>
|
||||||
<input id="search-filter" type="text" placeholder="Search documents..." />
|
<input id="search-filter" type="text" placeholder="Search metadata and document content..." />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Maintenance | 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 href="/config.html">Config</a>
|
||||||
|
<a class="active" href="/maintenance.html">Maintenance</a>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="main-panel">
|
||||||
|
<header class="toolbar">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">System tools</p>
|
||||||
|
<h2>Maintenance</h2>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="maintenance-actions" aria-label="Maintenance actions">
|
||||||
|
<article class="maintenance-action">
|
||||||
|
<div>
|
||||||
|
<h3>Rebuild database</h3>
|
||||||
|
<p>Replace the database index using the metadata sidecar files in storage.</p>
|
||||||
|
</div>
|
||||||
|
<button id="rebuild-button" class="secondary-btn" type="button">Rebuild database</button>
|
||||||
|
<div id="rebuild-operation" class="maintenance-operation" hidden>
|
||||||
|
<progress id="rebuild-progress" aria-label="Database rebuild progress"></progress>
|
||||||
|
<p id="rebuild-status" class="action-status" role="status" aria-live="polite"></p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="maintenance-action">
|
||||||
|
<div>
|
||||||
|
<h3>Consistency check</h3>
|
||||||
|
<p>Compare database records and metadata sidecars with the files present in storage.</p>
|
||||||
|
</div>
|
||||||
|
<button id="check-button" class="secondary-btn" type="button">Run consistency check</button>
|
||||||
|
<div id="check-operation" class="maintenance-operation" hidden>
|
||||||
|
<progress id="check-progress" aria-label="Consistency check progress"></progress>
|
||||||
|
<p id="check-status" class="action-status" role="status" aria-live="polite"></p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/maintenance.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
const rebuildButton = document.getElementById('rebuild-button');
|
||||||
|
const checkButton = document.getElementById('check-button');
|
||||||
|
const actionButtons = [rebuildButton, checkButton];
|
||||||
|
|
||||||
|
const runAction = async ({ operation, progress, status, endpoint, method, confirmation, getResult }) => {
|
||||||
|
if (!window.confirm(confirmation)) return;
|
||||||
|
|
||||||
|
actionButtons.forEach((actionButton) => { actionButton.disabled = true; });
|
||||||
|
operation.hidden = false;
|
||||||
|
progress.hidden = false;
|
||||||
|
progress.removeAttribute('value');
|
||||||
|
status.textContent = 'Running...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(endpoint, { method });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
const result = await response.json();
|
||||||
|
progress.value = 1;
|
||||||
|
status.textContent = getResult(result);
|
||||||
|
} catch (error) {
|
||||||
|
progress.hidden = true;
|
||||||
|
status.textContent = 'The action failed. Check the API server and storage backend.';
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
actionButtons.forEach((actionButton) => { actionButton.disabled = false; });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
rebuildButton.addEventListener('click', () => runAction({
|
||||||
|
operation: document.getElementById('rebuild-operation'),
|
||||||
|
progress: document.getElementById('rebuild-progress'),
|
||||||
|
status: document.getElementById('rebuild-status'),
|
||||||
|
endpoint: '/api/consistency/rebuild',
|
||||||
|
method: 'POST',
|
||||||
|
confirmation: 'Rebuild the database from sidecar files? Existing database records will be replaced.',
|
||||||
|
getResult: (result) => `Database rebuilt from ${result.count} sidecar file${result.count === 1 ? '' : 's'}.`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
checkButton.addEventListener('click', () => runAction({
|
||||||
|
operation: document.getElementById('check-operation'),
|
||||||
|
progress: document.getElementById('check-progress'),
|
||||||
|
status: document.getElementById('check-status'),
|
||||||
|
endpoint: '/api/consistency',
|
||||||
|
method: 'GET',
|
||||||
|
confirmation: 'Run a consistency check? This scans metadata and database records against storage.',
|
||||||
|
getResult: (anomalies) => anomalies.length
|
||||||
|
? `Consistency check complete: ${anomalies.length} issue${anomalies.length === 1 ? '' : 's'} found.`
|
||||||
|
: 'Consistency check complete: no issues found.',
|
||||||
|
}));
|
||||||
@@ -172,6 +172,50 @@ body {
|
|||||||
padding: 18px;
|
padding: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.maintenance-actions {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
max-width: 900px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.maintenance-action {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) max-content;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px 24px;
|
||||||
|
padding: 18px;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.maintenance-action h3 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.maintenance-action > div:first-child p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.maintenance-operation {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.maintenance-operation progress {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 8px;
|
||||||
|
accent-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.maintenance-operation .action-status {
|
||||||
|
min-height: 0;
|
||||||
|
margin: 8px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
.config-description {
|
.config-description {
|
||||||
margin: 0 0 14px;
|
margin: 0 0 14px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
@@ -521,13 +565,14 @@ body {
|
|||||||
|
|
||||||
.additional-field-row {
|
.additional-field-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(160px, 1fr) minmax(180px, 1.5fr) auto;
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1.5fr) auto;
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
align-items: center;
|
align-items: end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.additional-field-row input {
|
.additional-field-row select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.additional-field-empty {
|
.additional-field-empty {
|
||||||
@@ -688,6 +733,14 @@ body {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.maintenance-action {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.maintenance-action > button {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
.main-panel {
|
.main-panel {
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user