added chip for arrow links und abbrivations

This commit is contained in:
beo3000 2026-02-28 16:34:03 +01:00
parent d26dacf7e8
commit 828631467c
16 changed files with 415 additions and 59 deletions

View File

@ -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;
}

View File

@ -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))

View File

@ -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<HTMLElement>('[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<string, unknown> | null)?.personSubType === 'employee';
const ratingCtx = isEmployee ? findRatingContext(assignmentEl) : null;
showPersonPopup(name, ctx.id, ratingCtx, e.clientX, e.clientY, assignmentEl);
}
});
return;
}
const personEl = target.closest<HTMLElement>('[data-person]');
if (personEl) {
e.preventDefault();

View File

@ -1,6 +1,6 @@
<script lang="ts">
import type { AgendaContext } from '@ka-note/shared';
import { upsertContext } from '$lib/db/repositories';
import type { AgendaContext, PersonMeta } from '@ka-note/shared';
import { upsertContext, contextNameExists, renameMentionCascade } from '$lib/db/repositories';
import { scopeSettings } from '$lib/stores/scopeContext';
interface Props {
@ -17,6 +17,7 @@
const canRename = $derived(context.id !== 'daily-log');
let editing = $state(false);
let nameInput = $state('');
let duplicateWarning = $state(false);
function editableName(): string {
return context.name.replace(/^(Person |Project |Firma )/, '');
@ -31,6 +32,7 @@
function startEdit() {
nameInput = editableName();
duplicateWarning = false;
editing = true;
}
@ -38,7 +40,25 @@
editing = false;
const trimmed = nameInput.trim();
if (!trimmed || trimmed === editableName()) return;
await upsertContext({ id: context.id, name: fullName(trimmed) });
const isMentionable = context.type === 'person' || context.type === 'project' || context.type === 'company';
if (isMentionable) {
const exists = await contextNameExists(fullName(trimmed), context.type, context.id);
if (exists) {
duplicateWarning = true;
nameInput = editableName();
return;
}
}
const oldName = editableName();
const newName = trimmed;
const oldAbbr = context.type === 'person' ? (context.meta as PersonMeta | null)?.abbreviation : undefined;
await upsertContext({ id: context.id, name: fullName(newName) });
if (isMentionable) {
await renameMentionCascade(oldName, newName, context.type, oldAbbr, oldAbbr);
}
}
function onKeydown(e: KeyboardEvent) {
@ -59,14 +79,19 @@
<h1 class="flex items-center justify-between border-b-2 pb-2.5 mb-5" style="border-color: {headerColor}">
{#if editing}
<input
class="text-2xl font-bold bg-transparent border-b-2 text-inherit outline-none w-full mr-4"
style="border-color: {headerColor}"
bind:value={nameInput}
onkeydown={onKeydown}
onblur={saveEdit}
autofocus
/>
<div class="flex flex-col flex-1 mr-4">
<input
class="text-2xl font-bold bg-transparent border-b-2 text-inherit outline-none w-full"
style="border-color: {headerColor}"
bind:value={nameInput}
onkeydown={onKeydown}
onblur={saveEdit}
autofocus
/>
{#if duplicateWarning}
<span class="text-xs text-red-400 mt-0.5">Name bereits vergeben.</span>
{/if}
</div>
{:else}
<span class="text-2xl font-bold">{context.name}</span>
{#if canRename}

View File

@ -420,6 +420,18 @@
</div>
{/if}
</div>
<div class="mb-2.5 flex flex-col">
<label class="mb-1 text-sm text-[#aaa]">Kürzel <span class="text-[#666] font-normal">(für -&gt; Zuweisungen)</span>:</label>
<input class="rounded border border-[#555] bg-[#111] px-2.5 py-1.5 text-[#ddd] font-mono"
value={meta.abbreviation ?? ''}
placeholder="z.B. CHFI"
oninput={(e) => { e.currentTarget.value = e.currentTarget.value.replace(/\s/g, '').toUpperCase(); }}
onchange={(e) => {
const v = e.currentTarget.value.replace(/\s/g, '').toUpperCase();
updateMeta('abbreviation', v || undefined);
}}
/>
</div>
<div class="mb-2.5 flex flex-col">
<label class="mb-1 text-sm text-[#aaa]">Notizen:</label>
<EditableMarkdown
@ -504,7 +516,7 @@
>{isDone ? '✓' : ''}</button>
<span class="mt-0.5 text-xs text-muted whitespace-nowrap">{entry.date} {formatTime(entry.updatedAt)}</span>
<div class="flex-1 {isDone ? 'line-through opacity-50' : ''}">
<div class="font-bold"><LinkTitle text={title} /></div>
<div class="font-bold"><RenderedMarkdown text={title} /></div>
{#if body}
<RenderedMarkdown text={body} class="mt-1 text-sm text-[#ccc]" />
{/if}

View File

@ -223,7 +223,7 @@
let calendarEvents = $state<CalendarEvent[]>([]);
// Unknown-persons dialog (shown after calendar import)
interface UnknownAttendee { name: string; email: string; selected: boolean; }
interface UnknownAttendee { name: string; editedName: string; email: string; selected: boolean; }
let unknownDialogOpen = $state(false);
let unknownAttendees = $state<UnknownAttendee[]>([]);
@ -264,7 +264,7 @@
} else {
const cleaned = att.name.replace(/\s*\(.*?\)\s*$/, '').trim();
mentions.push(quoteMention('@', cleaned));
if (att.email) unknowns.push({ name: cleaned, email: att.email, selected: true });
if (att.email) unknowns.push({ name: cleaned, editedName: cleaned, email: att.email, selected: true });
}
}
@ -278,13 +278,14 @@
async function confirmUnknownPersons() {
for (const u of unknownAttendees.filter(u => u.selected)) {
const slug = u.name.toLowerCase().replace(/\s+/g, '-');
const finalName = u.editedName.trim() || u.name;
const slug = finalName.toLowerCase().replace(/\s+/g, '-');
await upsertContext({
id: `u-${slug}`,
name: `Person ${u.name}`,
name: `Person ${finalName}`,
type: 'person',
sortOrder: 99,
meta: { fullName: u.name, email: u.email, phone: '', duSince: '' } satisfies PersonMeta,
meta: { fullName: finalName, email: u.email, phone: '', duSince: '' } satisfies PersonMeta,
});
}
unknownDialogOpen = false;
@ -504,11 +505,15 @@
<p class="mb-2 text-sm text-[#ccc]">Unbekannte Teilnehmer anlegen?</p>
<div class="mb-3 flex flex-col gap-1.5">
{#each unknownAttendees as u}
<label class="flex cursor-pointer items-center gap-2 text-sm">
<input type="checkbox" bind:checked={u.selected} class="accent-accent" />
<span class="text-white">{u.name}</span>
<span class="text-xs text-muted">{u.email}</span>
</label>
<div class="flex items-center gap-2 text-sm">
<input type="checkbox" bind:checked={u.selected} class="accent-accent cursor-pointer" />
<input
type="text"
bind:value={u.editedName}
class="flex-1 rounded border border-[#555] bg-[#2a2a2a] px-2 py-0.5 text-white focus:border-accent focus:outline-none"
/>
<span class="shrink-0 text-xs text-muted">{u.email}</span>
</div>
{/each}
</div>
<div class="flex gap-2">

View File

@ -6,16 +6,18 @@
class?: string;
}
let { text, class: className = '' }: Props = $props();
// tiptap-markdown may serialize -> as \-> (backslash-escaped); normalize for display.
const normalized = $derived(text.replace(/\\?-(?:&gt;|>)/g, '->'));
</script>
{#if isUrl(text)}
{#if isUrl(normalized)}
<a
href={text}
href={normalized}
target="_blank"
rel="noopener noreferrer"
class="text-blue-400 hover:underline {className}"
onclick={(e) => e.stopPropagation()}
>{truncateUrlDisplay(text)}</a>
>{truncateUrlDisplay(normalized)}</a>
{:else}
<span class={className}>{text}</span>
<span class={className}>{normalized}</span>
{/if}

View File

@ -40,7 +40,9 @@
export function getMarkdown(): string {
if (!editor) return '';
const raw = editor.storage.markdown.getMarkdown();
// tiptap-markdown serializer escapes > as &gt; and also backslash-escapes ->;
// normalize both forms back to ->.
const raw = editor.storage.markdown.getMarkdown().replace(/\\?-(?:&gt;|>)/g, '->');
return objectUrlToKaImg(raw);
}

View File

@ -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<boolean> {
export async function contextNameExists(name: string, type: ContextType, excludeId?: string): Promise<boolean> {
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<boolean> {
return count > 0;
}
export async function findContextByAbbreviation(abbr: string): Promise<AgendaContext | undefined> {
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<AgendaContext | undefined> {
const q = name.toLowerCase();
return db.contexts
@ -538,6 +546,84 @@ export async function softDeletePage(id: string): Promise<void> {
}
}
export async function renameMentionCascade(
oldName: string,
newName: string,
type: 'person' | 'project' | 'company',
oldAbbr?: string,
newAbbr?: string
): Promise<void> {
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<void> {
const pattern = `[[${oldTitle}]]`;
const replacement = `[[${newTitle}]]`;

View File

@ -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)) {

View File

@ -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}`);
}

View File

@ -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 '<span class="text-warning font-bold">&rarr;</span> '
+ `<span class="inline-block ml-1 rounded bg-tag-bg px-1.5 py-0.5 text-sm font-bold text-white border border-[#6272a4]">${token.name}</span>`;
return '<span class="text-warning font-bold">&rarr;</span>'
+ `<span class="inline-block ml-1 rounded bg-tag-bg px-1.5 py-0.5 text-sm font-bold text-white border border-[#6272a4] cursor-pointer hover:border-white" data-assignment="${token.name}">${token.name}</span>`;
}
};
@ -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(/\\?-(?:&gt;|>)/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);

Binary file not shown.

Binary file not shown.

View File

@ -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;
}

99
plans/rename-cascade.md Normal file
View File

@ -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?