feat: add Meilisearch backend integration with Docker Compose setup and Excalidraw support

This commit is contained in:
2025-10-14 10:41:15 -04:00
parent b359e8ab8e
commit 32a9998b40
71 changed files with 11544 additions and 435 deletions
+1 -1
View File
@@ -185,7 +185,7 @@
></app-file-explorer>
}
@case ('tags') {
<app-tags-view [tags]="filteredTags()" (tagSelected)="handleTagClick($event)"></app-tags-view>
<app-tags-view [tags]="allTags()" (tagSelected)="handleTagClick($event)"></app-tags-view>
}
@case ('graph') {
<div class="h-full p-2"><app-graph-view [graphData]="graphData()" (nodeSelected)="selectNote($event)"></app-graph-view></div>
+69 -106
View File
@@ -175,108 +175,36 @@
<div class="flex-1 overflow-y-auto">
@switch (activeView()) {
@case ('files') {
<div class="px-2 pt-2 pb-2">
<input
type="search"
[value]="fileExplorerFilter()"
(input)="fileExplorerFilter.set(($any($event.target).value || '').toString())"
placeholder="Filtrer fichiers et dossiers..."
class="w-full rounded-full border border-border bg-card px-3 py-1.5 text-sm text-text-main placeholder:text-text-muted"
aria-label="Filtrer arborescence"
/>
</div>
<app-file-explorer
[nodes]="filteredFileTree()"
[nodes]="filteredExplorerTree()"
[selectedNoteId]="selectedNoteId()"
(fileSelected)="selectNote($event)"
(fileSelected)="onFileNodeSelected($event)"
></app-file-explorer>
}
@case ('tags') {
<app-tags-view [tags]="filteredTags()" (tagSelected)="handleTagClick($event)"></app-tags-view>
<app-tags-view [tags]="allTags()" (tagSelected)="handleTagClick($event)"></app-tags-view>
}
@case ('search') {
<div class="space-y-4 p-3">
<div class="flex flex-col gap-3">
<div class="relative">
<app-search-input-with-assistant
[value]="sidebarSearchTerm()"
(valueChange)="updateSearchTerm($event)"
(submit)="onSearchSubmit($event)"
[placeholder]="'Rechercher dans la voûte...'"
[context]="'vault-sidebar'"
[showSearchIcon]="true"
[showExamples]="false"
[inputClass]="'w-full rounded-full border border-border bg-bg-muted/70 py-2.5 pr-4 text-sm text-text-main placeholder:text-text-muted shadow-subtle focus:outline-none focus:ring-2 focus:ring-ring transition-all dark:border-gray-600 dark:bg-gray-800/80 dark:text-gray-100 dark:placeholder-gray-400 dark:focus:ring-blue-500'"
/>
</div>
@if (activeTagDisplay(); as tagDisplay) {
<div class="flex items-center justify-between rounded-lg border border-border bg-card px-3 py-2">
<div class="flex items-center gap-2 text-sm font-small text-text-main">
<span>🔖</span>
<span class="truncate">{{ tagDisplay }}</span>
</div>
<button class="btn btn-sm btn-ghost" (click)="clearTagFilter()">Effacer</button>
</div>
}
</div>
<div class="space-y-3">
<div class="flex items-center justify-between px-2">
<h3 class="text-xs font-semibold uppercase tracking-wide text-text-muted">Résultats</h3>
<span class="text-xs text-text-muted">{{ searchResults().length }}</span>
</div>
@if (searchResults().length > 0) {
<ul class="space-y-1">
@for (note of searchResults(); track note.id) {
<li
(click)="selectNote(note.id)"
class="cursor-pointer rounded-lg border border-transparent bg-card px-3 py-2 transition hover:border-border hover:bg-bg-muted"
[ngClass]="{ 'border-border bg-bg-muted': selectedNoteId() === note.id }"
>
<div class="truncate text-sm font-semibold text-text-main">{{ note.title }}</div>
<div class="truncate text-xs text-text-muted">{{ note.content.substring(0, 100) }}</div>
</li>
}
</ul>
} @else {
<p class="rounded-lg border border-dashed border-border px-3 py-3 text-sm text-text-muted">Aucun résultat pour cette recherche.</p>
}
</div>
@if (calendarSelectionLabel() || calendarSearchState() !== 'idle' || calendarResults().length > 0 || calendarSearchError()) {
<div class="space-y-3 rounded-xl border border-border bg-card p-3 shadow-subtle">
<div class="flex items-center justify-between">
<div>
<h3 class="text-sm font-semibold text-text-main">Résultats du calendrier</h3>
@if (calendarSelectionLabel(); as selectionLabel) {
<p class="text-xs text-text-muted">{{ selectionLabel }}</p>
}
</div>
<button
class="btn btn-sm btn-ghost"
(click)="clearCalendarResults()"
aria-label="Effacer les résultats du calendrier"
>
Effacer
</button>
</div>
@if (calendarSearchState() === 'loading') {
<div class="rounded-lg bg-bg-muted px-3 py-2 text-xs text-text-muted">Recherche en cours...</div>
} @else if (calendarSearchError(); as calError) {
<div class="rounded-lg border border-red-500/40 bg-red-500/10 px-3 py-2 text-xs text-red-500 dark:border-red-500/30 dark:bg-red-500/15 dark:text-red-400">{{ calError }}</div>
} @else if (calendarResults().length === 0) {
<div class="rounded-lg bg-bg-muted px-3 py-2 text-xs text-text-muted">Sélectionnez une date dans le calendrier pour voir les notes correspondantes.</div>
} @else {
<ul class="space-y-2">
@for (file of calendarResults(); track file.id) {
<li>
<button
(click)="selectNote(file.id)"
class="w-full rounded-lg border border-transparent bg-card px-3 py-2 text-left transition hover:border-border hover:bg-bg-muted"
>
<div class="truncate text-sm font-semibold text-text-main">{{ file.title }}</div>
<div class="mt-1 flex flex-wrap gap-2 text-xs text-text-muted">
<span>Créé&nbsp;: {{ file.createdAt | date:'mediumDate' }}</span>
<span>Modifié&nbsp;: {{ file.updatedAt | date:'mediumDate' }}</span>
</div>
</button>
</li>
}
</ul>
}
</div>
}
<div class="h-full p-2">
<app-search-panel
[placeholder]="'Rechercher dans la voûte...'"
[context]="'vault-sidebar'"
[initialQuery]="sidebarSearchTerm()"
(noteOpen)="selectNote($event.noteId)"
(searchTermChange)="onSidebarSearchTermChange($event)"
(optionsChange)="onSidebarOptionsChange($event)"
></app-search-panel>
</div>
}
@case ('calendar') {
@@ -363,6 +291,18 @@
</div>
</div>
<div class="hidden items-center gap-2 lg:flex">
<button
type="button"
class="btn btn-icon btn-ghost"
(click)="createNewDrawing()"
aria-label="Nouveau dessin Excalidraw"
title="Nouveau dessin"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5">
<path d="M12 5v14" />
<path d="M5 12h14" />
</svg>
</button>
<button
type="button"
class="btn btn-icon btn-ghost disabled:opacity-40 disabled:pointer-events-none"
@@ -440,6 +380,18 @@
</div>
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between lg:gap-6">
<div class="flex items-center gap-2 lg:hidden">
<button
type="button"
class="btn btn-icon btn-ghost"
(click)="createNewDrawing()"
aria-label="Nouveau dessin Excalidraw"
title="Nouveau dessin"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5">
<path d="M12 5v14" />
<path d="M5 12h14" />
</svg>
</button>
<button
type="button"
class="btn btn-icon btn-ghost disabled:opacity-40 disabled:pointer-events-none"
@@ -506,6 +458,7 @@
[context]="'vault-header'"
[showSearchIcon]="true"
[inputClass]="'w-full rounded-full border border-border bg-bg-muted/70 pl-11 pr-4 py-2.5 text-sm text-text-main placeholder:text-text-muted shadow-subtle focus:outline-none focus:ring-2 focus:ring-ring'"
(optionsChange)="onHeaderSearchOptionsChange($event)"
/>
</div>
<button
@@ -527,19 +480,29 @@
</app-graph-view-container-v2>
</div>
} @else {
@if (selectedNote(); as note) {
<app-note-viewer
[note]="note"
[noteHtmlContent]="renderedNoteContent()"
[allNotes]="vaultService.allNotes()"
(noteLinkClicked)="selectNote($event)"
(tagClicked)="handleTagClick($event)"
(wikiLinkActivated)="handleWikiLink($event)"
></app-note-viewer>
@if (activeView() === 'drawings') {
@if (currentDrawingPath()) {
<app-drawings-editor [path]="currentDrawingPath()!"></app-drawings-editor>
} @else {
<div class="flex h-full items-center justify-center">
<p class="text-text-muted">Aucun dessin sélectionné</p>
</div>
}
} @else {
<div class="flex h-full items-center justify-center">
<p class="text-text-muted">Sélectionnez une note pour commencer</p>
</div>
@if (selectedNote(); as note) {
<app-note-viewer
[note]="note"
[noteHtmlContent]="renderedNoteContent()"
[allNotes]="vaultService.allNotes()"
(noteLinkClicked)="selectNote($event)"
(tagClicked)="handleTagClick($event)"
(wikiLinkActivated)="handleWikiLink($event)"
></app-note-viewer>
} @else {
<div class="flex h-full items-center justify-center">
<p class="text-text-muted">Sélectionnez une note pour commencer</p>
</div>
}
}
}
</div>
+329 -44
View File
@@ -1,4 +1,5 @@
import { Component, ChangeDetectionStrategy, HostListener, inject, signal, computed, effect, ElementRef, OnDestroy, OnInit } from '@angular/core';
import { isObservable } from 'rxjs';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
// Services
@@ -16,11 +17,14 @@ import { GraphViewContainerV2Component } from './components/graph-view-container
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 { DrawingsEditorComponent } from './app/features/drawings/drawings-editor.component';
import { DrawingsFileService, ExcalidrawScene } from './app/features/drawings/drawings-file.service';
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 { SearchPanelComponent } from './components/search-panel/search-panel.component';
import { SearchHistoryService } from './core/search/search-history.service';
import { GraphIndexService } from './core/graph/graph-index.service';
import { SearchIndexService } from './core/search/search-index.service';
@@ -50,6 +54,8 @@ interface TocEntry {
AddBookmarkModalComponent,
GraphInlineSettingsComponent,
SearchInputWithAssistantComponent,
SearchPanelComponent,
DrawingsEditorComponent,
],
templateUrl: './app.component.simple.html',
styleUrls: ['./app.component.css'],
@@ -68,12 +74,14 @@ export class AppComponent implements OnInit, OnDestroy {
private readonly searchOrchestrator = inject(SearchOrchestratorService);
private readonly logService = inject(LogService);
private elementRef = inject(ElementRef);
private readonly drawingsFiles = inject(DrawingsFileService);
// --- State Signals ---
isSidebarOpen = signal<boolean>(true);
isOutlineOpen = signal<boolean>(true);
outlineTab = signal<'outline' | 'settings'>('outline');
activeView = signal<'files' | 'graph' | 'tags' | 'search' | 'calendar' | 'bookmarks'>('files');
activeView = signal<'files' | 'graph' | 'tags' | 'search' | 'calendar' | 'bookmarks' | 'drawings'>('files');
currentDrawingPath = signal<string | null>(null);
selectedNoteId = signal<string>('');
sidebarSearchTerm = signal<string>('');
tableOfContents = signal<TocEntry[]>([]);
@@ -87,15 +95,186 @@ export class AppComponent implements OnInit, OnDestroy {
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;
}
if (typeof window === 'undefined') return;
this.viewportWidth.set(window.innerWidth);
};
// --- Search cross-UI sync ---
onHeaderSearchOptionsChange(opts: { caseSensitive: boolean; regexMode: boolean; highlight: boolean }): void {
this.lastCaseSensitive.set(!!opts.caseSensitive);
this.lastRegexMode.set(!!opts.regexMode);
this.lastHighlightEnabled.set(opts.highlight !== false);
this.scheduleApplyDocumentHighlight();
}
onFileNodeSelected(noteId: string): void {
if (!noteId) return;
const meta = this.vaultService.getFastMetaById(noteId);
const p = meta?.path || '';
if (/\.excalidraw(?:\.md)?$/i.test(p)) {
this.openDrawing(p);
return;
}
this.selectNote(noteId);
}
openDrawing(path: string): void {
const p = (path || '').replace(/^\/+/, '');
if (!p) return;
this.currentDrawingPath.set(p);
this.activeView.set('drawings');
}
async createNewDrawing(): Promise<void> {
// Build a timestamped filename inside Inbox/ by default
const now = new Date();
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, '0');
const d = String(now.getDate()).padStart(2, '0');
const hh = String(now.getHours()).padStart(2, '0');
const mm = String(now.getMinutes()).padStart(2, '0');
const ss = String(now.getSeconds()).padStart(2, '0');
const filename = `Drawing ${y}-${m}-${d} ${hh}.${mm}.${ss}.excalidraw.md`;
const path = filename;
const scene: ExcalidrawScene = { elements: [], appState: {}, files: {} };
try {
await this.drawingsFiles.put(path, scene).toPromise();
this.openDrawing(path);
this.logService.log('NAVIGATE', { action: 'DRAWING_CREATED', path });
} catch (e) {
this.logService.log('NAVIGATE', { action: 'DRAWING_CREATE_FAILED', path, error: String(e) }, 'error');
}
}
onSidebarSearchTermChange(term: string): void {
const t = (term ?? '').trim();
this.lastSearchTerm.set(t);
this.scheduleApplyDocumentHighlight();
}
onSidebarOptionsChange(opts: { caseSensitive?: boolean; regexMode?: boolean; highlight?: boolean }): void {
if (typeof opts.caseSensitive === 'boolean') this.lastCaseSensitive.set(opts.caseSensitive);
if (typeof opts.regexMode === 'boolean') this.lastRegexMode.set(opts.regexMode);
if (typeof opts.highlight === 'boolean') this.lastHighlightEnabled.set(opts.highlight);
this.scheduleApplyDocumentHighlight();
}
// --- Active document highlight ---
private highlightScheduled = false;
private scheduleApplyDocumentHighlight(): void {
if (this.highlightScheduled) return;
this.highlightScheduled = true;
queueMicrotask(() => {
this.highlightScheduled = false;
this.applyActiveDocumentHighlight();
});
}
private clearActiveDocumentHighlight(): void {
const host = (this.elementRef.nativeElement as HTMLElement).querySelector('.note-content-area');
if (!host) return;
host.querySelectorAll('mark.doc-highlight').forEach(mark => {
const parent = mark.parentNode as Node | null;
if (!parent) return;
const text = document.createTextNode(mark.textContent || '');
parent.replaceChild(text, mark);
parent.normalize?.();
});
}
private applyActiveDocumentHighlight(): void {
const term = this.lastSearchTerm().trim();
const enabled = this.lastHighlightEnabled();
const caseSensitive = this.lastCaseSensitive();
const regexMode = this.lastRegexMode();
// Clear previous highlights first
this.clearActiveDocumentHighlight();
if (!enabled || !term) return;
const container = (this.elementRef.nativeElement as HTMLElement).querySelector('.note-content-area');
if (!container) return;
// Inner content rendered by NoteViewerComponent
const contentRoot = container.querySelector<HTMLElement>('app-note-viewer');
const rootEl = contentRoot ?? container;
const patterns: RegExp[] = [];
try {
if (regexMode) {
const flags = caseSensitive ? 'g' : 'gi';
patterns.push(new RegExp(term, flags));
} else {
const flags = caseSensitive ? 'g' : 'gi';
const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
patterns.push(new RegExp(escaped, flags));
}
} catch {
return;
}
const walker = document.createTreeWalker(rootEl, NodeFilter.SHOW_TEXT, {
acceptNode: (node) => {
const parentEl = node.parentElement as HTMLElement | null;
if (!parentEl) return NodeFilter.FILTER_REJECT;
// Skip inside code/pre
const tag = parentEl.tagName.toLowerCase();
if (tag === 'code' || tag === 'pre' || parentEl.closest('code,pre')) return NodeFilter.FILTER_REJECT;
if (parentEl.closest('button, a.md-wiki-link')) return NodeFilter.FILTER_REJECT;
if (!node.nodeValue || !node.nodeValue.trim()) return NodeFilter.FILTER_REJECT;
return NodeFilter.FILTER_ACCEPT;
}
} as any);
const maxMarks = 1000;
let count = 0;
const nodesToProcess: Text[] = [];
while (walker.nextNode()) {
nodesToProcess.push(walker.currentNode as Text);
if (nodesToProcess.length > 5000) break;
}
for (const textNode of nodesToProcess) {
let nodeText = textNode.nodeValue || '';
let replaced = false;
const fragments: (string | HTMLElement)[] = [];
let lastIndex = 0;
const pattern = patterns[0];
pattern.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = pattern.exec(nodeText)) && count < maxMarks) {
const start = m.index;
const end = m.index + m[0].length;
if (start > lastIndex) fragments.push(nodeText.slice(lastIndex, start));
const mark = document.createElement('mark');
mark.className = 'doc-highlight bg-yellow-200 dark:bg-yellow-600 text-gray-900 dark:text-gray-900 px-0.5 rounded';
mark.textContent = nodeText.slice(start, end);
fragments.push(mark);
lastIndex = end;
replaced = true;
count++;
if (!regexMode) pattern.lastIndex = end; // ensure forward progress
}
if (!replaced) continue;
if (lastIndex < nodeText.length) fragments.push(nodeText.slice(lastIndex));
const parent = textNode.parentNode as Node | null;
if (!parent) continue;
const frag = document.createDocumentFragment();
for (const part of fragments) {
if (typeof part === 'string') {
frag.appendChild(document.createTextNode(part));
} else {
frag.appendChild(part);
}
}
parent.replaceChild(frag, textNode);
}
}
isDesktopView = computed<boolean>(() => this.viewportWidth() >= 1024);
private wasDesktop = false;
@@ -111,8 +290,37 @@ export class AppComponent implements OnInit, OnDestroy {
private calendarSearchTriggered = false;
private pendingWikiNavigation = signal<{ noteId: string; heading?: string; block?: string } | null>(null);
// --- File explorer filter ---
fileExplorerFilter = signal<string>('');
filteredExplorerTree = computed<VaultNode[]>(() => {
const term = this.fileExplorerFilter().trim().toLowerCase();
const tree = this.effectiveFileTree();
if (!term) return tree;
const filterNodes = (nodes: VaultNode[]): VaultNode[] => {
const out: VaultNode[] = [];
for (const n of nodes) {
if (n.type === 'file') {
if (n.name.toLowerCase().includes(term)) out.push(n);
} else {
const children = filterNodes(n.children);
if (n.name.toLowerCase().includes(term) || children.length > 0) {
out.push({ ...n, children, isOpen: true });
}
}
}
return out;
};
return filterNodes(tree);
});
readonly isDarkMode = this.themeService.isDark;
// --- Cross-UI search/highlight state for active document ---
private lastSearchTerm = signal<string>('');
private lastHighlightEnabled = signal<boolean>(true);
private lastCaseSensitive = signal<boolean>(false);
private lastRegexMode = signal<boolean>(false);
// Bookmark state
readonly isCurrentNoteBookmarked = computed(() => {
const noteId = this.selectedNoteId();
@@ -141,6 +349,11 @@ export class AppComponent implements OnInit, OnDestroy {
// --- Data Signals ---
fileTree = this.vaultService.fileTree;
fastFileTree = this.vaultService.fastFileTree;
effectiveFileTree = computed<VaultNode[]>(() => {
const fast = this.fastFileTree();
return fast && fast.length ? fast : this.fileTree();
});
graphData = this.vaultService.graphData;
allTags = this.vaultService.tags;
vaultName = this.vaultService.vaultName;
@@ -204,30 +417,40 @@ export class AppComponent implements OnInit, OnDestroy {
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));
const tree = this.effectiveFileTree();
if (!term || term.startsWith('#')) return tree;
// Simple flat search for files (top-level only in this view)
return tree.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 raw = this.sidebarSearchTerm().trim();
if (!raw) return null;
// Support both syntaxes: "#tag" and "tag:foo/bar"
if (raw.startsWith('#')) {
const v = raw.slice(1).trim();
return v ? v.toLowerCase() : null;
}
const tag = rawTerm.slice(1).trim();
return tag ? tag.toLowerCase() : null;
if (/^tag:/i.test(raw)) {
const v = raw.slice(4).trim();
return v ? v.toLowerCase() : null;
}
return null;
});
activeTagDisplay = computed<string | null>(() => {
const rawTerm = this.sidebarSearchTerm().trim();
if (!rawTerm.startsWith('#')) {
return null;
const raw = this.sidebarSearchTerm().trim();
if (!raw) return null;
if (raw.startsWith('#')) {
return raw.slice(1).trim() || null;
}
const displayTag = rawTerm.slice(1).trim();
return displayTag || null;
if (/^tag:/i.test(raw)) {
return raw.slice(4).trim() || null;
}
return null;
});
searchResults = computed<Note[]>(() => {
const notes = this.vaultService.allNotes();
@@ -239,7 +462,13 @@ export class AppComponent implements OnInit, OnDestroy {
const tagFilter = this.activeTagFilter();
const effectiveQuery = tagFilter ? `tag:${tagFilter}` : rawQuery;
const results = this.searchOrchestrator.execute(effectiveQuery);
const exec = this.searchOrchestrator.execute(effectiveQuery);
if (isObservable(exec)) {
// Meilisearch path returns Observable; this computed must return synchronously.
// Defer to SearchPanel for reactive results; here return empty until other UI handles it.
return [];
}
const results = exec;
if (!results.length) {
return [];
}
@@ -295,8 +524,11 @@ export class AppComponent implements OnInit, OnDestroy {
const html = this.renderedNoteContent();
if (html && note) {
this.generateToc(html);
// Apply search highlights after content render
this.scheduleApplyDocumentHighlight();
} else {
this.tableOfContents.set([]);
this.clearActiveDocumentHighlight();
}
});
@@ -434,18 +666,37 @@ export class AppComponent implements OnInit, OnDestroy {
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,
});
}
// Build a date query for Meilisearch based on returned files
let minT = Number.POSITIVE_INFINITY;
let maxT = 0;
const toTime = (s?: string | null) => {
if (!s) return NaN;
const t = Date.parse(s);
return Number.isNaN(t) ? NaN : t;
};
for (const f of files) {
const ct = toTime(f.createdAt);
const ut = toTime(f.updatedAt);
if (!Number.isNaN(ct)) { minT = Math.min(minT, ct); maxT = Math.max(maxT, ct); }
if (!Number.isNaN(ut)) { minT = Math.min(minT, ut); maxT = Math.max(maxT, ut); }
}
if (Number.isFinite(minT) && maxT > 0) {
const toYMD = (ms: number) => new Date(ms).toISOString().slice(0, 10);
const startYMD = toYMD(minT);
const endYMD = toYMD(maxT);
const query = startYMD === endYMD ? `date:${startYMD}` : `from:${startYMD} to:${endYMD}`;
this.sidebarSearchTerm.set(query);
}
// Open search view and reset loading flag
this.isSidebarOpen.set(true);
this.activeView.set('search');
this.calendarSearchTriggered = false;
// Log calendar search execution
this.logService.log('CALENDAR_SEARCH_EXECUTED', {
resultsCount: files.length,
});
}
onCalendarSearchStateChange(state: 'idle' | 'loading' | 'error'): void {
@@ -560,7 +811,7 @@ export class AppComponent implements OnInit, OnDestroy {
handle?.addEventListener('lostpointercapture', cleanup);
}
setView(view: 'files' | 'graph' | 'tags' | 'search' | 'calendar' | 'bookmarks'): void {
setView(view: 'files' | 'graph' | 'tags' | 'search' | 'calendar' | 'bookmarks' | 'drawings'): void {
const previousView = this.activeView();
this.activeView.set(view);
this.sidebarSearchTerm.set('');
@@ -573,12 +824,34 @@ export class AppComponent implements OnInit, OnDestroy {
}
}
selectNote(noteId: string): void {
const note = this.vaultService.getNoteById(noteId);
async selectNote(noteId: string): Promise<void> {
let note = this.vaultService.getNoteById(noteId);
if (!note) {
return;
// Try to lazy-load using Meilisearch/slug mapping
const ok = await this.vaultService.ensureNoteLoadedById(noteId);
if (!ok) {
// Fallback: if we have fast metadata, map to slug id from path
const meta = this.vaultService.getFastMetaById(noteId);
if (meta?.path) {
await this.vaultService.ensureNoteLoadedByPath(meta.path);
const slugId = this.vaultService.buildSlugIdFromPath(meta.path);
note = this.vaultService.getNoteById(slugId);
}
} else {
note = this.vaultService.getNoteById(noteId);
if (!note) {
// If Meili id differs from slug id, try mapping
const meta = this.vaultService.getFastMetaById(noteId);
if (meta?.path) {
const slugId = this.vaultService.buildSlugIdFromPath(meta.path);
note = this.vaultService.getNoteById(slugId);
}
}
}
}
if (!note) return;
this.vaultService.ensureFolderOpen(note.originalPath);
this.selectedNoteId.set(note.id);
this.markdownViewerService.setCurrentNote(note);
@@ -594,14 +867,15 @@ export class AppComponent implements OnInit, OnDestroy {
}
handleTagClick(tagName: string): void {
const normalized = tagName.replace(/^#/, '').trim();
const normalized = tagName.replace(/^#/, '').replace(/\\/g, '/').trim();
if (!normalized) {
return;
}
this.isSidebarOpen.set(true);
this.activeView.set('search');
this.sidebarSearchTerm.set(`#${normalized}`);
// Use Meilisearch-friendly syntax
this.sidebarSearchTerm.set(`tag:${normalized}`);
}
updateSearchTerm(term: string, focusSearch = false): void {
@@ -623,6 +897,17 @@ export class AppComponent implements OnInit, OnDestroy {
query: query.trim(),
queryLength: query.trim().length,
});
// Track for document highlight
this.lastSearchTerm.set(query.trim());
this.scheduleApplyDocumentHighlight();
// Also emit to sync sidebar options if needed
this.onSidebarOptionsChange({
caseSensitive: this.lastCaseSensitive(),
regexMode: this.lastRegexMode(),
highlight: this.lastHighlightEnabled()
});
}
}
@@ -749,11 +1034,11 @@ export class AppComponent implements OnInit, OnDestroy {
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);
}
const path = String(bookmark.path);
this.vaultService.ensureNoteLoadedByPath(path).then(() => {
const slugId = this.vaultService.buildSlugIdFromPath(path);
this.selectNote(slugId);
});
}
}
@@ -831,7 +1116,7 @@ export class AppComponent implements OnInit, OnDestroy {
this.tableOfContents.set(toc);
}
private scrollToHeading(id: string): void {
scrollToHeading(id: string): void {
const contentArea = (this.elementRef.nativeElement as HTMLElement).querySelector('.note-content-area');
if (!contentArea) {
return;
@@ -0,0 +1,143 @@
<div class="flex flex-col h-[calc(100vh-180px)] lg:h-[calc(100vh-140px)] gap-2">
<!-- En-tête avec les boutons et les états -->
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<!-- Indicateur de sauvegarde (cliquable) -->
<button
type="button"
class="flex items-center gap-2 px-3 py-2 rounded-md transition-colors"
[class.cursor-pointer]="!isLoading() && !isSaving()"
[class.cursor-wait]="isSaving()"
[class.cursor-not-allowed]="isLoading()"
(click)="saveNow()"
[disabled]="isLoading()"
title="{{dirty() ? 'Non sauvegardé - Cliquer pour sauvegarder (Ctrl+S)' : isSaving() ? 'Sauvegarde en cours...' : 'Sauvegardé'}}"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
[class.text-red-500]="dirty() && !isSaving()"
[class.text-gray-400]="!dirty() && !isSaving()"
[class.text-yellow-500]="isSaving()"
[class.animate-pulse]="isSaving()"
>
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path>
<polyline points="17 21 17 13 7 13 7 21"></polyline>
<polyline points="7 3 7 8 15 8"></polyline>
</svg>
<span class="text-xs font-medium" [class.text-red-500]="dirty() && !isSaving()" [class.text-gray-400]="!dirty() && !isSaving()" [class.text-yellow-500]="isSaving()">
{{isSaving() ? 'Sauvegarde...' : dirty() ? 'Non sauvegardé' : 'Sauvegardé'}}
</span>
</button>
<button
type="button"
class="btn btn-sm"
(click)="exportPNG()"
[disabled]="isLoading() || isSaving() || !excalidrawReady"
>
🖼️ Export PNG
</button>
<button
type="button"
class="btn btn-sm"
(click)="exportSVG()"
[disabled]="isLoading() || isSaving() || !excalidrawReady"
>
🧩 Export SVG
</button>
<button
type="button"
class="btn btn-sm"
(click)="toggleFullscreen()"
[disabled]="isLoading()"
[title]="isFullscreen() ? 'Quitter le mode pleine écran' : 'Passer en mode pleine écran'"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
[class.text-blue-500]="isFullscreen()"
>
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"></path>
<polyline points="10,17 15,12 10,7"></polyline>
<line x1="15" x2="3" y1="12" y2="12"></line>
</svg>
{{isFullscreen() ? 'Quitter FS' : 'Pleine écran'}}
</button>
</div>
<!-- Conflit: fichier modifié sur le disque -->
<div *ngIf="hasConflict()" class="rounded-md border border-amber-600 bg-amber-900/30 text-amber-200 px-3 py-2">
<div class="flex items-center justify-between gap-3">
<div>
<strong>Conflit détecté</strong> — Le fichier a été modifié sur le disque. Choisissez une action.
</div>
<div class="flex items-center gap-2">
<button class="btn btn-xs" (click)="reloadFromDisk()">Recharger depuis le disque</button>
<button class="btn btn-xs btn-danger" (click)="resolveConflictOverwrite()">Écraser par la version locale</button>
</div>
</div>
</div>
<!-- Affichage des erreurs seulement -->
<div class="flex items-center gap-4" *ngIf="error() as err">
<div class="text-xs text-red-600">
{{ err }}
</div>
</div>
<!-- Fin de l'en-tête -->
</div>
<!-- État de chargement -->
<div *ngIf="isLoading()" class="flex-1 flex items-center justify-center">
<div class="text-center">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-2"></div>
<p class="text-text-muted">Chargement du dessin...</p>
</div>
</div>
<!-- Éditeur Excalidraw -->
<div
*ngIf="!isLoading()"
class="flex-1 min-h-0 rounded-xl border border-border bg-card overflow-hidden relative excalidraw-host"
[class.opacity-50]="isSaving()"
>
<excalidraw-editor
#editorEl
[initialData]="scene() || { elements: [], appState: { viewBackgroundColor: '#1e1e1e' } }"
[theme]="themeName()"
[lang]="'fr'"
(ready)="console.log('READY', $event); onExcalidrawReady()"
style="display:block; height:100%; width:100%"
></excalidraw-editor>
</div>
<!-- Toast notifications -->
<div *ngIf="toastMessage() as msg" class="fixed bottom-4 right-4 z-50">
<div
class="px-3 py-2 rounded-md shadow-md text-sm"
[ngClass]="{
'bg-green-600 text-white': toastType() === 'success',
'bg-red-600 text-white': toastType() === 'error',
'bg-gray-700 text-white': toastType() === 'info'
}"
>
{{ msg }}
</div>
</div>
</div>
@@ -0,0 +1,792 @@
import { CommonModule } from '@angular/common';
import {
AfterViewInit,
ChangeDetectionStrategy,
Component,
CUSTOM_ELEMENTS_SCHEMA,
DestroyRef,
ElementRef,
HostListener,
Input,
OnDestroy,
OnInit,
ViewChild,
computed,
inject,
signal
} from '@angular/core';
import { firstValueFrom, fromEvent, of, Subscription, interval } from 'rxjs';
import { debounceTime, distinctUntilChanged, map, switchMap, tap, catchError, filter } from 'rxjs/operators';
import { DrawingsFileService, ExcalidrawScene } from './drawings-file.service';
import { ExcalidrawIoService } from './excalidraw-io.service';
import { DrawingsPreviewService } from './drawings-preview.service';
import { ThemeService } from '../../core/services/theme.service';
@Component({
selector: 'app-drawings-editor',
standalone: true,
imports: [CommonModule],
templateUrl: './drawings-editor.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class DrawingsEditorComponent implements OnInit, AfterViewInit, OnDestroy {
@Input() path: string = '';
@ViewChild('editorEl', { static: false }) editorEl?: ElementRef<HTMLElement & {
setScene: (scene: any) => void;
__excalidrawAPI: any;
}>;
private readonly files = inject(DrawingsFileService);
private readonly excalIo = inject(ExcalidrawIoService);
private readonly previews = inject(DrawingsPreviewService);
private readonly theme = inject(ThemeService);
private readonly destroyRef = inject(DestroyRef);
// Propriétés réactives accessibles depuis le template
scene = signal<ExcalidrawScene | null>(null);
error = signal<string | null>(null);
isLoading = signal<boolean>(true);
isSaving = signal<boolean>(false);
dirty = signal<boolean>(false);
// Toast and conflict state
toastMessage = signal<string | null>(null);
toastType = signal<'success' | 'error' | 'info'>('info');
hasConflict = signal<boolean>(false);
isFullscreen = signal<boolean>(false);
private saveSub: Subscription | null = null;
private dirtyCheckSub: Subscription | null = null;
private sceneUpdateSub: Subscription | null = null;
private lastSavedHash: string | null = null;
private pollSub: Subscription | null = null;
excalidrawReady = false;
private hostListenersBound = false;
private latestSceneFromEvents: ExcalidrawScene | null = null;
readonly themeName = computed<'light' | 'dark'>(() => (this.theme.isDark() ? 'dark' : 'light'));
async ngOnInit(): Promise<void> {
try {
// Lazy register the custom element
await import('../../../../web-components/excalidraw/define');
this.setupExcalidraw();
} catch (err) {
console.error('Failed to load Excalidraw:', err);
this.error.set('Erreur de chargement de l\'éditeur de dessin.');
}
}
private waitForNextSceneChange(host: any, timeoutMs = 300): Promise<ExcalidrawScene | null> {
return new Promise((resolve) => {
let done = false;
const handler = (e: any) => {
if (done) return;
done = true;
try {
const detail = e?.detail as Partial<ExcalidrawScene> | undefined;
const normalized = detail ? this.normalizeSceneDetail(detail) : null;
resolve(normalized);
} catch {
resolve(null);
}
};
try { host.addEventListener('scene-change', handler, { once: true }); } catch {}
setTimeout(() => {
if (done) return;
done = true;
try { host.removeEventListener('scene-change', handler as any); } catch {}
resolve(null);
}, timeoutMs);
});
}
// Export current canvas as PNG and save alongside the drawing file
private async savePreviewPNG(): Promise<void> {
const host = this.editorEl?.nativeElement;
if (!host || !this.excalidrawReady) return;
try {
const blob = await this.previews.exportPNGFromElement(host, true);
if (!blob) return;
const base = String(this.path || '').replace(/\.excalidraw(?:\.md)?$/i, '');
const target = `${base}.png`;
await firstValueFrom(this.files.putBinary(target, blob, 'image/png'));
} catch (e) {
// Non bloquant
console.warn('savePreviewPNG failed', e);
}
}
// Overwrite on-disk file with current local scene (resolves conflict)
async resolveConflictOverwrite(): Promise<void> {
try {
const host: any = this.editorEl?.nativeElement;
const scene = host?.getScene ? host.getScene() : this.scene();
if (!scene) return;
this.isSaving.set(true);
await firstValueFrom(this.files.putForce(this.path, scene));
this.lastSavedHash = this.hashScene(scene);
this.dirty.set(false);
this.isSaving.set(false);
this.hasConflict.set(false);
this.showToast('Conflit résolu: fichier écrasé', 'success');
} catch (e) {
console.error('Overwrite error:', e);
this.isSaving.set(false);
this.showToast('Échec de l\'écrasement', 'error');
}
}
// Ctrl+S triggers manual save
@HostListener('window:keydown', ['$event'])
onKeyDown(ev: KeyboardEvent) {
if ((ev.ctrlKey || ev.metaKey) && (ev.key === 's' || ev.key === 'S')) {
ev.preventDefault();
this.saveNow();
}
// F11 toggles fullscreen
if (ev.key === 'F11') {
ev.preventDefault();
this.toggleFullscreen();
}
}
// Manual save now using the current scene from the editor API
async saveNow(): Promise<void> {
console.log('💾 Manual save triggered');
try {
const host: any = this.editorEl?.nativeElement;
if (!host) {
console.error('❌ No host element');
this.showToast('Éditeur non prêt', 'error');
return;
}
// Ensure the editor API is ready before proceeding
if (!host.__excalidrawAPI) {
const gotReady = await this.waitForReady(host, 1500);
if (!gotReady) {
console.warn('⏳ Save aborted: editor API not ready');
this.showToast('Éditeur en cours d’initialisation, réessayez', 'error');
return;
}
}
// Proactively ask the web component to dispatch a fresh scene event
try { host.__emitSceneChange?.(); } catch {}
// Yield to next frames to ensure Excalidraw applied last input
await this.awaitNextFrame();
await this.awaitNextFrame();
// Try multiple ways to get the scene to avoid race conditions
let scene = this.getCurrentSceneFromHost(host) || null;
const stateScene = this.scene();
if (!scene || (Array.isArray((scene as any).elements) ? (scene as any).elements.length : 0) < (Array.isArray((stateScene as any)?.elements) ? (stateScene as any).elements.length : 0)) {
scene = stateScene as any;
}
if (!scene || (Array.isArray((scene as any).elements) ? (scene as any).elements.length : 0) === 0) {
// If we have a recent scene from events with elements, use it immediately
const latest = this.latestSceneFromEvents;
const latestLen = Array.isArray((latest as any)?.elements) ? (latest as any).elements.length : 0;
const currentLen = Array.isArray((scene as any)?.elements) ? (scene as any).elements.length : 0;
if (latestLen > currentLen) {
scene = latest as any;
}
// Register wait BEFORE flushing to not miss the event
const waitPromise = this.waitForNextSceneChange(host, 3000);
try { host.__emitSceneChange?.(); } catch {}
const waited = await waitPromise;
const viaHostAfter = this.getCurrentSceneFromHost(host);
const candidates = [scene, waited, viaHostAfter, this.latestSceneFromEvents, this.scene()].filter(Boolean) as ExcalidrawScene[];
let best: ExcalidrawScene | null = null;
for (const c of candidates) {
const len = Array.isArray((c as any).elements) ? (c as any).elements.length : 0;
const bestLen = best && Array.isArray((best as any).elements) ? (best as any).elements.length : 0;
if (!best || len > bestLen) best = c;
}
scene = best;
}
if (scene) {
console.log('🧩 Snapshot', {
elements: Array.isArray((scene as any).elements) ? (scene as any).elements.length : 'n/a',
hasFiles: !!(scene as any).files && Object.keys((scene as any).files || {}).length,
});
}
if (!scene) {
console.error('❌ No scene data');
this.showToast('Aucune donnée à sauvegarder', 'error');
return;
}
console.log('📤 Sending save request...');
this.isSaving.set(true);
this.error.set(null);
const isExMd = /\.excalidraw\.md$/i.test(this.path || '');
let result: { rev: string } | null = null;
if (isExMd) {
// Use same compression as creation: build Obsidian MD with ```compressed-json```
const md = this.excalIo.toObsidianMd(scene as any, null);
result = await firstValueFrom(this.files.putText(this.path, md));
} else {
// For .excalidraw or .json let server persist plain JSON
result = await firstValueFrom(this.files.put(this.path, scene));
}
console.log('📥 Save response', result);
if (result && typeof result.rev === 'string' && result.rev.length > 0) {
const expectedHash = this.hashScene(scene);
// Verify by reloading from server and comparing hash
const reloaded = await firstValueFrom(this.files.get(this.path));
const actualHash = this.hashScene(reloaded);
const ok = expectedHash === actualHash;
console.log('🔁 Verify after save', {
ok,
expectedHash: expectedHash.slice(0,10),
actualHash: actualHash.slice(0,10),
lastSavedHash: this.lastSavedHash?.slice(0,10) || 'none',
elementsCount: Array.isArray((scene as any).elements) ? (scene as any).elements.length : 0
});
if (!ok) {
this.isSaving.set(false);
this.dirty.set(true);
this.showToast('Vérification échouée: rechargement conseillé', 'error');
return;
}
this.lastSavedHash = actualHash;
this.dirty.set(false);
this.isSaving.set(false);
this.hasConflict.set(false);
console.log('✅ Manual save successful');
this.showToast('Sauvegarde réussie', 'success');
} else {
console.error('❌ Save returned no rev');
this.isSaving.set(false);
this.showToast('Erreur de sauvegarde', 'error');
return;
}
} catch (e: any) {
console.error('❌ Manual save failed:', e);
const status = e?.status ?? e?.statusCode;
if (status === 409) {
this.error.set('Conflit: le fichier a été modifié sur le disque.');
this.hasConflict.set(true);
this.showToast('Conflit détecté', 'error');
} else {
this.error.set('Erreur de sauvegarde.');
this.showToast('Erreur de sauvegarde', 'error');
}
this.isSaving.set(false);
}
}
// More robust scene retrieval from the web component host / API
private getCurrentSceneFromHost(host: any): ExcalidrawScene | null {
try {
const api = host?.__excalidrawAPI;
const hasGetScene = typeof host?.getScene === 'function';
const hasUnderscoreGet = typeof host?.__getScene === 'function';
const hasLastEventGet = typeof host?.__getLastEventScene === 'function';
console.log('🔎 Scene access methods', { hasGetScene, hasUnderscoreGet, hasLastEventGet, apiAvailable: !!api });
const candidates: ExcalidrawScene[] = [] as any;
const viaPublic = hasGetScene ? host.getScene() : null;
if (viaPublic && typeof viaPublic === 'object') {
try { console.log('📸 viaPublic elements', Array.isArray(viaPublic.elements) ? viaPublic.elements.length : 'n/a'); } catch {}
candidates.push(viaPublic);
}
const viaPrivate = hasUnderscoreGet ? host.__getScene() : null;
if (viaPrivate && typeof viaPrivate === 'object') {
try { console.log('📸 viaPrivate elements', Array.isArray(viaPrivate.elements) ? viaPrivate.elements.length : 'n/a'); } catch {}
candidates.push(viaPrivate);
}
const viaLast = hasLastEventGet ? host.__getLastEventScene() : null;
if (viaLast && typeof viaLast === 'object') {
try { console.log('📸 viaLastEvent elements', Array.isArray(viaLast.elements) ? viaLast.elements.length : 'n/a'); } catch {}
candidates.push(viaLast);
}
if (api) {
const elements = api.getSceneElements?.() ?? [];
const appState = api.getAppState?.() ?? {};
const files = api.getFiles?.() ?? {};
try { console.log('📸 viaAPI elements', Array.isArray(elements) ? elements.length : 'n/a'); } catch {}
candidates.push({ elements, appState, files } as ExcalidrawScene);
}
if (candidates.length) {
let best = candidates[0];
for (const c of candidates) {
const len = Array.isArray((c as any).elements) ? (c as any).elements.length : 0;
const bestLen = Array.isArray((best as any).elements) ? (best as any).elements.length : 0;
if (len > bestLen) best = c;
}
return best;
}
} catch (err) {
console.warn('⚠️ Failed to get scene from host/api:', err);
}
return null;
}
private waitForReady(host: any, timeoutMs = 1500): Promise<boolean> {
return new Promise((resolve) => {
if (host?.__excalidrawAPI) return resolve(true);
let done = false;
const onReady = () => { if (!done) { done = true; resolve(true); } };
try { host.addEventListener('ready', onReady, { once: true }); } catch {}
setTimeout(() => {
if (done) return;
done = true;
try { host.removeEventListener('ready', onReady as any); } catch {}
resolve(!!host?.__excalidrawAPI);
}, timeoutMs);
});
}
// Reload from disk to resolve conflict
reloadFromDisk(): void {
if (!this.path) return;
this.isLoading.set(true);
this.files.get(this.path).subscribe({
next: (scene) => {
const transformed = this.prepareSceneData(scene);
this.scene.set(transformed);
this.lastSavedHash = this.hashScene(transformed);
this.dirty.set(false);
this.error.set(null);
this.hasConflict.set(false);
if (this.excalidrawReady && this.editorEl?.nativeElement) {
this.updateExcalidrawScene(transformed);
}
this.isLoading.set(false);
this.showToast('Rechargé depuis le disque', 'info');
},
error: (err) => {
console.error('Reload error:', err);
this.error.set('Erreur lors du rechargement.');
this.isLoading.set(false);
this.showToast('Erreur de rechargement', 'error');
},
});
}
// Simple toast helper
private showToast(message: string, type: 'success' | 'error' | 'info' = 'info'): void {
this.toastMessage.set(message);
this.toastType.set(type);
// auto-hide after 2.5s
setTimeout(() => {
this.toastMessage.set(null);
}, 2500);
}
private setupExcalidraw() {
if (!this.path) {
this.error.set('Aucun chemin de dessin fourni.');
return;
}
this.isLoading.set(true);
// Load initial scene
this.files.get(this.path).subscribe({
next: (scene) => {
try {
// Transform the scene data to match Excalidraw's expected format
const transformedScene = this.prepareSceneData(scene);
this.scene.set(transformedScene);
this.lastSavedHash = this.hashScene(transformedScene);
this.dirty.set(false);
this.error.set(null);
// If Excalidraw is already ready, update the scene
if (this.excalidrawReady && this.editorEl?.nativeElement) {
this.updateExcalidrawScene(transformedScene);
}
} catch (err) {
console.error('Error processing scene data:', err);
this.error.set('Format de dessin non supporté.');
} finally {
this.isLoading.set(false);
}
},
error: (err) => {
console.error('Error loading drawing:', err);
this.error.set('Erreur de chargement du dessin.');
this.isLoading.set(false);
},
});
}
private prepareSceneData(scene: ExcalidrawScene) {
return {
elements: Array.isArray(scene.elements) ? scene.elements : [],
appState: {
viewBackgroundColor: '#1e1e1e',
currentItemFontFamily: 1,
theme: this.themeName(),
...(scene.appState || {})
},
scrollToContent: true,
...(scene.files && { files: scene.files })
};
}
private updateExcalidrawScene(scene: any) {
if (!this.editorEl?.nativeElement) return;
// Try both methods to set the scene
if (this.editorEl.nativeElement.setScene) {
this.editorEl.nativeElement.setScene(scene);
} else if (this.editorEl.nativeElement.__excalidrawAPI?.updateScene) {
this.editorEl.nativeElement.__excalidrawAPI.updateScene(scene);
} else if (this.editorEl.nativeElement.__excalidrawAPI?.setState) {
this.editorEl.nativeElement.__excalidrawAPI.setState({
...scene,
commitToHistory: false
});
}
}
onExcalidrawReady() {
console.log('🎨 Excalidraw Ready - Binding listeners');
this.excalidrawReady = true;
try {
const host: any = this.editorEl?.nativeElement;
console.log('[Angular] READY handler host check', {
hasHost: !!host,
hasAPI: !!host?.__excalidrawAPI,
hasGetScene: typeof host?.getScene === 'function',
has__getScene: typeof host?.__getScene === 'function'
});
} catch {}
const scene = this.scene();
if (scene) {
this.updateExcalidrawScene(scene);
}
// Bind listeners NOW when the editor is confirmed ready
this.bindEditorHostListeners();
}
ngAfterViewInit(): void {
// Fallback: ensure bindings are attached even if the (ready) event never fires
queueMicrotask(() => this.ensureHostListenersFallback());
// Listen for fullscreen changes
document.addEventListener('fullscreenchange', () => {
this.isFullscreen.set(!!document.fullscreenElement);
});
}
private ensureHostListenersFallback(attempt = 0): void {
if (this.hostListenersBound) {
return;
}
const host = this.editorEl?.nativeElement;
if (!host) {
if (attempt >= 20) {
console.warn('[Angular] Fallback binding aborted - host still missing');
return;
}
setTimeout(() => this.ensureHostListenersFallback(attempt + 1), 100);
return;
}
console.warn('[Angular] Fallback binding listeners without ready event', { attempt });
this.excalidrawReady = true;
const scene = this.scene();
if (scene) {
this.updateExcalidrawScene(scene);
}
this.bindEditorHostListeners();
}
// Ensure we bind to the web component once it exists
private bindEditorHostListeners(): void {
if (this.hostListenersBound) return;
const host = this.editorEl?.nativeElement;
if (!host) {
console.warn('⚠️ Cannot bind listeners - host element not found');
return;
}
console.log('🔗 Binding Excalidraw host listeners', {
path: this.path,
lastSavedHash: (this.lastSavedHash || 'null').slice(0, 10)
});
this.hostListenersBound = true;
// Raw debug listeners to validate event propagation
try {
console.log('[Angular] 🎧 Attaching raw event listeners to host', { hostTagName: host.tagName });
host.addEventListener('ready', (e: any) => {
console.log('[Angular] (raw) READY received on host', e?.detail);
});
host.addEventListener('scene-change', (e: any) => {
const det = e?.detail;
const elCount = Array.isArray(det?.elements) ? det.elements.length : 'n/a';
console.log('[Angular] (raw) SCENE-CHANGE received on host', { elCount, detail: det });
});
document.addEventListener('scene-change', (e: any) => {
const det = e?.detail;
const elCount = Array.isArray(det?.elements) ? det.elements.length : 'n/a';
console.log('[Angular] (raw) SCENE-CHANGE received on document', { elCount });
});
console.log('[Angular] ✅ Raw event listeners attached successfully');
} catch (err) {
console.error('[Angular] ❌ Failed to attach raw listeners', err);
}
const sceneChange$ = fromEvent<CustomEvent>(host, 'scene-change').pipe(
debounceTime(250), // Short debounce for responsiveness
map((event) => {
const viaHost = this.getCurrentSceneFromHost(host);
const detail = event?.detail as Partial<ExcalidrawScene> | undefined;
const viaDetail = detail ? this.normalizeSceneDetail(detail) : null;
const candidates = [viaHost, viaDetail].filter(Boolean) as ExcalidrawScene[];
if (candidates.length === 0) {
console.warn('[Angular] Scene-change emitted without retrievable scene data');
return null;
}
// prefer the candidate with the highest number of elements
let best: ExcalidrawScene = candidates[0];
for (const c of candidates) {
const len = Array.isArray((c as any).elements) ? (c as any).elements.length : 0;
const bestLen = Array.isArray((best as any).elements) ? (best as any).elements.length : 0;
if (len > bestLen) best = c;
}
return best;
}),
filter((scene): scene is ExcalidrawScene => !!scene),
map(scene => ({ scene, hash: this.hashScene(scene) }))
);
// Subscription 1: Update the UI dirty status
this.dirtyCheckSub = sceneChange$.subscribe(({ hash }) => {
// For UX: immediately mark as dirty on any scene-change
console.log('✏️ Dirty flagged (event)', {
lastSaved: (this.lastSavedHash || 'null').slice(0, 10),
current: hash.slice(0, 10)
});
if (!this.dirty()) this.dirty.set(true);
});
this.sceneUpdateSub = sceneChange$.subscribe(({ scene }) => {
try {
const elements = Array.isArray((scene as any).elements) ? (scene as any).elements : [];
const appState = (scene as any).appState || {};
const files = (scene as any).files || {};
const updated = { elements, appState, files } as ExcalidrawScene;
this.scene.set(updated);
this.latestSceneFromEvents = updated;
} catch {}
});
// Fallback polling: update dirty flag even if events don't fire
this.pollSub = interval(1000).subscribe(() => {
const current = this.getCurrentSceneFromHost(host);
if (!current) return;
const hash = this.hashScene(current);
const isDirty = this.lastSavedHash !== hash;
if (isDirty !== this.dirty()) {
console.log('🕒 Dirty check (poll)', {
lastSaved: (this.lastSavedHash || 'null').slice(0, 10),
current: hash.slice(0, 10),
isDirty
});
this.dirty.set(isDirty);
}
});
// Subscription 2: Handle auto-saving (disabled temporarily)
// this.saveSub = sceneChange$.pipe(
// distinctUntilChanged((prev, curr) => prev.hash === curr.hash),
// debounceTime(2000),
// filter(({ hash }) => this.lastSavedHash !== hash),
// filter(() => !this.isSaving()),
// tap(({ hash }) => {
// console.log('💾 Autosaving... hash:', hash.substring(0, 10));
// this.isSaving.set(true);
// this.error.set(null);
// }),
// switchMap(({ scene, hash }) =>
// this.files.put(this.path, scene).pipe(
// tap(async () => {
// console.log('✅ Autosave successful', { newHash: hash.substring(0, 10) });
// this.lastSavedHash = hash;
// this.dirty.set(false);
// this.isSaving.set(false);
// this.hasConflict.set(false);
// try { await this.savePreviewPNG(); } catch (e) { console.warn('PNG preview update failed', e); }
// }),
// catchError((e) => {
// console.error('❌ Save error:', e);
// const status = e?.status ?? e?.statusCode;
// if (status === 409) {
// this.error.set('Conflit: le fichier a été modifié sur le disque.');
// this.hasConflict.set(true);
// } else {
// this.error.set('Erreur de sauvegarde. Veuillez réessayer.');
// }
// this.isSaving.set(false);
// return of({ rev: '' } as any);
// })
// )
// )
// ).subscribe();
console.log('✓ Dirty check and Autosave subscriptions active');
// Cleanup on destroy
this.destroyRef.onDestroy(() => {
console.log('🧹 Cleaning up listeners');
this.saveSub?.unsubscribe();
this.dirtyCheckSub?.unsubscribe();
this.sceneUpdateSub?.unsubscribe();
this.pollSub?.unsubscribe();
this.saveSub = null;
this.dirtyCheckSub = null;
this.sceneUpdateSub = null;
this.pollSub = null;
this.hostListenersBound = false;
});
}
ngOnDestroy(): void {
this.saveSub?.unsubscribe();
// Clean up fullscreen listener
document.removeEventListener('fullscreenchange', () => {
this.isFullscreen.set(!!document.fullscreenElement);
});
}
private awaitNextFrame(): Promise<void> {
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
}
@HostListener('window:beforeunload', ['$event'])
handleBeforeUnload(event: BeforeUnloadEvent): string | null {
if (this.dirty()) {
event.preventDefault();
// @ts-ignore - for older browsers
event.returnValue = '';
return '';
}
return null;
}
async exportPNG(): Promise<void> {
try {
const host = this.editorEl?.nativeElement;
if (!host || !this.excalidrawReady) {
console.error('Excalidraw host element not found');
this.showToast('Éditeur non prêt', 'error');
return;
}
const blob = await this.previews.exportPNGFromElement(host, true);
if (!blob) {
console.error('Failed to generate PNG blob');
this.showToast('Export PNG indisponible', 'error');
return;
}
const base = String(this.path || '').replace(/\.excalidraw(?:\.md)?$/i, '');
const target = `${base}.png`;
await firstValueFrom(this.files.putBinary(target, blob, 'image/png'));
this.error.set(null);
} catch (err) {
console.error('Error exporting PNG:', err);
this.error.set('Erreur lors de l\'export en PNG');
this.showToast('Erreur export PNG', 'error');
}
}
async exportSVG(): Promise<void> {
try {
const host = this.editorEl?.nativeElement;
if (!host || !this.excalidrawReady) {
console.error('Excalidraw host element not found or not ready');
this.showToast('Éditeur non prêt', 'error');
return;
}
const blob = await this.previews.exportSVGFromElement(host, true);
if (!blob) {
console.error('Failed to generate SVG blob');
this.showToast('Export SVG indisponible', 'error');
return;
}
const base = String(this.path || '').replace(/\.excalidraw(?:\.md)?$/i, '');
const target = `${base}.svg`;
await firstValueFrom(this.files.putBinary(target, blob, 'image/svg+xml'));
this.error.set(null);
} catch (err) {
console.error('Error exporting SVG:', err);
this.error.set('Erreur lors de l\'export en SVG');
this.showToast('Erreur export SVG', 'error');
}
}
toggleFullscreen(): void {
if (!document.fullscreenEnabled) {
this.showToast('Mode pleine écran non supporté', 'error');
return;
}
if (document.fullscreenElement) {
// Sortir du mode pleine écran
document.exitFullscreen().catch(err => {
console.error('Erreur lors de la sortie du mode pleine écran:', err);
this.showToast('Erreur sortie pleine écran', 'error');
});
} else {
// Entrer en mode pleine écran
const element = this.editorEl?.nativeElement?.parentElement;
if (element) {
element.requestFullscreen().catch(err => {
console.error('Erreur lors de l\'entrée en mode pleine écran:', err);
this.showToast('Erreur entrée pleine écran', 'error');
});
}
}
}
private hashScene(scene: ExcalidrawScene | null): string {
try {
if (!scene) return '';
// Normalize elements: remove volatile properties
const normEls = Array.isArray(scene.elements) ? scene.elements.map((el: any) => {
const { version, versionNonce, updated, ...rest } = el || {};
return rest;
}) : [];
// Stable sort of elements by id to avoid hash changes due to order
const sortedEls = normEls.slice().sort((a: any, b: any) => (a?.id || '').localeCompare(b?.id || ''));
// Stable sort of files keys to avoid hash changes due to key order
const filesObj = scene.files && typeof scene.files === 'object' ? scene.files : {};
const sortedFilesKeys = Object.keys(filesObj).sort();
const sortedFiles: Record<string, any> = {};
for (const k of sortedFilesKeys) sortedFiles[k] = filesObj[k];
const stable = { elements: sortedEls, files: sortedFiles };
return btoa(unescape(encodeURIComponent(JSON.stringify(stable))));
} catch (error) {
console.error('Error hashing scene:', error);
return '';
}
}
private normalizeSceneDetail(detail: Partial<ExcalidrawScene>): ExcalidrawScene {
const elements = Array.isArray(detail?.elements) ? detail.elements : [];
const appState = typeof detail?.appState === 'object' && detail?.appState ? detail.appState : {};
const files = typeof detail?.files === 'object' && detail?.files ? detail.files : {};
return { elements, appState, files };
}
}
@@ -0,0 +1,102 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Observable, map, tap } from 'rxjs';
export type ThemeName = 'light' | 'dark';
export interface ExcalidrawScene {
elements: any[];
appState?: Record<string, any>;
files?: Record<string, any>;
}
@Injectable({ providedIn: 'root' })
export class DrawingsFileService {
private etagCache = new Map<string, string>();
constructor(private http: HttpClient) {}
get(path: string): Observable<ExcalidrawScene> {
const url = `/api/files`;
return this.http.get<ExcalidrawScene>(url, {
params: { path: path },
observe: 'response'
}).pipe(
tap((res) => {
const etag = res.headers.get('ETag');
if (etag) this.etagCache.set(path, etag);
}),
map((res) => res.body as ExcalidrawScene)
);
}
put(path: string, scene: ExcalidrawScene): Observable<{ rev: string }> {
const url = `/api/files`;
const prev = this.etagCache.get(path);
let headers = new HttpHeaders({ 'Content-Type': 'application/json' });
if (prev) headers = headers.set('If-Match', prev);
return this.http.put<{ rev: string }>(url, scene, {
params: { path: path },
headers,
observe: 'response'
}).pipe(
tap((res) => {
const status = res.status;
const etag = res.headers.get('ETag');
console.log('[FilesService.put] status', status, 'path', path, 'rev', res.body?.rev);
if (etag) this.etagCache.set(path, etag);
}),
map((res) => res.body as { rev: string })
);
}
// Write raw text/markdown content (used for pre-compressed Obsidian Excalidraw MD)
putText(path: string, markdown: string): Observable<{ rev: string }> {
const url = `/api/files`;
const prev = this.etagCache.get(path);
let headers = new HttpHeaders({ 'Content-Type': 'text/markdown; charset=utf-8' });
if (prev) headers = headers.set('If-Match', prev);
return this.http.put<{ rev: string }>(url, markdown, {
params: { path: path },
headers,
observe: 'response'
}).pipe(
tap((res) => {
const status = res.status;
const etag = res.headers.get('ETag');
console.log('[FilesService.putText] status', status, 'path', path, 'rev', res.body?.rev);
if (etag) this.etagCache.set(path, etag);
}),
map((res) => res.body as { rev: string })
);
}
// Force write without If-Match (overwrite on disk)
putForce(path: string, scene: ExcalidrawScene): Observable<{ rev: string }> {
const url = `/api/files`;
const headers = new HttpHeaders({ 'Content-Type': 'application/json' });
return this.http.put<{ rev: string }>(url, scene, {
params: { path: path },
headers,
observe: 'response'
}).pipe(
tap((res) => {
const status = res.status;
const etag = res.headers.get('ETag');
console.log('[FilesService.putForce] status', status, 'path', path, 'rev', res.body?.rev);
if (etag) this.etagCache.set(path, etag);
}),
map((res) => res.body as { rev: string })
);
}
putBinary(path: string, blob: Blob, mime: string): Observable<{ ok: true }> {
const url = `/api/files/blob`;
return this.http.put<{ ok: true }>(url, blob, {
params: { path: path },
headers: new HttpHeaders({ 'Content-Type': mime }),
});
}
}
@@ -0,0 +1,16 @@
import { Injectable } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class DrawingsPreviewService {
async exportPNGFromElement(el: HTMLElement, withBackground = true): Promise<Blob | undefined> {
const anyEl: any = el as any;
if (!anyEl?.exportPNG) return undefined;
return await anyEl.exportPNG({ withBackground });
}
async exportSVGFromElement(el: HTMLElement, withBackground = true): Promise<Blob | undefined> {
const anyEl: any = el as any;
if (!anyEl?.exportSVG) return undefined;
return await anyEl.exportSVG({ withBackground });
}
}
@@ -0,0 +1,166 @@
import { Injectable } from '@angular/core';
import * as LZString from 'lz-string';
export interface ExcalidrawScene {
elements: any[];
appState?: Record<string, any>;
files?: Record<string, any>;
}
/**
* Service for parsing and serializing Excalidraw files in Obsidian format
*/
@Injectable({ providedIn: 'root' })
export class ExcalidrawIoService {
/**
* Extract front matter from markdown content
*/
extractFrontMatter(md: string): string | null {
if (!md) return null;
const match = md.match(/^---\s*\n([\s\S]*?)\n---/);
return match ? match[0] : null;
}
/**
* Parse Obsidian Excalidraw markdown format
* Extracts compressed-json block and decompresses using LZ-String
*/
parseObsidianMd(md: string): ExcalidrawScene | null {
if (!md || typeof md !== 'string') return null;
// Try to extract compressed-json block
const compressedMatch = md.match(/```\s*compressed-json\s*\n([\s\S]*?)\n```/i);
if (compressedMatch && compressedMatch[1]) {
try {
// Remove whitespace from base64 data
const compressed = compressedMatch[1].replace(/\s+/g, '');
// Decompress using LZ-String
const decompressed = LZString.decompressFromBase64(compressed);
if (!decompressed) {
console.warn('[Excalidraw] LZ-String decompression returned null');
return null;
}
// Parse JSON
const data = JSON.parse(decompressed);
return this.normalizeScene(data);
} catch (error) {
console.error('[Excalidraw] Failed to parse compressed-json:', error);
return null;
}
}
// Fallback: try to extract plain json block
const jsonMatch = md.match(/```\s*(?:excalidraw|json)\s*\n([\s\S]*?)\n```/i);
if (jsonMatch && jsonMatch[1]) {
try {
const data = JSON.parse(jsonMatch[1].trim());
return this.normalizeScene(data);
} catch (error) {
console.error('[Excalidraw] Failed to parse json block:', error);
return null;
}
}
return null;
}
/**
* Parse flat JSON format (legacy ObsiViewer format)
*/
parseFlatJson(text: string): ExcalidrawScene | null {
if (!text || typeof text !== 'string') return null;
try {
const data = JSON.parse(text);
// Validate it has the expected structure
if (data && typeof data === 'object' && Array.isArray(data.elements)) {
return this.normalizeScene(data);
}
return null;
} catch (error) {
return null;
}
}
/**
* Convert Excalidraw scene to Obsidian markdown format
*/
toObsidianMd(data: ExcalidrawScene, existingFrontMatter?: string | null): string {
// Normalize scene data
const scene = this.normalizeScene(data);
// Serialize to JSON
const json = JSON.stringify(scene);
// Compress using LZ-String
const compressed = LZString.compressToBase64(json);
// Use existing front matter or create default
const frontMatter = existingFrontMatter?.trim() || `---
excalidraw-plugin: parsed
tags: [excalidraw]
---`;
// Banner text (Obsidian standard)
const banner = `==⚠ Switch to EXCALIDRAW VIEW in the MORE OPTIONS menu of this document. ⚠==
You can decompress Drawing data with the command palette: 'Decompress current Excalidraw file'. For more info check in plugin settings under 'Saving'`;
// Construct full markdown
return `${frontMatter}
${banner}
# Excalidraw Data
## Text Elements
%%
## Drawing
\`\`\`compressed-json
${compressed}
\`\`\`
%%`;
}
/**
* Parse Excalidraw content from any supported format
* Tries Obsidian MD format first, then falls back to flat JSON
*/
parseAny(text: string): ExcalidrawScene | null {
// Try Obsidian format first
let data = this.parseObsidianMd(text);
if (data) return data;
// Fallback to flat JSON
data = this.parseFlatJson(text);
if (data) return data;
return null;
}
/**
* Normalize scene structure
*/
private normalizeScene(data: any): ExcalidrawScene {
return {
elements: Array.isArray(data?.elements) ? data.elements : [],
appState: (data && typeof data.appState === 'object') ? data.appState : {},
files: (data && typeof data.files === 'object') ? data.files : {}
};
}
/**
* Validate Excalidraw scene structure
*/
isValidScene(data: any): boolean {
if (!data || typeof data !== 'object') return false;
if (!Array.isArray(data.elements)) return false;
return true;
}
}
@@ -47,7 +47,6 @@
[cellTemplate]="dayCellTemplate"
[weekStartsOn]="1"
[events]="[]"
(pointerup)="onDatePointerUp()"
></mwl-calendar-month-view>
</div>
@@ -69,7 +68,8 @@
class="day-cell"
[ngClass]="getCellClasses(day)"
(pointerdown)="onDatePointerDown(day, $event)"
(pointerenter)="onDatePointerEnter(day)"
(pointerenter)="onDatePointerEnter(day, $event)"
(pointerup)="onDatePointerUp(day, $event)"
>
<span class="day-number">{{ day.date | date: 'd' }}</span>
<div class="indicators">
@@ -125,6 +125,8 @@ export class MarkdownCalendarComponent {
return;
}
event.preventDefault();
event.stopPropagation();
const date = this.normalizeDay(day.date);
this.dragAnchor = date;
this.selectedRange.set({ start: date, end: date });
@@ -138,9 +140,12 @@ export class MarkdownCalendarComponent {
this.emitSelectionSummary();
this.requestSearchPanel.emit();
this.refresh$.next();
// Capture pointer to enable drag across cells
(event.target as HTMLElement).setPointerCapture(event.pointerId);
}
onDatePointerEnter(day: CalendarMonthViewDay<Date>): void {
onDatePointerEnter(day: CalendarMonthViewDay<Date>, event: PointerEvent): void {
if (!this.dragAnchor) {
return;
}
@@ -152,12 +157,13 @@ export class MarkdownCalendarComponent {
this.refresh$.next();
}
@HostListener('document:pointerup')
finishRangeSelection(): void {
@HostListener('document:pointerup', ['$event'])
finishRangeSelection(event: PointerEvent): void {
this.finalizeSelection();
}
onDatePointerUp(): void {
onDatePointerUp(day: CalendarMonthViewDay<Date>, event: PointerEvent): void {
event.stopPropagation();
this.finalizeSelection();
}
@@ -87,6 +87,19 @@ import { SearchOptions } from '../../core/search/search-parser.types';
.*
</button>
<!-- Highlight toggle (Hi) -->
<button
type="button"
(click)="toggleHighlight()"
[class.bg-accent]="highlight()"
[class.text-white]="highlight()"
class="btn-standard-xs"
[title]="highlight() ? 'Highlight enabled' : 'Highlight disabled'"
aria-label="Toggle highlight"
>
Hi
</button>
<!-- Clear button -->
<button
*ngIf="query"
@@ -133,6 +146,9 @@ export class SearchBarComponent implements AfterViewInit, OnInit {
@Input() showSearchIcon: boolean = true;
@Input() inputClass: string = 'w-full rounded-full border border-border bg-bg-muted/70 py-2.5 pr-4 text-sm text-text-main placeholder:text-text-muted shadow-subtle focus:outline-none focus:ring-2 focus:ring-ring transition-all dark:border-gray-600 dark:bg-gray-800/80 dark:text-gray-100 dark:placeholder-gray-400 dark:focus:ring-blue-500';
@Input() initialQuery: string = '';
@Input() caseSensitiveDefault: boolean = false;
@Input() regexDefault: boolean = false;
@Input() highlightDefault: boolean = true;
@Output() search = new EventEmitter<{ query: string; options: SearchOptions }>();
@Output() queryChange = new EventEmitter<string>();
@@ -145,6 +161,7 @@ export class SearchBarComponent implements AfterViewInit, OnInit {
query = '';
caseSensitive = signal(false);
regexMode = signal(false);
highlight = signal(true);
anchorElement: HTMLElement | null = null;
private historyIndex = -1;
@@ -154,6 +171,9 @@ export class SearchBarComponent implements AfterViewInit, OnInit {
if (this.initialQuery) {
this.query = this.initialQuery;
}
this.caseSensitive.set(!!this.caseSensitiveDefault);
this.regexMode.set(!!this.regexDefault);
this.highlight.set(this.highlightDefault !== false);
}
/**
@@ -222,7 +242,8 @@ export class SearchBarComponent implements AfterViewInit, OnInit {
query: this.query,
options: {
caseSensitive: this.caseSensitive(),
regexMode: this.regexMode()
regexMode: this.regexMode(),
highlight: this.highlight()
}
});
}
@@ -274,7 +295,8 @@ export class SearchBarComponent implements AfterViewInit, OnInit {
query: this.query,
options: {
caseSensitive: this.caseSensitive(),
regexMode: this.regexMode()
regexMode: this.regexMode(),
highlight: this.highlight()
}
});
}
@@ -292,7 +314,23 @@ export class SearchBarComponent implements AfterViewInit, OnInit {
query: this.query,
options: {
caseSensitive: this.caseSensitive(),
regexMode: this.regexMode()
regexMode: this.regexMode(),
highlight: this.highlight()
}
});
}
}
/** Toggle highlight mode */
toggleHighlight(): void {
this.highlight.update(v => !v);
if (this.query.trim()) {
this.search.emit({
query: this.query,
options: {
caseSensitive: this.caseSensitive(),
regexMode: this.regexMode(),
highlight: this.highlight()
}
});
}
@@ -316,7 +354,8 @@ export class SearchBarComponent implements AfterViewInit, OnInit {
query: '',
options: {
caseSensitive: this.caseSensitive(),
regexMode: this.regexMode()
regexMode: this.regexMode(),
highlight: this.highlight()
}
});
}
@@ -7,13 +7,15 @@ import {
ViewChild,
ElementRef,
ChangeDetectionStrategy,
AfterViewInit
AfterViewInit,
OnInit,
inject
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { SearchQueryAssistantComponent } from '../search-query-assistant/search-query-assistant.component';
import { inject } from '@angular/core';
import { SearchHistoryService } from '../../core/search/search-history.service';
import { SearchPreferencesService } from '../../core/search/search-preferences.service';
/**
* Search input with integrated query assistant
@@ -51,16 +53,25 @@ import { SearchHistoryService } from '../../core/search/search-history.service';
[attr.aria-label]="placeholder"
/>
<button
*ngIf="value"
(click)="clear()"
class="absolute right-3 top-1/2 -translate-y-1/2 p-1 rounded-full hover:bg-bg-muted dark:hover:bg-gray-700 transition-colors"
aria-label="Clear search"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<div class="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
<!-- Case (Aa) -->
<button type="button" class="btn-standard-xs" [class.bg-accent]="caseSensitive()" [class.text-white]="caseSensitive()" (click)="toggleCase()" title="Case sensitivity">Aa</button>
<!-- Regex (.*) -->
<button type="button" class="btn-standard-xs font-mono" [class.bg-accent]="regexMode()" [class.text-white]="regexMode()" (click)="toggleRegex()" title="Regex mode">.*</button>
<!-- Highlight (Hi) -->
<button type="button" class="btn-standard-xs" [class.bg-accent]="highlight()" [class.text-white]="highlight()" (click)="toggleHighlight()" title="Highlight results">Hi</button>
<!-- Clear -->
<button
*ngIf="value"
(click)="clear()"
class="btn-standard-icon"
aria-label="Clear search"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<app-search-query-assistant
@@ -81,7 +92,7 @@ import { SearchHistoryService } from '../../core/search/search-history.service';
}
`]
})
export class SearchInputWithAssistantComponent implements AfterViewInit {
export class SearchInputWithAssistantComponent implements AfterViewInit, OnInit {
@Input() placeholder: string = 'Search...';
@Input() value: string = '';
@Input() context: string = 'default';
@@ -91,15 +102,29 @@ export class SearchInputWithAssistantComponent implements AfterViewInit {
@Output() valueChange = new EventEmitter<string>();
@Output() submit = new EventEmitter<string>();
@Output() optionsChange = new EventEmitter<{ caseSensitive: boolean; regexMode: boolean; highlight: boolean }>();
@ViewChild('searchInput') searchInputRef?: ElementRef<HTMLInputElement>;
@ViewChild('assistant') assistantRef?: SearchQueryAssistantComponent;
anchorElement: HTMLElement | null = null;
private historyService = inject(SearchHistoryService);
private prefs = inject(SearchPreferencesService);
caseSensitive = signal(false);
regexMode = signal(false);
highlight = signal(true);
constructor(private hostElement: ElementRef<HTMLElement>) {}
ngOnInit(): void {
// Load header search defaults
const p = this.prefs.getPreferences('vault-header');
this.caseSensitive.set(!!p.caseSensitive);
this.regexMode.set(!!p.regexMode);
this.highlight.set(p.highlight !== false);
}
ngAfterViewInit(): void {
this.anchorElement = this.hostElement.nativeElement;
}
@@ -150,6 +175,7 @@ export class SearchInputWithAssistantComponent implements AfterViewInit {
this.historyService.add(this.context, this.value);
}
this.submit.emit(this.value);
this.emitOptionsChange();
if (this.assistantRef) {
this.assistantRef.refreshHistoryView();
this.assistantRef.close();
@@ -194,6 +220,7 @@ export class SearchInputWithAssistantComponent implements AfterViewInit {
this.historyService.add(this.context, query);
}
this.submit.emit(query);
this.emitOptionsChange();
if (this.searchInputRef) {
this.searchInputRef.nativeElement.value = query;
@@ -217,6 +244,7 @@ export class SearchInputWithAssistantComponent implements AfterViewInit {
this.searchInputRef.nativeElement.value = '';
this.searchInputRef.nativeElement.focus();
}
this.emitOptionsChange();
}
/**
@@ -227,4 +255,30 @@ export class SearchInputWithAssistantComponent implements AfterViewInit {
this.searchInputRef.nativeElement.focus();
}
}
private emitOptionsChange(): void {
this.optionsChange.emit({
caseSensitive: this.caseSensitive(),
regexMode: this.regexMode(),
highlight: this.highlight()
});
}
toggleCase(): void {
this.caseSensitive.update(v => !v);
this.prefs.togglePreference('vault-header', 'caseSensitive');
this.emitOptionsChange();
}
toggleRegex(): void {
this.regexMode.update(v => !v);
this.prefs.togglePreference('vault-header', 'regexMode');
this.emitOptionsChange();
}
toggleHighlight(): void {
this.highlight.update(v => !v);
this.prefs.togglePreference('vault-header', 'highlight');
this.emitOptionsChange();
}
}
@@ -6,12 +6,17 @@ import {
signal,
computed,
OnInit,
OnDestroy,
OnChanges,
SimpleChanges,
inject,
ChangeDetectionStrategy,
effect,
untracked
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { isObservable, Subject } from 'rxjs';
import { take, debounceTime, distinctUntilChanged } from 'rxjs/operators';
import { FormsModule } from '@angular/forms';
import { SearchBarComponent } from '../search-bar/search-bar.component';
import { SearchResultsComponent } from '../search-results/search-results.component';
@@ -21,6 +26,8 @@ import { SearchPreferencesService } from '../../core/search/search-preferences.s
import { SearchOptions } from '../../core/search/search-parser.types';
import { VaultService } from '../../services/vault.service';
import { ClientLoggingService } from '../../services/client-logging.service';
import { environment } from '../../core/logging/environment';
import { parseSearchQuery } from '../../core/search/search-parser';
/**
* Complete search panel with bar and results
@@ -38,13 +45,17 @@ import { ClientLoggingService } from '../../services/client-logging.service';
<app-search-bar
[placeholder]="placeholder"
[context]="context"
[initialQuery]="initialQuery"
[showSearchIcon]="true"
[showExamples]="false"
[caseSensitiveDefault]="caseSensitive"
[regexDefault]="regexMode"
[highlightDefault]="highlightEnabled"
(search)="onSearch($event)"
(queryChange)="onQueryChange($event)"
/>
</div>
<!-- Search options toggles -->
@if (hasSearched() && results().length > 0) {
<div class="flex flex-col gap-3 p-4 border-b border-border dark:border-gray-700 bg-bg-muted dark:bg-gray-800">
@@ -94,6 +105,17 @@ import { ClientLoggingService } from '../../services/client-logging.service';
</label>
</div>
}
<!-- Explain search terms panel -->
@if (explainSearchTerms && currentQuery().trim()) {
<div class="p-4 border-b border-border dark:border-gray-700 bg-bg-primary/60 dark:bg-gray-900/60">
<h4 class="text-xs font-semibold uppercase tracking-wide text-text-muted mb-2">Match all of:</h4>
<ul class="ml-1 space-y-1 text-xs">
@for (line of explanationLines(); track $index) {
<li class="text-text-muted dark:text-gray-400">{{ line }}</li>
}
</ul>
</div>
}
<!-- Search results -->
<div class="flex-1 overflow-hidden">
@@ -118,6 +140,7 @@ import { ClientLoggingService } from '../../services/client-logging.service';
[collapseAll]="collapseResults"
[showMoreContext]="showMoreContext"
[contextLines]="contextLines()"
[highlight]="lastOptions.highlight !== false"
(noteOpen)="onNoteOpen($event)"
/>
} @else {
@@ -138,11 +161,14 @@ import { ClientLoggingService } from '../../services/client-logging.service';
}
`]
})
export class SearchPanelComponent implements OnInit {
export class SearchPanelComponent implements OnInit, OnDestroy, OnChanges {
@Input() placeholder: string = 'Search in vault...';
@Input() context: string = 'vault';
@Input() initialQuery: string = '';
@Output() noteOpen = new EventEmitter<{ noteId: string; line?: number }>();
@Output() searchTermChange = new EventEmitter<string>();
@Output() optionsChange = new EventEmitter<SearchOptions>();
private orchestrator = inject(SearchOrchestratorService);
private searchIndex = inject(SearchIndexService);
@@ -156,30 +182,68 @@ export class SearchPanelComponent implements OnInit {
currentQuery = signal('');
private lastOptions: SearchOptions = {};
private searchSubject = new Subject<{ query: string; options?: SearchOptions }>();
// UI toggles
collapseResults = false;
showMoreContext = false;
explainSearchTerms = false;
caseSensitive = false;
regexMode = false;
highlightEnabled = true;
// Computed context lines based on showMoreContext
contextLines = computed(() => this.showMoreContext ? 5 : 2);
private syncIndexEffect = effect(() => {
const notes = this.vaultService.allNotes();
this.logger.info('SearchPanel', 'Detected notes change, rebuilding index', {
context: this.context,
noteCount: notes.length
});
this.searchIndex.rebuildIndex(notes);
// Build explanation lines from the local parser (UI-only, independent of provider)
explanationLines = computed(() => {
const q = this.currentQuery().trim();
if (!q) return [] as string[];
try {
const parsed = parseSearchQuery(q, { caseSensitive: false, regexMode: false });
const lines: string[] = [];
const f = parsed.diagnostics?.filters;
if (f) {
if (f.file?.length) {
f.file.forEach(v => lines.push(`Match file name: "${v}"`));
}
if (f.path?.length) {
f.path.forEach(v => lines.push(`Match path: "${v}"`));
}
if (f.tag?.length) {
f.tag.forEach(v => lines.push(`Match tag: #${v}`));
}
}
// Remaining tokens (rough): use diagnostics.tokens minus operators
const tokens = parsed.diagnostics?.tokens ?? [];
tokens
.filter(t => t.toUpperCase() !== 'AND' && t.toUpperCase() !== 'OR')
.filter(t => !/^\w+:/.test(t))
.forEach(t => lines.push(`Matches text: "${t.replace(/^"|"$/g,'')}"`));
return lines;
} catch {
return [] as string[];
}
});
const query = untracked(() => this.currentQuery());
if (query && query.trim()) {
this.logger.debug('SearchPanel', 'Re-running search after index rebuild', {
query,
context: this.context
private syncIndexEffect = effect(() => {
// Only rebuild index if not using Meilisearch
if (!environment.USE_MEILI) {
const notes = this.vaultService.allNotes();
this.logger.info('SearchPanel', 'Detected notes change, rebuilding index', {
context: this.context,
noteCount: notes.length
});
this.executeSearch(query);
this.searchIndex.rebuildIndex(notes);
const query = untracked(() => this.currentQuery());
if (query && query.trim()) {
this.logger.debug('SearchPanel', 'Re-running search after index rebuild', {
query,
context: this.context
});
this.executeSearch(query);
}
}
}, { allowSignalWrites: true });
@@ -189,6 +253,40 @@ export class SearchPanelComponent implements OnInit {
this.collapseResults = prefs.collapseResults;
this.showMoreContext = prefs.showMoreContext;
this.explainSearchTerms = prefs.explainSearchTerms;
this.caseSensitive = prefs.caseSensitive;
this.regexMode = prefs.regexMode;
this.highlightEnabled = prefs.highlight;
// Setup debounced search for live typing (only when using Meilisearch)
if (environment.USE_MEILI) {
this.searchSubject.pipe(
debounceTime(300),
distinctUntilChanged((prev, curr) => prev.query === curr.query)
).subscribe(({ query, options }) => {
this.executeSearch(query, options);
});
}
// Execute initial query if provided
const iq = this.initialQuery?.trim();
if (iq && iq.length >= 2) {
this.currentQuery.set(iq);
this.executeSearch(iq);
}
}
ngOnDestroy(): void {
this.searchSubject.complete();
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['initialQuery']) {
const iq = (this.initialQuery ?? '').trim();
if (iq && iq !== this.currentQuery()) {
this.currentQuery.set(iq);
this.executeSearch(iq);
}
}
}
/**
@@ -218,33 +316,72 @@ export class SearchPanelComponent implements OnInit {
query: trimmed,
options: baseOptions,
contextLines: this.contextLines(),
context: this.context
context: this.context,
useMeilisearch: environment.USE_MEILI
});
this.isSearching.set(true);
setTimeout(() => {
// With Meilisearch, execute immediately (no setTimeout needed)
// Without Meilisearch, use setTimeout to avoid blocking UI
const executeNow = () => {
try {
const searchResults = this.orchestrator.execute(trimmed, {
const exec = this.orchestrator.execute(trimmed, {
...baseOptions,
contextLines: this.contextLines()
});
this.logger.info('SearchPanel', 'Search completed', {
query: trimmed,
resultCount: searchResults.length
});
this.results.set(searchResults);
this.hasSearched.set(true);
if (isObservable(exec)) {
exec.pipe(take(1)).subscribe({
next: (arr) => {
this.logger.info('SearchPanel', 'Search completed (obs)', {
query: trimmed,
resultCount: arr.length,
firstResult: arr.length > 0 ? { noteId: arr[0].noteId, matchCount: arr[0].matches.length } : null
});
console.log('[SearchPanel] Results received:', arr);
this.results.set(arr);
this.hasSearched.set(true);
this.isSearching.set(false);
},
error: (e) => {
this.logger.error('SearchPanel', 'Search execution error (obs)', {
error: e instanceof Error ? e.message : String(e),
stack: e instanceof Error ? e.stack : undefined
});
console.error('[SearchPanel] Search error:', e);
this.results.set([]);
this.hasSearched.set(true);
this.isSearching.set(false);
}
});
} else {
const searchResults = exec;
this.logger.info('SearchPanel', 'Search completed', {
query: trimmed,
resultCount: searchResults.length
});
this.results.set(searchResults);
this.hasSearched.set(true);
this.isSearching.set(false);
}
} catch (error) {
this.logger.error('SearchPanel', 'Search execution error', {
error: error instanceof Error ? error.message : String(error)
});
this.results.set([]);
this.hasSearched.set(true);
} finally {
this.isSearching.set(false);
}
}, 0);
};
if (environment.USE_MEILI) {
// Execute immediately with Meilisearch (backend handles it)
executeNow();
} else {
// Use setTimeout to avoid blocking UI with local search
setTimeout(executeNow, 0);
}
}
/**
@@ -255,6 +392,15 @@ export class SearchPanelComponent implements OnInit {
this.currentQuery.set(query);
this.lastOptions = { ...options };
// Persist user option toggles
this.preferences.updatePreferences(this.context, {
caseSensitive: !!options.caseSensitive,
regexMode: !!options.regexMode,
highlight: options.highlight !== false
});
// Emit upwards for header to sync highlight to document
this.searchTermChange.emit(query);
this.optionsChange.emit({ ...options });
this.executeSearch(query, options);
}
@@ -264,8 +410,14 @@ export class SearchPanelComponent implements OnInit {
onQueryChange(query: string): void {
this.currentQuery.set(query);
// Could implement debounced live search here
// For now, only search on Enter
// With Meilisearch, enable live search with debounce
if (environment.USE_MEILI && query.trim().length >= 2) {
this.searchSubject.next({ query, options: this.lastOptions });
} else if (!query.trim()) {
// Clear results if query is empty
this.results.set([]);
this.hasSearched.set(false);
}
}
/**
@@ -294,6 +446,8 @@ export class SearchPanelComponent implements OnInit {
this.preferences.updatePreferences(this.context, {
collapseResults: this.collapseResults
});
// No need to re-run search, just update the preference
// The search-results component will react to the input change
}
/**
@@ -306,7 +460,7 @@ export class SearchPanelComponent implements OnInit {
// Re-run search with new context lines
if (this.currentQuery()) {
this.executeSearch(this.currentQuery());
this.executeSearch(this.currentQuery(), this.lastOptions);
}
}
@@ -5,9 +5,10 @@ import {
EventEmitter,
signal,
computed,
effect,
ChangeDetectionStrategy,
inject
inject,
OnChanges,
SimpleChanges
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
@@ -197,7 +198,7 @@ type SortOption = 'relevance' | 'name' | 'modified';
}
`]
})
export class SearchResultsComponent {
export class SearchResultsComponent implements OnChanges {
@Input() set results(value: SearchResult[]) {
this._results.set(value);
this.buildGroups(value);
@@ -206,6 +207,7 @@ export class SearchResultsComponent {
@Input() collapseAll: boolean = false;
@Input() showMoreContext: boolean = false;
@Input() contextLines: number = 2;
@Input() highlight: boolean = true;
@Output() noteOpen = new EventEmitter<{ noteId: string; line?: number }>();
@@ -216,13 +218,12 @@ export class SearchResultsComponent {
private groups = signal<ResultGroup[]>([]);
sortBy: SortOption = 'relevance';
constructor() {
// Watch for collapseAll changes
effect(() => {
if (this.collapseAll !== undefined) {
this.applyCollapseAll(this.collapseAll);
}
});
constructor() {}
ngOnChanges(changes: SimpleChanges): void {
if (changes['collapseAll'] && !changes['collapseAll'].firstChange) {
this.applyCollapseAll(this.collapseAll);
}
}
/**
@@ -347,6 +348,19 @@ export class SearchResultsComponent {
return '';
}
// If highlight is disabled, strip any pre-highlighted HTML and return plain text
if (!this.highlight) {
return this.highlighter.stripHtml(match.context);
}
// If context already contains highlight markup from Meilisearch
// (e.g., <mark> or <em> tags), return it as-is to preserve server-side highlights.
// We purposely avoid escaping HTML here because it is produced by our backend with safe tags.
const hasPreHighlightedHtml = /<(mark|em)>/i.test(match.context);
if (hasPreHighlightedHtml) {
return match.context;
}
// If we have ranges, use them for precise highlighting
if (match.ranges && match.ranges.length > 0) {
return this.highlighter.highlightWithRanges(match.context, match.ranges);
@@ -0,0 +1,251 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TagsViewComponent } from './tags-view.component';
import { TagInfo } from '../../types';
import { signal } from '@angular/core';
describe('TagsViewComponent', () => {
let component: TagsViewComponent;
let fixture: ComponentFixture<TagsViewComponent>;
const mockTags: TagInfo[] = [
{ name: 'AI', count: 6 },
{ name: 'angular', count: 1 },
{ name: 'debian/server', count: 3 },
{ name: 'debian/desktop', count: 2 },
{ name: 'automatisation', count: 2 },
{ name: 'budget', count: 1 },
{ name: 'docker/automation', count: 5 },
{ name: 'docker/test', count: 3 },
];
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TagsViewComponent]
}).compileComponents();
fixture = TestBed.createComponent(TagsViewComponent);
component = fixture.componentInstance;
// Set required input
fixture.componentRef.setInput('tags', mockTags);
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('Filtering', () => {
it('should filter tags by search query', () => {
component.searchQuery.set('docker');
fixture.detectChanges();
const displayed = component['filteredTags']();
expect(displayed.length).toBe(2);
expect(displayed.every(t => t.name.includes('docker'))).toBe(true);
});
it('should be case insensitive', () => {
component.searchQuery.set('DOCKER');
fixture.detectChanges();
const displayed = component['filteredTags']();
expect(displayed.length).toBe(2);
});
it('should handle accents', () => {
component.searchQuery.set('automatisation');
fixture.detectChanges();
const displayed = component['filteredTags']();
expect(displayed.length).toBe(1);
});
});
describe('Sorting', () => {
it('should sort alphabetically A-Z', () => {
component.sortMode.set('alpha-asc');
fixture.detectChanges();
const sorted = component['sortedTags']();
expect(sorted[0].name).toBe('AI');
expect(sorted[sorted.length - 1].name).toBe('docker/test');
});
it('should sort alphabetically Z-A', () => {
component.sortMode.set('alpha-desc');
fixture.detectChanges();
const sorted = component['sortedTags']();
expect(sorted[0].name).toBe('docker/test');
expect(sorted[sorted.length - 1].name).toBe('AI');
});
it('should sort by frequency descending', () => {
component.sortMode.set('freq-desc');
fixture.detectChanges();
const sorted = component['sortedTags']();
expect(sorted[0].name).toBe('AI'); // count: 6
expect(sorted[1].name).toBe('docker/automation'); // count: 5
});
it('should sort by frequency ascending', () => {
component.sortMode.set('freq-asc');
fixture.detectChanges();
const sorted = component['sortedTags']();
const firstCount = sorted[0].count;
const lastCount = sorted[sorted.length - 1].count;
expect(firstCount).toBeLessThanOrEqual(lastCount);
});
});
describe('Grouping', () => {
it('should not group when mode is "none"', () => {
component.groupMode.set('none');
fixture.detectChanges();
const groups = component.displayedGroups();
expect(groups.length).toBe(1);
expect(groups[0].label).toBe('all');
expect(groups[0].tags.length).toBe(mockTags.length);
});
it('should group by hierarchy', () => {
component.groupMode.set('hierarchy');
fixture.detectChanges();
const groups = component.displayedGroups();
// Should have groups: AI, angular, debian, automatisation, budget, docker
expect(groups.length).toBeGreaterThan(1);
const dockerGroup = groups.find(g => g.label === 'docker');
expect(dockerGroup).toBeDefined();
expect(dockerGroup!.tags.length).toBe(2);
});
it('should group alphabetically', () => {
component.groupMode.set('alpha');
fixture.detectChanges();
const groups = component.displayedGroups();
// Should have groups for A, B, D
expect(groups.length).toBeGreaterThan(1);
const aGroup = groups.find(g => g.label === 'A');
expect(aGroup).toBeDefined();
expect(aGroup!.tags.length).toBeGreaterThan(0);
});
});
describe('Group expansion', () => {
it('should toggle group expansion', () => {
component.groupMode.set('hierarchy');
fixture.detectChanges();
const groups = component.displayedGroups();
const firstGroup = groups[0];
const initialState = firstGroup.isExpanded;
component.toggleGroup(firstGroup.label);
fixture.detectChanges();
const updatedGroups = component.displayedGroups();
const updatedGroup = updatedGroups.find(g => g.label === firstGroup.label);
expect(updatedGroup!.isExpanded).toBe(!initialState);
});
it('should auto-expand all groups when switching modes', () => {
component.groupMode.set('hierarchy');
fixture.detectChanges();
const groups = component.displayedGroups();
expect(groups.every(g => g.isExpanded)).toBe(true);
});
});
describe('Display helpers', () => {
it('should display short name in hierarchy mode', () => {
component.groupMode.set('hierarchy');
const result = component.displayTagName('docker', 'docker/automation');
expect(result).toBe('automation');
});
it('should display full name in non-hierarchy mode', () => {
component.groupMode.set('none');
const result = component.displayTagName('docker', 'docker/automation');
expect(result).toBe('docker/automation');
});
it('should handle root tag correctly', () => {
component.groupMode.set('hierarchy');
const result = component.displayTagName('docker', 'docker');
expect(result).toBe('docker');
});
});
describe('Events', () => {
it('should emit tagSelected on tag click', (done) => {
component.tagSelected.subscribe((tagName: string) => {
expect(tagName).toBe('docker/automation');
done();
});
component.onTagClick('docker/automation');
});
});
describe('Reset', () => {
it('should reset all filters', () => {
component.searchQuery.set('test');
component.sortMode.set('freq-desc');
component.groupMode.set('hierarchy');
component.resetFilters();
expect(component.searchQuery()).toBe('');
expect(component.sortMode()).toBe('alpha-asc');
expect(component.groupMode()).toBe('none');
});
});
describe('Stats', () => {
it('should calculate total tags correctly', () => {
const total = component.totalTags();
expect(total).toBe(mockTags.length);
});
it('should calculate displayed tags after filtering', () => {
component.searchQuery.set('docker');
fixture.detectChanges();
const displayed = component.totalDisplayedTags();
expect(displayed).toBe(2);
});
});
describe('Performance', () => {
it('should handle large tag lists efficiently', () => {
const largeTags: TagInfo[] = Array.from({ length: 1000 }, (_, i) => ({
name: `tag-${i}`,
count: Math.floor(Math.random() * 100)
}));
fixture.componentRef.setInput('tags', largeTags);
fixture.detectChanges();
const start = performance.now();
component.sortMode.set('freq-desc');
fixture.detectChanges();
const duration = performance.now() - start;
expect(duration).toBeLessThan(50); // Should be < 50ms
});
});
});
+443 -183
View File
@@ -8,149 +8,252 @@ import {
computed,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { TagInfo } from '../../types';
interface TagSection {
letter: string;
type SortMode = 'alpha-asc' | 'alpha-desc' | 'freq-desc' | 'freq-asc';
type GroupMode = 'none' | 'hierarchy' | 'alpha';
interface TagGroup {
label: string;
tags: TagInfo[];
isExpanded: boolean;
level: number;
}
@Component({
selector: 'app-tags-view',
imports: [CommonModule],
imports: [CommonModule, FormsModule],
styles: [
`
:host {
--tv-scroll-thumb-light: color-mix(in srgb, var(--text-main) 35%, transparent);
--tv-scroll-thumb-dark: color-mix(in srgb, var(--text-muted) 55%, transparent);
--tv-scroll-thumb: rgba(128, 128, 128, 0.3);
--tv-scroll-track: transparent;
}
:host .custom-scrollbar {
:host-context(.dark) {
--tv-scroll-thumb: rgba(200, 200, 200, 0.2);
}
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: var(--tv-scroll-thumb-light) var(--tv-scroll-track);
scrollbar-color: var(--tv-scroll-thumb) var(--tv-scroll-track);
}
:host([data-theme='dark']) .custom-scrollbar,
:host-context(.dark) .custom-scrollbar {
scrollbar-color: var(--tv-scroll-thumb-dark) var(--tv-scroll-track);
}
:host .custom-scrollbar::-webkit-scrollbar {
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
height: 6px;
}
:host .custom-scrollbar::-webkit-scrollbar-track {
.custom-scrollbar::-webkit-scrollbar-track {
background: var(--tv-scroll-track);
}
:host .custom-scrollbar::-webkit-scrollbar-thumb {
background-color: var(--tv-scroll-thumb-light);
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: var(--tv-scroll-thumb);
border-radius: 3px;
}
.tag-item {
transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
}
.tag-item:hover {
transform: translateX(2px);
}
.group-header {
transition: background-color 0.15s ease;
}
.rotate-90 {
transform: rotate(90deg);
}
.expand-icon {
transition: transform 0.2s ease;
}
/* Toolbar layout and compact controls */
.toolbar-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: nowrap;
}
.control-select {
min-width: 0;
flex: 1 1 0;
height: 32px;
padding: 0 10px;
border-radius: 9999px;
}
:host([data-theme='dark']) .custom-scrollbar::-webkit-scrollbar-thumb,
:host-context(.dark) .custom-scrollbar::-webkit-scrollbar-thumb {
background-color: var(--tv-scroll-thumb-dark);
.icon-btn {
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 9999px;
}
/* Remove blue focus rectangle and native search decorations */
#tag-search,
#tag-search:focus,
#tag-search:focus-visible {
outline: none !important;
box-shadow: none !important;
}
#tag-search {
appearance: none;
-webkit-appearance: none;
border: 0;
}
input[type='search']::-webkit-search-decoration,
input[type='search']::-webkit-search-cancel-button,
input[type='search']::-webkit-search-results-button,
input[type='search']::-webkit-search-results-decoration {
display: none;
.icon-btn--muted {
color: var(--obs-l-text-muted);
}
`,
],
template: `
<div class="flex h-full flex-col gap-4 p-3 bg-card">
<div class="w-full rounded-full border border-border bg-card px-3 py-1.5 shadow-subtle focus-within:outline-none focus-within:ring-0">
<label class="sr-only" for="tag-search">Rechercher des tags</label>
<div class="flex items-center gap-2.5 text-text-muted">
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-5.2-5.2m0 0A7 7 0 105.8 5.8a7 7 0 0010 10z" />
</svg>
<div class="flex h-full flex-col bg-obs-l-bg-main dark:bg-obs-d-bg-main">
<!-- Toolbar -->
<div class="flex flex-col gap-3 border-b border-obs-l-border dark:border-obs-d-border p-3">
<!-- Search bar -->
<div class="relative">
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-obs-l-text-muted dark:text-obs-d-text-muted">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<input
id="tag-search"
type="search"
[value]="searchTerm()"
(input)="onSearchChange($event.target?.value ?? '')"
[(ngModel)]="searchQuery"
(ngModelChange)="onSearchChange()"
placeholder="Rechercher un tag..."
class="w-full border-0 bg-transparent text-sm text-text-main placeholder:text-text-muted outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0"
class="w-full rounded-md border border-obs-l-border dark:border-obs-d-border bg-obs-l-bg-secondary dark:bg-obs-d-bg-secondary pl-9 pr-3 py-2 text-sm text-obs-l-text-main dark:text-obs-d-text-main placeholder:text-obs-l-text-muted dark:placeholder:text-obs-d-text-muted focus:outline-none focus:ring-2 focus:ring-obs-l-accent dark:focus:ring-obs-d-accent"
/>
</div>
<!-- Controls row -->
<div class="toolbar-row">
<!-- Sort dropdown -->
<select
[(ngModel)]="sortMode"
(ngModelChange)="onSortChange()"
class="control-select border border-obs-l-border dark:border-obs-d-border bg-obs-l-bg-secondary dark:bg-obs-d-bg-secondary text-xs text-obs-l-text-main dark:text-obs-d-text-main focus:outline-none focus:ring-2 focus:ring-obs-l-accent dark:focus:ring-obs-d-accent"
>
<option value="alpha-asc">A → Z</option>
<option value="alpha-desc">Z → A</option>
<option value="freq-desc">Fréquence ↓</option>
<option value="freq-asc">Fréquence ↑</option>
</select>
<!-- Group mode dropdown -->
<select
[(ngModel)]="groupMode"
(ngModelChange)="onGroupModeChange()"
class="control-select border border-obs-l-border dark:border-obs-d-border bg-obs-l-bg-secondary dark:bg-obs-d-bg-secondary text-xs text-obs-l-text-main dark:text-obs-d-text-main focus:outline-none focus:ring-2 focus:ring-obs-l-accent dark:focus:ring-obs-d-accent"
>
<option value="none">Sans groupe</option>
<option value="hierarchy">Hiérarchie</option>
<option value="alpha">Alphabétique</option>
</select>
<!-- Reset button -->
<button
type="button"
(click)="resetFilters()"
class="icon-btn border border-obs-l-border dark:border-obs-d-border bg-obs-l-bg-secondary dark:bg-obs-d-bg-secondary text-obs-l-text-muted dark:text-obs-d-text-muted hover:text-obs-l-text-main dark:hover:text-obs-d-text-main hover:bg-obs-l-bg-hover dark:hover:bg-obs-d-bg-hover transition-colors"
title="Réinitialiser les filtres"
aria-label="Réinitialiser les filtres"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
<!-- Expand all toggle (only when grouped) -->
<button
type="button"
(click)="toggleExpandAll()"
[disabled]="groupMode() === 'none'"
[attr.aria-pressed]="allExpanded()"
class="icon-btn border border-obs-l-border dark:border-obs-d-border bg-obs-l-bg-secondary dark:bg-obs-d-bg-secondary transition-colors"
[ngClass]="groupMode() === 'none' ? 'text-obs-l-text-muted/60 dark:text-obs-d-text-muted/60 cursor-not-allowed' : (allExpanded() ? 'text-obs-l-text-main dark:text-obs-d-text-main' : 'text-obs-l-text-muted dark:text-obs-d-text-muted hover:text-obs-l-text-main dark:hover:text-obs-d-text-main hover:bg-obs-l-bg-hover dark:hover:bg-obs-d-bg-hover')"
[title]="allExpanded() ? 'Replier tout' : 'Déplier tout'"
[attr.aria-label]="allExpanded() ? 'Replier tout' : 'Déplier tout'"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 12h16M4 6h16M4 18h16" />
</svg>
</button>
</div>
</div>
<div class="flex flex-1 gap-3 overflow-hidden">
<div class="custom-scrollbar flex-1 overflow-y-auto pr-1">
@if (displayedSections().length > 0) {
<div class="space-y-4">
@for (section of displayedSections(); track section.letter) {
<section class="space-y-2">
<header class="flex items-center justify-between">
<h2 class="text-xs font-semibold uppercase tracking-wide text-text-muted">
{{ section.letter }}
</h2>
<span class="text-[0.65rem] text-text-muted/70">
{{ section.tags.length }} tag(s)
<!-- Tags list -->
<div class="flex-1 overflow-y-auto custom-scrollbar">
@if (displayedGroups().length === 0) {
<div class="flex flex-col items-center justify-center h-full text-obs-l-text-muted dark:text-obs-d-text-muted p-8">
<svg class="h-12 w-12 mb-3 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
<p class="text-sm">Aucun tag trouvé</p>
</div>
} @else {
<div class="py-2">
@for (group of displayedGroups(); track group.label) {
<div class="mb-1">
<!-- Group header (if grouped) -->
@if (groupMode() !== 'none') {
<button
type="button"
(click)="toggleGroup(group.label)"
class="group-header w-full flex items-center justify-between px-3 py-2 text-left hover:bg-obs-l-bg-hover dark:hover:bg-obs-d-bg-hover"
>
<div class="flex items-center gap-2">
<svg
class="h-3 w-3 text-obs-l-text-muted dark:text-obs-d-text-muted expand-icon"
[class.rotate-90]="group.isExpanded"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
<span class="text-xs font-semibold text-obs-l-text-muted dark:text-obs-d-text-muted uppercase tracking-wide">
{{ group.label }}
</span>
</div>
<span class="text-xs text-obs-l-text-muted dark:text-obs-d-text-muted">
{{ group.tags.length }}
</span>
</header>
<div class="flex flex-wrap gap-2">
@for (tag of section.tags; track tag.name) {
</button>
}
<!-- Tags in group -->
@if (groupMode() === 'none' || group.isExpanded) {
<div [class.pl-6]="groupMode() !== 'none'">
@for (tag of group.tags; track tag.name) {
<button
type="button"
class="chip justify-between px-2 py-0.5 text-[0.7rem]"
(click)="tagSelected.emit(tag.name)"
(click)="onTagClick(tag.name)"
class="tag-item w-full flex items-center justify-between px-3 py-2 text-left hover:bg-obs-l-bg-hover dark:hover:bg-obs-d-bg-hover group"
>
<span class="flex items-center gap-2 text-text-main">
<span aria-hidden="true">🔖</span>
<span class="truncate">{{ tag.name }}</span>
<div class="flex items-center gap-2 flex-1 min-w-0">
<svg class="h-3.5 w-3.5 text-obs-l-text-muted dark:text-obs-d-text-muted flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
<span class="text-sm text-obs-l-text-main dark:text-obs-d-text-main truncate">
{{ displayTagName(group.label, tag.name) }}
</span>
</div>
<span class="text-xs text-obs-l-text-muted dark:text-obs-d-text-muted ml-2 flex-shrink-0">
{{ tag.count }}
</span>
<span class="badge text-text-muted">{{ tag.count }}</span>
</button>
}
</div>
</section>
}
</div>
} @else {
<div class="flex h-full items-center justify-center rounded-lg border border-dashed border-border bg-bg-muted px-4 py-6 text-center text-sm text-text-muted">
Aucun tag ne correspond à votre recherche.
</div>
}
</div>
<nav class="custom-scrollbar sticky top-0 flex h-full w-12 flex-col items-center justify-start gap-2 self-start rounded-lg border border-border bg-card px-2 py-3 text-[0.7rem] font-medium text-text-muted shadow-subtle overflow-y-auto">
<span class="text-[0.6rem] uppercase tracking-wide text-text-muted/70">A–Z</span>
<div class="grid grid-cols-1 gap-1">
@for (letter of availableLetters(); track letter) {
<button
type="button"
class="flex h-9 w-9 items-center justify-center rounded-full border border-transparent text-[0.7rem] transition"
[ngClass]="activeLetter() === letter ? 'border-border bg-bg-muted text-text-main font-semibold' : 'text-text-muted hover:border-border hover:bg-bg-muted'"
[attr.aria-pressed]="activeLetter() === letter"
(click)="onLetterClick(letter)"
>
{{ letter }}
</button>
}
</div>
}
</div>
</nav>
}
</div>
<!-- Stats footer -->
<div class="border-t border-obs-l-border dark:border-obs-d-border px-3 py-2">
<p class="text-xs text-obs-l-text-muted dark:text-obs-d-text-muted">
{{ totalDisplayedTags() }} tag(s) affiché(s) sur {{ totalTags() }}
</p>
</div>
</div>
`,
@@ -162,64 +265,199 @@ export class TagsViewComponent {
numeric: true,
});
// Inputs/Outputs
readonly tags = input.required<TagInfo[]>();
readonly tagSelected = output<string>();
readonly searchTerm = signal('');
readonly activeLetter = signal<string | null>(null);
// State signals
searchQuery = signal('');
sortMode = signal<SortMode>('alpha-asc');
groupMode = signal<GroupMode>('none');
private expandedGroups = signal<Set<string>>(new Set());
private userCustomExpansion = signal(false);
private lastMode: GroupMode | null = null;
private readonly sections = computed<TagSection[]>(() => {
const rawTerm = this.searchTerm().trim();
const normalizedTerm = this.normalize(rawTerm);
const allTags = (this.tags() ?? []).slice().sort((a, b) => this.collator.compare(a.name, b.name));
// Normalized tags with forward slashes
private readonly normalizedTags = computed<TagInfo[]>(() => {
return (this.tags() ?? []).map(t => ({
...t,
name: (t.name ?? '').replace(/\\/g, '/')
}));
});
const filtered = rawTerm
? allTags.filter((tag) => this.normalize(tag.name).includes(normalizedTerm))
: allTags;
// Filtered tags based on search query
private readonly filteredTags = computed<TagInfo[]>(() => {
const query = this.normalize(this.searchQuery().trim());
const allTags = this.normalizedTags();
if (!query) return allTags;
return allTags.filter(tag =>
this.normalize(tag.name).includes(query)
);
});
// Sorted tags based on sort mode
private readonly sortedTags = computed<TagInfo[]>(() => {
const tags = [...this.filteredTags()];
const mode = this.sortMode();
switch (mode) {
case 'alpha-asc':
return tags.sort((a, b) => this.collator.compare(a.name, b.name));
case 'alpha-desc':
return tags.sort((a, b) => this.collator.compare(b.name, a.name));
case 'freq-desc':
return tags.sort((a, b) => b.count - a.count || this.collator.compare(a.name, b.name));
case 'freq-asc':
return tags.sort((a, b) => a.count - b.count || this.collator.compare(a.name, b.name));
default:
return tags;
}
});
// Grouped tags based on group mode
readonly displayedGroups = computed<TagGroup[]>(() => {
const tags = this.sortedTags();
const mode = this.groupMode();
const expanded = this.expandedGroups();
if (mode === 'none') {
return [{
label: 'all',
tags,
isExpanded: true,
level: 0
}];
}
if (mode === 'hierarchy') {
return this.buildHierarchyGroups(tags, expanded);
}
if (mode === 'alpha') {
return this.buildAlphaGroups(tags, expanded);
}
return [];
});
// Stats
readonly totalTags = computed(() => this.normalizedTags().length);
readonly totalDisplayedTags = computed(() => this.filteredTags().length);
constructor() {
// Auto-expand groups on mode change; don't override user toggles
effect(() => {
const mode = this.groupMode();
const labels = this.getCurrentGroupLabels();
// If switched mode, reset custom flag and initialize expansion
if (this.lastMode !== mode) {
this.userCustomExpansion.set(false);
this.lastMode = mode;
}
if (mode === 'none') {
if (this.expandedGroups().size > 0) this.expandedGroups.set(new Set());
return;
}
// Initialize only when user hasn't interacted yet or when current set mismatches and user hasn't customized
const current = this.expandedGroups();
const needsInit = current.size === 0 || current.size !== labels.length || labels.some(l => !current.has(l));
if (!this.userCustomExpansion() && needsInit) {
this.expandedGroups.set(new Set(labels));
}
}, { allowSignalWrites: true });
}
// Compute current group labels from mode + sorted tags
private getCurrentGroupLabels(): string[] {
const mode = this.groupMode();
const tags = this.sortedTags();
if (mode === 'none') return [];
if (mode === 'hierarchy') {
const roots = new Set<string>();
for (const t of tags) {
const root = (t.name?.split('/')?.[0] || t.name) ?? '';
if (root) roots.add(root);
}
return Array.from(roots).sort((a, b) => this.collator.compare(a, b));
}
// alpha
const letters = new Set<string>();
for (const t of tags) letters.add(this.extractLetter(t.name));
return Array.from(letters).sort((a, b) => this.sortLetters(a, b));
}
// Build hierarchy groups (e.g., docker/automation, docker/test -> docker group)
private buildHierarchyGroups(tags: TagInfo[], expanded: Set<string>): TagGroup[] {
const grouped = new Map<string, TagInfo[]>();
for (const tag of filtered) {
for (const tag of tags) {
const parts = tag.name.split('/');
const root = parts[0] || tag.name;
if (!grouped.has(root)) {
grouped.set(root, []);
}
grouped.get(root)!.push(tag);
}
return Array.from(grouped.entries())
.sort((a, b) => this.collator.compare(a[0], b[0]))
.map(([label, tags]) => ({
label,
tags: tags.sort((a, b) => this.collator.compare(a.name, b.name)),
isExpanded: expanded.has(label),
level: 0
}));
}
// Build alphabetical groups (A, B, C, etc.)
private buildAlphaGroups(tags: TagInfo[], expanded: Set<string>): TagGroup[] {
const grouped = new Map<string, TagInfo[]>();
for (const tag of tags) {
const letter = this.extractLetter(tag.name);
if (!grouped.has(letter)) {
grouped.set(letter, []);
}
grouped.get(letter)!.push(tag);
}
const sortedSections = Array.from(grouped.entries())
return Array.from(grouped.entries())
.sort((a, b) => this.sortLetters(a[0], b[0]))
.map(([letter, tags]) => ({
letter,
tags: tags.slice().sort((a, b) => this.collator.compare(a.name, b.name)),
.map(([label, tags]) => ({
label,
tags,
isExpanded: expanded.has(label),
level: 0
}));
}
return sortedSections;
});
private extractLetter(name: string): string {
const normalized = this.normalize(name).toUpperCase();
const char = normalized.charAt(0);
if (!char) return '#';
if (/[A-Z]/.test(char)) return char;
if (/[0-9]/.test(char)) return '#';
return char;
}
readonly availableLetters = computed(() => this.sections().map((section) => section.letter));
readonly displayedSections = computed(() => {
const active = this.activeLetter();
if (!active) {
return this.sections();
}
return this.sections().filter((section) => section.letter === active);
});
constructor() {
effect(() => {
const letters = this.availableLetters();
const active = this.activeLetter();
if (active && !letters.includes(active)) {
this.resetLetterState();
}
});
effect(() => {
if (!this.searchTerm().trim()) {
this.resetLetterState();
}
});
private sortLetters(a: string, b: string): number {
const rank = (value: string): number => {
if (value === '#') return 0;
if (/^[A-Z]$/.test(value)) return value.charCodeAt(0) - 64;
return 100 + value.charCodeAt(0);
};
const diff = rank(a) - rank(b);
return diff !== 0 ? diff : this.collator.compare(a, b);
}
private normalize(value: string): string {
@@ -229,58 +467,80 @@ export class TagsViewComponent {
.toLowerCase();
}
private extractLetter(name: string): string {
const normalized = this.normalize(name).toUpperCase();
const char = normalized.charAt(0);
if (!char) {
return '#';
}
if (/[A-Z]/.test(char)) {
return char;
}
if (/[0-9]/.test(char)) {
return '#';
}
return char;
}
private sortLetters(a: string, b: string): number {
const rank = (value: string): number => {
if (value === '#') {
return 0;
/**
* Display helper: remove the group prefix from tag name
* Example: group="docker", name="docker/automation" => "automation"
*/
displayTagName(groupLabel: string, tagName: string): string {
const mode = this.groupMode();
// In hierarchy mode, remove the root prefix
if (mode === 'hierarchy') {
const full = String(tagName ?? '');
const prefix = String(groupLabel ?? '');
if (!prefix || full === prefix) return full;
if (full.startsWith(prefix + '/')) {
return full.slice(prefix.length + 1);
}
if (/^[A-Z]$/.test(value)) {
return value.charCodeAt(0) - 64;
}
return tagName;
}
// Event handlers
onSearchChange(): void {
// Search query is bound via ngModel, no action needed
}
onSortChange(): void {
// Sort mode is bound via ngModel, computed signals will update
}
onGroupModeChange(): void {
// Group mode is bound via ngModel, computed signals will update
}
toggleGroup(label: string): void {
this.expandedGroups.update(groups => {
const newGroups = new Set(groups);
if (newGroups.has(label)) {
newGroups.delete(label);
} else {
newGroups.add(label);
}
return 100 + value.charCodeAt(0);
};
const diff = rank(a) - rank(b);
return diff !== 0 ? diff : this.collator.compare(a, b);
return newGroups;
});
this.userCustomExpansion.set(true);
}
onSearchChange(raw: string): void {
this.searchTerm.set(raw);
if (raw.trim()) {
this.activeLetter.set(null);
onTagClick(tagName: string): void {
this.tagSelected.emit(tagName);
}
resetFilters(): void {
this.searchQuery.set('');
this.sortMode.set('alpha-asc');
this.groupMode.set('none');
this.userCustomExpansion.set(false);
}
// Expand all toggle for grouped modes
allExpanded = computed(() => {
if (this.groupMode() === 'none') return false;
const labels = this.getCurrentGroupLabels();
const current = this.expandedGroups();
return labels.length > 0 && labels.every(l => current.has(l));
});
toggleExpandAll(): void {
if (this.groupMode() === 'none') return;
const labels = this.getCurrentGroupLabels();
if (this.allExpanded()) {
this.expandedGroups.set(new Set());
} else {
this.expandedGroups.set(new Set(labels));
}
}
clearSearch(): void {
this.searchTerm.set('');
this.resetLetterState();
}
onLetterClick(letter: string): void {
const active = this.activeLetter();
if (active === letter) {
this.resetLetterState();
return;
}
this.activeLetter.set(letter);
}
private resetLetterState(): void {
this.activeLetter.set(null);
this.userCustomExpansion.set(true);
}
}
+1
View File
@@ -1,6 +1,7 @@
export const environment = {
production: false,
appVersion: '0.1.0',
USE_MEILI: true, // Set to true to enable Meilisearch backend search
logging: {
enabled: true,
endpoint: '/api/log',
+7 -2
View File
@@ -1,4 +1,5 @@
import { Injectable, inject } from '@angular/core';
import { isObservable } from 'rxjs';
import { SearchOrchestratorService } from './search-orchestrator.service';
import { SearchOptions } from './search-parser.types';
@@ -45,8 +46,12 @@ export class SearchEvaluatorService {
*/
search(query: string, options?: SearchOptions): SearchResult[] {
// Delegate to the new orchestrator
const results = this.orchestrator.execute(query, options);
const exec = this.orchestrator.execute(query, options);
if (isObservable(exec)) {
// Legacy API is synchronous; when Meili (Observable) is enabled, return empty for compatibility.
return [];
}
const results = exec;
// Convert to legacy format (without ranges)
return results.map(result => ({
noteId: result.noteId,
@@ -0,0 +1,103 @@
import { inject, Injectable, signal } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { finalize } from 'rxjs/operators';
/**
* Search hit from Meilisearch with highlighting
*/
export interface SearchHit {
id: string;
title: string;
path: string;
file: string;
tags?: string[];
properties?: Record<string, unknown>;
updatedAt?: number;
createdAt?: number;
excerpt?: string;
_formatted?: {
title?: string;
content?: string;
};
}
/**
* Search response from Meilisearch API
*/
export interface SearchResponse {
hits: SearchHit[];
estimatedTotalHits: number;
facetDistribution?: Record<string, Record<string, number>>;
processingTimeMs: number;
query: string;
}
/**
* Search options
*/
export interface SearchOptions {
limit?: number;
offset?: number;
sort?: string;
highlight?: boolean;
}
/**
* Service for server-side search using Meilisearch
*/
@Injectable({ providedIn: 'root' })
export class SearchMeilisearchService {
private http = inject(HttpClient);
/** Loading state signal */
loading = signal(false);
/**
* Execute a search query
*/
search(q: string, opts?: SearchOptions): Observable<SearchResponse> {
this.loading.set(true);
let params = new HttpParams().set('q', q);
if (opts?.limit !== undefined) {
params = params.set('limit', String(opts.limit));
}
if (opts?.offset !== undefined) {
params = params.set('offset', String(opts.offset));
}
if (opts?.sort) {
params = params.set('sort', opts.sort);
}
if (opts?.highlight === false) {
params = params.set('highlight', 'false');
}
const url = `/api/search?${params.toString()}`;
console.log('[SearchMeilisearchService] Sending request:', url);
return this.http
.get<SearchResponse>('/api/search', { params })
.pipe(
finalize(() => {
console.log('[SearchMeilisearchService] Request completed');
this.loading.set(false);
})
);
}
/**
* Trigger a full reindex of the vault
*/
reindex(): Observable<{ ok: boolean; indexed: boolean; count: number; elapsedMs: number }> {
this.loading.set(true);
return this.http
.post<{ ok: boolean; indexed: boolean; count: number; elapsedMs: number }>(
'/api/reindex',
{}
)
.pipe(finalize(() => this.loading.set(false)));
}
}
+83 -1
View File
@@ -6,6 +6,10 @@ import { ClientLoggingService } from '../../services/client-logging.service';
import { SearchLogService } from '../../app/core/logging/log.service';
import { SearchDiagEvent } from '../../app/core/logging/search-log.model';
import { ParseDiagnostics } from './search-parser.types';
import { SearchMeilisearchService } from './search-meilisearch.service';
import { environment } from '../logging/environment';
import { Observable, of } from 'rxjs';
import { map } from 'rxjs/operators';
/**
* Match range for highlighting
@@ -69,11 +73,18 @@ export class SearchOrchestratorService {
private searchIndex = inject(SearchIndexService);
private logger = inject(ClientLoggingService);
private diagnosticsLogger = inject(SearchLogService);
private meiliSearch = inject(SearchMeilisearchService);
/**
* Execute a search query and return matching notes with highlights
* Delegates to Meilisearch if USE_MEILI is enabled
*/
execute(query: string, options?: SearchExecutionOptions): SearchResult[] {
execute(query: string, options?: SearchExecutionOptions): SearchResult[] | Observable<SearchResult[]> {
// Use Meilisearch backend if enabled
if (environment.USE_MEILI) {
return this.searchWithMeili(query, options);
}
if (!query || !query.trim()) {
return [];
}
@@ -803,4 +814,75 @@ export class SearchOrchestratorService {
return score;
}
/**
* Search using Meilisearch backend
*/
private searchWithMeili(query: string, options?: SearchExecutionOptions): Observable<SearchResult[]> {
this.logger.info('SearchOrchestrator', 'Using Meilisearch backend', { query });
console.log('[SearchOrchestrator] Calling Meilisearch with query:', query);
return this.meiliSearch.search(query, {
limit: options?.maxResults ?? 20,
highlight: options?.highlight !== false
}).pipe(
map(response => {
this.logger.info('SearchOrchestrator', 'Meilisearch response', {
hitCount: response.hits.length,
processingTimeMs: response.processingTimeMs
});
console.log('[SearchOrchestrator] Raw Meilisearch response:', response);
// Transform Meilisearch hits to SearchResult format
return response.hits.map(hit => {
const matches: SearchMatch[] = [];
// Prefer Meilisearch-provided highlights if available
if (hit._formatted?.title && hit._formatted.title !== hit.title) {
matches.push({
type: 'content',
text: hit.title,
context: hit._formatted.title,
ranges: []
});
}
if (hit._formatted?.content) {
matches.push({
type: 'content',
text: hit.excerpt || '',
context: hit._formatted.content,
ranges: []
});
}
// Fallback when no highlights were returned by Meilisearch
if (matches.length === 0) {
// Use excerpt if present, otherwise a cropped portion of content
const context = hit.excerpt || (typeof (hit as any).content === 'string' ? String((hit as any).content).slice(0, 200) + '…' : '');
if (context) {
matches.push({
type: 'content',
text: '',
context,
ranges: []
});
}
this.logger.debug('SearchOrchestrator', 'No highlight in Meilisearch hit, used fallback context', {
id: hit.id,
title: hit.title
});
}
const result = {
noteId: hit.id,
matches,
score: 100, // Meilisearch has its own ranking
allRanges: []
};
console.log('[SearchOrchestrator] Transformed hit:', { id: hit.id, matchCount: matches.length, result });
return result;
});
})
);
}
}
+2
View File
@@ -119,4 +119,6 @@ export interface SearchOptions {
caseSensitive?: boolean;
/** Enable regex mode */
regexMode?: boolean;
/** Enable UI highlighting in results and active document */
highlight?: boolean;
}
@@ -16,6 +16,8 @@ export interface SearchPreferences {
contextLines: number;
/** Explain search terms */
explainSearchTerms: boolean;
/** Enable UI highlighting in results and active document */
highlight: boolean;
}
/**
@@ -27,7 +29,8 @@ const DEFAULT_PREFERENCES: SearchPreferences = {
collapseResults: false,
showMoreContext: false,
contextLines: 2,
explainSearchTerms: false
explainSearchTerms: false,
highlight: true
};
/**
@@ -84,7 +87,7 @@ export class SearchPreferencesService {
*/
togglePreference(
context: string,
key: keyof Pick<SearchPreferences, 'caseSensitive' | 'regexMode' | 'collapseResults' | 'showMoreContext' | 'explainSearchTerms'>
key: keyof Pick<SearchPreferences, 'caseSensitive' | 'regexMode' | 'collapseResults' | 'showMoreContext' | 'explainSearchTerms' | 'highlight'>
): void {
const current = this.getPreferences(context);
this.updatePreferences(context, {
+3
View File
@@ -0,0 +1,3 @@
(window as any).process = {
env: { DEBUG: undefined },
};
+212 -2
View File
@@ -1,8 +1,8 @@
import { Injectable, signal, computed, OnDestroy } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Note, VaultNode, GraphData, TagInfo, VaultFolder } from '../types';
import { Note, VaultNode, GraphData, TagInfo, VaultFolder, FileMetadata } from '../types';
import { VaultEventsService, VaultEventPayload } from './vault-events.service';
import { Subscription } from 'rxjs';
import { Subscription, firstValueFrom } from 'rxjs';
interface VaultApiNote {
id: string;
@@ -26,11 +26,17 @@ interface VaultApiResponse {
})
export class VaultService implements OnDestroy {
private notesMap = signal<Map<string, Note>>(new Map());
// Fast file tree built from Meilisearch metadata
private fastTreeSignal = signal<VaultNode[]>([]);
private idToPathFast = new Map<string, string>();
private slugIdToPathFast = new Map<string, string>();
private metaByPathFast = new Map<string, FileMetadata>();
private openFolderPaths = signal(new Set<string>());
private initialVaultName = this.resolveVaultName();
allNotes = computed(() => Array.from(this.notesMap().values()));
vaultName = signal<string>(this.initialVaultName);
fastFileTree = computed<VaultNode[]>(() => this.fastTreeSignal());
fileTree = computed<VaultNode[]>(() => {
const root: VaultFolder = { type: 'folder', name: 'root', path: '', children: [], isOpen: true };
@@ -93,6 +99,193 @@ export class VaultService implements OnDestroy {
return root.children;
});
/**
* Load fast file tree from Meilisearch-backed metadata
*/
private loadFastFileTree(): void {
this.http.get<FileMetadata[]>(`/api/files/metadata`).subscribe({
next: (items) => {
try {
this.buildFastTree(items || []);
} catch (e) {
// Ignore
}
},
error: () => {
// Silent fallback to regular tree
}
});
}
private buildFastTree(items: FileMetadata[]): void {
this.idToPathFast.clear();
this.slugIdToPathFast.clear();
this.metaByPathFast.clear();
// Root folder
const root: VaultFolder = { type: 'folder', name: 'root', path: '', children: [], isOpen: true };
const folders = new Map<string, VaultFolder>([['', root]]);
// Index
for (const it of items) {
if (!it?.path) continue;
const path = it.path.replace(/\\/g, '/');
const slugId = this.buildSlugIdFromPath(path);
const safeId = it.id;
if (safeId) this.idToPathFast.set(String(safeId), path);
if (slugId) this.slugIdToPathFast.set(slugId, path);
this.metaByPathFast.set(path, it);
const parts = path.split('/').filter(Boolean);
const folderSegments = parts.slice(0, -1);
let currentPath = '';
let parentFolder = root;
for (const seg of folderSegments) {
currentPath = currentPath ? `${currentPath}/${seg}` : seg;
let folder = folders.get(currentPath);
if (!folder) {
folder = { type: 'folder', name: seg, path: currentPath, children: [], isOpen: this.openFolderPaths().has(currentPath) };
folders.set(currentPath, folder);
parentFolder.children.push(folder);
} else {
folder.isOpen = this.openFolderPaths().has(currentPath);
}
parentFolder = folder;
}
parentFolder.children.push({
type: 'file',
name: parts[parts.length - 1] ?? path,
path: path.startsWith('/') ? path : `/${path}`,
id: slugId
});
}
// Sort children
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);
this.fastTreeSignal.set(root.children);
}
/**
* Apply current openFolderPaths state to the fast file tree without rebuilding.
*/
private applyOpenStateToFastTree(): void {
const apply = (nodes: VaultNode[]) => {
for (const node of nodes) {
if (node.type === 'folder') {
node.isOpen = this.openFolderPaths().has(node.path);
apply(node.children);
}
}
};
const current = this.fastTreeSignal();
if (!current || current.length === 0) return;
apply(current);
// Trigger change detection by setting a new array reference
this.fastTreeSignal.set([...current]);
}
/**
* Build slug id from a file path like "folder/note.md" to match server's id
*/
buildSlugIdFromPath(filePath: string): string {
const noExt = filePath
.replace(/\\/g, '/')
.replace(/\.(md|excalidraw(?:\.md)?)$/i, '');
const segments = noExt.split('/').filter(Boolean);
const slugSegments = segments.map(seg => this.slugifySegment(seg));
return slugSegments.join('/');
}
private slugifySegment(segment: string): string {
const normalized = segment
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.trim();
const slug = normalized
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
return slug || normalized.toLowerCase() || segment.toLowerCase();
}
/**
* Get fast metadata by id (supports both Meilisearch id and slug id)
*/
getFastMetaById(id: string): FileMetadata | undefined {
const path = this.idToPathFast.get(id) || this.slugIdToPathFast.get(id);
if (!path) return undefined;
return this.metaByPathFast.get(path);
}
/**
* Ensure a note is loaded in memory by Meilisearch or slug id.
* Returns true if the note is available after the call.
*/
async ensureNoteLoadedById(id: string): Promise<boolean> {
if (!id) return false;
if (this.getNoteById(id)) return true;
const path = this.idToPathFast.get(id) || this.slugIdToPathFast.get(id);
if (!path) return false;
return this.ensureNoteLoadedByPath(path);
}
/**
* Ensure a note is loaded in memory by file path relative to the vault.
* Example path: "folder1/example.md"
*/
async ensureNoteLoadedByPath(path: string): Promise<boolean> {
if (!path) return false;
const slugId = this.buildSlugIdFromPath(path);
if (this.getNoteById(slugId)) return true;
try {
const url = `/vault/${encodeURI(path)}`;
const raw = await firstValueFrom(this.http.get(url, { responseType: 'text' as any }));
const normalizedRaw = String(raw).replace(/\r\n/g, '\n');
const { frontmatter, body } = this.parseFrontmatter(normalizedRaw);
const derivedTitle = this.extractTitle(body, slugId);
const noteTitle = (frontmatter.title as string) || derivedTitle;
const meta = this.metaByPathFast.get(path);
const fallbackUpdatedAt = new Date().toISOString();
const note: Note = {
id: slugId,
title: noteTitle,
content: body,
rawContent: normalizedRaw,
tags: Array.isArray(frontmatter.tags) ? (frontmatter.tags as string[]) : [],
frontmatter,
backlinks: [],
mtime: Date.now(),
fileName: path.split('/').pop() ?? `${slugId}.md`,
filePath: path,
originalPath: path.replace(/\.md$/i, ''),
createdAt: meta?.createdAt ?? undefined,
updatedAt: meta?.updatedAt ?? fallbackUpdatedAt,
};
const current = new Map(this.notesMap());
current.set(note.id, note);
this.notesMap.set(current);
return true;
} catch (e) {
return false;
}
}
graphData = computed<GraphData>(() => {
const startTime = performance.now();
const notes = this.allNotes();
@@ -143,8 +336,21 @@ export class VaultService implements OnDestroy {
tags = computed<TagInfo[]>(() => {
const tagCounts = new Map<string, number>();
const isValidTag = (raw: string): boolean => {
if (!raw) return false;
const tag = `${raw}`.trim();
if (!tag) return false;
// Exclude template placeholders and braces
if (/[{}]/.test(tag)) return false;
// Exclude pure numeric tags (e.g., 01, 1709)
if (/^\d+$/.test(tag)) return false;
// Exclude hex-like tags (e.g., 000000, 6dbafa, 383838)
if (/^[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/.test(tag)) return false;
return true;
};
for (const note of this.allNotes()) {
for (const tag of note.tags) {
if (!isValidTag(tag)) continue;
tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1);
}
}
@@ -157,6 +363,8 @@ export class VaultService implements OnDestroy {
private refreshTimeoutId: ReturnType<typeof setTimeout> | null = null;
constructor(private http: HttpClient, private vaultEvents: VaultEventsService) {
// Build a fast file tree from Meilisearch metadata for instant UI
this.loadFastFileTree();
this.refreshNotes();
this.observeVaultEvents();
}
@@ -185,6 +393,7 @@ export class VaultService implements OnDestroy {
}
return newPaths;
});
this.applyOpenStateToFastTree();
}
ensureFolderOpen(originalPath: string): void {
@@ -205,6 +414,7 @@ export class VaultService implements OnDestroy {
}
this.openFolderPaths.set(updatedPaths);
this.applyOpenStateToFastTree();
}
refresh(): void {
+44
View File
@@ -1,5 +1,25 @@
@import './styles-test.css';
/* Excalidraw CSS variables (thème sombre) */
/* .excalidraw {
--color-primary: #ffffff;
--color-secondary: #333333;
--color-background: #121212;
--color-border: #444444;
--color-text: #ffffff;
--color-ui-element: #2d2d2d;
--color-selected: #007bff;
--color-hover: #333333;
--color-error: #ff4d4f;
--color-success: #52c41a;
} */
/* .excalidraw {
--color-background: #1e1e1e !important;
--color-text: white !important;
--color-ui-element: #2d2d2d !important;
} */
.md-attachment-figure {
display: flex;
flex-direction: column;
@@ -7,6 +27,30 @@
gap: 0.25rem;
}
/* Excalidraw host sizing to ensure canvas renders full size */
excalidraw-editor {
display: block;
width: 100%;
height: 100%;
min-height: 480px;
position: relative; /* anchor absolutely-positioned children if any */
}
/* Scoped minimal layout for Excalidraw internals */
excalidraw-editor .excalidraw {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
}
excalidraw-editor .excalidraw .excalidraw__canvas-container,
excalidraw-editor .excalidraw .layer-ui__wrapper {
position: absolute;
inset: 0;
}
/* Removed global .excalidraw override to avoid conflicting with Excalidraw internal styles */
.md-attachment-figure img {
margin-bottom: 0;
}