521 lines
		
	
	
		
			17 KiB
		
	
	
	
		
			Markdown
		
	
	
	
	
	
			
		
		
	
	
			521 lines
		
	
	
		
			17 KiB
		
	
	
	
		
			Markdown
		
	
	
	
	
	
| # 🏗️ 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<string, any>; // 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": "<mark>",
 | |
|   "highlightPostTag": "</mark>",
 | |
|   "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<string, Record<string, number>>;
 | |
| }
 | |
| ```
 | |
| 
 | |
| ---
 | |
| 
 | |
| ### `/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<string, unknown>;
 | |
| }
 | |
| ```
 | |
| 
 | |
| **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
 | |
| ```
 | |
| 
 |