Refine calendar tag filtering and markdown tag token UX
This commit is contained in:
parent
96fcaeec6d
commit
8f67269f44
@ -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;
|
||||
});
|
||||
|
||||
|
||||
@ -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")}
|
||||
|
||||
@ -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>
|
||||
|
||||
153
Journal.App/src/lib/utils/metadata.ts
Normal file
153
Journal.App/src/lib/utils/metadata.ts
Normal 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)
|
||||
]);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user