From 361fea7408087063ddbd45ce25440619bd12c265 Mon Sep 17 00:00:00 2001 From: Randall Hand Date: Sun, 5 Jul 2026 10:13:48 -0400 Subject: [PATCH] feat(nodes): add per-node free-text notes field (#3921) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an optional free-text `notes` annotation per node, scoped per source like other per-node local metadata (favorites/ignored/hide-from-map). This mirrors the official Meshtastic mobile clients' local "notes" field, but persists server-side so it survives across devices/browsers. It is purely MeshMonitor-local state — never synced to or from the mesh. Backend: - Migration 112 adds nullable `nodes.notes` (TEXT / VARCHAR(2000)) across SQLite/PostgreSQL/MySQL, idempotent; existing rows keep NULL. - Schema (3 backends), DbNode / NodeData / DeviceInfo types, dbNodeMapper. - NodesRepository.setNodeNotes (Drizzle, all backends, source-scoped) and DatabaseService.setNodeNotesAsync (repo-backed, no raw SQL). - upsertNode preserves notes across packet-driven updates and, like `mobile`, intentionally omits notes from the conflict DO-UPDATE so a packet never clobbers a user-authored note. - POST /api/nodes/:nodeId/notes route (requirePermission nodes/write, source from body, string + 2000-char + hex-nodeId validation). Frontend: - Editable notes textarea in NodeDetailsBlock (node detail view in Messages), gated on nodes/write permission; read-only display otherwise. Draft tracks a local baseline so Save settles immediately before the node poll catches up. - apiService.setNodeNotes client method + styling. Tests: - Repository per-source isolation + notes-preservation tests. - Route validation-contract tests (set/clear/type/length/sourceId/nodeId). - Migration registry count/last-name bump to 112. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01AphLfgUJWaFNAgU5UXdJ4n --- CLAUDE.md | 2 +- src/components/MessagesTab.tsx | 14 ++- src/components/NodeDetailsBlock.css | 62 ++++++++++++ src/components/NodeDetailsBlock.tsx | 80 +++++++++++++++- src/db/migrations.test.ts | 12 +-- src/db/migrations.ts | 17 ++++ src/db/repositories/nodes.test.ts | 64 +++++++++++++ src/db/repositories/nodes.ts | 23 +++++ src/db/schema/nodes.ts | 12 +++ src/db/types.ts | 2 + .../migrations/112_add_notes_to_nodes.ts | 69 ++++++++++++++ src/server/server.test.ts | 95 +++++++++++++++++++ src/server/server.ts | 72 ++++++++++++++ src/server/utils/dbNodeMapper.ts | 3 + src/services/api.ts | 21 ++++ src/services/database.ts | 31 ++++++ src/types/device.ts | 1 + 17 files changed, 571 insertions(+), 9 deletions(-) create mode 100644 src/server/migrations/112_add_notes_to_nodes.ts diff --git a/CLAUDE.md b/CLAUDE.md index fb290c329..50aa0dfd5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -98,7 +98,7 @@ For per-source permission tests, mock `getUserPermissionSetAsync(userId, sourceI ### Migration Registry Migrations use a centralized registry in `src/db/migrations.ts`. Each migration has functions for all three backends. -**Current migration count:** 110 (latest: `110_add_meshcore_position_history`). +**Current migration count:** 112 (latest: `112_add_notes_to_nodes`). For the full "adding a migration" recipe see [Migration recipe](#migration-recipe) below. diff --git a/src/components/MessagesTab.tsx b/src/components/MessagesTab.tsx index bff3dfba5..af8fea251 100644 --- a/src/components/MessagesTab.tsx +++ b/src/components/MessagesTab.tsx @@ -2158,7 +2158,19 @@ const MessagesTab: React.FC = ({ )} - {selectedNode && } + {selectedNode && ( + { + if (!selectedNode.user?.id) throw new Error('Node has no ID'); + await apiService.setNodeNotes(selectedNode.user.id, notes, sourceId); + showToast(t('node_details.notes_saved', 'Notes saved'), 'success'); + }} + /> + )} {/* Security Details Section */} {selectedNode && diff --git a/src/components/NodeDetailsBlock.css b/src/components/NodeDetailsBlock.css index 7cedd1db4..924315cad 100644 --- a/src/components/NodeDetailsBlock.css +++ b/src/components/NodeDetailsBlock.css @@ -131,6 +131,68 @@ grid-column: span 2; } +/* Notes (#3921) — editable free-text per-node annotation */ +.node-detail-notes-input { + width: 100%; + box-sizing: border-box; + margin-top: 4px; + padding: 8px 10px; + background-color: var(--ctp-base); + color: var(--ctp-text); + border: 1px solid var(--ctp-surface1); + border-radius: var(--radius-sm); + font-family: inherit; + font-size: 0.95rem; + line-height: 1.4; + resize: vertical; +} + +.node-detail-notes-input:focus { + outline: none; + border-color: var(--ctp-blue); +} + +.node-detail-notes-input:disabled { + opacity: 0.6; +} + +.node-detail-notes-readonly { + font-size: 0.95rem; + font-weight: 400; + white-space: pre-wrap; + word-break: break-word; +} + +.node-detail-notes-error { + display: block; + color: var(--ctp-red); + font-size: 0.85rem; + margin-top: 4px; +} + +.node-detail-notes-actions { + display: flex; + justify-content: flex-end; + margin-top: 8px; +} + +.node-detail-notes-save { + background-color: var(--ctp-blue); + color: var(--ctp-base); + border: none; + border-radius: var(--radius-sm); + padding: 6px 14px; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + transition: opacity 0.2s ease; +} + +.node-detail-notes-save:disabled { + opacity: 0.5; + cursor: not-allowed; +} + /* Public key styling */ .node-detail-public-key { font-family: var(--font-mono); diff --git a/src/components/NodeDetailsBlock.tsx b/src/components/NodeDetailsBlock.tsx index f1172972e..bd0b3134b 100644 --- a/src/components/NodeDetailsBlock.tsx +++ b/src/components/NodeDetailsBlock.tsx @@ -15,9 +15,19 @@ interface NodeDetailsBlockProps { node: DeviceInfo | null; timeFormat?: TimeFormat; dateFormat?: DateFormat; + /** + * When true (and `onSaveNotes` is provided), the free-text notes field (#3921) + * is rendered as an editable textarea. Otherwise any existing note is shown + * read-only. + */ + canEditNotes?: boolean; + /** Persist the edited note. Resolves on success, rejects on failure. */ + onSaveNotes?: (notes: string) => Promise; } -const NodeDetailsBlock: React.FC = ({ node, timeFormat = '24', dateFormat = 'MM/DD/YYYY' }) => { +const MAX_NODE_NOTES_LENGTH = 2000; + +const NodeDetailsBlock: React.FC = ({ node, timeFormat = '24', dateFormat = 'MM/DD/YYYY', canEditNotes = false, onSaveNotes }) => { const { t } = useTranslation(); const { channels } = useChannels(); const { currentNodeId } = useDeviceConfig(); @@ -29,14 +39,50 @@ const NodeDetailsBlock: React.FC = ({ node, timeFormat = return stored === 'true'; }); + // #3921: editable free-text notes. Draft is kept in local state and only + // persisted on Save; it re-syncs whenever the selected node (or its stored + // note) changes. + const nodeNum = node?.nodeNum; + const storedNotes = node?.notes ?? ''; + const [notesDraft, setNotesDraft] = useState(storedNotes); + // Baseline the draft is compared against for the "dirty" check. It tracks the + // last-persisted value locally so the Save button settles immediately after a + // successful save, even before the polled `nodes` prop catches up. + const [notesBaseline, setNotesBaseline] = useState(storedNotes); + const [notesSaving, setNotesSaving] = useState(false); + const [notesError, setNotesError] = useState(null); + useEffect(() => { localStorage.setItem('nodeDetailsCollapsed', isCollapsed.toString()); }, [isCollapsed]); + // Re-sync draft/baseline whenever the selected node or its stored note changes. + useEffect(() => { + setNotesDraft(storedNotes); + setNotesBaseline(storedNotes); + setNotesError(null); + }, [nodeNum, storedNotes]); + if (!node) { return null; } + const notesDirty = notesDraft !== notesBaseline; + + const handleSaveNotes = async () => { + if (!onSaveNotes || notesSaving || !notesDirty) return; + setNotesSaving(true); + setNotesError(null); + try { + await onSaveNotes(notesDraft); + setNotesBaseline(notesDraft); + } catch (err) { + setNotesError(err instanceof Error ? err.message : t('node_details.notes_error', 'Failed to save notes')); + } finally { + setNotesSaving(false); + } + }; + /** * Get battery level indicator class based on percentage */ @@ -439,6 +485,38 @@ const NodeDetailsBlock: React.FC = ({ node, timeFormat = )} + + {/* Notes (#3921) — editable when permitted, otherwise read-only */} + {(canEditNotes && onSaveNotes) ? ( +
+
{t('node_details.notes', 'Notes')}
+