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
|
|
```
|
|
|