# πŸ—οΈ ARCHITECTURE CIBLE & SCHΓ‰MAS ## 1. Diagramme Architecture Globale ``` β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ FRONTEND (Angular 20) β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”‚ UI Layer β”‚ β”‚ Main Thread β”‚ β”‚ Web Workers β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β€’ Components β”‚ β”‚ β€’ Services β”‚ β”‚ β€’ Markdown β”‚ β”‚ β”‚ β”‚ β€’ OnPush CD β”‚ β”‚ β€’ Signals β”‚ β”‚ Parser β”‚ β”‚ β”‚ β”‚ β€’ Virtual β”‚ β”‚ β€’ HTTP β”‚ β”‚ β€’ Search β”‚ β”‚ β”‚ β”‚ Scroll β”‚ β”‚ β€’ State Mgmt β”‚ β”‚ Index β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β€’ Graph β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ Layout β”‚ β”‚ β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ HTTP/JSON β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ BACKEND (Node.js Express) β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”‚ API Routes β”‚ β”‚ Services β”‚ β”‚ Watchers β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β€’ /api/vault β”‚ β”‚ β€’ VaultSvc β”‚ β”‚ β€’ Chokidar β”‚ β”‚ β”‚ β”‚ β€’ /api/searchβ”‚ β”‚ β€’ SearchSvc β”‚ β”‚ β€’ SSE Events β”‚ β”‚ β”‚ β”‚ β€’ /api/log β”‚ β”‚ β€’ LogSvc β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β€’ /api/healthβ”‚ β”‚ β€’ CacheSvc β”‚ β”‚ β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ └──────────────────┴─────────────────┐ β”‚ β”‚ β–Ό β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”‚ Meilisearch β”‚ β”‚ β”‚ β”‚ (Search Engine)β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β€’ Index vault_{} β”‚ β”‚ β”‚ β”‚ β€’ Typo-tolerance β”‚ β”‚ β”‚ β”‚ β€’ Facets β”‚ β”‚ β”‚ β”‚ β€’ Highlights β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Filesystem / Volumes β”‚ β”‚ β”‚ β”‚ β€’ /app/vault (Obsidian) β”‚ β”‚ β€’ /app/db (logs, cache) β”‚ β”‚ β€’ /app/tmp β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` --- ## 2. SchΓ©ma d'Index Meilisearch ### Index par VoΓ»te: `vault_{vaultId}` **Configuration Index:** ```json { "primaryKey": "docId", "searchableAttributes": [ "title", "content", "headings", "tags", "fileName", "path" ], "filterableAttributes": [ "tags", "folder", "ext", "hasAttachment", "mtime", "size" ], "sortableAttributes": [ "mtime", "title", "size" ], "rankingRules": [ "words", "typo", "proximity", "attribute", "sort", "exactness" ], "typoTolerance": { "enabled": true, "minWordSizeForTypos": { "oneTypo": 4, "twoTypos": 8 }, "disableOnWords": [], "disableOnAttributes": [] }, "faceting": { "maxValuesPerFacet": 100 }, "pagination": { "maxTotalHits": 1000 } } ``` **Structure Document:** ```typescript interface MeilisearchDocument { docId: string; // Format: {vaultId}:{relativePath} vaultId: string; // Ex: "primary", "work", "personal" title: string; // Note title fileName: string; // Ex: "home.md" path: string; // Ex: "folder1/subfolder/note" folder: string; // Ex: "folder1/subfolder" ext: string; // Ex: "md" content: string; // Body markdown (stripped frontmatter) contentPreview: string; // First 200 chars headings: string[]; // ["# Heading 1", "## Heading 2"] tags: string[]; // ["#project", "#important"] properties: Record; // Frontmatter key-value hasAttachment: boolean; // Contains ![[image.png]] linkCount: number; // Number of outgoing links backlinksCount: number; // Number of incoming links mtime: number; // Unix timestamp size: number; // Bytes createdAt: string; // ISO 8601 updatedAt: string; // ISO 8601 } ``` --- ## 3. Mapping OpΓ©rateurs Obsidian β†’ Meilisearch | OpΓ©rateur Obsidian | Meilisearch Query/Filter | Exemple | |--------------------|--------------------------|---------| | `tag:#project` | `filter: "tags = '#project'"` | `?filter=tags%20%3D%20%27%23project%27` | | `path:folder1/` | `filter: "folder = 'folder1'"` | `?filter=folder%20%3D%20%27folder1%27` | | `file:home` | `filter: "fileName = 'home.md'"` | `?filter=fileName%20%3D%20%27home.md%27` | | `-tag:#archive` | `filter: "tags != '#archive'"` | `?filter=tags%20!%3D%20%27%23archive%27` | | `content:search term` | `q=search term&attributesToSearchOn=content` | Default search | | `section:"My Heading"` | `q=My Heading&attributesToSearchOn=headings` | Section-specific | | `task:TODO` | Custom backend filter (tasks extracted) | - | | `has:attachment` | `filter: "hasAttachment = true"` | `?filter=hasAttachment%20%3D%20true` | **Query combinΓ©e exemple:** ``` Obsidian: tag:#project -tag:#archive path:work/ important Meilisearch: POST /indexes/vault_primary/search { "q": "important", "filter": [ "tags = '#project'", "tags != '#archive'", "folder = 'work'" ], "attributesToHighlight": ["content", "title"], "highlightPreTag": "", "highlightPostTag": "", "limit": 50 } ``` --- ## 4. Routes API Backend ### `/api/search` - Recherche unifiΓ©e **Endpoint:** `POST /api/search` **Request:** ```typescript interface SearchRequest { query: string; // Raw Obsidian query vaultId?: string; // Default: "primary" options?: { limit?: number; // Default: 50 offset?: number; // Pagination attributesToRetrieve?: string[]; attributesToHighlight?: string[]; }; } ``` **Response:** ```typescript interface SearchResponse { hits: Array<{ docId: string; title: string; path: string; _formatted?: { // With highlights title: string; content: string; }; _matchesPosition?: MatchRange[]; }>; estimatedTotalHits: number; processingTimeMs: number; query: string; facetDistribution?: Record>; } ``` --- ### `/api/search/suggest` - Autocomplete **Endpoint:** `GET /api/search/suggest?q=term&type=tag` **Response:** ```typescript interface SuggestResponse { suggestions: string[]; type: 'tag' | 'file' | 'path' | 'property'; } ``` --- ### `/api/search/facets` - Facettes disponibles **Endpoint:** `GET /api/search/facets` **Response:** ```typescript interface FacetsResponse { tags: Array<{ name: string; count: number }>; folders: Array<{ name: string; count: number }>; extensions: Array<{ name: string; count: number }>; } ``` --- ### `/api/search/reindex` - RΓ©indexation manuelle **Endpoint:** `POST /api/search/reindex` **Request:** ```typescript interface ReindexRequest { vaultId: string; full?: boolean; // true = rebuild complet, false = delta } ``` **Response:** ```typescript interface ReindexResponse { taskId: string; // Meilisearch task UID status: 'enqueued' | 'processing' | 'succeeded' | 'failed'; indexedDocuments?: number; durationMs?: number; } ``` --- ### `/api/log` - Logging endpoint **Endpoint:** `POST /api/log` **Request:** ```typescript interface LogBatchRequest { records: LogRecord[]; } interface LogRecord { ts: string; // ISO 8601 level: 'debug' | 'info' | 'warn' | 'error'; app: string; // "ObsiViewer" sessionId: string; // UUID userAgent: string; context: { version: string; route?: string; theme?: 'light' | 'dark'; vault?: string; }; event: LogEvent; data: Record; } ``` **Response:** ```typescript interface LogResponse { accepted: number; rejected: number; errors?: string[]; } ``` --- ## 5. Plan /api/log - Γ‰vΓ©nements StandardisΓ©s (12+) ### Γ‰vΓ©nements Frontend | Event | Trigger | Data Fields | Exemple | |-------|---------|-------------|---------| | `APP_START` | App bootstrap | `viewport: {width, height}` | User opens app | | `APP_STOP` | beforeunload | - | User closes tab | | `PAGE_VIEW` | Route change | `route: string, previousRoute?: string` | Navigate to /graph | | `SEARCH_EXECUTED` | Search submit | `query: string, queryLength: number, resultsCount?: number` | User searches "tag:#project" | | `SEARCH_OPTIONS_APPLIED` | Filter toggle | `options: {caseSensitive, regex, wholeWord}` | Enable case-sensitive | | `GRAPH_VIEW_OPEN` | Graph tab click | - | User opens graph view | | `GRAPH_INTERACTION` | Node click/hover | `action: 'click'\|'hover', nodeId: string` | Click node in graph | | `BOOKMARKS_OPEN` | Bookmarks panel | - | Open bookmarks | | `BOOKMARKS_MODIFY` | Add/edit/delete | `action: 'add'\|'update'\|'delete', path: string` | Bookmark note | | `CALENDAR_SEARCH_EXECUTED` | Calendar date select | `resultsCount: number, dateRange?: string` | Select date range | | `ERROR_BOUNDARY` | Uncaught error | `message: string, stack?: string, componentStack?: string` | React error boundary | | `PERFORMANCE_METRIC` | Web Vitals | `metric: 'LCP'\|'FID'\|'CLS', value: number` | Lighthouse metrics | ### Γ‰vΓ©nements Backend (Γ  implΓ©menter) | Event | Trigger | Data Fields | |-------|---------|-------------| | `VAULT_INDEXED` | Meilisearch indexation complΓ¨te | `vaultId: string, documentsCount: number, durationMs: number` | | `VAULT_WATCH_ERROR` | Chokidar error | `vaultId: string, error: string` | | `SEARCH_BACKEND_EXECUTED` | Meilisearch query | `query: string, hitsCount: number, processingTimeMs: number` | --- ## 6. StratΓ©gie Worker/WebGL pour Graph - CritΓ¨res Anti-Gel ### ProblΓ¨me actuel - Graph layout calcul dans Web Worker βœ… (bon) - Rendu Canvas dans main thread ❌ (peut bloquer) - Pas de LOD (Level of Detail) ❌ - Pas de capping nodes/links ❌ ### Solution cible #### A) Web Worker pour Layout (dΓ©jΓ  implΓ©mentΓ©) - `graph-layout.worker.ts` calcule positions avec d3-force - Communication via `postMessage` βœ… - **Garde-fou:** Timeout 30s, max iterations 1000 #### B) Canvas Rendering OptimisΓ© **Throttle redraw:** ```typescript private lastDrawTime = 0; private readonly MIN_FRAME_INTERVAL = 16; // 60fps max private scheduleRedraw(): void { const now = performance.now(); if (now - this.lastDrawTime < this.MIN_FRAME_INTERVAL) { return; // Skip frame } requestAnimationFrame(() => { this.draw(); this.lastDrawTime = performance.now(); }); } ``` **LOD (Level of Detail):** ```typescript private draw(): void { const zoom = this.transform().k; // Adaptive rendering based on zoom if (zoom < 0.5) { // Far view: circles only, no labels, no arrows this.drawNodesSimple(ctx, nodes); } else if (zoom < 1.5) { // Medium: circles + labels this.drawNodes(ctx, nodes); this.drawLabels(ctx, nodes); } else { // Close: full detail this.drawLinks(ctx, links, settings); this.drawNodes(ctx, nodes); this.drawLabels(ctx, nodes); } } ``` **Capping nodes/links:** ```typescript private readonly MAX_VISIBLE_NODES = 500; private readonly MAX_VISIBLE_LINKS = 1000; private cullNodes(nodes: SimulationNode[]): SimulationNode[] { // Show only visible nodes in viewport + margin const visible = nodes.filter(n => this.isInViewport(n)); if (visible.length > this.MAX_VISIBLE_NODES) { // Sort by importance (link count, selection state) return visible .sort((a, b) => b.importance - a.importance) .slice(0, this.MAX_VISIBLE_NODES); } return visible; } ``` **Clustering pour grands graphes (>1000 nodes):** ```typescript interface ClusterNode { id: string; type: 'cluster'; children: string[]; // Node IDs in cluster x: number; y: number; radius: number; } // Use force-cluster layout for >1000 nodes if (nodes.length > 1000) { const clusters = this.clusterByFolder(nodes, settings.clusterThreshold); this.renderClusters(clusters); } ``` **CritΓ¨res d'arrΓͺt du gel UI:** - βœ… Aucune frame >50ms (pas de janky scroll) - βœ… Redraw budget: <16ms par frame (60fps) - βœ… Worker layout timeout: 30s max - βœ… Progressive rendering: 100 nodes/frame si >500 total - βœ… User interaction priority: stopper animation si clic dΓ©tectΓ© --- ## 7. Docker Multi-Stage + Healthcheck **Dockerfile optimisΓ©:** ```dockerfile # syntax=docker/dockerfile:1 # ========== Stage 1: Builder ========== FROM node:20-alpine AS builder WORKDIR /build # Install dependencies (leverage layer cache) COPY package*.json ./ RUN npm ci --only=production=false # Copy source COPY . . # Build Angular (production) RUN npx ng build --configuration=production # Prune dev dependencies RUN npm prune --omit=dev # ========== Stage 2: Runtime ========== FROM node:20-alpine AS runtime # Security: non-root user RUN addgroup -g 1001 -S nodejs && \ adduser -S nodejs -u 1001 WORKDIR /app # Install curl for healthcheck RUN apk add --no-cache curl # Copy artifacts from builder COPY --from=builder --chown=nodejs:nodejs /build/dist ./dist COPY --from=builder --chown=nodejs:nodejs /build/server ./server COPY --from=builder --chown=nodejs:nodejs /build/node_modules ./node_modules COPY --from=builder --chown=nodejs:nodejs /build/package*.json ./ COPY --from=builder --chown=nodejs:nodejs /build/db ./db # Create volumes RUN mkdir -p /app/vault /app/tmp /app/logs && \ chown -R nodejs:nodejs /app USER nodejs # Expose port EXPOSE 4000 # Healthcheck HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD curl -f http://localhost:4000/api/health || exit 1 # Environment defaults ENV NODE_ENV=production \ PORT=4000 \ VAULT_PATH=/app/vault \ LOG_LEVEL=info # Start server CMD ["node", "./server/index.mjs"] ``` **Variables d'env clΓ©s:** ```bash # .env.example PORT=4000 NODE_ENV=production VAULT_PATH=/app/vault VAULT_ID=primary MEILISEARCH_URL=http://meilisearch:7700 MEILISEARCH_KEY=masterKey LOG_LEVEL=info LOG_ENDPOINT=http://localhost:4000/api/log ENABLE_SEARCH_CACHE=true CACHE_TTL_SECONDS=300 ```