diff --git a/Journal.App/src/lib/components/SidePanel.svelte b/Journal.App/src/lib/components/SidePanel.svelte index 124cd41..4107835 100644 --- a/Journal.App/src/lib/components/SidePanel.svelte +++ b/Journal.App/src/lib/components/SidePanel.svelte @@ -9,6 +9,7 @@ import { createFragmentDraft, fragmentsStore, serializeFragment } from "$lib/stores/fragments"; import { createListDraft, createListFromLabel, listsStore } from "$lib/stores/lists"; import { createTodoListDraft, createTodoListFromLabel, serializeTodoList, todoListsStore, todosStore } from "$lib/stores/todos"; + import { extractEntryTags } from "$lib/utils/metadata"; export let activeSection = "entries"; export let activeDocumentId = ""; @@ -234,6 +235,12 @@ return tokens.some((token) => lower.includes(token)); } + function matchesTags(itemTags: string[] | undefined, tagTokens: string[]): boolean { + if (tagTokens.length === 0) return true; + const normalized = (itemTags ?? []).map((tag) => tag.toLowerCase().trim()).filter(Boolean); + return tagTokens.some((token) => normalized.includes(token)); + } + async function refreshCalendarTimeline(): Promise { calendarBusy = true; calendarError = ""; @@ -247,11 +254,7 @@ const { startDate, endDate } = getActiveDateRange(); const entryItems = await searchEntriesAsItems({ dataDirectory, - startDate, - endDate, - query: calendarQuery.trim() || undefined, - tags: parseCsv(calendarTags), - types: parseCsv(calendarTypes) + query: calendarQuery.trim() || undefined }); const [fragmentDtos, listDtos, todoDtos] = await Promise.all([ @@ -266,24 +269,24 @@ const hasTypeFilter = typeTokens.length > 0; const fragmentItems = fragmentDtos - .map(toFragmentTimelineItem) - .filter((item) => { - const date = item.sortDate ? parseIsoDate(item.sortDate) : null; + .filter((fragment) => { + const date = parseIsoDate(fragment.time); if (!date || !isWithinRange(date, startDate, endDate)) return false; - if (!matchesTextQuery(item.initialContent, query)) return false; - if (!matchesAnyToken(`${item.label}\n${item.initialContent}`, tagTokens)) return false; - if (hasTypeFilter && !matchesAnyToken(`${item.label}\n${item.initialContent}`, typeTokens)) return false; + if (!matchesTextQuery(fragment.description, query)) return false; + if (!matchesTags(fragment.tags, tagTokens)) return false; + if (hasTypeFilter && !matchesAnyToken(fragment.type ?? "", typeTokens)) return false; return true; - }); + }) + .map(toFragmentTimelineItem); const listItems = listDtos .map(toListTimelineItem) .filter((item) => { if (hasTypeFilter) return false; + if (tagTokens.length > 0) return false; const date = item.sortDate ? parseIsoDate(item.sortDate) : null; if (!date || !isWithinRange(date, startDate, endDate)) return false; if (!matchesTextQuery(item.initialContent, query) && !matchesTextQuery(item.label, query)) return false; - if (!matchesAnyToken(`${item.label}\n${item.initialContent}`, tagTokens)) return false; return true; }); @@ -291,10 +294,10 @@ .map(toTodoTimelineItem) .filter((item) => { if (hasTypeFilter) return false; + if (tagTokens.length > 0) return false; const date = item.sortDate ? parseIsoDate(item.sortDate) : null; if (!date || !isWithinRange(date, startDate, endDate)) return false; if (!matchesTextQuery(item.initialContent, query) && !matchesTextQuery(item.label, query)) return false; - if (!matchesAnyToken(`${item.label}\n${item.initialContent}`, tagTokens)) return false; return true; }); @@ -311,7 +314,10 @@ }) .filter((item) => { if (hasTypeFilter) return false; - if (!matchesAnyToken(`${item.label}\n${item.initialContent}`, tagTokens)) return false; + if (!matchesTags(extractEntryTags(item.initialContent), tagTokens)) return false; + if (!matchesTextQuery(item.initialContent, query) && !matchesTextQuery(item.label, query)) return false; + const date = item.sortDate ? parseIsoDate(item.sortDate) : null; + if (date && !isWithinRange(date, startDate, endDate)) return false; return true; }); diff --git a/Journal.App/src/lib/components/editor/MarkdownEditor.svelte b/Journal.App/src/lib/components/editor/MarkdownEditor.svelte index 88e906f..e810df1 100644 --- a/Journal.App/src/lib/components/editor/MarkdownEditor.svelte +++ b/Journal.App/src/lib/components/editor/MarkdownEditor.svelte @@ -226,6 +226,23 @@ attachmentModalOpen = false; } + function insertTagToken() { + if (!editorInput) return; + const current = markdownText; + const start = editorInput.selectionStart ?? 0; + const end = editorInput.selectionEnd ?? start; + const selected = current.slice(start, end); + const insertion = selected ? `[[${selected}]]` : "[[]]"; + const next = `${current.slice(0, start)}${insertion}${current.slice(end)}`; + updateDraft(next); + queueMicrotask(() => { + if (!editorInput) return; + const cursor = selected ? start + insertion.length : start + 2; + editorInput.focus(); + editorInput.setSelectionRange(cursor, cursor); + }); + } + function resolveJournalLinkTarget(targetId: string): { id: string; label: string; initialContent: string } | null { if (!targetId) return null; @@ -442,6 +459,7 @@ onBold={() => applyWrap("**")} onItalic={() => applyWrap("*")} onUnderline={() => applyWrap("++")} + onTag={insertTagToken} onLink={insertLink} onToggleUl={() => toggleListMode("ul")} onToggleOl={() => toggleListMode("ol")} diff --git a/Journal.App/src/lib/components/editor/MarkdownToolbar.svelte b/Journal.App/src/lib/components/editor/MarkdownToolbar.svelte index 9e5b515..93afed7 100644 --- a/Journal.App/src/lib/components/editor/MarkdownToolbar.svelte +++ b/Journal.App/src/lib/components/editor/MarkdownToolbar.svelte @@ -14,6 +14,7 @@ export let onBold: () => void = () => {}; export let onItalic: () => void = () => {}; export let onUnderline: () => void = () => {}; + export let onTag: () => void = () => {}; export let onLink: () => void = () => {}; export let onToggleUl: () => void = () => {}; export let onToggleOl: () => void = () => {}; @@ -142,6 +143,9 @@ + diff --git a/Journal.App/src/lib/utils/metadata.ts b/Journal.App/src/lib/utils/metadata.ts new file mode 100644 index 0000000..f77e330 --- /dev/null +++ b/Journal.App/src/lib/utils/metadata.ts @@ -0,0 +1,153 @@ +function normalizeTags(tags: string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const tag of tags) { + const normalized = tag.trim(); + if (!normalized) continue; + const key = normalized.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + result.push(normalized); + } + return result; +} + +function splitFrontmatter(content: string): { frontmatter: string | null; body: string } { + const normalized = (content ?? "").replace(/\r\n/g, "\n"); + if (!normalized.startsWith("---\n")) { + return { frontmatter: null, body: normalized }; + } + + const closingIndex = normalized.indexOf("\n---", 4); + if (closingIndex === -1) { + return { frontmatter: null, body: normalized }; + } + + const frontmatter = normalized.slice(4, closingIndex).trim(); + const bodyStart = closingIndex + "\n---".length; + const body = normalized.slice(bodyStart).replace(/^\n/, ""); + return { frontmatter, body }; +} + +function parseTagsValue(rawValue: string): string[] { + const value = rawValue.trim(); + if (!value) return []; + + if (value.startsWith("[") && value.endsWith("]")) { + return normalizeTags( + value + .slice(1, -1) + .split(",") + .map((token) => token.trim().replace(/^["']|["']$/g, "")) + ); + } + + return normalizeTags( + value + .split(",") + .map((token) => token.trim().replace(/^["']|["']$/g, "")) + ); +} + +function formatTagsValue(tags: string[]): string { + const normalized = normalizeTags(tags); + if (!normalized.length) return ""; + return `[${normalized.join(", ")}]`; +} + +export function parseTagsFromMarkdown(content: string): string[] { + const { frontmatter } = splitFrontmatter(content); + if (!frontmatter) return []; + + const line = frontmatter.split("\n").find((entry) => /^\s*tags\s*:/i.test(entry)); + if (!line) return []; + const value = line.replace(/^\s*tags\s*:/i, ""); + return parseTagsValue(value); +} + +export function stripFrontmatter(content: string): string { + return splitFrontmatter(content).body; +} + +export function setTagsInMarkdown(content: string, tags: string[]): string { + const normalizedBody = (content ?? "").replace(/\r\n/g, "\n"); + const normalizedTags = normalizeTags(tags); + const tagsLine = normalizedTags.length > 0 ? `tags: ${formatTagsValue(normalizedTags)}` : ""; + const { frontmatter, body } = splitFrontmatter(normalizedBody); + + if (!frontmatter) { + if (!tagsLine) return normalizedBody; + const trimmedBody = body.replace(/^\n+/, ""); + return `---\n${tagsLine}\n---\n\n${trimmedBody}`; + } + + const lines = frontmatter + .split("\n") + .map((line) => line.trimEnd()) + .filter((line) => !/^\s*tags\s*:/i.test(line)); + + if (tagsLine) { + lines.unshift(tagsLine); + } + + if (lines.length === 0) { + return body; + } + + return `---\n${lines.join("\n")}\n---\n\n${body}`; +} + +export function extractBracketTags(content: string): string[] { + const normalized = (content ?? "").replace(/\r\n/g, "\n"); + const matches = normalized.matchAll(/\[\[([^\]]+)\]\]/g); + const tokens: string[] = []; + for (const match of matches) { + const raw = (match[1] ?? "").trim(); + if (!raw) continue; + tokens.push( + ...raw + .split(",") + .map((token) => token.trim()) + .filter(Boolean) + ); + } + return normalizeTags(tokens); +} + +export function extractTagsFromTagsSection(content: string): string[] { + const normalized = (content ?? "").replace(/\r\n/g, "\n"); + const lines = normalized.split("\n"); + const collected: string[] = []; + + let inTagsSection = false; + for (const rawLine of lines) { + const line = rawLine.trim(); + if (!inTagsSection) { + if (/^#{1,6}\s+tags\s*$/i.test(line)) { + inTagsSection = true; + } + continue; + } + + if (!line) break; + if (/^#{1,6}\s+/.test(line)) break; + + const cleaned = line.replace(/^[-*+]\s+/, ""); + collected.push( + ...cleaned + .split(",") + .map((token) => token.trim()) + .filter(Boolean) + ); + } + + return normalizeTags(collected); +} + +export function extractEntryTags(content: string): string[] { + return normalizeTags([ + ...parseTagsFromMarkdown(content), + ...extractBracketTags(content), + ...extractTagsFromTagsSection(content) + ]); +}