mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 21:47:39 +02:00
[Feature] Rework Archive duplicates tagging (#718)
[Feature] Rework Archive duplicates tagging (#718)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'コスト',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '成本',
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user