64577f2d92
Replace all hardcoded English visible text with {{ _("key") }} calls in:
- queue_dashboard.html: headings, stat cards, table headers, empty states
- api_tokens.html: form labels, table headers, status badges, aria-labels
- similarity_dashboard.html: headings, stat labels, controls, empty states
- backup.html: buttons, config labels, table, retention section, confirm dialog
- shared_links.html: form labels/options, table headers, status badges
- duplicates.html: tabs, role tags, near-dup form, explanation bullets
Follows i18n rules: placeholder/aria-label/title attrs translated,
JS strings in <script> left as-is, Alpine.js bindings use Jinja2
server-side evaluation pattern for dynamic translated strings.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
459 lines
19 KiB
HTML
459 lines
19 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}{{ _("duplicates.page_title") }}{% endblock %}
|
|
|
|
{% block head_extra %}
|
|
<style>
|
|
.dup-container { max-width: 1100px; margin: 0 auto; }
|
|
.dup-group {
|
|
background: white; border-radius: 0.5rem;
|
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
|
margin-bottom: 1.5rem; overflow: hidden;
|
|
}
|
|
.dup-group-header {
|
|
padding: 1rem 1.25rem;
|
|
border-bottom: 1px solid #e5e7eb;
|
|
display: flex; align-items: center; gap: 0.75rem;
|
|
background: #f9fafb;
|
|
}
|
|
.dup-badge {
|
|
display: inline-flex; align-items: center; gap: 0.3rem;
|
|
font-size: 0.75rem; font-weight: 700; padding: 0.2rem 0.6rem;
|
|
border-radius: 9999px;
|
|
}
|
|
.dup-badge-exact { background: #fee2e2; color: #991b1b; }
|
|
.dup-badge-near { background: #fef3c7; color: #92400e; }
|
|
.dup-file-row {
|
|
display: flex; align-items: center; gap: 0.75rem;
|
|
padding: 0.75rem 1.25rem; border-bottom: 1px solid #f3f4f6;
|
|
}
|
|
.dup-file-row:last-child { border-bottom: none; }
|
|
.dup-file-row.original { background: #f0fdf4; }
|
|
.dup-file-row.duplicate { background: #fff7ed; }
|
|
.dup-file-row.near-dup { background: #fffbeb; }
|
|
.dup-role-tag {
|
|
font-size: 0.7rem; font-weight: 700; padding: 0.15rem 0.5rem;
|
|
border-radius: 9999px; white-space: nowrap;
|
|
}
|
|
.tag-original { background: #d1fae5; color: #065f46; }
|
|
.tag-duplicate { background: #fee2e2; color: #991b1b; }
|
|
.tag-near { background: #fef3c7; color: #92400e; }
|
|
.dup-filename { font-weight: 600; color: #1f2937; word-break: break-all; }
|
|
.dup-meta { font-size: 0.8rem; color: #6b7280; }
|
|
.dup-score { font-size: 0.8rem; font-weight: 700; color: #d97706; }
|
|
.dup-actions { margin-left: auto; display: flex; gap: 0.5rem; flex-shrink: 0; }
|
|
|
|
/* near-dup search UI */
|
|
.near-dup-search {
|
|
background: white; border-radius: 0.5rem;
|
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
|
padding: 1.25rem; margin-bottom: 2rem;
|
|
}
|
|
.near-dup-search h3 { font-size: 1rem; font-weight: 700; margin-bottom: 0.75rem; }
|
|
.nd-form { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: flex-end; }
|
|
.nd-form label { font-size: 0.8rem; font-weight: 600; color: #374151; }
|
|
.nd-form input, .nd-form select {
|
|
padding: 0.4rem 0.6rem; border: 1px solid #d1d5db;
|
|
border-radius: 0.375rem; font-size: 0.85rem;
|
|
}
|
|
.nd-form input[type=number] { width: 90px; }
|
|
.nd-results { margin-top: 1rem; }
|
|
|
|
.empty-state {
|
|
text-align: center; padding: 3rem; color: #6b7280;
|
|
}
|
|
.empty-state i { font-size: 3rem; margin-bottom: 0.75rem; display: block; }
|
|
.tabs { display: flex; gap: 0; border-bottom: 2px solid #e5e7eb; margin-bottom: 1.5rem; }
|
|
.tab-btn {
|
|
padding: 0.6rem 1.25rem; font-weight: 600; font-size: 0.9rem;
|
|
cursor: pointer; border: none; background: none;
|
|
border-bottom: 3px solid transparent; margin-bottom: -2px;
|
|
color: #6b7280; transition: color .15s;
|
|
}
|
|
.tab-btn.active { color: #2563eb; border-bottom-color: #2563eb; }
|
|
.tab-panel { display: none; }
|
|
.tab-panel.active { display: block; }
|
|
|
|
/* pagination */
|
|
.pagination { display: flex; gap: 0.5rem; justify-content: center; margin-top: 1.5rem; }
|
|
.page-btn {
|
|
padding: 0.4rem 0.75rem; border: 1px solid #d1d5db;
|
|
border-radius: 0.375rem; font-size: 0.85rem; cursor: pointer;
|
|
background: white; color: #374151;
|
|
}
|
|
.page-btn:hover:not(:disabled) { background: #f3f4f6; }
|
|
.page-btn:disabled { opacity: 0.4; cursor: default; }
|
|
.page-btn.current { background: #2563eb; color: white; border-color: #2563eb; }
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<main id="main-content" class="dup-container px-4 py-8">
|
|
<h1 class="text-2xl font-bold mb-2">{{ _("duplicates.heading") }}</h1>
|
|
<p class="text-gray-500 mb-6 text-sm">
|
|
{{ _("duplicates.subtitle_1") }}
|
|
<span class="dup-badge dup-badge-exact">{{ _("duplicates.exact_duplicates_badge") }}</span>.
|
|
{{ _("duplicates.subtitle_2_pre") }} <strong>{{ _("duplicates.tab_near") }}</strong> {{ _("duplicates.subtitle_2_post") }}
|
|
</p>
|
|
|
|
<!-- Tabs -->
|
|
<div class="tabs" role="tablist" aria-label="{{ _('duplicates.tabs_aria') }}">
|
|
<button class="tab-btn active" role="tab" aria-selected="true"
|
|
aria-controls="tab-exact" id="btn-exact"
|
|
onclick="switchTab('exact')">
|
|
<i class="fas fa-clone mr-1" aria-hidden="true"></i>
|
|
{{ _("duplicates.tab_exact") }}
|
|
{% if total_groups is defined %}
|
|
<span class="ml-1 text-xs bg-red-100 text-red-700 font-bold px-1.5 rounded-full">
|
|
{{ total_groups }}
|
|
</span>
|
|
{% endif %}
|
|
</button>
|
|
<button class="tab-btn" role="tab" aria-selected="false"
|
|
aria-controls="tab-near" id="btn-near"
|
|
onclick="switchTab('near')">
|
|
<i class="fas fa-layer-group mr-1" aria-hidden="true"></i>
|
|
{{ _("duplicates.tab_near") }}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- ── Tab: Exact Duplicates ── -->
|
|
<div id="tab-exact" class="tab-panel active" role="tabpanel" aria-labelledby="btn-exact">
|
|
|
|
{% if groups %}
|
|
<p class="text-sm text-gray-500 mb-4">
|
|
{{ _("duplicates.found_prefix") }} <strong>{{ total_groups }}</strong> {{ _("duplicates.found_groups") }}
|
|
<strong>{{ total_duplicate_files }}</strong> {{ _("duplicates.found_files") }}
|
|
</p>
|
|
|
|
{% for group in groups %}
|
|
<section class="dup-group" aria-label="{{ _('duplicates.group_aria') }} {{ loop.index }}">
|
|
<div class="dup-group-header">
|
|
<i class="fas fa-fingerprint text-gray-400" aria-hidden="true"></i>
|
|
<span class="text-xs font-mono text-gray-500" title="SHA-256 hash">
|
|
{{ group.filehash[:16] }}…
|
|
</span>
|
|
<span class="dup-badge dup-badge-exact">
|
|
<i class="fas fa-copy" aria-hidden="true"></i>
|
|
{{ group.duplicate_count }} {{ _("duplicates.dup_singular") if group.duplicate_count == 1 else _("duplicates.dup_plural") }}
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Original file -->
|
|
{% if group.original %}
|
|
<div class="dup-file-row original">
|
|
<span class="dup-role-tag tag-original">{{ _("duplicates.role_original") }}</span>
|
|
<div class="flex-1 min-w-0">
|
|
<div class="dup-filename">
|
|
<a href="/files/{{ group.original.id }}"
|
|
class="hover:text-blue-600">
|
|
{{ group.original.original_filename or '(unnamed)' }}
|
|
</a>
|
|
</div>
|
|
<div class="dup-meta">
|
|
ID: {{ group.original.id }}
|
|
{% if group.original.file_size %}
|
|
· {{ (group.original.file_size / 1024)|round(1) }} KB
|
|
{% endif %}
|
|
{% if group.original.created_at %}
|
|
· {{ group.original.created_at[:19].replace('T',' ') }}
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
<div class="dup-actions">
|
|
<a href="/files/{{ group.original.id }}"
|
|
class="text-sm text-blue-600 hover:underline"
|
|
aria-label="{{ _('duplicates.view_original_aria') }} {{ group.original.original_filename }}">
|
|
<i class="fas fa-external-link-alt" aria-hidden="true"></i> {{ _("common.view") }}
|
|
</a>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Duplicate files -->
|
|
{% for dup in group.duplicates %}
|
|
<div class="dup-file-row duplicate">
|
|
<span class="dup-role-tag tag-duplicate">{{ _("duplicates.role_duplicate") }}</span>
|
|
<div class="flex-1 min-w-0">
|
|
<div class="dup-filename">
|
|
<a href="/files/{{ dup.id }}" class="hover:text-blue-600">
|
|
{{ dup.original_filename or '(unnamed)' }}
|
|
</a>
|
|
</div>
|
|
<div class="dup-meta">
|
|
ID: {{ dup.id }}
|
|
{% if dup.file_size %}
|
|
· {{ (dup.file_size / 1024)|round(1) }} KB
|
|
{% endif %}
|
|
{% if dup.created_at %}
|
|
· {{ dup.created_at[:19].replace('T',' ') }}
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
<div class="dup-actions">
|
|
<a href="/files/{{ dup.id }}"
|
|
class="text-sm text-blue-600 hover:underline"
|
|
aria-label="{{ _('duplicates.view_dup_aria') }} {{ dup.original_filename }}">
|
|
<i class="fas fa-external-link-alt" aria-hidden="true"></i> {{ _("common.view") }}
|
|
</a>
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
</section>
|
|
{% endfor %}
|
|
|
|
<!-- Pagination -->
|
|
{% if pagination.pages > 1 %}
|
|
<nav class="pagination" aria-label="{{ _('duplicates.pagination_aria') }}">
|
|
<button class="page-btn" onclick="changePage({{ pagination.page - 1 }})"
|
|
{% if pagination.page <= 1 %}disabled{% endif %}
|
|
aria-label="{{ _('common.prev_page') }}">
|
|
<i class="fas fa-chevron-left" aria-hidden="true"></i>
|
|
</button>
|
|
{% for p in range(1, pagination.pages + 1) %}
|
|
<button class="page-btn {% if p == pagination.page %}current{% endif %}"
|
|
onclick="changePage({{ p }})"
|
|
aria-label="{{ _('common.page') }} {{ p }}"
|
|
{% if p == pagination.page %}aria-current="page"{% endif %}>
|
|
{{ p }}
|
|
</button>
|
|
{% endfor %}
|
|
<button class="page-btn" onclick="changePage({{ pagination.page + 1 }})"
|
|
{% if pagination.page >= pagination.pages %}disabled{% endif %}
|
|
aria-label="{{ _('common.next_page') }}">
|
|
<i class="fas fa-chevron-right" aria-hidden="true"></i>
|
|
</button>
|
|
</nav>
|
|
{% endif %}
|
|
|
|
{% else %}
|
|
<div class="empty-state">
|
|
<i class="fas fa-check-circle text-green-400" aria-hidden="true"></i>
|
|
<p class="font-semibold text-lg text-gray-700">{{ _("duplicates.no_exact_heading") }}</p>
|
|
<p class="text-sm mt-1">
|
|
{{ _("duplicates.no_exact_detail_pre") }} <strong>{{ _("duplicates.tab_near") }}</strong> {{ _("duplicates.no_exact_detail_post") }}
|
|
</p>
|
|
</div>
|
|
{% endif %}
|
|
</div><!-- /tab-exact -->
|
|
|
|
<!-- ── Tab: Near-Duplicate Finder ── -->
|
|
<div id="tab-near" class="tab-panel" role="tabpanel" aria-labelledby="btn-near">
|
|
|
|
<div class="near-dup-search">
|
|
<h3>
|
|
<i class="fas fa-search mr-1 text-yellow-500" aria-hidden="true"></i>
|
|
{{ _("duplicates.near_dup_heading") }}
|
|
</h3>
|
|
<p class="text-sm text-gray-500 mb-4">
|
|
{{ _("duplicates.near_dup_desc") }}
|
|
</p>
|
|
|
|
<form id="nearDupForm" onsubmit="findNearDups(event)">
|
|
<div class="nd-form">
|
|
<div class="flex flex-col gap-1">
|
|
<label for="ndFileId">{{ _("duplicates.file_id_label") }}</label>
|
|
<input type="number" id="ndFileId" name="file_id" min="1"
|
|
placeholder="{{ _('duplicates.file_id_placeholder') }}" required
|
|
style="min-height:44px;">
|
|
</div>
|
|
<div class="flex flex-col gap-1">
|
|
<label for="ndThreshold">{{ _("duplicates.threshold_label") }}</label>
|
|
<input type="number" id="ndThreshold" name="threshold"
|
|
min="0" max="1" step="0.05"
|
|
value="{{ near_duplicate_threshold }}"
|
|
style="min-height:44px;">
|
|
</div>
|
|
<div class="flex flex-col gap-1">
|
|
<label for="ndLimit">{{ _("duplicates.max_results_label") }}</label>
|
|
<select id="ndLimit" name="limit" style="min-height:44px;">
|
|
<option value="5" selected>5</option>
|
|
<option value="10">10</option>
|
|
<option value="20">20</option>
|
|
</select>
|
|
</div>
|
|
<button type="submit"
|
|
class="bg-yellow-500 hover:bg-yellow-600 text-white font-bold
|
|
px-5 rounded-lg transition-colors"
|
|
style="min-height:44px;">
|
|
<i class="fas fa-search mr-1" aria-hidden="true"></i> {{ _("duplicates.find_btn") }}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
|
|
<div id="ndStatus" class="mt-3 text-sm text-gray-500" aria-live="polite"></div>
|
|
|
|
<div id="ndResults" class="nd-results" aria-live="polite"></div>
|
|
</div>
|
|
|
|
<!-- How it works explanation -->
|
|
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 text-sm text-blue-800">
|
|
<p class="font-semibold mb-1">
|
|
<i class="fas fa-info-circle mr-1" aria-hidden="true"></i>
|
|
{{ _("duplicates.how_it_works_heading") }}
|
|
</p>
|
|
<ul class="list-disc ml-5 space-y-1">
|
|
<li>{{ _("duplicates.how_step1_pre") }} <em>{{ _("duplicates.embedding_vector") }}</em> {{ _("duplicates.how_step1_post") }}</li>
|
|
<li>{{ _("duplicates.how_step2_pre") }} <em>{{ _("duplicates.cosine_similarity") }}</em>{{ _("duplicates.how_step2_post") }}</li>
|
|
<li>{{ _("duplicates.how_step3_pre") }} <strong>{{ near_duplicate_threshold }}</strong> {{ _("duplicates.how_step3_mid") }}
|
|
{{ (near_duplicate_threshold * 100)|round|int }}% {{ _("duplicates.how_step3_post") }}</li>
|
|
<li>{{ _("duplicates.how_step4") }} <strong>> 0.90</strong>.</li>
|
|
</ul>
|
|
</div>
|
|
</div><!-- /tab-near -->
|
|
|
|
</main>
|
|
{% endblock %}
|
|
|
|
{% block scripts %}
|
|
<script>
|
|
// ── Tab switching ──────────────────────────────────────────────────────────
|
|
function switchTab(name) {
|
|
document.querySelectorAll('.tab-btn').forEach(btn => {
|
|
const active = btn.id === 'btn-' + name;
|
|
btn.classList.toggle('active', active);
|
|
btn.setAttribute('aria-selected', active ? 'true' : 'false');
|
|
});
|
|
document.querySelectorAll('.tab-panel').forEach(panel => {
|
|
panel.classList.toggle('active', panel.id === 'tab-' + name);
|
|
});
|
|
}
|
|
|
|
// ── Pagination ─────────────────────────────────────────────────────────────
|
|
function changePage(p) {
|
|
const url = new URL(window.location.href);
|
|
url.searchParams.set('page', p);
|
|
window.location.href = url.toString();
|
|
}
|
|
|
|
// ── Near-duplicate API call ───────────────────────────────────────────────
|
|
async function findNearDups(e) {
|
|
e.preventDefault();
|
|
const fileId = document.getElementById('ndFileId').value.trim();
|
|
const threshold = parseFloat(document.getElementById('ndThreshold').value) || 0.85;
|
|
const limit = parseInt(document.getElementById('ndLimit').value) || 5;
|
|
const status = document.getElementById('ndStatus');
|
|
const results = document.getElementById('ndResults');
|
|
|
|
if (!fileId) {
|
|
status.textContent = 'Please enter a File ID.';
|
|
return;
|
|
}
|
|
|
|
status.textContent = 'Searching…';
|
|
results.innerHTML = '';
|
|
|
|
try {
|
|
const url = `/api/files/${encodeURIComponent(fileId)}/duplicates`
|
|
+ `?near_duplicate_threshold=${threshold}&near_duplicate_limit=${limit}`;
|
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
|
|
if (!csrfToken) {
|
|
status.textContent = 'Security token not found. Please reload the page.';
|
|
return;
|
|
}
|
|
const resp = await fetch(url, {
|
|
headers: { 'X-CSRF-Token': csrfToken }
|
|
});
|
|
|
|
if (!resp.ok) {
|
|
const err = await resp.json().catch(() => ({}));
|
|
status.textContent = `Error ${resp.status}: ${err.detail || resp.statusText}`;
|
|
return;
|
|
}
|
|
|
|
const data = await resp.json();
|
|
renderNearDupResults(data, threshold);
|
|
status.textContent = '';
|
|
} catch (err) {
|
|
status.textContent = `Network error: ${err.message}`;
|
|
}
|
|
}
|
|
|
|
function renderNearDupResults(data, threshold) {
|
|
const container = document.getElementById('ndResults');
|
|
|
|
if (!data.near_duplicates || data.near_duplicates.length === 0) {
|
|
container.innerHTML = `
|
|
<div class="empty-state py-6">
|
|
<i class="fas fa-check-circle text-green-400 text-3xl mb-2" aria-hidden="true"></i>
|
|
<p class="font-semibold text-gray-700">No near-duplicates found</p>
|
|
<p class="text-sm text-gray-500 mt-1">
|
|
No other document exceeded the ${(threshold * 100).toFixed(0)}% similarity threshold.
|
|
${!data.near_duplicates ? 'This file may not have OCR text yet.' : ''}
|
|
</p>
|
|
</div>`;
|
|
return;
|
|
}
|
|
|
|
let html = `<h4 class="font-semibold text-gray-700 mb-2 mt-2">
|
|
Found <strong>${data.near_duplicate_count}</strong> near-duplicate(s) for file #${data.file_id}
|
|
</h4>
|
|
<div class="dup-group">`;
|
|
|
|
// Show target file header
|
|
html += `
|
|
<div class="dup-group-header">
|
|
<i class="fas fa-file-alt text-gray-400" aria-hidden="true"></i>
|
|
<span class="text-sm font-semibold">Reference file: ID ${data.file_id}</span>
|
|
<span class="dup-badge dup-badge-near ml-auto">
|
|
Threshold ≥ ${(data.near_duplicate_threshold * 100).toFixed(0)}%
|
|
</span>
|
|
</div>`;
|
|
|
|
data.near_duplicates.forEach(nd => {
|
|
const scorePercent = (nd.similarity_score * 100).toFixed(1);
|
|
const scoreColor = nd.similarity_score >= 0.95
|
|
? 'text-red-600' : nd.similarity_score >= 0.85
|
|
? 'text-yellow-600' : 'text-orange-500';
|
|
const createdAt = nd.created_at ? nd.created_at.substring(0, 19).replace('T', ' ') : '';
|
|
|
|
html += `
|
|
<div class="dup-file-row near-dup">
|
|
<span class="dup-role-tag tag-near">Near-Dup</span>
|
|
<div class="flex-1 min-w-0">
|
|
<div class="dup-filename">
|
|
<a href="/files/${nd.file_id}" class="hover:text-blue-600">
|
|
${escapeHtml(nd.original_filename || '(unnamed)')}
|
|
</a>
|
|
</div>
|
|
<div class="dup-meta">
|
|
ID: ${nd.file_id}
|
|
${nd.document_title ? ' · ' + escapeHtml(nd.document_title) : ''}
|
|
${createdAt ? ' · ' + createdAt : ''}
|
|
</div>
|
|
</div>
|
|
<div class="dup-actions items-center">
|
|
<span class="dup-score ${scoreColor}" title="Cosine similarity score">
|
|
${scorePercent}% match
|
|
</span>
|
|
<a href="/files/${nd.file_id}"
|
|
class="text-sm text-blue-600 hover:underline ml-2"
|
|
aria-label="View file ${escapeHtml(nd.original_filename || '')}">
|
|
<i class="fas fa-external-link-alt" aria-hidden="true"></i>
|
|
</a>
|
|
</div>
|
|
</div>`;
|
|
});
|
|
|
|
html += '</div>';
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
function escapeHtml(str) {
|
|
return String(str)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
}
|
|
|
|
// Pre-fill file ID from query string (e.g. /duplicates?file_id=42&tab=near)
|
|
(function () {
|
|
const params = new URLSearchParams(window.location.search);
|
|
const fid = params.get('file_id');
|
|
if (fid) document.getElementById('ndFileId').value = fid;
|
|
const tab = params.get('tab');
|
|
if (tab === 'near') switchTab('near');
|
|
})();
|
|
</script>
|
|
{% endblock %}
|