diff --git a/ocr_service/main.py b/ocr_service/main.py index 5ca51eb..1d5a43f 100644 --- a/ocr_service/main.py +++ b/ocr_service/main.py @@ -69,7 +69,7 @@ def start_processing(item: ProcessItem): except ocrmypdf.UnsupportedImageFormatError: logger.error(f"Unsupported image format: {item.local_file_path}") item.ocr_status = OCRStatus.UNSUPPORTED - ocr_error = "Unsupported image format" + ocr_error = OCRStatus.UNSUPPORTED.value except ocrmypdf.DpiError as dpiex: logger.error(f"DPI error: {item.local_file_path} {dpiex}") item.ocr_status = OCRStatus.DPI_ERROR diff --git a/scansynclib/scansynclib/ProcessItem.py b/scansynclib/scansynclib/ProcessItem.py index bca857a..744836b 100644 --- a/scansynclib/scansynclib/ProcessItem.py +++ b/scansynclib/scansynclib/ProcessItem.py @@ -59,29 +59,31 @@ def get_progress(cls, status: ProcessStatus) -> int: class OCRStatus(Enum): """Enumeration of possible OCR statuses. - UNKNOWN: OCR status not yet determined. - PENDING: Item is queued for OCR. - PROCESSING: OCR is currently running on the item. - COMPLETED: OCR completed successfully on the item. - FAILED: OCR failed on the item. - SKIPPED: Item was skipped and OCR was not performed. - UNSUPPORTED: Item type is not supported for OCR. - DPI_ERROR: Image DPI is too low for accurate OCR. - INPUT_ERROR: Error reading input image/PDF. - OUTPUT_ERROR: Error writing OCR output file. + Each value is the human-readable description shown in the web UI as the status badge. + + UNKNOWN: Status has not yet been determined for this item. + PENDING: Item is queued and waiting for OCR processing to begin. + PROCESSING: OCR is actively running on the item. + COMPLETED: OCR finished successfully and text was extracted. + FAILED: OCR finished but the output was unusable. + SKIPPED: OCR was not performed on this item. + UNSUPPORTED: The image format is not supported by the OCR engine. + DPI_ERROR: The image resolution is too low for accurate OCR. + INPUT_ERROR: The input file could not be read by the OCR engine. + OUTPUT_ERROR: The OCR engine could not write the output file. NO_TEXT: OCR completed but the output file contained no extractable text. """ - UNKNOWN = 0 - PENDING = 1 - PROCESSING = 2 - COMPLETED = 3 - FAILED = -1 - SKIPPED = -2 - UNSUPPORTED = -3 - DPI_ERROR = -4 - INPUT_ERROR = -5 - OUTPUT_ERROR = -6 - NO_TEXT = -7 + UNKNOWN = "Unknown" + PENDING = "Waiting for OCR" + PROCESSING = "OCR in progress" + COMPLETED = "OCR completed" + FAILED = "OCR failed" + SKIPPED = "OCR skipped" + UNSUPPORTED = "Unsupported image format" + DPI_ERROR = "Image DPI too low for accurate OCR" + INPUT_ERROR = "Error reading input file" + OUTPUT_ERROR = "Error writing OCR output file" + NO_TEXT = "No text found in OCR output" class FileNamingStatus(Enum): @@ -185,6 +187,9 @@ def __init__(self, local_file_path: str, item_type: ItemType, status: ProcessSta self.file_naming_db_id = None """The ID of the file naming entry in the database, if applicable.""" + self.sync_db_id = None + """The ID of the sync (upload) entry in the database, if applicable.""" + self.file_naming_status = FileNamingStatus.PENDING """The status of the file naming process.""" diff --git a/scansynclib/scansynclib/db/schema.sql b/scansynclib/scansynclib/db/schema.sql index 0f9f5d2..584bc9d 100644 --- a/scansynclib/scansynclib/db/schema.sql +++ b/scansynclib/scansynclib/db/schema.sql @@ -42,4 +42,14 @@ CREATE TABLE IF NOT EXISTS file_naming_jobs ( file_naming_status TEXT NOT NULL, success Boolean NOT NULL DEFAULT 0, error_description TEXT +); + +CREATE TABLE IF NOT EXISTS sync_jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + scanneddata_id INTEGER NOT NULL, + started DATETIME NOT NULL DEFAULT (DATETIME('now', 'localtime')), + finished DATETIME, + sync_status TEXT NOT NULL, + success Boolean NOT NULL DEFAULT 0, + error_description TEXT ); \ No newline at end of file diff --git a/tests/test_logs_api.py b/tests/test_logs_api.py new file mode 100644 index 0000000..2aaa599 --- /dev/null +++ b/tests/test_logs_api.py @@ -0,0 +1,167 @@ +"""Tests for the OCR and sync logging API endpoints.""" + +import json +import pytest +import sys +import os +from unittest.mock import patch, MagicMock + +# Add paths for imports +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../scansynclib')) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../web_service/src')) + +# Ensure the data directory exists for sqlite_wrapper module-level initialization +os.makedirs(os.path.join(os.path.dirname(__file__), '../data'), exist_ok=True) + +# Mock Redis before any scansynclib imports, since settings.py connects at module level +import redis as _real_redis + + +def _mock_from_url(*args, **kwargs): + mock_client = MagicMock() + mock_client.get.return_value = None # No existing settings in Redis + mock_client.set.return_value = True + mock_client.publish.return_value = 0 + mock_pubsub = MagicMock() + mock_pubsub.subscribe.return_value = None + mock_pubsub.listen.return_value = iter([]) # Empty iterator + mock_client.pubsub.return_value = mock_pubsub + return mock_client + + +@pytest.fixture(scope="session", autouse=True) +def mock_redis_from_url(): + """Patch redis.Redis.from_url for the entire test session and restore afterwards.""" + orig = _real_redis.Redis.from_url + _real_redis.Redis.from_url = _mock_from_url + yield + _real_redis.Redis.from_url = orig + + +@pytest.fixture +def app(): + """Create a Flask test app with the api blueprint.""" + from flask import Flask + from routes.api import api_bp + + app = Flask(__name__) + app.register_blueprint(api_bp) + app.config['TESTING'] = True + return app + + +@pytest.fixture +def client(app): + """Create a Flask test client.""" + return app.test_client() + + +class TestOcrLogsAPI: + """Test cases for the /api/ocr-logs endpoint.""" + + def test_ocr_logs_returns_paginated_data(self, client): + logs = [ + { + 'id': 2, + 'scanneddata_id': 5, + 'started': '2024-06-01 12:00:00', + 'finished': '2024-06-01 12:01:00', + 'ocr_status': 'COMPLETED', + 'ocr_error': None, + 'file_name': 'invoice.pdf', + } + ] + with patch('routes.api.execute_query') as mock_query: + mock_query.side_effect = [1, logs] # count, logs + response = client.get('/api/ocr-logs?page=1&per_page=20') + data = json.loads(response.data) + + assert response.status_code == 200 + assert data['total_count'] == 1 + assert data['total_pages'] == 1 + assert data['page'] == 1 + assert data['logs'][0]['ocr_status'] == 'COMPLETED' + + def test_ocr_logs_success_filter(self, client): + with patch('routes.api.execute_query') as mock_query: + mock_query.side_effect = [0, []] + client.get('/api/ocr-logs?filter=success') + + count_query = mock_query.call_args_list[0].args[0] + assert "ocr_status = 'COMPLETED'" in count_query + + def test_ocr_logs_failed_filter(self, client): + with patch('routes.api.execute_query') as mock_query: + mock_query.side_effect = [0, []] + client.get('/api/ocr-logs?filter=failed') + + count_query = mock_query.call_args_list[0].args[0] + assert "NOT IN ('COMPLETED', 'PROCESSING')" in count_query + + def test_ocr_logs_handles_none_count(self, client): + with patch('routes.api.execute_query') as mock_query: + mock_query.side_effect = [None, []] + response = client.get('/api/ocr-logs') + data = json.loads(response.data) + + assert response.status_code == 200 + assert data['total_count'] == 0 + assert data['total_pages'] == 0 + + def test_ocr_logs_database_error_returns_500(self, client): + with patch('routes.api.execute_query') as mock_query: + mock_query.side_effect = Exception("boom") + response = client.get('/api/ocr-logs') + + assert response.status_code == 500 + + +class TestSyncLogsAPI: + """Test cases for the /api/sync-logs endpoint.""" + + def test_sync_logs_returns_paginated_data(self, client): + logs = [ + { + 'id': 7, + 'scanneddata_id': 9, + 'started': '2024-06-01 12:00:00', + 'finished': '2024-06-01 12:02:00', + 'sync_status': 'COMPLETED', + 'success': 1, + 'error_description': None, + 'file_name': 'doc.pdf', + } + ] + with patch('routes.api.execute_query') as mock_query: + mock_query.side_effect = [1, logs] + response = client.get('/api/sync-logs') + data = json.loads(response.data) + + assert response.status_code == 200 + assert data['total_count'] == 1 + assert data['logs'][0]['sync_status'] == 'COMPLETED' + + def test_sync_logs_success_filter(self, client): + with patch('routes.api.execute_query') as mock_query: + mock_query.side_effect = [0, []] + client.get('/api/sync-logs?filter=success') + + count_query = mock_query.call_args_list[0].args[0] + assert "sync_jobs.success = 1" in count_query + + def test_sync_logs_failed_filter(self, client): + with patch('routes.api.execute_query') as mock_query: + mock_query.side_effect = [0, []] + client.get('/api/sync-logs?filter=failed') + + count_query = mock_query.call_args_list[0].args[0] + assert "sync_jobs.success = 0" in count_query + + def test_sync_logs_pagination_math(self, client): + with patch('routes.api.execute_query') as mock_query: + mock_query.side_effect = [12, []] + response = client.get('/api/sync-logs?per_page=5') + data = json.loads(response.data) + + assert data['total_count'] == 12 + assert data['total_pages'] == 3 diff --git a/upload_service/main.py b/upload_service/main.py index ebdd255..1152bf5 100644 --- a/upload_service/main.py +++ b/upload_service/main.py @@ -3,7 +3,7 @@ from scansynclib.ProcessItem import ProcessItem, ProcessStatus from scansynclib.logging import logger from scansynclib.helpers import connect_rabbitmq, move_to_failed -from scansynclib.sqlite_wrapper import update_scanneddata_database +from scansynclib.sqlite_wrapper import update_scanneddata_database, execute_query from scansynclib.onedrive_api import upload_small from scansynclib.config import config import os @@ -14,25 +14,44 @@ RABBITQUEUE = "upload_queue" +def finalize_sync_job(item: ProcessItem, error: str = None): + """Persist the final state of a sync (upload) job to the sync_jobs table.""" + success = 1 if item.status == ProcessStatus.COMPLETED else 0 + execute_query( + "UPDATE sync_jobs SET sync_status = ?, success = ?, error_description = ?, finished = DATETIME('now', 'localtime') WHERE id = ?", + (item.status.name, success, error, item.sync_db_id) + ) + + def callback(ch, method, properties, body): + item = None try: item: ProcessItem = pickle.loads(body) if not isinstance(item, ProcessItem): logger.warning("Received object, that is not of type ProcessItem. Skipping.") return logger.info(f"Received PDF for Upload: {item.filename}") + item.sync_db_id = execute_query( + "INSERT INTO sync_jobs (scanneddata_id, sync_status) VALUES (?, ?)", + (item.db_id, ProcessStatus.SYNC.name), + return_last_id=True + ) if not os.path.exists(item.ocr_file): logger.error(f"OCR file does not exist for upload: {item.ocr_file}") item.status = ProcessStatus.SYNC_FAILED update_scanneddata_database(item, {"file_status": item.status.value}) + finalize_sync_job(item, "OCR file does not exist for upload") move_to_failed(item) else: start_processing(item) ch.basic_ack(delivery_tag=method.delivery_tag) except Exception: logger.exception(f"Failed processing {body}.") - item.status = ProcessStatus.SYNC_FAILED - update_scanneddata_database(item, {"file_status": item.status.value}) + if item is not None and isinstance(item, ProcessItem): + item.status = ProcessStatus.SYNC_FAILED + update_scanneddata_database(item, {"file_status": item.status.value}) + if item.sync_db_id is not None: + finalize_sync_job(item, "Unexpected error during upload") def start_processing(item: ProcessItem): @@ -68,6 +87,7 @@ def start_processing(item: ProcessItem): logger.error(f"Failed to upload {item.ocr_file}") item.status = ProcessStatus.SYNC_FAILED move_to_failed(item) + finalize_sync_job(item, "Failed to upload file to OneDrive") else: logger.info(f"Upload completed: {item.filename}") @@ -95,6 +115,7 @@ def start_processing(item: ProcessItem): logger.exception(f"Failed to delete additional local file {additional_path}") item.status = ProcessStatus.COMPLETED + finalize_sync_job(item) update_scanneddata_database(item, {"file_status": item.status.value}) diff --git a/web_service/src/routes/api.py b/web_service/src/routes/api.py index 706b8bb..45c388e 100644 --- a/web_service/src/routes/api.py +++ b/web_service/src/routes/api.py @@ -5,6 +5,7 @@ from scansynclib.ollama_helper import test_ollama_server from scansynclib.settings import settings from scansynclib.settings_schema import FileNamingMethod, FileNamingSettings +from scansynclib.ProcessItem import OCRStatus api_bp = Blueprint('api', __name__) @@ -266,6 +267,101 @@ def file_naming_logs(): return Response(json.dumps({}), mimetype='application/json', status=500) +def _fetch_job_logs(table, success_filter, failed_filter): + """Build a paginated, filterable response for a *_jobs logging table. + + The ``table``, ``success_filter`` and ``failed_filter`` arguments are + hardcoded constants supplied by the calling route (never user input), so + they are safe to interpolate into the query. Only pagination values come + from the request and those are passed as bound parameters. + """ + try: + page = max(1, int(request.args.get('page', 1))) + except (ValueError, TypeError): + page = 1 + try: + per_page = max(1, min(100, int(request.args.get('per_page', 20)))) + except (ValueError, TypeError): + per_page = 20 + filter = request.args.get('filter', 'all').lower() + offset = (page - 1) * per_page + + where_clause = "" + if filter == "success": + where_clause = f"WHERE {success_filter}" + elif filter == "failed": + where_clause = f"WHERE {failed_filter}" + + count_query = f"SELECT COUNT(*) FROM {table} {where_clause}" + total_count = execute_query(count_query, (), return_scalar=True) or 0 + logger.debug(f"Total {table} count (filter={filter}): {total_count}") + + logs_query = f""" + SELECT {table}.*, scanneddata.file_name + FROM {table} + LEFT JOIN scanneddata ON {table}.scanneddata_id = scanneddata.id + {where_clause} + ORDER BY {table}.started DESC + LIMIT ? OFFSET ? + """ + logs = execute_query(logs_query, (per_page, offset), fetchall=True) + + return { + "logs": logs, + "page": page, + "per_page": per_page, + "total_count": total_count, + "total_pages": (total_count + per_page - 1) // per_page + } + + +@api_bp.get('/api/ocr-logs') +def ocr_logs(): + """ + Route to display the OCR logs with pagination. + Accepts 'page', 'per_page' and 'filter' as URL query parameters. + Each log entry is enriched with 'ocr_status_text' (the human-readable + description from OCRStatus) so the frontend never needs to re-define it. + """ + try: + logger.info("Requested OCR logs") + logger.debug(f"Request args: {request.args}") + response_data = _fetch_job_logs( + "ocr_jobs", + "ocr_jobs.ocr_status = 'COMPLETED'", + "ocr_jobs.ocr_status NOT IN ('COMPLETED', 'PROCESSING')" + ) + for log in (response_data.get("logs") or []): + try: + log["ocr_status_text"] = OCRStatus[log["ocr_status"]].value + except (KeyError, TypeError): + log["ocr_status_text"] = log.get("ocr_status") or "Unknown" + return Response(json.dumps(response_data, default=str), mimetype='application/json', status=200) + except Exception as e: + logger.exception(f"Error retrieving OCR logs: {e}") + return Response(json.dumps({}), mimetype='application/json', status=500) + + +@api_bp.get('/api/sync-logs') +def sync_logs(): + """ + Route to display the sync (upload) logs with pagination. + Accepts 'page', 'per_page' and 'filter' as URL query parameters. + """ + try: + logger.info("Requested sync logs") + logger.debug(f"Request args: {request.args}") + response_data = _fetch_job_logs( + "sync_jobs", + "sync_jobs.success = 1", + "sync_jobs.success = 0" + ) + return Response(json.dumps(response_data, default=str), mimetype='application/json', status=200) + except Exception as e: + logger.exception(f"Error retrieving sync logs: {e}") + return Response(json.dumps({}), mimetype='application/json', status=500) + + @api_bp.get('/api/delete-id/') def delete_id_from_db(job_id: int): """ diff --git a/web_service/src/static/js/settings.js b/web_service/src/static/js/settings.js index 0a24c17..e0f00e2 100644 --- a/web_service/src/static/js/settings.js +++ b/web_service/src/static/js/settings.js @@ -1,9 +1,7 @@ /* global ollama_enabled ollamaModel */ let isRequestPending = false; -const LOGS_PER_PAGE = 5; -let logsPage = 1; -let logsSuccessFilter = 'all'; +const LOGS_PER_PAGE = 10; document.getElementById('onedrive-settings-form').addEventListener('submit', async function(event) { event.preventDefault(); @@ -181,17 +179,11 @@ document.addEventListener('DOMContentLoaded', () => { } try { - // Only fetch logs when the accordion is opened for the first time - let loaded = false; - const logsCollapse = document.getElementById('logsCollapse'); - logsCollapse.addEventListener('show.bs.collapse', function() { - if (!loaded) { - fetchLogs(); - loaded = true; - } - }); + // Initialise the log tables. Each table lazily loads its data the first + // time its accordion is expanded (see createLogsTable). + initLogTables(); } catch (error) { - console.error('Error attaching logsCollapse event listener:', error); + console.error('Error initialising log tables:', error); } if (ollama_enabled) { @@ -204,13 +196,6 @@ document.addEventListener('DOMContentLoaded', () => { } }); -document.getElementById('refresh-logs-btn').onclick = () => fetchLogs(logsPage, logsSuccessFilter); - -document.getElementById('logs-success-filter').addEventListener('change', function() { - logsSuccessFilter = this.value; - fetchLogs(1, logsSuccessFilter); -}); - /* exported openLoginPopup */ function openLoginPopup() { document.getElementById("onedrive-container").classList.add("d-none"); @@ -507,80 +492,176 @@ function deleteOllama() { }); } -function fetchLogs(page = 1, filter = logsSuccessFilter) { - document.querySelector('#refresh-logs-btn').disabled = true; - let url = `/api/file-naming-logs?page=${page}&per_page=${LOGS_PER_PAGE}`; - if (filter && filter !== 'all') { - url += `&filter=${filter}`; +// Factory that wires up a paginated, filterable logs table. Each instance owns +// its own page/filter state and lazily loads data the first time its accordion +// is expanded. This is shared by the File Naming, OCR and Sync log tables. +function createLogsTable(config) { + let currentPage = 1; + let currentFilter = 'all'; + + const refreshBtn = document.getElementById(config.refreshBtnId); + const filterSelect = document.getElementById(config.filterId); + const collapse = document.getElementById(config.collapseId); + let loaded = false; + + function load(page = currentPage, filter = currentFilter) { + currentPage = page; + currentFilter = filter; + if (refreshBtn) { + refreshBtn.disabled = true; + } + let url = `${config.endpoint}?page=${page}&per_page=${LOGS_PER_PAGE}`; + if (filter && filter !== 'all') { + url += `&filter=${filter}`; + } + fetch(url) + .then(res => res.json()) + .then(data => render(data.logs, data.page, data.total_pages)) + .catch(() => render([], 1, 1)) + .finally(() => { + if (refreshBtn) { + refreshBtn.disabled = false; + } + }); } - fetch(url) - .then(res => res.json()) - .then(data => { - renderLogsTable(data.logs, data.page, data.total_pages, data.total_count); - }) - .catch(() => { - renderLogsTable([], 1, 1, 0); - }) - .finally(() => { - document.querySelector('#refresh-logs-btn').disabled = false; + + function render(logs, page, totalPages) { + const table = document.getElementById(config.tableId); + const tbody = table.querySelector('tbody'); + const empty = document.getElementById(config.emptyId); + const pagination = document.getElementById(config.paginationId); + tbody.innerHTML = ''; + if (!logs || logs.length === 0) { + empty.classList.remove('d-none'); + table.classList.add('d-none'); + pagination.innerHTML = ''; + return; + } + empty.classList.add('d-none'); + table.classList.remove('d-none'); + logs.forEach((log) => { + tbody.innerHTML += config.renderRow(log); }); -} + // Attach click handlers to show the full (untruncated) text. The full + // value is stored in a data attribute rather than an inline onclick to + // avoid embedding untrusted content in a JavaScript string literal. + tbody.querySelectorAll('.js-show-full').forEach((el) => { + el.addEventListener('click', () => alert(el.getAttribute('data-fulltext'))); + }); + renderPagination(pagination, page, totalPages, load, () => currentFilter); + } -function renderLogsTable(logs, page, totalPages) { - const tbody = document.querySelector('#logs-table tbody'); - const empty = document.getElementById('logs-empty'); - const pagination = document.getElementById('logs-pagination'); - tbody.innerHTML = ''; - if (!logs || logs.length === 0) { - empty.classList.remove('d-none'); - document.getElementById('logs-table').classList.add('d-none'); - pagination.innerHTML = ''; - return; + if (refreshBtn) { + refreshBtn.onclick = () => load(currentPage, currentFilter); } - empty.classList.add('d-none'); - document.getElementById('logs-table').classList.remove('d-none'); - logs.forEach((log) => { - const statusBadge = getStatusBadge(log.file_naming_status); - // Show full error on click for mobile (and always show truncated with tooltip on desktop) - let error = ''; - if (log.error_description) { - const truncated = truncate(log.error_description, 40); - error = ` - - ${truncated} - - `; - } - // Truncate file name to 15 characters - const truncatedFileName = truncate(log.file_name, 15); - let fileNameHtml = ''; - if (log.file_name && log.file_name.length > 15) { - fileNameHtml = ` - - ${escapeHtml(truncatedFileName)} - - `; - } else { - fileNameHtml = escapeHtml(log.file_name); - } - tbody.innerHTML += ` + if (filterSelect) { + filterSelect.addEventListener('change', function() { + load(1, this.value); + }); + } + if (collapse) { + collapse.addEventListener('show.bs.collapse', function() { + if (!loaded) { + load(); + loaded = true; + } + }); + } + + return { load }; +} + +function initLogTables() { + createLogsTable({ + endpoint: '/api/file-naming-logs', + collapseId: 'logsCollapse', + tableId: 'logs-table', + emptyId: 'logs-empty', + paginationId: 'logs-pagination', + filterId: 'logs-success-filter', + refreshBtnId: 'refresh-logs-btn', + renderRow: (log) => ` ${log.id} - ${statusBadge} - ${fileNameHtml} + ${getStatusBadge(log.file_naming_status)} + ${renderFileNameCell(log.file_name)} ${escapeHtml(log.method)} ${escapeHtml(log.model)} ${escapeHtml(log.started)} ${escapeHtml(log.finished)} - ${error} + ${renderErrorCell(log.error_description)} - `; + ` + }); + + createLogsTable({ + endpoint: '/api/ocr-logs', + collapseId: 'ocr-logsCollapse', + tableId: 'ocr-logs-table', + emptyId: 'ocr-logs-empty', + paginationId: 'ocr-logs-pagination', + filterId: 'ocr-logs-success-filter', + refreshBtnId: 'refresh-ocr-logs-btn', + renderRow: (log) => ` + + ${log.id} + ${getStatusBadge(log.ocr_status, log.ocr_status_text)} + ${renderFileNameCell(log.file_name)} + ${escapeHtml(log.started)} + ${escapeHtml(log.finished)} + ${renderErrorCell(log.ocr_error)} + + ` }); - renderPagination(page, totalPages); + + createLogsTable({ + endpoint: '/api/sync-logs', + collapseId: 'sync-logsCollapse', + tableId: 'sync-logs-table', + emptyId: 'sync-logs-empty', + paginationId: 'sync-logs-pagination', + filterId: 'sync-logs-success-filter', + refreshBtnId: 'refresh-sync-logs-btn', + renderRow: (log) => ` + + ${log.id} + ${getStatusBadge(log.sync_status)} + ${renderFileNameCell(log.file_name)} + ${escapeHtml(log.started)} + ${escapeHtml(log.finished)} + ${renderErrorCell(log.error_description)} + + ` + }); +} + +// Show full file name on click (mobile) and via tooltip (desktop), truncated to 15 chars. +function renderFileNameCell(fileName) { + const truncated = truncate(fileName, 15); + if (fileName && fileName.length > 15) { + return ` + + ${escapeHtml(truncated)} + + `; + } + return escapeHtml(fileName); } -function renderPagination(page, totalPages) { - const pagination = document.getElementById('logs-pagination'); +// Show full error on click (mobile) and via tooltip (desktop), truncated to 40 chars. +function renderErrorCell(errorText) { + if (!errorText) { + return ''; + } + const truncated = truncate(errorText, 40); + return ` + + ${escapeHtml(truncated)} + + `; +} + +function renderPagination(pagination, page, totalPages, loadFn, getFilter) { pagination.innerHTML = ''; if (totalPages <= 1) return; let html = ''; @@ -598,22 +679,31 @@ function renderPagination(page, totalPages) { link.onclick = (e) => { e.preventDefault(); const p = parseInt(link.getAttribute('data-page')); - if (p >= 1 && p <= totalPages) fetchLogs(p, logsSuccessFilter); + if (p >= 1 && p <= totalPages) loadFn(p, getFilter()); }; }); } -function getStatusBadge(status) { - switch (status) { - case 'COMPLETED': - return 'Completed'; - case 'FAILED': - return 'Failed'; - case 'PROCESSING': - return 'Processing'; - default: - return `${escapeHtml("Failed")}`; +function getStatusBadge(status, displayText) { + if (!status) { + return 'Unknown'; + } + const normalized = status.toUpperCase(); + const label = escapeHtml(displayText || toTitleCase(status)); + if (normalized === 'COMPLETED') { + return `${label}`; + } + if (['PENDING', 'PROCESSING', 'SYNC', 'SYNCING'].includes(normalized)) { + return `${label}`; + } + return `${label}`; +} + +function toTitleCase(status) { + if (!status) { + return ''; } + return status.toLowerCase().split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' '); } function truncate(str, n) { diff --git a/web_service/src/templates/settings-tab/settings-tab-ocr.html b/web_service/src/templates/settings-tab/settings-tab-ocr.html index a31c47a..c1999ad 100644 --- a/web_service/src/templates/settings-tab/settings-tab-ocr.html +++ b/web_service/src/templates/settings-tab/settings-tab-ocr.html @@ -1 +1,57 @@ -
OCR settings will be available in the future.
\ No newline at end of file +
+

OCR settings will be available in the future.

+
+
+

+ +

+
+
+
+
+ Recent Activity +
+ + +
+
+
+ + + + + + + + + + + + + + +
#StatusFile NameStartedFinishedError
+
+ +
+
+ +
No logs found.
+
+
+
+
+
+
\ No newline at end of file diff --git a/web_service/src/templates/settings-tab/settings-tab-onedrive.html b/web_service/src/templates/settings-tab/settings-tab-onedrive.html index e5ac2a2..ec41388 100644 --- a/web_service/src/templates/settings-tab/settings-tab-onedrive.html +++ b/web_service/src/templates/settings-tab/settings-tab-onedrive.html @@ -167,4 +167,58 @@

{{ user_name or "Not Logged In" }}

+
+
+

+ +

+
+
+
+
+ Recent Activity +
+ + +
+
+
+ + + + + + + + + + + + + + +
#StatusFile NameStartedFinishedError
+
+ +
+
+ +
No logs found.
+
+
+
+
+
\ No newline at end of file