first commit
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { FileMetadata } from '../types';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class MarkdownCalendarService {
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
getFilesMetadata(): Observable<FileMetadata[]> {
|
||||
return this.http.get<FileMetadata[]>('/api/files/metadata');
|
||||
}
|
||||
|
||||
searchFilesByDate(date: Date): Observable<FileMetadata[]> {
|
||||
return this.http.get<FileMetadata[]>('/api/files/by-date', {
|
||||
params: { date: date.toISOString() },
|
||||
});
|
||||
}
|
||||
|
||||
searchFilesByDateRange(startDate: Date, endDate: Date): Observable<FileMetadata[]> {
|
||||
return this.http.get<FileMetadata[]>('/api/files/by-date-range', {
|
||||
params: {
|
||||
start: startDate.toISOString(),
|
||||
end: endDate.toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
groupMetadataByDay(metadata: FileMetadata[]): Map<string, { created: FileMetadata[]; updated: FileMetadata[] }> {
|
||||
const map = new Map<string, { created: FileMetadata[]; updated: FileMetadata[] }>();
|
||||
|
||||
for (const file of metadata) {
|
||||
const createdKey = this.normalizeDateKey(file.createdAt);
|
||||
const updatedKey = this.normalizeDateKey(file.updatedAt);
|
||||
|
||||
if (createdKey) {
|
||||
const bucket = map.get(createdKey) ?? { created: [], updated: [] };
|
||||
bucket.created = this.appendUnique(bucket.created, file);
|
||||
map.set(createdKey, bucket);
|
||||
}
|
||||
|
||||
if (updatedKey) {
|
||||
const bucket = map.get(updatedKey) ?? { created: [], updated: [] };
|
||||
bucket.updated = this.appendUnique(bucket.updated, file);
|
||||
map.set(updatedKey, bucket);
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
private normalizeDateKey(value: string | undefined | null): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
date.setHours(0, 0, 0, 0);
|
||||
return date.toDateString();
|
||||
}
|
||||
|
||||
private appendUnique(list: FileMetadata[], file: FileMetadata): FileMetadata[] {
|
||||
return list.some((item) => item.id === file.id) ? list : list.concat(file);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import hljs from 'highlight.js';
|
||||
import { Note } from '../types';
|
||||
|
||||
type TableAlignment = 'left' | 'center' | 'right' | null;
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class MarkdownService {
|
||||
|
||||
private readonly tagPaletteSize = 12;
|
||||
private tagColorCache = new Map<string, number>();
|
||||
|
||||
render(markdown: string, allNotes: Note[]): string {
|
||||
let html = markdown;
|
||||
|
||||
// Process code blocks first to prevent inner content from being parsed
|
||||
const codeBlocks = new Map<string, { html: string; language: string }>();
|
||||
html = html.replace(/```([^\n]*)\n([\s\S]*?)```/g, (match, lang, code) => {
|
||||
const id = `___CODEBLOCK_${codeBlocks.size}___`;
|
||||
const rawLanguage = (lang ?? '').trim();
|
||||
const normalizedLanguage = rawLanguage.toLowerCase();
|
||||
const isMermaid = normalizedLanguage === 'mermaid';
|
||||
let highlightedCode = '';
|
||||
|
||||
if (!isMermaid) {
|
||||
if (normalizedLanguage && hljs.getLanguage(normalizedLanguage)) {
|
||||
highlightedCode = hljs.highlight(code, { language: normalizedLanguage }).value;
|
||||
} else if (normalizedLanguage) {
|
||||
highlightedCode = hljs.highlightAuto(code).value;
|
||||
} else {
|
||||
highlightedCode = hljs.highlightAuto(code).value;
|
||||
}
|
||||
}
|
||||
|
||||
const languageLabel = rawLanguage || 'plaintext';
|
||||
const languageDisplay = rawLanguage ? rawLanguage : 'Plain text';
|
||||
const encodedRawCode = encodeURIComponent(code);
|
||||
const safeLanguageDisplay = this.escapeHtml(languageDisplay);
|
||||
const headerHtml = `
|
||||
<div class="code-block__header">
|
||||
<div class="code-block__controls" aria-hidden="true">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
<div class="code-block__actions">
|
||||
<button type="button" class="code-block__language-badge" data-language="${languageLabel.toLowerCase()}" data-code-id="${id}">${safeLanguageDisplay}</button>
|
||||
<span class="code-block__copy-feedback" hidden></span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const codeHtml = isMermaid
|
||||
? `
|
||||
<div class="code-block__body mermaid-block__body">
|
||||
<div class="mermaid-diagram" data-mermaid-code="${encodedRawCode}"></div>
|
||||
<pre class="mermaid-source" hidden><code class="hljs" data-raw-code="${encodedRawCode}">${this.escapeHtml(code)}</code></pre>
|
||||
</div>
|
||||
`
|
||||
: `<pre class="code-block__body"><code class="hljs" data-raw-code="${encodedRawCode}">${highlightedCode}</code></pre>`;
|
||||
const wrapperClass = isMermaid ? 'code-block code-block--mermaid text-left' : 'code-block text-left';
|
||||
const wrapper = `<div class="${wrapperClass}" style="max-width: 800px; width: 100%; margin: 0;" data-language="${languageLabel.toLowerCase()}" data-code-id="${id}">${headerHtml}${codeHtml}</div>`;
|
||||
|
||||
codeBlocks.set(id, { html: wrapper, language: normalizedLanguage });
|
||||
return id;
|
||||
});
|
||||
html = this.convertTables(html);
|
||||
html = this.decorateInlineTags(html);
|
||||
|
||||
// Headings (H1-H6)
|
||||
let firstLevelOneHeadingSkipped = false;
|
||||
html = html.replace(/^(\#{1,6})\s+(.*)/gm, (match, hashes, content) => {
|
||||
const level = hashes.length;
|
||||
if (level === 1 && !firstLevelOneHeadingSkipped) {
|
||||
firstLevelOneHeadingSkipped = true;
|
||||
return '';
|
||||
}
|
||||
const id = this.slugify(content);
|
||||
const headingClass = `md-heading md-heading-${level}`;
|
||||
return `<h${level} id="${id}" class="${headingClass} text-obs-l-text-main dark:text-white font-bold mt-6 mb-3 pb-1 border-b border-obs-l-border dark:border-obs-d-border">${content}</h${level}>`;
|
||||
});
|
||||
|
||||
// Bold, Italic, Strikethrough
|
||||
html = html.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
|
||||
html = html.replace(/\*(.*?)\*/g, '<em>$1</em>');
|
||||
html = html.replace(/~~(.*?)~~/g, '<del>$1</del>');
|
||||
|
||||
// Blockquotes & Callouts
|
||||
html = html.replace(/^>\s?\[!(\w+)\]\n(>.*)/gm, (match, type, content) => {
|
||||
const calloutType = type.toUpperCase();
|
||||
const calloutClass = this.getCalloutClass(calloutType);
|
||||
const cleanedContent = content.replace(/^>\s?/gm, '');
|
||||
const title = this.formatCalloutTitle(calloutType);
|
||||
const bodyHtml = cleanedContent
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0)
|
||||
.map(line => `<p>${line}</p>`)
|
||||
.join('');
|
||||
|
||||
return `<div class="${calloutClass} text-left" style="max-width: 800px; width: 100%; margin: 0;" data-callout-type="${calloutType.toLowerCase()}"><div class="callout__title">${title}</div><div class="callout__body">${bodyHtml}</div></div>`;
|
||||
});
|
||||
html = html.replace(/^>\s(.*)/gm, '<blockquote class="border-l-4 border-gray-300 dark:border-gray-500 pl-4 py-2 my-4 italic text-gray-700 dark:text-gray-300">$1</blockquote>');
|
||||
|
||||
// Lists (simple implementation)
|
||||
html = html.replace(/^\s*[\-\*]\s(.*)/gm, '<li class="ml-6 list-disc">$1</li>');
|
||||
html = html.replace(/^\s*\d+\.\s(.*)/gm, '<li class="ml-6 list-decimal">$1</li>');
|
||||
html = html.replace(/<\/li>\n<li/g, '</li><li'); // Fix spacing
|
||||
html = html.replace(/(<li.*<\/li>)/gs, (match) => {
|
||||
if (match.includes('list-disc')) return `<ul class="mb-4">${match}</ul>`;
|
||||
if (match.includes('list-decimal')) return `<ol class="mb-4">${match}</ol>`;
|
||||
return match;
|
||||
});
|
||||
|
||||
// Inline Code
|
||||
html = html.replace(/(`+)([\s\S]*?)\1/g, (match, fence, inlineCode) => {
|
||||
if (inlineCode.includes('\n')) {
|
||||
return match;
|
||||
}
|
||||
return `<code class="inline-code">${this.escapeHtml(inlineCode)}</code>`;
|
||||
});
|
||||
|
||||
// Internal Links [[link|alias]] or [[link]]
|
||||
html = html.replace(/\[\[([^|\]\n]+)(?:\|([^\]\n]+))?\]\]/g, (match, link, alias) => {
|
||||
const targetText = alias || link;
|
||||
const targetId = link.toLowerCase().replace(/\s+/g, '-');
|
||||
const noteExists = allNotes.some(n => n.id === targetId || n.title === link);
|
||||
const classes = noteExists ? 'text-obs-l-accent dark:text-obs-d-accent hover:underline cursor-pointer' : 'text-red-500 dark:text-red-400 cursor-not-allowed';
|
||||
return `<a data-note-id="${targetId}" data-note-title="${link}" class="${classes}">${targetText}</a>`;
|
||||
});
|
||||
|
||||
// External Links [text](url)
|
||||
html = html.replace(/\[(.*?)\]\((.*?)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer" class="text-blue-600 dark:text-blue-400 hover:underline">$1</a>');
|
||||
|
||||
// Embedded Files ![[file.png]]
|
||||
html = html.replace(/!\[\[(.*?)\]\]/g, (match, filename) => {
|
||||
return `<figure class="my-4"><img src="https://picsum.photos/600/400?random=${Math.random()}" alt="${filename}" class="rounded-lg max-w-full h-auto mx-auto"><figcaption class="text-center text-sm text-obs-l-text-muted dark:text-obs-d-text-muted mt-2">${filename}</figcaption></figure>`;
|
||||
});
|
||||
|
||||
// Horizontal Rule
|
||||
html = html.replace(/^-{3,}/gm, '<hr class="my-6 border-obs-l-border dark:border-obs-d-border">');
|
||||
|
||||
// Paragraphs and line breaks
|
||||
html = html.split('\n').map(p => p.trim() === '' ? '' : `<p>${p}</p>`).join('').replace(/<\/p><p>/g, '</p><br><p>');
|
||||
|
||||
// Restore code blocks
|
||||
for (const [id, block] of codeBlocks.entries()) {
|
||||
html = html.replace(id, block.html);
|
||||
}
|
||||
|
||||
// Placeholder for unsupported elements
|
||||
html = html.replace(/\$\$(.*?)\$\$/g, '<div class="my-4 p-4 bg-obs-l-bg-secondary text-obs-l-text-muted dark:bg-obs-d-bg-secondary dark:text-obs-d-text-muted rounded text-center">[LaTeX Equation: $1]</div>');
|
||||
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
private getCalloutClass(type: string): string {
|
||||
const baseClass = 'callout';
|
||||
switch (type) {
|
||||
case 'NOTE':
|
||||
case 'INFO':
|
||||
return `${baseClass} callout-note`;
|
||||
case 'TIP':
|
||||
case 'TODO':
|
||||
return `${baseClass} callout-tip`;
|
||||
case 'WARNING':
|
||||
case 'CAUTION':
|
||||
return `${baseClass} callout-warning`;
|
||||
case 'DANGER':
|
||||
case 'ERROR':
|
||||
return `${baseClass} callout-danger`;
|
||||
default:
|
||||
return `${baseClass} callout-default`;
|
||||
}
|
||||
}
|
||||
|
||||
private formatCalloutTitle(type: string): string {
|
||||
const lower = type.toLowerCase();
|
||||
return lower.charAt(0).toUpperCase() + lower.slice(1);
|
||||
}
|
||||
|
||||
private decorateInlineTags(markdown: string): string {
|
||||
const tagRegex = /(^|[\s(>)])#([^\s#.,;:!?"'(){}\[\]<>]+)/g;
|
||||
return markdown.replace(tagRegex, (match, prefix, tag) => {
|
||||
if (!tag) {
|
||||
return match;
|
||||
}
|
||||
const trimmedTag = tag.trim();
|
||||
if (!trimmedTag) {
|
||||
return match;
|
||||
}
|
||||
const colorClass = this.getTagColorClass(trimmedTag);
|
||||
return `${prefix}<button type="button" class="md-tag-badge ${colorClass}" data-tag="${trimmedTag}" data-origin="inline">#${trimmedTag}</button>`;
|
||||
});
|
||||
}
|
||||
|
||||
private getTagColorClass(tag: string): string {
|
||||
const normalized = tag.toLowerCase();
|
||||
if (this.tagColorCache.has(normalized)) {
|
||||
return `md-tag-color-${this.tagColorCache.get(normalized)}`;
|
||||
}
|
||||
|
||||
let hash = 0;
|
||||
for (let i = 0; i < normalized.length; i++) {
|
||||
hash = (hash * 31 + normalized.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
const index = hash % this.tagPaletteSize;
|
||||
this.tagColorCache.set(normalized, index);
|
||||
return `md-tag-color-${index}`;
|
||||
}
|
||||
|
||||
private convertTables(markdown: string): string {
|
||||
const lines = markdown.split('\n');
|
||||
const result: string[] = [];
|
||||
let index = 0;
|
||||
|
||||
while (index < lines.length) {
|
||||
const currentLine = lines[index];
|
||||
if (this.isTableRow(currentLine) && this.isSeparatorRow(lines[index + 1] ?? '')) {
|
||||
const headerCells = this.splitTableRow(currentLine);
|
||||
const separatorLine = lines[index + 1];
|
||||
const rows: string[][] = [];
|
||||
index += 2;
|
||||
|
||||
while (index < lines.length && this.isTableRow(lines[index])) {
|
||||
rows.push(this.splitTableRow(lines[index]));
|
||||
index += 1;
|
||||
}
|
||||
|
||||
const columnCount = Math.max(
|
||||
headerCells.length,
|
||||
...rows.map(row => row.length)
|
||||
);
|
||||
const alignments = this.parseAlignments(separatorLine, columnCount);
|
||||
const headerHtml = Array.from({ length: columnCount }, (_, colIndex) => {
|
||||
const content = headerCells[colIndex] ?? '';
|
||||
return this.buildTableCell('th', content, alignments[colIndex]);
|
||||
}).join('');
|
||||
|
||||
const bodyHtml = rows.map(row => {
|
||||
const cells = Array.from({ length: columnCount }, (_, colIndex) => {
|
||||
const content = row[colIndex] ?? '';
|
||||
return this.buildTableCell('td', content, alignments[colIndex]);
|
||||
}).join('');
|
||||
return `<tr>${cells}</tr>`;
|
||||
}).join('');
|
||||
|
||||
const tableHtml = `<div class="markdown-table"><table><thead><tr>${headerHtml}</tr></thead>${rows.length ? `<tbody>${bodyHtml}</tbody>` : '<tbody></tbody>'}</table></div>`;
|
||||
result.push(tableHtml);
|
||||
continue;
|
||||
}
|
||||
|
||||
result.push(currentLine);
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return result.join('\n');
|
||||
}
|
||||
|
||||
private isTableRow(line: string): boolean {
|
||||
if (!line) {
|
||||
return false;
|
||||
}
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith('|') || !trimmed.endsWith('|')) {
|
||||
return false;
|
||||
}
|
||||
const segments = this.splitTableRow(trimmed);
|
||||
return segments.length > 0;
|
||||
}
|
||||
|
||||
private isSeparatorRow(line: string): boolean {
|
||||
if (!line) {
|
||||
return false;
|
||||
}
|
||||
const segments = this.splitTableRow(line);
|
||||
if (!segments.length) {
|
||||
return false;
|
||||
}
|
||||
return segments.every(segment => {
|
||||
const normalized = segment.replace(/\s+/g, '');
|
||||
return /^:?-{3,}:?$/.test(normalized);
|
||||
});
|
||||
}
|
||||
|
||||
private splitTableRow(line: string): string[] {
|
||||
let trimmed = line.trim();
|
||||
if (trimmed.startsWith('|')) {
|
||||
trimmed = trimmed.slice(1);
|
||||
}
|
||||
if (trimmed.endsWith('|')) {
|
||||
trimmed = trimmed.slice(0, -1);
|
||||
}
|
||||
return trimmed.split('|').map(cell => cell.trim());
|
||||
}
|
||||
|
||||
private parseAlignments(separatorLine: string, columnCount: number): TableAlignment[] {
|
||||
const segments = this.splitTableRow(separatorLine);
|
||||
return Array.from({ length: columnCount }, (_, index) => {
|
||||
const segment = (segments[index] ?? '').trim();
|
||||
const normalized = segment.replace(/\s+/g, '');
|
||||
if (normalized.startsWith(':') && normalized.endsWith(':')) {
|
||||
return 'center';
|
||||
}
|
||||
if (normalized.startsWith(':')) {
|
||||
return 'left';
|
||||
}
|
||||
if (normalized.endsWith(':')) {
|
||||
return 'right';
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
private buildTableCell(tag: 'th' | 'td', content: string, alignment: TableAlignment): string {
|
||||
const alignStyle = alignment ? ` style="text-align:${alignment};"` : '';
|
||||
return `<${tag}${alignStyle}>${content}</${tag}>`;
|
||||
}
|
||||
|
||||
private escapeHtml(unsafe: string): string {
|
||||
return unsafe
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
private slugify(text: string): string {
|
||||
return text.toString().toLowerCase()
|
||||
.replace(/\s+/g, '-') // Replace spaces with -
|
||||
.replace(/[^\w\-]+/g, '') // Remove all non-word chars
|
||||
.replace(/\-\-+/g, '-') // Replace multiple - with single -
|
||||
.replace(/^-+/, '') // Trim - from start of text
|
||||
.replace(/-+$/, ''); // Trim - from end of text
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
import { Injectable, signal, computed } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Note, VaultNode, GraphData, TagInfo, VaultFolder } from '../types';
|
||||
|
||||
interface VaultApiNote {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
tags: string[];
|
||||
mtime: number;
|
||||
fileName?: string;
|
||||
filePath?: string;
|
||||
originalPath?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
interface VaultApiResponse {
|
||||
notes: VaultApiNote[];
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class VaultService {
|
||||
private notesMap = signal<Map<string, Note>>(new Map());
|
||||
private openFolderPaths = signal(new Set<string>());
|
||||
private initialVaultName = this.resolveVaultName();
|
||||
|
||||
allNotes = computed(() => Array.from(this.notesMap().values()));
|
||||
vaultName = signal<string>(this.initialVaultName);
|
||||
|
||||
fileTree = computed<VaultNode[]>(() => {
|
||||
const root: VaultFolder = { type: 'folder', name: 'root', path: '', children: [], isOpen: true };
|
||||
const folders = new Map<string, VaultFolder>([['', root]]);
|
||||
const openFolders = this.openFolderPaths();
|
||||
|
||||
const sortedNotes = this.allNotes().slice().sort((a, b) => {
|
||||
return a.originalPath.localeCompare(b.originalPath, undefined, { sensitivity: 'base' });
|
||||
});
|
||||
|
||||
for (const note of sortedNotes) {
|
||||
const originalSegments = note.originalPath.split('/').filter(Boolean);
|
||||
const folderSegments = originalSegments.slice(0, -1);
|
||||
|
||||
let currentPath = '';
|
||||
let parentFolder = root;
|
||||
|
||||
for (const segment of folderSegments) {
|
||||
currentPath = currentPath ? `${currentPath}/${segment}` : segment;
|
||||
let folder = folders.get(currentPath);
|
||||
if (!folder) {
|
||||
folder = {
|
||||
type: 'folder',
|
||||
name: segment,
|
||||
path: currentPath,
|
||||
children: [],
|
||||
isOpen: openFolders.has(currentPath)
|
||||
};
|
||||
folders.set(currentPath, folder);
|
||||
parentFolder.children.push(folder);
|
||||
} else {
|
||||
folder.isOpen = openFolders.has(currentPath);
|
||||
}
|
||||
parentFolder = folder;
|
||||
}
|
||||
|
||||
parentFolder.children.push({
|
||||
type: 'file',
|
||||
name: note.fileName,
|
||||
path: note.filePath.startsWith('/') ? note.filePath : `/${note.filePath}`,
|
||||
id: note.id
|
||||
});
|
||||
}
|
||||
|
||||
const sortChildren = (node: VaultFolder) => {
|
||||
node.children.sort((a, b) => {
|
||||
if (a.type === 'folder' && b.type === 'file') return -1;
|
||||
if (a.type === 'file' && b.type === 'folder') return 1;
|
||||
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
|
||||
});
|
||||
node.children.forEach(child => {
|
||||
if (child.type === 'folder') {
|
||||
sortChildren(child);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
sortChildren(root);
|
||||
|
||||
return root.children;
|
||||
});
|
||||
|
||||
graphData = computed<GraphData>(() => {
|
||||
const notes = this.allNotes();
|
||||
const nodes = notes.map(note => ({ id: note.id, label: note.title }));
|
||||
const edges: { source: string, target: string }[] = [];
|
||||
|
||||
for (const note of notes) {
|
||||
const linkRegex = /\[\[([^|\]\n]+)(?:\|([^\]\n]+))?\]\]/g;
|
||||
let match;
|
||||
while ((match = linkRegex.exec(note.content)) !== null) {
|
||||
const linkPath = match[1].toLowerCase().replace(/\s+/g, '-');
|
||||
const targetNote = notes.find(n => n.id === linkPath || n.title === match[1] || (n.frontmatter?.aliases as string[])?.includes(match[1]));
|
||||
if (targetNote && targetNote.id !== note.id) {
|
||||
edges.push({ source: note.id, target: targetNote.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes, edges };
|
||||
});
|
||||
|
||||
tags = computed<TagInfo[]>(() => {
|
||||
const tagCounts = new Map<string, number>();
|
||||
for (const note of this.allNotes()) {
|
||||
for (const tag of note.tags) {
|
||||
tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1);
|
||||
}
|
||||
}
|
||||
return Array.from(tagCounts.entries())
|
||||
.map(([name, count]) => ({ name, count }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
});
|
||||
|
||||
constructor(private http: HttpClient) {
|
||||
this.refreshNotes();
|
||||
}
|
||||
|
||||
getNoteById(id: string): Note | undefined {
|
||||
return this.notesMap().get(id);
|
||||
}
|
||||
|
||||
toggleFolder(path: string): void {
|
||||
this.openFolderPaths.update(paths => {
|
||||
const newPaths = new Set(paths);
|
||||
if (newPaths.has(path)) {
|
||||
newPaths.delete(path);
|
||||
} else {
|
||||
newPaths.add(path);
|
||||
}
|
||||
return newPaths;
|
||||
});
|
||||
}
|
||||
|
||||
ensureFolderOpen(originalPath: string): void {
|
||||
if (!originalPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = originalPath.split('/').filter(Boolean);
|
||||
if (parts.length <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedPaths = new Set(this.openFolderPaths());
|
||||
let currentPath = '';
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i];
|
||||
updatedPaths.add(currentPath);
|
||||
}
|
||||
|
||||
this.openFolderPaths.set(updatedPaths);
|
||||
}
|
||||
|
||||
refresh(): void {
|
||||
this.refreshNotes();
|
||||
}
|
||||
|
||||
private refreshNotes() {
|
||||
this.http.get<VaultApiResponse>('/api/vault').subscribe({
|
||||
next: ({ notes }) => {
|
||||
const newNotesMap = new Map<string, Note>();
|
||||
|
||||
let detectedHomeVaultName: string | null = null;
|
||||
|
||||
for (const apiNote of notes) {
|
||||
const { frontmatter, body } = this.parseFrontmatter(apiNote.content);
|
||||
const derivedTitle = this.extractTitle(body, apiNote.id);
|
||||
const noteTitle = frontmatter.title || apiNote.title || derivedTitle;
|
||||
|
||||
const originalPath = (apiNote.originalPath ?? apiNote.filePath?.replace(/\.md$/i, '') ?? apiNote.id).replace(/\\/g, '/');
|
||||
const fileName = apiNote.fileName ?? (() => {
|
||||
const parts = originalPath.split('/').filter(Boolean);
|
||||
const lastSegment = parts.pop();
|
||||
if (apiNote.filePath) {
|
||||
return apiNote.filePath.split('/').pop() ?? `${lastSegment ?? apiNote.id}.md`;
|
||||
}
|
||||
return `${lastSegment ?? apiNote.id}.md`;
|
||||
})();
|
||||
const filePath = apiNote.filePath ?? `${originalPath}.md`;
|
||||
|
||||
const tagSet = new Set<string>();
|
||||
if (Array.isArray(apiNote.tags)) {
|
||||
apiNote.tags.forEach(tag => tagSet.add(tag));
|
||||
}
|
||||
if (Array.isArray(frontmatter.tags)) {
|
||||
(frontmatter.tags as string[]).forEach(tag => tagSet.add(tag));
|
||||
}
|
||||
|
||||
const fallbackUpdatedAt = new Date((frontmatter.mtime ?? apiNote.mtime) || Date.now()).toISOString();
|
||||
const note: Note = {
|
||||
id: apiNote.id,
|
||||
title: noteTitle,
|
||||
content: body,
|
||||
tags: Array.from(tagSet),
|
||||
frontmatter,
|
||||
backlinks: [],
|
||||
mtime: frontmatter.mtime || apiNote.mtime || Date.now(),
|
||||
fileName,
|
||||
filePath,
|
||||
originalPath,
|
||||
createdAt: typeof apiNote.createdAt === 'string' ? apiNote.createdAt : undefined,
|
||||
updatedAt: typeof apiNote.updatedAt === 'string' ? apiNote.updatedAt : fallbackUpdatedAt
|
||||
};
|
||||
|
||||
if (this.shouldUseVaultName(frontmatter, apiNote.filePath, apiNote.originalPath)) {
|
||||
const candidateName = frontmatter.NomDeVoute || frontmatter.nomdevoute || frontmatter.vaultName;
|
||||
if (typeof candidateName === 'string' && candidateName.trim()) {
|
||||
detectedHomeVaultName = candidateName.trim();
|
||||
}
|
||||
}
|
||||
|
||||
newNotesMap.set(apiNote.id, note);
|
||||
}
|
||||
|
||||
this.computeBacklinks(newNotesMap);
|
||||
this.notesMap.set(newNotesMap);
|
||||
|
||||
if (detectedHomeVaultName) {
|
||||
this.vaultName.set(this.formatVaultName(detectedHomeVaultName));
|
||||
}
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('Failed to load vault notes from API', error);
|
||||
this.notesMap.set(new Map());
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private computeBacklinks(notesMap: Map<string, Note>) {
|
||||
const allNotes = Array.from(notesMap.values());
|
||||
allNotes.forEach(note => {
|
||||
note.backlinks = [];
|
||||
});
|
||||
|
||||
for (const note of allNotes) {
|
||||
const linkRegex = /\[\[([^|\]\n]+)(?:\|([^\]\n]+))?\]\]/g;
|
||||
let match;
|
||||
while ((match = linkRegex.exec(note.content)) !== null) {
|
||||
const linkTarget = match[1].toLowerCase().replace(/\s+/g, '-');
|
||||
const targetNote = allNotes.find(n =>
|
||||
n.id === linkTarget ||
|
||||
n.title === match[1] ||
|
||||
(Array.isArray(n.frontmatter?.aliases) && (n.frontmatter.aliases as string[]).includes(match[1]))
|
||||
);
|
||||
|
||||
if (targetNote && targetNote.id !== note.id) {
|
||||
const targetInMap = notesMap.get(targetNote.id);
|
||||
if (targetInMap && !targetInMap.backlinks.includes(note.id)) {
|
||||
targetInMap.backlinks.push(note.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private parseFrontmatter(content: string): { frontmatter: { [key: string]: any }, body: string } {
|
||||
const sanitizedContent = content.replace(/^\uFEFF/, '');
|
||||
const normalizedContent = sanitizedContent.replace(/\r\n?/g, '\n');
|
||||
const match = normalizedContent.match(/^---\n([\s\S]+?)\n---\n?([\s\S]*)/);
|
||||
if (match) {
|
||||
const frontmatterText = match[1];
|
||||
const body = match[2].trim();
|
||||
const frontmatter: { [key: string]: any } = {};
|
||||
const lines = frontmatterText.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const rawLine = lines[i];
|
||||
if (!rawLine.trim() || rawLine.trim().startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const colonIndex = rawLine.indexOf(':');
|
||||
if (colonIndex === -1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = rawLine.slice(0, colonIndex).trim();
|
||||
let valuePart = rawLine.slice(colonIndex + 1).trim();
|
||||
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!valuePart) {
|
||||
const listValues: any[] = [];
|
||||
let j = i + 1;
|
||||
while (j < lines.length) {
|
||||
const listLine = lines[j];
|
||||
if (!listLine.trim()) {
|
||||
break;
|
||||
}
|
||||
if (/^\s*-\s+/.test(listLine)) {
|
||||
const listItem = listLine.replace(/^\s*-\s+/, '');
|
||||
listValues.push(this.parseFrontmatterValue(listItem.trim()));
|
||||
j++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (listValues.length) {
|
||||
frontmatter[key] = listValues;
|
||||
i = j - 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (valuePart.startsWith('[') && valuePart.endsWith(']')) {
|
||||
const arrayContent = valuePart.substring(1, valuePart.length - 1);
|
||||
frontmatter[key] = arrayContent.split(',').map(v => this.parseFrontmatterValue(v.trim()));
|
||||
continue;
|
||||
}
|
||||
|
||||
frontmatter[key] = this.parseFrontmatterValue(valuePart);
|
||||
}
|
||||
return { frontmatter, body };
|
||||
}
|
||||
return { frontmatter: {}, body: content.trim() };
|
||||
}
|
||||
|
||||
private parseFrontmatterValue(rawValue: string): any {
|
||||
const trimmed = rawValue.trim();
|
||||
if (!trimmed) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const unquoted = trimmed.replace(/^['"]|['"]$/g, '');
|
||||
|
||||
if (/^(true|false)$/i.test(unquoted)) {
|
||||
return unquoted.toLowerCase() === 'true';
|
||||
}
|
||||
|
||||
const numeric = Number(unquoted);
|
||||
if (!isNaN(numeric) && unquoted !== '') {
|
||||
return numeric;
|
||||
}
|
||||
|
||||
return unquoted;
|
||||
}
|
||||
|
||||
private extractTitle(content: string, fallback: string): string {
|
||||
const match = content.match(/^#\s+(.*)/);
|
||||
return match ? match[1] : fallback.split('/').pop()!.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
||||
}
|
||||
|
||||
private resolveVaultName(): string {
|
||||
const homeFile = (window as any)?.APP_CONFIG?.vault?.home;
|
||||
if (homeFile && typeof homeFile === 'string') {
|
||||
const segments = homeFile.split(/[\/]/).filter(Boolean);
|
||||
if (segments.length) {
|
||||
return this.formatVaultName(segments[segments.length - 1]);
|
||||
}
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
const appConfig = (window as any).APP_CONFIG;
|
||||
const explicitName = appConfig?.vault?.name;
|
||||
if (explicitName && typeof explicitName === 'string') {
|
||||
return explicitName;
|
||||
}
|
||||
|
||||
const configuredPath = appConfig?.vault?.path;
|
||||
if (configuredPath && typeof configuredPath === 'string') {
|
||||
const segments = configuredPath.split(/[\\/]/).filter(Boolean);
|
||||
if (segments.length) {
|
||||
return this.formatVaultName(segments[segments.length - 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 'Vault';
|
||||
}
|
||||
|
||||
private shouldUseVaultName(frontmatter: { [key: string]: any }, filePath?: string, originalPath?: string): boolean {
|
||||
const hasMetaName = typeof frontmatter?.NomDeVoute === 'string'
|
||||
|| typeof frontmatter?.nomdevoute === 'string'
|
||||
|| typeof frontmatter?.vaultName === 'string';
|
||||
if (!hasMetaName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedPath = (filePath ?? originalPath ?? '').replace(/\\/g, '/');
|
||||
const pathSegments = normalizedPath.split('/').filter(Boolean).map(segment => segment.toLowerCase());
|
||||
if (!pathSegments.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lastSegment = pathSegments[pathSegments.length - 1];
|
||||
return pathSegments.length === 1 && (lastSegment === 'home.md' || lastSegment === 'home');
|
||||
}
|
||||
|
||||
private formatVaultName(value: string): string {
|
||||
return value
|
||||
.replace(/[-_]+/g, ' ')
|
||||
.replace(/\b\w/g, (char) => char.toUpperCase());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user