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 { createFragmentDraft, fragmentsStore, serializeFragment } from "$lib/stores/fragments";
|
||||||
import { createListDraft, createListFromLabel, listsStore } from "$lib/stores/lists";
|
import { createListDraft, createListFromLabel, listsStore } from "$lib/stores/lists";
|
||||||
import { createTodoListDraft, createTodoListFromLabel, serializeTodoList, todoListsStore, todosStore } from "$lib/stores/todos";
|
import { createTodoListDraft, createTodoListFromLabel, serializeTodoList, todoListsStore, todosStore } from "$lib/stores/todos";
|
||||||
|
import { extractEntryTags } from "$lib/utils/metadata";
|
||||||
|
|
||||||
export let activeSection = "entries";
|
export let activeSection = "entries";
|
||||||
export let activeDocumentId = "";
|
export let activeDocumentId = "";
|
||||||
@ -234,6 +235,12 @@
|
|||||||
return tokens.some((token) => lower.includes(token));
|
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> {
|
async function refreshCalendarTimeline(): Promise<void> {
|
||||||
calendarBusy = true;
|
calendarBusy = true;
|
||||||
calendarError = "";
|
calendarError = "";
|
||||||
@ -247,11 +254,7 @@
|
|||||||
const { startDate, endDate } = getActiveDateRange();
|
const { startDate, endDate } = getActiveDateRange();
|
||||||
const entryItems = await searchEntriesAsItems({
|
const entryItems = await searchEntriesAsItems({
|
||||||
dataDirectory,
|
dataDirectory,
|
||||||
startDate,
|
query: calendarQuery.trim() || undefined
|
||||||
endDate,
|
|
||||||
query: calendarQuery.trim() || undefined,
|
|
||||||
tags: parseCsv(calendarTags),
|
|
||||||
types: parseCsv(calendarTypes)
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const [fragmentDtos, listDtos, todoDtos] = await Promise.all([
|
const [fragmentDtos, listDtos, todoDtos] = await Promise.all([
|
||||||
@ -266,24 +269,24 @@
|
|||||||
const hasTypeFilter = typeTokens.length > 0;
|
const hasTypeFilter = typeTokens.length > 0;
|
||||||
|
|
||||||
const fragmentItems = fragmentDtos
|
const fragmentItems = fragmentDtos
|
||||||
.map(toFragmentTimelineItem)
|
.filter((fragment) => {
|
||||||
.filter((item) => {
|
const date = parseIsoDate(fragment.time);
|
||||||
const date = item.sortDate ? parseIsoDate(item.sortDate) : null;
|
|
||||||
if (!date || !isWithinRange(date, startDate, endDate)) return false;
|
if (!date || !isWithinRange(date, startDate, endDate)) return false;
|
||||||
if (!matchesTextQuery(item.initialContent, query)) return false;
|
if (!matchesTextQuery(fragment.description, query)) return false;
|
||||||
if (!matchesAnyToken(`${item.label}\n${item.initialContent}`, tagTokens)) return false;
|
if (!matchesTags(fragment.tags, tagTokens)) return false;
|
||||||
if (hasTypeFilter && !matchesAnyToken(`${item.label}\n${item.initialContent}`, typeTokens)) return false;
|
if (hasTypeFilter && !matchesAnyToken(fragment.type ?? "", typeTokens)) return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
})
|
||||||
|
.map(toFragmentTimelineItem);
|
||||||
|
|
||||||
const listItems = listDtos
|
const listItems = listDtos
|
||||||
.map(toListTimelineItem)
|
.map(toListTimelineItem)
|
||||||
.filter((item) => {
|
.filter((item) => {
|
||||||
if (hasTypeFilter) return false;
|
if (hasTypeFilter) return false;
|
||||||
|
if (tagTokens.length > 0) return false;
|
||||||
const date = item.sortDate ? parseIsoDate(item.sortDate) : null;
|
const date = item.sortDate ? parseIsoDate(item.sortDate) : null;
|
||||||
if (!date || !isWithinRange(date, startDate, endDate)) return false;
|
if (!date || !isWithinRange(date, startDate, endDate)) return false;
|
||||||
if (!matchesTextQuery(item.initialContent, query) && !matchesTextQuery(item.label, query)) 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;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -291,10 +294,10 @@
|
|||||||
.map(toTodoTimelineItem)
|
.map(toTodoTimelineItem)
|
||||||
.filter((item) => {
|
.filter((item) => {
|
||||||
if (hasTypeFilter) return false;
|
if (hasTypeFilter) return false;
|
||||||
|
if (tagTokens.length > 0) return false;
|
||||||
const date = item.sortDate ? parseIsoDate(item.sortDate) : null;
|
const date = item.sortDate ? parseIsoDate(item.sortDate) : null;
|
||||||
if (!date || !isWithinRange(date, startDate, endDate)) return false;
|
if (!date || !isWithinRange(date, startDate, endDate)) return false;
|
||||||
if (!matchesTextQuery(item.initialContent, query) && !matchesTextQuery(item.label, query)) 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;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -311,7 +314,10 @@
|
|||||||
})
|
})
|
||||||
.filter((item) => {
|
.filter((item) => {
|
||||||
if (hasTypeFilter) return false;
|
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;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -226,6 +226,23 @@
|
|||||||
attachmentModalOpen = false;
|
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 {
|
function resolveJournalLinkTarget(targetId: string): { id: string; label: string; initialContent: string } | null {
|
||||||
if (!targetId) return null;
|
if (!targetId) return null;
|
||||||
|
|
||||||
@ -442,6 +459,7 @@
|
|||||||
onBold={() => applyWrap("**")}
|
onBold={() => applyWrap("**")}
|
||||||
onItalic={() => applyWrap("*")}
|
onItalic={() => applyWrap("*")}
|
||||||
onUnderline={() => applyWrap("++")}
|
onUnderline={() => applyWrap("++")}
|
||||||
|
onTag={insertTagToken}
|
||||||
onLink={insertLink}
|
onLink={insertLink}
|
||||||
onToggleUl={() => toggleListMode("ul")}
|
onToggleUl={() => toggleListMode("ul")}
|
||||||
onToggleOl={() => toggleListMode("ol")}
|
onToggleOl={() => toggleListMode("ol")}
|
||||||
|
|||||||
@ -14,6 +14,7 @@
|
|||||||
export let onBold: () => void = () => {};
|
export let onBold: () => void = () => {};
|
||||||
export let onItalic: () => void = () => {};
|
export let onItalic: () => void = () => {};
|
||||||
export let onUnderline: () => void = () => {};
|
export let onUnderline: () => void = () => {};
|
||||||
|
export let onTag: () => void = () => {};
|
||||||
export let onLink: () => void = () => {};
|
export let onLink: () => void = () => {};
|
||||||
export let onToggleUl: () => void = () => {};
|
export let onToggleUl: () => void = () => {};
|
||||||
export let onToggleOl: () => 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">
|
<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>
|
<span class="material-symbols-outlined" aria-hidden="true">format_underlined</span>
|
||||||
</button>
|
</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">
|
<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>
|
<span class="material-symbols-outlined" aria-hidden="true">link</span>
|
||||||
</button>
|
</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