Refine calendar tag filtering and markdown tag token UX

This commit is contained in:
Jacob Schmidt 2026-02-28 00:54:25 -06:00
parent 96fcaeec6d
commit 8f67269f44
4 changed files with 196 additions and 15 deletions

View File

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

View File

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

View File

@ -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 @@
<button type="button" class="toolbar-btn toolbar-icon-btn" on:click={onUnderline} aria-label="Underline" title="Underline">
<span class="material-symbols-outlined" aria-hidden="true">format_underlined</span>
</button>
<button type="button" class="toolbar-btn toolbar-icon-btn" on:click={onTag} aria-label="Tag" title="Tag [[...]]">
<span class="material-symbols-outlined" aria-hidden="true">sell</span>
</button>
<button type="button" class="toolbar-btn toolbar-icon-btn" on:click={onLink} aria-label="Link" title="Link">
<span class="material-symbols-outlined" aria-hidden="true">link</span>
</button>

View File

@ -0,0 +1,153 @@
function normalizeTags(tags: string[]): string[] {
const seen = new Set<string>();
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)
]);
}