diff --git a/ka-note/client/src/lib/actions/mention.ts b/ka-note/client/src/lib/actions/mention.ts index 0c14051..63719fe 100644 --- a/ka-note/client/src/lib/actions/mention.ts +++ b/ka-note/client/src/lib/actions/mention.ts @@ -10,16 +10,19 @@ export function mention(node: HTMLInputElement | HTMLTextAreaElement) { let createOptions: CreateOption[] = []; let selectedIndex = 0; let mentionStart = -1; + let mentionMode: '@' | '->' = '@'; let active = false; let suppressInput = false; - function getQuery(): string | null { + function getQuery(): { query: string; mode: '@' | '->' } | null { const pos = node.selectionStart ?? 0; const text = node.value; if (active && mentionStart >= 0 && mentionStart < pos) { - if (text[mentionStart] !== '@') return null; - return text.slice(mentionStart + 1, pos); + const trigger = text[mentionStart] === '@' ? '@' : '->'; + if (trigger !== mentionMode) return null; + const offset = mentionMode === '->' ? 2 : 1; + return { query: text.slice(mentionStart + offset, pos), mode: mentionMode }; } for (let i = pos - 1; i >= 0; i--) { @@ -27,10 +30,24 @@ export function mention(node: HTMLInputElement | HTMLTextAreaElement) { if (ch === '@') { if (i === 0 || /\s/.test(text[i - 1])) { mentionStart = i; - return text.slice(i + 1, pos); + mentionMode = '@'; + return { query: text.slice(i + 1, pos), mode: '@' }; } return null; } + // Check for -> trigger: '>' at position i, '-' at i-1. + // Allow a single optional space between -> and the query (e.g. "-> ANDST"). + if (ch === '>' && i > 0 && text[i - 1] === '-') { + if (i - 1 === 0 || /[\s\n]/.test(text[i - 2])) { + mentionStart = i - 1; + mentionMode = '->'; + return { query: text.slice(i + 1, pos).trimStart(), mode: '->' }; + } + return null; + } + // Allow spaces only when we haven't yet passed a non-word char other than space + // (needed for "-> ANDST" where the space is between trigger and query). + // For @-mentions, spaces are not allowed in the query (quoted names use autocomplete). if (/\s/.test(ch)) return null; } return null; @@ -112,17 +129,17 @@ export function mention(node: HTMLInputElement | HTMLTextAreaElement) { async function handleInput() { if (suppressInput) return; - const query = getQuery(); - if (query === null) { + const qResult = getQuery(); + if (qResult === null) { if (active) hide(); return; } - const result = await fetchMentionItems(query); + const result = await fetchMentionItems(qResult.query, qResult.mode); // Re-check after async: state may have changed (e.g. selectItem ran) if (suppressInput) return; - const queryNow = getQuery(); - if (queryNow === null) { + const qResultNow = getQuery(); + if (qResultNow === null) { if (active) hide(); return; } diff --git a/ka-note/client/src/lib/actions/mentionCore.ts b/ka-note/client/src/lib/actions/mentionCore.ts index 37e5dc3..413a1f7 100644 --- a/ka-note/client/src/lib/actions/mentionCore.ts +++ b/ka-note/client/src/lib/actions/mentionCore.ts @@ -19,6 +19,11 @@ export function extractMentionName(ctx: AgendaContext): string { return ctx.name.replace(/^(Person|Project|Firma)\s+/, ''); } +export function extractAbbreviation(ctx: AgendaContext): string | undefined { + if (ctx.type !== 'person') return undefined; + return (ctx.meta as { abbreviation?: string } | null)?.abbreviation || undefined; +} + export function quoteMention(prefix: string, name: string): string { return name.includes(' ') ? `${prefix}"${name}"` : `${prefix}${name}`; } @@ -34,12 +39,33 @@ function buildMentionItem(ctx: AgendaContext): MentionItem { return { context: ctx, mentionName, icon, insertText }; } -export async function fetchMentionItems(query: string): Promise<{ items: MentionItem[]; createOptions: CreateOption[] }> { +export async function fetchMentionItems(query: string, mode: '@' | '->' = '@'): Promise<{ items: MentionItem[]; createOptions: CreateOption[] }> { const all = await db.contexts .filter(c => !c.deletedAt && !c.archivedAt && (c.type === 'person' || c.type === 'project' || c.type === 'company')) .toArray(); const q = query.replace(/_/g, ' ').toLowerCase(); + + if (mode === '->') { + // Arrow mode: only persons with an abbreviation, match against abbreviation OR full name + const items = all + .filter(c => c.type === 'person') + .flatMap(c => { + const abbr = extractAbbreviation(c); + if (!abbr) return []; + const fullName = extractMentionName(c).toLowerCase(); + if (!abbr.toLowerCase().includes(q) && !fullName.includes(q)) return []; + return [{ + context: c, + mentionName: `${abbr} – ${extractMentionName(c)}`, + icon: '\u{1F464}', + insertText: `-> ${abbr}`, + } as MentionItem]; + }) + .slice(0, 8); + return { items, createOptions: [] }; + } + const items = all .map(buildMentionItem) .filter(m => m.mentionName.toLowerCase().includes(q)) diff --git a/ka-note/client/src/lib/actions/refClick.ts b/ka-note/client/src/lib/actions/refClick.ts index 8329fc4..ecb26c9 100644 --- a/ka-note/client/src/lib/actions/refClick.ts +++ b/ka-note/client/src/lib/actions/refClick.ts @@ -1,5 +1,5 @@ import { goto } from '$app/navigation'; -import { findContextByMentionName, upsertContext, createPage } from '$lib/db/repositories'; +import { findContextByMentionName, findContextByAbbreviation, upsertContext, createPage } from '$lib/db/repositories'; import { db } from '$lib/db/schema'; interface RefPopup { @@ -249,6 +249,22 @@ export function refClick(node: HTMLElement) { return; } + const assignmentEl = target.closest('[data-assignment]'); + if (assignmentEl) { + e.preventDefault(); + e.stopPropagation(); + const abbr = assignmentEl.dataset.assignment!; + findContextByAbbreviation(abbr).then(ctx => { + if (ctx) { + const name = ctx.name.replace(/^Person\s+/, ''); + const isEmployee = (ctx.meta as Record | null)?.personSubType === 'employee'; + const ratingCtx = isEmployee ? findRatingContext(assignmentEl) : null; + showPersonPopup(name, ctx.id, ratingCtx, e.clientX, e.clientY, assignmentEl); + } + }); + return; + } + const personEl = target.closest('[data-person]'); if (personEl) { e.preventDefault(); diff --git a/ka-note/client/src/lib/components/ContextHeader.svelte b/ka-note/client/src/lib/components/ContextHeader.svelte index 186e811..6db898c 100644 --- a/ka-note/client/src/lib/components/ContextHeader.svelte +++ b/ka-note/client/src/lib/components/ContextHeader.svelte @@ -1,6 +1,6 @@ -{#if isUrl(text)} +{#if isUrl(normalized)} e.stopPropagation()} - >{truncateUrlDisplay(text)} + >{truncateUrlDisplay(normalized)} {:else} - {text} + {normalized} {/if} diff --git a/ka-note/client/src/lib/components/MarkdownEditor.svelte b/ka-note/client/src/lib/components/MarkdownEditor.svelte index eee6c88..d0052b6 100644 --- a/ka-note/client/src/lib/components/MarkdownEditor.svelte +++ b/ka-note/client/src/lib/components/MarkdownEditor.svelte @@ -40,7 +40,9 @@ export function getMarkdown(): string { if (!editor) return ''; - const raw = editor.storage.markdown.getMarkdown(); + // tiptap-markdown serializer escapes > as > and also backslash-escapes ->; + // normalize both forms back to ->. + const raw = editor.storage.markdown.getMarkdown().replace(/\\?-(?:>|>)/g, '->'); return objectUrlToKaImg(raw); } diff --git a/ka-note/client/src/lib/db/repositories.ts b/ka-note/client/src/lib/db/repositories.ts index ee4c8f8..3c19aef 100644 --- a/ka-note/client/src/lib/db/repositories.ts +++ b/ka-note/client/src/lib/db/repositories.ts @@ -5,6 +5,7 @@ import { getAccessToken } from '$lib/auth/authStore'; import { normalizeTitleAndBody } from '$lib/utils/titleUtils'; import { scopeSettings } from '$lib/stores/scopeContext'; import type { AgendaContext, Topic, HistoryEntry, Rating, ContextType, TopicStatus, ProjectMeta, PersonMeta, CompanyMeta, EventMeta, Page, Notebook, PageNotebook } from '@ka-note/shared'; +import { renameMentions, renameAssignment } from '$lib/utils/mentionReplace'; // --- Contexts --- @@ -87,10 +88,10 @@ export async function reorderContext(id: string, direction: 'up' | 'down'): Prom } } -export async function contextNameExists(name: string, type: ContextType): Promise { +export async function contextNameExists(name: string, type: ContextType, excludeId?: string): Promise { const q = name.toLowerCase(); const count = await db.contexts - .filter(c => !c.deletedAt && c.type === type && c.name.toLowerCase() === q) + .filter(c => !c.deletedAt && c.type === type && c.name.toLowerCase() === q && c.id !== excludeId) .count(); return count > 0; } @@ -107,6 +108,13 @@ export async function notebookNameExists(name: string): Promise { return count > 0; } +export async function findContextByAbbreviation(abbr: string): Promise { + const q = abbr.toLowerCase(); + return db.contexts + .filter(c => !c.deletedAt && c.type === 'person' && (c.meta as { abbreviation?: string } | null)?.abbreviation?.toLowerCase() === q) + .first(); +} + export async function findContextByMentionName(name: string, type: 'person' | 'project' | 'company'): Promise { const q = name.toLowerCase(); return db.contexts @@ -538,6 +546,84 @@ export async function softDeletePage(id: string): Promise { } } +export async function renameMentionCascade( + oldName: string, + newName: string, + type: 'person' | 'project' | 'company', + oldAbbr?: string, + newAbbr?: string +): Promise { + const ts = now(); + await db.transaction('rw', db.historyEntries, db.topics, db.contexts, db.ratings, async () => { + const entries = await db.historyEntries.filter(h => !h.deletedAt).toArray(); + for (const h of entries) { + let updated = renameMentions(h.text, oldName, newName, type); + if (type === 'person' && oldAbbr && newAbbr) { + updated = renameAssignment(updated, oldAbbr, newAbbr); + } + if (updated !== h.text) { + await db.historyEntries.update(h.id, { text: updated, updatedAt: ts, version: h.version + 1 }); + } + } + + const topics = await db.topics.filter(t => !t.deletedAt).toArray(); + for (const t of topics) { + let updated = renameMentions(t.title, oldName, newName, type); + if (type === 'person' && oldAbbr && newAbbr) { + updated = renameAssignment(updated, oldAbbr, newAbbr); + } + if (updated !== t.title) { + await db.topics.update(t.id, { title: updated, updatedAt: ts, version: t.version + 1 }); + } + } + + const contexts = await db.contexts + .filter(c => !c.deletedAt && (c.type === 'person' || c.type === 'project' || c.type === 'company')) + .toArray(); + for (const c of contexts) { + const meta = c.meta as { notes?: string } | null; + if (!meta?.notes) continue; + let updated = renameMentions(meta.notes, oldName, newName, type); + if (type === 'person' && oldAbbr && newAbbr) { + updated = renameAssignment(updated, oldAbbr, newAbbr); + } + if (updated !== meta.notes) { + await db.contexts.update(c.id, { meta: { ...meta, notes: updated }, updatedAt: ts, version: c.version + 1 }); + } + } + + const ratings = await db.ratings.filter(r => !r.deletedAt).toArray(); + for (const r of ratings) { + if (!r.comment) continue; + const updated = renameMentions(r.comment, oldName, newName, type); + if (updated !== r.comment) { + await db.ratings.update(r.id, { comment: updated, updatedAt: ts, version: r.version + 1 }); + } + } + + // ratings.personName is a structured field — update on person rename + if (type === 'person') { + const byPerson = await db.ratings.where('personName').equals(oldName).filter(r => !r.deletedAt).toArray(); + for (const r of byPerson) { + await db.ratings.update(r.id, { personName: newName, updatedAt: ts, version: r.version + 1 }); + } + } + + // EventMeta.participants — update plain name strings on person rename + if (type === 'person') { + const events = await db.contexts.filter(c => !c.deletedAt && c.type === 'event').toArray(); + for (const ev of events) { + const meta = ev.meta as { participants?: string[] } | null; + if (!meta?.participants) continue; + const updated = meta.participants.map(p => p === oldName ? newName : p); + if (updated.some((p, i) => p !== meta.participants![i])) { + await db.contexts.update(ev.id, { meta: { ...meta, participants: updated }, updatedAt: ts, version: ev.version + 1 }); + } + } + } + }); +} + async function renameTitleInLinks(oldTitle: string, newTitle: string): Promise { const pattern = `[[${oldTitle}]]`; const replacement = `[[${newTitle}]]`; diff --git a/ka-note/client/src/lib/editor/tiptapMention.ts b/ka-note/client/src/lib/editor/tiptapMention.ts index 8bf8c8c..8f5094b 100644 --- a/ka-note/client/src/lib/editor/tiptapMention.ts +++ b/ka-note/client/src/lib/editor/tiptapMention.ts @@ -12,7 +12,8 @@ const mentionPluginKey = new PluginKey('mentionSuggestion'); interface MentionState { active: boolean; query: string; - from: number; // position of '@' in doc + from: number; // position of trigger start in doc + mode: '@' | '->'; } export const TiptapMention = Extension.create({ @@ -81,13 +82,16 @@ export const TiptapMention = Extension.create({ const savedTo = editor.state.selection.from; console.log('[tiptap-mention] selectItem', index, 'items:', items.length, 'create:', createOptions.length, 'from:', savedFrom, 'to:', savedTo); try { + const insertRaw = (text: string) => { + const { state, view } = editor; + const tr = state.tr.deleteRange(savedFrom, savedTo).insertText(text + ' ', savedFrom); + view.dispatch(tr); + editor.commands.focus(); + }; if (index < items.length) { const text = items[index].insertText; console.log('[tiptap-mention] inserting:', text); - editor.chain().focus() - .deleteRange({ from: savedFrom, to: savedTo }) - .insertContent(text + ' ') - .run(); + insertRaw(text); console.log('[tiptap-mention] after insert, content:', editor.storage.markdown.getMarkdown().slice(0, 100)); } else { const opt = createOptions[index - items.length]; @@ -96,10 +100,7 @@ export const TiptapMention = Extension.create({ const displayName = opt.query.replace(/_/g, ' '); const tag = opt.type === 'company' ? quoteMention('@F:', displayName) : opt.type === 'person' ? quoteMention('@', displayName) : quoteMention('@P:', displayName); console.log('[tiptap-mention] inserting after create:', tag); - editor.chain().focus() - .deleteRange({ from: savedFrom, to: savedTo }) - .insertContent(tag + ' ') - .run(); + insertRaw(tag); } } catch (err) { console.error('tiptap mention selectItem error:', err); @@ -117,7 +118,6 @@ export const TiptapMention = Extension.create({ Math.max(0, from - 50), from, undefined, '\ufffc' ); - // Scan backwards for '@' for (let i = textBefore.length - 1; i >= 0; i--) { const ch = textBefore[i]; if (ch === '@') { @@ -126,7 +126,16 @@ export const TiptapMention = Extension.create({ // Skip @P: and @F: prefixes (handled differently) if (/^[Pp]:|^[Ff]:/.test(query)) return null; const docFrom = from - (textBefore.length - i); - return { active: true, query, from: docFrom }; + return { active: true, query, from: docFrom, mode: '@' }; + } + return null; + } + // Check for -> trigger + if (ch === '>' && i > 0 && textBefore[i - 1] === '-') { + if (i - 1 === 0 || /[\s\n]/.test(textBefore[i - 2])) { + const query = textBefore.slice(i + 1).trimStart(); + const docFrom = from - (textBefore.length - (i - 1)); + return { active: true, query, from: docFrom, mode: '->' }; } return null; } @@ -143,7 +152,7 @@ export const TiptapMention = Extension.create({ } mentionFrom = mentionState.from; - const result = await fetchMentionItems(mentionState.query); + const result = await fetchMentionItems(mentionState.query, mentionState.mode); // Re-check after async: state may have changed (e.g. selectItem ran) if (!getQueryFromState(view)) { diff --git a/ka-note/client/src/lib/utils/mentionReplace.ts b/ka-note/client/src/lib/utils/mentionReplace.ts new file mode 100644 index 0000000..887afb5 --- /dev/null +++ b/ka-note/client/src/lib/utils/mentionReplace.ts @@ -0,0 +1,51 @@ +// Pure text replacement for @mention rename cascade. +// Does NOT touch -> assignments (those use abbreviations, handled separately). + +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function buildReplacement(prefix: string, name: string): string { + return name.includes(' ') ? `${prefix}"${name}"` : `${prefix}${name}`; +} + +/** Replace all @mentions / @P: / @F: of oldName with newName in text. */ +export function renameMentions( + text: string, + oldName: string, + newName: string, + type: 'person' | 'project' | 'company' +): string { + const esc = escapeRegex(oldName); + // Word boundary after name: not followed by word char or closing quote that is part of a longer name + const boundary = '(?=[^\\w"]|$)'; + + if (type === 'person') { + // @"Old Name" or @OldName (must NOT be @P: or @F:) + const quoted = new RegExp(`@(?![Pp]:|[Ff]:)"${esc}"${boundary}`, 'g'); + const bare = new RegExp(`@(?![Pp]:|[Ff]:)${esc}${boundary}`, 'g'); + text = text.replace(quoted, buildReplacement('@', newName)); + text = text.replace(bare, buildReplacement('@', newName)); + } else if (type === 'project') { + const quoted = new RegExp(`@[Pp]:"${esc}"${boundary}`, 'g'); + const bare = new RegExp(`@[Pp]:${esc}${boundary}`, 'g'); + const newVal = buildReplacement('@P:', newName); + text = text.replace(quoted, newVal); + text = text.replace(bare, newVal); + } else { + const quoted = new RegExp(`@[Ff]:"${esc}"${boundary}`, 'g'); + const bare = new RegExp(`@[Ff]:${esc}${boundary}`, 'g'); + const newVal = buildReplacement('@F:', newName); + text = text.replace(quoted, newVal); + text = text.replace(bare, newVal); + } + + return text; +} + +/** Replace -> OldAbbr with -> NewAbbr in text (exact word match). */ +export function renameAssignment(text: string, oldAbbr: string, newAbbr: string): string { + const esc = escapeRegex(oldAbbr); + const pattern = new RegExp(`(->\\s*)${esc}(?=[^\\w]|$)`, 'g'); + return text.replace(pattern, `$1${newAbbr}`); +} diff --git a/ka-note/client/src/lib/utils/renderMarkdown.ts b/ka-note/client/src/lib/utils/renderMarkdown.ts index 2082b90..965cb01 100644 --- a/ka-note/client/src/lib/utils/renderMarkdown.ts +++ b/ka-note/client/src/lib/utils/renderMarkdown.ts @@ -9,14 +9,16 @@ const assignmentExt: TokenizerExtension & RendererExtension = { level: 'inline', start(src: string) { return src.indexOf('->'); }, tokenizer(src: string) { - const match = /^->\s*([\w-]+)/.exec(src); + // Support quoted names and bare words; also handle ->NAME (no space). + // \u200B may be prepended during preprocessing to prevent list-item conflict. + const match = /^\u200B?->\s*"([^"]+)"/.exec(src) ?? /^\u200B?->\s*([\w-]+)/.exec(src); if (match) { return { type: 'assignment', raw: match[0], name: match[1] }; } }, renderer(token) { - return ' ' - + `${token.name}`; + return '' + + `${token.name}`; } }; @@ -199,8 +201,11 @@ const PURIFY_CONFIG = { export function renderMarkdown(text: string): string { if (!text) return ''; // tiptap-markdown escapes [ as \[ — restore [[WikiLinks]] before parsing - const unescaped = text.replace(/\\\[\\\[(.+?)\\\]\\\]/g, '[[$1]]'); - const raw = markedInstance.parse(unescaped) as string; + const unescaped = text.replace(/\\\[\\\[(.+?)\\\]\\\]/g, '[[$1]]').replace(/\\?-(?:>|>)/g, '->'); + // Prevent Marked from treating -> at line start as a list item + blockquote. + // Replace leading -> with a zero-width space prefix so it stays inline. + const preprocessed = unescaped.replace(/(^|\n)([ \t]*)->/g, '$1$2\u200B->'); + const raw = markedInstance.parse(preprocessed) as string; const withTables = wrapTables(raw); const withCallouts = processCallouts(withTables); const sanitized = DOMPurify.sanitize(withCallouts, PURIFY_CONFIG); diff --git a/ka-note/server/ka-note.db-shm b/ka-note/server/ka-note.db-shm index 33533b2..67d8ac5 100644 Binary files a/ka-note/server/ka-note.db-shm and b/ka-note/server/ka-note.db-shm differ diff --git a/ka-note/server/ka-note.db-wal b/ka-note/server/ka-note.db-wal index 3604182..03611e6 100644 Binary files a/ka-note/server/ka-note.db-wal and b/ka-note/server/ka-note.db-wal differ diff --git a/ka-note/shared/src/types.ts b/ka-note/shared/src/types.ts index 321182d..67daecf 100644 --- a/ka-note/shared/src/types.ts +++ b/ka-note/shared/src/types.ts @@ -31,9 +31,10 @@ export interface PersonMeta { phone: string; mobile?: string; duSince: string; - birthday?: string; // YYYY-MM-DD - joinDate?: string; // YYYY-MM-DD (employee/colleague) + birthday?: string; // YYYY-MM-DD + joinDate?: string; // YYYY-MM-DD (employee/colleague) personSubType?: PersonSubType; + abbreviation?: string; // short code, no spaces, used for -> assignments notes?: string; } diff --git a/plans/rename-cascade.md b/plans/rename-cascade.md new file mode 100644 index 0000000..d34ecd5 --- /dev/null +++ b/plans/rename-cascade.md @@ -0,0 +1,99 @@ +# Plan: Rename Cascade + Duplicate Check + +## Features +1. After renaming a person/project/company, update all @mentions in existing text +2. On rename: prevent duplicate names of the same type + +--- + +## Affected Files + +| File | Change | +|------|--------| +| `client/src/lib/utils/mentionReplace.ts` | NEW — `renameMentions(text, oldName, newName, type)` | +| `client/src/lib/db/repositories.ts` | ADD `renameMentionCascade(oldName, newName, type)` | +| `client/src/lib/components/ContextHeader.svelte` | UPDATE `saveEdit()` — duplicate check + cascade; add warning UI | + +--- + +## New File: `mentionReplace.ts` + +Pure function, no Dexie dependency. + +```typescript +export function renameMentions( + text: string, + oldName: string, + newName: string, + type: 'person' | 'project' | 'company' +): string +``` + +**Regex patterns to replace:** + +| Type | Unquoted | Quoted | +|------|----------|--------| +| person | `@OldName` | `@"Old Name"` | +| project | `@P:OldName` / `@p:OldName` | `@P:"Old Name"` | +| company | `@F:OldName` / `@f:OldName` | `@F:"Old Name"` | +| assignment (person only) | `-> OldName` | n/a (not supported by extractors) | + +**Replacement:** use `quoteMention`-logic — add quotes if newName contains spaces. + +**Boundary condition:** match must end at `[\w"]` boundary to avoid partial matches (e.g. `@Alice` must not match `@AliceBob`). + +--- + +## `renameMentionCascade` in repositories.ts + +Run in a single Dexie transaction (`rw` on historyEntries, topics, contexts, ratings). + +**Fields to scan:** + +| Table | Field | Notes | +|-------|-------|-------| +| `historyEntries` | `text` | Primary mention location | +| `topics` | `title` | Inline mentions possible | +| `contexts` | `meta.notes` | PersonMeta / ProjectMeta / CompanyMeta only | +| `ratings` | `comment` | Optional, may have @mentions | +| `ratings` | `personName` | Structured field, person rename only (exact match) | + +For each changed record: increment `version`, update `updatedAt`. + +Pattern to follow: `renameTitleInLinks()` already exists in repositories.ts (line ~541). + +--- + +## `ContextHeader.svelte` — `saveEdit()` changes + +``` +1. trimmed = nameInput.trim() +2. if trimmed === editableName() → return (no-op) +3. if type is person/project/company: + - check contextNameExists(fullName(trimmed), type) + - if exists AND id !== context.id → show duplicate warning, revert, return +4. oldName = editableName() +5. await upsertContext({ id, name: fullName(trimmed) }) +6. if type is person/project/company: + - await renameMentionCascade(oldName, trimmed, type) +``` + +Add `duplicateWarning` state + small error message below input. + +--- + +## Sequencing + +1. `mentionReplace.ts` — implement + manual regex tests +2. `repositories.ts` — add `renameMentionCascade` +3. `ContextHeader.svelte` — wire up + +--- + +## Open Questions + +1. **`-> Name` with spaces:** `extractors.ts` arrow pattern only matches `[\w]+`. If renamed from single-word to multi-word, arrow assignments break silently. Extend extractors to support `-> "First Last"`, or accept as edge case? +2. **`EventMeta.participants`:** Plain `string[]`, not mention syntax. Update on person rename? +3. **Case-only rename** (Alice → ALICE): `contextNameExists` is case-insensitive — will incorrectly block. Need to exclude self from duplicate check. +4. **Performance:** Full table scan (no text index). Acceptable for local-first small datasets — add a comment. +5. **Sync conflicts:** Cascade causes many `version++`. Last-write-wins acceptable, or need special marker?