first commit

This commit is contained in:
2025-09-27 18:13:02 -04:00
commit 6e1b5407b7
253 changed files with 289900 additions and 0 deletions
+69
View File
@@ -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);
}
}
+337
View 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
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
}
}
+407
View File
@@ -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());
}
}