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
This commit is contained in:
parent
b873577e93
commit
b1da9b111d
@ -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! 🚀**
|
|
||||||
441
docs/ARCHITECTURE/HUB_WORKSPACE_ARCHITECTURE.md
Normal file
441
docs/ARCHITECTURE/HUB_WORKSPACE_ARCHITECTURE.md
Normal file
@ -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_<workspaceId>_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-<id>.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=<id>` → `{ 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: <etag>` → 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/<id>/vault:/app/vault
|
||||||
|
/data/workspaces/<id>/assets:/app/assets
|
||||||
|
/data/workspaces/<id>/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=<id>
|
||||||
|
REQUIRE_JWT=true
|
||||||
|
HUB_JWT_PUBLIC_KEY_FILE=/keys/hub_rsa.pub
|
||||||
|
```
|
||||||
|
|
||||||
|
### Labels Traefik (ex.)
|
||||||
|
|
||||||
|
```
|
||||||
|
"traefik.enable": "true"
|
||||||
|
"traefik.http.routers.obsiviewer-<id>.rule": "Host(`obsiviewer-<id>.local`)"
|
||||||
|
"traefik.http.services.obsiviewer-<id>.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_<workspaceId>_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).
|
||||||
@ -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)
|
### Étape 1: Lancer le backend (Terminal 1)
|
||||||
|
```bash
|
||||||
```typescript
|
cd c:\dev\git\web\ObsiViewer
|
||||||
// src/app/app.component.ts
|
node server/index.mjs
|
||||||
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 2: Utiliser dans NotesListComponent (2 min)
|
**Attendu**: Logs du serveur, port 4000 actif
|
||||||
|
```
|
||||||
```typescript
|
[Config] { MEILI_HOST: 'http://127.0.0.1:7700', ... }
|
||||||
// src/app/features/list/notes-list.component.ts
|
[Server] Listening on port 4000
|
||||||
import { Component, inject } from '@angular/core';
|
|
||||||
import { UrlStateService } from '../../services/url-state.service';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-notes-list',
|
|
||||||
standalone: true,
|
|
||||||
template: `
|
|
||||||
<!-- Afficher le filtre actif -->
|
|
||||||
<div *ngIf="urlState.activeTag() as tag">
|
|
||||||
Filtre: #{{ tag }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Ouvrir une note -->
|
|
||||||
<button *ngFor="let note of notes"
|
|
||||||
(click)="selectNote(note)">
|
|
||||||
{{ note.title }}
|
|
||||||
</button>
|
|
||||||
`
|
|
||||||
})
|
|
||||||
export class NotesListComponent {
|
|
||||||
urlState = inject(UrlStateService);
|
|
||||||
|
|
||||||
selectNote(note: Note): void {
|
|
||||||
this.urlState.openNote(note.filePath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### É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
|
# Ouvrir une note
|
||||||
http://localhost:4200/viewer?note=Docs/Architecture.md
|
http://localhost:3000/?note=Allo-3/test-new-file.md
|
||||||
|
|
||||||
# Filtrer par tag
|
|
||||||
http://localhost:4200/viewer?tag=Ideas
|
|
||||||
|
|
||||||
# Filtrer par dossier
|
# 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
|
# 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
|
## ⚠️ Troubleshooting
|
||||||
- [ ] NotesListComponent utilise le service
|
|
||||||
- [ ] Les URLs fonctionnent
|
|
||||||
- [ ] L'état est restauré après rechargement
|
|
||||||
|
|
||||||
## 📚 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**
|
### Problème: URL ne change pas après interaction
|
||||||
- `docs/URL_STATE_SERVICE_INTEGRATION.md`
|
**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**
|
### Problème: Note ne s'ouvre pas
|
||||||
- `src/app/components/url-state-integration-examples.ts`
|
**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**
|
### Problème: Erreur dans la console
|
||||||
- NoteViewComponent
|
**Solution**:
|
||||||
- FoldersComponent
|
1. Copier l'erreur complète
|
||||||
- TagsComponent
|
2. Vérifier les logs du serveur
|
||||||
- SearchComponent
|
3. Consulter `URL_STATE_INTEGRATION_TEST.md` pour les cas connus
|
||||||
|
|
||||||
4. **Ajouter le partage de lien**
|
---
|
||||||
```typescript
|
|
||||||
async shareCurrentState(): Promise<void> {
|
|
||||||
await this.urlState.copyCurrentUrlToClipboard();
|
|
||||||
this.toast.success('Lien copié!');
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🎯 Cas d'usage courants
|
## 📊 Vérification Rapide
|
||||||
|
|
||||||
### Ouvrir une note
|
### Backend OK?
|
||||||
```typescript
|
```bash
|
||||||
await this.urlState.openNote('Docs/Architecture.md');
|
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
|
## 🎓 Concepts Clés
|
||||||
```typescript
|
|
||||||
await this.urlState.filterByFolder('Notes/Meetings');
|
|
||||||
```
|
|
||||||
|
|
||||||
### Rechercher
|
### URL Parameters
|
||||||
```typescript
|
- `?note=...` → Ouvre une note
|
||||||
await this.urlState.updateSearch('performance');
|
- `?folder=...` → Filtre par dossier
|
||||||
```
|
- `?tag=...` → Filtre par tag
|
||||||
|
- `?quick=...` → Filtre par quick link
|
||||||
|
- `?search=...` → Applique la recherche
|
||||||
|
|
||||||
### Réinitialiser
|
### Priorité
|
||||||
```typescript
|
- Si `note` est présent → ouvre la note
|
||||||
await this.urlState.resetState();
|
- 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
|
## 📝 Prochaines Étapes
|
||||||
urlState.currentNote()
|
|
||||||
|
|
||||||
// Tag actif
|
1. **Tester les 5 tests rapides** (5 min)
|
||||||
urlState.activeTag()
|
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
|
## 📞 Aide Rapide
|
||||||
urlState.activeQuickLink()
|
|
||||||
|
|
||||||
// Recherche active
|
| Question | Réponse |
|
||||||
urlState.activeSearch()
|
|----------|---------|
|
||||||
```
|
| 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! 🚀**
|
||||||
|
|||||||
@ -15,6 +15,75 @@ import express from 'express';
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
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
|
// ENDPOINT 5: /api/folders/rename - Rename folder with validation
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@ -35,7 +35,8 @@ import {
|
|||||||
setupDeleteNoteEndpoint,
|
setupDeleteNoteEndpoint,
|
||||||
setupRenameFolderEndpoint,
|
setupRenameFolderEndpoint,
|
||||||
setupDeleteFolderEndpoint,
|
setupDeleteFolderEndpoint,
|
||||||
setupCreateFolderEndpoint
|
setupCreateFolderEndpoint,
|
||||||
|
setupRenameFileEndpoint
|
||||||
} from './index-phase3-patch.mjs';
|
} from './index-phase3-patch.mjs';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
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)
|
// Setup rename folder endpoint (must be before catch-all)
|
||||||
setupRenameFolderEndpoint(app, vaultDir, broadcastVaultEvent, metadataCache);
|
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)
|
// Setup delete folder endpoint (must be before catch-all)
|
||||||
setupDeleteFolderEndpoint(app, vaultDir, broadcastVaultEvent, metadataCache);
|
setupDeleteFolderEndpoint(app, vaultDir, broadcastVaultEvent, metadataCache);
|
||||||
|
|
||||||
|
|||||||
@ -30,14 +30,34 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="path-wrap flex items-center gap-1 min-w-0 flex-1 cursor-pointer" (click)="onPathClick()" (contextmenu)="onPathContextMenu($event)" role="button" tabindex="0" [attr.aria-label]="'Ouvrir le dossier ' + pathParts.filename">
|
<div class="path-wrap flex items-center gap-1 min-w-0 flex-1" (contextmenu)="onPathContextMenu($event)" role="group" tabindex="0" [attr.aria-label]="'Chemin du fichier ' + pathParts.filename">
|
||||||
<span class="path-prefix shrink min-w-0 overflow-hidden whitespace-nowrap text-ellipsis" [title]="fullPath">
|
<span class="path-prefix shrink min-w-0 overflow-hidden whitespace-nowrap text-ellipsis cursor-pointer" (click)="onPathClick()" [title]="fullPath">
|
||||||
{{ pathParts.prefix }}
|
{{ pathParts.prefix }}
|
||||||
</span>
|
</span>
|
||||||
<span class="path-sep" *ngIf="pathParts.prefix">/</span>
|
<span class="path-sep" *ngIf="pathParts.prefix">/</span>
|
||||||
<span class="path-filename whitespace-nowrap" [title]="pathParts.filename">
|
|
||||||
{{ pathParts.filename }}
|
<ng-container *ngIf="!isRenaming; else renameTpl">
|
||||||
</span>
|
<span class="path-filename whitespace-nowrap editable transition-opacity duration-150" [title]="'Renommer le fichier'" (click)="onFileNameClick($event)">
|
||||||
|
{{ pathParts.filename }}
|
||||||
|
<span class="edit-icon ml-1 opacity-0 transition-opacity duration-150" aria-hidden="true">
|
||||||
|
<!-- pencil icon -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="inline h-3.5 w-3.5 align-[-1px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M12 20h9"/>
|
||||||
|
<path d="M16.5 3.5a2.121 2.121 0 1 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</ng-container>
|
||||||
|
<ng-template #renameTpl>
|
||||||
|
<input
|
||||||
|
#renameInput
|
||||||
|
type="text"
|
||||||
|
class="inline-edit bg-transparent border-b border-[var(--primary)] focus:outline-none font-semibold text-[0.95rem] w-auto"
|
||||||
|
[value]="pathParts.filename"
|
||||||
|
(keydown)="onRenameKeydown($event, renameInput.value)"
|
||||||
|
(blur)="onRenameBlur(renameInput.value)"
|
||||||
|
/>
|
||||||
|
</ng-template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -19,6 +19,20 @@
|
|||||||
font-size: 0.95rem;
|
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 {
|
.note-header__action {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@ -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 { CommonModule } from '@angular/common';
|
||||||
import { debounceTime, Subject } from 'rxjs';
|
import { debounceTime, Subject } from 'rxjs';
|
||||||
import { splitPathKeepFilename } from '../../../../shared/utils/path';
|
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 { PropertiesPopoverComponent } from '../properties-popover/properties-popover.component';
|
||||||
import { FrontmatterPropertiesService } from '../../shared/frontmatter-properties.service';
|
import { FrontmatterPropertiesService } from '../../shared/frontmatter-properties.service';
|
||||||
import { VaultService } from '../../../../../services/vault.service';
|
import { VaultService } from '../../../../../services/vault.service';
|
||||||
|
import { ToastService } from '../../../../shared/toast/toast.service';
|
||||||
|
import { UrlStateService } from '../../../../services/url-state.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-note-header',
|
selector: 'app-note-header',
|
||||||
@ -28,6 +30,11 @@ export class NoteHeaderComponent implements AfterViewInit, OnDestroy, OnChanges
|
|||||||
|
|
||||||
pathParts: { prefix: string; filename: string } = { prefix: '', filename: '' };
|
pathParts: { prefix: string; filename: string } = { prefix: '', filename: '' };
|
||||||
|
|
||||||
|
// Inline rename state
|
||||||
|
isRenaming = false;
|
||||||
|
originalFileName = '';
|
||||||
|
@ViewChild('renameInput') renameInput?: ElementRef<HTMLInputElement>;
|
||||||
|
|
||||||
private ro?: ResizeObserver;
|
private ro?: ResizeObserver;
|
||||||
private resize$ = new Subject<void>();
|
private resize$ = new Subject<void>();
|
||||||
|
|
||||||
@ -39,6 +46,8 @@ export class NoteHeaderComponent implements AfterViewInit, OnDestroy, OnChanges
|
|||||||
private vcr = inject(ViewContainerRef);
|
private vcr = inject(ViewContainerRef);
|
||||||
private frontmatterService = inject(FrontmatterPropertiesService);
|
private frontmatterService = inject(FrontmatterPropertiesService);
|
||||||
private vaultService = inject(VaultService);
|
private vaultService = inject(VaultService);
|
||||||
|
private toast = inject(ToastService);
|
||||||
|
private urlState = inject(UrlStateService);
|
||||||
|
|
||||||
constructor(private host: ElementRef<HTMLElement>) {}
|
constructor(private host: ElementRef<HTMLElement>) {}
|
||||||
|
|
||||||
@ -148,6 +157,87 @@ export class NoteHeaderComponent implements AfterViewInit, OnDestroy, OnChanges
|
|||||||
this.openDirectory.emit();
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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 {
|
onPathContextMenu(event: MouseEvent): void {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
this.copyRequested.emit();
|
this.copyRequested.emit();
|
||||||
|
|||||||
@ -261,16 +261,22 @@ export class UrlStateService implements OnDestroy {
|
|||||||
/**
|
/**
|
||||||
* Ouvrir une note via l'URL
|
* Ouvrir une note via l'URL
|
||||||
*/
|
*/
|
||||||
async openNote(notePath: string): Promise<void> {
|
async openNote(notePath: string, options?: { force?: boolean }): Promise<void> {
|
||||||
const note = this.vaultService.allNotes().find(n => n.filePath === notePath);
|
const trimmed = (notePath ?? '').trim();
|
||||||
|
if (!trimmed) {
|
||||||
if (!note) {
|
console.warn('openNote() called with empty path');
|
||||||
console.warn(`Note not found: ${notePath}`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mettre à jour l'URL
|
const note = this.vaultService.allNotes().find(n => n.filePath === trimmed);
|
||||||
await this.updateUrl({ note: notePath });
|
|
||||||
|
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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -214,6 +214,22 @@ export class VaultService implements OnDestroy {
|
|||||||
// PUBLIC API
|
// 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 {
|
getNoteById(id: string): Note | undefined {
|
||||||
return this.notesMap().get(id);
|
return this.notesMap().get(id);
|
||||||
}
|
}
|
||||||
@ -807,12 +823,12 @@ export class VaultService implements OnDestroy {
|
|||||||
private handleVaultEvent(event: VaultEventPayload): void {
|
private handleVaultEvent(event: VaultEventPayload): void {
|
||||||
if (!event?.event) return;
|
if (!event?.event) return;
|
||||||
|
|
||||||
// Handle folder-specific events with immediate refresh
|
// Handle folder/file-specific events with immediate refresh
|
||||||
if (event.event === 'folder-rename' || event.event === 'folder-delete') {
|
if (event.event === 'folder-rename' || event.event === 'folder-delete' || event.event === 'file-rename') {
|
||||||
console.log(`[VaultService] Received ${event.event} event:`, event);
|
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.refreshNotes();
|
||||||
this.loadFastFileTree(true); // authoritative refresh to avoid stale folders
|
this.loadFastFileTree(true); // authoritative refresh to avoid stale state
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,10 +2,12 @@
|
|||||||
titre: Nouvelle note 3
|
titre: Nouvelle note 3
|
||||||
auteur: Bruno Charest
|
auteur: Bruno Charest
|
||||||
creation_date: 2025-10-24T15:44:07.120Z
|
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: ""
|
catégorie: ""
|
||||||
tags: []
|
tags:
|
||||||
aliases: []
|
- ""
|
||||||
|
aliases:
|
||||||
|
- ""
|
||||||
status: en-cours
|
status: en-cours
|
||||||
publish: false
|
publish: false
|
||||||
favoris: false
|
favoris: false
|
||||||
@ -1,8 +1,10 @@
|
|||||||
---
|
---
|
||||||
titre: "Nouvelle note 1"
|
titre: "Nouvelle note 3"
|
||||||
auteur: "Bruno Charest"
|
auteur: "Bruno Charest"
|
||||||
creation_date: "2025-10-24T03:30:58.977Z"
|
creation_date: "2025-10-24T15:44:07.120Z"
|
||||||
modification_date: "2025-10-24T03:30:58.977Z"
|
modification_date: "2025-10-24T11:44:07-04:00"
|
||||||
|
tags: [""]
|
||||||
|
aliases: [""]
|
||||||
status: "en-cours"
|
status: "en-cours"
|
||||||
publish: false
|
publish: false
|
||||||
favoris: false
|
favoris: false
|
||||||
@ -12,4 +14,3 @@ archive: false
|
|||||||
draft: false
|
draft: false
|
||||||
private: false
|
private: false
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -15,3 +15,4 @@ archive: false
|
|||||||
draft: false
|
draft: false
|
||||||
private: false
|
private: false
|
||||||
---
|
---
|
||||||
|
# Nouvelle note 1
|
||||||
Loading…
x
Reference in New Issue
Block a user