Compare commits
2 Commits
6e35822708
...
46b63b56e8
| Author | SHA1 | Date |
|---|---|---|
|
|
46b63b56e8 | |
|
|
d14669bdcb |
|
|
@ -1 +1 @@
|
|||
1.1.105
|
||||
1.1.108
|
||||
|
|
@ -28,7 +28,7 @@ export function mention(node: HTMLInputElement | HTMLTextAreaElement) {
|
|||
for (let i = pos - 1; i >= 0; i--) {
|
||||
const ch = text[i];
|
||||
if (ch === '@') {
|
||||
if (i === 0 || /\s/.test(text[i - 1])) {
|
||||
if (i === 0 || /[\s\n]/.test(text[i - 1])) {
|
||||
mentionStart = i;
|
||||
mentionMode = '@';
|
||||
return { query: text.slice(i + 1, pos), mode: '@' };
|
||||
|
|
@ -45,10 +45,9 @@ export function mention(node: HTMLInputElement | HTMLTextAreaElement) {
|
|||
}
|
||||
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;
|
||||
// For ->, a single space between trigger and query is valid — skip it.
|
||||
if (ch === ' ' && i > 1 && text[i - 1] === '>' && text[i - 2] === '-') continue;
|
||||
if (/[\s\n]/.test(ch)) return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
import CompanyPersonsView from './CompanyPersonsView.svelte';
|
||||
import RatingModal from './RatingModal.svelte';
|
||||
import RatingsView from './RatingsView.svelte';
|
||||
import PersonMeetingsView from './PersonMeetingsView.svelte';
|
||||
|
||||
interface Props {
|
||||
contextId: string;
|
||||
|
|
@ -118,6 +119,8 @@
|
|||
<CompanyPersonsView context={$context} />
|
||||
{:else if activeView === 'ratings'}
|
||||
<RatingsView personName={$context.name.replace(/^Person /, '')} />
|
||||
{:else if activeView === 'person-meetings'}
|
||||
<PersonMeetingsView context={$context} />
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="text-muted">Context not found.</div>
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@
|
|||
|
||||
// Metadata collapsed by default
|
||||
let metaOpen = $state(false);
|
||||
let abbrError = $state('');
|
||||
|
||||
// --- Notes ---
|
||||
let noteTitle = $state('');
|
||||
|
|
@ -424,13 +425,25 @@
|
|||
<label class="mb-1 text-sm text-[#aaa]">Kürzel <span class="text-[#666] font-normal">(für -> 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);
|
||||
placeholder="z.B. CHfi"
|
||||
oninput={(e) => { e.currentTarget.value = e.currentTarget.value.replace(/\s/g, ''); }}
|
||||
onchange={async (e) => {
|
||||
const v = e.currentTarget.value.replace(/\s/g, '');
|
||||
if (!v) { updateMeta('abbreviation', undefined as any); return; }
|
||||
const conflict = await db.contexts
|
||||
.filter(c => !c.deletedAt && c.type === 'person' && c.id !== context.id
|
||||
&& (c.meta as PersonMeta | null)?.abbreviation?.toLowerCase() === v.toLowerCase())
|
||||
.first();
|
||||
if (conflict) {
|
||||
abbrError = `Kürzel bereits vergeben (${conflict.name.replace(/^Person\s+/, '')})`;
|
||||
e.currentTarget.value = meta.abbreviation ?? '';
|
||||
} else {
|
||||
abbrError = '';
|
||||
updateMeta('abbreviation', v);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{#if abbrError}<span class="text-xs text-red-400 mt-0.5">{abbrError}</span>{/if}
|
||||
</div>
|
||||
<div class="mb-2.5 flex flex-col">
|
||||
<label class="mb-1 text-sm text-[#aaa]">Notizen:</label>
|
||||
|
|
@ -507,30 +520,26 @@
|
|||
{@const lines = entry.text.split('\n')}
|
||||
{@const title = lines[0]}
|
||||
{@const body = lines.slice(1).join('\n').trim()}
|
||||
{@const isDone = !!entry.doneAt}
|
||||
<div class="group mb-3 flex items-start gap-2 rounded bg-card-bg p-2.5">
|
||||
<button
|
||||
class="mt-1 flex h-4 w-4 shrink-0 items-center justify-center rounded border text-xs {isDone ? 'border-green-500 bg-green-500/20 text-green-400' : 'border-[#555] text-transparent hover:border-[#888]'}"
|
||||
onclick={() => toggleHistoryEntryDone(entry.id)}
|
||||
title={isDone ? 'Als offen markieren' : 'Als erledigt markieren'}
|
||||
>{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"><RenderedMarkdown text={title} /></div>
|
||||
{#if body}
|
||||
<RenderedMarkdown text={body} class="mt-1 text-sm text-[#ccc]" />
|
||||
{/if}
|
||||
<div class="group mb-3 rounded bg-card-bg p-2.5">
|
||||
<div class="mb-1 flex items-center justify-between">
|
||||
<span class="text-xs text-muted">{entry.date} {formatTime(entry.updatedAt)}</span>
|
||||
<div class="flex gap-2 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<button
|
||||
class="text-xs text-[#666] hover:text-[#ccc]"
|
||||
onclick={() => startEdit(entry)}
|
||||
title="Bearbeiten"
|
||||
>✎</button>
|
||||
<button
|
||||
class="text-xs text-[#666] hover:text-red-400"
|
||||
onclick={() => handleDeleteNote(entry.id)}
|
||||
title="Löschen"
|
||||
>×</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="mt-0.5 text-xs text-[#666] opacity-0 transition-opacity group-hover:opacity-100 hover:text-[#ccc]"
|
||||
onclick={() => startEdit(entry)}
|
||||
title="Bearbeiten"
|
||||
>✎</button>
|
||||
<button
|
||||
class="mt-0.5 text-xs text-[#666] opacity-0 transition-opacity group-hover:opacity-100 hover:text-red-400"
|
||||
onclick={() => handleDeleteNote(entry.id)}
|
||||
title="Löschen"
|
||||
>×</button>
|
||||
<div class="font-bold"><RenderedMarkdown text={title} /></div>
|
||||
{#if body}
|
||||
<RenderedMarkdown text={body} class="mt-1 text-sm text-[#ccc]" />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,142 @@
|
|||
<script lang="ts">
|
||||
import { liveQuery } from 'dexie';
|
||||
import { db } from '$lib/db/schema';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { AgendaContext, EventMeta } from '@ka-note/shared';
|
||||
import { extractMentions, extractAssignments } from '$lib/utils/extractors';
|
||||
import { JOURNAL_TOPIC_ID } from '$lib/db/repositories';
|
||||
|
||||
interface Props {
|
||||
context: AgendaContext;
|
||||
}
|
||||
let { context }: Props = $props();
|
||||
|
||||
const RANGES = [
|
||||
{ label: '30 Tage', days: 30 },
|
||||
{ label: '90 Tage', days: 90 },
|
||||
{ label: '1 Jahr', days: 365 },
|
||||
{ label: 'Alles', days: 0 },
|
||||
];
|
||||
let rangeIdx = $state(1);
|
||||
|
||||
function cutoffDate(days: number): string {
|
||||
if (!days) return '';
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - days);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function matchesName(name: string, personName: string): boolean {
|
||||
return name.trim().toLowerCase() === personName.trim().toLowerCase();
|
||||
}
|
||||
|
||||
interface ActivityItem {
|
||||
date: string;
|
||||
time?: string;
|
||||
title: string;
|
||||
snippet?: string;
|
||||
/** navigate to this context */
|
||||
contextId: string;
|
||||
/** if set, open journal tab at this date */
|
||||
journalDate?: string;
|
||||
type: 'event' | 'mention';
|
||||
}
|
||||
|
||||
const activity = liveQuery(async () => {
|
||||
const personName = context.name.replace(/^Person /, '');
|
||||
const items: ActivityItem[] = [];
|
||||
|
||||
// 1. Events where person is participant
|
||||
const events = await db.contexts
|
||||
.filter(c => !c.deletedAt && c.type === 'event')
|
||||
.toArray();
|
||||
for (const ev of events) {
|
||||
const meta = ev.meta as EventMeta | null;
|
||||
if (!meta?.participants?.length) continue;
|
||||
if (!meta.participants.some(p => matchesName(p, personName))) continue;
|
||||
const targetContextId = meta.linkedContextId ?? 'daily-log';
|
||||
items.push({
|
||||
date: meta.date,
|
||||
time: meta.time,
|
||||
title: ev.name,
|
||||
contextId: targetContextId,
|
||||
journalDate: meta.date,
|
||||
type: 'event',
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Journal history entries mentioning person (@mention or -> assignment)
|
||||
const journalTopic = await db.topics.get(JOURNAL_TOPIC_ID);
|
||||
if (journalTopic) {
|
||||
const entries = await db.historyEntries
|
||||
.where('topicId').equals(JOURNAL_TOPIC_ID)
|
||||
.filter(h => !h.deletedAt)
|
||||
.toArray();
|
||||
for (const h of entries) {
|
||||
const names = new Set([...extractMentions(h.text), ...extractAssignments(h.text)]);
|
||||
if (!names.has(personName)) continue;
|
||||
// Show first non-empty line as snippet
|
||||
const snippet = h.text.split('\n').find(l => l.trim()) ?? '';
|
||||
items.push({
|
||||
date: h.date,
|
||||
title: 'Journal',
|
||||
snippet,
|
||||
contextId: 'daily-log',
|
||||
journalDate: h.date,
|
||||
type: 'mention',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items.sort((a, b) => b.date.localeCompare(a.date) || (b.time ?? '').localeCompare(a.time ?? ''));
|
||||
});
|
||||
|
||||
const filtered = $derived(() => {
|
||||
const cutoff = cutoffDate(RANGES[rangeIdx].days);
|
||||
return ($activity ?? []).filter(item => !cutoff || item.date >= cutoff);
|
||||
});
|
||||
|
||||
function navigate(item: ActivityItem) {
|
||||
if (item.journalDate) {
|
||||
goto(`/context/${item.contextId}?date=${item.journalDate}`);
|
||||
} else {
|
||||
goto(`/context/${item.contextId}`);
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
const [y, m, d] = iso.split('-');
|
||||
return `${d}.${m}.${y}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mb-4 flex gap-2">
|
||||
{#each RANGES as r, i}
|
||||
<button
|
||||
class="rounded px-3 py-1 text-sm transition-colors {rangeIdx === i
|
||||
? 'bg-accent text-white'
|
||||
: 'bg-[#2a2a2a] text-[#888] hover:text-[#bbb]'}"
|
||||
onclick={() => rangeIdx = i}
|
||||
>{r.label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#each filtered() as item (item.contextId + item.date + (item.time ?? ''))}
|
||||
<button
|
||||
class="mb-2 w-full rounded-lg border-l-[5px] bg-card-bg p-3 text-left transition-colors hover:bg-[#2a2a2a] {item.type === 'event' ? 'border-l-accent' : 'border-l-info'}"
|
||||
onclick={() => navigate(item)}
|
||||
>
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-xs text-[#666]">{formatDate(item.date)}</span>
|
||||
{#if item.time}
|
||||
<span class="text-xs text-[#555]">{item.time}</span>
|
||||
{/if}
|
||||
<span class="text-sm font-medium text-[#ddd]">{item.title}</span>
|
||||
</div>
|
||||
{#if item.snippet}
|
||||
<div class="mt-0.5 truncate text-xs text-[#777]">{item.snippet}</div>
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<div class="text-center text-muted">Keine Aktivität im gewählten Zeitraum.</div>
|
||||
{/each}
|
||||
|
|
@ -38,7 +38,8 @@
|
|||
function markUnknown(chip: HTMLElement, prefix: string) {
|
||||
// Replace the chip (and its preceding arrow span) with plain text
|
||||
const arrow = chip.previousElementSibling;
|
||||
const text = document.createTextNode(`${prefix}${chip.dataset.assignment ?? chip.dataset.person ?? ''}`);
|
||||
const name = chip.dataset.assignment ?? chip.dataset.person ?? '';
|
||||
const text = document.createTextNode(`${prefix}${name}`);
|
||||
chip.parentNode?.insertBefore(text, chip);
|
||||
chip.remove();
|
||||
if (arrow && /→|→/.test(arrow.textContent ?? '')) arrow.remove();
|
||||
|
|
@ -49,7 +50,11 @@
|
|||
await Promise.all([
|
||||
...Array.from(assignmentChips).map(async chip => {
|
||||
const ctx = await findContextByAbbreviation(chip.dataset.assignment!);
|
||||
if (!ctx) { markUnknown(chip, '-> '); return; }
|
||||
if (!ctx) {
|
||||
const showArrow = chip.dataset.showArrow === '1';
|
||||
markUnknown(chip, showArrow ? '=> ' : '-> ');
|
||||
return;
|
||||
}
|
||||
const subType = (ctx.meta as Record<string, unknown> | null)?.personSubType as PersonSubType | undefined;
|
||||
applySubtypeColor(chip, subType);
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -54,6 +54,9 @@
|
|||
...(isEmployee
|
||||
? [{ id: "ratings", label: "Bewertungen" }]
|
||||
: []),
|
||||
...(isPerson
|
||||
? [{ id: "person-meetings", label: "Meetings" }]
|
||||
: []),
|
||||
],
|
||||
);
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
export function extractAssignments(text: string): string[] {
|
||||
const regex = /->\s*([\w]+)/g;
|
||||
const regex = /(?:->|=>)\s*([\w]+)/g;
|
||||
const result: string[] = [];
|
||||
let match;
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
|
|
|
|||
|
|
@ -43,9 +43,9 @@ export function renameMentions(
|
|||
return text;
|
||||
}
|
||||
|
||||
/** Replace -> OldAbbr with -> NewAbbr in text (exact word match). */
|
||||
/** Replace -> OldAbbr / => OldAbbr with -> NewAbbr / => NewAbbr in text (case-insensitive word match). */
|
||||
export function renameAssignment(text: string, oldAbbr: string, newAbbr: string): string {
|
||||
const esc = escapeRegex(oldAbbr);
|
||||
const pattern = new RegExp(`(->\\s*)${esc}(?=[^\\w]|$)`, 'g');
|
||||
const pattern = new RegExp(`((?:->|=>)\\s*)${esc}(?=[^\\w]|$)`, 'gi');
|
||||
return text.replace(pattern, `$1${newAbbr}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,23 @@ import hljs from 'highlight.js';
|
|||
|
||||
// --- Custom inline extensions for Ka-Note tags ---
|
||||
|
||||
const assignmentWithArrowExt: TokenizerExtension & RendererExtension = {
|
||||
name: 'assignmentWithArrow',
|
||||
level: 'inline',
|
||||
start(src: string) { return src.indexOf('=>'); },
|
||||
tokenizer(src: string) {
|
||||
// \u200B may be prepended during preprocessing.
|
||||
const match = /^\u200B?=>\s*"([^"]+)"/.exec(src) ?? /^\u200B?=>\s*([\w-]+)/.exec(src);
|
||||
if (match) {
|
||||
return { type: 'assignmentWithArrow', raw: match[0], name: match[1] };
|
||||
}
|
||||
},
|
||||
renderer(token) {
|
||||
return '<span class="text-warning font-bold">→</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}" data-show-arrow="1">${token.name}</span>`;
|
||||
}
|
||||
};
|
||||
|
||||
const assignmentExt: TokenizerExtension & RendererExtension = {
|
||||
name: 'assignment',
|
||||
level: 'inline',
|
||||
|
|
@ -17,8 +34,7 @@ const assignmentExt: TokenizerExtension & RendererExtension = {
|
|||
}
|
||||
},
|
||||
renderer(token) {
|
||||
return '<span class="text-warning font-bold">→</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>`;
|
||||
return `<span class="inline-block 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>`;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -108,7 +124,7 @@ function truncateLinkUrl(url: string): string {
|
|||
const markedInstance = new Marked({
|
||||
gfm: true,
|
||||
breaks: true,
|
||||
extensions: [wikiLinkExt, assignmentExt, projectRefExt, companyRefExt, personMentionExt],
|
||||
extensions: [wikiLinkExt, assignmentWithArrowExt, assignmentExt, projectRefExt, companyRefExt, personMentionExt],
|
||||
renderer: {
|
||||
link({ href, title, text }) {
|
||||
if (!href) return text;
|
||||
|
|
@ -189,6 +205,7 @@ const PURIFY_CONFIG = {
|
|||
ALLOWED_ATTR: [
|
||||
'href', 'target', 'rel', 'src', 'alt', 'title', 'width', 'height',
|
||||
'class', 'style', 'data-person', 'data-project', 'data-company', 'data-wiki-page',
|
||||
'data-assignment', 'data-show-arrow',
|
||||
'type', 'checked', 'disabled' // for GFM task lists
|
||||
],
|
||||
ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|blob):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i
|
||||
|
|
@ -202,9 +219,11 @@ export function renderMarkdown(text: string): string {
|
|||
if (!text) return '';
|
||||
// tiptap-markdown escapes [ as \[ — restore [[WikiLinks]] before parsing
|
||||
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->');
|
||||
// Prevent Marked from treating -> / => at line start as a list item + blockquote.
|
||||
// Replace leading -> / => with a zero-width space prefix so they stay inline.
|
||||
const preprocessed = unescaped
|
||||
.replace(/(^|\n)([ \t]*)->/g, '$1$2\u200B->')
|
||||
.replace(/(^|\n)([ \t]*)=>/g, '$1$2\u200B=>');
|
||||
const raw = markedInstance.parse(preprocessed) as string;
|
||||
const withTables = wrapTables(raw);
|
||||
const withCallouts = processCallouts(withTables);
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Loading…
Reference in New Issue