[Feature] Rework Archive duplicates tagging (#718)

[Feature] Rework Archive duplicates tagging (#718)
This commit is contained in:
Keybored
2026-03-16 15:40:24 +01:00
committed by GitHub
parent 3ca91eb6d1
commit 92c3ce3993
12 changed files with 251 additions and 27 deletions
+94 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>