Let a print with no 3MF be given its filament weight (issue #1820)

When the sliced file stays somewhere Bambuddy cannot read, the archive is
built from the printer's report alone and carries no weight. Nothing could
supply one afterwards: rescan reads the figure out of the 3MF, and that
archive has no file to read. The reporter's H2S print left 46.16 g on the
spool with nothing recording it, and he corrected Spoolman by hand.

Edit Archive now has a Filament used (g) field. It is written to the
archive's most recent run as well, because the Projects roll-up and the
Prometheus counter sum PrintLogEntry rather than the cards - correcting
only the archive would fix the display and leave every aggregate reading
the old figure, or none at all.

But not over a figure the run measured for itself. A run's grams come from
the tracked spool delta when there is one and only fall back to copying
the archive's estimate when there is not, so mirroring unconditionally
would overwrite a measurement with a typed estimate. The mirror now takes
a run that has no figure, or one holding exactly what this archive held -
which also makes the undo complete, since clearing the archive clears the
copy it made and leaves a measured run alone.

The field is text rather than a number input. A number input reports an
empty string for anything the browser judges malformed, a decimal comma in
a locale that does not expect one included, and that reads here as "the
user cleared it" - it would have wiped a good figure while the field still
showed what was typed. Filtering on the way in keeps what is displayed and
what would be sent the same string, and clamps it to the range the API
accepts: this modal has no error surface, so a refused save looks like
nothing happened at all.

Saving also invalidates the archive's runs query. The Print Log this modal
renders at its top reads them separately and kept serving the pre-edit row,
so a correction looked like it had not taken - true for the status and
failure-reason mirrors since #1444 as well.

Second half of the same report: the internal-storage probe (#2856) logged
which file it found but not where. On a printer that keeps uploads for
weeks - the reporter has months of them in /cache - a reprint of a name
that was re-sliced but never re-sent can match an older copy, and without
the directory that mismatch is invisible rather than merely rare. The
download helper returns the path that served the file instead of a bare
flag; every caller only ever tested it for truth.
This commit is contained in:
maziggy
2026-08-18 10:36:42 +02:00
parent 90fac7b529
commit d227d42272
27 changed files with 545 additions and 102 deletions
+72 -1
View File
@@ -1,7 +1,7 @@
import { useState, useEffect, useRef } from 'react';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { X, Save, Tag, Camera, Trash2, Loader2, Plus, FolderKanban, Hash, Link } from 'lucide-react';
import { X, Save, Tag, Camera, Trash2, Loader2, Plus, FolderKanban, Hash, Link, Weight } from 'lucide-react';
import { api } from '../api/client';
import type { Archive } from '../api/client';
import { Button } from './Button';
@@ -29,6 +29,12 @@ export const FAILURE_REASON_KEYS = [
// Keys for archive statuses - translated at render time
const ARCHIVE_STATUS_KEYS = ['completed', 'failed', 'aborted', 'printing'] as const;
// Mirrors the API's own bound on filament_used_grams. Clamped here as well so
// the field cannot produce a request the backend would reject with a 422 —
// this modal has no error surface, so a refused save looks like nothing
// happened at all (#1820).
const MAX_FILAMENT_GRAMS = 100000;
interface EditArchiveModalProps {
archive: Archive;
onClose: () => void;
@@ -67,6 +73,12 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
});
const [status, setStatus] = useState(archive.status);
const [quantity, setQuantity] = useState(archive.quantity ?? 1);
// Kept as a string so the field can be genuinely empty: a print archived
// without its 3MF has no figure at all, and "" has to stay distinguishable
// from 0 both on the way in and on the way out (#1820).
const [filamentGrams, setFilamentGrams] = useState(
archive.filament_used_grams != null ? String(archive.filament_used_grams) : ''
);
const [photos, setPhotos] = useState<string[]>(archive.photos || []);
const [externalUrl, setExternalUrl] = useState(archive.external_url || '');
const [uploadingPhoto, setUploadingPhoto] = useState(false);
@@ -151,6 +163,12 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
// This form can change the archive's project, so the project detail
// views need refreshing too — not just the overview cards (#2731).
invalidateArchiveAndProjectViews(queryClient);
// Some of what this form writes is mirrored onto the archive's most
// recent run — status and failure reason since #1444, filament grams
// since #1820 — and the Print Log this modal renders at its top reads
// the runs through their own query. Without this it serves the cached
// pre-edit row, so the correction looks like it did not take.
queryClient.invalidateQueries({ queryKey: ['archive-runs', archive.id] });
onClose();
},
});
@@ -202,6 +220,22 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
updateData.status = status;
}
// Sent only when the user actually touched it, so an ordinary save of an
// archive that has its 3MF cannot overwrite the sliced figure with a
// rounded one from the input.
const trimmedGrams = filamentGrams.trim();
const typedGrams = trimmedGrams === '' ? null : Number(trimmedGrams);
// An empty field means "no figure" and clears the stored one; a field that
// holds something unparseable (a lone decimal point, mid-typing) means the
// user is not finished, and must not read as a clear. Clamped here as well
// as on blur because Enter submits without the field losing focus.
const gramsUnparseable = typedGrams !== null && !Number.isFinite(typedGrams);
const parsedGrams = typedGrams === null ? null : Math.min(Math.max(typedGrams, 0), MAX_FILAMENT_GRAMS);
const originalGrams = archive.filament_used_grams ?? null;
if (!gramsUnparseable && parsedGrams !== originalGrams) {
updateData.filament_used_grams = parsedGrams;
}
// Handle failure_reason based on status
if (status === 'failed' || status === 'aborted') {
updateData.failure_reason = failureReason || undefined;
@@ -310,6 +344,43 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
</p>
</div>
{/* Filament used - the only way to supply a figure for a print that
archived without its 3MF, which no rescan can repair (#1820). */}
<div>
<label className="block text-sm text-bambu-gray mb-1" htmlFor="archive-filament-grams">
<Weight className="w-4 h-4 inline mr-1" />
{t('editArchive.filamentUsed')}
</label>
<input
id="archive-filament-grams"
type="text"
inputMode="decimal"
value={filamentGrams}
// Text rather than number, and filtered on the way in. A number
// input reports an empty string for anything the browser judges
// malformed — including a decimal comma in a locale it doesn't
// expect — which would read here as "the user cleared it" and
// wipe a good figure. Filtering keeps what is displayed and what
// would be sent the same thing, and keeps the value inside the
// range the API accepts: this modal shows nothing at all when a
// save is refused, so it must not be able to send a refusable one.
onChange={(e) => {
const next = e.target.value.replace(',', '.');
if (next === '' || /^\d*\.?\d*$/.test(next)) setFilamentGrams(next);
}}
onBlur={() => setFilamentGrams((current) => {
const parsed = Number(current);
if (current === '' || !Number.isFinite(parsed)) return '';
return String(Math.min(parsed, MAX_FILAMENT_GRAMS));
})}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
placeholder={t('editArchive.filamentUsedPlaceholder')}
/>
<p className="text-xs text-bambu-gray mt-1">
{t('editArchive.filamentUsedHelp')}
</p>
</div>
{/* Notes */}
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('editArchive.notes')}</label>