Files
bambuddy/frontend/src/components/PendingUploadsPanel.tsx
T
maziggy 01a7e6ee93 fix(archive,vp): strip .gcode.3mf properly + sync review/archive name (#1152)
@smandon retested the original #1152 fix on the latest daily and surfaced
  two distinct holes:

  1. ``Path(name).stem`` only strips the *last* suffix, so Bambu Studio's
     default ``Plate_1.gcode.3mf`` exports landed in the archive UI as
     ``Plate_1.gcode`` — never the bare ``Plate_1`` the user expected.

  2. The pending-uploads review card always showed the raw FTP filename,
     while the eventual ``PrintArchive.print_name`` resolved from the 3MF's
     embedded title (or, with the toggle on ``filename``, the stripped stem).
     Net effect: same upload showed two different names depending on which
     view you were looking at, with no way for the toggle to flip both
     views in lockstep.

  Three changes:

  - ``resolve_display_stem`` helper in ``services/archive.py`` strips
    ``.gcode.3mf`` / ``.3mf`` / ``.gcode`` (case-insensitive). Applied at
    the archive-creation site so ``Plate_1.gcode.3mf`` → ``Plate_1`` for
    every flow that produces a ``PrintArchive`` row.

  - ``PendingUpload.metadata_print_name`` (new nullable column) is
    populated at FTP-receive time by peeking at the 3MF's embedded title
    via the existing ``ThreeMFParser``. Read happens once per upload —
    the list endpoint then doesn't have to reopen each 3MF on every
    render. Parser failures are swallowed and the column stays NULL;
    the response model gracefully falls back to the stripped filename.

  - ``PendingUploadResponse.display_name`` is a computed field that
    mirrors ``archive_print``'s exact precedence — ``filename`` toggle
    → stripped stem; ``metadata`` toggle (default) → cached title or
    stripped stem. The frontend's review card reads it (with
    ``upload.filename`` as a defensive fallback) and surfaces the raw
    FTP filename via tooltip so users can still inspect what arrived.

  Migration is one idempotent ``ALTER TABLE pending_uploads ADD COLUMN
  metadata_print_name VARCHAR(255)`` (Postgres/SQLite-safe). Pre-migration
  rows have NULL and degrade to filename-stem behaviour without any
  operator action.

  Tests: 14 unit tests in ``test_archive_display_stem.py`` covering the
  canonical normalisation rules (Bambu Studio default name, mixed case,
  dots-in-the-middle, edge cases like ``.gcode.3mf``-only, full-path
  inputs); 6 integration tests in ``test_pending_upload_display_name.py``
  pinning the response contract (default toggle uses metadata title when
  present, falls back to stripped stem when absent, ``filename`` toggle
  overrides metadata, ``filename`` toggle still strips the double suffix,
  ``GET /{id}`` exposes the same field, whitespace-only metadata behaves
  like absent); 3 frontend tests in ``PendingUploadsPanel.test.tsx``
  pinning the review card's render path (resolved name shown, fallback
  to filename when display_name is empty, raw filename available via
  tooltip). Full backend suite: 3598 passed; frontend build clean; no
  regressions in any flow that previously processed ``.3mf`` /
  ``.gcode`` / non-3D filenames.
2026-05-01 12:34:56 +02:00

374 lines
13 KiB
TypeScript

