fix(spoolbuddy): show the colour name the rest of Bambuddy shows (issue #3090)

SpoolBuddy said "Unknown color" under a correctly-coloured swatch for
spools the inventory page names without trouble.

The name was never in the spool record. Bambu's RFID tags frequently
carry no readable colour name -- some carry an internal code instead --
so Bambuddy has always resolved the swatch's own hex against the colour
catalog, and the kiosk was rendering the empty column. Exactly one
SpoolBuddy file already did it right, which is what marks this as an
inconsistency rather than a kiosk simplification.

Route every SpoolBuddy colour-name display through resolveSpoolColorName,
which also stops the spools that do carry a code from showing "A06-D0" at
the user. The write-tag edit form keeps the raw stored value on purpose:
offering a derived name for editing invites the user to save it as though
they had typed it.

Spoolman has no colour-name field at all, so _map_spoolman_spool puts the
spool's subtype there and sets color_name_is_synthesized. That flag now
travels on the tag-matched broadcast, and resolveSpoolColorName takes a
third argument to honour it -- a synthesised name loses to the catalog
and survives only as a last resort. Spoolman installs were reading
"Silk+" as a colour on the Inventory page and the AMS hover card too, so
those call sites pass the flag as well.

Searching by a colour you can read on screen now finds it, in the kiosk
and in Bambuddy: the shared inventory filter matches the resolved name as
well as the stored one. That makes the filter depend on the catalog,
which loads asynchronously, so the three memoised call sites take its
version as a dependency -- without that, a query typed before the catalog
arrives keeps its empty result and reproduces the very symptom being
fixed.

The fallback label was hardcoded English in components that already
import useTranslation; it is now spoolbuddy.spool.unknownColor in all 14
This commit is contained in:
maziggy
2026-09-19 11:32:10 +02:00
parent 9fc2c6df2d
commit e4a9ef4550
40 changed files with 727 additions and 172 deletions
+7 -3
View File
@@ -8,7 +8,7 @@ import { Button } from './Button';
import { ConfirmModal } from './ConfirmModal';
import { useToast } from '../contexts/ToastContext';
import { filterSpoolsByQuery } from '../utils/inventorySearch';
import { getSwatchStyle } from '../utils/colors';
import { getSwatchStyle, resolveSpoolColorName } from '../utils/colors';
interface AssignSpoolModalProps {
isOpen: boolean;
@@ -431,7 +431,9 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
style={getSwatchStyle(spool.rgba)}
/>
)}
<span className="text-xs text-bambu-gray truncate">{spool.color_name || ''}</span>
<span className="text-xs text-bambu-gray truncate">
{resolveSpoolColorName(spool.color_name, spool.rgba, spool.color_name_is_synthesized) || ''}
</span>
</div>
{spool.label_weight && (
<p className="text-xs text-bambu-gray mt-1">
@@ -511,7 +513,9 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
style={getSwatchStyle(spool.rgba)}
/>
)}
<span className="text-xs text-bambu-gray truncate">{spool.color_name || ''}</span>
<span className="text-xs text-bambu-gray truncate">
{resolveSpoolColorName(spool.color_name, spool.rgba, spool.color_name_is_synthesized) || ''}
</span>
</div>
{spool.label_weight && (
<p className="text-xs text-bambu-gray mt-1">
+20 -2
View File
@@ -46,6 +46,9 @@ interface InventoryConfig {
subtype: string | null;
brand: string | null;
color_name: string | null;
// Spoolman has no colour-name field, so `color_name` on a Spoolman-backed
// spool is usually its subtype standing in for one (#3090).
color_name_is_synthesized?: boolean;
rgba?: string | null;
extra_colors?: string | null;
effect_type?: string | null;
@@ -215,8 +218,23 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
// already resolved with the slot's material (#2875).
// Trimmed, so a spool saved with a whitespace-only colour name leaves the
// swatch reading the catalogue answer instead of reading blank.
const assignedColorName = resolveSpoolColorName(inventory?.assignedSpool?.color_name ?? null, null)?.trim();
// A synthesised name is dropped outright rather than passed through the
// helper: with a null rgba the helper has nothing to resolve against and
// would hand back "Silk+", displacing the catalogue answer the caller
// already worked out from the slot's material (#3090).
const assignedColorName = inventory?.assignedSpool?.color_name_is_synthesized
? undefined
: resolveSpoolColorName(inventory?.assignedSpool?.color_name ?? null, null)?.trim();
const displayColorName = assignedColorName || data.colorName;
// The ASSIGNED row has the spool's own swatch to hand, so unlike the header
// above it can resolve a missing or synthesised name against the catalogue.
const assignedRowColorName = inventory?.assignedSpool
? resolveSpoolColorName(
inventory.assignedSpool.color_name,
inventory.assignedSpool.rgba ?? null,
inventory.assignedSpool.color_name_is_synthesized,
)
: null;
const assignedRemainingWeight = inventory?.assignedSpool?.remainingWeightGrams ?? null;
// The header paints the spool's own swatch whenever the spool describes more
@@ -473,7 +491,7 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
{inventory.assignedSpool.brand ? `${inventory.assignedSpool.brand} ` : ''}
{inventory.assignedSpool.material}
{inventory.assignedSpool.subtype ? ` ${inventory.assignedSpool.subtype}` : ''}
{inventory.assignedSpool.color_name ? ` - ${inventory.assignedSpool.color_name}` : ''}
{assignedRowColorName ? ` - ${assignedRowColorName}` : ''}
</p>
<span className="text-[10px] font-mono text-bambu-gray shrink-0">#{inventory.assignedSpool.id}</span>
</div>
@@ -4,12 +4,13 @@ import { X, Loader2, Printer, CheckSquare, Square, Search } from 'lucide-react';
import { api, type SpoolLabelTemplate, type InventorySpool } from '../api/client';
import { Button } from './Button';
import { useToast } from '../contexts/ToastContext';
import { getSwatchStyle } from '../utils/colors';
import { getSwatchStyle, resolveSpoolColorName } from '../utils/colors';
import { useColorCatalogVersion } from '../hooks/useColorCatalogVersion';
/** Subset of InventorySpool the modal needs for checkbox rendering. */
type SpoolForLabel = Pick<
InventorySpool,
'id' | 'material' | 'subtype' | 'brand' | 'color_name' | 'rgba'
'id' | 'material' | 'subtype' | 'brand' | 'color_name' | 'color_name_is_synthesized' | 'rgba'
>;
interface LabelTemplatePickerModalProps {
@@ -109,15 +110,22 @@ function swatchStyle(rgba: string | null | undefined): React.CSSProperties {
return getSwatchStyle(rgba);
}
function labelColorName(s: SpoolForLabel): string | null {
return resolveSpoolColorName(s.color_name, s.rgba, s.color_name_is_synthesized);
}
function spoolDisplayName(s: SpoolForLabel): string {
const head = s.color_name ?? `${s.material}${s.subtype ? ` ${s.subtype}` : ''}`;
// Resolved, not stored: most Bambu spools arrive with no colour name on the
// tag, and picking a label template for "PLA Silk" tells you nothing about
// which of six red spools you are looking at (#3090).
const head = labelColorName(s) ?? `${s.material}${s.subtype ? ` ${s.subtype}` : ''}`;
const brand = s.brand ? ` · ${s.brand}` : '';
return `${head}${brand}`;
}
/** Build a lowercased haystack that the search input matches against. */
function searchableText(s: SpoolForLabel): string {
return [s.color_name, s.material, s.subtype, s.brand, `#${s.id}`]
return [labelColorName(s), s.color_name, s.material, s.subtype, s.brand, `#${s.id}`]
.filter(Boolean)
.join(' ')
.toLowerCase();
@@ -175,6 +183,9 @@ export function LabelTemplatePickerModal({
spoolmanMode,
}: LabelTemplatePickerModalProps) {
const { t } = useTranslation();
// The spool filter below resolves colour names through the catalog; its
// memo has to recompute when the catalog finishes loading (#3090).
const colorCatalogVersion = useColorCatalogVersion();
const { showToast } = useToast();
const [pending, setPending] = useState<SpoolLabelTemplate | null>(null);
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
@@ -230,13 +241,18 @@ export function LabelTemplatePickerModal({
}, [sortedSpools]);
const visibleSpools = useMemo(() => {
// Named so this memo depends on it: searchableText resolves colour names
// through the catalog, which resolveSpoolColorName reads from module state
// the linter cannot follow. Without it a query typed before the catalog
// loads keeps its empty result (#3090).
void colorCatalogVersion;
const q = search.trim().toLowerCase();
return sortedSpools.filter((s) => {
if (materialFilter && (s.material || '').toUpperCase() !== materialFilter) return false;
if (q && !searchableText(s).includes(q)) return false;
return true;
});
}, [sortedSpools, search, materialFilter]);
}, [sortedSpools, search, materialFilter, colorCatalogVersion]);
const allVisibleChecked =
visibleSpools.length > 0 && visibleSpools.every((s) => selectedIds.has(s.id));
@@ -7,7 +7,7 @@ import { ConfirmModal } from '../ConfirmModal';
import { AmsUnitCard, NozzleBadge } from './AmsUnitCard';
import type { AmsThresholds } from './AmsUnitCard';
import { getFillBarColor } from '../../utils/amsHelpers';
import { getSwatchStyle } from '../../utils/colors';
import { getSwatchStyle, resolveSpoolColorName } from '../../utils/colors';
function getAmsName(id: number): string {
if (id <= 3) return `AMS ${String.fromCharCode(65 + id)}`;
@@ -379,7 +379,9 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId, spoolmanMo
<h2 className="text-sm font-semibold text-zinc-100 truncate">
{t('spoolbuddy.modal.assignToAmsTitle', 'Assign to AMS')}
<span className="font-normal text-zinc-500 ml-2">
{spool.color_name || 'Unknown'} &bull; {spool.brand} {spool.material}{spool.subtype && ` ${spool.subtype}`}
{resolveSpoolColorName(spool.color_name, spool.rgba, spool.color_name_is_synthesized) ||
t('spoolbuddy.spool.unknownColor')}{' '}
&bull; {spool.brand} {spool.material}{spool.subtype && ` ${spool.subtype}`}
</span>
<span className="text-[10px] font-mono text-zinc-500 ml-2 shrink-0">#{spool.id}</span>
</h2>
@@ -5,7 +5,7 @@ import { Check, AlertTriangle, RefreshCw, Unlink } from 'lucide-react';
import type { InventorySpool } from '../../api/client';
import { spoolbuddyApi, api } from '../../api/client';
import { SpoolIcon } from './SpoolIcon';
import { spoolColorString } from '../../utils/colors';
import { resolveSpoolColorName, spoolColorString } from '../../utils/colors';
const DEFAULT_CORE_WEIGHT_KEY = 'spoolbuddy-default-core-weight';
@@ -63,6 +63,10 @@ export function InventorySpoolInfoCard({
const kProfiles = (spool.k_profiles && spool.k_profiles.length > 0) ? spool.k_profiles : fetchedKProfiles;
const colorHex = spoolColorString(spool.rgba);
// The stored name is not the displayed one: Bambu tags often carry no
// colour name at all, or an internal code, and Spoolman has no field for
// one — the catalog resolves the swatch's hex instead (#3090, #857).
const colorName = resolveSpoolColorName(spool.color_name, spool.rgba, spool.color_name_is_synthesized);
const coreWeight = (spool.core_weight && spool.core_weight > 0)
? spool.core_weight
@@ -152,7 +156,7 @@ export function InventorySpoolInfoCard({
<div className="flex-1 min-w-0 pt-1">
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold text-zinc-100">
{spool.color_name || 'Unknown color'}
{colorName || t('spoolbuddy.spool.unknownColor')}
</h3>
<span className="text-xs font-mono text-zinc-500 shrink-0">#{spool.id}</span>
</div>
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { X } from 'lucide-react';
import type { InventorySpool } from '../../api/client';
import { SpoolIcon } from './SpoolIcon';
import { spoolColorString } from '../../utils/colors';
import { resolveSpoolColorName, spoolColorString } from '../../utils/colors';
interface LinkSpoolModalProps {
isOpen: boolean;
@@ -108,7 +108,7 @@ export function LinkSpoolModal({
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<div className="font-medium text-zinc-100 truncate">
{spool.color_name || 'Unknown color'}
{resolveSpoolColorName(spool.color_name, spool.rgba, spool.color_name_is_synthesized) || t('spoolbuddy.spool.unknownColor')}
</div>
<span className="text-[10px] font-mono text-zinc-500 shrink-0">#{spool.id}</span>
</div>
@@ -4,7 +4,7 @@ import { Check, AlertTriangle, RefreshCw, Unlink } from 'lucide-react';
import type { MatchedSpool } from '../../hooks/useSpoolBuddyState';
import { spoolbuddyApi } from '../../api/client';
import { SpoolIcon } from './SpoolIcon';
import { spoolColorString } from '../../utils/colors';
import { resolveSpoolColorName, spoolColorString } from '../../utils/colors';
// Storage key for default core weight
const DEFAULT_CORE_WEIGHT_KEY = 'spoolbuddy-default-core-weight';
@@ -38,6 +38,10 @@ export function SpoolInfoCard({ spool, scaleWeight, onClose, onSyncWeight, onAss
const [synced, setSynced] = useState(false);
const colorHex = spoolColorString(spool.rgba);
// The stored name is not the displayed one: Bambu tags often carry no
// colour name at all, or an internal code, and Spoolman has no field for
// one — the catalog resolves the swatch's hex instead (#3090, #857).
const colorName = resolveSpoolColorName(spool.color_name, spool.rgba, spool.color_name_is_synthesized);
// Use spool's core_weight if set, otherwise fall back to default
const coreWeight = (spool.core_weight && spool.core_weight > 0)
@@ -104,7 +108,7 @@ export function SpoolInfoCard({ spool, scaleWeight, onClose, onSyncWeight, onAss
<div className="flex-1 min-w-0 pt-1">
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold text-zinc-100">
{spool.color_name || 'Unknown color'}
{colorName || t('spoolbuddy.spool.unknownColor')}
</h3>
<span className="text-xs font-mono text-zinc-500 shrink-0">#{spool.id}</span>
</div>
@@ -4,7 +4,7 @@ import { Check, RefreshCw, AlertTriangle, X } from 'lucide-react';
import type { MatchedSpool } from '../../hooks/useSpoolBuddyState';
import { spoolbuddyApi } from '../../api/client';
import { SpoolIcon } from './SpoolIcon';
import { spoolColorString } from '../../utils/colors';
import { resolveSpoolColorName, spoolColorString } from '../../utils/colors';
// Storage key for default core weight (shared with SpoolInfoCard)
const DEFAULT_CORE_WEIGHT_KEY = 'spoolbuddy-default-core-weight';
@@ -136,6 +136,10 @@ interface KnownSpoolViewProps {
function KnownSpoolView({ spool, scaleWeight, weightStable, syncing, synced, onSyncWeight, onAssignToAms, onClose }: KnownSpoolViewProps) {
const { t } = useTranslation();
const colorHex = spoolColorString(spool.rgba);
// The stored name is not the displayed one: Bambu tags often carry no
// colour name at all, or an internal code, and Spoolman has no field for
// one — the catalog resolves the swatch's hex instead (#3090, #857).
const colorName = resolveSpoolColorName(spool.color_name, spool.rgba, spool.color_name_is_synthesized);
const coreWeight = (spool.core_weight && spool.core_weight > 0)
? spool.core_weight
@@ -190,7 +194,7 @@ function KnownSpoolView({ spool, scaleWeight, weightStable, syncing, synced, onS
<div className="flex-1 min-w-0 pt-1">
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold text-zinc-100">
{spool.color_name || 'Unknown color'}
{colorName || t('spoolbuddy.spool.unknownColor')}
</h3>
<span className="text-xs font-mono text-zinc-500 shrink-0">#{spool.id}</span>
</div>