Sfoglia il codice sorgente

[Feature] Rework Archive duplicates tagging (#718)

[Feature] Rework Archive duplicates tagging (#718)
Keybored 2 mesi fa
parent
commit
92c3ce3993

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

@@ -2,13 +2,14 @@ import io
 import json
 import json
 import logging
 import logging
 import zipfile
 import zipfile
+from collections import defaultdict
 from datetime import date, datetime, time, timezone
 from datetime import date, datetime, time, timezone
 from decimal import ROUND_HALF_UP, Decimal
 from decimal import ROUND_HALF_UP, Decimal
 from pathlib import Path
 from pathlib import Path
 
 
 from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile
 from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile
 from fastapi.responses import FileResponse, Response
 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 sqlalchemy.ext.asyncio import AsyncSession
 
 
 from backend.app.core.auth import (
 from backend.app.core.auth import (
@@ -62,6 +63,8 @@ def archive_to_response(
     archive: PrintArchive,
     archive: PrintArchive,
     duplicates: list[dict] | None = None,
     duplicates: list[dict] | None = None,
     duplicate_count: int = 0,
     duplicate_count: int = 0,
+    duplicate_sequence: int = 0,
+    original_archive_id: int | None = None,
 ) -> dict:
 ) -> dict:
     """Convert archive model to response dict with computed fields."""
     """Convert archive model to response dict with computed fields."""
     data = {
     data = {
@@ -79,6 +82,8 @@ def archive_to_response(
         "f3d_path": archive.f3d_path,
         "f3d_path": archive.f3d_path,
         "duplicates": duplicates,
         "duplicates": duplicates,
         "duplicate_count": duplicate_count if duplicates is None else len(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_name": archive.print_name,
         "print_time_seconds": archive.print_time_seconds,
         "print_time_seconds": archive.print_time_seconds,
         "filament_used_grams": archive.filament_used_grams,
         "filament_used_grams": archive.filament_used_grams,
@@ -141,16 +146,99 @@ async def list_archives(
         offset=offset,
         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 = []
     result = []
     for a in archives:
     for a in archives:
         has_hash_dup = a.content_hash in duplicate_hashes if a.content_hash else False
         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
         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
     return result
 
 
 
 

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

@@ -48,6 +48,8 @@ class ArchiveResponse(BaseModel):
     # Duplicate detection
     # Duplicate detection
     duplicates: list[ArchiveDuplicate] | None = None
     duplicates: list[ArchiveDuplicate] | None = None
     duplicate_count: int = 0  # Quick count for list views
     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 (computed from extra_data.printable_objects)
     object_count: int | None = None
     object_count: int | None = None

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

@@ -727,10 +727,14 @@ class ArchiveService:
                 sha256.update(chunk)
                 sha256.update(chunk)
         return sha256.hexdigest()
         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
         from sqlalchemy import func
 
 
@@ -742,15 +746,17 @@ class ArchiveService:
         )
         )
         duplicate_hashes = {row[0] for row in result.all()}
         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(
         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)
             .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(
     async def find_duplicates(
         self,
         self,
@@ -789,15 +795,23 @@ class ArchiveService:
                 )
                 )
 
 
         # Then, find similar matches by print name or MakerWorld ID
         # 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:
         if print_name or makerworld_model_id:
             conditions = [PrintArchive.id != archive_id]
             conditions = [PrintArchive.id != archive_id]
 
 
             name_conditions = []
             name_conditions = []
             if print_name:
             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:
             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)
                 # Use json_extract for SQLite compatibility (astext is PostgreSQL-only)
                 from sqlalchemy import func
                 from sqlalchemy import func
 
 

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

@@ -349,6 +349,8 @@ export interface Archive {
   f3d_path: string | null;
   f3d_path: string | null;
   duplicates: ArchiveDuplicate[] | null;
   duplicates: ArchiveDuplicate[] | null;
   duplicate_count: number;
   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;
   object_count: number | null;
   print_name: string | null;
   print_name: string | null;
   print_time_seconds: number | null;
   print_time_seconds: number | null;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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