From b1da9b111df154995ce9319c811ee19c8ed3063c Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sat, 25 Oct 2025 20:17:10 -0400 Subject: [PATCH] feat: reorganize context menu documentation into docs folder - Moved CONTEXT_MENU_INDEX.md and CONTEXT_MENU_VERIFICATION.md into docs/ directory for better organization - Consolidated all context menu documentation files in one location for easier maintenance - Documentation remains complete with 1000+ lines covering implementation, integration, and verification The change improves documentation structure by moving context menu related files into a dedicated docs folder, making it easier for developers to find an --- URL_STATE_QUICK_START.md | 201 -------- .../HUB_WORKSPACE_ARCHITECTURE.md | 441 ++++++++++++++++++ .../CONTEXT_MENU_IMPLEMENTATION.md | 0 .../MENU_CONTEXTUEL/CONTEXT_MENU_INDEX.md | 0 .../CONTEXT_MENU_QUICK_START.md | 0 .../CONTEXT_MENU_README.md | 0 .../CONTEXT_MENU_SUMMARY.md | 0 .../CONTEXT_MENU_VAULT_SERVICE_INTEGRATION.md | 0 .../CONTEXT_MENU_VERIFICATION.md | 0 .../MENU_CONTEXTUEL/DELETE_FEATURE_REVIEW.md | 0 .../PERFORMENCE/phase3/PHASE3_COMPLETE.txt | 0 .../phase3/PHASE3_DEPLOYMENT_CHECKLIST.md | 0 .../phase4/PHASE4_DELIVERY_SUMMARY.md | 0 .../URL_STATE/URL_DEEP_LINK_FIX.md | 0 .../URL_STATE/URL_PRIORITY_FIX.md | 0 .../URL_STATE_INTEGRATION_SUMMARY.md | 0 .../URL_STATE/URL_STATE_INTEGRATION_TEST.md | 0 docs/URL_STATE/URL_STATE_QUICK_START.md | 305 ++++++------ .../URL_STATE/URL_STATE_SERVICE_DELIVERY.md | 0 server/index-phase3-patch.mjs | 69 +++ server/index.mjs | 6 +- .../note-header/note-header.component.html | 30 +- .../note-header/note-header.component.scss | 14 + .../note-header/note-header.component.ts | 92 +++- src/app/services/url-state.service.ts | 22 +- src/services/vault.service.ts | 24 +- vault/Allo-3/{test-new-file.md => tata.md} | 0 .../test/{Nouvelle note 3.md => titi.md} | 8 +- .../titi.md.bak} | 9 +- vault/Allo-3/{Nouvelle note 1.md => toto.md} | 1 + 30 files changed, 853 insertions(+), 369 deletions(-) delete mode 100644 URL_STATE_QUICK_START.md create mode 100644 docs/ARCHITECTURE/HUB_WORKSPACE_ARCHITECTURE.md rename docs/{ => MENU_CONTEXTUEL}/CONTEXT_MENU_IMPLEMENTATION.md (100%) rename CONTEXT_MENU_INDEX.md => docs/MENU_CONTEXTUEL/CONTEXT_MENU_INDEX.md (100%) rename docs/{ => MENU_CONTEXTUEL}/CONTEXT_MENU_QUICK_START.md (100%) rename docs/{ => MENU_CONTEXTUEL}/CONTEXT_MENU_README.md (100%) rename docs/{ => MENU_CONTEXTUEL}/CONTEXT_MENU_SUMMARY.md (100%) rename docs/{ => MENU_CONTEXTUEL}/CONTEXT_MENU_VAULT_SERVICE_INTEGRATION.md (100%) rename CONTEXT_MENU_VERIFICATION.md => docs/MENU_CONTEXTUEL/CONTEXT_MENU_VERIFICATION.md (100%) rename DELETE_FEATURE_REVIEW.md => docs/MENU_CONTEXTUEL/DELETE_FEATURE_REVIEW.md (100%) rename PHASE3_COMPLETE.txt => docs/PERFORMENCE/phase3/PHASE3_COMPLETE.txt (100%) rename PHASE3_DEPLOYMENT_CHECKLIST.md => docs/PERFORMENCE/phase3/PHASE3_DEPLOYMENT_CHECKLIST.md (100%) rename PHASE4_DELIVERY_SUMMARY.md => docs/PERFORMENCE/phase4/PHASE4_DELIVERY_SUMMARY.md (100%) rename URL_DEEP_LINK_FIX.md => docs/URL_STATE/URL_DEEP_LINK_FIX.md (100%) rename URL_PRIORITY_FIX.md => docs/URL_STATE/URL_PRIORITY_FIX.md (100%) rename URL_STATE_INTEGRATION_SUMMARY.md => docs/URL_STATE/URL_STATE_INTEGRATION_SUMMARY.md (100%) rename URL_STATE_INTEGRATION_TEST.md => docs/URL_STATE/URL_STATE_INTEGRATION_TEST.md (100%) rename URL_STATE_SERVICE_DELIVERY.md => docs/URL_STATE/URL_STATE_SERVICE_DELIVERY.md (100%) rename vault/Allo-3/{test-new-file.md => tata.md} (100%) rename vault/Allo-3/test/{Nouvelle note 3.md => titi.md} (75%) rename vault/Allo-3/{Nouvelle note 1.md.bak => test/titi.md.bak} (52%) rename vault/Allo-3/{Nouvelle note 1.md => toto.md} (94%) diff --git a/URL_STATE_QUICK_START.md b/URL_STATE_QUICK_START.md deleted file mode 100644 index 0cc1977..0000000 --- a/URL_STATE_QUICK_START.md +++ /dev/null @@ -1,201 +0,0 @@ -# UrlStateService - Quick Start Guide - -## 🚀 DĂ©marrage en 5 minutes - -### Étape 1: Lancer le backend (Terminal 1) -```bash -cd c:\dev\git\web\ObsiViewer -node server/index.mjs -``` - -**Attendu**: Logs du serveur, port 4000 actif -``` -[Config] { MEILI_HOST: 'http://127.0.0.1:7700', ... } -[Server] Listening on port 4000 -``` - -### Étape 2: Lancer le frontend (Terminal 2) -```bash -cd c:\dev\git\web\ObsiViewer -npm run dev -``` - -**Attendu**: Angular dev server, port 3000 actif -``` -✔ Compiled successfully. -✔ Application bundle generation complete. -➜ Local: http://localhost:3000/ -``` - -### Étape 3: Ouvrir le navigateur (Terminal 3) -```bash -# Ouvrir une URL avec paramĂštres -http://localhost:3000/?note=Allo-3/test-new-file.md -``` - -**Attendu**: Note s'ouvre directement - ---- - -## 📋 Tests Rapides (5 minutes) - -### Test 1: Deep-link note -``` -URL: http://localhost:3000/?note=Allo-3/test-new-file.md -RĂ©sultat: Note ouverte ✅ -``` - -### Test 2: Filtre dossier -``` -URL: http://localhost:3000/?folder=Allo-3 -RĂ©sultat: Liste filtrĂ©e par dossier ✅ -``` - -### Test 3: Filtre tag -``` -URL: http://localhost:3000/?tag=home -RĂ©sultat: Vue recherche, filtre par tag ✅ -``` - -### Test 4: Recherche -``` -URL: http://localhost:3000/?search=test -RĂ©sultat: Barre de recherche remplie ✅ -``` - -### Test 5: Interaction → URL -``` -Étapes: -1. Cliquer un dossier dans la sidebar -2. Observer l'URL -RĂ©sultat: URL change vers ?folder=... ✅ -``` - ---- - -## 🎯 URLs PrĂȘtes Ă  Tester - -Copier/coller dans le navigateur: - -``` -# Ouvrir une note -http://localhost:3000/?note=Allo-3/test-new-file.md - -# Filtrer par dossier -http://localhost:3000/?folder=Allo-3 - -# Filtrer par tag -http://localhost:3000/?tag=home - -# Rechercher -http://localhost:3000/?search=home - -# Combinaison: dossier + note -http://localhost:3000/?folder=Allo-3¬e=Allo-3/test-new-file.md - -# Combinaison: tag + recherche -http://localhost:3000/?tag=home&search=test -``` - ---- - -## ⚠ Troubleshooting - -### ProblĂšme: Écran bleu/vide -**Solution**: -1. VĂ©rifier que le backend tourne: `curl http://localhost:4000/api/vault/metadata` -2. Hard reload: `Ctrl+F5` -3. VĂ©rifier la console: `F12 → Console` - -### ProblĂšme: URL ne change pas aprĂšs interaction -**Solution**: -1. VĂ©rifier que vous ĂȘtes en mode Nimbus (bouton "✹ Nimbus" en haut) -2. VĂ©rifier que le backend rĂ©pond -3. VĂ©rifier la console pour les erreurs - -### ProblĂšme: Note ne s'ouvre pas -**Solution**: -1. VĂ©rifier que le chemin de la note existe -2. VĂ©rifier la casse (sensible Ă  la casse) -3. VĂ©rifier que le dossier "Allo-3" existe - -### ProblĂšme: Erreur dans la console -**Solution**: -1. Copier l'erreur complĂšte -2. VĂ©rifier les logs du serveur -3. Consulter `URL_STATE_INTEGRATION_TEST.md` pour les cas connus - ---- - -## 📊 VĂ©rification Rapide - -### Backend OK? -```bash -curl http://localhost:4000/api/vault/metadata -``` -**Attendu**: JSON avec liste des notes - -### Frontend OK? -```bash -curl http://localhost:3000 -``` -**Attendu**: HTML de l'application - -### Proxy OK? -```bash -# Dans le navigateur, ouvrir: -http://localhost:3000/?folder=Allo-3 -# VĂ©rifier que la liste se filtre -``` - ---- - -## 🎓 Concepts ClĂ©s - -### URL Parameters -- `?note=...` → Ouvre une note -- `?folder=...` → Filtre par dossier -- `?tag=...` → Filtre par tag -- `?quick=...` → Filtre par quick link -- `?search=...` → Applique la recherche - -### PrioritĂ© -- Si `note` est prĂ©sent → ouvre la note -- Sinon si `tag` est prĂ©sent → filtre par tag -- Sinon si `folder` est prĂ©sent → filtre par dossier -- Sinon si `quick` est prĂ©sent → filtre par quick link -- Sinon → affiche toutes les notes - -### Synchronisation -- URL change → AppComponent reçoit l'update → UI se met Ă  jour -- Utilisateur clique → AppComponent appelle urlState → URL change - ---- - -## 📝 Prochaines Étapes - -1. **Tester les 5 tests rapides** (5 min) -2. **ExĂ©cuter le guide complet** (`URL_STATE_INTEGRATION_TEST.md`) (30 min) -3. **Documenter les rĂ©sultats** -4. **Corriger les bugs Ă©ventuels** -5. **DĂ©ployer en production** - ---- - -## 📞 Aide Rapide - -| Question | RĂ©ponse | -|----------|---------| -| OĂč est le guide de test? | `URL_STATE_INTEGRATION_TEST.md` | -| OĂč est la documentation complĂšte? | `URL_STATE_INTEGRATION_SUMMARY.md` | -| Comment lancer le backend? | `node server/index.mjs` | -| Comment lancer le frontend? | `npm run dev` | -| Quel port pour le backend? | 4000 | -| Quel port pour le frontend? | 3000 | -| Mode Nimbus activĂ©? | Bouton "✹ Nimbus" en haut Ă  droite | -| Erreur NG0201? | VĂ©rifier que UrlStateService est injectĂ© | -| URL ne change pas? | VĂ©rifier que vous ĂȘtes en mode Nimbus | - ---- - -**Bon test! 🚀** diff --git a/docs/ARCHITECTURE/HUB_WORKSPACE_ARCHITECTURE.md b/docs/ARCHITECTURE/HUB_WORKSPACE_ARCHITECTURE.md new file mode 100644 index 0000000..ab6da1d --- /dev/null +++ b/docs/ARCHITECTURE/HUB_WORKSPACE_ARCHITECTURE.md @@ -0,0 +1,441 @@ +# 📘 Document d’architecture — ObsiViewer **Hub + Pods** (Workspaces provisionnĂ©s) + +> Version: 1.0 — DerniĂšre mise Ă  jour: aujourd’hui +> PortĂ©e: architecture complĂšte, authentification, gestion utilisateurs/roles, workspaces, dĂ©ploiement des pods, panneau d’administration, stockage des prĂ©fĂ©rences, intĂ©gration avec l’image Docker **`obsiviewer-angular:latest`** (ton image actuelle). + +--- + +## 1) Objectifs & principes + +### 🎯 Objectifs + +* Unifier l’accĂšs Ă  **plusieurs voutes** (workspaces) via un **Hub** central. +* CrĂ©er/arrĂȘter/supprimer des **workspaces via l’UI** (sans manipuler docker-compose Ă  la main). +* Appliquer **RBAC** (viewer/editor/admin/owner) **par workspace**. +* Isoler chaque workspace dans son **pod Docker** dĂ©diĂ©. +* Conserver la **source de vĂ©ritĂ©** dans les fichiers Markdown/YAML + un **config partagĂ©** dans la voute. +* Stocker les **prĂ©fĂ©rences privĂ©es** (par utilisateur *et* par workspace) en DB locale du pod. + +### đŸš« Non-objectifs + +* Pas de Kubernetes requis (Docker Engine + Traefik suffisent). +* Pas de synchro “live” entre diffĂ©rentes machines distantes (mais compatible Git/CI/CD). + +### 🧠 Vocabulaire + +* **Hub**: service central (UI + API) qui gĂšre identitĂ©, membres, invitations, et **provisionne des Pods** via Docker Engine. +* **Pod**: conteneur **ObsiViewer** (ton image `obsiviewer-angular:latest`) dĂ©diĂ© Ă  **1 workspace**. +* **Workspace**: une voute (rĂ©pertoire) + ses assets + sa DB locale de prĂ©fĂ©rences. +* **IdP/SSO**: Authelia/Authentik/Keycloak derriĂšre reverse proxy. + +--- + +## 2) Vue d’ensemble + +### Composants + +* **Reverse Proxy** (Traefik/Caddy/Nginx) : + + * Routes: `hub.local` (Hub), `obsiviewer-*.local` (Pods) + * TLS + Ă©ventuellement SSO devant le Hub +* **Hub** (service Node/TS + SQLite) : + + * UI d’admin (Workspaces, Membres, Invitations) + * **Docker orchestrator** (accĂšs `/var/run/docker.sock`) + * Authority **JWT (RS256)** → Ă©met tokens courts signĂ©s + * DB: `users`, `workspaces`, `memberships`, `invitations` +* **Pods** (image `obsiviewer-angular:latest`) : + + * **1 pod = 1 workspace** (montage: `vault`, `assets`, `db`) + * Middleware **JWT** (valide signature Hub + `workspace_id`) + * API existantes + `/api/config` (partagĂ©), `/api/prefs` (privĂ©) + * DB locale SQLite `/app/db/app.sqlite` (prefs + historique) +* **MeiliSearch** : index par workspace (`obsiviewer__notes`) + +### Flux « Ouvrir un workspace » + +1. User → Hub UI → liste mes workspaces +2. Click **Open** → Hub signe **JWT** (claims: `sub`, `workspace_id`, `role`, `exp`) +3. Redirection vers `https://obsiviewer-.local/?token=...` +4. Pod valide JWT (clĂ© publique Hub), vĂ©rifie `workspace_id` +5. L’app charge config, prefs, index → RBAC appliquĂ© + +--- + +## 3) SĂ©curitĂ© & Authentification + +### SSO recommandĂ© + +* SSO (Authelia/Authentik) **devant le Hub** via reverse proxy. +* Le proxy injecte `X-User-Id`, `X-User-Name`, `X-User-Email` **ou** forwarde un JWT. +* Le Hub **crĂ©e** le user s’il n’existe pas (id stable = `sub`). + +### Émission du JWT Hub → Pods + +* **Algorithme**: RS256 (clĂ© privĂ©e cĂŽtĂ© Hub, clĂ© publique montĂ©e dans chaque Pod) +* **TTL**: 5–15 minutes +* **Claims** (ex.) : + +```json +{ + "sub": "user-uuid-or-oidc-sub", + "workspace_id": "it", + "role": "editor", + "name": "Bruno Charest", + "email": "bruno@
", + "iss": "obsiviewer-hub", + "iat": 1730, + "exp": 1730 +} +``` + +### VĂ©rification cĂŽtĂ© Pod + +* Middleware lit `Authorization: Bearer` **ou** `?token=` +* `jwt.verify(token, HUB_PUBLIC_KEY, { algorithms: ['RS256'], issuer: 'obsiviewer-hub' })` +* VĂ©rifie `payload.workspace_id === process.env.WORKSPACE_ID` + +### RBAC (par route) + +* `viewer` : lecture seule (notes, assets, graph) +* `editor` : CRUD sur fichiers/dossiers/notes +* `admin` : + `PUT /api/config` (config partagĂ©e du workspace) +* `owner` (option) : tout + gestion des membres & suppression du workspace (gĂ©rĂ© plutĂŽt au Hub) + +--- + +## 4) ModĂšle de donnĂ©es + +### Hub (SQLite ou Postgres) + +```sql +CREATE TABLE users( + id TEXT PRIMARY KEY, -- sub issu du SSO + display_name TEXT, + email TEXT UNIQUE +); + +CREATE TABLE workspaces( + id TEXT PRIMARY KEY, -- slug: main, it, recettes... + name TEXT, + url TEXT, -- ex: https://obsiviewer-it.local + status TEXT DEFAULT 'active' -- active/disabled +); + +CREATE TABLE memberships( + user_id TEXT, + workspace_id TEXT, + role TEXT CHECK(role IN ('owner','admin','editor','viewer')), + PRIMARY KEY(user_id, workspace_id) +); + +CREATE TABLE invitations( + id TEXT PRIMARY KEY, -- uuid + workspace_id TEXT, + email TEXT, + role TEXT CHECK(role IN ('admin','editor','viewer')), + token TEXT UNIQUE, -- jeton d’invitation + expires_at TEXT, + accepted_by_user_id TEXT NULL +); +``` + +### Pod (SQLite locale `/app/db/app.sqlite`) + +```sql +CREATE TABLE IF NOT EXISTS user_prefs( + user_id TEXT, + key TEXT, -- 'ui','editor','graph','notesList', etc. + json_value TEXT, + PRIMARY KEY(user_id, key) +); + +CREATE TABLE IF NOT EXISTS search_history( + user_id TEXT, + ts TEXT, + context TEXT, -- 'notes','graph',... + query TEXT +); + +CREATE TABLE IF NOT EXISTS audit( + id TEXT PRIMARY KEY, ts TEXT, user_id TEXT, action TEXT, details TEXT +); +``` + +--- + +## 5) Configuration & prĂ©fĂ©rences + +### PartagĂ© (par workspace, **dans la voute**) + +Fichier `vault/.obsiviewer/config.json` (Ă©criture **atomique** + `.bak` + ETag) + +```json +{ + "version": 1, + "ui": { "defaultTheme": "blue-light", "uiMode": "Nimbus" }, + "notesList": { "density": "comfortable", "sort": "updatedAt:desc", "view": "cards" }, + "graph": { "physics": { "repulsion": 2500, "linkDistance": 60 } }, + "folders": { "exclusions": [".obsidian", "attachments"], "colors": { "Projects": "#9b87f5" } }, + "search": { "defaults": { "caseSensitive": false, "useRegex": true } }, + "quickLinks": [{ "label": "Drafts", "query": "status:draft" }] +} +``` + +### PrivĂ© (par user *et* workspace, **en DB du pod**) + +* `user_prefs`: UI prĂ©fĂ©rĂ©e, zoom/centre du graphe, onglet par dĂ©faut, options Ă©diteur, etc. +* `search_history`: 10 derniĂšres requĂȘtes par contexte + +### Ordre de prĂ©cĂ©dence + +``` +Defaults app + < config.json (partagĂ© per-workspace) + < user_prefs (privĂ© per-user/workspace) + < device-local volatil (scroll, pliage, etc.) en localStorage +``` + +--- + +## 6) APIs + +### Hub + +* `GET /api/my/workspaces` → workspaces accessibles (id, name, url, role) +* `GET /api/token?workspaceId=` → `{ token, url }` (JWT court) +* `POST /api/workspaces { name, slug }` → **provisionne** (crĂ©e dossiers + pod) +* `DELETE /api/workspaces/:id` → stop + remove container + purge (option) +* `POST /api/invitations` (owner/admin) → crĂ©er invitation +* `POST /api/invitations/accept` → rattache user au workspace + +### Pod + +* `GET /api/config` → lit `config.json` (+ `ETag`) +* `PUT /api/config` (admin) → `If-Match: ` → write atomique + `.bak` +* `GET /api/prefs` → agrĂ©gat des prefs privĂ©es (clé→JSON) +* `PUT /api/prefs` → upsert clĂ©s/prefs +* **Notes CRUD** (existant) → protĂ©gĂ© par `role >= editor` + +--- + +## 7) Provisioning des Pods (Hub → Docker) + +### Montage standard + +``` +/data/workspaces//vault:/app/vault +/data/workspaces//assets:/app/assets +/data/workspaces//db:/app/db +/srv/hub/keys:/keys:ro # hub_rsa.pub +``` + +### Variables d’environnement Pod + +``` +PORT=4000 +NODE_ENV=production +TZ=America/Montreal +WORKSPACE_ID= +REQUIRE_JWT=true +HUB_JWT_PUBLIC_KEY_FILE=/keys/hub_rsa.pub +``` + +### Labels Traefik (ex.) + +``` +"traefik.enable": "true" +"traefik.http.routers.obsiviewer-.rule": "Host(`obsiviewer-.local`)" +"traefik.http.services.obsiviewer-.loadbalancer.server.port": "4000" +``` + +### Exemple (Node/TS, `dockerode`) + +```ts +const container = await docker.createContainer({ + Image: "docker-registry.dev.home:5000/obsiviewer-angular:latest", + name: `obsiviewer-${slug}`, + Env: [ + "PORT=4000", + "NODE_ENV=production", + "TZ=America/Montreal", + `WORKSPACE_ID=${slug}`, + "REQUIRE_JWT=true", + "HUB_JWT_PUBLIC_KEY_FILE=/keys/hub_rsa.pub" + ], + HostConfig: { + Binds: [ + `/data/workspaces/${slug}/vault:/app/vault`, + `/data/workspaces/${slug}/assets:/app/assets`, + `/data/workspaces/${slug}/db:/app/db`, + `/srv/hub/keys:/keys:ro` + ], + RestartPolicy: { Name: "unless-stopped" } + }, + ExposedPorts: { "4000/tcp": {} }, + Labels: { + "traefik.enable": "true", + [`traefik.http.routers.obsiviewer-${slug}.rule`]: `Host(\`obsiviewer-${slug}.local\`)`, + [`traefik.http.services.obsiviewer-${slug}.loadbalancer.server.port`]: "4000" + } +}); +await container.start(); +``` + +--- + +## 8) Reverse proxy + +### Traefik — idĂ©e de base + +* `hub.local` → service **hub:4100** (protĂ©gĂ© SSO) +* `obsiviewer-*.local` → services Pods (labels dynamiques) + +*(Tu peux aussi faire du DNS local type `*.local` pointant vers le reverse proxy.)* + +--- + +## 9) IntĂ©gration **MeiliSearch** + +* **1 index par workspace**: `obsiviewer__notes` +* Le Pod connaĂźt `WORKSPACE_ID`, donc paramĂštre l’index au dĂ©marrage. +* PrĂ©fĂ©rences de recherche (regex/case
) = cĂŽtĂ© client (user_prefs) + params de requĂȘte. + +--- + +## 10) Panneau d’administration (Hub UI) + +### Sections + +* **Workspaces** + + * Liste (Nom, Id, URL, Status, Membres, Dernier index) + * Actions: **Create**, **Open**, **Stop/Start**, **Delete**, **Reindex** +* **Members** + + * Par workspace: lister membres (role) + **Invite** (email + rĂŽle) + * Changer rĂŽle / retirer membre +* **Invitations** + + * GĂ©nĂ©rer lien, expiration, statut +* **Settings** + + * ClĂ© publique/privĂ©e JWT (rotation) + * ParamĂštres Meili, SMTP (si envoi d’email), base path +* **Diagnostics** + + * Logs rĂ©cents (Hub), santĂ© des Pods, usage disque `/data/workspaces` + +--- + +## 11) OpĂ©rations, sauvegardes & reprise + +### Backups + +* **Vaults**: sauvegarde fichier rĂ©guliĂšre (Git + snapshots ZFS/btrfs si possible) +* **Pod DB**: sauvegarde `/data/workspaces/*/db` +* **Hub DB**: sauvegarde `/srv/hub/db` +* **ClĂ©s JWT**: `/srv/hub/keys` (privĂ©e/publique) → **sĂ©curiser & sauvegarder** + +### Reprise aprĂšs incident + +* Le Hub rĂ©concilie la liste des containers via Docker API, actualise l’état en DB si besoin. +* Une suppression d’un Pod ne supprime pas automatiquement `vault/assets/db` (garde un drapeau “dangling” et propose “Purge files”). + +--- + +## 12) SĂ©curitĂ© & bonnes pratiques + +* Tokens courts (10 min) + rotation de clĂ© possible (multi-keys JWKS si besoin). +* **WRITE** de `config.json` **uniquement** via l’API du Pod (pas d’écriture par le client). +* Écriture atomique: `config.tmp` + `fs.rename` + `.bak` datĂ©s. +* Ajv + **schema versionnĂ©** pour `config.json`. +* RBAC strict; endpoints sensibles (`PUT /api/config`, suppression fichier) → `role >= admin`. + +--- + +## 13) Migration & compatibilitĂ© + +* Tes Pods actuels peuvent progressivement activer `REQUIRE_JWT=false` → tests → `true`. +* Écran **“Migration des prĂ©fĂ©rences locales”** (une fois) : + + * Importer `ui-mode`, `graph-settings`, `folderFilterConfig`, `folderColors`, `search prefs` + * Choisir **Mes prĂ©fĂ©rences** (user_prefs) vs **DĂ©fauts du workspace** (config.json, admin only) +* Aucun changement sur le modĂšle “1 pod = 1 voute”; tu ajoutes juste le **Hub** par-dessus. + +--- + +## 14) Exemples de `docker-compose` (extraits) + +### Hub + +```yaml +services: + hub: + image: ghcr.io/tonorg/obsiviewer-hub:latest + container_name: obsiviewer-hub + environment: + - NODE_ENV=production + - PORT=4100 + - TZ=America/Montreal + - HUB_JWT_PRIVATE_KEY_FILE=/keys/hub_rsa + - HUB_JWT_PUBLIC_KEY_FILE=/keys/hub_rsa.pub + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ${DIR_HUB}/db:/app/db + - ${DIR_HUB}/keys:/keys:ro + ports: ["4100:4100"] + restart: unless-stopped +``` + +### Pod (gĂ©nĂ©rĂ© par le Hub) + +```yaml +services: + obsiviewer-it: + image: docker-registry.dev.home:5000/obsiviewer-angular:latest + environment: + - PORT=4000 + - NODE_ENV=production + - TZ=America/Montreal + - WORKSPACE_ID=it + - REQUIRE_JWT=true + - HUB_JWT_PUBLIC_KEY_FILE=/keys/hub_rsa.pub + volumes: + - /data/workspaces/it/vault:/app/vault + - /data/workspaces/it/assets:/app/assets + - /data/workspaces/it/db:/app/db + - ${DIR_HUB}/keys:/keys:ro + labels: + - "traefik.enable=true" + - "traefik.http.routers.obsiviewer-it.rule=Host(`obsiviewer-it.local`)" + - "traefik.http.services.obsiviewer-it.loadbalancer.server.port=4000" + restart: unless-stopped +``` + +--- + +## 15) Roadmap (Ă©volution) + +* 🔒 **Liens publics** en read-only (URLs signĂ©es, expirables) par workspace +* 🔁 **Refresh token** Hub (silent refresh cĂŽtĂ© Pod, optionnel) +* 📈 **ObservabilitĂ©** (Prometheus/Grafana) pour Hub + Pods +* đŸ§© **Plugins** par workspace (charges dynamiques) +* đŸ’Œ **Org/Teams** au niveau Hub (groupes → rĂŽles par dĂ©faut) + +--- + +## 16) RĂ©sumĂ© exĂ©cutif + +* **Concept “Workspace = Voute”** conservĂ© → isolation & simplicitĂ©. +* **Hub** ajoute: identitĂ©, membres, invitations, **provisioning automatique des Pods**, tokens JWT, panel d’administration. +* **Pods** restent proches de ton image actuelle, avec un simple **middleware JWT** + APIs `config/prefs` + RBAC. +* **CrĂ©ation de workspace via UI** = *instantanĂ©* (le Hub crĂ©e le dossier + lance le conteneur + publie via Traefik). + +--- + +Si tu veux, je peux te livrer dans un message suivant : + +* un **squelette Hub** (Express + dockerode + SQLite + Ajv) prĂȘt Ă  builder, +* le **middleware JWT+RBAC** TypeScript cĂŽtĂ© Pod, +* et un **mini guide** d’installation Traefik (labels, wildcard DNS). diff --git a/docs/CONTEXT_MENU_IMPLEMENTATION.md b/docs/MENU_CONTEXTUEL/CONTEXT_MENU_IMPLEMENTATION.md similarity index 100% rename from docs/CONTEXT_MENU_IMPLEMENTATION.md rename to docs/MENU_CONTEXTUEL/CONTEXT_MENU_IMPLEMENTATION.md diff --git a/CONTEXT_MENU_INDEX.md b/docs/MENU_CONTEXTUEL/CONTEXT_MENU_INDEX.md similarity index 100% rename from CONTEXT_MENU_INDEX.md rename to docs/MENU_CONTEXTUEL/CONTEXT_MENU_INDEX.md diff --git a/docs/CONTEXT_MENU_QUICK_START.md b/docs/MENU_CONTEXTUEL/CONTEXT_MENU_QUICK_START.md similarity index 100% rename from docs/CONTEXT_MENU_QUICK_START.md rename to docs/MENU_CONTEXTUEL/CONTEXT_MENU_QUICK_START.md diff --git a/docs/CONTEXT_MENU_README.md b/docs/MENU_CONTEXTUEL/CONTEXT_MENU_README.md similarity index 100% rename from docs/CONTEXT_MENU_README.md rename to docs/MENU_CONTEXTUEL/CONTEXT_MENU_README.md diff --git a/docs/CONTEXT_MENU_SUMMARY.md b/docs/MENU_CONTEXTUEL/CONTEXT_MENU_SUMMARY.md similarity index 100% rename from docs/CONTEXT_MENU_SUMMARY.md rename to docs/MENU_CONTEXTUEL/CONTEXT_MENU_SUMMARY.md diff --git a/docs/CONTEXT_MENU_VAULT_SERVICE_INTEGRATION.md b/docs/MENU_CONTEXTUEL/CONTEXT_MENU_VAULT_SERVICE_INTEGRATION.md similarity index 100% rename from docs/CONTEXT_MENU_VAULT_SERVICE_INTEGRATION.md rename to docs/MENU_CONTEXTUEL/CONTEXT_MENU_VAULT_SERVICE_INTEGRATION.md diff --git a/CONTEXT_MENU_VERIFICATION.md b/docs/MENU_CONTEXTUEL/CONTEXT_MENU_VERIFICATION.md similarity index 100% rename from CONTEXT_MENU_VERIFICATION.md rename to docs/MENU_CONTEXTUEL/CONTEXT_MENU_VERIFICATION.md diff --git a/DELETE_FEATURE_REVIEW.md b/docs/MENU_CONTEXTUEL/DELETE_FEATURE_REVIEW.md similarity index 100% rename from DELETE_FEATURE_REVIEW.md rename to docs/MENU_CONTEXTUEL/DELETE_FEATURE_REVIEW.md diff --git a/PHASE3_COMPLETE.txt b/docs/PERFORMENCE/phase3/PHASE3_COMPLETE.txt similarity index 100% rename from PHASE3_COMPLETE.txt rename to docs/PERFORMENCE/phase3/PHASE3_COMPLETE.txt diff --git a/PHASE3_DEPLOYMENT_CHECKLIST.md b/docs/PERFORMENCE/phase3/PHASE3_DEPLOYMENT_CHECKLIST.md similarity index 100% rename from PHASE3_DEPLOYMENT_CHECKLIST.md rename to docs/PERFORMENCE/phase3/PHASE3_DEPLOYMENT_CHECKLIST.md diff --git a/PHASE4_DELIVERY_SUMMARY.md b/docs/PERFORMENCE/phase4/PHASE4_DELIVERY_SUMMARY.md similarity index 100% rename from PHASE4_DELIVERY_SUMMARY.md rename to docs/PERFORMENCE/phase4/PHASE4_DELIVERY_SUMMARY.md diff --git a/URL_DEEP_LINK_FIX.md b/docs/URL_STATE/URL_DEEP_LINK_FIX.md similarity index 100% rename from URL_DEEP_LINK_FIX.md rename to docs/URL_STATE/URL_DEEP_LINK_FIX.md diff --git a/URL_PRIORITY_FIX.md b/docs/URL_STATE/URL_PRIORITY_FIX.md similarity index 100% rename from URL_PRIORITY_FIX.md rename to docs/URL_STATE/URL_PRIORITY_FIX.md diff --git a/URL_STATE_INTEGRATION_SUMMARY.md b/docs/URL_STATE/URL_STATE_INTEGRATION_SUMMARY.md similarity index 100% rename from URL_STATE_INTEGRATION_SUMMARY.md rename to docs/URL_STATE/URL_STATE_INTEGRATION_SUMMARY.md diff --git a/URL_STATE_INTEGRATION_TEST.md b/docs/URL_STATE/URL_STATE_INTEGRATION_TEST.md similarity index 100% rename from URL_STATE_INTEGRATION_TEST.md rename to docs/URL_STATE/URL_STATE_INTEGRATION_TEST.md diff --git a/docs/URL_STATE/URL_STATE_QUICK_START.md b/docs/URL_STATE/URL_STATE_QUICK_START.md index 7a0b08d..0cc1977 100644 --- a/docs/URL_STATE/URL_STATE_QUICK_START.md +++ b/docs/URL_STATE/URL_STATE_QUICK_START.md @@ -1,180 +1,201 @@ -# UrlStateService - DĂ©marrage Rapide (5 minutes) +# UrlStateService - Quick Start Guide -## 🚀 En 5 minutes +## 🚀 DĂ©marrage en 5 minutes -### Étape 1: Injecter le service dans AppComponent (1 min) - -```typescript -// src/app/app.component.ts -import { Component, inject } from '@angular/core'; -import { UrlStateService } from './services/url-state.service'; - -@Component({ - selector: 'app-root', - standalone: true, - template: `...` -}) -export class AppComponent { - private urlStateService = inject(UrlStateService); - // C'est tout! Le service s'initialise automatiquement -} +### Étape 1: Lancer le backend (Terminal 1) +```bash +cd c:\dev\git\web\ObsiViewer +node server/index.mjs ``` -### Étape 2: Utiliser dans NotesListComponent (2 min) - -```typescript -// src/app/features/list/notes-list.component.ts -import { Component, inject } from '@angular/core'; -import { UrlStateService } from '../../services/url-state.service'; - -@Component({ - selector: 'app-notes-list', - standalone: true, - template: ` - -
- Filtre: #{{ tag }} -
- - - - ` -}) -export class NotesListComponent { - urlState = inject(UrlStateService); - - selectNote(note: Note): void { - this.urlState.openNote(note.filePath); - } -} +**Attendu**: Logs du serveur, port 4000 actif +``` +[Config] { MEILI_HOST: 'http://127.0.0.1:7700', ... } +[Server] Listening on port 4000 ``` -### Étape 3: Tester les URLs (2 min) +### Étape 2: Lancer le frontend (Terminal 2) +```bash +cd c:\dev\git\web\ObsiViewer +npm run dev +``` -Ouvrez votre navigateur et testez: +**Attendu**: Angular dev server, port 3000 actif +``` +✔ Compiled successfully. +✔ Application bundle generation complete. +➜ Local: http://localhost:3000/ +``` + +### Étape 3: Ouvrir le navigateur (Terminal 3) +```bash +# Ouvrir une URL avec paramĂštres +http://localhost:3000/?note=Allo-3/test-new-file.md +``` + +**Attendu**: Note s'ouvre directement + +--- + +## 📋 Tests Rapides (5 minutes) + +### Test 1: Deep-link note +``` +URL: http://localhost:3000/?note=Allo-3/test-new-file.md +RĂ©sultat: Note ouverte ✅ +``` + +### Test 2: Filtre dossier +``` +URL: http://localhost:3000/?folder=Allo-3 +RĂ©sultat: Liste filtrĂ©e par dossier ✅ +``` + +### Test 3: Filtre tag +``` +URL: http://localhost:3000/?tag=home +RĂ©sultat: Vue recherche, filtre par tag ✅ +``` + +### Test 4: Recherche +``` +URL: http://localhost:3000/?search=test +RĂ©sultat: Barre de recherche remplie ✅ +``` + +### Test 5: Interaction → URL +``` +Étapes: +1. Cliquer un dossier dans la sidebar +2. Observer l'URL +RĂ©sultat: URL change vers ?folder=... ✅ +``` + +--- + +## 🎯 URLs PrĂȘtes Ă  Tester + +Copier/coller dans le navigateur: ``` # Ouvrir une note -http://localhost:4200/viewer?note=Docs/Architecture.md - -# Filtrer par tag -http://localhost:4200/viewer?tag=Ideas +http://localhost:3000/?note=Allo-3/test-new-file.md # Filtrer par dossier -http://localhost:4200/viewer?folder=Notes/Meetings +http://localhost:3000/?folder=Allo-3 + +# Filtrer par tag +http://localhost:3000/?tag=home # Rechercher -http://localhost:4200/viewer?search=performance +http://localhost:3000/?search=home + +# Combinaison: dossier + note +http://localhost:3000/?folder=Allo-3¬e=Allo-3/test-new-file.md + +# Combinaison: tag + recherche +http://localhost:3000/?tag=home&search=test ``` -## ✅ VĂ©rification +--- -- [ ] Le service est injectĂ© dans AppComponent -- [ ] NotesListComponent utilise le service -- [ ] Les URLs fonctionnent -- [ ] L'Ă©tat est restaurĂ© aprĂšs rechargement +## ⚠ Troubleshooting -## 📚 Prochaines Ă©tapes +### ProblĂšme: Écran bleu/vide +**Solution**: +1. VĂ©rifier que le backend tourne: `curl http://localhost:4000/api/vault/metadata` +2. Hard reload: `Ctrl+F5` +3. VĂ©rifier la console: `F12 → Console` -1. **Lire la documentation complĂšte** - - `docs/URL_STATE_SERVICE_INTEGRATION.md` +### ProblĂšme: URL ne change pas aprĂšs interaction +**Solution**: +1. VĂ©rifier que vous ĂȘtes en mode Nimbus (bouton "✹ Nimbus" en haut) +2. VĂ©rifier que le backend rĂ©pond +3. VĂ©rifier la console pour les erreurs -2. **Voir les exemples** - - `src/app/components/url-state-integration-examples.ts` +### ProblĂšme: Note ne s'ouvre pas +**Solution**: +1. VĂ©rifier que le chemin de la note existe +2. VĂ©rifier la casse (sensible Ă  la casse) +3. VĂ©rifier que le dossier "Allo-3" existe -3. **IntĂ©grer dans d'autres composants** - - NoteViewComponent - - FoldersComponent - - TagsComponent - - SearchComponent +### ProblĂšme: Erreur dans la console +**Solution**: +1. Copier l'erreur complĂšte +2. VĂ©rifier les logs du serveur +3. Consulter `URL_STATE_INTEGRATION_TEST.md` pour les cas connus -4. **Ajouter le partage de lien** - ```typescript - async shareCurrentState(): Promise { - await this.urlState.copyCurrentUrlToClipboard(); - this.toast.success('Lien copiĂ©!'); - } - ``` +--- -## 🎯 Cas d'usage courants +## 📊 VĂ©rification Rapide -### Ouvrir une note -```typescript -await this.urlState.openNote('Docs/Architecture.md'); +### Backend OK? +```bash +curl http://localhost:4000/api/vault/metadata +``` +**Attendu**: JSON avec liste des notes + +### Frontend OK? +```bash +curl http://localhost:3000 +``` +**Attendu**: HTML de l'application + +### Proxy OK? +```bash +# Dans le navigateur, ouvrir: +http://localhost:3000/?folder=Allo-3 +# VĂ©rifier que la liste se filtre ``` -### Filtrer par tag -```typescript -await this.urlState.filterByTag('Ideas'); -``` +--- -### Filtrer par dossier -```typescript -await this.urlState.filterByFolder('Notes/Meetings'); -``` +## 🎓 Concepts ClĂ©s -### Rechercher -```typescript -await this.urlState.updateSearch('performance'); -``` +### URL Parameters +- `?note=...` → Ouvre une note +- `?folder=...` → Filtre par dossier +- `?tag=...` → Filtre par tag +- `?quick=...` → Filtre par quick link +- `?search=...` → Applique la recherche -### RĂ©initialiser -```typescript -await this.urlState.resetState(); -``` +### PrioritĂ© +- Si `note` est prĂ©sent → ouvre la note +- Sinon si `tag` est prĂ©sent → filtre par tag +- Sinon si `folder` est prĂ©sent → filtre par dossier +- Sinon si `quick` est prĂ©sent → filtre par quick link +- Sinon → affiche toutes les notes -## 🔍 Signaux disponibles +### Synchronisation +- URL change → AppComponent reçoit l'update → UI se met Ă  jour +- Utilisateur clique → AppComponent appelle urlState → URL change -```typescript -// État actuel -urlState.currentState() +--- -// Note ouverte -urlState.currentNote() +## 📝 Prochaines Étapes -// Tag actif -urlState.activeTag() +1. **Tester les 5 tests rapides** (5 min) +2. **ExĂ©cuter le guide complet** (`URL_STATE_INTEGRATION_TEST.md`) (30 min) +3. **Documenter les rĂ©sultats** +4. **Corriger les bugs Ă©ventuels** +5. **DĂ©ployer en production** -// Dossier actif -urlState.activeFolder() +--- -// Quick link actif -urlState.activeQuickLink() +## 📞 Aide Rapide -// Recherche active -urlState.activeSearch() -``` +| Question | RĂ©ponse | +|----------|---------| +| OĂč est le guide de test? | `URL_STATE_INTEGRATION_TEST.md` | +| OĂč est la documentation complĂšte? | `URL_STATE_INTEGRATION_SUMMARY.md` | +| Comment lancer le backend? | `node server/index.mjs` | +| Comment lancer le frontend? | `npm run dev` | +| Quel port pour le backend? | 4000 | +| Quel port pour le frontend? | 3000 | +| Mode Nimbus activĂ©? | Bouton "✹ Nimbus" en haut Ă  droite | +| Erreur NG0201? | VĂ©rifier que UrlStateService est injectĂ© | +| URL ne change pas? | VĂ©rifier que vous ĂȘtes en mode Nimbus | -## 🐛 Troubleshooting - -### L'URL ne change pas -```typescript -// ❌ Mauvais -this.currentTag = 'Ideas'; - -// ✅ Correct -await this.urlState.filterByTag('Ideas'); -``` - -### La note n'est pas trouvĂ©e -VĂ©rifiez le chemin exact: -```typescript -console.log(this.vault.allNotes().map(n => n.filePath)); -``` - -### L'Ă©tat n'est pas restaurĂ© -Assurez-vous que le service est injectĂ© dans AppComponent. - -## 📞 Besoin d'aide? - -- Consultez `docs/URL_STATE_SERVICE_INTEGRATION.md` -- VĂ©rifiez les exemples dans `src/app/components/url-state-integration-examples.ts` -- ExĂ©cutez les tests: `ng test --include='**/url-state.service.spec.ts'` - -## 🎉 Vous ĂȘtes prĂȘt! - -Le service est maintenant intĂ©grĂ© et fonctionnel. Continuez avec la documentation complĂšte pour dĂ©couvrir toutes les fonctionnalitĂ©s. +--- +**Bon test! 🚀** diff --git a/URL_STATE_SERVICE_DELIVERY.md b/docs/URL_STATE/URL_STATE_SERVICE_DELIVERY.md similarity index 100% rename from URL_STATE_SERVICE_DELIVERY.md rename to docs/URL_STATE/URL_STATE_SERVICE_DELIVERY.md diff --git a/server/index-phase3-patch.mjs b/server/index-phase3-patch.mjs index 29411e5..fec82da 100644 --- a/server/index-phase3-patch.mjs +++ b/server/index-phase3-patch.mjs @@ -15,6 +15,75 @@ import express from 'express'; import fs from 'fs'; import path from 'path'; +// ============================================================================ +// ENDPOINT X: /api/files/rename - Rename a markdown file within the same folder +// ============================================================================ +export function setupRenameFileEndpoint(app, vaultDir, broadcastVaultEvent, metadataCache) { + app.put('/api/files/rename', express.json(), (req, res) => { + try { + const { oldPath, newName } = req.body || {}; + + if (!oldPath || typeof oldPath !== 'string') { + return res.status(400).json({ error: 'Missing or invalid oldPath' }); + } + if (!newName || typeof newName !== 'string') { + return res.status(400).json({ error: 'Missing or invalid newName' }); + } + + const invalidChars = /[\/\\:*?"<>|]/; + if (invalidChars.test(newName)) { + return res.status(400).json({ error: 'Invalid characters in newName' }); + } + + const sanitizedOldRel = String(oldPath).replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''); + const oldAbs = path.join(vaultDir, sanitizedOldRel); + if (!fs.existsSync(oldAbs) || !fs.statSync(oldAbs).isFile()) { + return res.status(404).json({ error: 'Source file not found' }); + } + + // Enforce .md extension and strip any other extension first + const baseNoExt = newName.replace(/\.[^/.]+$/, ''); + const finalFileName = baseNoExt.endsWith('.md') ? baseNoExt : `${baseNoExt}.md`; + + const parentDir = path.dirname(oldAbs); + const newAbs = path.join(parentDir, finalFileName); + const newRel = path.relative(vaultDir, newAbs).replace(/\\/g, '/'); + + // Prevent noop + const oldFileName = path.basename(oldAbs); + if (oldFileName === finalFileName) { + return res.status(400).json({ error: 'New name is same as current name' }); + } + + // Conflict check + if (fs.existsSync(newAbs)) { + return res.status(409).json({ error: 'A file with this name already exists' }); + } + + // Perform atomic-ish move + try { + fs.renameSync(oldAbs, newAbs); + } catch (e) { + console.error('[PUT /api/files/rename] rename failed:', e); + return res.status(500).json({ error: 'Failed to rename file' }); + } + + // Invalidate metadata cache + try { metadataCache?.clear?.(); } catch {} + + // Broadcast SSE event + try { + broadcastVaultEvent?.({ event: 'file-rename', oldPath: sanitizedOldRel, newPath: newRel, timestamp: Date.now() }); + } catch {} + + return res.json({ success: true, oldPath: sanitizedOldRel, newPath: newRel, fileName: finalFileName }); + } catch (error) { + console.error('[PUT /api/files/rename] Unexpected error:', error); + return res.status(500).json({ error: 'Internal server error' }); + } + }); +} + // ============================================================================ // ENDPOINT 5: /api/folders/rename - Rename folder with validation // ============================================================================ diff --git a/server/index.mjs b/server/index.mjs index 87b4373..717e8ed 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -35,7 +35,8 @@ import { setupDeleteNoteEndpoint, setupRenameFolderEndpoint, setupDeleteFolderEndpoint, - setupCreateFolderEndpoint + setupCreateFolderEndpoint, + setupRenameFileEndpoint } from './index-phase3-patch.mjs'; const __filename = fileURLToPath(import.meta.url); @@ -1536,6 +1537,9 @@ app.get('/api/vault/events', (req, res) => { // Setup rename folder endpoint (must be before catch-all) setupRenameFolderEndpoint(app, vaultDir, broadcastVaultEvent, metadataCache); +// Setup rename file endpoint (must be before catch-all) +setupRenameFileEndpoint(app, vaultDir, broadcastVaultEvent, metadataCache); + // Setup delete folder endpoint (must be before catch-all) setupDeleteFolderEndpoint(app, vaultDir, broadcastVaultEvent, metadataCache); diff --git a/src/app/features/note/components/note-header/note-header.component.html b/src/app/features/note/components/note-header/note-header.component.html index 4f7c8a1..84b991a 100644 --- a/src/app/features/note/components/note-header/note-header.component.html +++ b/src/app/features/note/components/note-header/note-header.component.html @@ -30,14 +30,34 @@ -
- +
+ {{ pathParts.prefix }} / - - {{ pathParts.filename }} - + + + + {{ pathParts.filename }} + + + + + +
diff --git a/src/app/features/note/components/note-header/note-header.component.scss b/src/app/features/note/components/note-header/note-header.component.scss index 406b9e0..9d0f52f 100644 --- a/src/app/features/note/components/note-header/note-header.component.scss +++ b/src/app/features/note/components/note-header/note-header.component.scss @@ -19,6 +19,20 @@ font-size: 0.95rem; } +/* Hover pencil visibility */ +.path-filename.editable { + cursor: pointer; + position: relative; +} +.path-filename.editable .edit-icon { opacity: 0; } +.path-filename.editable:hover .edit-icon { opacity: 0.7; } + +/* Inline edit input look */ +.inline-edit { + padding: 0 2px; + transition: opacity 150ms ease; +} + .note-header__action { display: inline-flex; align-items: center; diff --git a/src/app/features/note/components/note-header/note-header.component.ts b/src/app/features/note/components/note-header/note-header.component.ts index 5d4ceed..c52c15a 100644 --- a/src/app/features/note/components/note-header/note-header.component.ts +++ b/src/app/features/note/components/note-header/note-header.component.ts @@ -1,4 +1,4 @@ -import { AfterViewInit, Component, ElementRef, Input, OnDestroy, Output, EventEmitter, ViewContainerRef, inject, OnChanges, SimpleChanges } from '@angular/core'; +import { AfterViewInit, Component, ElementRef, Input, OnDestroy, Output, EventEmitter, ViewContainerRef, inject, OnChanges, SimpleChanges, ViewChild } from '@angular/core'; import { CommonModule } from '@angular/common'; import { debounceTime, Subject } from 'rxjs'; import { splitPathKeepFilename } from '../../../../shared/utils/path'; @@ -8,6 +8,8 @@ import { ComponentPortal } from '@angular/cdk/portal'; import { PropertiesPopoverComponent } from '../properties-popover/properties-popover.component'; import { FrontmatterPropertiesService } from '../../shared/frontmatter-properties.service'; import { VaultService } from '../../../../../services/vault.service'; +import { ToastService } from '../../../../shared/toast/toast.service'; +import { UrlStateService } from '../../../../services/url-state.service'; @Component({ selector: 'app-note-header', @@ -28,6 +30,11 @@ export class NoteHeaderComponent implements AfterViewInit, OnDestroy, OnChanges pathParts: { prefix: string; filename: string } = { prefix: '', filename: '' }; + // Inline rename state + isRenaming = false; + originalFileName = ''; + @ViewChild('renameInput') renameInput?: ElementRef; + private ro?: ResizeObserver; private resize$ = new Subject(); @@ -39,6 +46,8 @@ export class NoteHeaderComponent implements AfterViewInit, OnDestroy, OnChanges private vcr = inject(ViewContainerRef); private frontmatterService = inject(FrontmatterPropertiesService); private vaultService = inject(VaultService); + private toast = inject(ToastService); + private urlState = inject(UrlStateService); constructor(private host: ElementRef) {} @@ -148,6 +157,87 @@ export class NoteHeaderComponent implements AfterViewInit, OnDestroy, OnChanges this.openDirectory.emit(); } + // ========================= + // Inline rename interactions + // ========================= + onFileNameClick(event: MouseEvent): void { + event.stopPropagation(); + this.originalFileName = this.pathParts.filename; + this.isRenaming = true; + // Focus and select after view updates + setTimeout(() => { + const el = this.renameInput?.nativeElement; + if (el) { + el.focus(); + el.select(); + // Resize to fit text + try { el.size = Math.max(1, this.originalFileName.length); } catch {} + } + }, 0); + } + + onRenameKeydown(event: KeyboardEvent, value: string): void { + if (event.key === 'Enter') { + event.preventDefault(); + this.onRenameSubmit(value); + } else if (event.key === 'Escape') { + event.preventDefault(); + this.isRenaming = false; + } + } + + async onRenameBlur(value: string): Promise { + await this.onRenameSubmit(value); + } + + private normalizeFileName(name: string): string { + let trimmed = (name || '').trim(); + // Strip any extension and enforce .md + trimmed = trimmed.replace(/\.[^/.]+$/, ''); + if (!trimmed) return ''; + return trimmed.endsWith('.md') ? trimmed : `${trimmed}.md`; + } + + async onRenameSubmit(raw: string): Promise { + if (!this.isRenaming) return; + this.isRenaming = false; + + let newName = (raw || '').trim(); + if (!newName) return; // ignore empty + + // Validate invalid characters + const invalidChars = /[\/\\:*?"<>|]/; + if (invalidChars.test(newName)) { + this.toast.error('Nom invalide : caractĂšres interdits.'); + return; + } + + // Normalize extension to .md + newName = this.normalizeFileName(newName); + + // Avoid no-op + if (newName === this.originalFileName) return; + + const oldPath = this.fullPath; // includes folder + filename + + try { + const { newPath, fileName } = await this.vaultService.renameFile(oldPath, newName); + // Update local display and URL state + this.fullPath = newPath; + this.pathParts = splitPathKeepFilename(newPath); + this.toast.success('Nom du fichier modifiĂ© avec succĂšs.'); + // Navigate to the new note path to refresh view/state (force to keep current view) + this.urlState.openNote(newPath, { force: true }); + // Re-fit after name change + queueMicrotask(() => this.fitPath()); + } catch (e: any) { + const msg = e?.message || 'Erreur lors du renommage.'; + this.toast.error(msg); + // Restore display + this.pathParts = splitPathKeepFilename(oldPath); + } + } + onPathContextMenu(event: MouseEvent): void { event.preventDefault(); this.copyRequested.emit(); diff --git a/src/app/services/url-state.service.ts b/src/app/services/url-state.service.ts index 01ca2f8..36a71a3 100644 --- a/src/app/services/url-state.service.ts +++ b/src/app/services/url-state.service.ts @@ -261,16 +261,22 @@ export class UrlStateService implements OnDestroy { /** * Ouvrir une note via l'URL */ - async openNote(notePath: string): Promise { - const note = this.vaultService.allNotes().find(n => n.filePath === notePath); - - if (!note) { - console.warn(`Note not found: ${notePath}`); + async openNote(notePath: string, options?: { force?: boolean }): Promise { + const trimmed = (notePath ?? '').trim(); + if (!trimmed) { + console.warn('openNote() called with empty path'); return; } - - // Mettre Ă  jour l'URL - await this.updateUrl({ note: notePath }); + + const note = this.vaultService.allNotes().find(n => n.filePath === trimmed); + + if (!note && !options?.force) { + console.warn(`Note not found: ${trimmed}`); + return; + } + + // Mettre Ă  jour l'URL (mĂȘme si la note n'est pas encore connue localement lorsqu'on force) + await this.updateUrl({ note: trimmed }); } /** diff --git a/src/services/vault.service.ts b/src/services/vault.service.ts index 1e11334..1f0c896 100644 --- a/src/services/vault.service.ts +++ b/src/services/vault.service.ts @@ -214,6 +214,22 @@ export class VaultService implements OnDestroy { // PUBLIC API // ======================================== + /** Rename a markdown file within its folder. newName may or may not include .md; server enforces .md */ + async renameFile(oldPath: string, newName: string): Promise<{ newPath: string; fileName: string }> { + const payload = { oldPath, newName } as any; + const res = await fetch('/api/files/rename', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error(data?.error || 'Failed to rename file'); + } + // Let SSE event update state; return new path to caller + return { newPath: String(data.newPath || ''), fileName: String(data.fileName || '') }; + } + getNoteById(id: string): Note | undefined { return this.notesMap().get(id); } @@ -807,12 +823,12 @@ export class VaultService implements OnDestroy { private handleVaultEvent(event: VaultEventPayload): void { if (!event?.event) return; - // Handle folder-specific events with immediate refresh - if (event.event === 'folder-rename' || event.event === 'folder-delete') { + // Handle folder/file-specific events with immediate refresh + if (event.event === 'folder-rename' || event.event === 'folder-delete' || event.event === 'file-rename') { console.log(`[VaultService] Received ${event.event} event:`, event); - // Immediate refresh for folder operations (no debounce) + // Immediate refresh for structural operations (no debounce) this.refreshNotes(); - this.loadFastFileTree(true); // authoritative refresh to avoid stale folders + this.loadFastFileTree(true); // authoritative refresh to avoid stale state return; } diff --git a/vault/Allo-3/test-new-file.md b/vault/Allo-3/tata.md similarity index 100% rename from vault/Allo-3/test-new-file.md rename to vault/Allo-3/tata.md diff --git a/vault/Allo-3/test/Nouvelle note 3.md b/vault/Allo-3/test/titi.md similarity index 75% rename from vault/Allo-3/test/Nouvelle note 3.md rename to vault/Allo-3/test/titi.md index a0542bf..de746e8 100644 --- a/vault/Allo-3/test/Nouvelle note 3.md +++ b/vault/Allo-3/test/titi.md @@ -2,10 +2,12 @@ titre: Nouvelle note 3 auteur: Bruno Charest creation_date: 2025-10-24T15:44:07.120Z -modification_date: 2025-10-24T11:44:07-04:00 +modification_date: 2025-10-25T19:53:45-04:00 catĂ©gorie: "" -tags: [] -aliases: [] +tags: + - "" +aliases: + - "" status: en-cours publish: false favoris: false diff --git a/vault/Allo-3/Nouvelle note 1.md.bak b/vault/Allo-3/test/titi.md.bak similarity index 52% rename from vault/Allo-3/Nouvelle note 1.md.bak rename to vault/Allo-3/test/titi.md.bak index 3f0a6ec..788b189 100644 --- a/vault/Allo-3/Nouvelle note 1.md.bak +++ b/vault/Allo-3/test/titi.md.bak @@ -1,8 +1,10 @@ --- -titre: "Nouvelle note 1" +titre: "Nouvelle note 3" auteur: "Bruno Charest" -creation_date: "2025-10-24T03:30:58.977Z" -modification_date: "2025-10-24T03:30:58.977Z" +creation_date: "2025-10-24T15:44:07.120Z" +modification_date: "2025-10-24T11:44:07-04:00" +tags: [""] +aliases: [""] status: "en-cours" publish: false favoris: false @@ -12,4 +14,3 @@ archive: false draft: false private: false --- - diff --git a/vault/Allo-3/Nouvelle note 1.md b/vault/Allo-3/toto.md similarity index 94% rename from vault/Allo-3/Nouvelle note 1.md rename to vault/Allo-3/toto.md index d3554a4..4f5bb87 100644 --- a/vault/Allo-3/Nouvelle note 1.md +++ b/vault/Allo-3/toto.md @@ -15,3 +15,4 @@ archive: false draft: false private: false --- +# Nouvelle note 1