# đŸ’» EXEMPLES DE CODE — Diffs CiblĂ©s (5+ exemples copier-coller) ## Exemple 1: CDK Virtual Scroll pour RĂ©sultats de Recherche ### Fichier: `src/components/search-results/search-results.component.ts` **AVANT (rendu complet):** ```typescript @Component({ selector: 'app-search-results', template: `
@for (note of results(); track note.id) { }
`, changeDetection: ChangeDetectionStrategy.OnPush }) export class SearchResultsComponent { results = input.required(); noteSelected = output(); selectNote(note: Note): void { this.noteSelected.emit(note); } } ``` **APRÈS (avec Virtual Scroll):** ```typescript import { CdkVirtualScrollViewport, ScrollingModule } from '@angular/cdk/scrolling'; @Component({ selector: 'app-search-results', imports: [CommonModule, ScrollingModule, NoteCardComponent], template: ` @if (results().length === 0) {
Aucun résultat trouvé
}
`, styles: [` .results-viewport { height: 100%; width: 100%; } .result-item { height: 80px; /* Match itemSize */ padding: 0.5rem; } `], changeDetection: ChangeDetectionStrategy.OnPush }) export class SearchResultsComponent { results = input.required(); noteSelected = output(); selectNote(note: Note): void { this.noteSelected.emit(note); } // CRITICAL: trackBy optimizes change detection trackByNoteId(index: number, note: Note): string { return note.id; } } ``` **Gain attendu:** - DOM nodes: 500 → ~15 (97% rĂ©duction) - Scroll FPS: 30 → 60 - Temps rendu initial: 800ms → 50ms --- ## Exemple 2: Web Worker pour Parsing Markdown ### Fichier: `src/services/markdown.worker.ts` (NOUVEAU) ```typescript /// import MarkdownIt from 'markdown-it'; import markdownItAnchor from 'markdown-it-anchor'; import markdownItTaskLists from 'markdown-it-task-lists'; import markdownItAttrs from 'markdown-it-attrs'; import markdownItFootnote from 'markdown-it-footnote'; import markdownItMultimdTable from 'markdown-it-multimd-table'; import hljs from 'highlight.js'; interface ParseRequest { id: string; markdown: string; options?: { currentNotePath?: string; attachmentsBase?: string; }; } interface ParseResponse { id: string; html: string; error?: string; } // Initialize MarkdownIt (reuse across requests) const md = new MarkdownIt({ html: true, linkify: false, typographer: false, breaks: false, highlight: (code, lang) => { if (lang && hljs.getLanguage(lang)) { return hljs.highlight(code, { language: lang }).value; } return hljs.highlightAuto(code).value; } }); md.use(markdownItAnchor, { slugify: slugify, tabIndex: false }); md.use(markdownItTaskLists, { enabled: false }); md.use(markdownItMultimdTable, { multiline: true, rowspan: true, headerless: true }); md.use(markdownItFootnote); md.use(markdownItAttrs, { leftDelimiter: '{', rightDelimiter: '}', allowedAttributes: ['id', 'class'] }); // Message handler self.addEventListener('message', (event: MessageEvent) => { const { id, markdown, options } = event.data; try { const html = md.render(markdown); const response: ParseResponse = { id, html }; self.postMessage(response); } catch (error) { const response: ParseResponse = { id, html: '', error: error instanceof Error ? error.message : String(error) }; self.postMessage(response); } }); function slugify(text: string): string { return text .toString() .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .replace(/[^\w\s-]/g, '') .trim() .replace(/\s+/g, '-') .replace(/-+/g, '-') .toLowerCase(); } ``` ### Fichier: `src/services/markdown-worker.service.ts` (NOUVEAU) ```typescript import { Injectable, NgZone } from '@angular/core'; import { Observable, Subject, filter, map, take, timeout } from 'rxjs'; interface WorkerTask { id: string; markdown: string; options?: any; resolve: (html: string) => void; reject: (error: Error) => void; } @Injectable({ providedIn: 'root' }) export class MarkdownWorkerService { private workers: Worker[] = []; private readonly WORKER_COUNT = 2; // CPU cores - 2 private currentWorkerIndex = 0; private taskCounter = 0; private responses$ = new Subject<{ id: string; html: string; error?: string }>(); constructor(private zone: NgZone) { this.initWorkers(); } private initWorkers(): void { for (let i = 0; i < this.WORKER_COUNT; i++) { const worker = new Worker( new URL('./markdown.worker.ts', import.meta.url), { type: 'module' } ); // Handle responses outside Angular zone this.zone.runOutsideAngular(() => { worker.addEventListener('message', (event) => { this.zone.run(() => { this.responses$.next(event.data); }); }); }); this.workers.push(worker); } } /** * Parse markdown in worker (non-blocking) */ parse(markdown: string, options?: any): Observable { const id = `task-${this.taskCounter++}`; // Round-robin worker selection const worker = this.workers[this.currentWorkerIndex]; this.currentWorkerIndex = (this.currentWorkerIndex + 1) % this.WORKER_COUNT; // Send to worker worker.postMessage({ id, markdown, options }); // Wait for response return this.responses$.pipe( filter(response => response.id === id), take(1), timeout(5000), // 5s timeout map(response => { if (response.error) { throw new Error(response.error); } return response.html; }) ); } ngOnDestroy(): void { this.workers.forEach(w => w.terminate()); } } ``` ### Fichier: `src/services/markdown.service.ts` (MODIFIÉ) ```typescript @Injectable({ providedIn: 'root' }) export class MarkdownService { private workerService = inject(MarkdownWorkerService); /** * Render markdown to HTML (async via worker) */ renderAsync(markdown: string, options?: any): Observable { return this.workerService.parse(markdown, options).pipe( map(html => this.postProcess(html, options)) ); } /** * Synchronous fallback (SSR, tests) */ renderSync(markdown: string, options?: any): string { // Old synchronous implementation (kept for compatibility) const md = this.createMarkdownIt(); return this.postProcess(md.render(markdown), options); } private postProcess(html: string, options?: any): string { // Apply transformations that need note context html = this.transformCallouts(html); html = this.transformTaskLists(html); html = this.wrapTables(html); return html; } } ``` **Gain attendu:** - Main thread block: 500ms → <16ms - Concurrent parsing: support multiple notes - TTI improvement: ~30% --- ## Exemple 3: Lazy Import Mermaid + `runOutsideAngular` ### Fichier: `src/components/note-viewer/note-viewer.component.ts` **AVANT:** ```typescript import mermaid from 'mermaid'; // ❌ Statique, 1.2MB au boot @Component({...}) export class NoteViewerComponent implements AfterViewInit { ngAfterViewInit(): void { this.renderMermaid(); } private renderMermaid(): void { const diagrams = this.elementRef.nativeElement.querySelectorAll('.mermaid-diagram'); diagrams.forEach(el => { const code = decodeURIComponent(el.dataset.mermaidCode || ''); mermaid.render(`mermaid-${Date.now()}`, code).then(({ svg }) => { el.innerHTML = svg; }); }); } } ``` **APRÈS:** ```typescript import { NgZone } from '@angular/core'; @Component({...}) export class NoteViewerComponent implements AfterViewInit { private zone = inject(NgZone); private mermaidLoaded = false; private mermaidModule: typeof import('mermaid') | null = null; ngAfterViewInit(): void { this.renderMermaidAsync(); } private async renderMermaidAsync(): Promise { const diagrams = this.elementRef.nativeElement.querySelectorAll('.mermaid-diagram'); if (diagrams.length === 0) { return; // No mermaid diagrams, skip loading } // Lazy load mermaid ONLY when needed if (!this.mermaidLoaded) { try { // runOutsideAngular: avoid triggering change detection during load await this.zone.runOutsideAngular(async () => { const { default: mermaid } = await import('mermaid'); mermaid.initialize({ startOnLoad: false, theme: document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'default' }); this.mermaidModule = mermaid; this.mermaidLoaded = true; }); } catch (error) { console.error('[Mermaid] Failed to load:', error); return; } } // Render diagrams outside Angular zone (heavy computation) await this.zone.runOutsideAngular(async () => { for (const el of Array.from(diagrams)) { const code = decodeURIComponent((el as HTMLElement).dataset.mermaidCode || ''); try { const { svg } = await this.mermaidModule!.render( `mermaid-${Date.now()}-${Math.random()}`, code ); // Re-enter zone only for DOM update this.zone.run(() => { el.innerHTML = svg; }); } catch (error) { console.error('[Mermaid] Render error:', error); el.innerHTML = `
Mermaid error: ${error}
`; } } }); } } ``` **MĂȘme pattern pour MathJax:** ```typescript private async renderMathAsync(): Promise { const mathElements = this.elementRef.nativeElement.querySelectorAll('.md-math-block, .md-math-inline'); if (mathElements.length === 0) return; await this.zone.runOutsideAngular(async () => { const { default: mathjax } = await import('markdown-it-mathjax3'); // ... configuration and rendering }); } ``` **Gain attendu:** - Initial bundle: -1.2MB (mermaid) - TTI: 4.2s → 2.5s - Mermaid load only when needed: lazy --- ## Exemple 4: Service `SearchMeilisearchService` + Mapping OpĂ©rateurs ### Fichier: `src/services/search-meilisearch.service.ts` (NOUVEAU) ```typescript import { Injectable, inject } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable, map, catchError, of } from 'rxjs'; interface MeilisearchSearchRequest { q: string; filter?: string | string[]; attributesToHighlight?: string[]; highlightPreTag?: string; highlightPostTag?: string; limit?: number; offset?: number; } interface MeilisearchHit { docId: string; title: string; path: string; fileName: string; content: string; tags: string[]; _formatted?: { title: string; content: string; }; _matchesPosition?: any; } interface MeilisearchSearchResponse { hits: MeilisearchHit[]; estimatedTotalHits: number; processingTimeMs: number; query: string; } @Injectable({ providedIn: 'root' }) export class SearchMeilisearchService { private http = inject(HttpClient); private readonly BASE_URL = '/api/search'; /** * Execute search with Obsidian operator mapping */ search(query: string, options?: { limit?: number; vaultId?: string; }): Observable { const { filters, searchTerms } = this.parseObsidianQuery(query); const request: MeilisearchSearchRequest = { q: searchTerms.join(' '), filter: filters, attributesToHighlight: ['title', 'content', 'headings'], highlightPreTag: '', highlightPostTag: '', limit: options?.limit ?? 50, offset: 0 }; return this.http.post( `${this.BASE_URL}`, { ...request, vaultId: options?.vaultId ?? 'primary' } ).pipe( catchError(error => { console.error('[SearchMeilisearch] Error:', error); return of({ hits: [], estimatedTotalHits: 0, processingTimeMs: 0, query }); }) ); } /** * Parse Obsidian query into Meilisearch filters + search terms */ private parseObsidianQuery(query: string): { filters: string[]; searchTerms: string[]; } { const filters: string[] = []; const searchTerms: string[] = []; // Extract operators: tag:, path:, file:, -tag:, etc. const tokens = query.match(/(-)?(\w+):([^\s]+)|([^\s]+)/g) || []; for (const token of tokens) { // Negative tag if (token.match(/^-tag:#?(.+)/)) { const tag = token.replace(/^-tag:#?/, ''); filters.push(`tags != '#${tag}'`); continue; } // Positive tag if (token.match(/^tag:#?(.+)/)) { const tag = token.replace(/^tag:#?/, ''); filters.push(`tags = '#${tag}'`); continue; } // Path filter if (token.match(/^path:(.+)/)) { const path = token.replace(/^path:/, ''); filters.push(`folder = '${path}'`); continue; } // File filter if (token.match(/^file:(.+)/)) { const file = token.replace(/^file:/, ''); filters.push(`fileName = '${file}.md'`); continue; } // Has attachment if (token === 'has:attachment') { filters.push('hasAttachment = true'); continue; } // Regular search term if (!token.startsWith('-') && !token.includes(':')) { searchTerms.push(token); } } return { filters, searchTerms }; } /** * Get autocomplete suggestions */ suggest(query: string, type: 'tag' | 'file' | 'path'): Observable { return this.http.get<{ suggestions: string[] }>( `${this.BASE_URL}/suggest`, { params: { q: query, type } } ).pipe( map(response => response.suggestions), catchError(() => of([])) ); } } ``` **Utilisation dans `SearchOrchestratorService`:** ```typescript @Injectable({ providedIn: 'root' }) export class SearchOrchestratorService { private meilisearch = inject(SearchMeilisearchService); private localIndex = inject(SearchIndexService); // Fallback execute(query: string, options?: SearchExecutionOptions): Observable { // Use Meilisearch if available, fallback to local return this.meilisearch.search(query, options).pipe( map(response => this.transformMeilisearchResults(response)), catchError(error => { console.warn('[Search] Meilisearch failed, using local index', error); return of(this.executeLocal(query, options)); }) ); } private transformMeilisearchResults(response: MeilisearchSearchResponse): SearchResult[] { return response.hits.map(hit => ({ noteId: hit.docId.split(':')[1], // Extract noteId from docId matches: this.extractMatches(hit), score: 100, // Meilisearch handles scoring allRanges: [] })); } } ``` **Gain attendu:** - Search P95: 800ms → 50ms (16x faster) - Typo-tolerance: "porject" trouve "project" - Highlights server-side: pas de calcul frontend --- ## Exemple 5: API `/api/log` Backend + Contrat TypeScript ### Fichier: `server/routes/log.mjs` (NOUVEAU) ```javascript import express from 'express'; import { promises as fs } from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const router = express.Router(); const LOG_DIR = path.join(__dirname, '../logs'); const MAX_LOG_SIZE = 10 * 1024 * 1024; // 10MB const LOG_FILE = path.join(LOG_DIR, 'client-events.jsonl'); // Ensure log directory exists await fs.mkdir(LOG_DIR, { recursive: true }); /** * POST /api/log - Receive batch of client events */ router.post('/log', async (req, res) => { try { const { records } = req.body; if (!Array.isArray(records) || records.length === 0) { return res.status(400).json({ accepted: 0, rejected: 0, errors: ['Invalid request: records must be a non-empty array'] }); } // Validate and sanitize records const validRecords = []; const errors = []; for (let i = 0; i < records.length; i++) { const record = records[i]; // Required fields if (!record.ts || !record.event || !record.sessionId) { errors.push(`Record ${i}: missing required fields`); continue; } // Sanitize sensitive data const sanitized = { ts: record.ts, level: record.level || 'info', app: record.app || 'ObsiViewer', sessionId: record.sessionId, event: record.event, context: sanitizeContext(record.context || {}), data: sanitizeData(record.data || {}) }; validRecords.push(sanitized); } // Write to JSONL (one JSON per line) if (validRecords.length > 0) { const lines = validRecords.map(r => JSON.stringify(r)).join('\n') + '\n'; await appendLog(lines); } res.json({ accepted: validRecords.length, rejected: records.length - validRecords.length, errors: errors.length > 0 ? errors : undefined }); } catch (error) { console.error('[Log API] Error:', error); res.status(500).json({ accepted: 0, rejected: 0, errors: ['Internal server error'] }); } }); /** * Append to log file with rotation */ async function appendLog(content) { try { // Check file size, rotate if needed try { const stats = await fs.stat(LOG_FILE); if (stats.size > MAX_LOG_SIZE) { const rotated = `${LOG_FILE}.${Date.now()}`; await fs.rename(LOG_FILE, rotated); console.log(`[Log] Rotated log to ${rotated}`); } } catch (err) { // File doesn't exist yet, create it } await fs.appendFile(LOG_FILE, content, 'utf-8'); } catch (error) { console.error('[Log] Write error:', error); } } /** * Sanitize context (redact vault path, keep version/theme) */ function sanitizeContext(context) { return { version: context.version, route: context.route?.replace(/[?&].*/, ''), // Strip query params theme: context.theme, // vault: redacted }; } /** * Sanitize data (hash sensitive fields) */ function sanitizeData(data) { const sanitized = { ...data }; // Hash query strings if (sanitized.query && typeof sanitized.query === 'string') { sanitized.query = hashString(sanitized.query); } // Redact file paths if (sanitized.path && typeof sanitized.path === 'string') { sanitized.path = '[REDACTED]'; } return sanitized; } /** * Simple hash (not cryptographic, just obfuscation) */ function hashString(str) { let hash = 0; for (let i = 0; i < str.length; i++) { const chr = str.charCodeAt(i); hash = ((hash << 5) - hash) + chr; hash |= 0; } return `hash_${Math.abs(hash).toString(16)}`; } export default router; ``` ### Fichier: `server/index.mjs` (MODIFIÉ) ```javascript import express from 'express'; import logRouter from './routes/log.mjs'; const app = express(); // Middleware app.use(express.json({ limit: '1mb' })); // Routes app.use('/api', logRouter); // Health endpoint app.get('/api/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); // Start server const PORT = process.env.PORT || 4000; app.listen(PORT, () => { console.log(`[Server] Listening on port ${PORT}`); }); ``` ### Fichier: `src/core/logging/log.model.ts` (ÉvĂ©nements standardisĂ©s) ```typescript export type LogEvent = // App lifecycle | 'APP_START' | 'APP_STOP' // Navigation | 'PAGE_VIEW' // Search | 'SEARCH_EXECUTED' | 'SEARCH_OPTIONS_APPLIED' | 'SEARCH_DIAG_START' | 'SEARCH_DIAG_PARSE' | 'SEARCH_DIAG_PLAN' | 'SEARCH_DIAG_EXEC_PROVIDER' | 'SEARCH_DIAG_RESULT_MAP' | 'SEARCH_DIAG_SUMMARY' | 'SEARCH_DIAG_ERROR' // Graph | 'GRAPH_VIEW_OPEN' | 'GRAPH_INTERACTION' // Bookmarks | 'BOOKMARKS_OPEN' | 'BOOKMARKS_MODIFY' // Calendar | 'CALENDAR_SEARCH_EXECUTED' // Errors | 'ERROR_BOUNDARY' | 'PERFORMANCE_METRIC'; export interface LogRecord { ts: string; // ISO 8601 level: LogLevel; app: string; sessionId: string; userAgent: string; context: LogContext; event: LogEvent; data: Record; } export interface LogContext { version: string; route?: string; theme?: 'light' | 'dark'; vault?: string; } export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; ``` **Exemple payload:** ```json { "records": [ { "ts": "2025-10-06T15:30:00.000Z", "level": "info", "app": "ObsiViewer", "sessionId": "abc-123-def-456", "userAgent": "Mozilla/5.0...", "context": { "version": "1.0.0", "route": "/", "theme": "dark" }, "event": "SEARCH_EXECUTED", "data": { "query": "tag:#project", "queryLength": 13, "resultsCount": 42 } } ] } ``` **Gain attendu:** - Diagnostics production possibles - CorrĂ©lation Ă©vĂ©nements via sessionId - Rotation automatique logs - RGPD-compliant (redaction champs sensibles) --- ## RĂ©sumĂ© des Exemples | # | Feature | Fichiers ModifiĂ©s/Créés | Gain Principal | |---|---------|-------------------------|----------------| | 1 | CDK Virtual Scroll | `search-results.component.ts` | -97% DOM nodes | | 2 | Markdown Worker | `markdown.worker.ts`, `markdown-worker.service.ts` | -500ms freeze | | 3 | Lazy Mermaid | `note-viewer.component.ts` | -1.2MB bundle | | 4 | Meilisearch Service | `search-meilisearch.service.ts` | 16x faster search | | 5 | /api/log Backend | `server/routes/log.mjs` | Diagnostics production | **Tous ces exemples sont prĂȘts Ă  copier-coller et tester immĂ©diatement.**