import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Loader2, Archive, Trash2, FileBox, Clock, Upload, ChevronDown, ChevronUp } from 'lucide-react';
import { pendingUploadsApi } from '../api/client';
import type { PendingUpload, ProjectListItem } from '../api/client';
import { api } from '../api/client';
import { Card, CardContent, CardHeader } from './Card';
import { Button } from './Button';
import { useToast } from '../contexts/ToastContext';
import { ConfirmModal } from './ConfirmModal';
import { formatFileSize } from '../utils/file';
function formatTimeAgo(dateStr: string): string {
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `${diffHours}h ago`;
const diffDays = Math.floor(diffHours / 24);
return `${diffDays}d ago`;
}
interface PendingUploadItemProps {
upload: PendingUpload;
projects: ProjectListItem[];
onArchive: (id: number, data?: { tags?: string; notes?: string; project_id?: number }) => void;
onDiscard: (id: number) => void;
isArchiving: boolean;
isDiscarding: boolean;
}
function PendingUploadItem({
upload,
projects,
onArchive,
onDiscard,
isArchiving,
isDiscarding,
}: PendingUploadItemProps) {
const [expanded, setExpanded] = useState(false);
const [tags, setTags] = useState(upload.tags || '');
const [notes, setNotes] = useState(upload.notes || '');
const [projectId, setProjectId] = useState<number | null>(upload.project_id);
const [showDiscardConfirm, setShowDiscardConfirm] = useState(false);
return (
<Card>
<CardContent className="py-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<FileBox className="w-8 h-8 text-bambu-green flex-shrink-0" />
<div>
<p className="text-white font-medium" title={upload.filename}>{upload.display_name || upload.filename}</p>
<div className="flex items-center gap-2 text-xs text-bambu-gray">
<span>{formatFileSize(upload.file_size)}</span>
<span>·</span>
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{formatTimeAgo(upload.uploaded_at)}
</span>
{upload.source_ip && (
<>
<span>·</span>
<span>from {upload.source_ip}</span>
</>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setExpanded(!expanded)}
className="p-1 text-bambu-gray hover:text-white transition-colors"
>
{expanded ? <ChevronUp className="w-5 h-5" /> : <ChevronDown className="w-5 h-5" />}
</button>
<Button
variant="primary"
size="sm"
onClick={() => onArchive(upload.id, { tags, notes, project_id: projectId || undefined })}
disabled={isArchiving}
>
{isArchiving ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<>
<Archive className="w-4 h-4" />
Archive
</>
)}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => setShowDiscardConfirm(true)}
disabled={isDiscarding}
>
{isDiscarding ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4 text-red-400" />
)}
</Button>
</div>
</div>
{/* Discard Confirmation Modal */}
{showDiscardConfirm && (
<ConfirmModal
title="Discard Upload"
message={`Are you sure you want to discard "${upload.filename}"? This cannot be undone.`}
confirmText="Discard"
variant="danger"
onConfirm={() => {
onDiscard(upload.id);
setShowDiscardConfirm(false);
}}
onCancel={() => setShowDiscardConfirm(false)}
/>
)}
{/* Expanded details for adding tags/notes/project */}
{expanded && (
<div className="mt-4 pt-4 border-t border-bambu-dark-tertiary space-y-3">
<div>
<label className="block text-sm text-bambu-gray mb-1">Tags</label>
<input
type="text"
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder="e.g., functional, prototype, gift"
className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-2 text-white placeholder-bambu-gray text-sm"
/>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">Notes</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Add notes about this print..."
rows={2}
className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-2 text-white placeholder-bambu-gray text-sm resize-none"
/>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">Project</label>
<select
value={projectId || ''}
onChange={(e) => setProjectId(e.target.value ? Number(e.target.value) : null)}
className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-2 text-white text-sm"
>
<option value="">No project</option>
{projects.map((project) => (
<option key={project.id} value={project.id}>
{project.name}
</option>
))}
</select>
</div>
</div>
)}
</CardContent>
</Card>
);
}
export function PendingUploadsPanel() {
const queryClient = useQueryClient();
const { showToast } = useToast();
const [showArchiveAllConfirm, setShowArchiveAllConfirm] = useState(false);
const [showDiscardAllConfirm, setShowDiscardAllConfirm] = useState(false);
const [archivingIds, setArchivingIds] = useState<Set<number>>(new Set());
const [discardingIds, setDiscardingIds] = useState<Set<number>>(new Set());
// Fetch pending uploads
const { data: uploads, isLoading: uploadsLoading } = useQuery({
queryKey: ['pending-uploads'],
queryFn: pendingUploadsApi.list,
refetchInterval: 10000, // Refresh every 10 seconds
});
// Fetch projects for dropdown
const { data: projects } = useQuery({
queryKey: ['projects'],
queryFn: () => api.getProjects(),
select: (rows) => [...rows].sort((a, b) => a.name.localeCompare(b.name)),
});
// Archive mutation
const archiveMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data?: { tags?: string; notes?: string; project_id?: number } }) =>
pendingUploadsApi.archive(id, data),
onMutate: ({ id }) => {
setArchivingIds((prev) => new Set(prev).add(id));
},
onSettled: (_, __, { id }) => {
setArchivingIds((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['pending-uploads'] });
queryClient.invalidateQueries({ queryKey: ['archives'] });
showToast(`Archived: ${data.print_name}`);
},
onError: (error: Error) => {
showToast(error.message || 'Failed to archive', 'error');
},
});
// Discard mutation
const discardMutation = useMutation({
mutationFn: (id: number) => pendingUploadsApi.discard(id),
onMutate: (id) => {
setDiscardingIds((prev) => new Set(prev).add(id));
},
onSettled: (_, __, id) => {
setDiscardingIds((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['pending-uploads'] });
showToast('Upload discarded');
},
onError: (error: Error) => {
showToast(error.message || 'Failed to discard', 'error');
},
});
// Archive all mutation
const archiveAllMutation = useMutation({
mutationFn: pendingUploadsApi.archiveAll,
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['pending-uploads'] });
queryClient.invalidateQueries({ queryKey: ['archives'] });
showToast(`Archived ${data.archived} files${data.failed > 0 ? `, ${data.failed} failed` : ''}`);
},
onError: (error: Error) => {
showToast(error.message || 'Failed to archive all', 'error');
},
});
// Discard all mutation
const discardAllMutation = useMutation({
mutationFn: pendingUploadsApi.discardAll,
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['pending-uploads'] });
showToast(`Discarded ${data.discarded} files`);
},
onError: (error: Error) => {
showToast(error.message || 'Failed to discard all', 'error');
},
});
if (uploadsLoading) {
return (
<Card>
<CardContent className="py-8 flex justify-center">
<Loader2 className="w-6 h-6 animate-spin text-bambu-green" />
</CardContent>
</Card>
);
}
if (!uploads || uploads.length === 0) {
return null; // Don't render if no pending uploads
}
return (
<div className="mb-6">
<Card className="border-l-4 border-l-yellow-500">
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Upload className="w-5 h-5 text-yellow-500" />
<h2 className="text-lg font-semibold text-white">
Pending Uploads ({uploads.length})
</h2>
</div>
<div className="flex items-center gap-2">
<Button
variant="primary"
size="sm"
onClick={() => setShowArchiveAllConfirm(true)}
disabled={archiveAllMutation.isPending}
>
{archiveAllMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<>
<Archive className="w-4 h-4" />
Archive All
</>
)}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => setShowDiscardAllConfirm(true)}
disabled={discardAllMutation.isPending}
>
{discardAllMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<>
<Trash2 className="w-4 h-4" />
Discard All
</>
)}
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-bambu-gray mb-4">
These files were uploaded via the virtual printer. Review and archive them to add to your collection.
</p>
<div className="space-y-3">
{uploads.map((upload) => (
<PendingUploadItem
key={upload.id}
upload={upload}
projects={projects || []}
onArchive={(id, data) => archiveMutation.mutate({ id, data })}
onDiscard={(id) => discardMutation.mutate(id)}
isArchiving={archivingIds.has(upload.id)}
isDiscarding={discardingIds.has(upload.id)}
/>
))}
</div>
</CardContent>
</Card>
{/* Archive All Confirmation */}
{showArchiveAllConfirm && (
<ConfirmModal
title="Archive All Uploads"
message={`Are you sure you want to archive all ${uploads.length} pending uploads?`}
confirmText="Archive All"
onConfirm={() => {
archiveAllMutation.mutate();
setShowArchiveAllConfirm(false);
}}
onCancel={() => setShowArchiveAllConfirm(false)}
/>
)}
{/* Discard All Confirmation */}
{showDiscardAllConfirm && (
<ConfirmModal
title="Discard All Uploads"
message={`Are you sure you want to discard all ${uploads.length} pending uploads? This cannot be undone.`}
confirmText="Discard All"
variant="danger"
onConfirm={() => {
discardAllMutation.mutate();
setShowDiscardAllConfirm(false);
}}
onCancel={() => setShowDiscardAllConfirm(false)}
/>
)}
</div>
);
}