fix(inventory): show all per-printer/per-nozzle variants in spool form's Slicer Preset dropdown (issue #1248)

Two defects in buildFilamentOptions, surfaced together:

  1. The function was precedence-based — cloud presets short-circuited
     the local-presets branch, silently hiding any imported Local Profile
     while the user was logged into Bambu Cloud. The wiki documents the
     dropdown as "merged and deduplicated" across cloud + local + built-in.

  2. Cloud default presets and local presets were being collapsed by base
     name (everything after "@" stripped), so all P1S/X1C/A1 variants of
     "Bambu PLA Basic" rendered as a single row. The spool form is
     printer-agnostic by design, so the right semantic is to show every
     variant individually — the union across all printers — not collapse
     them. AMS Slot is per-printer (it filters), the spool form is
     union-of-all (it doesn't).

  Rewrote the merge to push each cloud setting_id and each LocalPreset row
  as its own FilamentOption with the full @printer suffix preserved in
  displayName. Built-in dedup against cloud setting_id is kept (mirrors
  ConfigureAmsSlotModal.tsx). Wired api.getBuiltinFilaments() into both
  callers. slicer_filament persistence is unchanged so existing spools
  keep slicing correctly.
This commit is contained in:
maziggy
2026-05-09 08:43:51 +02:00
parent f5ecc61cda
commit 30fe88a334
10 changed files with 364 additions and 166 deletions
+1
View File
@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
## [0.2.4b4] - Unreleased
### Fixed
- **Spool form's "Slicer Preset" dropdown silently dropped Local Profiles when Bambu Cloud was connected, and collapsed per-printer/per-nozzle variants of cloud and local presets into a single entry** ([#1248](https://github.com/maziggy/bambuddy/issues/1248), reported by @andretietz) — Two distinct defects in the same code path. **Defect 1 (the reported bug):** `buildFilamentOptions` in `frontend/src/components/spool-form/utils.ts` was precedence-based — `if (cloudPresets.length > 0)` returned the cloud list and never reached the local-presets branch, so any Local Profile imported via Profiles → Local Profiles was silently invisible whenever the user was logged into Bambu Cloud (the same profile rendered fine with a green `Local` badge in the AMS Slot configuration modal). The wiki documents the dropdown as "merged and deduplicated" across cloud + local + built-in. **Defect 2 (surfaced during fix verification):** the spool form was collapsing all `@Bambu Lab P1S 0.4 nozzle` / `@Bambu Lab X1C 0.4 nozzle` / `@Bambu Lab A1 0.4 nozzle` variants of "Bambu PLA Basic" into a single dropdown entry by stripping the `@printer` suffix and dedup'ing by base name (one Map.set per family for cloud defaults, one per family for local presets). The AMS Slot modal lists each variant individually and filters by the active printer model, so the user observed strictly more entries in the AMS Slot than in the Add Spool modal even after the merge fix. The right semantic for the spool form — printer-agnostic by design, since a spool isn't bound to a printer — is to show every variant as its own row, exactly as if you'd summed the AMS Slot's per-printer-filtered output across all printers. **Fix:** rewrote `buildFilamentOptions` to (a) actually merge all three sources, dropping the precedence early-return, and (b) push each cloud `setting_id` and each `LocalPreset` row as its own `FilamentOption` instead of collapsing by `name.replace(/@.*$/, '')`. `displayName` now keeps the full `@printer 0.4 nozzle` suffix so users can pick the right variant. Built-in dedup against cloud setting_id is preserved (mirrors `ConfigureAmsSlotModal.tsx:498` exactly). Wired `api.getBuiltinFilaments()` into both callers — `SpoolFormModal` and `SpoolBuddyWriteTagPage`. **Persistence safety:** the saved `slicer_filament` shape is unchanged — cloud picks still persist their `setting_id`, local picks still persist `preset.filament_type || String(preset.id)` (consumed by `backend/app/utils/filament_ids.py::normalize_slicer_filament` which expects `GFL05`/`GFSL05` shapes; persisting the bare LocalPreset row id would break slicing). Local-preset `allCodes` now carries both the `filament_type` form and the `String(preset.id)` form so `findPresetOption` resolves both old (pre-fix) and new picks. **React-key collision:** with collapse removed, two LocalPreset rows can share the same `code` if they share `filament_type`; the dropdown key in `FilamentSection.tsx` is now composed `${option.code}::${option.name}` to stay unique. **Tests:** new `frontend/src/__tests__/components/spool-form/buildFilamentOptions.test.ts` with 9 cases — the #1248 regression case, "one entry per cloud setting_id, no @printer collapse", "list each local preset individually", "@printer suffix preserved in displayName", local `allCodes` carrying both shapes, the `GFA00`↔`GFSA00` built-in dedup, the all-empty fallback, and the alphabetical sort. The two existing `vi.mock('../../api/client')` blocks in `SpoolFormModal.test.tsx` and `SpoolFormBulk.test.tsx` were updated with the new `getBuiltinFilaments` stub.
- **SpoolBuddy install.sh re-run failed with `Permission denied` on root-owned files in update mode** — `download_spoolbuddy()` ran `git fetch + git checkout + git reset --hard` *before* the post-install chown at the end of the function. If a previous install left stray root-owned files in the tree (e.g. `static/assets/*` written by an earlier `sudo` run, or a frontend build that wrote as root), the `git reset --hard` step aborted with EACCES on the unlink/replace step before reaching the chown. The script then exited and the kiosk's underlying ownership problem persisted, so the next attempt would fail the same way. **Fix:** pre-emptively `chown -R spoolbuddy:spoolbuddy "$INSTALL_PATH"` in the update branch *before* any git operation runs. The script already runs as root (enforced by `check_root`), so the chown is always safe. The existing post-install chown at the end stays — it now mostly catches new files created during this run that need their ownership normalised. Same root cause showed up on the kiosk's *runtime* SSH update path (Bambuddy → kiosk: `git checkout dev && git reset --hard origin/dev` running as the `spoolbuddy` user) but that path can't `chown` without sudoers expansion — the install.sh fix is the immediate recovery, and re-running the install script restores a clean ownership baseline that the runtime updater can keep healthy thereafter.
- **SpoolBuddy SSH update aborted with `TypeError: startswith first arg must be bytes or a tuple of bytes, not str` after the host-key store succeeded** — `perform_ssh_update` calls `asyncssh.import_known_hosts(...)` to materialise an `SSHKnownHosts` object for `_run_ssh_command`'s `known_hosts=` keyword arg. Both call sites (the stored-key path at line 221 and the just-stored TOFU re-parse at line 272) passed `f"{ip} {key}\n".encode()` — i.e. `bytes`. asyncssh's parser does line-based string operations (`line.startswith('#')` with a `str` literal), so any `bytes` input crashes inside its loader with `TypeError`. The two `try`/`except` clauses caught only `(ValueError, asyncssh.Error)`, missing `TypeError`, so the crash bubbled up and aborted the whole update right after the schema fix successfully persisted the host key. **Fix:** drop the `.encode()` at both call sites — pass the str directly. Widened both except clauses to `(ValueError, TypeError, asyncssh.Error)` so any future asyncssh API surprise degrades to the existing fallback (TOFU mode without host-key verification, with a logger.warning) instead of crashing the update. Existing SSH tests all mocked `asyncssh.import_known_hosts` itself so they never reached the parser — added `test_perform_ssh_update_passes_str_not_bytes_to_import_known_hosts` to capture both call sites' arguments and assert `isinstance(arg, str)` so re-introducing `.encode()` fails CI immediately.
- **SpoolBuddy SSH update crashed on Postgres with `value too long for type character varying(500)` when storing the device's RSA host key** — `spoolbuddy_devices.ssh_host_key` was declared as `String(500)`, which is fine for SQLite (ignores VARCHAR length) and for ed25519 host keys (~120 chars), but RSA host keys in OpenSSH format are typically 370 chars (2048-bit) → 544 chars (3072-bit) → ~720 chars (4096-bit). Postgres enforces the limit strictly, so any kiosk reporting an RSA-3072 or larger host key on the first SSH update aborted at the `UPDATE spoolbuddy_devices SET ssh_host_key=...` flush — the `git fetch + pip install + systemctl restart` may have run successfully but the persistence of the TOFU host key failed and the device's update_status was never written. **Fix:** widened `ssh_host_key` from `String(500)` → `Text` on the model, plus an idempotent `ALTER TABLE spoolbuddy_devices ALTER COLUMN ssh_host_key TYPE TEXT` migration gated on `not is_sqlite()` (Postgres-only; SQLite is a no-op since it doesn't enforce VARCHAR length). Existing rows are preserved — `TYPE TEXT` is a metadata-only change on Postgres for `VARCHAR(N)` → `TEXT` so it's a fast migration even on populated tables. Originally introduced in the H1 SSH-host-key TOFU security fix; the 500-char floor was a guess based on ed25519 sizes that the RSA case immediately blew past.
@@ -31,6 +31,7 @@ vi.mock('../../api/client', () => ({
getSpoolCatalog: vi.fn().mockResolvedValue([]),
getColorCatalog: vi.fn().mockResolvedValue([]),
getLocalPresets: vi.fn().mockResolvedValue({ filament: [] }),
getBuiltinFilaments: vi.fn().mockResolvedValue([]),
getPrinters: vi.fn().mockResolvedValue([]),
getSpoolUsageHistory: vi.fn().mockResolvedValue([]),
createSpool: vi.fn().mockResolvedValue({ id: 99 }),
@@ -23,6 +23,7 @@ vi.mock('../../api/client', () => ({
getSpoolCatalog: vi.fn().mockResolvedValue([]),
getColorCatalog: vi.fn().mockResolvedValue([]),
getLocalPresets: vi.fn().mockResolvedValue({ filament: [] }),
getBuiltinFilaments: vi.fn().mockResolvedValue([]),
getPrinters: vi.fn().mockResolvedValue([]),
getSpoolUsageHistory: vi.fn().mockResolvedValue([]),
createSpool: vi.fn().mockResolvedValue({ id: 99 }),
@@ -0,0 +1,162 @@
/**
* Regression tests for buildFilamentOptions (#1248).
*
* The original bug was precedence-based merging: cloud presets, when present,
* fully replaced the local-presets branch and silently hid Local Profiles.
*
* The follow-up clarification: the spool form is printer-agnostic, so it must
* show every per-printer / per-nozzle variant of a preset as its own entry —
* unlike the AMS Slot modal which is per-printer and filters down to the
* active printer model. Both surfaces should render the same set of presets
* if you summed the AMS Slot's per-printer-filtered output across all
* printers; the spool form just shows the union directly.
*/
import { describe, it, expect } from 'vitest';
import { buildFilamentOptions } from '../../../components/spool-form/utils';
import type { SlicerSetting, LocalPreset, BuiltinFilament } from '../../../api/client';
const cloudPreset = (overrides: Partial<SlicerSetting> = {}): SlicerSetting => ({
setting_id: 'GFSL00@P1S',
name: 'Bambu PLA Basic @Bambu Lab P1S 0.4 nozzle',
type: 'filament',
version: null,
user_id: null,
updated_time: null,
is_custom: false,
...overrides,
});
const localPreset = (overrides: Partial<LocalPreset> = {}): LocalPreset => ({
id: 1,
name: 'My Custom PETG @Bambu Lab P2S 0.4 nozzle',
preset_type: 'filament',
source: 'local',
filament_type: 'GFG00',
filament_vendor: 'Acme',
nozzle_temp_min: 230,
nozzle_temp_max: 260,
pressure_advance: null,
default_filament_colour: null,
filament_cost: null,
filament_density: null,
compatible_printers: null,
inherits: null,
version: null,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
...overrides,
});
const builtin = (overrides: Partial<BuiltinFilament> = {}): BuiltinFilament => ({
filament_id: 'GFA00',
name: 'Bambu ASA Basic',
...overrides,
});
describe('buildFilamentOptions', () => {
it('returns one entry per cloud setting_id (no @printer collapse)', () => {
const options = buildFilamentOptions(
[
cloudPreset({ setting_id: 'GFSL00@P1S', name: 'Bambu PLA Basic @Bambu Lab P1S 0.4 nozzle' }),
cloudPreset({ setting_id: 'GFSL00@X1C', name: 'Bambu PLA Basic @Bambu Lab X1C 0.4 nozzle' }),
cloudPreset({ setting_id: 'GFSL00@A1', name: 'Bambu PLA Basic @Bambu Lab A1 0.4 nozzle' }),
],
new Set(),
);
expect(options).toHaveLength(3);
expect(options.map(o => o.code)).toEqual([
'GFSL00@A1',
'GFSL00@P1S',
'GFSL00@X1C',
]);
});
it('keeps the @printer suffix in displayName so users can tell variants apart', () => {
const options = buildFilamentOptions(
[cloudPreset({ name: 'Bambu PLA Basic @Bambu Lab P1S 0.4 nozzle' })],
new Set(),
);
expect(options[0].displayName).toBe('Bambu PLA Basic @Bambu Lab P1S 0.4 nozzle');
});
it('merges local profiles even when cloud has presets (#1248 regression)', () => {
const options = buildFilamentOptions(
[cloudPreset()],
new Set(),
[localPreset()],
);
const names = options.map(o => o.name);
expect(names).toContain('Bambu PLA Basic @Bambu Lab P1S 0.4 nozzle');
expect(names).toContain('My Custom PETG @Bambu Lab P2S 0.4 nozzle');
});
it('merges built-in filaments alongside cloud and local sources', () => {
const options = buildFilamentOptions(
[cloudPreset()],
new Set(),
[localPreset()],
[builtin()],
);
const names = options.map(o => o.name);
expect(names).toContain('Bambu PLA Basic @Bambu Lab P1S 0.4 nozzle');
expect(names).toContain('My Custom PETG @Bambu Lab P2S 0.4 nozzle');
expect(names).toContain('Bambu ASA Basic');
});
it('lists each local preset individually (no @printer collapse)', () => {
const options = buildFilamentOptions(
[],
new Set(),
[
localPreset({ id: 1, name: 'My PETG @Bambu Lab P2S 0.4 nozzle' }),
localPreset({ id: 2, name: 'My PETG @Bambu Lab X1C 0.4 nozzle' }),
localPreset({ id: 3, name: 'My PETG @Bambu Lab P2S 0.6 nozzle' }),
],
);
expect(options).toHaveLength(3);
expect(options.map(o => o.name).sort()).toEqual([
'My PETG @Bambu Lab P2S 0.4 nozzle',
'My PETG @Bambu Lab P2S 0.6 nozzle',
'My PETG @Bambu Lab X1C 0.4 nozzle',
]);
});
it('local-preset allCodes carries both filament_type and the row id for findPresetOption', () => {
const options = buildFilamentOptions(
[],
new Set(),
[localPreset({ id: 42, filament_type: 'GFL00' })],
);
expect(options[0].allCodes).toEqual(expect.arrayContaining(['GFL00', '42']));
});
it('falls back to hardcoded list only when every source is empty', () => {
const options = buildFilamentOptions([], new Set(), [], []);
expect(options.length).toBeGreaterThan(0);
expect(options.map(o => o.name)).toContain('Generic PLA');
});
it('skips a built-in whose setting_id is already covered by cloud', () => {
const options = buildFilamentOptions(
[cloudPreset({ setting_id: 'GFSA00', name: 'Bambu ASA Basic' })],
new Set(),
undefined,
[builtin()],
);
const asaCount = options.map(o => o.name).filter(n => n === 'Bambu ASA Basic').length;
expect(asaCount).toBe(1);
});
it('result is sorted alphabetically by displayName', () => {
const options = buildFilamentOptions(
[],
new Set(),
[
localPreset({ id: 1, name: 'Zebra PLA' }),
localPreset({ id: 2, name: 'Alpha PLA' }),
],
);
expect(options.map(o => o.name)).toEqual(['Alpha PLA', 'Zebra PLA']);
});
});
+7 -3
View File
@@ -3,7 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { X, Loader2, Save, Beaker, Palette, Zap, Tag, Unlink } from 'lucide-react';
import { api, ApiError } from '../api/client';
import type { InventorySpool, SlicerSetting, SpoolCatalogEntry, LocalPreset, SpoolmanBulkCreateResult, SpoolKProfileInput, SpoolmanFilamentEntry } from '../api/client';
import type { InventorySpool, SlicerSetting, SpoolCatalogEntry, LocalPreset, BuiltinFilament, SpoolmanBulkCreateResult, SpoolKProfileInput, SpoolmanFilamentEntry } from '../api/client';
import { Button } from './Button';
import { useToast } from '../contexts/ToastContext';
import type { SpoolFormData, PrinterWithCalibrations, ColorPreset } from './spool-form/types';
@@ -71,6 +71,9 @@ export function SpoolFormModal({
// Local presets (OrcaSlicer imports)
const [localPresets, setLocalPresets] = useState<LocalPreset[]>([]);
// Built-in filaments (static fallback)
const [builtinFilaments, setBuiltinFilaments] = useState<BuiltinFilament[]>([]);
// Color catalog
const [colorCatalog, setColorCatalog] = useState<{ manufacturer: string; color_name: string; hex_color: string; material: string | null }[]>([]);
@@ -130,6 +133,7 @@ export function SpoolFormModal({
}
api.getColorCatalog().then(setColorCatalog).catch(console.error);
api.getLocalPresets().then(r => setLocalPresets(r.filament)).catch(console.error);
api.getBuiltinFilaments().then(setBuiltinFilaments).catch(console.error);
// Fetch printer calibrations if not provided via props
if (printersWithCalibrations.length === 0) {
@@ -182,8 +186,8 @@ export function SpoolFormModal({
// Build filament options: cloud → local → fallback
const filamentOptions = useMemo(
() => buildFilamentOptions(cloudPresets, new Set(), localPresets),
[cloudPresets, localPresets],
() => buildFilamentOptions(cloudPresets, new Set(), localPresets, builtinFilaments),
[cloudPresets, localPresets, builtinFilaments],
);
// Extract brands from presets
@@ -164,7 +164,7 @@ export function FilamentSection({
) : (
filteredPresets.map(option => (
<button
key={option.code}
key={`${option.code}::${option.name}`}
type="button"
className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary truncate ${
selectedPresetOption?.code === option.code
+94 -68
View File
@@ -1,4 +1,4 @@
import type { SlicerSetting, LocalPreset } from '../../api/client';
import type { SlicerSetting, LocalPreset, BuiltinFilament } from '../../api/client';
import type { ColorPreset, FilamentOption } from './types';
import { KNOWN_VARIANTS, DEFAULT_BRANDS, RECENT_COLORS_KEY, MAX_RECENT_COLORS } from './constants';
@@ -119,92 +119,118 @@ export function extractBrandsFromPresets(presets: SlicerSetting[], localPresets?
return Array.from(brandSet).sort((a, b) => a.localeCompare(b));
}
// Build filament options from local presets (OrcaSlicer imports)
// Build filament options from local presets (OrcaSlicer / BambuStudio imports).
// Each preset gets its own entry — no base-name collapse — so the spool form
// shows all per-printer/per-nozzle variants the user has imported. The spool
// itself is printer-agnostic, so the variant the user picks just becomes the
// stored slicer_filament code (consumed by normalize_slicer_filament during
// slicing — kept as preset.filament_type when available so the existing
// "GFL05"-style normalisation still resolves).
function buildLocalFilamentOptions(localPresets: LocalPreset[]): FilamentOption[] {
const filamentPresets = localPresets.filter(p => p.preset_type === 'filament');
if (filamentPresets.length === 0) return [];
const presetsMap = new Map<string, FilamentOption>();
for (const preset of filamentPresets) {
const baseName = preset.name.replace(/@.*$/, '').trim();
const existing = presetsMap.get(baseName);
if (existing) {
existing.allCodes.push(String(preset.id));
} else {
// Use filament_type as the code if available (e.g. "GFL00"), otherwise use the id
const code = preset.filament_type || String(preset.id);
presetsMap.set(baseName, {
code,
name: baseName,
displayName: baseName,
isCustom: false,
allCodes: [code],
});
}
}
return Array.from(presetsMap.values()).sort((a, b) => a.displayName.localeCompare(b.displayName));
const options: FilamentOption[] = filamentPresets.map(preset => {
const code = preset.filament_type || String(preset.id);
// allCodes carries every shape an existing saved spool might have stored
// for this preset (filament_type and the row id), so findPresetOption
// resolves both old and new picks.
const allCodes = Array.from(new Set([code, String(preset.id)]));
return {
code,
name: preset.name,
displayName: preset.name,
isCustom: false,
allCodes,
};
});
return options.sort((a, b) => a.displayName.localeCompare(b.displayName));
}
// Build filament options: cloud presets → local presets → hardcoded fallback
// Build filament options by merging cloud presets, local profiles, and built-in
// filaments — matching the behavior of ConfigureAmsSlotModal and the wiki's
// "Where Presets Come From" section. Earlier versions were precedence-based
// (cloud-only when cloud had any presets), which silently hid Local Profiles
// from users logged into Bambu Cloud — see #1248.
export function buildFilamentOptions(
cloudPresets: SlicerSetting[],
configuredPrinterModels: Set<string>,
localPresets?: LocalPreset[],
builtinFilaments?: BuiltinFilament[],
): FilamentOption[] {
// 1. Cloud presets (highest priority)
if (cloudPresets.length > 0) {
const customPresets: FilamentOption[] = [];
const defaultPresetsMap = new Map<string, FilamentOption>();
const customPresets: FilamentOption[] = [];
const defaultPresets: FilamentOption[] = [];
const cloudCodes = new Set<string>();
for (const preset of cloudPresets) {
if (preset.is_custom) {
// Custom presets: include if matches configured printers or no printer filter
const presetNameUpper = preset.name.toUpperCase();
const matchesPrinter = configuredPrinterModels.size === 0 ||
Array.from(configuredPrinterModels).some(model => presetNameUpper.includes(model)) ||
!presetNameUpper.includes('@');
// 1. Cloud presets — each setting_id gets its own entry. The spool form is
// printer-agnostic so we deliberately do NOT collapse "@P1S" / "@X1C"
// variants into a single row; the user picks the variant they want and
// its setting_id is what gets persisted.
for (const preset of cloudPresets) {
if (preset.is_custom) {
const presetNameUpper = preset.name.toUpperCase();
const matchesPrinter = configuredPrinterModels.size === 0 ||
Array.from(configuredPrinterModels).some(model => presetNameUpper.includes(model)) ||
!presetNameUpper.includes('@');
if (matchesPrinter) {
customPresets.push({
code: preset.setting_id,
name: preset.name,
displayName: `${preset.name} (Custom)`,
isCustom: true,
allCodes: [preset.setting_id],
});
}
} else {
// Default presets: deduplicate by base name
const baseName = preset.name.replace(/@.*$/, '').trim();
const existing = defaultPresetsMap.get(baseName);
if (existing) {
existing.allCodes.push(preset.setting_id);
} else {
defaultPresetsMap.set(baseName, {
code: preset.setting_id,
name: baseName,
displayName: baseName,
isCustom: false,
allCodes: [preset.setting_id],
});
}
if (matchesPrinter) {
customPresets.push({
code: preset.setting_id,
name: preset.name,
displayName: `${preset.name} (Custom)`,
isCustom: true,
allCodes: [preset.setting_id],
});
cloudCodes.add(preset.setting_id);
}
} else {
defaultPresets.push({
code: preset.setting_id,
name: preset.name,
displayName: preset.name,
isCustom: false,
allCodes: [preset.setting_id],
});
cloudCodes.add(preset.setting_id);
}
return [
...customPresets,
...Array.from(defaultPresetsMap.values()),
].sort((a, b) => a.displayName.localeCompare(b.displayName));
}
// 2. Local presets (OrcaSlicer imports)
if (localPresets && localPresets.length > 0) {
const localOptions = buildLocalFilamentOptions(localPresets);
if (localOptions.length > 0) return localOptions;
// 2. Local profiles (OrcaSlicer / BambuStudio imports)
const localOptions = localPresets && localPresets.length > 0
? buildLocalFilamentOptions(localPresets)
: [];
// 3. Built-in filaments — only those not already represented by a cloud preset.
// Cloud setting_ids look like "GFSA00", built-in filament_ids look like "GFA00";
// map between the two so we don't render the same filament twice.
const builtinOptions: FilamentOption[] = [];
if (builtinFilaments && builtinFilaments.length > 0) {
for (const bf of builtinFilaments) {
const settingId = bf.filament_id.startsWith('GF')
? 'GFS' + bf.filament_id.slice(2)
: bf.filament_id;
if (cloudCodes.has(bf.filament_id) || cloudCodes.has(settingId)) continue;
builtinOptions.push({
code: bf.filament_id,
name: bf.name,
displayName: bf.name,
isCustom: false,
allCodes: [bf.filament_id, settingId],
});
}
}
// 3. Hardcoded fallback
return FALLBACK_PRESETS;
const merged = [
...customPresets,
...defaultPresets,
...localOptions,
...builtinOptions,
];
// 4. Hardcoded fallback only when literally every source is empty.
if (merged.length === 0) return FALLBACK_PRESETS;
return merged.sort((a, b) => a.displayName.localeCompare(b.displayName));
}
// Find selected preset option
@@ -7,6 +7,7 @@ import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolB
import {
api,
spoolbuddyApi,
type BuiltinFilament,
type InventorySpool,
type LocalPreset,
type SlicerSetting,
@@ -412,6 +413,7 @@ function NewSpoolTouchForm({ currencySymbol, onCreated, selectedSpool, t }: {
const [loadingCloudPresets, setLoadingCloudPresets] = useState(false);
const [cloudPresets, setCloudPresets] = useState<SlicerSetting[]>([]);
const [localPresets, setLocalPresets] = useState<LocalPreset[]>([]);
const [builtinFilaments, setBuiltinFilaments] = useState<BuiltinFilament[]>([]);
const [spoolCatalog, setSpoolCatalog] = useState<SpoolCatalogEntry[]>([]);
const [colorCatalog, setColorCatalog] = useState<
{ manufacturer: string; color_name: string; hex_color: string; material: string | null }[]
@@ -451,6 +453,7 @@ function NewSpoolTouchForm({ currencySymbol, onCreated, selectedSpool, t }: {
api.getSpoolCatalog().then(setSpoolCatalog).catch(() => undefined);
api.getColorCatalog().then(setColorCatalog).catch(() => undefined);
api.getLocalPresets().then(r => setLocalPresets(r.filament)).catch(() => undefined);
api.getBuiltinFilaments().then(setBuiltinFilaments).catch(() => undefined);
try {
const printers = await api.getPrinters();
@@ -496,8 +499,8 @@ function NewSpoolTouchForm({ currencySymbol, onCreated, selectedSpool, t }: {
}, [printersWithCalibrations]);
const filamentOptions = useMemo(
() => buildFilamentOptions(cloudPresets, new Set(), localPresets),
[cloudPresets, localPresets],
() => buildFilamentOptions(cloudPresets, new Set(), localPresets, builtinFilaments),
[cloudPresets, localPresets, builtinFilaments],
);
const selectedPresetOption = useMemo(
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -26,7 +26,7 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-W4CVK7gF.js"></script>
<script type="module" crossorigin src="/assets/index-CdUD30tt.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CHOG7xih.css">
</head>
<body>