diff --git a/docs/superpowers/specs/2026-06-14-furnidata-publicname-sync-design.md b/docs/superpowers/specs/2026-06-14-furnidata-publicname-sync-design.md new file mode 100644 index 0000000..a808da0 --- /dev/null +++ b/docs/superpowers/specs/2026-06-14-furnidata-publicname-sync-design.md @@ -0,0 +1,59 @@ +# Sync `public_name` from furnidata (Furni Editor) — design + +**Date:** 2026-06-14 +**Repo:** `ui` (Nitro-V3 React client) — client-only +**Status:** approved (brainstorming) + +## Problem + +In the Furni Editor, the **"Public Name (DB fallback)"** field is `items_base.public_name`. When a furni has a furnidata entry with a display `name` but its DB `public_name` is empty, the two are out of sync. The 10046 edit/create handler already mirrors the furnidata name into `public_name` going forward (step "5b"), but **existing** furni that were never edited via the new editor keep an empty `public_name`. We want a per-furni manual way to fill the empty DB field from the present furnidata name. + +## Scope (decided) + +- **Per-furni**, inside the editor (not bulk). +- **Manual** trigger (a button), not automatic-on-open. +- Direction: `items_base.public_name` ← `furnidata.name` (only when DB is empty). + +## Approach (A — chosen) + +Reuse the existing generic item-update path. No new packet, **no server change**. + +- The server `FurniEditorUpdateEvent` already runs `UPDATE items_base SET WHERE id` and `public_name` is in `ALLOWED_UPDATE_FIELDS`. +- The client already has the `update` action (`FurniEditorUpdateComposer`) whose 10044 success handler shows the "Item updated successfully" toast and re-fetches the detail. + +Rejected: (B) dedicated sync packet/handler — needs a new packet + renderer registration + emulator rebuild/restart (Codex active); overkill. (C) re-trigger 10046 — gated on "dirty" and would broadcast an unnecessary 10047. + +## Components (2 files, client only) + +1. `src/hooks/furni-editor/useFurniEditor.ts` + - Add `syncPublicName(id: number, name: string)`: sets `pendingActionRef = { action: 'update', itemId: id }` and sends `FurniEditorUpdateComposer(id, JSON.stringify({ publicName: name }))`. Sets `loading`. Export it from the hook. + - Reuses the existing `FurniEditorResultEvent` (10044) handler: on success → "Item updated successfully" + re-fetch detail; on failure → existing alert + re-fetch revert. + +2. `src/components/furni-editor/views/FurniEditorEditView.tsx` + - Add a small **"Sync from furnidata"** button next to the read-only "Public Name (DB fallback)" field (Basic Info section). + - Thread `onSyncPublicName` through props from the parent that wires the hook (same place `onUpdateFurnidata` etc. are wired). + +## Button visibility (all three true) + +1. `furnidataEditable` — entry exists AND classname matches (never sync a mismatched classname's name). +2. DB empty: `!String(item.publicName ?? '').trim()`. +3. furnidata name present: `!!String(furniDataEntry?.name ?? '').trim()`. + +Disabled while `loading`. Name synced is the **stored** `furniDataEntry.name`, not the editable `furniName` state (avoid syncing an unsaved edit). + +## Data flow + +click → `syncPublicName(item.id, furniDataEntry.name)` → `FurniEditorUpdateComposer({ publicName })` → server `UPDATE items_base.public_name` → 10044 success → toast + detail re-fetch → field shows the name, button condition no longer met → button disappears. + +## Error handling + +Inherited from the existing 10044 failure path (alert with server message + detail re-fetch to revert). No new handling needed. + +## Testing + +- Primary: runtime verification via Claude-in-Chrome — pick a furni with empty `public_name` + a furnidata name (Search list shows DB-empty rows with the 🎵 placeholder), click "Sync from furnidata", confirm the wire (10045/`update` → 10044 success), the DB field now shows the name, and the button disappears. Confirm `items_base.public_name` updated in the DB. +- Optional unit test: `syncPublicName` sends the right composer payload; visibility predicate. + +## Branch + +Commit on the active `feat/furni-editor-create-missing` branch (related furni-editor work) unless a separate PR is preferred. Client-only; no renderer/emulator change. diff --git a/src/components/furni-editor/FurniEditorView.tsx b/src/components/furni-editor/FurniEditorView.tsx index 75db342..c6ee646 100644 --- a/src/components/furni-editor/FurniEditorView.tsx +++ b/src/components/furni-editor/FurniEditorView.tsx @@ -19,7 +19,7 @@ export const FurniEditorView: FC<{}> = () => selectedItem, setSelectedItem, furniDataEntry, furniDataDiagnostic, interactions, searchItems, loadDetail, loadBySpriteId, updateItem, deleteItem, loadInteractions, - updateFurnidata, revertFurnidata, importText, importResult + updateFurnidata, revertFurnidata, syncPublicName, importText, importResult } = useFurniEditor(); const isMod = useHasPermission('acc_catalogfurni'); @@ -159,6 +159,7 @@ export const FurniEditorView: FC<{}> = () => onBack={ handleBack } onUpdateFurnidata={ updateFurnidata } onRevertFurnidata={ revertFurnidata } + onSyncPublicName={ syncPublicName } onImportText={ importText } importResult={ importResult } /> diff --git a/src/components/furni-editor/views/FurniEditorEditView.tsx b/src/components/furni-editor/views/FurniEditorEditView.tsx index b5e3d35..99aad65 100644 --- a/src/components/furni-editor/views/FurniEditorEditView.tsx +++ b/src/components/furni-editor/views/FurniEditorEditView.tsx @@ -15,6 +15,7 @@ interface FurniEditorEditViewProps onBack: () => void; onUpdateFurnidata: (id: number, name: string, description: string) => void; onRevertFurnidata: (id: number) => void; + onSyncPublicName: (id: number, name: string) => void; onImportText: (id: number) => void; importResult: { found: boolean; name: string; description: string; classname: string; nonce: number } | null; } @@ -122,7 +123,7 @@ const CopyValue: FC<{ value: string | number }> = ({ value }) => export const FurniEditorEditView: FC = props => { - const { item, furniDataEntry, furniDataDiagnostic, interactions, loading, onUpdate, onDelete, onBack, onUpdateFurnidata, onRevertFurnidata, onImportText, importResult } = props; + const { item, furniDataEntry, furniDataDiagnostic, interactions, loading, onUpdate, onDelete, onBack, onUpdateFurnidata, onRevertFurnidata, onSyncPublicName, onImportText, importResult } = props; const saveRef = useRef<() => void>(null); const [ form, setForm ] = useState({ @@ -245,6 +246,21 @@ export const FurniEditorEditView: FC = props => return cn ? (cn === itemCn) : true; }, [ furniDataEntry, item ]); + // No furnidata entry at all → the editor can CREATE one (the server upserts: + // it builds a complete entry from items_base on save). Distinct from the + // classname-mismatch case (an entry resolved by id but for a different + // classname), which stays locked to avoid an id collision. + const furnidataCreatable = useMemo(() => !furniDataEntry, [ furniDataEntry ]); + + // Show a one-click "sync" when the DB public_name is empty but the (matching) + // furnidata entry already has a name — fills items_base.public_name from the + // stored furnidata name so the DB fallback stops being blank. + const canSyncPublicName = useMemo(() => + furnidataEditable && + !String(form.publicName ?? '').trim() && + !!String(furniDataEntry?.name ?? '').trim(), + [ furnidataEditable, form.publicName, furniDataEntry ]); + // True only when the name/description actually differ from the stored furnidata // entry. Used to gate the Save button: saving an unchanged value makes the // server writer return false, which the handler misreports as "Classname not @@ -364,16 +380,18 @@ export const FurniEditorEditView: FC = props => Display name & description { furnidataEditable ? LIVE - : NO FURNIDATA } + : furnidataCreatable + ? NEW + : NO FURNIDATA } { furnidataEditable && furnidataDirty && Unsaved } - { furnidataEditable ? ( + { (furnidataEditable || furnidataCreatable) ? ( <>
- setFurniName(e.target.value) } maxLength={ 256 } /> + setFurniName(e.target.value) } maxLength={ 256 } placeholder={ furnidataCreatable ? (form.publicName || form.itemName) : undefined } />
@@ -381,26 +399,31 @@ export const FurniEditorEditView: FC = props =>
- - - + + { furnidataEditable && + <> + + + } + { furnidataCreatable && + No furnidata entry yet — saving creates a complete one from the item data. } { importNote && { importNote } } ) : (
- This furni has no matching furnidata entry ({ furnidataMissReason.replace(/_/g, ' ') }), so its display name can't be edited here. Clients fall back to the DB Public Name below. + A furnidata entry resolved by id but for a different classname ({ furnidataMissReason.replace(/_/g, ' ') }) — name editing is locked to avoid an id collision. Clients fall back to the DB Public Name below.
) } @@ -414,6 +437,10 @@ export const FurniEditorEditView: FC = props =>
+ { canSyncPublicName && + }
diff --git a/src/hooks/furni-editor/useFurniEditor.ts b/src/hooks/furni-editor/useFurniEditor.ts index 783de34..d6a210e 100644 --- a/src/hooks/furni-editor/useFurniEditor.ts +++ b/src/hooks/furni-editor/useFurniEditor.ts @@ -190,6 +190,15 @@ export const useFurniEditor = () => { setError(parser.message || 'Operation failed'); + // updateFurnidata applies an optimistic publicName change before the + // server replies. On failure (e.g. a sprite-id collision when creating + // an entry for a variant furni) re-fetch the detail so the UI reverts + // to the true state instead of showing the name that was never applied. + if(actionItemId) + { + SendMessageComposer(new FurniEditorDetailComposer(actionItemId)); + } + if(simpleAlert) { simpleAlert(parser.message || 'Operation failed', NotificationAlertType.ALERT, null, null, 'Furni Editor Error'); @@ -282,6 +291,17 @@ export const useFurniEditor = () => SendMessageComposer(new FurniEditorRevertFurnidataComposer(id)); }, []); + // Fill an empty items_base.public_name from the furnidata display name. Reuses + // the generic item update (a partial { publicName } payload is accepted), so the + // existing 'update' result path shows the toast and re-fetches the detail. + const syncPublicName = useCallback((id: number, name: string) => + { + setLoading(true); + setError(null); + pendingActionRef.current = { action: 'update', itemId: id }; + SendMessageComposer(new FurniEditorUpdateComposer(id, JSON.stringify({ publicName: name }))); + }, []); + const importText = useCallback((id: number) => { setLoading(true); @@ -314,6 +334,6 @@ export const useFurniEditor = () => selectedItem, setSelectedItem, catalogItems, furniDataEntry, furniDataDiagnostic, interactions, searchItems, loadDetail, loadBySpriteId, updateItem, deleteItem, loadInteractions, - updateFurnidata, revertFurnidata, importText, importResult + updateFurnidata, revertFurnidata, syncPublicName, importText, importResult }; };