Parcourir la source

[Feature] Rework Archive duplicates tagging (#718)

[Feature] Rework Archive duplicates tagging (#718)
Keybored il y a 2 mois
Parent
commit
92c3ce3993

+ 94 - 6
backend/app/api/routes/archives.py

@@ -2,13 +2,14 @@ import io
 import json
 import logging
 import zipfile
+from collections import defaultdict
 from datetime import date, datetime, time, timezone
 from decimal import ROUND_HALF_UP, Decimal
 from pathlib import Path
 
 from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile
 from fastapi.responses import FileResponse, Response
-from sqlalchemy import func, select
+from sqlalchemy import and_, func, or_, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.auth import (
@@ -62,6 +63,8 @@ def archive_to_response(
     archive: PrintArchive,
     duplicates: list[dict] | None = None,
     duplicate_count: int = 0,
+    duplicate_sequence: int = 0,
+    original_archive_id: int | None = None,
 ) -> dict:
     """Convert archive model to response dict with computed fields."""
     data = {
@@ -79,6 +82,8 @@ def archive_to_response(
         "f3d_path": archive.f3d_path,
         "duplicates": duplicates,
         "duplicate_count": duplicate_count if duplicates is None else len(duplicates),
+        "duplicate_sequence": duplicate_sequence,
+        "original_archive_id": original_archive_id,
         "print_name": archive.print_name,
         "print_time_seconds": archive.print_time_seconds,
         "filament_used_grams": archive.filament_used_grams,
@@ -141,16 +146,99 @@ async def list_archives(
         offset=offset,
     )
 
-    # Get sets of hashes and names that have duplicates (efficient single queries)
-    duplicate_hashes, duplicate_names = await service.get_duplicate_hashes_and_names()
+    # Get sets of duplicate hashes and duplicate (name, hash) pairs (efficient single queries)
+    duplicate_hashes, duplicate_name_hash_pairs = await service.get_duplicate_hashes_and_names()
 
-    # Mark archives that have duplicates (by hash or by print name)
+    # Batch-load duplicate groups once for the current page keys.
+    duplicate_hashes_in_page = {
+        a.content_hash for a in archives if a.content_hash and a.content_hash in duplicate_hashes
+    }
+    duplicate_name_hash_keys_in_page = {
+        (a.print_name.lower(), a.content_hash)
+        for a in archives
+        if a.print_name and a.content_hash and (a.print_name.lower(), a.content_hash) in duplicate_name_hash_pairs
+    }
+
+    duplicate_meta_by_archive_id: dict[int, tuple[int, int, int]] = {}
+
+    if duplicate_hashes_in_page or duplicate_name_hash_keys_in_page:
+        duplicate_group_conditions = []
+        if duplicate_hashes_in_page:
+            duplicate_group_conditions.append(PrintArchive.content_hash.in_(duplicate_hashes_in_page))
+        if duplicate_name_hash_keys_in_page:
+            name_hash_conditions = [
+                and_(func.lower(PrintArchive.print_name) == name, PrintArchive.content_hash == hash_)
+                for name, hash_ in duplicate_name_hash_keys_in_page
+            ]
+            duplicate_group_conditions.extend(name_hash_conditions)
+
+        duplicate_group_rows = await db.execute(
+            select(
+                PrintArchive.id,
+                PrintArchive.created_at,
+                PrintArchive.content_hash,
+                func.lower(PrintArchive.print_name).label("print_name_lower"),
+            ).where(or_(*duplicate_group_conditions))
+        )
+
+        duplicate_groups_by_hash: dict[str, list[tuple[int, datetime]]] = defaultdict(list)
+        duplicate_groups_by_name_hash: dict[tuple[str, str], list[tuple[int, datetime]]] = defaultdict(list)
+
+        for archive_id, created_at, content_hash, print_name_lower in duplicate_group_rows.all():
+            if content_hash and content_hash in duplicate_hashes_in_page:
+                duplicate_groups_by_hash[content_hash].append((archive_id, created_at))
+            if (
+                print_name_lower
+                and content_hash
+                and (print_name_lower, content_hash) in duplicate_name_hash_keys_in_page
+            ):
+                duplicate_groups_by_name_hash[(print_name_lower, content_hash)].append((archive_id, created_at))
+
+        for group in duplicate_groups_by_hash.values():
+            if len(group) < 2:
+                continue
+            group.sort(key=lambda x: x[1])
+            original_id = group[0][0]
+            duplicate_count = len(group) - 1
+            for sequence, (archive_id, _) in enumerate(group):
+                duplicate_meta_by_archive_id[archive_id] = (sequence, original_id, duplicate_count)
+
+        # Keep hash-based grouping precedence; name/hash groups only fill missing items.
+        for group in duplicate_groups_by_name_hash.values():
+            if len(group) < 2:
+                continue
+            group.sort(key=lambda x: x[1])
+            original_id = group[0][0]
+            duplicate_count = len(group) - 1
+            for sequence, (archive_id, _) in enumerate(group):
+                duplicate_meta_by_archive_id.setdefault(archive_id, (sequence, original_id, duplicate_count))
+
+    # Build response with duplicate sequence and original archive ID pre-computed
     result = []
     for a in archives:
         has_hash_dup = a.content_hash in duplicate_hashes if a.content_hash else False
-        has_name_dup = a.print_name and a.print_name.lower() in duplicate_names
+        has_name_dup = (
+            bool(a.print_name and a.content_hash)
+            and (a.print_name.lower(), a.content_hash) in duplicate_name_hash_pairs
+        )
         has_duplicate = has_hash_dup or has_name_dup
-        result.append(archive_to_response(a, duplicate_count=1 if has_duplicate else 0))
+
+        # Pre-compute duplicate sequence and original archive ID
+        duplicate_sequence = 0
+        original_archive_id: int | None = None
+        duplicate_count = 1 if has_duplicate else 0
+
+        if has_duplicate and a.id in duplicate_meta_by_archive_id:
+            duplicate_sequence, original_archive_id, duplicate_count = duplicate_meta_by_archive_id[a.id]
+
+        result.append(
+            archive_to_response(
+                a,
+                duplicate_count=duplicate_count,
+                duplicate_sequence=duplicate_sequence,
+                original_archive_id=original_archive_id,
+            )
+        )
     return result
 
 

+ 2 - 0
backend/app/schemas/archive.py

@@ -48,6 +48,8 @@ class ArchiveResponse(BaseModel):
     # Duplicate detection
     duplicates: list[ArchiveDuplicate] | None = None
     duplicate_count: int = 0  # Quick count for list views
+    duplicate_sequence: int = 0  # 0 = original, 1+ = nth duplicate
+    original_archive_id: int | None = None  # ID of the first/original archive
 
     # Object count (computed from extra_data.printable_objects)
     object_count: int | None = None

+ 25 - 11
backend/app/services/archive.py

@@ -727,10 +727,14 @@ class ArchiveService:
                 sha256.update(chunk)
         return sha256.hexdigest()
 
-    async def get_duplicate_hashes_and_names(self) -> tuple[set[str], set[str]]:
-        """Get all content hashes and print names that appear more than once.
+    async def get_duplicate_hashes_and_names(self) -> tuple[set[str], set[tuple[str, str]]]:
+        """Get all content hashes and (print name, hash) pairs that appear more than once.
 
-        Returns a tuple of (duplicate_hashes, duplicate_names).
+        For hashes: returns all hashes with > 1 archive (true duplicates).
+        For name/hash pairs: returns only pairs that have > 1 archive
+                     (i.e., same file archived multiple times, not different files with same name).
+
+        Returns a tuple of (duplicate_hashes, duplicate_name_hash_pairs).
         """
         from sqlalchemy import func
 
@@ -742,15 +746,17 @@ class ArchiveService:
         )
         duplicate_hashes = {row[0] for row in result.all()}
 
+        # Find print names that have multiple archives with the SAME hash
+        # This avoids marking different files with the same name as duplicates
         result = await self.db.execute(
-            select(func.lower(PrintArchive.print_name))
-            .where(PrintArchive.print_name.isnot(None))
-            .group_by(func.lower(PrintArchive.print_name))
+            select(func.lower(PrintArchive.print_name), PrintArchive.content_hash)
+            .where(PrintArchive.print_name.isnot(None), PrintArchive.content_hash.isnot(None))
+            .group_by(func.lower(PrintArchive.print_name), PrintArchive.content_hash)
             .having(func.count(PrintArchive.id) > 1)
         )
-        duplicate_names = {row[0] for row in result.all()}
+        duplicate_name_hash_pairs = {(row[0], row[1]) for row in result.all()}
 
-        return duplicate_hashes, duplicate_names
+        return duplicate_hashes, duplicate_name_hash_pairs
 
     async def find_duplicates(
         self,
@@ -789,15 +795,23 @@ class ArchiveService:
                 )
 
         # Then, find similar matches by print name or MakerWorld ID
+        # Prefer strict name+hash matching when hash exists; fallback to name-only for legacy/manual
+        # archives that may not have a content_hash.
         if print_name or makerworld_model_id:
             conditions = [PrintArchive.id != archive_id]
 
             name_conditions = []
             if print_name:
-                # Match if print names are similar (ignoring case)
-                name_conditions.append(PrintArchive.print_name.ilike(print_name))
+                if content_hash:
+                    # Match if print names are similar AND have the same hash (same file)
+                    name_conditions.append(
+                        and_(PrintArchive.print_name.ilike(print_name), PrintArchive.content_hash == content_hash)
+                    )
+                else:
+                    # Fallback for archives without hash data: match by print name only.
+                    name_conditions.append(PrintArchive.print_name.ilike(print_name))
             if makerworld_model_id:
-                # Match by MakerWorld model ID stored in extra_data
+                # Match by MakerWorld model ID stored in extra_data (same design from MakerWorld)
                 # Use json_extract for SQLite compatibility (astext is PostgreSQL-only)
                 from sqlalchemy import func
 

+ 2 - 0
frontend/src/api/client.ts

@@ -349,6 +349,8 @@ export interface Archive {
   f3d_path: string | null;
   duplicates: ArchiveDuplicate[] | null;
   duplicate_count: number;
+  duplicate_sequence: number;  // 0 = original, 1+ = nth duplicate
+  original_archive_id: number | null;  // ID of the first/original archive
   object_count: number | null;
   print_name: string | null;
   print_time_seconds: number | null;

+ 3 - 0
frontend/src/i18n/locales/de.ts

@@ -524,6 +524,7 @@ export default {
     sortSize: 'Größe',
     noArchives: 'Keine Archive gefunden',
     noArchivesSearch: 'Keine Archive entsprechen Ihrer Suche',
+    originalPrintNotVisible: 'Ursprünglicher Druck nicht sichtbar - versuchen Sie, die Filter zu löschen',
     noArchivesYet: 'Noch keine Archive',
     loadingArchives: 'Lade Archive...',
     releaseToUpload: 'Loslassen zum Hochladen',
@@ -536,6 +537,8 @@ export default {
     manageTags: 'Tags verwalten',
     showFailedPrints: 'Fehlgeschlagene Drucke anzeigen',
     hideFailedPrints: 'Fehlgeschlagene Drucke ausblenden',
+    hideDuplicates: 'Duplikate ausblenden',
+    viewOriginalPrint: 'Klicken, um den ursprünglichen Druck anzuzeigen (#{{id}})',
     printTime: 'Druckzeit',
     filamentUsed: 'Verbrauchtes Filament',
     cost: 'Kosten',

+ 3 - 0
frontend/src/i18n/locales/en.ts

@@ -524,6 +524,7 @@ export default {
     sortSize: 'Size',
     noArchives: 'No archives found',
     noArchivesSearch: 'No archives match your search',
+    originalPrintNotVisible: 'Original print not visible - try clearing filters',
     noArchivesYet: 'No archives yet',
     loadingArchives: 'Loading archives...',
     releaseToUpload: 'Release to upload',
@@ -536,6 +537,8 @@ export default {
     manageTags: 'Manage Tags',
     showFailedPrints: 'Show failed prints',
     hideFailedPrints: 'Hide failed prints',
+    hideDuplicates: 'Hide Duplicates',
+    viewOriginalPrint: 'Click to view original print (#{{id}})',
     printTime: 'Print Time',
     filamentUsed: 'Filament Used',
     cost: 'Cost',

+ 3 - 0
frontend/src/i18n/locales/fr.ts

@@ -524,6 +524,7 @@ export default {
     sortSize: 'Taille',
     noArchives: 'Aucune archive trouvée',
     noArchivesSearch: 'Aucune archive ne correspond',
+    originalPrintNotVisible: 'Impression d\'origine non visible - essayez d\'effacer les filtres',
     noArchivesYet: 'Pas encore d\'archive',
     loadingArchives: 'Chargement...',
     releaseToUpload: 'Relâcher pour téléverser',
@@ -536,6 +537,8 @@ export default {
     manageTags: 'Gérer les tags',
     showFailedPrints: 'Afficher les échecs',
     hideFailedPrints: 'Masquer les échecs',
+    hideDuplicates: 'Masquer les doublons',
+    viewOriginalPrint: 'Cliquez pour afficher l\'impression originale (#{{id}})',
     printTime: 'Temps d\'impression',
     filamentUsed: 'Filament utilisé',
     cost: 'Coût',

+ 3 - 0
frontend/src/i18n/locales/it.ts

@@ -524,6 +524,7 @@ export default {
     sortSize: 'Dimensione',
     noArchives: 'Nessun archivio trovato',
     noArchivesSearch: 'Nessun archivio corrisponde alla ricerca',
+    originalPrintNotVisible: 'Stampa originale non visibile - prova a rimuovere i filtri',
     noArchivesYet: 'Nessun archivio ancora',
     loadingArchives: 'Caricamento archivi...',
     releaseToUpload: 'Rilascia per caricare',
@@ -536,6 +537,8 @@ export default {
     manageTags: 'Gestisci tag',
     showFailedPrints: 'Mostra stampe fallite',
     hideFailedPrints: 'Nascondi stampe fallite',
+    hideDuplicates: 'Nascondi duplicati',
+    viewOriginalPrint: 'Fai clic per visualizzare la stampa originale (#{{id}})',
     printTime: 'Tempo di stampa',
     filamentUsed: 'Filamento usato',
     cost: 'Costo',

+ 3 - 0
frontend/src/i18n/locales/ja.ts

@@ -523,6 +523,7 @@ export default {
     sortSize: 'サイズ',
     noArchives: 'アーカイブが見つかりません',
     noArchivesSearch: '検索条件に一致するアーカイブがありません',
+    originalPrintNotVisible: '元の印刷が表示されていません - フィルターをクリアしてみてください',
     noArchivesYet: 'アーカイブはまだありません',
     loadingArchives: 'アーカイブを読み込み中...',
     releaseToUpload: 'ドロップしてアップロード',
@@ -535,6 +536,8 @@ export default {
     manageTags: 'タグを管理',
     showFailedPrints: '失敗した印刷を表示',
     hideFailedPrints: '失敗した印刷を非表示',
+    hideDuplicates: '重複を非表示',
+    viewOriginalPrint: 'クリックして元の印刷を表示 (#{{id}})',
     printTime: '印刷時間',
     filamentUsed: 'フィラメント使用量',
     cost: 'コスト',

+ 3 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -524,6 +524,7 @@ export default {
     sortSize: 'Tamanho',
     noArchives: 'Nenhum arquivo encontrado',
     noArchivesSearch: 'Nenhum arquivo corresponde à sua pesquisa',
+    originalPrintNotVisible: 'Impressão original não visível - tente limpar os filtros',
     noArchivesYet: 'Ainda não há arquivos',
     loadingArchives: 'Carregando arquivos...',
     releaseToUpload: 'Solte para enviar',
@@ -536,6 +537,8 @@ export default {
     manageTags: 'Gerenciar etiquetas',
     showFailedPrints: 'Mostrar impressões falhas',
     hideFailedPrints: 'Ocultar impressões falhas',
+    hideDuplicates: 'Ocultar duplicados',
+    viewOriginalPrint: 'Clique para visualizar a impressão original (#{{id}})',
     printTime: 'Tempo de impressão',
     filamentUsed: 'Filamento usado',
     cost: 'Custo',

+ 3 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -524,6 +524,7 @@ export default {
     sortSize: '大小',
     noArchives: '未找到归档',
     noArchivesSearch: '没有匹配搜索的归档',
+    originalPrintNotVisible: '原始打印不可见 - 请尝试清除筛选条件',
     noArchivesYet: '暂无归档',
     loadingArchives: '加载归档中...',
     releaseToUpload: '释放以上传',
@@ -536,6 +537,8 @@ export default {
     manageTags: '管理标签',
     showFailedPrints: '显示失败的打印',
     hideFailedPrints: '隐藏失败的打印',
+    hideDuplicates: '隐藏重复项',
+    viewOriginalPrint: '点击查看原始打印 (#{{id}})',
     printTime: '打印时间',
     filamentUsed: '耗材用量',
     cost: '成本',

+ 107 - 10
frontend/src/pages/ArchivesPage.tsx

@@ -41,6 +41,7 @@ import {
   MoreVertical,
   FileSpreadsheet,
   GitCompare,
+  GitBranch,
   Loader2,
   FolderKanban,
   ChevronLeft,
@@ -151,6 +152,7 @@ function ArchiveCard({
   preferredSlicer = 'bambu_studio',
   currency,
   t,
+  onNavigateToArchive,
 }: {
   archive: Archive;
   printerName: string;
@@ -163,6 +165,7 @@ function ArchiveCard({
   preferredSlicer?: SlicerType;
   currency: string;
   t: TFunction;
+  onNavigateToArchive?: (archiveId: number) => void;
 }) {
   // Debug: log when card is highlighted
   if (isHighlighted) {
@@ -202,6 +205,10 @@ function ArchiveCard({
     staleTime: 5 * 60 * 1000, // Cache for 5 minutes
   });
 
+  // Use pre-computed duplicate sequence and original archive ID from list response
+  const duplicateSequence = archive.duplicate_sequence ?? 0;
+  const originalArchiveId = archive.original_archive_id ?? null;
+
   const plates = platesData?.plates ?? [];
   const isMultiPlate = platesData?.is_multi_plate ?? false;
   const displayPlateIndex = currentPlateIndex ?? 0;
@@ -755,14 +762,27 @@ function ArchiveCard({
           </div>
         )}
         {/* Duplicate badge */}
-        {archive.duplicate_count > 0 && (
-          <div
-            className="absolute top-2 right-12 px-2 py-1 rounded text-xs bg-purple-500/80 text-white flex items-center gap-1"
-            title={t('archives.card.duplicateTitle')}
+        {archive.duplicate_count > 0 && duplicateSequence > 0 && originalArchiveId && (
+          <button
+            onClick={(e) => {
+              e.stopPropagation();
+              onNavigateToArchive?.(originalArchiveId);
+            }}
+            className="absolute top-2 right-12 px-2 py-1 rounded text-xs bg-purple-500/80 hover:bg-purple-600/90 text-white flex items-center gap-1 transition-colors cursor-pointer"
+            title={t('archives.viewOriginalPrint', { id: originalArchiveId })}
           >
             <Copy className="w-3 h-3" />
-            {t('archives.card.duplicate')}
-          </div>
+            #{duplicateSequence}
+          </button>
+        )}
+        {archive.duplicate_count > 0 && duplicateSequence === 0 && (
+          <span
+            className="absolute top-2 right-12 px-2 py-1 rounded text-xs bg-purple-500/80 text-white flex items-center gap-1"
+            title={`${archive.duplicate_count} reprint${archive.duplicate_count === 1 ? '' : 's'}`}
+          >
+            <GitBranch className="w-3 h-3" />
+            +{archive.duplicate_count}
+          </span>
         )}
         {/* Source 3MF badge */}
         {archive.source_3mf_path && (
@@ -852,6 +872,9 @@ function ArchiveCard({
       </div>
 
       <CardContent className="p-4 flex-1 flex flex-col">
+        {/* Archive ID */}
+        <p className="text-[10px] text-bambu-gray/70 mb-1">#{archive.id}</p>
+
         {/* Title */}
         <div className="flex items-center justify-between gap-2 mb-1">
           <h3 className="min-w-0 font-medium text-white truncate">
@@ -885,6 +908,15 @@ function ArchiveCard({
           >
             {isSlicedFile(archive) ? t('archives.card.gcode') : t('archives.card.source')}
           </span>
+          {/* File hash badge */}
+          {archive.content_hash && (
+            <span
+              className="text-[10px] px-1.5 py-0.5 rounded font-mono bg-bambu-dark-tertiary/50 text-bambu-gray-light opacity-0 transition-opacity duration-150 group-hover:opacity-100"
+              title={`SHA256: ${archive.content_hash}`}
+            >
+              {archive.content_hash.slice(0, 8).toUpperCase()}
+            </span>
+          )}
           {archive.project_name && (
             <span
               className="text-xs px-1.5 py-0.5 rounded-full truncate max-w-[120px]"
@@ -1396,6 +1428,7 @@ function ArchiveListRow({
   isHighlighted,
   preferredSlicer = 'bambu_studio',
   t,
+  onNavigateToArchive,
 }: {
   archive: Archive;
   printerName: string;
@@ -1406,6 +1439,7 @@ function ArchiveListRow({
   isHighlighted?: boolean;
   preferredSlicer?: SlicerType;
   t: TFunction;
+  onNavigateToArchive?: (archiveId: number) => void;
 }) {
   const queryClient = useQueryClient();
   const { showToast } = useToast();
@@ -1429,6 +1463,10 @@ function ArchiveListRow({
   const f3dInputRef = useRef<HTMLInputElement>(null);
   const timelapseInputRef = useRef<HTMLInputElement>(null);
 
+  // Use pre-computed duplicate sequence and original archive ID from list response
+  const duplicateSequence = archive.duplicate_sequence ?? 0;
+  const originalArchiveId = archive.original_archive_id ?? null;
+
   const timelapseDeleteMutation = useMutation({
     mutationFn: () => api.deleteArchiveTimelapse(archive.id),
     onSuccess: () => {
@@ -1867,6 +1905,28 @@ function ArchiveListRow({
                 {archive.status === 'aborted' ? t('archives.card.cancelled') : t('archives.card.failed')}
               </span>
             )}
+            {archive.duplicate_count > 0 && duplicateSequence > 0 && originalArchiveId && (
+              <button
+                onClick={(e) => {
+                  e.stopPropagation();
+                  onNavigateToArchive?.(originalArchiveId);
+                }}
+                className="px-1.5 py-0.5 rounded text-[10px] leading-tight bg-purple-500/80 hover:bg-purple-600/90 text-white flex-shrink-0 transition-colors flex items-center gap-1"
+                title={t('archives.viewOriginalPrint', { id: originalArchiveId })}
+              >
+                <Copy className="w-3 h-3" />
+                #{duplicateSequence}
+              </button>
+            )}
+            {archive.duplicate_count > 0 && duplicateSequence === 0 && (
+              <span
+                className="px-1.5 py-0.5 rounded text-[10px] leading-tight bg-purple-500/80 text-white flex-shrink-0 flex items-center gap-1"
+                title={`${archive.duplicate_count} reprint${archive.duplicate_count === 1 ? '' : 's'}`}
+              >
+                <GitBranch className="w-3 h-3" />
+                +{archive.duplicate_count}
+              </span>
+            )}
             {archive.timelapse_path && (
               <span title={t('archives.list.hasTimelapse')}>
                 <Film className="w-3.5 h-3.5 text-bambu-green flex-shrink-0" />
@@ -2295,6 +2355,9 @@ export function ArchivesPage() {
   const [hideFailed, setHideFailed] = useState(() =>
     localStorage.getItem('archiveHideFailed') === 'true'
   );
+  const [hideDuplicates, setHideDuplicates] = useState(() =>
+    localStorage.getItem('archiveHideDuplicates') === 'true'
+  );
   const [filterTag, setFilterTag] = useState<string | null>(() =>
     localStorage.getItem('archiveFilterTag')
   );
@@ -2323,6 +2386,7 @@ export function ArchivesPage() {
   const [showCompareModal, setShowCompareModal] = useState(false);
   const [showTagManagement, setShowTagManagement] = useState(false);
   const [highlightedArchiveId, setHighlightedArchiveId] = useState<number | null>(null);
+  const [pendingNavigationArchiveId, setPendingNavigationArchiveId] = useState<number | null>(null);
 
   // Log view state
   const [logFilterUser, setLogFilterUser] = useState<string | null>(() =>
@@ -2347,6 +2411,11 @@ export function ArchivesPage() {
     return saved ? Number(saved) : 25;
   });
 
+  const handleNavigateToArchive = useCallback((archiveId: number) => {
+    setPendingNavigationArchiveId(archiveId);
+    setHighlightedArchiveId(archiveId);
+  }, []);
+
   // Clear highlight after 5 seconds and scroll to highlighted element
   useEffect(() => {
     if (highlightedArchiveId) {
@@ -2355,6 +2424,11 @@ export function ArchivesPage() {
         const element = document.querySelector(`[data-archive-id="${highlightedArchiveId}"]`);
         if (element) {
           element.scrollIntoView({ behavior: 'smooth', block: 'center' });
+        } else if (pendingNavigationArchiveId === highlightedArchiveId) {
+          showToast(t('archives.originalPrintNotVisible'), 'warning');
+        }
+        if (pendingNavigationArchiveId === highlightedArchiveId) {
+          setPendingNavigationArchiveId(null);
         }
       }, 100);
 
@@ -2365,7 +2439,7 @@ export function ArchivesPage() {
         clearTimeout(clearTimer);
       };
     }
-  }, [highlightedArchiveId]);
+  }, [highlightedArchiveId, pendingNavigationArchiveId, showToast, t]);
 
   const { data: archives, isLoading } = useQuery({
     queryKey: ['archives', filterPrinter],
@@ -2472,6 +2546,10 @@ export function ArchivesPage() {
     localStorage.setItem('archiveHideFailed', hideFailed.toString());
   }, [hideFailed]);
 
+  useEffect(() => {
+    localStorage.setItem('archiveHideDuplicates', hideDuplicates.toString());
+  }, [hideDuplicates]);
+
   useEffect(() => {
     if (filterTag) {
       localStorage.setItem('archiveFilterTag', filterTag);
@@ -2600,6 +2678,10 @@ export function ArchivesPage() {
       // Hide failed filter (don't apply when viewing failed collection)
       const matchesHideFailed = collection === 'failed' || !hideFailed || (a.status !== 'failed' && a.status !== 'aborted');
 
+      // Hide duplicates filter (don't apply when viewing duplicates collection)
+      const matchesHideDuplicates =
+        collection === 'duplicates' || !hideDuplicates || a.duplicate_count === 0 || a.duplicate_sequence === 0;
+
       // Tag filter
       const archiveTags = a.tags?.split(',').map(t => t.trim()) || [];
       const matchesTag = !filterTag || archiveTags.includes(filterTag);
@@ -2610,7 +2692,7 @@ export function ArchivesPage() {
         (filterFileType === 'gcode' && isGcodeFile) ||
         (filterFileType === 'source' && !isGcodeFile);
 
-      return matchesCollection && matchesSearch && matchesMaterial && matchesColor && matchesFavorites && matchesHideFailed && matchesTag && matchesFileType;
+      return matchesCollection && matchesSearch && matchesMaterial && matchesColor && matchesFavorites && matchesHideFailed && matchesHideDuplicates && matchesTag && matchesFileType;
     })
     .sort((a, b) => {
       switch (sortBy) {
@@ -2678,11 +2760,12 @@ export function ArchivesPage() {
     setFilterMaterial(null);
     setFilterFavorites(false);
     setHideFailed(false);
+    setHideDuplicates(false);
     setFilterTag(null);
     setFilterFileType('all');
   };
 
-  const hasTopFilters = search || filterPrinter || filterMaterial || filterFavorites || hideFailed || filterTag || filterFileType !== 'all';
+  const hasTopFilters = search || filterPrinter || filterMaterial || filterFavorites || hideFailed || hideDuplicates || filterTag || filterFileType !== 'all';
 
   // Drag & drop handlers for page-wide upload
   const handleDragOver = useCallback((e: React.DragEvent) => {
@@ -2751,7 +2834,7 @@ export function ArchivesPage() {
 
   return (
     <div
-      className="p-4 md:p-8 relative min-h-full"
+      className="p-4 md:p-8 relative"
       onDragOver={handleDragOver}
       onDragLeave={handleDragLeave}
       onDrop={handleDrop}
@@ -3081,6 +3164,18 @@ export function ArchivesPage() {
               <AlertCircle className={`w-4 h-4 ${hideFailed ? '' : ''}`} />
               <span className="text-sm hidden md:inline">Hide Failed</span>
             </button>
+            <button
+              onClick={() => setHideDuplicates(!hideDuplicates)}
+              className={`flex items-center gap-2 px-3 py-2 rounded-lg border transition-colors flex-shrink-0 ${
+                hideDuplicates
+                  ? 'bg-purple-500/20 border-purple-500 text-purple-400'
+                  : 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
+              }`}
+              title={t('archives.hideDuplicates')}
+            >
+              <Copy className="w-4 h-4" />
+              <span className="text-sm hidden md:inline">{t('archives.hideDuplicates')}</span>
+            </button>
             {uniqueTags.length > 0 && (
               <div className="flex items-center gap-2 flex-shrink-0">
                 <Tag className="w-4 h-4 text-bambu-gray hidden md:block" />
@@ -3225,6 +3320,7 @@ export function ArchivesPage() {
               preferredSlicer={preferredSlicer}
               currency={currency}
               t={t}
+              onNavigateToArchive={handleNavigateToArchive}
             />
           ))}
         </div>
@@ -3253,6 +3349,7 @@ export function ArchivesPage() {
                 isHighlighted={archive.id === highlightedArchiveId}
                 preferredSlicer={preferredSlicer}
                 t={t}
+                onNavigateToArchive={handleNavigateToArchive}
               />
             ))}
           </div>