fix: merge main branch and renumber migration 027→037
Resolve 3 merge conflicts and renumber the automation_hooks migration to follow main's migration chain (036_add_document_translation_fields). Conflicts resolved: - app/api/__init__.py: add automation_router alongside main's new routers - app/utils/settings_service.py: add automation_hooks_enabled alongside compliance_enabled - tests/conftest.py: add AutomationHook alongside AuditLog/ComplianceTemplate imports Migration renumbered: - 027_add_automation_hooks → 037_add_automation_hooks - down_revision: 026_add_scheduled_jobs → 036_add_document_translation_fields Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
+183
-135
@@ -1,5 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}File Records{% endblock %}
|
||||
{% block title %}{{ _("files.page_title") }}{% endblock %}
|
||||
|
||||
{% block head_extra %}
|
||||
<script src="/static/js/common.js"></script>
|
||||
@@ -427,16 +427,16 @@
|
||||
<div id="dropOverlay" class="drop-overlay" aria-hidden="true">
|
||||
<div class="drop-message">
|
||||
<i class="fas fa-cloud-upload-alt" aria-hidden="true"></i>
|
||||
<p>Drop files or folders anywhere to upload</p>
|
||||
<div class="drop-hint">Supports PDF, Office docs, images, HTML, Markdown, and more – folders are processed recursively</div>
|
||||
<p>{{ _("files.drop_overlay_title") }}</p>
|
||||
<div class="drop-hint">{{ _("files.drop_overlay_hint") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload progress modal -->
|
||||
<div id="uploadModal" class="upload-modal" role="dialog" aria-modal="true" aria-label="Upload progress">
|
||||
<div id="uploadModal" class="upload-modal" role="dialog" aria-modal="true" aria-label="{{ _('files.upload_modal_aria') }}">
|
||||
<div class="upload-modal-header">
|
||||
<span><i class="fas fa-upload" aria-hidden="true"></i> Uploading Files</span>
|
||||
<button class="close-modal-btn" onclick="closeUploadModal()" aria-label="Close upload progress">
|
||||
<span><i class="fas fa-upload" aria-hidden="true"></i> {{ _("files.upload_modal_header") }}</span>
|
||||
<button class="close-modal-btn" onclick="closeUploadModal()" aria-label="{{ _('files.upload_modal_close') }}">
|
||||
<i class="fas fa-times" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -447,7 +447,7 @@
|
||||
</div>
|
||||
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<h2 class="text-3xl font-bold mb-6">File Records</h2>
|
||||
<h2 class="text-3xl font-bold mb-6">{{ _("files.page_title") }}</h2>
|
||||
|
||||
<!-- Queue pending banner (populated via JS) -->
|
||||
<div id="queueBanner" class="hidden bg-blue-50 border-l-4 border-blue-400 text-blue-800 p-4 mb-6 rounded-r-lg" role="status">
|
||||
@@ -455,36 +455,35 @@
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-spinner fa-spin mr-3 text-blue-500" aria-hidden="true"></i>
|
||||
<span>
|
||||
<strong id="queueBannerCount">0</strong> item(s) are currently queued or being processed.
|
||||
Files will appear here once processing completes.
|
||||
<strong id="queueBannerCount">0</strong> {{ _("files.queue_banner_items") }}
|
||||
</span>
|
||||
</div>
|
||||
<a href="/admin/queue" class="text-blue-600 hover:text-blue-800 text-sm font-medium whitespace-nowrap ml-4"
|
||||
id="queueBannerLink" style="display:none;">
|
||||
<i class="fas fa-external-link-alt mr-1" aria-hidden="true"></i>View Queue
|
||||
<i class="fas fa-external-link-alt mr-1" aria-hidden="true"></i>{{ _("files.queue_banner_link") }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="error-message">
|
||||
<p><strong>Error:</strong> {{ error }}</p>
|
||||
<p class="mt-2">This might be due to a configuration or import issue. Please check the server logs.</p>
|
||||
<p><strong>{{ _("common.error") }}:</strong> {{ error }}</p>
|
||||
<p class="mt-2">{{ _("files.error_hint") }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Filters Section -->
|
||||
<div class="filters-section">
|
||||
<form method="get" action="/files" class="filter-group" role="search" aria-label="Filter files">
|
||||
<form method="get" action="/files" class="filter-group" role="search" aria-label="{{ _('files.filter_form_aria') }}">
|
||||
<div class="filter-item">
|
||||
<label for="search">Search Filename</label>
|
||||
<input type="text" id="search" name="search" value="{{ search }}" placeholder="Enter filename...">
|
||||
<label for="search">{{ _("files.filter_search_label") }}</label>
|
||||
<input type="text" id="search" name="search" value="{{ search }}" placeholder="{{ _('files.filter_search_placeholder') }}">
|
||||
</div>
|
||||
|
||||
<div class="filter-item">
|
||||
<label for="mime_type">MIME Type</label>
|
||||
<label for="mime_type">{{ _("files.filter_mime_type") }}</label>
|
||||
<select id="mime_type" name="mime_type">
|
||||
<option value="">All Types</option>
|
||||
<option value="">{{ _("files.filter_all_types") }}</option>
|
||||
{% for mt in mime_types %}
|
||||
<option value="{{ mt }}" {% if mt == mime_type %}selected{% endif %}>{{ mt }}</option>
|
||||
{% endfor %}
|
||||
@@ -492,31 +491,31 @@
|
||||
</div>
|
||||
|
||||
<div class="filter-item">
|
||||
<label for="status">Status</label>
|
||||
<label for="status">{{ _("common.status") }}</label>
|
||||
<select id="status" name="status">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="pending" {% if status == "pending" %}selected{% endif %}>Pending</option>
|
||||
<option value="processing" {% if status == "processing" %}selected{% endif %}>Processing</option>
|
||||
<option value="completed" {% if status == "completed" %}selected{% endif %}>Completed</option>
|
||||
<option value="failed" {% if status == "failed" %}selected{% endif %}>Failed</option>
|
||||
<option value="duplicate" {% if status == "duplicate" %}selected{% endif %}>Duplicate</option>
|
||||
<option value="">{{ _("files.filter_all_statuses") }}</option>
|
||||
<option value="pending" {% if status == "pending" %}selected{% endif %}>{{ _("common.pending") }}</option>
|
||||
<option value="processing" {% if status == "processing" %}selected{% endif %}>{{ _("common.processing") }}</option>
|
||||
<option value="completed" {% if status == "completed" %}selected{% endif %}>{{ _("common.completed") }}</option>
|
||||
<option value="failed" {% if status == "failed" %}selected{% endif %}>{{ _("common.failed") }}</option>
|
||||
<option value="duplicate" {% if status == "duplicate" %}selected{% endif %}>{{ _("files.status_duplicate") }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="filter-item">
|
||||
<label for="date_from">Date From</label>
|
||||
<input type="date" id="date_from" name="date_from" value="{{ date_from }}" aria-label="Filter from date">
|
||||
<label for="date_from">{{ _("files.filter_date_from") }}</label>
|
||||
<input type="date" id="date_from" name="date_from" value="{{ date_from }}" aria-label="{{ _('files.filter_date_from_aria') }}">
|
||||
</div>
|
||||
|
||||
<div class="filter-item">
|
||||
<label for="date_to">Date To</label>
|
||||
<input type="date" id="date_to" name="date_to" value="{{ date_to }}" aria-label="Filter to date">
|
||||
<label for="date_to">{{ _("files.filter_date_to") }}</label>
|
||||
<input type="date" id="date_to" name="date_to" value="{{ date_to }}" aria-label="{{ _('files.filter_date_to_aria') }}">
|
||||
</div>
|
||||
|
||||
<div class="filter-item">
|
||||
<label for="storage_provider">Storage Provider</label>
|
||||
<label for="storage_provider">{{ _("files.filter_storage_provider") }}</label>
|
||||
<select id="storage_provider" name="storage_provider">
|
||||
<option value="">All Providers</option>
|
||||
<option value="">{{ _("files.filter_all_providers") }}</option>
|
||||
<option value="dropbox" {% if storage_provider == "dropbox" %}selected{% endif %}>Dropbox</option>
|
||||
<option value="google_drive" {% if storage_provider == "google_drive" %}selected{% endif %}>Google Drive</option>
|
||||
<option value="onedrive" {% if storage_provider == "onedrive" %}selected{% endif %}>OneDrive</option>
|
||||
@@ -525,32 +524,33 @@
|
||||
<option value="webdav" {% if storage_provider == "webdav" %}selected{% endif %}>WebDAV</option>
|
||||
<option value="ftp" {% if storage_provider == "ftp" %}selected{% endif %}>FTP</option>
|
||||
<option value="sftp" {% if storage_provider == "sftp" %}selected{% endif %}>SFTP</option>
|
||||
<option value="icloud" {% if storage_provider == "icloud" %}selected{% endif %}>iCloud Drive</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="filter-item">
|
||||
<label for="tags">Tags</label>
|
||||
<input type="text" id="tags" name="tags" value="{{ tags }}" placeholder="e.g. invoice,amazon" aria-label="Filter by tags (comma-separated)">
|
||||
<label for="tags">{{ _("files.tags") }}</label>
|
||||
<input type="text" id="tags" name="tags" value="{{ tags }}" placeholder="{{ _('files.filter_tags_placeholder') }}" aria-label="{{ _('files.filter_tags_aria') }}">
|
||||
</div>
|
||||
|
||||
<div class="filter-item">
|
||||
<label for="ocr_quality">OCR Quality</label>
|
||||
<select id="ocr_quality" name="ocr_quality" aria-label="Filter by OCR quality score">
|
||||
<option value="">All Files</option>
|
||||
<option value="poor" {% if ocr_quality == "poor" %}selected{% endif %} aria-label="Poor quality, score less than {{ ocr_quality_threshold }}">Poor (score < {{ ocr_quality_threshold }})</option>
|
||||
<option value="good" {% if ocr_quality == "good" %}selected{% endif %} aria-label="Good quality, score at least {{ ocr_quality_threshold }}">Good (score ≥ {{ ocr_quality_threshold }})</option>
|
||||
<option value="unchecked" {% if ocr_quality == "unchecked" %}selected{% endif %} aria-label="Not yet assessed">Not yet assessed</option>
|
||||
<label for="ocr_quality">{{ _("files.filter_ocr_quality") }}</label>
|
||||
<select id="ocr_quality" name="ocr_quality" aria-label="{{ _('files.filter_ocr_quality_aria') }}">
|
||||
<option value="">{{ _("files.filter_ocr_all") }}</option>
|
||||
<option value="poor" {% if ocr_quality == "poor" %}selected{% endif %} aria-label="Poor quality, score less than {{ ocr_quality_threshold }}">{{ _("files.filter_ocr_poor") }} (score < {{ ocr_quality_threshold }})</option>
|
||||
<option value="good" {% if ocr_quality == "good" %}selected{% endif %} aria-label="Good quality, score at least {{ ocr_quality_threshold }}">{{ _("files.filter_ocr_good") }} (score ≥ {{ ocr_quality_threshold }})</option>
|
||||
<option value="unchecked" {% if ocr_quality == "unchecked" %}selected{% endif %} aria-label="Not yet assessed">{{ _("files.filter_ocr_unchecked") }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="filter-item">
|
||||
<label> </label>
|
||||
<button type="submit">Apply Filters</button>
|
||||
<button type="submit">{{ _("files.filter_apply") }}</button>
|
||||
</div>
|
||||
|
||||
<div class="filter-item">
|
||||
<label> </label>
|
||||
<button type="button" class="clear" onclick="clearFilters()">Clear</button>
|
||||
<button type="button" class="clear" onclick="clearFilters()">{{ _("files.filter_clear") }}</button>
|
||||
</div>
|
||||
|
||||
<!-- Hidden fields to preserve sort order -->
|
||||
@@ -564,13 +564,13 @@
|
||||
<div class="filters-section" style="margin-top: 0.5rem;">
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; width: 100%;">
|
||||
<label style="font-weight: 600; font-size: 0.85rem; white-space: nowrap;">
|
||||
<i class="fas fa-bookmark" aria-hidden="true"></i> Saved Searches:
|
||||
<i class="fas fa-bookmark" aria-hidden="true"></i> {{ _("files.saved_searches_label") }}
|
||||
</label>
|
||||
<div id="saved-searches-list" style="display: flex; gap: 0.25rem; flex-wrap: wrap;" aria-live="polite">
|
||||
<span style="color: var(--text-muted); font-size: 0.85rem;">Loading...</span>
|
||||
<span style="color: var(--text-muted); font-size: 0.85rem;">{{ _("common.loading") }}</span>
|
||||
</div>
|
||||
<button type="button" onclick="saveCurrentFilters()" class="btn-save-search" style="margin-left: auto; font-size: 0.8rem; padding: 0.25rem 0.5rem; cursor: pointer;" aria-label="Save current filters as a saved search">
|
||||
<i class="fas fa-plus" aria-hidden="true"></i> Save Current
|
||||
<button type="button" onclick="saveCurrentFilters()" class="btn-save-search" style="margin-left: auto; font-size: 0.8rem; padding: 0.25rem 0.5rem; cursor: pointer;" aria-label="{{ _('files.saved_searches_save_aria') }}">
|
||||
<i class="fas fa-plus" aria-hidden="true"></i> {{ _("files.saved_searches_save") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -579,13 +579,13 @@
|
||||
<div class="filters-section" style="margin-top: 0.75rem;">
|
||||
<div style="width: 100%;">
|
||||
<label for="fulltext-search" style="font-weight: 600; display: block; margin-bottom: 0.4rem;">
|
||||
<i class="fas fa-search" aria-hidden="true"></i> Full-Text Search (OCR text, metadata, tags)
|
||||
<i class="fas fa-search" aria-hidden="true"></i> {{ _("files.fulltext_search_label") }}
|
||||
</label>
|
||||
<div style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
|
||||
<input
|
||||
type="text"
|
||||
id="fulltext-search"
|
||||
placeholder="Search document content, sender, tags, type..."
|
||||
placeholder="{{ _('files.fulltext_search_placeholder') }}"
|
||||
style="flex: 1; min-width: 220px; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 0.375rem; font-size: 0.875rem;"
|
||||
oninput="debounceSearch(this.value)"
|
||||
>
|
||||
@@ -594,14 +594,14 @@
|
||||
onclick="runFullTextSearch()"
|
||||
style="padding: 0.5rem 1rem; background-color: #3182ce; color: white; border: none; border-radius: 0.375rem; cursor: pointer; font-size: 0.875rem; white-space: nowrap;"
|
||||
>
|
||||
Search
|
||||
{{ _("common.search") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick="clearFullTextSearch()"
|
||||
style="padding: 0.5rem 1rem; background-color: #6b7280; color: white; border: none; border-radius: 0.375rem; cursor: pointer; font-size: 0.875rem; white-space: nowrap;"
|
||||
>
|
||||
Clear
|
||||
{{ _("files.filter_clear") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -620,23 +620,23 @@
|
||||
<div id="bulkActionsBar" class="filters-section" style="display: none; background-color: #e6f3ff;">
|
||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-3">
|
||||
<div>
|
||||
<strong id="selectedCount">0</strong> files selected
|
||||
<strong id="selectedCount">0</strong> {{ _("files.files_selected") }}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button type="button" onclick="bulkReprocess()" style="padding: 0.5rem 1rem; background-color: #3182ce; color: white; border: none; border-radius: 0.25rem; cursor: pointer; font-weight: 600; min-height: 44px;">
|
||||
<i class="fas fa-sync" aria-hidden="true"></i> Reprocess Selected
|
||||
<i class="fas fa-sync" aria-hidden="true"></i> {{ _("files.bulk_reprocess") }}
|
||||
</button>
|
||||
<button type="button" onclick="bulkReprocessCloudOcr()" style="padding: 0.5rem 1rem; background-color: #805ad5; color: white; border: none; border-radius: 0.25rem; cursor: pointer; font-weight: 600; min-height: 44px;">
|
||||
<i class="fas fa-cloud" aria-hidden="true"></i> Re-run Cloud OCR
|
||||
<i class="fas fa-cloud" aria-hidden="true"></i> {{ _("files.bulk_cloud_ocr") }}
|
||||
</button>
|
||||
<button type="button" onclick="bulkDownload()" style="padding: 0.5rem 1rem; background-color: #38a169; color: white; border: none; border-radius: 0.25rem; cursor: pointer; font-weight: 600; min-height: 44px;">
|
||||
<i class="fas fa-file-archive" aria-hidden="true"></i> Download as ZIP
|
||||
<i class="fas fa-file-archive" aria-hidden="true"></i> {{ _("files.bulk_download") }}
|
||||
</button>
|
||||
<button type="button" onclick="bulkDelete()" style="padding: 0.5rem 1rem; background-color: #e53e3e; color: white; border: none; border-radius: 0.25rem; cursor: pointer; font-weight: 600; min-height: 44px;">
|
||||
<i class="fas fa-trash" aria-hidden="true"></i> Delete Selected
|
||||
<i class="fas fa-trash" aria-hidden="true"></i> {{ _("files.bulk_delete") }}
|
||||
</button>
|
||||
<button type="button" onclick="clearSelection()" style="padding: 0.5rem 1rem; background-color: #718096; color: white; border: none; border-radius: 0.25rem; cursor: pointer; font-weight: 600; min-height: 44px;">
|
||||
Clear Selection
|
||||
{{ _("files.bulk_clear_selection") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -644,14 +644,14 @@
|
||||
|
||||
<!-- File table (desktop) -->
|
||||
<div class="hidden md:block overflow-x-auto">
|
||||
<table class="file-table" id="fileTable" aria-label="File records">
|
||||
<table class="file-table" id="fileTable" aria-label="{{ _('files.table_aria') }}">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" style="width: 40px;">
|
||||
<input type="checkbox" id="selectAll" onclick="toggleSelectAll()" title="Select all files on this page" aria-label="Select all files on this page">
|
||||
<input type="checkbox" id="selectAll" onclick="toggleSelectAll()" title="{{ _('files.table_select_all') }}" aria-label="{{ _('files.table_select_all') }}">
|
||||
</th>
|
||||
<th scope="col" class="sortable" onclick="sortTable('id')" role="columnheader" aria-sort="{% if sort_by == 'id' %}{% if sort_order == 'asc' %}ascending{% else %}descending{% endif %}{% else %}none{% endif %}">
|
||||
ID
|
||||
{{ _("files.table_id") }}
|
||||
<span class="sort-indicator {% if sort_by == 'id' %}active{% endif %}">
|
||||
{% if sort_by == 'id' %}
|
||||
{% if sort_order == 'asc' %}▲{% else %}▼{% endif %}
|
||||
@@ -661,7 +661,7 @@
|
||||
</span>
|
||||
</th>
|
||||
<th scope="col" class="sortable" onclick="sortTable('original_filename')">
|
||||
Original Filename
|
||||
{{ _("files.table_original_filename") }}
|
||||
<span class="sort-indicator {% if sort_by == 'original_filename' %}active{% endif %}">
|
||||
{% if sort_by == 'original_filename' %}
|
||||
{% if sort_order == 'asc' %}▲{% else %}▼{% endif %}
|
||||
@@ -671,7 +671,7 @@
|
||||
</span>
|
||||
</th>
|
||||
<th scope="col" class="sortable" onclick="sortTable('file_size')">
|
||||
File Size
|
||||
{{ _("files.file_size") }}
|
||||
<span class="sort-indicator {% if sort_by == 'file_size' %}active{% endif %}">
|
||||
{% if sort_by == 'file_size' %}
|
||||
{% if sort_order == 'asc' %}▲{% else %}▼{% endif %}
|
||||
@@ -681,7 +681,7 @@
|
||||
</span>
|
||||
</th>
|
||||
<th scope="col" class="sortable" onclick="sortTable('mime_type')">
|
||||
MIME Type
|
||||
{{ _("files.table_mime_type") }}
|
||||
<span class="sort-indicator {% if sort_by == 'mime_type' %}active{% endif %}">
|
||||
{% if sort_by == 'mime_type' %}
|
||||
{% if sort_order == 'asc' %}▲{% else %}▼{% endif %}
|
||||
@@ -690,9 +690,9 @@
|
||||
{% endif %}
|
||||
</span>
|
||||
</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">{{ _("common.status") }}</th>
|
||||
<th scope="col" class="sortable" onclick="sortTable('created_at')">
|
||||
Created At
|
||||
{{ _("files.table_created_at") }}
|
||||
<span class="sort-indicator {% if sort_by == 'created_at' %}active{% endif %}">
|
||||
{% if sort_by == 'created_at' %}
|
||||
{% if sort_order == 'asc' %}▲{% else %}▼{% endif %}
|
||||
@@ -701,7 +701,7 @@
|
||||
{% endif %}
|
||||
</span>
|
||||
</th>
|
||||
<th scope="col">Actions</th>
|
||||
<th scope="col">{{ _("files.table_actions") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -716,19 +716,19 @@
|
||||
<td>{{ file.mime_type }}</td>
|
||||
<td>
|
||||
<span class="status-badge status-{{ file.processing_status }}">
|
||||
{{ file.processing_status | title }}
|
||||
{% if file.processing_status == 'pending' %}{{ _("common.pending") }}{% elif file.processing_status == 'processing' %}{{ _("common.processing") }}{% elif file.processing_status == 'completed' %}{{ _("common.completed") }}{% elif file.processing_status == 'failed' %}{{ _("common.failed") }}{% elif file.processing_status == 'duplicate' %}{{ _("files.status_duplicate") }}{% else %}{{ file.processing_status | title }}{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ file.created_at.strftime('%Y-%m-%d %H:%M:%S') if file.created_at else 'N/A' }}</td>
|
||||
<td>{{ file.created_at.strftime('%Y-%m-%d %H:%M:%S') if file.created_at else _('common.not_available') }}</td>
|
||||
<td>
|
||||
<div style="display: flex; align-items: center;">
|
||||
<button onclick="openPreviewModal({{ file.id }}, '{{ file.mime_type or '' }}', '{{ file.original_filename | e }}', event)" class="action-btn" title="Quick preview" aria-label="Preview {{ file.original_filename }}" style="min-height:44px;min-width:44px;">
|
||||
<button onclick="openPreviewModal({{ file.id }}, '{{ file.mime_type or '' }}', '{{ file.original_filename | e }}', event)" class="action-btn" title="{{ _('files.action_preview') }}" aria-label="{{ _('files.action_preview') }} {{ file.original_filename }}" style="min-height:44px;min-width:44px;">
|
||||
<i class="fas fa-eye" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button onclick="viewFileDetail({{ file.id }}, event)" class="action-btn" title="View details" aria-label="View details for {{ file.original_filename }}" style="min-height:44px;min-width:44px;">
|
||||
<button onclick="viewFileDetail({{ file.id }}, event)" class="action-btn" title="{{ _('files.action_details') }}" aria-label="{{ _('files.action_details') }} {{ file.original_filename }}" style="min-height:44px;min-width:44px;">
|
||||
<i class="fas fa-info-circle" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button onclick="showDeleteModal({{ file.id }}, event)" class="action-btn delete" title="Delete file" aria-label="Delete {{ file.original_filename }}" style="min-height:44px;min-width:44px;">
|
||||
<button onclick="showDeleteModal({{ file.id }}, event)" class="action-btn delete" title="{{ _('files.action_delete') }}" aria-label="{{ _('files.action_delete') }} {{ file.original_filename }}" style="min-height:44px;min-width:44px;">
|
||||
<i class="fas fa-trash" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -736,7 +736,7 @@
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="8" class="text-center py-4">No files found</td>
|
||||
<td colspan="8" class="text-center py-4">{{ _("files.table_empty") }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
@@ -752,25 +752,25 @@
|
||||
tabindex="0"
|
||||
onclick="viewFileDetail({{ file.id }}, event)"
|
||||
onkeydown="if(event.key==='Enter'||event.key===' '){viewFileDetail({{ file.id }}, event);}"
|
||||
aria-label="View details for {{ file.original_filename }}"
|
||||
aria-label="{{ _('files.action_details') }} {{ file.original_filename }}"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-semibold text-gray-800 truncate">{{ file.original_filename }}</p>
|
||||
<p class="text-xs text-gray-500 mt-1">ID: {{ file.id }} · {{ (file.file_size / 1024) | round(2) }} KB</p>
|
||||
<p class="text-xs text-gray-500 mt-0.5">{{ file.mime_type }}</p>
|
||||
<p class="text-xs text-gray-400 mt-0.5">{{ file.created_at.strftime('%Y-%m-%d %H:%M') if file.created_at else 'N/A' }}</p>
|
||||
<p class="text-xs text-gray-400 mt-0.5">{{ file.created_at.strftime('%Y-%m-%d %H:%M') if file.created_at else _('common.not_available') }}</p>
|
||||
</div>
|
||||
<div class="flex flex-col items-end gap-2 flex-shrink-0">
|
||||
<span class="status-badge status-{{ file.processing_status }}">{{ file.processing_status | title }}</span>
|
||||
<div class="flex gap-1" role="group" aria-label="File actions" onclick="event.stopPropagation();" onkeydown="event.stopPropagation();">
|
||||
<button onclick="openPreviewModal({{ file.id }}, '{{ file.mime_type or '' }}', '{{ file.original_filename | e }}', event)" class="action-btn" title="Quick preview" aria-label="Preview {{ file.original_filename }}" style="min-height:44px;min-width:44px;">
|
||||
<span class="status-badge status-{{ file.processing_status }}">{% if file.processing_status == 'pending' %}{{ _("common.pending") }}{% elif file.processing_status == 'processing' %}{{ _("common.processing") }}{% elif file.processing_status == 'completed' %}{{ _("common.completed") }}{% elif file.processing_status == 'failed' %}{{ _("common.failed") }}{% elif file.processing_status == 'duplicate' %}{{ _("files.status_duplicate") }}{% else %}{{ file.processing_status | title }}{% endif %}</span>
|
||||
<div class="flex gap-1" role="group" aria-label="{{ _('files.file_actions_aria') }}" onclick="event.stopPropagation();" onkeydown="event.stopPropagation();">
|
||||
<button onclick="openPreviewModal({{ file.id }}, '{{ file.mime_type or '' }}', '{{ file.original_filename | e }}', event)" class="action-btn" title="{{ _('files.action_preview') }}" aria-label="{{ _('files.action_preview') }} {{ file.original_filename }}" style="min-height:44px;min-width:44px;">
|
||||
<i class="fas fa-eye" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button onclick="viewFileDetail({{ file.id }}, event)" class="action-btn" title="View details" aria-label="View details for {{ file.original_filename }}" style="min-height:44px;min-width:44px;">
|
||||
<button onclick="viewFileDetail({{ file.id }}, event)" class="action-btn" title="{{ _('files.action_details') }}" aria-label="{{ _('files.action_details') }} {{ file.original_filename }}" style="min-height:44px;min-width:44px;">
|
||||
<i class="fas fa-info-circle" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button onclick="showDeleteModal({{ file.id }}, event)" class="action-btn delete" title="Delete file" aria-label="Delete {{ file.original_filename }}" style="min-height:44px;min-width:44px;">
|
||||
<button onclick="showDeleteModal({{ file.id }}, event)" class="action-btn delete" title="{{ _('files.action_delete') }}" aria-label="{{ _('files.action_delete') }} {{ file.original_filename }}" style="min-height:44px;min-width:44px;">
|
||||
<i class="fas fa-trash" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -778,33 +778,33 @@
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-center py-4 text-gray-500">No files found</p>
|
||||
<p class="text-center py-4 text-gray-500">{{ _("files.table_empty") }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if pagination.pages > 1 %}
|
||||
<nav class="pagination flex-wrap gap-2" aria-label="File list pagination">
|
||||
<nav class="pagination flex-wrap gap-2" aria-label="{{ _('files.pagination_nav_aria') }}">
|
||||
<div class="pagination-info" aria-live="polite">
|
||||
Showing {{ ((pagination.page - 1) * pagination.per_page + 1) }} -
|
||||
{{ _("files.pagination_showing") }} {{ ((pagination.page - 1) * pagination.per_page + 1) }} -
|
||||
{{ min(pagination.page * pagination.per_page, pagination.total) }}
|
||||
of {{ pagination.total }} files
|
||||
{{ _("files.pagination_of") }} {{ pagination.total }} {{ _("files.pagination_files") }}
|
||||
</div>
|
||||
<div class="pagination-buttons flex-wrap">
|
||||
{% if pagination.page > 1 %}
|
||||
<button class="pagination-button" onclick="goToPage(1)" aria-label="Go to first page" style="min-height:44px;">First</button>
|
||||
<button class="pagination-button" onclick="goToPage({{ pagination.page - 1 }})" aria-label="Go to previous page" style="min-height:44px;">Previous</button>
|
||||
<button class="pagination-button" onclick="goToPage(1)" aria-label="{{ _('files.pagination_first') }}" style="min-height:44px;">{{ _("files.pagination_first") }}</button>
|
||||
<button class="pagination-button" onclick="goToPage({{ pagination.page - 1 }})" aria-label="{{ _('files.pagination_previous') }}" style="min-height:44px;">{{ _("files.pagination_previous") }}</button>
|
||||
{% endif %}
|
||||
|
||||
{% for p in range(max(1, pagination.page - 2), min(pagination.pages + 1, pagination.page + 3)) %}
|
||||
<button class="pagination-button {% if p == pagination.page %}active{% endif %}" onclick="goToPage({{ p }})" aria-label="Page {{ p }}" {% if p == pagination.page %}aria-current="page"{% endif %} style="min-height:44px;">
|
||||
<button class="pagination-button {% if p == pagination.page %}active{% endif %}" onclick="goToPage({{ p }})" aria-label="{{ _('common.page') }} {{ p }}" {% if p == pagination.page %}aria-current="page"{% endif %} style="min-height:44px;">
|
||||
{{ p }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
|
||||
{% if pagination.page < pagination.pages %}
|
||||
<button class="pagination-button" onclick="goToPage({{ pagination.page + 1 }})" aria-label="Go to next page" style="min-height:44px;">Next</button>
|
||||
<button class="pagination-button" onclick="goToPage({{ pagination.pages }})" aria-label="Go to last page" style="min-height:44px;">Last</button>
|
||||
<button class="pagination-button" onclick="goToPage({{ pagination.page + 1 }})" aria-label="{{ _('files.pagination_next') }}" style="min-height:44px;">{{ _("files.pagination_next") }}</button>
|
||||
<button class="pagination-button" onclick="goToPage({{ pagination.pages }})" aria-label="{{ _('files.pagination_last') }}" style="min-height:44px;">{{ _("files.pagination_last") }}</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</nav>
|
||||
@@ -813,11 +813,11 @@
|
||||
<!-- Delete confirmation modal -->
|
||||
<div id="deleteModal" class="modal" role="dialog" aria-modal="true" aria-labelledby="deleteModalTitle">
|
||||
<div class="modal-content">
|
||||
<div class="modal-title" id="deleteModalTitle">Confirm Deletion</div>
|
||||
<p>Are you sure you want to delete this file?</p>
|
||||
<div class="modal-title" id="deleteModalTitle">{{ _("files.delete_modal_title") }}</div>
|
||||
<p>{{ _("files.delete_modal_message") }}</p>
|
||||
<div class="modal-buttons">
|
||||
<button id="cancelDelete" class="modal-btn modal-btn-cancel" style="min-height:44px;min-width:80px;">Cancel</button>
|
||||
<button id="confirmDelete" class="modal-btn modal-btn-delete" style="min-height:44px;min-width:80px;">Delete</button>
|
||||
<button id="cancelDelete" class="modal-btn modal-btn-cancel" style="min-height:44px;min-width:80px;">{{ _("files.delete_modal_cancel") }}</button>
|
||||
<button id="confirmDelete" class="modal-btn modal-btn-delete" style="min-height:44px;min-width:80px;">{{ _("files.delete_modal_confirm") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -826,8 +826,8 @@
|
||||
<div id="previewOverlay" class="preview-overlay" role="dialog" aria-modal="true" aria-labelledby="previewTitle">
|
||||
<div class="preview-panel">
|
||||
<div class="preview-panel-header">
|
||||
<h3 id="previewTitle">Preview</h3>
|
||||
<button class="preview-panel-close" onclick="closePreviewModal()" aria-label="Close preview">
|
||||
<h3 id="previewTitle">{{ _("files.preview_modal_title") }}</h3>
|
||||
<button class="preview-panel-close" onclick="closePreviewModal()" aria-label="{{ _('files.preview_modal_close') }}">
|
||||
<i class="fas fa-times" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -841,6 +841,54 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ── Translations for JavaScript ──
|
||||
const __i18n = {
|
||||
previewFallback: {{ _("files.js_preview_fallback") | tojson }},
|
||||
fullView: {{ _("files.js_full_view") | tojson }},
|
||||
download: {{ _("files.js_download") | tojson }},
|
||||
loading: {{ _("files.js_loading") | tojson }},
|
||||
failedLoadPdf: {{ _("files.js_failed_load_pdf") | tojson }},
|
||||
pdfPageInfo: {{ _("files.js_pdf_page_info") | tojson }},
|
||||
failedLoadFile: {{ _("files.js_failed_load_file") | tojson }},
|
||||
openFullViewAria: {{ _("files.js_open_full_view_aria") | tojson }},
|
||||
downloadFileAria: {{ _("files.js_download_file_aria") | tojson }},
|
||||
failedDeleteFile: {{ _("files.js_failed_delete_file") | tojson }},
|
||||
errorDeletingFile: {{ _("files.js_error_deleting_file") | tojson }},
|
||||
noSavedSearches: {{ _("files.js_no_saved_searches") | tojson }},
|
||||
loadSavedSearchesError: {{ _("files.js_load_saved_searches_error") | tojson }},
|
||||
noFiltersToSave: {{ _("files.js_no_filters_to_save") | tojson }},
|
||||
enterSearchName: {{ _("files.js_enter_search_name") | tojson }},
|
||||
deleteSavedSearchConfirm: {{ _("files.js_delete_saved_search_confirm") | tojson }},
|
||||
deleteSavedSearchAria: {{ _("files.js_delete_saved_search_aria") | tojson }},
|
||||
selectFilesDelete: {{ _("files.js_select_files_delete") | tojson }},
|
||||
confirmDeleteFiles: {{ _("files.js_confirm_delete_files") | tojson }},
|
||||
failedDeleteFiles: {{ _("files.js_failed_delete_files") | tojson }},
|
||||
errorDeletingFiles: {{ _("files.js_error_deleting_files") | tojson }},
|
||||
selectFilesReprocess: {{ _("files.js_select_files_reprocess") | tojson }},
|
||||
confirmReprocessFiles: {{ _("files.js_confirm_reprocess_files") | tojson }},
|
||||
failedReprocessFiles: {{ _("files.js_failed_reprocess_files") | tojson }},
|
||||
errorReprocessingFiles: {{ _("files.js_error_reprocessing_files") | tojson }},
|
||||
selectFilesCloudOcr: {{ _("files.js_select_files_cloud_ocr") | tojson }},
|
||||
confirmCloudOcr: {{ _("files.js_confirm_cloud_ocr") | tojson }},
|
||||
failedQueueCloudOcr: {{ _("files.js_failed_queue_cloud_ocr") | tojson }},
|
||||
errorQueuingCloudOcr: {{ _("files.js_error_queuing_cloud_ocr") | tojson }},
|
||||
selectFilesDownload: {{ _("files.js_select_files_download") | tojson }},
|
||||
failedCreateZip: {{ _("files.js_failed_create_zip") | tojson }},
|
||||
errorDownloadingFiles: {{ _("files.js_error_downloading_files") | tojson }},
|
||||
searching: {{ _("files.js_searching") | tojson }},
|
||||
searchUnavailable: {{ _("files.js_search_unavailable") | tojson }},
|
||||
searchResultsCount: {{ _("files.js_search_results_count") | tojson }},
|
||||
noResults: {{ _("files.js_no_results") | tojson }},
|
||||
untitled: {{ _("files.js_untitled") | tojson }},
|
||||
viewFile: {{ _("files.js_view_file") | tojson }},
|
||||
prevPage: {{ _("files.js_prev_page") | tojson }},
|
||||
pageInfo: {{ _("files.js_page_info") | tojson }},
|
||||
nextPage: {{ _("files.js_next_page") | tojson }},
|
||||
zoomIn: {{ _("files.js_zoom_in") | tojson }},
|
||||
zoomOut: {{ _("files.js_zoom_out") | tojson }},
|
||||
resetZoom: {{ _("files.js_reset_zoom") | tojson }},
|
||||
};
|
||||
|
||||
// Modal functionality
|
||||
const deleteModal = document.getElementById('deleteModal');
|
||||
const cancelDelete = document.getElementById('cancelDelete');
|
||||
@@ -868,7 +916,7 @@
|
||||
const title = document.getElementById('previewTitle');
|
||||
const body = document.getElementById('previewBody');
|
||||
const footer = document.getElementById('previewFooter');
|
||||
title.textContent = filename || 'Preview';
|
||||
title.textContent = filename || __i18n.previewFallback;
|
||||
body.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:300px;color:#9ca3af;"><i class="fas fa-spinner fa-spin" style="font-size:2rem;"></i></div>';
|
||||
footer.innerHTML = '';
|
||||
_pmPdf = { doc: null, page: 1, total: 0 };
|
||||
@@ -877,8 +925,8 @@
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
footer.innerHTML =
|
||||
`<a href="/files/${fileId}" aria-label="Open full view"><i class="fas fa-expand" aria-hidden="true"></i> Full view</a>` +
|
||||
`<a href="/api/files/${fileId}/download?version=processed" aria-label="Download file"><i class="fas fa-download" aria-hidden="true"></i> Download</a>`;
|
||||
`<a href="/files/${fileId}" aria-label="${__i18n.openFullViewAria}"><i class="fas fa-expand" aria-hidden="true"></i> ${__i18n.fullView}</a>` +
|
||||
`<a href="/api/files/${fileId}/download?version=processed" aria-label="${__i18n.downloadFileAria}"><i class="fas fa-download" aria-hidden="true"></i> ${__i18n.download}</a>`;
|
||||
|
||||
// Try "processed" first; fall back to "original" on 404
|
||||
_pmLoadPreview(body, fileId, mime, filename, 'processed');
|
||||
@@ -911,7 +959,7 @@
|
||||
body.innerHTML =
|
||||
'<div class="pm-pdf-controls">' +
|
||||
'<button onclick="_pmPdfPage(-1)" id="pm-prev" aria-label="Previous page"><i class="fas fa-chevron-left" aria-hidden="true"></i></button>' +
|
||||
'<span id="pm-page-info" style="font-size:0.8rem;min-width:100px;text-align:center;">Loading…</span>' +
|
||||
'<span id="pm-page-info" style="font-size:0.8rem;min-width:100px;text-align:center;">' + __i18n.loading + '</span>' +
|
||||
'<button onclick="_pmPdfPage(1)" id="pm-next" aria-label="Next page"><i class="fas fa-chevron-right" aria-hidden="true"></i></button>' +
|
||||
'</div>' +
|
||||
'<div class="pm-canvas-wrap" id="pm-canvas-wrap"><div style="text-align:center;padding:3rem;color:#94a3b8;"><i class="fas fa-spinner fa-spin" style="font-size:2rem;"></i></div></div>';
|
||||
@@ -923,7 +971,7 @@
|
||||
_pmPdfRender();
|
||||
}).catch(function() {
|
||||
if (onError) { onError(); return; }
|
||||
document.getElementById('pm-canvas-wrap').innerHTML = '<div style="text-align:center;padding:2rem;color:#f87171;"><i class="fas fa-exclamation-triangle" style="font-size:2rem;margin-bottom:0.5rem;"></i><p>Failed to load PDF</p></div>';
|
||||
document.getElementById('pm-canvas-wrap').innerHTML = '<div style="text-align:center;padding:2rem;color:#f87171;"><i class="fas fa-exclamation-triangle" style="font-size:2rem;margin-bottom:0.5rem;"></i><p>' + __i18n.failedLoadPdf + '</p></div>';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -953,7 +1001,7 @@
|
||||
|
||||
function _pmPdfUpdateInfo() {
|
||||
var info = document.getElementById('pm-page-info');
|
||||
if (info) info.textContent = 'Page ' + _pmPdf.page + ' of ' + _pmPdf.total;
|
||||
if (info) info.textContent = __i18n.pdfPageInfo.replace('{page}', _pmPdf.page).replace('{total}', _pmPdf.total);
|
||||
var prev = document.getElementById('pm-prev');
|
||||
var next = document.getElementById('pm-next');
|
||||
if (prev) prev.disabled = _pmPdf.page <= 1;
|
||||
@@ -964,12 +1012,12 @@
|
||||
function _pmRenderImage(body, url, alt) {
|
||||
body.innerHTML =
|
||||
'<div class="pm-img-controls">' +
|
||||
'<button onclick="_pmImgZoom(1.25)" title="Zoom in" aria-label="Zoom in"><i class="fas fa-search-plus" aria-hidden="true"></i></button>' +
|
||||
'<button onclick="_pmImgZoom(0.8)" title="Zoom out" aria-label="Zoom out"><i class="fas fa-search-minus" aria-hidden="true"></i></button>' +
|
||||
'<button onclick="_pmImgReset()" title="Reset" aria-label="Reset zoom"><i class="fas fa-expand" aria-hidden="true"></i></button>' +
|
||||
'<button onclick="_pmImgZoom(1.25)" title="' + __i18n.zoomIn + '" aria-label="' + __i18n.zoomIn + '"><i class="fas fa-search-plus" aria-hidden="true"></i></button>' +
|
||||
'<button onclick="_pmImgZoom(0.8)" title="' + __i18n.zoomOut + '" aria-label="' + __i18n.zoomOut + '"><i class="fas fa-search-minus" aria-hidden="true"></i></button>' +
|
||||
'<button onclick="_pmImgReset()" title="' + __i18n.resetZoom + '" aria-label="' + __i18n.resetZoom + '"><i class="fas fa-expand" aria-hidden="true"></i></button>' +
|
||||
'<span id="pm-zoom-level" style="font-size:0.8rem;color:#6b7280;">100%</span>' +
|
||||
'</div>' +
|
||||
'<div class="pm-img-wrap" id="pm-img-wrap"><img id="pm-preview-img" src="' + url + '" alt="' + (alt || 'Preview') + '" draggable="false" style="max-width:100%;height:auto;"></div>';
|
||||
'<div class="pm-img-wrap" id="pm-img-wrap"><img id="pm-preview-img" src="' + url + '" alt="' + (alt || __i18n.previewFallback) + '" draggable="false" style="max-width:100%;height:auto;"></div>';
|
||||
// Drag-pan
|
||||
var wrap = document.getElementById('pm-img-wrap');
|
||||
if (wrap) {
|
||||
@@ -1000,7 +1048,7 @@
|
||||
|
||||
// Text rendering inside preview modal
|
||||
function _pmRenderText(body, url, onError) {
|
||||
body.innerHTML = '<div class="pm-text-content" id="pm-text-content"><div style="text-align:center;padding:2rem;color:#94a3b8;"><i class="fas fa-spinner fa-spin"></i> Loading…</div></div>';
|
||||
body.innerHTML = '<div class="pm-text-content" id="pm-text-content"><div style="text-align:center;padding:2rem;color:#94a3b8;"><i class="fas fa-spinner fa-spin"></i> ' + __i18n.loading + '</div></div>';
|
||||
fetch(url)
|
||||
.then(function(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.text(); })
|
||||
.then(function(text) {
|
||||
@@ -1021,7 +1069,7 @@
|
||||
.catch(function() {
|
||||
if (onError) { onError(); return; }
|
||||
var c = document.getElementById('pm-text-content');
|
||||
if (c) c.innerHTML = '<div style="color:#f87171;padding:1rem;">Failed to load file</div>';
|
||||
if (c) c.innerHTML = '<div style="color:#f87171;padding:1rem;">' + __i18n.failedLoadFile + '</div>';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1065,7 +1113,7 @@
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
return response.json().then(err => {
|
||||
throw new Error(err.detail || 'Failed to delete file');
|
||||
throw new Error(err.detail || __i18n.failedDeleteFile);
|
||||
});
|
||||
} else {
|
||||
// Non-JSON response (likely HTML error page)
|
||||
@@ -1084,7 +1132,7 @@
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert(`Error deleting file: ${error.message}`);
|
||||
alert(__i18n.errorDeletingFile.replace('{error}', error.message));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1124,14 +1172,14 @@
|
||||
const container = document.getElementById('saved-searches-list');
|
||||
if (!container) return;
|
||||
if (!searches || searches.length === 0) {
|
||||
container.innerHTML = '<span style="color: var(--text-muted); font-size: 0.85rem;">No saved searches yet</span>';
|
||||
container.innerHTML = '<span style="color: var(--text-muted); font-size: 0.85rem;">' + __i18n.noSavedSearches + '</span>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = searches.map(s => {
|
||||
const params = new URLSearchParams(s.filters);
|
||||
return `<span class="saved-search-tag" style="display: inline-flex; align-items: center; gap: 0.25rem; background: var(--bg-tertiary, #e5e7eb); padding: 0.2rem 0.5rem; border-radius: 0.25rem; font-size: 0.8rem;">
|
||||
<a href="/files?${params.toString()}" style="text-decoration: none; color: inherit;">${s.name}</a>
|
||||
<button type="button" onclick="deleteSavedSearch(${s.id})" style="border: none; background: none; cursor: pointer; color: var(--text-muted); padding: 0; line-height: 1;" aria-label="Delete saved search ${s.name}">
|
||||
<button type="button" onclick="deleteSavedSearch(${s.id})" style="border: none; background: none; cursor: pointer; color: var(--text-muted); padding: 0; line-height: 1;" aria-label="${__i18n.deleteSavedSearchAria.replace('{name}', s.name)}">
|
||||
<i class="fas fa-times" aria-hidden="true"></i>
|
||||
</button>
|
||||
</span>`;
|
||||
@@ -1139,7 +1187,7 @@
|
||||
})
|
||||
.catch(() => {
|
||||
const container = document.getElementById('saved-searches-list');
|
||||
if (container) container.innerHTML = '<span style="color: var(--text-muted); font-size: 0.85rem;">Could not load saved searches</span>';
|
||||
if (container) container.innerHTML = '<span style="color: var(--text-muted); font-size: 0.85rem;">' + __i18n.loadSavedSearchesError + '</span>';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1152,10 +1200,10 @@
|
||||
if (val) filters[key] = val;
|
||||
});
|
||||
if (Object.keys(filters).length === 0) {
|
||||
alert('No filters to save. Please apply at least one filter (search, date, status, tags, etc.) before saving.');
|
||||
alert(__i18n.noFiltersToSave);
|
||||
return;
|
||||
}
|
||||
const name = prompt('Enter a name for this saved search:');
|
||||
const name = prompt(__i18n.enterSearchName);
|
||||
if (!name || !name.trim()) return;
|
||||
fetch('/api/saved-searches', {
|
||||
method: 'POST',
|
||||
@@ -1171,7 +1219,7 @@
|
||||
}
|
||||
|
||||
function deleteSavedSearch(id) {
|
||||
if (!confirm('Delete this saved search?')) return;
|
||||
if (!confirm(__i18n.deleteSavedSearchConfirm)) return;
|
||||
fetch(`/api/saved-searches/${id}`, { method: 'DELETE' })
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error('Failed to delete');
|
||||
@@ -1226,11 +1274,11 @@
|
||||
function bulkDelete() {
|
||||
const fileIds = getSelectedFileIds();
|
||||
if (fileIds.length === 0) {
|
||||
alert('Please select files to delete');
|
||||
alert(__i18n.selectFilesDelete);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Are you sure you want to delete ${fileIds.length} file(s)?`)) {
|
||||
if (!confirm(__i18n.confirmDeleteFiles.replace('{count}', fileIds.length))) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1244,7 +1292,7 @@
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
return response.json().then(err => {
|
||||
throw new Error(err.detail || 'Failed to delete files');
|
||||
throw new Error(err.detail || __i18n.failedDeleteFiles);
|
||||
});
|
||||
}
|
||||
return response.json();
|
||||
@@ -1255,18 +1303,18 @@
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert(`Error deleting files: ${error.message}`);
|
||||
alert(__i18n.errorDeletingFiles.replace('{error}', error.message));
|
||||
});
|
||||
}
|
||||
|
||||
function bulkReprocess() {
|
||||
const fileIds = getSelectedFileIds();
|
||||
if (fileIds.length === 0) {
|
||||
alert('Please select files to reprocess');
|
||||
alert(__i18n.selectFilesReprocess);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Are you sure you want to reprocess ${fileIds.length} file(s)?`)) {
|
||||
if (!confirm(__i18n.confirmReprocessFiles.replace('{count}', fileIds.length))) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1280,7 +1328,7 @@
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
return response.json().then(err => {
|
||||
throw new Error(err.detail || 'Failed to reprocess files');
|
||||
throw new Error(err.detail || __i18n.failedReprocessFiles);
|
||||
});
|
||||
}
|
||||
return response.json();
|
||||
@@ -1297,18 +1345,18 @@
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert(`Error reprocessing files: ${error.message}`);
|
||||
alert(__i18n.errorReprocessingFiles.replace('{error}', error.message));
|
||||
});
|
||||
}
|
||||
|
||||
function bulkReprocessCloudOcr() {
|
||||
const fileIds = getSelectedFileIds();
|
||||
if (fileIds.length === 0) {
|
||||
alert('Please select files to re-run Cloud OCR on');
|
||||
alert(__i18n.selectFilesCloudOcr);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Are you sure you want to re-run Cloud OCR on ${fileIds.length} file(s)?`)) {
|
||||
if (!confirm(__i18n.confirmCloudOcr.replace('{count}', fileIds.length))) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1322,7 +1370,7 @@
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
return response.json().then(err => {
|
||||
throw new Error(err.detail || 'Failed to queue Cloud OCR');
|
||||
throw new Error(err.detail || __i18n.failedQueueCloudOcr);
|
||||
});
|
||||
}
|
||||
return response.json();
|
||||
@@ -1339,14 +1387,14 @@
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert(`Error queuing Cloud OCR: ${error.message}`);
|
||||
alert(__i18n.errorQueuingCloudOcr.replace('{error}', error.message));
|
||||
});
|
||||
}
|
||||
|
||||
function bulkDownload() {
|
||||
const fileIds = getSelectedFileIds();
|
||||
if (fileIds.length === 0) {
|
||||
alert('Please select files to download');
|
||||
alert(__i18n.selectFilesDownload);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1360,7 +1408,7 @@
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
return response.json().then(err => {
|
||||
throw new Error(err.detail || 'Failed to create ZIP download');
|
||||
throw new Error(err.detail || __i18n.failedCreateZip);
|
||||
});
|
||||
}
|
||||
return response.blob();
|
||||
@@ -1377,7 +1425,7 @@
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert(`Error downloading files: ${error.message}`);
|
||||
alert(__i18n.errorDownloadingFiles.replace('{error}', error.message));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1471,7 +1519,7 @@
|
||||
const summary = document.getElementById('search-results-summary');
|
||||
const pagination = document.getElementById('search-results-pagination');
|
||||
|
||||
list.innerHTML = '<div style="padding: 1rem; color: #6b7280; font-size: 0.875rem;"><i class="fas fa-spinner fa-spin"></i> Searching…</div>';
|
||||
list.innerHTML = '<div style="padding: 1rem; color: #6b7280; font-size: 0.875rem;"><i class="fas fa-spinner fa-spin"></i> ' + __i18n.searching + '</div>';
|
||||
summary.textContent = '';
|
||||
pagination.innerHTML = '';
|
||||
panel.style.display = 'block';
|
||||
@@ -1484,7 +1532,7 @@
|
||||
})
|
||||
.then(data => renderSearchResults(data, q))
|
||||
.catch(err => {
|
||||
list.innerHTML = `<div style="padding: 1rem; color: #dc2626; font-size: 0.875rem;"><i class="fas fa-exclamation-triangle"></i> Search unavailable: ${err.message}</div>`;
|
||||
list.innerHTML = '<div style="padding: 1rem; color: #dc2626; font-size: 0.875rem;"><i class="fas fa-exclamation-triangle"></i> ' + __i18n.searchUnavailable.replace('{error}', err.message) + '</div>';
|
||||
summary.textContent = '';
|
||||
});
|
||||
}
|
||||
@@ -1496,17 +1544,17 @@
|
||||
const pagination = document.getElementById('search-results-pagination');
|
||||
|
||||
const { results, total, page, pages } = data;
|
||||
summary.textContent = `${total} result${total !== 1 ? 's' : ''} for "${q}"`;
|
||||
summary.textContent = __i18n.searchResultsCount.replace('{total}', total).replace('{query}', q);
|
||||
|
||||
if (!results || results.length === 0) {
|
||||
list.innerHTML = '<div style="padding: 1rem; color: #6b7280; font-size: 0.875rem;">No results found.</div>';
|
||||
list.innerHTML = '<div style="padding: 1rem; color: #6b7280; font-size: 0.875rem;">' + __i18n.noResults + '</div>';
|
||||
pagination.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = results.map(hit => {
|
||||
const fmt = hit._formatted || {};
|
||||
const title = fmt.document_title || hit.document_title || hit.original_filename || '(untitled)';
|
||||
const title = fmt.document_title || hit.document_title || hit.original_filename || __i18n.untitled;
|
||||
const filename = fmt.original_filename || hit.original_filename || '';
|
||||
const snippet = fmt.ocr_text || '';
|
||||
const tags = Array.isArray(hit.tags) ? hit.tags.join(', ') : (hit.tags || '');
|
||||
@@ -1524,7 +1572,7 @@
|
||||
${snippet ? `<div style="margin-top: 0.4rem; font-size: 0.8rem; color: #374151; white-space: pre-wrap; word-break: break-word;">…${snippet}…</div>` : ''}
|
||||
</div>
|
||||
<div style="flex-shrink: 0;">
|
||||
<a href="/files/${hit.file_id}" style="padding: 0.25rem 0.6rem; background: #f3f4f6; color: #374151; border-radius: 0.25rem; font-size: 0.8rem; text-decoration: none; white-space: nowrap;" title="View file">
|
||||
<a href="/files/${hit.file_id}" style="padding: 0.25rem 0.6rem; background: #f3f4f6; color: #374151; border-radius: 0.25rem; font-size: 0.8rem; text-decoration: none; white-space: nowrap;" title="${__i18n.viewFile}">
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -1535,11 +1583,11 @@
|
||||
if (pages > 1) {
|
||||
const btns = [];
|
||||
if (page > 1) {
|
||||
btns.push(`<button onclick="runFullTextSearch(${page - 1})" style="padding: 0.25rem 0.6rem; border: 1px solid #d1d5db; border-radius: 0.25rem; font-size: 0.8rem; cursor: pointer; background: white;">« Prev</button>`);
|
||||
btns.push(`<button onclick="runFullTextSearch(${page - 1})" style="padding: 0.25rem 0.6rem; border: 1px solid #d1d5db; border-radius: 0.25rem; font-size: 0.8rem; cursor: pointer; background: white;">${__i18n.prevPage}</button>`);
|
||||
}
|
||||
btns.push(`<span style="padding: 0.25rem 0.6rem; font-size: 0.8rem; color: #6b7280;">Page ${page} / ${pages}</span>`);
|
||||
btns.push(`<span style="padding: 0.25rem 0.6rem; font-size: 0.8rem; color: #6b7280;">${__i18n.pageInfo.replace('{page}', page).replace('{pages}', pages)}</span>`);
|
||||
if (page < pages) {
|
||||
btns.push(`<button onclick="runFullTextSearch(${page + 1})" style="padding: 0.25rem 0.6rem; border: 1px solid #d1d5db; border-radius: 0.25rem; font-size: 0.8rem; cursor: pointer; background: white;">Next »</button>`);
|
||||
btns.push(`<button onclick="runFullTextSearch(${page + 1})" style="padding: 0.25rem 0.6rem; border: 1px solid #d1d5db; border-radius: 0.25rem; font-size: 0.8rem; cursor: pointer; background: white;">${__i18n.nextPage}</button>`);
|
||||
}
|
||||
pagination.innerHTML = btns.join('');
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user