import React, { useState, useEffect, useCallback } from 'react'; import { useQuery, useMutation } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { Gauge, Loader2, RefreshCw, Printer, Plus, X, AlertCircle, WifiOff, Trash2, Search, Copy, Download, Upload, CheckSquare, Square, StickyNote, } from 'lucide-react'; import { api } from '../api/client'; import type { KProfile, KProfileCreate, KProfileDelete, Permission } from '../api/client'; import { buildFilamentPresetOptions, resolveFilamentId, type FilamentPresetOption, type FilamentPresetSource, } from '../utils/filamentPresets'; import { Card, CardContent } from './Card'; import { Button } from './Button'; import { useToast } from '../contexts/ToastContext'; import { useAuth } from '../contexts/AuthContext'; import { useCancellableTimeout } from '../hooks/useCancellableTimeout'; interface KProfileCardProps { profile: KProfile; onEdit: () => void; onCopy?: () => void; selectionMode?: boolean; isSelected?: boolean; onToggleSelect?: () => void; note?: string; // Note text to display as preview } // Truncate to 3 decimal places (like Bambu Studio) instead of rounding const truncateK = (value: string) => { const num = parseFloat(value); return (Math.trunc(num * 1000) / 1000).toFixed(3); }; // nozzle_id encodes the flow type, per the slicer's own generator: // "H" + (Standard ? "S" : "H") + "00" + "-" + diameter // so "HS00-0.4" is Standard and "HH00-0.4" is High Flow. The "00" is a literal, // not a material code. const STANDARD_FLOW = 'HS00'; const HIGH_FLOW = 'HH00'; // Many printers omit nozzle_id from their extrusion_cali_get response entirely // (#1748) — the field simply isn't in the payload. BambuStudio treats that as // Standard (its parser falls back to nvtStandard when the key is absent), and // so do we: the flow type stays a real, editable value rather than a blank. const getNozzleTypePrefix = (nozzleId: string) => { const match = nozzleId.match(/^([A-Z]{2}\d{2})/); return match ? match[1] : STANDARD_FLOW; }; // Short label for the profile list. const getFlowTypeLabel = (nozzleId: string) => getNozzleTypePrefix(nozzleId) === HIGH_FLOW ? 'HF' : 'S'; // Extract filament name from profile name (e.g., "High Flow_Devil Design PLA Basic" -> "Devil Design PLA Basic") const extractFilamentName = (profileName: string) => { // Profile names are formatted as "{Flow Type}_{Filament Name}" or "{Flow Type} {Filament Name}" // Remove common prefixes - check both underscore and space separators const prefixes = [ 'High Flow_', 'High Flow ', // underscore or space 'Standard_', 'Standard ', 'HF_', 'HF ', 'S_', 'S ', ]; for (const prefix of prefixes) { if (profileName.startsWith(prefix)) { return profileName.slice(prefix.length); } } // If no prefix found, check for underscore separator const underscoreIdx = profileName.indexOf('_'); if (underscoreIdx > 0) { return profileName.slice(underscoreIdx + 1); } return profileName; }; function KProfileCard({ profile, onEdit, onCopy, selectionMode, isSelected, onToggleSelect, note }: KProfileCardProps) { const flowType = getFlowTypeLabel(profile.nozzle_id); const diameter = profile.nozzle_diameter; const handleClick = () => { if (selectionMode && onToggleSelect) { onToggleSelect(); } else { onEdit(); } }; return (
{selectionMode && ( )} {!selectionMode && onCopy && ( )}
); } interface KProfileModalProps { profile?: KProfile; printerId: number; nozzleDiameter: string; existingProfiles?: KProfile[]; // Existing profiles, used for name resolution builtinFilaments?: { filament_id: string; name: string }[]; // Filament ID → name lookup filamentPresets?: FilamentPresetOption[]; // Every filament this install knows, tiered isDualNozzle?: boolean; // Whether this is a dual-nozzle printer supportsFlowType?: boolean; // Model sells both Standard and High Flow nozzles initialNote?: string; // Initial note value for the profile initialNoteKey?: string | null; // Key the note was stored under (for clearing) onClose: () => void; onSave: () => void; onSaveNote?: (settingId: string, note: string) => void; // Callback to save note hasPermission: (permission: Permission) => boolean; } function KProfileModal({ profile, printerId, nozzleDiameter, existingProfiles = [], builtinFilaments = [], filamentPresets = [], isDualNozzle = false, supportsFlowType = true, initialNote = '', initialNoteKey = null, onClose, onSave, onSaveNote, hasPermission, }: KProfileModalProps) { const { t } = useTranslation(); const { showToast } = useToast(); const [name, setName] = useState(profile?.name || ''); const [kValue, setKValue] = useState( profile?.k_value ? truncateK(profile.k_value) : '0.020' ); // What the Filament select is bound to. When editing, the printer's own // filament_id (the select is read-only). For a new profile, the *preset // handle* from the tiered list — a local row id, an Orca UUID, a Bambu Cloud // setting_id or a builtin filament id — which is resolved to a real // filament_id on submit, since only some tiers carry one directly. const [filamentChoice, setFilamentChoice] = useState(profile?.filament_id || ''); // Split nozzle into type and diameter // Both selects are read-only while editing: they report what the printer // holds, they don't set it. '' means the printer reported no nozzle_id, which // single-nozzle models never do (#1748) — showing "High Flow" there was the // UI inventing a value the printer never sent. const [nozzleType, setNozzleType] = useState( profile ? getNozzleTypePrefix(profile.nozzle_id) : STANDARD_FLOW ); const [modalDiameter, setModalDiameter] = useState( profile?.nozzle_diameter || nozzleDiameter ); // For new profiles on dual-nozzle: allow selecting multiple extruders // For editing: use single extruder from the profile const [selectedExtruders, setSelectedExtruders] = useState( profile ? [profile.extruder_id] : isDualNozzle ? [0, 1] : [0] // Default: both extruders for new dual-nozzle profiles ); const [isSyncing, setIsSyncing] = useState(false); const [savingProgress, setSavingProgress] = useState({ current: 0, total: 0 }); const [note, setNote] = useState(initialNote); const [filamentQuery, setFilamentQuery] = useState(''); // The modal defers its own close so the printer has time to process the // command; that timer must not outlive the modal. const { schedule: scheduleClose } = useCancellableTimeout(); // Name for the filament an existing profile is bound to. The builtin table // (which the parent has already merged with the user's cloud presets) is // authoritative; a profile whose filament_id is in neither falls back to the // name the printer stored for it. const editedFilamentName = React.useMemo(() => { if (!profile?.filament_id) return ''; const builtinName = builtinFilaments.find(bf => bf.filament_id === profile.filament_id)?.name; if (builtinName) return builtinName; const fromProfile = existingProfiles.find(p => p.filament_id === profile.filament_id); return extractFilamentName(fromProfile?.name || profile.name || '') || profile.filament_id; }, [profile, existingProfiles, builtinFilaments]); // The tiered list, grouped for rendering. Order is fixed app-wide — // imported, then Orca Cloud, then Bambu Cloud, then the hardcoded table — // and buildFilamentPresetOptions has already sorted by it, so grouping is // just a partition that preserves that order. const presetGroups = React.useMemo(() => { const labels: [FilamentPresetSource, string][] = [ ['local', t('kProfiles.modal.source.local')], ['orca_cloud', t('kProfiles.modal.source.orcaCloud')], ['cloud', t('kProfiles.modal.source.bambuCloud')], ['builtin', t('kProfiles.modal.source.builtin')], ]; const query = filamentQuery.trim().toLowerCase(); const matches = query ? filamentPresets.filter(p => p.name.toLowerCase().includes(query)) : filamentPresets; return labels .map(([source, label]) => ({ source, label, items: matches.filter(p => p.source === source) })) .filter(g => g.items.length > 0); }, [filamentPresets, filamentQuery, t]); const saveMutation = useMutation({ mutationFn: (data: KProfileCreate) => { console.log('[KProfile] Calling API...'); return api.setKProfile(printerId, data); }, onSuccess: (result, variables) => { console.log('[KProfile] Save success:', result); showToast(t('kProfiles.toast.profileSaved')); // Save note if it changed (including clearing it) if (onSaveNote && note !== initialNote) { let profileKey: string; if (note === '' && initialNoteKey) { // Clearing note: use the same key it was stored under profileKey = initialNoteKey; } else if (profile && profile.slot_id > 0) { // Editing: use setting_id if available, or composite key with slot_id profileKey = profile.setting_id || `slot_${profile.slot_id}_${profile.filament_id}_${profile.extruder_id}`; } else { // New profile: use name as key (matched against the reloaded profile, // so it has to be the resolved filament_id that was sent — not the // preset handle the user picked). profileKey = `name_${name}_${variables.filament_id}`; } onSaveNote(profileKey, note); } // Show syncing indicator while printer processes the command setIsSyncing(true); // Add delay before closing to give printer time to process the save // onSave will trigger refetch in the parent component scheduleClose(() => { setIsSyncing(false); onSave(); }, 2500); }, onError: (error: Error) => { console.error('[KProfile] Save error:', error); showToast(error.message, 'error'); setIsSyncing(false); }, }); const deleteMutation = useMutation({ mutationFn: (data: KProfileDelete) => { console.log('[KProfile] Deleting profile...'); return api.deleteKProfile(printerId, data); }, onSuccess: (result) => { console.log('[KProfile] Delete success:', result); showToast(t('kProfiles.toast.profileDeleted')); // Show syncing indicator while printer processes the command setIsSyncing(true); // Add longer delay for delete - printer needs more time to process // before it can return the updated profile list scheduleClose(() => { setIsSyncing(false); onClose(); }, 4000); }, onError: (error: Error) => { console.error('[KProfile] Delete error:', error); showToast(error.message, 'error'); setIsSyncing(false); }, }); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const handleDelete = () => { if (!profile) return; deleteMutation.mutate({ slot_id: profile.slot_id, extruder_id: profile.extruder_id, nozzle_id: profile.nozzle_id, nozzle_diameter: profile.nozzle_diameter, filament_id: profile.filament_id, setting_id: profile.setting_id, }); }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); // Validate at least one extruder is selected for dual-nozzle if (isDualNozzle && !profile && selectedExtruders.length === 0) { showToast(t('kProfiles.toast.selectAtLeastOneExtruder'), 'error'); return; } // Format k_value to 6 decimal places for Bambu protocol const formattedKValue = parseFloat(kValue).toFixed(6); // Combine nozzle type and diameter into nozzle_id (e.g., "HH00-0.4") const nozzleId = `${nozzleType}-${modalDiameter}`; // An edit is delete + re-add on single-nozzle printers, so the nozzle // fields have to survive the round trip — both selects are disabled while // editing. Rebuilding them blindly from the selects is what let a 0.6mm // profile come back as "HH00-0.4" once the parse defaults had stamped it // 0.4 (#1748), so prefer whatever the printer reported. Where it reported // no nozzle_id at all, send the rebuilt one rather than an empty string — // the field is part of the profile's identity on the wire and the slicer // always populates it. const editNozzleId = profile ? profile.nozzle_id || nozzleId : nozzleId; const editDiameter = profile ? profile.nozzle_diameter : modalDiameter; // The printer indexes its calibration table by filament_id, so the preset // the user picked has to be reduced to one before anything is sent. Only // the builtin tier and Bambu's official cloud presets carry one outright; // a cloud *user* preset needs its detail fetched, and imported / Orca // presets have no Bambu id at all and map to the generic for their // material. Refuse rather than guess when nothing resolves — a profile // filed under the wrong filament is invisible to the slot that needs it. let resolvedFilamentId = profile?.filament_id || ''; if (!profile) { const picked = filamentPresets.find(p => p.id === filamentChoice); if (!picked) { showToast(t('kProfiles.toast.selectFilament'), 'error'); return; } resolvedFilamentId = await resolveFilamentId(picked, api.getCloudSettingDetail); if (!resolvedFilamentId) { showToast(t('kProfiles.toast.filamentNotResolvable', { name: picked.name }), 'error'); return; } } // For editing or single extruder: just save one profile if (profile || selectedExtruders.length === 1) { const payload = { name: name, k_value: formattedKValue, filament_id: resolvedFilamentId, nozzle_id: editNozzleId, nozzle_diameter: editDiameter, extruder_id: profile ? profile.extruder_id : selectedExtruders[0], setting_id: profile?.setting_id, slot_id: profile?.slot_id ?? 0, }; console.log('[KProfile] Saving profile:', payload); saveMutation.mutate(payload); return; } // For new profiles with multiple extruders: use batch endpoint setIsSyncing(true); setSavingProgress({ current: 1, total: selectedExtruders.length }); // Build payload for all selected extruders const batchPayload = selectedExtruders.map(extruderId => ({ name: name, k_value: formattedKValue, filament_id: resolvedFilamentId, nozzle_id: nozzleId, nozzle_diameter: modalDiameter, extruder_id: extruderId, setting_id: undefined, slot_id: 0, })); console.log(`[KProfile] Saving ${batchPayload.length} profiles in batch:`, batchPayload); try { await api.setKProfilesBatch(printerId, batchPayload); showToast(t('kProfiles.toast.profilesSaved', { count: selectedExtruders.length })); // Save note for new batch profiles if (onSaveNote && note) { const profileKey = `name_${name}_${resolvedFilamentId}`; onSaveNote(profileKey, note); } } catch (error) { console.error('[KProfile] Failed to save batch:', error); showToast(t('kProfiles.toast.failedToSaveBatch'), 'error'); setIsSyncing(false); setSavingProgress({ current: 0, total: 0 }); return; } setSavingProgress({ current: selectedExtruders.length, total: selectedExtruders.length }); // Wait for final sync before closing // onSave will trigger refetch in the parent component scheduleClose(() => { setIsSyncing(false); setSavingProgress({ current: 0, total: 0 }); onSave(); }, 3000); }; return (
{/* Syncing overlay */} {isSyncing && (

{savingProgress.total > 1 ? t('kProfiles.modal.savingExtruder', { current: savingProgress.current, total: savingProgress.total }) : t('kProfiles.modal.syncing')}

{t('kProfiles.modal.pleaseWait')}

)}

{profile ? t('kProfiles.modal.editTitle') : t('kProfiles.modal.addTitle')}

{/* Profile Name - read-only when editing */}
setName(e.target.value)} disabled={!!profile} 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 ${profile ? 'opacity-60 cursor-not-allowed' : ''}`} placeholder={t('kProfiles.modal.profileNamePlaceholder')} required={!profile} />
{/* K-Value - always editable */}
{ // Allow typing any decimal value const val = e.target.value; if (val === '' || /^\d*\.?\d*$/.test(val)) { setKValue(val); } }} onBlur={(e) => { // Format to 3 decimal places on blur const num = parseFloat(e.target.value); if (!isNaN(num)) { setKValue((Math.trunc(num * 1000) / 1000).toFixed(3)); } }} 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 font-mono" placeholder={t('kProfiles.modal.kValuePlaceholder')} required />

{t('kProfiles.modal.kValueHelp')}

{/* Filament - read-only when editing */}
{profile ? ( // Editing or copying: the filament is fixed, so this is a // readout rather than a control.
{editedFilamentName || profile.filament_id}
) : ( // A real list rather than a setFilamentQuery(e.target.value)} placeholder={t('kProfiles.modal.searchFilaments')} className="w-full pl-10 pr-3 py-2 bg-bambu-dark text-white placeholder-bambu-gray focus:outline-none" />
{presetGroups.length === 0 ? (

{filamentPresets.length === 0 ? t('kProfiles.modal.noFilamentsHelp') : t('kProfiles.modal.noFilamentMatches')}

) : presetGroups.map((group) => (
{group.label} {group.items.length}
{group.items.map((f) => ( ))}
))}
)} {/* Flow Type and Nozzle Size - read-only when editing. Flow type is hidden on models sold with a single nozzle variant (the A-series), where the choice would be meaningless — same gate the slicer applies via support_nozzle_volume(). */}
{/* Extruder - only show for dual-nozzle printers */} {isDualNozzle && (
{profile ? ( // Read-only display for editing
{profile.extruder_id === 1 ? t('kProfiles.modal.left') : t('kProfiles.modal.right')}
) : ( // Checkboxes for new profile - can select both
)}
)} {/* Notes */}