Files
gh-christianlouis-docuelevate/frontend/templates/duplicates.html
copilot-swe-agent[bot] 64577f2d92 i18n: translate 6 HTML Jinja2 templates with _() calls
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>
2026-03-11 12:22:46 +00:00

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>&gt; 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// 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 %}