ObsiViewer/src/app.component.ts

902 lines
27 KiB
TypeScript

import { Component, ChangeDetectionStrategy, HostListener, inject, signal, computed, effect, ElementRef, OnDestroy, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
// Services
import { VaultService } from './services/vault.service';
import { MarkdownService } from './services/markdown.service';
import { MarkdownViewerService } from './services/markdown-viewer.service';
import { DownloadService } from './core/services/download.service';
import { ThemeService } from './app/core/services/theme.service';
import { LogService } from './core/logging/log.service';
// Components
import { FileExplorerComponent } from './components/file-explorer/file-explorer.component';
import { NoteViewerComponent, WikiLinkActivation } from './components/tags-view/note-viewer/note-viewer.component';
import { GraphViewContainerV2Component } from './components/graph-view-container-v2/graph-view-container-v2.component';
import { TagsViewComponent } from './components/tags-view/tags-view.component';
import { MarkdownCalendarComponent } from './components/markdown-calendar/markdown-calendar.component';
import { GraphInlineSettingsComponent } from './app/graph/ui/inline-settings-panel.component';
import { RawViewOverlayComponent } from './shared/overlays/raw-view-overlay.component';
import { BookmarksPanelComponent } from './components/bookmarks-panel/bookmarks-panel.component';
import { AddBookmarkModalComponent, type BookmarkFormData, type BookmarkDeleteEvent } from './components/add-bookmark-modal/add-bookmark-modal.component';
import { BookmarksService } from './core/bookmarks/bookmarks.service';
import { SearchInputWithAssistantComponent } from './components/search-input-with-assistant/search-input-with-assistant.component';
import { SearchHistoryService } from './core/search/search-history.service';
import { GraphIndexService } from './core/graph/graph-index.service';
// Types
import { FileMetadata, Note, TagInfo, VaultNode } from './types';
interface TocEntry {
level: number;
text: string;
id: string;
}
@Component({
selector: 'app-root',
imports: [
CommonModule,
FormsModule,
FileExplorerComponent,
NoteViewerComponent,
GraphViewContainerV2Component,
TagsViewComponent,
MarkdownCalendarComponent,
RawViewOverlayComponent,
BookmarksPanelComponent,
AddBookmarkModalComponent,
GraphInlineSettingsComponent,
SearchInputWithAssistantComponent,
],
templateUrl: './app.component.simple.html',
styleUrls: ['./app.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppComponent implements OnInit, OnDestroy {
readonly vaultService = inject(VaultService);
private markdownService = inject(MarkdownService);
private markdownViewerService = inject(MarkdownViewerService);
private downloadService = inject(DownloadService);
private readonly themeService = inject(ThemeService);
private readonly bookmarksService = inject(BookmarksService);
private readonly searchHistoryService = inject(SearchHistoryService);
private readonly graphIndexService = inject(GraphIndexService);
private readonly logService = inject(LogService);
private elementRef = inject(ElementRef);
// --- State Signals ---
isSidebarOpen = signal<boolean>(true);
isOutlineOpen = signal<boolean>(true);
outlineTab = signal<'outline' | 'settings'>('outline');
activeView = signal<'files' | 'graph' | 'tags' | 'search' | 'calendar' | 'bookmarks'>('files');
selectedNoteId = signal<string>('');
sidebarSearchTerm = signal<string>('');
tableOfContents = signal<TocEntry[]>([]);
leftSidebarWidth = signal<number>(288);
rightSidebarWidth = signal<number>(288);
isRawViewOpen = signal<boolean>(false);
isRawViewWrapped = signal<boolean>(true);
showAddBookmarkModal = signal<boolean>(false);
readonly LEFT_MIN_WIDTH = 220;
readonly LEFT_MAX_WIDTH = 520;
readonly RIGHT_MIN_WIDTH = 220;
readonly RIGHT_MAX_WIDTH = 520;
private rawViewTriggerElement: HTMLElement | null = null;
private viewportWidth = signal<number>(typeof window !== 'undefined' ? window.innerWidth : 0);
private resizeHandler = () => {
if (typeof window === 'undefined') {
return;
}
this.viewportWidth.set(window.innerWidth);
};
isDesktopView = computed<boolean>(() => this.viewportWidth() >= 1024);
private wasDesktop = false;
calendarResults = signal<FileMetadata[]>([]);
calendarSearchState = signal<'idle' | 'loading' | 'error'>('idle');
calendarSearchError = signal<string | null>(null);
calendarSelectionLabel = signal<string | null>(null);
calendarOverlayVisible = computed<boolean>(() =>
this.calendarSearchState() === 'loading' ||
!!this.calendarSearchError() ||
this.calendarResults().length > 0
);
private calendarSearchTriggered = false;
private pendingWikiNavigation = signal<{ noteId: string; heading?: string; block?: string } | null>(null);
readonly isDarkMode = this.themeService.isDark;
// Bookmark state
readonly isCurrentNoteBookmarked = computed(() => {
const noteId = this.selectedNoteId();
if (!noteId) return false;
const note = this.selectedNote();
if (!note) return false;
const doc = this.bookmarksService.doc();
const notePath = note.filePath;
const findBookmark = (items: any[]): boolean => {
for (const item of items) {
if (item.type === 'file' && item.path === notePath) {
return true;
}
if (item.type === 'group' && item.items) {
if (findBookmark(item.items)) return true;
}
}
return false;
};
return findBookmark(doc.items);
});
// --- Data Signals ---
fileTree = this.vaultService.fileTree;
graphData = this.vaultService.graphData;
allTags = this.vaultService.tags;
vaultName = this.vaultService.vaultName;
// --- Computed Signals ---
selectedNote = computed<Note | undefined>(() => {
const id = this.selectedNoteId();
return id ? this.vaultService.getNoteById(id) : undefined;
});
renderedNoteContent = computed<string>(() => {
const note = this.selectedNote();
if (!note) return '';
const allNotes = this.vaultService.allNotes();
return this.markdownService.render(note.content, allNotes, note);
});
rawNoteContent = computed<string>(() => {
const note = this.selectedNote();
if (!note) {
return '';
}
return note.rawContent ?? note.content ?? '';
});
rawNoteFilename = computed<string>(() => {
const note = this.selectedNote();
if (!note) {
return this.buildFallbackFilename();
}
const name = note.fileName?.trim();
return name && name.length > 0 ? name : this.buildFallbackFilename();
});
selectedNoteBreadcrumb = computed<string[]>(() => {
const note = this.selectedNote();
if (!note) {
return [];
}
const vaultTitle = this.vaultName().trim();
const breadcrumb: string[] = [vaultTitle || 'Vault'];
const pathSegments = note.originalPath.split('/').filter(Boolean);
if (pathSegments.length === 0) {
breadcrumb.push(note.fileName.replace(/\.md$/i, ''));
return breadcrumb;
}
const displaySegments = [...pathSegments];
displaySegments[displaySegments.length - 1] = note.fileName.replace(/\.md$/i, '');
return breadcrumb.concat(displaySegments);
});
filteredTags = computed<TagInfo[]>(() => {
const term = this.sidebarSearchTerm().trim().toLowerCase();
const cleanedTerm = term.startsWith('#') ? term.slice(1) : term;
if (!cleanedTerm) return this.allTags();
return this.allTags().filter(tag => tag.name.toLowerCase().includes(cleanedTerm));
});
filteredFileTree = computed<VaultNode[]>(() => {
const term = this.sidebarSearchTerm().trim().toLowerCase();
if (!term || term.startsWith('#')) return this.fileTree();
// Simple flat search for files
return this.fileTree().filter(node => node.type === 'file' && node.name.toLowerCase().includes(term));
});
activeTagFilter = computed<string | null>(() => {
const rawTerm = this.sidebarSearchTerm().trim();
if (!rawTerm.startsWith('#')) {
return null;
}
const tag = rawTerm.slice(1).trim();
return tag ? tag.toLowerCase() : null;
});
activeTagDisplay = computed<string | null>(() => {
const rawTerm = this.sidebarSearchTerm().trim();
if (!rawTerm.startsWith('#')) {
return null;
}
const displayTag = rawTerm.slice(1).trim();
return displayTag || null;
});
searchResults = computed<Note[]>(() => {
const notes = this.vaultService.allNotes();
const tagFilter = this.activeTagFilter();
if (tagFilter) {
return notes.filter(note => note.tags.some(tag => tag.toLowerCase() === tagFilter));
}
const term = this.sidebarSearchTerm().trim().toLowerCase();
if (!term) return [];
const cleanedTerm = term.startsWith('#') ? term.slice(1) : term;
return notes.filter(note =>
note.title.toLowerCase().includes(cleanedTerm) ||
note.content.toLowerCase().includes(cleanedTerm) ||
note.tags.some(tag => tag.toLowerCase().includes(cleanedTerm))
);
});
constructor() {
this.themeService.initFromStorage();
if (typeof window !== 'undefined') {
window.addEventListener('resize', this.resizeHandler, { passive: true });
}
this.wasDesktop = this.isDesktopView();
if (!this.isDesktopView()) {
this.isSidebarOpen.set(false);
this.isOutlineOpen.set(false);
}
// Initialize outline tab from localStorage
if (typeof window !== 'undefined') {
const savedTab = window.localStorage.getItem('graphPaneTab');
if (savedTab === 'settings' || savedTab === 'outline') {
this.outlineTab.set(savedTab as 'outline' | 'settings');
}
}
effect(() => {
const isDesktop = this.isDesktopView();
if (isDesktop && !this.wasDesktop) {
this.isSidebarOpen.set(true);
this.isOutlineOpen.set(true);
}
if (!isDesktop && this.wasDesktop) {
this.isSidebarOpen.set(false);
this.isOutlineOpen.set(false);
}
this.wasDesktop = isDesktop;
});
// Effect to generate Table of Contents when the note changes
effect(() => {
const note = this.selectedNote();
this.markdownViewerService.setCurrentNote(note ?? null);
const html = this.renderedNoteContent();
if (html && note) {
this.generateToc(html);
} else {
this.tableOfContents.set([]);
}
});
// Effect to rebuild graph index when notes change
effect(() => {
const notes = this.vaultService.allNotes();
this.graphIndexService.rebuildIndex(notes);
});
// Persist outline tab
effect(() => {
const tab = this.outlineTab();
if (typeof window !== 'undefined') {
window.localStorage.setItem('graphPaneTab', tab);
}
});
effect(() => {
if (!this.selectedNote()) {
this.isRawViewOpen.set(false);
}
});
// Check for reduced motion preference for testing
if (typeof window !== 'undefined') {
const params = new URLSearchParams(window.location.search);
if (params.has('reduced-motion')) {
document.body.classList.add('prefers-reduced-motion');
}
}
effect(() => {
const pending = this.pendingWikiNavigation();
const activeNoteId = this.selectedNoteId();
const html = this.renderedNoteContent();
if (!pending || pending.noteId !== activeNoteId || !html) {
return;
}
queueMicrotask(() => {
if (pending.heading) {
this.scrollToHeading(pending.heading);
} else if (pending.block) {
this.scrollToBlock(pending.block);
}
this.pendingWikiNavigation.set(null);
});
});
effect(() => {
if (typeof document === 'undefined') {
return;
}
const vaultTitle = this.vaultName().trim();
document.title = `ObsiWatcher - ${vaultTitle || 'Vault'}`;
});
// Effect to select first available note when vault data loads
effect(() => {
const notes = this.vaultService.allNotes();
const currentId = this.selectedNoteId();
if (!notes.length) {
return;
}
const currentExists = notes.some(note => note.id === currentId);
if (!currentExists) {
const firstNote = notes[0];
this.vaultService.ensureFolderOpen(firstNote.originalPath);
this.selectedNoteId.set(firstNote.id);
}
});
}
ngOnInit(): void {
// Log app start
this.logService.log('APP_START', {
viewport: {
width: typeof window !== 'undefined' ? window.innerWidth : 0,
height: typeof window !== 'undefined' ? window.innerHeight : 0,
},
});
}
ngOnDestroy(): void {
// Log app stop
this.logService.log('APP_STOP');
if (typeof window !== 'undefined') {
window.removeEventListener('resize', this.resizeHandler);
}
}
// --- Methods ---
toggleTheme(): void {
this.themeService.toggleTheme();
}
toggleSidebar(): void {
this.isSidebarOpen.update(value => !value);
}
closeSidebar(): void {
this.isSidebarOpen.set(false);
}
toggleSidebarTo(state: boolean): void {
this.isSidebarOpen.set(state);
}
toggleOutline(): void {
this.isOutlineOpen.update(value => !value);
}
closeOutlinePanel(): void {
this.isOutlineOpen.set(false);
}
toggleOutlineTo(state: boolean): void {
this.isOutlineOpen.set(state);
}
setOutlineTab(tab: 'outline' | 'settings'): void {
this.outlineTab.set(tab);
}
isDesktop(): boolean {
return this.isDesktopView();
}
onCalendarResultsChange(files: FileMetadata[]): void {
this.calendarResults.set(files);
if (this.calendarSearchTriggered || files.length > 0 || this.activeView() === 'search') {
this.isSidebarOpen.set(true);
this.activeView.set('search');
this.calendarSearchTriggered = false;
// Log calendar search execution
if (files.length > 0) {
this.logService.log('CALENDAR_SEARCH_EXECUTED', {
resultsCount: files.length,
});
}
}
}
onCalendarSearchStateChange(state: 'idle' | 'loading' | 'error'): void {
this.calendarSearchState.set(state);
if (state === 'loading') {
this.calendarSearchTriggered = true;
}
}
onCalendarSearchErrorChange(message: string | null): void {
this.calendarSearchError.set(message);
if (message) {
this.isSidebarOpen.set(true);
this.activeView.set('search');
this.calendarSearchTriggered = false;
}
}
onCalendarSelectionSummaryChange(summary: string | null): void {
this.calendarSelectionLabel.set(summary);
}
onCalendarRequestSearchPanel(): void {
if (this.activeView() === 'calendar') {
if (!this.isSidebarOpen()) {
this.isSidebarOpen.set(true);
}
return;
}
this.isSidebarOpen.set(true);
this.activeView.set('search');
}
/**
* Clear the current calendar search results and related UI state.
* Used by the "Effacer" button in the search panel.
*/
clearCalendarResults(): void {
this.calendarResults.set([]);
this.calendarSearchState.set('idle');
this.calendarSearchError.set(null);
this.calendarSelectionLabel.set(null);
}
startLeftResize(event: PointerEvent): void {
if (!this.isSidebarOpen()) {
this.isSidebarOpen.set(true);
}
event.preventDefault();
const handle = event.currentTarget as HTMLElement | null;
handle?.setPointerCapture(event.pointerId);
const startX = event.clientX;
const startWidth = this.leftSidebarWidth();
const moveHandler = (moveEvent: PointerEvent) => {
const delta = moveEvent.clientX - startX;
let newWidth = startWidth + delta;
newWidth = Math.max(this.LEFT_MIN_WIDTH, Math.min(this.LEFT_MAX_WIDTH, newWidth));
this.leftSidebarWidth.set(newWidth);
};
const cleanup = () => {
window.removeEventListener('pointermove', moveHandler);
window.removeEventListener('pointerup', cleanup);
window.removeEventListener('pointercancel', cleanup);
if (handle && handle.hasPointerCapture?.(event.pointerId)) {
handle.releasePointerCapture(event.pointerId);
}
handle?.removeEventListener('lostpointercapture', cleanup);
};
window.addEventListener('pointermove', moveHandler);
window.addEventListener('pointerup', cleanup);
window.addEventListener('pointercancel', cleanup);
handle?.addEventListener('lostpointercapture', cleanup);
}
startRightResize(event: PointerEvent): void {
if (!this.isOutlineOpen()) {
this.isOutlineOpen.set(true);
}
event.preventDefault();
const handle = event.currentTarget as HTMLElement | null;
handle?.setPointerCapture(event.pointerId);
const startX = event.clientX;
const startWidth = this.rightSidebarWidth();
const moveHandler = (moveEvent: PointerEvent) => {
const delta = moveEvent.clientX - startX;
let newWidth = startWidth - delta;
newWidth = Math.max(this.RIGHT_MIN_WIDTH, Math.min(this.RIGHT_MAX_WIDTH, newWidth));
this.rightSidebarWidth.set(newWidth);
};
const cleanup = () => {
window.removeEventListener('pointermove', moveHandler);
window.removeEventListener('pointerup', cleanup);
window.removeEventListener('pointercancel', cleanup);
if (handle && handle.hasPointerCapture?.(event.pointerId)) {
handle.releasePointerCapture(event.pointerId);
}
handle?.removeEventListener('lostpointercapture', cleanup);
};
window.addEventListener('pointermove', moveHandler);
window.addEventListener('pointerup', cleanup);
window.addEventListener('pointercancel', cleanup);
handle?.addEventListener('lostpointercapture', cleanup);
}
setView(view: 'files' | 'graph' | 'tags' | 'search' | 'calendar' | 'bookmarks'): void {
const previousView = this.activeView();
this.activeView.set(view);
this.sidebarSearchTerm.set('');
// Log view changes
if (view === 'bookmarks' && previousView !== 'bookmarks') {
this.logService.log('BOOKMARKS_OPEN');
} else if (view === 'graph' && previousView !== 'graph') {
this.logService.log('GRAPH_VIEW_OPEN');
}
}
selectNote(noteId: string): void {
const note = this.vaultService.getNoteById(noteId);
if (!note) {
return;
}
this.vaultService.ensureFolderOpen(note.originalPath);
this.selectedNoteId.set(note.id);
this.markdownViewerService.setCurrentNote(note);
if (!this.isDesktopView() && this.activeView() === 'search') {
this.isSidebarOpen.set(false);
}
}
selectNoteFromGraph(noteId: string): void {
this.selectNote(noteId);
this.activeView.set('files');
}
handleTagClick(tagName: string): void {
const normalized = tagName.replace(/^#/, '').trim();
if (!normalized) {
return;
}
this.isSidebarOpen.set(true);
this.activeView.set('search');
this.sidebarSearchTerm.set(`#${normalized}`);
}
updateSearchTerm(term: string, focusSearch = false): void {
this.sidebarSearchTerm.set(term ?? '');
if (focusSearch || (term && term.trim().length > 0)) {
this.activeView.set('search');
}
}
onSearchSubmit(query: string): void {
if (query && query.trim()) {
// History is already handled by SearchInputWithAssistant using its [context]
// Update search term and switch to search view
this.sidebarSearchTerm.set(query);
this.activeView.set('search');
// Log search execution
this.logService.log('SEARCH_EXECUTED', {
query: query.trim(),
queryLength: query.trim().length,
});
}
}
toggleRawView(): void {
if (!this.selectedNote()) {
return;
}
if (this.isRawViewOpen()) {
this.closeRawView();
} else {
this.openRawView();
}
}
openRawView(): void {
if (!this.selectedNote()) {
return;
}
if (typeof document !== 'undefined') {
this.rawViewTriggerElement = document.activeElement as HTMLElement | null;
}
this.isRawViewOpen.set(true);
}
closeRawView(): void {
this.isRawViewOpen.set(false);
const target = this.rawViewTriggerElement;
this.rawViewTriggerElement = null;
if (target && typeof target.focus === 'function') {
queueMicrotask(() => target.focus());
}
}
toggleRawWrap(): void {
this.isRawViewWrapped.update(value => !value);
}
downloadCurrentNote(): void {
const note = this.selectedNote();
if (!note) {
return;
}
const filename = this.rawNoteFilename();
const content = this.rawNoteContent();
if (!content) {
console.warn('[ObsiViewer] Aucun contenu à télécharger.');
return;
}
this.downloadService.downloadText(content, filename);
}
toggleBookmarkModal(): void {
this.showAddBookmarkModal.update(v => !v);
}
closeBookmarkModal(): void {
this.showAddBookmarkModal.set(false);
}
onBookmarkSave(data: BookmarkFormData): void {
const note = this.selectedNote();
if (!note) return;
// Check if bookmark already exists
const doc = this.bookmarksService.doc();
let existingCtime: number | null = null;
const findExisting = (items: any[]): number | null => {
for (const item of items) {
if (item.type === 'file' && item.path === note.filePath) {
return item.ctime;
}
if (item.type === 'group' && item.items) {
const found = findExisting(item.items);
if (found) return found;
}
}
return null;
};
existingCtime = findExisting(doc.items);
if (existingCtime) {
// Update existing bookmark
this.bookmarksService.updateBookmark(existingCtime, {
title: data.title,
});
// If group changed, move it
if (data.groupCtime !== null) {
this.bookmarksService.moveBookmark(existingCtime, data.groupCtime, 0);
}
// Log bookmark modification
this.logService.log('BOOKMARKS_MODIFY', {
action: 'update',
path: data.path,
});
} else {
// Create new bookmark
this.bookmarksService.createFileBookmark(data.path, data.title, data.groupCtime);
// Log bookmark modification
this.logService.log('BOOKMARKS_MODIFY', {
action: 'add',
path: data.path,
});
}
this.closeBookmarkModal();
}
onBookmarkDelete(event: BookmarkDeleteEvent): void {
this.bookmarksService.removePathEverywhere(event.path);
// Log bookmark deletion
this.logService.log('BOOKMARKS_MODIFY', {
action: 'delete',
path: event.path,
});
this.closeBookmarkModal();
}
onBookmarkNavigate(bookmark: any): void {
if (bookmark.type === 'file' && bookmark.path) {
// Find note by matching filePath
const note = this.vaultService.allNotes().find(n => n.filePath === bookmark.path);
if (note) {
this.selectNote(note.id);
}
}
}
@HostListener('window:keydown', ['$event'])
handleGlobalKeydown(event: KeyboardEvent): void {
if (!event.altKey || event.repeat) {
return;
}
const key = event.key.toLowerCase();
if (key === 'r') {
if (!this.selectedNote()) {
return;
}
event.preventDefault();
this.toggleRawView();
}
if (key === 'd') {
if (!this.selectedNote()) {
return;
}
event.preventDefault();
this.downloadCurrentNote();
}
}
private buildFallbackFilename(): string {
const now = new Date();
const pad = (value: number) => value.toString().padStart(2, '0');
const year = now.getFullYear();
const month = pad(now.getMonth() + 1);
const day = pad(now.getDate());
const hours = pad(now.getHours());
const minutes = pad(now.getMinutes());
return `note-${year}${month}${day}-${hours}${minutes}.md`;
}
handleWikiLink(link: WikiLinkActivation): void {
const target = link.target?.trim();
if (!target) {
return;
}
const note = this.resolveWikiTarget(target);
if (!note) {
console.warn('[ObsiViewer] Wiki link target not found:', link);
return;
}
this.pendingWikiNavigation.set({
noteId: note.id,
heading: link.heading,
block: link.block
});
this.selectNote(note.id);
}
private generateToc(html: string): void {
const toc: TocEntry[] = [];
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const headings = doc.querySelectorAll('h1, h2, h3, h4, h5, h6');
headings.forEach(heading => {
if (!heading.id || !heading.textContent) {
return;
}
toc.push({
level: parseInt(heading.tagName.substring(1), 10),
text: heading.textContent,
id: heading.id
});
});
this.tableOfContents.set(toc);
}
private scrollToHeading(id: string): void {
const contentArea = (this.elementRef.nativeElement as HTMLElement).querySelector('.note-content-area');
if (!contentArea) {
return;
}
const element = contentArea.querySelector(`#${id}`);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
private scrollToBlock(blockId: string | undefined): void {
if (!blockId) {
return;
}
const contentArea = (this.elementRef.nativeElement as HTMLElement).querySelector('.note-content-area');
if (!contentArea) {
return;
}
const selectors = [`[data-block-id="${blockId}"]`, `#${blockId}`];
for (const selector of selectors) {
const element = contentArea.querySelector(selector);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
break;
}
}
}
private resolveWikiTarget(rawTarget: string): Note | undefined {
const normalized = rawTarget.trim();
if (!normalized) {
return undefined;
}
const lower = normalized.toLowerCase();
const slug = this.slugifyForWiki(normalized);
const normalizedPath = this.normalizePath(normalized);
const notes = this.vaultService.allNotes();
return notes.find(note => {
const title = note.title?.trim() ?? '';
const titleLower = title.toLowerCase();
const titleSlug = this.slugifyForWiki(title);
const fileBase = note.fileName.replace(/\.md$/i, '').trim();
const fileLower = fileBase.toLowerCase();
const filePathNormalized = this.normalizePath(fileBase);
const originalPath = this.normalizePath(note.originalPath);
const aliasMatch = Array.isArray(note.frontmatter?.aliases) && (note.frontmatter.aliases as string[]).some(alias => {
const trimmed = alias.trim();
const aliasLower = trimmed.toLowerCase();
return aliasLower === lower || this.slugifyForWiki(trimmed) === slug;
});
return (
note.id === lower ||
note.id === slug ||
titleLower === lower ||
titleSlug === slug ||
fileLower === lower ||
filePathNormalized === normalizedPath ||
originalPath === normalizedPath ||
aliasMatch
);
});
}
private slugifyForWiki(value: string): string {
return value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.trim()
.replace(/\s+/g, '-');
}
private normalizePath(path: string): string {
return path
.replace(/\\/g, '/')
.replace(/\.md$/i, '')
.replace(/^\/+/, '')
.toLowerCase();
}
}