Compare commits

..

8 Commits

Author SHA1 Message Date
55e5b359a1 Rendre le compose deployable depuis le depot
env_file rendait obligatoire un fichier non versionne : un clone neuf, ou une
stack Portainer construite depuis Git, echouait avec "env file .env not found".
La declaration etait de toute facon redondante, Compose lisant deja un .env
voisin pour resoudre les interpolations, et toutes les variables du .env.example
etant couvertes par environment: ou ports:.

Verifie : configuration resolue identique avec un .env present, a l'exception de
APP_PORT qui n'est plus injecte dans le conteneur. C'est une variable cote hote,
que le backend ne lit jamais et dont le port est fige dans le CMD.

Documente le deploiement par stack Portainer : variables d'environnement en
remplacement du .env, emplacement et proprietaire du dossier de donnees (uid
10001), mise a jour, administration par la console, et cas du proxy en Docker.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 11:08:30 +02:00
9ede2c5e68 Phase 2.1.1 : durcissement avant exposition publique
Fail-fast sur la configuration. Une limite de debit illisible (10/hours,
10/banana, -1/hour) arrete desormais le demarrage avec un message nommant la
variable, au lieu de desactiver silencieusement la protection : seules les
valeurs 0, off, none, disabled et vide desactivent volontairement. Meme
traitement pour MAX_SHARED_MAPS, MAX_SHARE_SIZE_MB, DEFAULT_SHARE_EXPIRY_DAYS
et chaque entree de TRUSTED_PROXY_CIDRS.

X-Forwarded-For n'est plus cru sur la seule foi de TRUST_PROXY_HEADERS : la
connexion doit aussi provenir d'un reseau declare dans TRUSTED_PROXY_CIDRS
(ipaddress, IPv4/IPv6/CIDR). Activer la confiance sans declarer de reseau est
refuse au demarrage, faute de quoi la configuration semblerait active sans
l'etre. L'adresse retenue est normalisee : les differentes ecritures d'une meme
IPv6 ne donnent plus des compteurs distincts.

MAX_SHARED_MAPS : comptage et insertion serialises par un verrou de processus,
pour que deux creations simultanees ne franchissent pas ensemble la derniere
place. Documente comme valable pour un service applicatif unique.

Ajoute APP_BIND pour ne pas publier 8080 sur toutes les interfaces en
production, une option backup --keep pour la retention, et documente Nginx,
Traefik en override, cron et systemd.

Corrige un bug de rendu du build local : mon edition avait reecrit
docker-entrypoint.sh en CRLF, rendant le shebang introuvable dans le conteneur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 09:53:29 +02:00
49870dcd56 Phase 2.1 : preparation au deploiement public
Rend une instance exposee sur Internet exploitable sans la transformer en
infrastructure : le produit reste local-first, sans compte et sans telemetrie.

Protection du service : limitation de debit en memoire par client et par
famille d'endpoint (health et ready jamais limites), interrupteur
SHARING_ENABLED, capacite maximale de partages, taille propre aux partages.
X-Forwarded-For n'est cru que si TRUST_PROXY_HEADERS l'autorise, et on lit alors
la derniere valeur, celle ajoutee par le proxy.

Expiration facultative des partages (24 h a 90 jours, ou jamais) : un lien
expire repond 410 et la page publique le dit, sans autre detail. Le proprietaire
peut prolonger ; aucune purge automatique.

Protection des liens non repertories : robots.txt, X-Robots-Tag et meta robots
sur /s/ et /api/, Referrer-Policy no-referrer sur tout le site, redaction des
jetons dans les journaux d'acces, share_token porte a 128 bits (les anciens
restent valides).

Schema gere par Alembic, applique au demarrage. Une base creee avant Alembic est
estampillee puis migree : verifie sur la base reelle de developpement, 4
partages inchanges et expires_at NULL.

Administration par CLI locale au conteneur (stats, liste, suppression, purge,
sauvegarde a chaud via l'API SQLite, restauration prudente) : aucune interface
/admin exposee.

Le jeton de gestion peut enfin etre exporte dans un fichier .openmind-share puis
reimporte depuis un autre navigateur, apres verification aupres du serveur. Il
n'est toujours jamais ajoute a un export .mmap.

Ajoute une page /privacy dont le texte est centralise, et corrige un bug trouve
en observant les logs : Alembic, lance dans le processus applicatif,
reappliquait la configuration de logs d'alembic.ini et desactivait les loggers
existants, ce qui faisait disparaitre les journaux d'acces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 09:22:17 +02:00
e3c530348e Phase 2 : mind map bilaterale, relations, templates et Markdown
Format .mmap v4 : `node.side` (cote d'une branche de niveau 1) et
`MindMap.relations` (relations secondaires). Migration v3 -> v4 sans perte, les
cartes v1/v2/v3 restent ouvrables telles quelles.

Une relation est une connexion purement visuelle : elle ne touche jamais
parentId, order, les enfants, le repli ni l'auto-layout. `withEdges()` continue
de deriver les liens de parentId et se contente de retirer les relations dont
une extremite a disparu.

Mind map bilaterale : repartition gloutonne et deterministe des branches selon
la taille des sous-arbres, heritage du cote par sous-arbre, deux passes dagre
recalees sur la racine. Choisir une disposition ne deplace plus la carte : seul
Reorganiser applique.

Ajoute aussi une galerie de huit modeles locaux, l'import Markdown (fichier ou
texte, nouvelle carte ou sous-arbre) reutilisant l'analyseur d'outline existant,
et l'export Markdown de la hierarchie.

Corrige un bug de rendu : React Flow ne remesure pas les poignees quand elles
changent de cote, ce qui faisait disparaitre les liens concernes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 08:12:00 +02:00
2afbf637ce Phase 1.2 : productivite de creation
Construire une grande carte demande beaucoup moins de gestes.

Ordre des freres (format v3)
`MindMapNode.order` devient la position parmi les freres. `parentId` reste la
seule source de verite de la relation hierarchique : une source par
preoccupation, jamais deux versions contradictoires. L'ordre est renumerote a
chaque modification structurelle dans `withEdges`, unique point de passage.
Le format passe en v3 : un lecteur v2 ignorerait `order` et reordonnerait
silencieusement la carte. Migration v2 -> v3 depuis la position dans le tableau,
v1 -> v2 -> v3 s'enchainent, et les noeuds sont ecrits en parcours de profondeur
pour rester corrects meme pour un lecteur anterieur.

Creation rapide
- Ajouter plusieurs enfants : une ligne = un noeud, avec apercu avant creation.
- Collage d'une liste multi-ligne sur un noeud : le presse-papiers interne reste
  prioritaire, l'edition de texte n'est jamais interceptee.
- Import d'une arborescence indentee : espaces ou tabulations, puces retirees,
  indentation incoherente signalee avec le numero de ligne plutot que devinee.
- Les nouveaux noeuds sont repartis autour du parent au lieu de se superposer.

Hierarchie
Monter, descendre, placer en premier ou en dernier, remonter d'un niveau,
"Deplacer vers..." avec recherche, suppression d'un noeud en conservant ses
enfants a la place qu'il occupait. Le re-parentage passe toujours par
`reparentNode` : la detection de cycle n'est ecrite qu'une fois.

Branches
Selectionner, dupliquer, copier, coller comme enfant, deplacer avec ses
descendants (reglage, ou Alt pour l'inverser le temps d'un glisser), replier et
developper une branche, tout replier, tout developper, replier au-dela d'un
niveau.

Clavier
Maj+Tab remonte d'un niveau, Maj+Entree ouvre la creation multiple, Alt+Haut et
Alt+Bas reordonnent. Tab, Entree et F2 gardent leur comportement.

Historique
Chaque operation logique reste une seule entree : creation multiple, import
d'outline, reordonnancement, deplacement de branche, duplication, collage,
suppression, tout replier.

Tests : 172 vitest (103 -> 172) et 36 pytest (31 -> 36).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 06:35:57 +02:00
a23a68629d Phase 1.1.1 : consolidation et coherence UX
Trois corrections ciblees, sans changement d'architecture ni d'interface.

1. Le style ne transporte plus l'icone
L'icone d'un noeud est du contenu, comme son texte ou sa note. Copier le style
de "Reseau" et le coller sur "Stockage" et "Securite" applique desormais les
couleurs, la forme et la mise en page en laissant chaque noeud avec sa propre
icone. Seule la presentation de l'icone (position, taille, espacement) reste
une propriete de style.

2. Le cercle conserve un rapport 1:1
`keepAspectRatio` sur le redimensionneur, et contrainte cote store pour les
trois autres chemins : redimensionnement direct, saisie d'une largeur ou d'une
hauteur dans le panneau, et conversion d'un noeud rectangulaire en cercle.
Un cercle ne peut plus devenir un ovale.

3. Point d'entree unique des documents
`normalizeMapDocument()` remplace `parseMapDocument()` et devient le seul
chemin par lequel une carte entre dans l'editeur, qu'elle vienne d'un fichier
.mmap, d'IndexedDB ou d'un partage. Le pipeline est toujours validation ->
migration -> normalisation. Les cartes locales passaient jusqu'ici a cote de la
migration : elles y passent maintenant a l'ouverture, sans etre reecrites en
base tant que l'utilisateur ne les modifie pas. Une carte locale videe de ses
noeuds reste ouvrable, et le message d'erreur s'adapte a la provenance.

Egalement
- Zoom minimum abaisse de 0.1 a 0.02 : "Adapter a l'ecran" butait sur ce
  plancher et ne montrait pas toute une carte de 181 noeuds.
- README : pipeline de normalisation, comportement du style et du cercle,
  limitations mesurees.

Tests : 103 vitest (85 -> 103) et 31 pytest, ruff, eslint et tsc au vert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 05:48:22 +02:00
bf83292e65 Regrouper les notifications de mesure en un seul rendu
Chaque noeud remonte sa taille reelle apres rendu. Sur une carte de 120 noeuds,
appliquer un reglage a toute la selection declenchait autant d'increments de
`sizeVersion` que de noeuds redimensionnes, donc autant de rendus successifs.

Les increments sont desormais regroupes dans une microtache : un seul rendu par
lot. Mesure sur 121 noeuds, onglet en arriere-plan (donc pessimiste) :
application d'un padding a toute la selection de ~730 ms a ~500 ms.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 17:36:06 +02:00
af6fd519c5 Phase 1.1 : mise en page et edition visuelle des noeuds
Evolution incrementale : aucun changement d'architecture, aucune fonctionnalite
retiree, compatibilite .mmap v1 et IndexedDB preservee.

Modele (format v2)
- MindMapNode gagne `layout`, `iconStyle` et `branch`, tous optionnels.
- NodeStyle gagne fontFamily, verticalAlign, lineHeight et shadow.
- MapSettings gagne layoutSpacing, snapToGrid, snapGrid et alignmentGuides.
- Un champ absent signifie "herite du theme" : rien n'est ecrit dans le
  document pour materialiser un defaut, donc les cartes deja stockees restent
  lisibles telles quelles. Migration v1 -> v2 : style.width devient
  layout.width, toujours lu en repli par le resolveur.

Mise en page
- Nouveau resolveur de style qui fusionne theme et personnalisations.
- Taille automatique fidele au contenu, taille personnalisee bornee,
  redimensionnement a la souris via NodeResizer.
- Padding horizontal et vertical, retour a la ligne, largeur maximale du texte,
  interligne, police, alignement horizontal et vertical, ombre.
- Icones : emojis conserves, bibliotheque Lucide ajoutee, position, taille et
  espacement configurables.

Multi-selection
- Barre contextuelle d'alignement (6 axes) et de distribution (2 axes).
- Application d'un reglage a toute la selection, avec etat "Mixte".

Style
- Copier / coller / reinitialiser le style, au clavier et par clic droit.

Auto-layout
- Espacement parent/enfant et entre branches configurable, presets Compact,
  Normal et Aere. Le layout reste declenche uniquement a la demande.

Historique
- Une seule entree par redimensionnement complet, par alignement, par
  distribution, par application multiple et par collage de style.

Panneau Proprietes
- Reorganise en cinq sections repliables dont l'etat est conserve pendant la
  session. Vraies icones d'alignement, tooltips et aria-labels partout.

Corrections
- Largeur des noeuds : word-break et overflow-wrap:anywhere ramenaient la
  largeur min-content a un caractere, ce qui faisait s'effondrer tous les
  noeuds automatiques.
- Largeur auto en max-content bornee, pour qu'une phrase longue occupe la
  largeur maximale au lieu de former une colonne etroite.

Tests
- 85 tests vitest (43 -> 85) et 31 tests pytest (25 -> 31).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 17:29:48 +02:00
121 changed files with 15272 additions and 698 deletions

View File

@@ -9,6 +9,11 @@ APP_NAME=OpenMind
# Port publie sur l'hote. L'application ecoute toujours sur 8080 dans le conteneur. # Port publie sur l'hote. L'application ecoute toujours sur 8080 dans le conteneur.
APP_PORT=8080 APP_PORT=8080
# Interface sur laquelle le port est publie. En production derriere un reverse
# proxy installe sur l'hote, mettez 127.0.0.1 : OpenMind n'est alors joignable
# que par le proxy, jamais directement depuis Internet.
APP_BIND=0.0.0.0
# Base SQLite des cartes PARTAGEES uniquement. # Base SQLite des cartes PARTAGEES uniquement.
# /app/data est monte depuis ./data sur l'hote (bind mount). # /app/data est monte depuis ./data sur l'hote (bind mount).
DATABASE_URL=sqlite:////app/data/openmind.db DATABASE_URL=sqlite:////app/data/openmind.db
@@ -17,6 +22,49 @@ DATABASE_URL=sqlite:////app/data/openmind.db
MAX_MAP_SIZE_MB=10 MAX_MAP_SIZE_MB=10
MAX_NODES=5000 MAX_NODES=5000
# ---------------------------------------------------------------------------
# Exposition publique
# ---------------------------------------------------------------------------
# false = instance entierement privee : aucune API de partage, bouton masque.
# Les cartes locales continuent de fonctionner normalement.
SHARING_ENABLED=true
# Limites de debit, au format <nombre>/<fenetre> (second, minute, hour, day).
# Pour desactiver une limite : 0, off, none, disabled, ou une valeur vide.
# Toute autre valeur illisible (10/hours, 10/banana...) ARRETE le demarrage :
# une faute de frappe ne doit pas desactiver silencieusement une protection.
# Les compteurs sont en memoire : ils repartent de zero au redemarrage.
SHARE_CREATE_RATE_LIMIT=10/hour
SHARE_READ_RATE_LIMIT=120/minute
SHARE_WRITE_RATE_LIMIT=60/hour
# Nombre total de partages heberges. 0 = illimite.
MAX_SHARED_MAPS=10000
# Taille maximale d'un partage. 0 = reprend MAX_MAP_SIZE_MB.
MAX_SHARE_SIZE_MB=5
# Duree de vie par defaut d'un nouveau partage, en jours. 0 = permanent.
DEFAULT_SHARE_EXPIRY_DAYS=30
# Autorise l'option "Jamais" lors du choix de l'expiration.
ALLOW_PERMANENT_SHARES=true
# A activer UNIQUEMENT derriere un reverse proxy de confiance : sinon un client
# direct pourrait falsifier X-Forwarded-For et contourner les limites de debit.
# Activer TRUST_PROXY_HEADERS impose de renseigner TRUSTED_PROXY_CIDRS : sans
# reseau declare, aucun en-tete ne serait accepte, et le demarrage echoue plutot
# que de laisser croire a une configuration active.
TRUST_PROXY_HEADERS=false
# Reseaux depuis lesquels X-Forwarded-For est accepte (IPv4, IPv6, CIDR),
# separes par des virgules. Exemple pour un proxy dans Docker :
# TRUSTED_PROXY_CIDRS=172.16.0.0/12,10.0.0.0/8
# Pour un Nginx sur l'hote, avec un port publie sur 127.0.0.1 :
# TRUSTED_PROXY_CIDRS=127.0.0.1/32,::1/128
TRUSTED_PROXY_CIDRS=
# Origines autorisees en CORS, separees par des virgules. # Origines autorisees en CORS, separees par des virgules.
# Vide = aucune origine externe (l'interface est servie par ce meme serveur). # Vide = aucune origine externe (l'interface est servie par ce meme serveur).
CORS_ORIGINS= CORS_ORIGINS=

View File

@@ -50,8 +50,13 @@ WORKDIR /app
COPY --from=backend-deps /opt/python /opt/python COPY --from=backend-deps /opt/python /opt/python
COPY backend/app ./app COPY backend/app ./app
COPY backend/alembic.ini ./alembic.ini
COPY backend/migrations ./migrations
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
COPY --from=frontend-build /build/dist ./static COPY --from=frontend-build /build/dist ./static
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
RUN mkdir -p /app/data && chown -R openmind:openmind /app RUN mkdir -p /app/data && chown -R openmind:openmind /app
USER openmind USER openmind
@@ -60,4 +65,6 @@ EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -fsS http://127.0.0.1:8080/api/health || exit 1 CMD curl -fsS http://127.0.0.1:8080/api/health || exit 1
# Les migrations sont appliquees par l'entrypoint, avant le demarrage d'uvicorn.
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080", "--proxy-headers"] CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080", "--proxy-headers"]

751
README.md
View File

@@ -105,6 +105,7 @@ Choix structurants :
| Sujet | Choix | Pourquoi | | Sujet | Choix | Pourquoi |
|---|---|---| |---|---|---|
| Hierarchie | `parentId` fait autorite, les liens en sont derives | un seul invariant a maintenir, pas de desynchronisation | | Hierarchie | `parentId` fait autorite, les liens en sont derives | un seul invariant a maintenir, pas de desynchronisation |
| Ordre des freres | `node.order`, renumerote a chaque modification | une source par preoccupation : la relation d'un cote, la position de l'autre |
| Modele | types metier propres, adaptes vers React Flow a l'affichage | le format de fichier ne depend pas de la bibliotheque de rendu | | Modele | types metier propres, adaptes vers React Flow a l'affichage | le format de fichier ne depend pas de la bibliotheque de rendu |
| Historique | instantanes `{nodes, edges}`, groupes par action | un glisser-deposer = une seule annulation | | Historique | instantanes `{nodes, edges}`, groupes par action | un glisser-deposer = une seule annulation |
| Auto-layout | dagre, declenche uniquement a la demande | les deplacements manuels ne sont jamais ecrases | | Auto-layout | dagre, declenche uniquement a la demande | les deplacements manuels ne sont jamais ecrases |
@@ -120,12 +121,47 @@ Toutes definies dans `.env` (modele : `.env.example`). Aucun secret n'est requis
|---|---|---| |---|---|---|
| `APP_NAME` | `OpenMind` | Nom du produit, affiche dans l'interface et l'onglet | | `APP_NAME` | `OpenMind` | Nom du produit, affiche dans l'interface et l'onglet |
| `APP_PORT` | `8080` | Port publie sur l'hote (le conteneur ecoute toujours 8080) | | `APP_PORT` | `8080` | Port publie sur l'hote (le conteneur ecoute toujours 8080) |
| `APP_BIND` | `0.0.0.0` | Interface de publication. `127.0.0.1` en production derriere un proxy local |
| `DATABASE_URL` | `sqlite:////app/data/openmind.db` | Base des cartes partagees | | `DATABASE_URL` | `sqlite:////app/data/openmind.db` | Base des cartes partagees |
| `MAX_MAP_SIZE_MB` | `10` | Taille maximale d'une carte envoyee au serveur | | `MAX_MAP_SIZE_MB` | `10` | Taille maximale d'une carte envoyee au serveur |
| `MAX_NODES` | `5000` | Nombre maximal de noeuds par carte | | `MAX_NODES` | `5000` | Nombre maximal de noeuds par carte |
| `CORS_ORIGINS` | vide | Origines externes autorisees, separees par des virgules | | `CORS_ORIGINS` | vide | Origines externes autorisees, separees par des virgules |
| `LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR` | | `LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR` |
Exposition publique :
| Variable | Defaut | Role |
|---|---|---|
| `SHARING_ENABLED` | `true` | `false` coupe entierement le service de partage ; les cartes locales continuent de fonctionner |
| `SHARE_CREATE_RATE_LIMIT` | `10/hour` | Limite de creation de partages, par client |
| `SHARE_READ_RATE_LIMIT` | `120/minute` | Limite de lecture d'un partage, par client |
| `SHARE_WRITE_RATE_LIMIT` | `60/hour` | Limite des mises a jour, changements d'expiration et suppressions |
| `MAX_SHARED_MAPS` | `10000` | Nombre total de partages heberges. `0` = illimite |
| `MAX_SHARE_SIZE_MB` | `0` | Taille maximale d'un partage. `0` reprend `MAX_MAP_SIZE_MB` |
| `DEFAULT_SHARE_EXPIRY_DAYS` | `30` | Duree de vie par defaut d'un nouveau partage. `0` = permanent |
| `ALLOW_PERMANENT_SHARES` | `true` | Autorise l'option "Jamais" dans le choix d'expiration |
| `TRUST_PROXY_HEADERS` | `false` | Croire `X-Forwarded-For`. **A n'activer que derriere un reverse proxy de confiance** |
| `TRUSTED_PROXY_CIDRS` | vide | Reseaux depuis lesquels `X-Forwarded-For` est accepte. Obligatoire si `TRUST_PROXY_HEADERS=true` |
Format des limites de debit : `<nombre>/<fenetre>`, ou la fenetre vaut `second`,
`minute`, `hour` ou `day`.
**La configuration est validee au demarrage.** Desactiver une limite se fait
avec `0`, `off`, `none`, `disabled` ou une valeur vide. Toute autre valeur
illisible arrete l'application avec un message nommant la variable :
```
Configuration invalide :
SHARE_CREATE_RATE_LIMIT : '10/hours' n'est pas une limite de debit valide :
fenetre 'hours' inconnue. Fenetres acceptees : day, hour, minute, second.
```
Une faute de frappe n'est pas une desactivation volontaire : sans ce controle,
une instance publique tournerait sans protection en croyant en avoir une. Sont
validees de la meme facon : `MAX_SHARED_MAPS >= 0`, `MAX_SHARE_SIZE_MB >= 0`
(`0` = reprend `MAX_MAP_SIZE_MB`), `MAX_MAP_SIZE_MB > 0`,
`DEFAULT_SHARE_EXPIRY_DAYS >= 0`, et chaque entree de `TRUSTED_PROXY_CIDRS`.
Le nom du produit est centralise : cote backend via `APP_NAME`, cote frontend Le nom du produit est centralise : cote backend via `APP_NAME`, cote frontend
dans `frontend/src/config/app.ts`. Le frontend recupere le nom a l'execution via dans `frontend/src/config/app.ts`. Le frontend recupere le nom a l'execution via
`GET /api/config` : changer `APP_NAME` suffit, sans recompiler. `GET /api/config` : changer `APP_NAME` suffit, sans recompiler.
@@ -144,14 +180,47 @@ Deux stockages **totalement distincts** :
Le dossier `./data` est monte en **bind mount** (aucun volume Docker nomme) : Le dossier `./data` est monte en **bind mount** (aucun volume Docker nomme) :
les fichiers sont donc directement lisibles depuis l'hote. les fichiers sont donc directement lisibles depuis l'hote.
### Sauvegarde a chaud
SQLite fonctionne en mode WAL : les ecritures recentes vivent dans
`openmind.db-wal`, et un simple `cp openmind.db` peut donc produire une copie
incoherente. La commande de sauvegarde utilise l'API de sauvegarde de SQLite,
qui produit une base complete **pendant que l'application tourne** :
```bash
docker compose exec openmind python -m app.cli backup /app/data/backups/openmind-$(date +%F).db
```
Le fichier obtenu se trouve sur l'hote dans `./data/backups/`. C'est une base
SQLite ordinaire : elle s'ouvre avec n'importe quel outil, sans OpenMind.
A froid, une archive du dossier reste valable :
```bash ```bash
docker compose stop docker compose stop
tar czf openmind-partages-$(date +%F).tar.gz data/ tar czf openmind-partages-$(date +%F).tar.gz data/
docker compose start docker compose start
``` ```
SQLite fonctionne en mode WAL : `openmind.db`, `openmind.db-wal` et ### Restauration
`openmind.db-shm` doivent etre sauvegardes ensemble.
```bash
docker compose exec openmind python -m app.cli restore /app/data/backups/openmind-2026-09-10.db --yes
docker compose restart
```
La commande verifie d'abord que le fichier est bien une base OpenMind, place une
copie de securite de la base actuelle dans `./data/backups/pre-restore-*.db`,
puis ecrit le contenu restaure. Le redemarrage n'est pas indispensable mais reste
recommande. Pour une restauration sans aucun risque, arretez le conteneur, copiez
le fichier a la place de `openmind.db` (en supprimant les `-wal` / `-shm`
associes), puis redemarrez.
> **Ne modifiez pas `openmind.db` depuis l'hote pendant que le conteneur tourne.**
> Les fichiers `-wal` / `-shm` de SQLite sont partages entre les processus qui
> ont la base ouverte : une ecriture concurrente depuis l'hote, a travers un bind
> mount, peut etre perdue ou desynchroniser l'application du fichier. Passez par
> la CLI dans le conteneur, ou arretez le conteneur.
Redemarrer le serveur n'a **aucun impact** sur les cartes privees locales. Redemarrer le serveur n'a **aucun impact** sur les cartes privees locales.
@@ -167,7 +236,7 @@ Deux secrets distincts sont generes cote serveur avec le module Python
| Jeton | Entropie | Ou il vit | A quoi il sert | | Jeton | Entropie | Ou il vit | A quoi il sert |
|---|---|---|---| |---|---|---|---|
| `share_token` | `secrets.token_urlsafe(12)`, environ 96 bits | dans l'URL publique `/s/<token>` | **lecture seule** | | `share_token` | `secrets.token_urlsafe(16)`, 128 bits | dans l'URL publique `/s/<token>` | **lecture seule** |
| `management_token` | `secrets.token_urlsafe(32)`, environ 256 bits | **uniquement** dans l'IndexedDB du proprietaire | modification et suppression | | `management_token` | `secrets.token_urlsafe(32)`, environ 256 bits | **uniquement** dans l'IndexedDB du proprietaire | modification et suppression |
Regles appliquees : Regles appliquees :
@@ -181,6 +250,55 @@ Regles appliquees :
supprimer un partage. supprimer un partage.
- Le lien est **non repertorie** : il n'apparait dans aucune liste publique, - Le lien est **non repertorie** : il n'apparait dans aucune liste publique,
mais toute personne qui le connait peut consulter la carte. mais toute personne qui le connait peut consulter la carte.
- Les jetons emis avant le passage a 128 bits restent valides : la recherche se
fait par valeur exacte, sans contrainte de longueur.
Protection du lien :
- `robots.txt` interdit `/s/` et `/api/`, et les reponses de ces chemins portent
`X-Robots-Tag: noindex, nofollow, noarchive`. La page publique ajoute la balise
`<meta name="robots">` correspondante. Ce n'est pas une mesure de securite :
c'est le jeton qui autorise la lecture.
- `Referrer-Policy: no-referrer` est applique a tout le site. Un lien externe
clique depuis une carte partagee ne transmet donc pas l'URL `/s/<token>` au
site de destination. Les liens des noeuds s'ouvrent avec
`rel="noopener noreferrer nofollow"`.
- Les journaux applicatifs remplacent les jetons par `[REDACTED]` dans les
chemins `/s/...` et `/api/shares/...`, et designent un partage par une
empreinte courte non reversible. Les autres routes restent lisibles pour le
diagnostic. **Un reverse proxy place devant l'instance a ses propres journaux**,
qui ne dependent pas d'OpenMind : configurez-les separement si vous ne voulez
pas y voir apparaitre les jetons.
### Expiration
Un partage peut expirer automatiquement. La duree est choisie a la creation
(24 heures, 7 jours, 30 jours, 90 jours, ou jamais) et modifiable ensuite depuis
le dialogue de partage. `DEFAULT_SHARE_EXPIRY_DAYS` fixe la valeur par defaut de
l'instance, `ALLOW_PERMANENT_SHARES=false` retire l'option "Jamais".
- Un partage expire repond `410 Gone` et la page publique affiche *Ce partage a
expire*, sans autre detail.
- Il n'est pas supprime pour autant : son proprietaire peut le prolonger, et
l'administrateur le purger.
- Les partages crees avant l'introduction de l'expiration ont `expires_at = NULL`
et restent donc valides indefiniment.
### Recuperer la main sur un partage
Le jeton de gestion vit uniquement dans l'IndexedDB du navigateur d'origine.
Depuis le dialogue de partage, **Exporter la cle de gestion** produit un petit
fichier `.openmind-share` :
```json
{ "version": 1, "shareToken": "...", "managementToken": "...", "title": "..." }
```
Depuis un autre navigateur, ouvrez la carte correspondante puis **Importer une
cle de gestion** : la cle est verifiee aupres du serveur concerne avant d'etre
enregistree localement. Ce fichier contient un secret : il n'est jamais ajoute
automatiquement a un export `.mmap`, et son export est toujours une action
explicite.
Cycle de vie : Cycle de vie :
@@ -202,15 +320,361 @@ serveur.
|---|---|---|---| |---|---|---|---|
| `GET` | `/api/health` | - | Etat du service | | `GET` | `/api/health` | - | Etat du service |
| `GET` | `/api/config` | - | Nom de l'application et limites | | `GET` | `/api/config` | - | Nom de l'application et limites |
| `GET` | `/api/ready` | - | Le service peut-il repondre (acces SQLite verifie) |
| `POST` | `/api/shares` | - | Creer un partage | | `POST` | `/api/shares` | - | Creer un partage |
| `GET` | `/api/shares/{share_token}` | - | Lire une carte partagee | | `GET` | `/api/shares/{share_token}` | - | Lire une carte partagee |
| `PUT` | `/api/shares/{share_token}` | `X-Management-Token` | Mettre a jour | | `PUT` | `/api/shares/{share_token}` | `X-Management-Token` | Mettre a jour le contenu |
| `PATCH` | `/api/shares/{share_token}` | `X-Management-Token` | Modifier l'expiration |
| `POST` | `/api/shares/{share_token}/verify` | `X-Management-Token` | Verifier une cle de gestion |
| `DELETE` | `/api/shares/{share_token}` | `X-Management-Token` | Supprimer | | `DELETE` | `/api/shares/{share_token}` | `X-Management-Token` | Supprimer |
`/api/health` et `/api/ready` ne sont soumis a aucune limitation de debit : le
healthcheck du conteneur reste fiable meme sous charge. Toutes les routes de
partage repondent `503` quand `SHARING_ENABLED=false`, et `429` avec un en-tete
`Retry-After` quand une limite est atteinte.
Documentation interactive : `http://localhost:8080/api/docs`. Documentation interactive : `http://localhost:8080/api/docs`.
--- ---
## Administration d'une instance
Il n'existe volontairement **aucune interface `/admin` exposee sur Internet**.
L'administration passe par une CLI locale au conteneur, donc reservee a qui a
deja un acces machine :
```bash
docker compose exec openmind python -m app.cli stats
docker compose exec openmind python -m app.cli shares list --limit 20
docker compose exec openmind python -m app.cli shares list --expired
docker compose exec openmind python -m app.cli shares delete <share-token> --yes
docker compose exec openmind python -m app.cli shares purge-expired --dry-run
docker compose exec openmind python -m app.cli shares purge-expired --yes
docker compose exec openmind python -m app.cli backup /app/data/backups/openmind.db
docker compose exec openmind python -m app.cli restore /app/data/backups/openmind.db --yes
```
| Commande | Role |
|---|---|
| `stats` | Nombre de partages (total, actifs, expires), capacite, taille de la base, plus ancien et plus recent |
| `shares list` | Listage. Options `--limit`, `--active`, `--expired` |
| `shares delete <token>` | Suppression administrative, sans jeton de gestion. Demande confirmation sauf `--yes` |
| `shares purge-expired` | Supprime physiquement les partages expires. `--dry-run` affiche sans rien supprimer |
| `backup <chemin>` | Sauvegarde a chaud via l'API SQLite. `--yes` ecrase un fichier existant |
| `restore <chemin>` | Restauration prudente : verification, copie de securite, puis remplacement |
`backup` accepte `--keep N` : apres ecriture, seules les `N` sauvegardes les
plus recentes du meme dossier sont conservees, la nouvelle comprise. Sans
`--keep`, rien n'est jamais supprime.
### Automatiser la purge et la sauvegarde
OpenMind n'embarque volontairement **aucun planificateur** : ces commandes
s'appellent depuis l'outillage de la machine.
Avec cron, une entree quotidienne :
```cron
# Sauvegarde a 3h15, sept jours de retention
15 3 * * * cd /srv/openmind && docker compose exec -T openmind python -m app.cli backup /app/data/backups/openmind-$(date +\%F).db --yes --keep 7
# Purge des partages expires a 3h30
30 3 * * * cd /srv/openmind && docker compose exec -T openmind python -m app.cli shares purge-expired --yes
```
Dans un fichier cron, `%` doit etre echappe : d'ou `\%F`.
Avec systemd, un service et son minuteur :
```ini
# /etc/systemd/system/openmind-backup.service
[Unit]
Description=Sauvegarde OpenMind
[Service]
Type=oneshot
WorkingDirectory=/srv/openmind
ExecStart=/usr/bin/docker compose exec -T openmind python -m app.cli backup /app/data/backups/openmind-daily.db --yes --keep 7
ExecStart=/usr/bin/docker compose exec -T openmind python -m app.cli shares purge-expired --yes
```
```ini
# /etc/systemd/system/openmind-backup.timer
[Unit]
Description=Sauvegarde quotidienne d'OpenMind
[Timer]
OnCalendar=daily
Persistent=true
[Install]
WantedBy=timers.target
```
```bash
systemctl enable --now openmind-backup.timer
```
Les fichiers produits arrivent dans `./data/backups/` sur l'hote : copiez-les
hors de la machine si vous voulez une vraie sauvegarde.
Aucune commande n'affiche le jeton de gestion, son hash, ni le contenu des
cartes. Les jetons de partage y sont masques (`sbcY...BCyw`) : suffisant pour
identifier une ligne, insuffisant pour ouvrir le lien.
---
## Deploiement avec Portainer
OpenMind se deploie comme une **stack** Portainer construite directement depuis
le depot Git. Aucun registre d'images n'est necessaire : Portainer clone le
depot et lance le build multi-etapes.
### 1. Creer la stack
*Stacks* -> *Add stack* -> onglet **Repository** :
| Champ | Valeur |
|---|---|
| Name | `openmind` |
| Repository URL | l'URL de clone du depot |
| Repository reference | `refs/heads/main` |
| Compose path | `docker-compose.yml` |
| Authentication | uniquement si le depot est prive |
Le premier deploiement compile le frontend puis l'image Python : comptez
plusieurs minutes. Les suivants reutilisent le cache de build.
### 2. Renseigner les variables
Le fichier `.env` n'est **pas** dans le depot : c'est normal, il contient la
configuration de votre instance. Utilisez la section **Environment variables**
de la stack, qui joue exactement le meme role. Toutes les variables ont une
valeur par defaut : ne declarez que ce que vous changez.
Pour une instance publique derriere un reverse proxy :
| Variable | Valeur |
|---|---|
| `APP_BIND` | `127.0.0.1` si le proxy tourne sur l'hote |
| `TRUST_PROXY_HEADERS` | `true` |
| `TRUSTED_PROXY_CIDRS` | le reseau d'ou arrivent les connexions du proxy |
| `DEFAULT_SHARE_EXPIRY_DAYS` | `30` |
| `MAX_SHARED_MAPS` | `10000` |
Rappel : `TRUST_PROXY_HEADERS=true` sans `TRUSTED_PROXY_CIDRS` **empeche le
demarrage**, volontairement. Si la stack ne demarre pas, la raison est dans les
logs du conteneur, en clair.
### 3. Le dossier de donnees
Le compose monte `./data` a cote du fichier compose, c'est-a-dire dans le
repertoire de la stack cree par Portainer, typiquement :
```
/var/lib/docker/volumes/portainer_data/_data/compose/<id-de-la-stack>/data/
```
Ce dossier contient `openmind.db` et vos sauvegardes : **c'est lui qu'il faut
sauvegarder**. Si vous preferez un emplacement stable et explicite, remplacez la
ligne de volume par un chemin absolu de l'hote :
```yaml
volumes:
- /srv/openmind/data:/app/data
```
Le conteneur tourne sous l'uid **10001**. Si vous creez le dossier vous-meme,
donnez-lui le bon proprietaire, sinon SQLite ne pourra pas ecrire :
```bash
sudo mkdir -p /srv/openmind/data
sudo chown -R 10001:10001 /srv/openmind/data
```
### 4. Mettre a jour
*Stacks* -> `openmind` -> **Pull and redeploy**, en cochant *Re-pull image and
redeploy* pour reconstruire. Les migrations de schema s'appliquent seules au
demarrage ; les partages existants sont conserves.
### 5. Administration
Portainer donne acces a la console du conteneur (*Containers* -> `openmind` ->
*Console*, commande `/bin/sh`). Toutes les commandes d'administration y
fonctionnent telles quelles :
```bash
python -m app.cli stats
python -m app.cli shares purge-expired --dry-run
python -m app.cli backup /app/data/backups/openmind.db --yes --keep 7
```
Pour automatiser sauvegarde et purge, gardez cron ou un timer systemd sur
l'hote : `docker exec openmind python -m app.cli ...`.
### Si le proxy tourne aussi dans Docker
Retirez la publication de port et placez les deux conteneurs sur un reseau
commun. Dans Portainer, ajoutez ces lignes a la stack :
```yaml
services:
openmind:
ports: !reset []
networks: [proxy]
networks:
proxy:
external: true
```
---
## Deploiement derriere un reverse proxy
Le conteneur ecoute simplement sur `8080` en HTTP. Le TLS est termine par le
reverse proxy ; OpenMind n'embarque ni certificat ni Traefik, et aucune solution
n'est imposee.
Points a respecter :
1. Transmettre l'hote et le protocole d'origine.
2. **Ne pas publier 8080 sur Internet.** Posez `APP_BIND=127.0.0.1` dans `.env`
si le proxy tourne sur l'hote : OpenMind n'est alors joignable que par lui.
Si le proxy tourne dans Docker, retirez entierement la section `ports` et
placez les deux conteneurs sur un reseau Docker commun.
3. Activer `TRUST_PROXY_HEADERS=true` **et** renseigner `TRUSTED_PROXY_CIDRS`
avec les reseaux d'ou viennent les connexions du proxy. Les deux vont
ensemble : activer la confiance sans declarer de reseau arrete le demarrage.
4. Ne pas mettre en cache `/api/`.
### Comment l'adresse du client est determinee
`X-Forwarded-For` est un en-tete que n'importe qui peut ecrire. OpenMind ne le
croit qu'a deux conditions cumulees :
| `TRUST_PROXY_HEADERS` | Connexion venant d'un reseau de `TRUSTED_PROXY_CIDRS` | Adresse retenue |
|---|---|---|
| `false` | peu importe | adresse reelle de la socket |
| `true` | non | adresse reelle de la socket |
| `true` | oui | derniere valeur de `X-Forwarded-For`, sinon `X-Real-IP`, sinon la socket |
OpenMind suppose **un reverse proxy frontal de confiance**. Un proxy
correctement configure (`$proxy_add_x_forwarded_for` chez Nginx) ajoute a la fin
de la chaine l'adresse qu'il a reellement vue : c'est donc la derniere valeur
qui fait foi, les precedentes pouvant avoir ete forgees par le client. Aucun
mecanisme multi-sauts n'est prevu ; avec plusieurs proxies en cascade, declarez
uniquement celui qui est directement devant OpenMind.
L'adresse retenue est normalisee avec le module standard `ipaddress` : les
differentes ecritures d'une meme IPv6 (`2001:db8::1`,
`2001:0db8:0000:...:0001`, `2001:DB8::1`) donnent une seule cle de limitation,
et il ne suffit donc pas de reecrire son adresse autrement pour repartir avec un
compteur neuf.
Aucune variable `PUBLIC_BASE_URL` n'est necessaire : le serveur ne fabrique
jamais d'URL absolue (il renvoie un chemin relatif `/s/<token>`), et le lien
affiche est construit dans le navigateur a partir de `window.location.origin`.
Un utilisateur sur `https://mind.example.com` obtient donc un lien sur ce
domaine, sans configuration.
Exemple Nginx :
```nginx
server {
listen 443 ssl;
server_name mind.example.com;
# certificats geres par votre outillage habituel
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 12m; # >= MAX_MAP_SIZE_MB
}
}
```
Cote OpenMind, dans `.env` :
```ini
APP_BIND=127.0.0.1
TRUST_PROXY_HEADERS=true
TRUSTED_PROXY_CIDRS=127.0.0.1/32,::1/128
```
Exemple Traefik (labels sur le service `openmind` du `docker-compose.yml`) :
```yaml
# docker-compose.override.yml — Traefik reste hors du projet.
services:
openmind:
# Plus aucun port publie : seul Traefik joint le conteneur.
ports: !reset []
networks: [proxy]
environment:
TRUST_PROXY_HEADERS: "true"
TRUSTED_PROXY_CIDRS: "172.16.0.0/12" # reseau du conteneur Traefik
labels:
- traefik.enable=true
- traefik.docker.network=proxy
- traefik.http.routers.openmind.rule=Host(`mind.example.com`)
- traefik.http.routers.openmind.entrypoints=websecure
- traefik.http.routers.openmind.tls.certresolver=letsencrypt
- traefik.http.services.openmind.loadbalancer.server.port=8080
networks:
proxy:
external: true
```
Traefik transmet `X-Forwarded-For`, `X-Forwarded-Proto` et `X-Real-IP` par
defaut. Adaptez `TRUSTED_PROXY_CIDRS` au sous-reseau reel du reseau `proxy`
(`docker network inspect proxy`).
Les journaux du reverse proxy contiennent les URL demandees, jetons de partage
compris. Si cela vous derange, configurez leur format ou leur retention : c'est
independant d'OpenMind.
---
## Migrations de schema
Le schema SQLite est gere par **Alembic**. Les migrations sont appliquees
automatiquement au demarrage du conteneur, avant qu'uvicorn ne serve la moindre
requete :
```
docker compose up -d -> docker-entrypoint.sh -> alembic upgrade head -> uvicorn
```
Trois cas, tous pris en charge sans intervention :
| Etat de depart | Ce qui se passe |
|---|---|
| Base absente | Toutes les revisions s'appliquent |
| Base creee avant Alembic (`create_all`) | La revision initiale est **estampillee** (aucune donnee touchee), puis les suivantes s'appliquent |
| Base deja a jour | Rien a faire |
Manuellement :
```bash
docker compose exec openmind python -m app.db.migrations # applique head
docker compose exec openmind alembic current # revision courante
docker compose exec openmind alembic history # historique
```
| Revision | Apport |
|---|---|
| `0001_initial_schema` | Table `shared_maps` telle qu'elle existait avant Alembic |
| `0002_share_expiry` | Colonne `expires_at` nullable et son index |
---
## Fonctionnalites ## Fonctionnalites
**Tableau de bord** : creation, import, recherche, date de derniere **Tableau de bord** : creation, import, recherche, date de derniere
@@ -226,11 +690,70 @@ double-clic, undo/redo, copier/coller, duplication, sauvegarde automatique.
Bezier, branches repliables avec compteur d'elements masques. Bezier, branches repliables avec compteur d'elements masques.
**Auto-layout**, uniquement sur demande : mind map droite, mind map gauche, **Auto-layout**, uniquement sur demande : mind map droite, mind map gauche,
arbre horizontal, arbre vertical. mind map bilaterale, arbre horizontal, arbre vertical. L'espacement est
configurable (distance parent/enfant et espacement entre branches), avec les
presets Compact, Normal et Aere. Choisir une disposition ou modifier
l'espacement ne deplace rien : c'est *Reorganiser* qui applique, ce qui protege
les positions placees a la main.
**Proprietes du noeud** : texte, couleurs de fond/texte/bordure, forme **Mind map bilaterale** : la racine reste au centre, les branches de niveau 1 se
(rectangle, arrondi, capsule, cercle), taille du texte, gras, italique, repartissent a gauche et a droite, et tous les descendants heritent du cote de
alignement, icone, URL ouverte dans un nouvel onglet, note. leur branche. La repartition automatique equilibre les deux cotes selon la
taille des sous-arbres ; le cote se change ensuite a la main (*Placer la branche
a gauche / a droite*).
**Relations secondaires** : une connexion visuelle entre deux noeuds
quelconques, independante de la hierarchie. Elle ne modifie ni le parent, ni
l'ordre, ni le repli, et l'auto-layout l'ignore totalement. Libelle libre,
trace (Bezier, courbe, droite), couleur, epaisseur, trait plein ou pointille,
fleches aux deux extremites. Une relation dont une extremite est masquee par un
repli disparait de l'affichage et revient au deploiement.
**Modeles** : galerie locale de huit modeles (carte vide, brainstorming, gestion
de projet, architecture IT, reunion, SWOT, plan d'etude, planification
personnelle). Choisir un modele en cree une copie : l'original n'est jamais
modifie, et la carte obtenue est une carte comme les autres.
**Markdown** : import d'un fichier `.md` ou d'un texte colle, comme nouvelle
carte depuis le tableau de bord ou comme sous-arbre d'un noeud existant. Les
titres et les listes construisent la hierarchie ; les paragraphes et les blocs
de code rejoignent la note du noeud, en texte brut. Export inverse en `.md` :
contenu et hierarchie uniquement, sans positions ni couleurs, avec un
avertissement si la carte contient des relations secondaires.
**Proprietes du noeud**, regroupees en sections repliables (Contenu,
Typographie, Apparence, Mise en page, Branche) : texte, icone, URL, notes ;
police, taille, gras, italique, alignement horizontal et vertical, interligne ;
couleurs de fond/texte/bordure, forme (rectangle, arrondi, capsule, cercle),
ombre ; taille automatique ou personnalisee, largeur, hauteur, padding
horizontal et vertical, retour a la ligne, largeur maximale du texte ; style,
epaisseur et couleur de la branche. Un reglage s'applique a toute la selection,
avec un etat *Mixte* quand les valeurs different.
**Creation rapide** : *Ajouter plusieurs enfants* (une ligne = un noeud, avec
apercu), collage d'une liste multi-ligne directement sur un noeud, import d'une
arborescence indentee (espaces ou tabulations, puces retirees, indentation
incoherente signalee), ajout avant / apres un frere.
**Hierarchie** : ordre explicite des freres (monter, descendre, placer en
premier / en dernier), remontee d'un niveau, *Deplacer vers...* avec recherche
du nouveau parent, suppression d'un noeud en conservant ses enfants.
**Branches** : selectionner, dupliquer, copier / coller comme enfant, deplacer
sur le canvas avec ses descendants, replier / developper une branche, tout
replier, tout developper, replier au-dela d'un niveau donne.
**Mise en page visuelle** : redimensionnement des noeuds a la souris (une seule
annulation par redimensionnement), taille automatique qui epouse le contenu,
alignement et distribution d'une multi-selection, aimantation sur la grille,
guides d'alignement pendant le deplacement, copier / coller / reinitialiser le
style (Ctrl+Maj+C, Ctrl+Maj+V), menu contextuel par clic droit. Le style couvre
les couleurs, la forme, la typographie, la mise en page, la presentation de
l'icone et la branche ; l'icone elle-meme, comme le texte, la note et l'URL,
appartient au contenu du noeud et n'est jamais remplacee par un collage.
**Bibliotheque d'icones** : emojis et icones integrees (Lucide), avec position
(gauche, droite, au-dessus), taille et espacement configurables.
**Themes** : Default, Minimal, Dark, Midnight, Ocean, Forest, Pastel, **Themes** : Default, Minimal, Dark, Midnight, Ocean, Forest, Pastel,
Monochrome, Blueprint. Plus un mode clair/sombre pour l'interface. Changer de Monochrome, Blueprint. Plus un mode clair/sombre pour l'interface. Changer de
@@ -240,7 +763,7 @@ theme ne supprime jamais une personnalisation explicite d'un noeud.
et mise en evidence temporaire du noeud, depliage automatique des branches et mise en evidence temporaire du noeud, depliage automatique des branches
repliees. repliees.
**Exports** : `.mmap` (format natif), PNG (x1, x2, fond transparent), SVG, PDF **Exports** : `.mmap` (format natif), Markdown (`.md`), PNG (x1, x2, fond transparent), SVG, PDF
(A4/A3, portrait/paysage, marges, adapter a une page ou taille reelle, fond, (A4/A3, portrait/paysage, marges, adapter a une page ou taille reelle, fond,
titre, date), impression via `@media print`. titre, date), impression via `@media print`.
@@ -265,6 +788,13 @@ l'application chargee.
| `Ctrl+C` | Copier | | `Ctrl+C` | Copier |
| `Ctrl+V` | Coller | | `Ctrl+V` | Coller |
| `Ctrl+D` | Dupliquer | | `Ctrl+D` | Dupliquer |
| `Ctrl+Maj+C` | Copier le style du noeud (sans son icone) |
| `Ctrl+Maj+V` | Coller le style sur la selection |
| `Maj+Tab` | Remonter le noeud d'un niveau |
| `Maj+Entree` | Ajouter plusieurs enfants |
| `Alt+Haut` / `Alt+Bas` | Monter / descendre parmi les freres |
| `Alt` + glisser | Inverser le mode de deplacement de branche |
| `Ctrl+V` | Coller : noeuds OpenMind, sinon liste de texte en enfants |
| `Ctrl+A` | Tout selectionner | | `Ctrl+A` | Tout selectionner |
| `Ctrl+S` | Sauvegarde explicite | | `Ctrl+S` | Sauvegarde explicite |
| `Ctrl+F` | Rechercher dans la carte | | `Ctrl+F` | Rechercher dans la carte |
@@ -278,6 +808,41 @@ l'application chargee.
Un `.mmap` est un fichier **JSON UTF-8** contenant le document complet de la Un `.mmap` est un fichier **JSON UTF-8** contenant le document complet de la
carte. Il ne contient jamais de jeton de partage. carte. Il ne contient jamais de jeton de partage.
Le format est en **version 3**.
| Version | Apport | Migration a l'import |
|---|---|---|
| v1 | format initial | `style.width` devient `layout.width` |
| v2 | mise en page des noeuds (`layout`, `iconStyle`, `branch`) | l'ordre des freres est materialise depuis leur position dans `nodes` |
| v3 | ordre explicite des freres (`node.order`) | aucune |
| v4 | cote des branches (`node.side`) et relations secondaires (`relations`) | aucune |
Les fichiers v1, v2 et v3 restent lisibles, et les cartes deja enregistrees dans
IndexedDB s'ouvrent sans modification. Le passage en v3 etait justifie : l'ordre
des freres fait partie de la semantique du document, et un lecteur v2
l'ignorerait en reordonnant silencieusement la carte. Les noeuds sont malgre
tout ecrits en parcours de profondeur, freres dans l'ordre, ce qui donne le bon
resultat meme a un lecteur qui ignore `order`.
Le passage en v4 l'est aussi : un lecteur v3 ignorerait `relations` et perdrait
donc une partie du document. La migration v3 -> v4 n'a en revanche rien a
inventer : aucun cote defini signifie "a repartir automatiquement", aucune
relation signifie "aucune". Une carte v3 s'affiche exactement comme avant.
Toute carte qui entre dans l'editeur passe par le meme point d'entree,
`normalizeMapDocument()`, quelle que soit sa provenance :
```
fichier .mmap ┐
IndexedDB ├─> validation -> migration de version -> normalisation -> modele courant
partage ┘
```
La normalisation est **non destructive** : le document fourni n'est jamais
modifie et rien n'est reecrit en base. Une carte v1 restee en IndexedDB s'ouvre
donc exactement comme avant, et n'est reenregistree au format courant que si
vous la modifiez.
```json ```json
{ {
"version": 1, "version": 1,
@@ -292,24 +857,72 @@ carte. Il ne contient jamais de jeton de partage.
"text": "Infrastructure", "text": "Infrastructure",
"position": { "x": 0, "y": 0 }, "position": { "x": 0, "y": 0 },
"parentId": null, "parentId": null,
"order": 0,
"side": "right",
"collapsed": false, "collapsed": false,
"icon": "\ud83d\udda5\ufe0f", "icon": "\ud83d\udda5\ufe0f",
"url": "https://exemple.fr", "url": "https://exemple.fr",
"note": "Texte libre", "note": "Texte libre",
"style": { "backgroundColor": "#eef2ff", "shape": "rounded" } "style": {
"backgroundColor": "#eef2ff",
"shape": "rounded",
"textAlign": "center",
"verticalAlign": "middle",
"lineHeight": 1.35,
"fontFamily": "sans",
"shadow": "soft"
},
"layout": {
"widthMode": "fixed",
"width": 240,
"heightMode": "auto",
"paddingX": 14,
"paddingY": 8,
"wrap": true,
"maxTextWidth": 260
},
"iconStyle": { "kind": "emoji", "position": "left", "size": 16, "gap": 8 },
"branch": { "style": "bezier", "width": 2, "color": "#94a3b8" }
} }
], ],
"edges": [{ "id": "e-a-b", "source": "a", "target": "b" }], "edges": [{ "id": "e-a-b", "source": "a", "target": "b" }],
"relations": [
{
"id": "r-uuid",
"sourceId": "a",
"targetId": "b",
"label": "depend de",
"style": "bezier",
"color": "#a855f7",
"width": 2,
"dashed": true,
"markerEnd": "arrow"
}
],
"viewport": { "x": 0, "y": 0, "zoom": 1 }, "viewport": { "x": 0, "y": 0, "zoom": 1 },
"settings": { "settings": {
"theme": "default", "theme": "default",
"layout": "mindmap-right", "layout": "mindmap-right",
"branchStyle": "bezier", "branchStyle": "bezier",
"showGrid": true "showGrid": true,
"layoutSpacing": { "parentGap": 100, "siblingGap": 50 },
"snapToGrid": false,
"snapGrid": 20,
"alignmentGuides": true,
"dragMovesBranch": false
} }
} }
``` ```
Deux precisions sur ce document :
- `edges` est **derive** de `parentId` : il est recalcule a chaque modification
et n'est jamais une source de verite. `relations`, au contraire, est une
donnee a part entiere, que rien ne permet de deduire de la hierarchie.
- `side` n'a de sens que sur un enfant direct d'une racine. Tous ses descendants
heritent du cote de leur branche, ce qui evite d'entretenir la meme
information sur chaque noeud.
A l'import, le fichier est **valide et normalise** : structure, version, champs A l'import, le fichier est **valide et normalise** : structure, version, champs
essentiels, limite de noeuds, assainissement des URL (`javascript:` et `data:` essentiels, limite de noeuds, assainissement des URL (`javascript:` et `data:`
sont supprimes), rejet des styles invalides. Le contenu n'est jamais execute ni sont supprimes), rejet des styles invalides. Le contenu n'est jamais execute ni
@@ -395,12 +1008,24 @@ docker run --rm -v "$PWD/frontend:/app" -w /app node:20-alpine sh -c "npm ci &&
Les tests backend couvrent : healthcheck, creation, lecture, mise a jour et Les tests backend couvrent : healthcheck, creation, lecture, mise a jour et
suppression d'un partage, refus avec un mauvais jeton de gestion, refus sans suppression d'un partage, refus avec un mauvais jeton de gestion, refus sans
jeton, partage inexistant, payload invalide, limites de taille et de noeuds, jeton, partage inexistant, payload invalide, limites de taille et de noeuds,
non-divulgation du jeton de gestion, repli SPA. non-divulgation du jeton de gestion, repli SPA, acceptation des documents v3 et
v4 (ordre des freres, cotes, relations) et refus d'une relation incoherente.
S'y ajoutent, pour l'exposition publique : limitation de debit (creation,
lecture, exemption du healthcheck, desactivation), coupure du partage, capacite
maximale, taille propre aux partages, expiration et prolongation, verification
d'une cle de gestion, resolution de l'adresse client derriere un proxy,
en-tetes d'indexation et de referrer, redaction des jetons dans les journaux,
migrations Alembic sur base neuve et sur base d'avant Alembic, et l'ensemble des
commandes de la CLI d'administration.
Les tests frontend couvrent : stockage IndexedDB, serialisation et validation Les tests frontend couvrent : stockage IndexedDB, serialisation et validation
d'import, migrations, store Zustand (creation, suppression de branche, d'import, migrations v1 -> v4, non-mutation et idempotence de la normalisation,
undo/redo, groupement du glisser-deposer, copier/coller, duplication, detection store Zustand (creation, suppression de branche, undo/redo, groupement du
de boucle, recherche, auto-layout, lecture seule), assainissement des URL. glisser-deposer, copier/coller, duplication, detection de boucle, recherche,
auto-layout, lecture seule), repartition et heritage des cotes, auto-layout
bilateral, relations secondaires (creation, refus, style, repli, duplication,
independance vis-a-vis de la hierarchie), analyse et export Markdown, galerie de
modeles, assainissement des URL.
--- ---
@@ -418,23 +1043,26 @@ de boucle, recherche, auto-layout, lecture seule), assainissement des URL.
├── backend/ ├── backend/
│ ├── requirements.txt │ ├── requirements.txt
│ ├── pyproject.toml # configuration ruff et pytest │ ├── pyproject.toml # configuration ruff et pytest
│ ├── alembic.ini # configuration Alembic (URL prise dans DATABASE_URL)
│ ├── migrations/ # revisions de schema
│ ├── app/ │ ├── app/
│ │ ├── main.py # assemblage uniquement │ │ ├── main.py # assemblage uniquement
│ │ ├── cli.py # administration locale : stats, partages, sauvegarde
│ │ ├── config.py │ │ ├── config.py
│ │ ├── logging_config.py │ │ ├── logging_config.py # format des logs et redaction des jetons
│ │ ├── middleware.py # en-tetes de securite, limite de taille │ │ ├── middleware.py # en-tetes de securite, cache, limite de taille
│ │ ├── web.py # service des fichiers statiques du frontend │ │ ├── web.py # service des fichiers statiques du frontend
│ │ ├── api/ # routes health, config, shares │ │ ├── api/ # routes health, ready, config, shares
│ │ ├── db/ # moteur, session, initialisation du schema │ │ ├── db/ # moteur, session, migrations
│ │ ├── models/ # SharedMap (SQLAlchemy) │ │ ├── models/ # SharedMap (SQLAlchemy)
│ │ ├── schemas/ # validation Pydantic │ │ ├── schemas/ # validation Pydantic
│ │ └── services/ # jetons, logique de partage, erreurs metier │ │ └── services/ # jetons, partage, limitation de debit, adresse client
│ └── tests/ │ └── tests/
└── frontend/ └── frontend/
├── index.html ├── index.html
├── public/ # manifest PWA, service worker, icones ├── public/ # manifest PWA, service worker, icones
└── src/ └── src/
├── config/app.ts # nom du produit et constantes ├── config/ # nom du produit, constantes, texte de confidentialite
├── types/map.ts # modele metier versionne ├── types/map.ts # modele metier versionne
├── db/ # schema Dexie ├── db/ # schema Dexie
├── services/ # depots, serialisation, client API ├── services/ # depots, serialisation, client API
@@ -442,8 +1070,10 @@ de boucle, recherche, auto-layout, lecture seule), assainissement des URL.
├── hooks/ # routeur minimal, debounce ├── hooks/ # routeur minimal, debounce
├── components/ # briques d'interface reutilisables ├── components/ # briques d'interface reutilisables
├── features/ ├── features/
│ ├── editor/ # canvas, noeud, barre d'outils, panneaux, layout, themes │ ├── editor/ # canvas, noeud, barre d'outils, panneaux, layout, bilateral, relations, themes
│ ├── templates/ # galerie et definitions des modeles
│ ├── maps/ # tableau de bord │ ├── maps/ # tableau de bord
│ ├── privacy/ # page /privacy
│ ├── export/ # PNG, SVG, PDF, impression │ ├── export/ # PNG, SVG, PDF, impression
│ └── sharing/ # dialogue de partage, page publique │ └── sharing/ # dialogue de partage, page publique
├── utils/ ├── utils/
@@ -490,14 +1120,78 @@ de boucle, recherche, auto-layout, lecture seule), assainissement des URL.
- **Gestion d'un partage** : le jeton de gestion vit dans l'IndexedDB du - **Gestion d'un partage** : le jeton de gestion vit dans l'IndexedDB du
navigateur d'origine. Depuis un autre appareil, un partage ne peut etre ni mis navigateur d'origine. Depuis un autre appareil, un partage ne peut etre ni mis
a jour ni supprime. a jour ni supprime.
- **Dispositions reservees** : `mindmap-both`, `organization` et `freeform` - **Dispositions reservees** : `organization` et `freeform` existent dans le
existent dans le modele de donnees mais ne sont pas proposees dans modele de donnees mais ne sont pas proposees dans l'interface.
l'interface. - **Relations et export Markdown** : l'export `.md` decrit le contenu et la
hierarchie. Les relations secondaires n'y ont pas de representation fidele et
ne sont pas inventees ; un message le signale au moment de l'export.
- **Relations et minimap** : la minimap n'affiche que les noeuds. Les relations
y sont invisibles, comme les branches hierarchiques.
- **Doublon de relation** : deux relations identiques (meme source vers meme
cible) se superposeraient, la seconde serait donc invisible et impossible a
selectionner. Une seule est acceptee par couple ; le sens inverse, lui, reste
autorise.
- **Relation dupliquee** : dupliquer ou coller une branche reporte les relations
dont les deux extremites sont dans la branche. Une relation qui sort vers un
noeud exterieur n'est pas recreee : rien ne dit que la copie doit viser la
meme cible, et le deviner creerait un lien non demande.
- **Bilaterale et racines multiples** : le recalage des deux cotes se fait sur
la premiere racine. Une carte a plusieurs racines reste affichee, mais les
racines suivantes ne sont pas recentrees individuellement.
- **Types de noeuds reserves** : `note`, `image`, `group`, `link` et `task` sont - **Types de noeuds reserves** : `note`, `image`, `group`, `link` et `task` sont
acceptes par le format, mais la V1 ne produit que `root` et `standard`. acceptes par le format, mais la V1 ne produit que `root` et `standard`.
- **Migrations de schema SQLite** : la V1 initialise le schema avec - **Limitation de debit en memoire** : les compteurs vivent dans le processus.
`create_all`, une seule table. Alembic n'est pas utilise ; il faudra Un redemarrage du conteneur les remet a zero, et plusieurs workers uvicorn
l'introduire au premier changement de schema. auraient chacun les leurs. C'est un garde-fou contre la saturation triviale
d'une instance publique, pas un quota strict. Un reverse proxy peut ajouter sa
propre limitation si le besoin est plus exigeant.
- **Capacite maximale** : le comptage et l'insertion sont serialises par un
verrou de processus, ce qui empeche deux creations simultanees de franchir
ensemble la derniere place. Cette garantie vaut pour l'architecture livree :
un seul service applicatif devant un SQLite local. Avec plusieurs workers ou
plusieurs conteneurs, `MAX_SHARED_MAPS` redeviendrait un garde-fou approximatif
et non un quota transactionnel.
- **Un seul niveau de proxy** : OpenMind suppose un reverse proxy frontal de
confiance et lit la derniere valeur de `X-Forwarded-For`. Avec plusieurs
proxies en cascade, declarez uniquement celui qui est directement devant
l'application ; aucun mecanisme multi-sauts n'est prevu.
- **Partage expire et information** : un lien expire repond `410` et non `404`,
ce qui revele qu'un partage a existe a cette adresse. C'est assume : sans cela,
la page publique ne pourrait pas expliquer la difference entre un lien expire
et un lien erronne. Rien d'autre n'est divulgue.
- **Purge des partages expires** : elle n'est pas automatique. Un partage expire
cesse d'etre lisible mais reste en base jusqu'a un
`shares purge-expired`, ce qui laisse a son proprietaire la possibilite de le
prolonger. Planifiez la commande si vous voulez liberer l'espace regulierement.
- **Ecriture concurrente sur la base SQLite** : ne modifiez pas `openmind.db`
depuis l'hote pendant que le conteneur tourne. Les fichiers `-wal` / `-shm`
sont partages entre processus ; a travers un bind mount, une ecriture externe
peut etre perdue ou desynchroniser l'application du fichier. Utilisez la CLI
dans le conteneur, ou arretez le conteneur.
- **Guides d'alignement** : ils apparaissent pendant un deplacement lorsqu'un
bord ou un centre coincide (a 6 px pres) avec celui d'un autre noeud. Ils sont
purement visuels : ils n'aimantent pas le noeud. Pour aimanter, activez
*Aimanter sur la grille*.
- **Alignement vertical** : il n'a d'effet visible que si le noeud est plus haut
que son texte, c'est-a-dire avec une hauteur fixe ou un padding vertical
important.
- **Cercle** : un noeud circulaire garde toujours un rapport 1:1. Modifier sa
largeur ajuste sa hauteur et inversement ; convertir un noeud rectangulaire en
cercle le ramene a un carre. Il n'est donc pas possible d'obtenir un ovale.
- **Plusieurs racines** : le modele autorise depuis la v1 une carte a plusieurs
racines, et certaines cartes existantes en dependent ; ce n'est donc pas
interdit. Une carte mentale classique n'en a qu'une, et l'interface pousse
dans ce sens : *Detacher* est une action explicite, et un noeud detache
devient une racine a part entiere. Un noeud ne peut jamais devenir son propre
parent ni celui d'un de ses ancetres.
- **Reordonner ne deplace pas les noeuds sur le canvas** : l'ordre est logique.
Il se voit immediatement dans l'ordre des liens et dans le resultat de
*Reorganiser* ; les positions libres, elles, restent celles choisies par
l'utilisateur.
- **Deplacement sur une tres grande carte** : sur une carte de ~180 noeuds, une
frame de glisser-deposer coute environ 60 a 80 ms car React Flow recalcule
l'ensemble des noeuds controles. Le deplacement reste utilisable mais moins
fluide que sur une carte de taille courante.
- **Mobile** : consultation et edition basique fonctionnent, mais l'interface - **Mobile** : consultation et edition basique fonctionnent, mais l'interface
est concue pour desktop et tablette. est concue pour desktop et tablette.
- **Aucun compte utilisateur** : c'est voulu. Pas de connexion, pas de - **Aucun compte utilisateur** : c'est voulu. Pas de connexion, pas de
@@ -520,6 +1214,7 @@ n'est utilisee.
| dagre | MIT | | dagre | MIT |
| html-to-image | MIT | | html-to-image | MIT |
| jsPDF | MIT | | jsPDF | MIT |
| lucide-react | ISC |
| Vite, Tailwind CSS, TypeScript, ESLint, Vitest | MIT | | Vite, Tailwind CSS, TypeScript, ESLint, Vitest | MIT |
| FastAPI, Starlette, Pydantic, SQLAlchemy, Uvicorn | MIT / BSD | | FastAPI, Starlette, Pydantic, SQLAlchemy, Uvicorn | MIT / BSD |

42
backend/alembic.ini Normal file
View File

@@ -0,0 +1,42 @@
# Configuration Alembic d'OpenMind.
# L'URL de la base n'est PAS ecrite ici : elle vient de DATABASE_URL, via
# migrations/env.py, afin qu'il n'existe qu'une seule source de configuration.
[alembic]
script_location = migrations
prepend_sys_path = .
version_path_separator = os
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stdout,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

View File

@@ -4,12 +4,14 @@ from __future__ import annotations
from typing import Annotated from typing import Annotated
from fastapi import Depends, Header from fastapi import Depends, Header, Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.config import Settings, get_settings from app.config import Settings, get_settings
from app.db.session import get_db from app.db.session import get_db
from app.services.errors import ShareForbiddenError from app.services.client_ip import resolve_client_ip
from app.services.errors import RateLimitedError, ShareForbiddenError, SharingDisabledError
from app.services.rate_limit import limiter, parse_rate
MANAGEMENT_TOKEN_HEADER = "X-Management-Token" MANAGEMENT_TOKEN_HEADER = "X-Management-Token"
@@ -27,3 +29,45 @@ def require_management_token(
ManagementToken = Annotated[str, Depends(require_management_token)] ManagementToken = Annotated[str, Depends(require_management_token)]
def require_sharing_enabled(settings: AppSettings) -> None:
"""Coupe toute l'API de partage quand l'instance est privee.
Les cartes locales continuent de fonctionner : seul le service serveur est
indisponible, et il le dit clairement.
"""
if not settings.sharing_enabled:
raise SharingDisabledError()
SharingEnabled = Annotated[None, Depends(require_sharing_enabled)]
def _rate_limit(bucket: str, spec_name: str): # type: ignore[no-untyped-def]
"""Fabrique une dependance de limitation pour une famille d'endpoints.
La limitation est posee sur les routes concernees, jamais en middleware
global : /api/health et /api/ready ne peuvent donc pas etre bloques, ce qui
garantit que le healthcheck du conteneur reste fiable sous charge.
"""
def dependency(request: Request, settings: AppSettings) -> None:
rule = parse_rate(getattr(settings, spec_name))
if rule is None:
return
client = resolve_client_ip(
request,
trust_proxy_headers=settings.trust_proxy_headers,
trusted_networks=settings.trusted_proxy_networks,
)
retry_after = limiter.check(f"{bucket}:{client}", rule)
if retry_after is not None:
raise RateLimitedError(retry_after)
return dependency
RateLimitCreate = Annotated[None, Depends(_rate_limit("share:create", "share_create_rate_limit"))]
RateLimitRead = Annotated[None, Depends(_rate_limit("share:read", "share_read_rate_limit"))]
RateLimitWrite = Annotated[None, Depends(_rate_limit("share:write", "share_write_rate_limit"))]

View File

@@ -1,19 +1,48 @@
"""Endpoints de sante et de configuration publique.""" """Endpoints de sante et de configuration publique.
Aucune limitation de debit n'est posee ici : le healthcheck du conteneur doit
rester fiable meme quand une instance subit un afflux de requetes.
"""
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter import logging
from app.api.deps import AppSettings from fastapi import APIRouter
from fastapi.responses import JSONResponse
from sqlalchemy import text
from app.api.deps import AppSettings, DbSession
logger = logging.getLogger(__name__)
router = APIRouter(tags=["system"]) router = APIRouter(tags=["system"])
# Durees proposees dans l'interface. `None` = permanent.
SHARE_EXPIRY_OPTIONS: list[int | None] = [1, 7, 30, 90, None]
@router.get("/health", summary="Etat du service") @router.get("/health", summary="Etat du service")
def health() -> dict[str, str]: def health() -> dict[str, str]:
return {"status": "ok"} return {"status": "ok"}
@router.get("/ready", summary="Le service peut-il repondre : base de donnees joignable")
def ready(session: DbSession): # type: ignore[no-untyped-def]
"""Complete /health : verifie que SQLite repond reellement.
Utile derriere un orchestrateur qui doit distinguer "processus vivant" de
"capable de servir", par exemple quand le volume de donnees est absent ou
en lecture seule.
"""
try:
session.execute(text("SELECT 1"))
except Exception:
logger.exception("Base de donnees injoignable")
return JSONResponse(status_code=503, content={"status": "unavailable"})
return {"status": "ready"}
@router.get("/config", summary="Configuration publique exposee au frontend") @router.get("/config", summary="Configuration publique exposee au frontend")
def public_config(settings: AppSettings) -> dict[str, object]: def public_config(settings: AppSettings) -> dict[str, object]:
"""Aucun secret ici : uniquement ce dont l'interface a besoin.""" """Aucun secret ici : uniquement ce dont l'interface a besoin."""
@@ -21,5 +50,9 @@ def public_config(settings: AppSettings) -> dict[str, object]:
"appName": settings.app_name, "appName": settings.app_name,
"maxNodes": settings.max_nodes, "maxNodes": settings.max_nodes,
"maxMapSizeMb": settings.max_map_size_mb, "maxMapSizeMb": settings.max_map_size_mb,
"sharingEnabled": True, "maxShareSizeMb": settings.max_share_size_mb_effective,
"sharingEnabled": settings.sharing_enabled,
"allowPermanentShares": settings.allow_permanent_shares,
"defaultShareExpiryDays": settings.default_share_expiry_days,
"shareExpiryOptions": SHARE_EXPIRY_OPTIONS,
} }

View File

@@ -4,10 +4,19 @@ from __future__ import annotations
from fastapi import APIRouter, Response, status from fastapi import APIRouter, Response, status
from app.api.deps import DbSession, ManagementToken from app.api.deps import (
DbSession,
ManagementToken,
RateLimitCreate,
RateLimitRead,
RateLimitWrite,
SharingEnabled,
)
from app.schemas.share import ( from app.schemas.share import (
ShareCreatedOut, ShareCreatedOut,
ShareCreateIn, ShareCreateIn,
ShareExpiryIn,
ShareOwnerOut,
SharePublicOut, SharePublicOut,
ShareUpdatedOut, ShareUpdatedOut,
) )
@@ -26,9 +35,21 @@ def _share_path(share_token: str) -> str:
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
summary="Publier un snapshot de carte et obtenir un lien non repertorie", summary="Publier un snapshot de carte et obtenir un lien non repertorie",
) )
def create_share(payload: ShareCreateIn, session: DbSession) -> ShareCreatedOut: def create_share(
payload: ShareCreateIn,
session: DbSession,
_sharing: SharingEnabled,
_rate: RateLimitCreate,
) -> ShareCreatedOut:
# `permanent` distingue "aucune preference" (defaut de l'instance) de
# "explicitement sans expiration", que le JSON seul ne permet pas de separer.
explicit = payload.permanent or payload.expires_in_days is not None
share, management_token = share_service.create_share( share, management_token = share_service.create_share(
session, title=payload.title, document=payload.map session,
title=payload.title,
document=payload.map,
expires_in_days=None if payload.permanent else payload.expires_in_days,
expiry_explicit=explicit,
) )
return ShareCreatedOut( return ShareCreatedOut(
share_token=share.share_token, share_token=share.share_token,
@@ -37,6 +58,7 @@ def create_share(payload: ShareCreateIn, session: DbSession) -> ShareCreatedOut:
title=share.title, title=share.title,
created_at=share.created_at, created_at=share.created_at,
updated_at=share.updated_at, updated_at=share.updated_at,
expires_at=share.expires_at,
) )
@@ -45,7 +67,12 @@ def create_share(payload: ShareCreateIn, session: DbSession) -> ShareCreatedOut:
response_model=SharePublicOut, response_model=SharePublicOut,
summary="Lire une carte partagee (public, lecture seule)", summary="Lire une carte partagee (public, lecture seule)",
) )
def read_share(share_token: str, session: DbSession) -> SharePublicOut: def read_share(
share_token: str,
session: DbSession,
_sharing: SharingEnabled,
_rate: RateLimitRead,
) -> SharePublicOut:
share = share_service.get_share(session, share_token) share = share_service.get_share(session, share_token)
return SharePublicOut( return SharePublicOut(
share_token=share.share_token, share_token=share.share_token,
@@ -54,6 +81,7 @@ def read_share(share_token: str, session: DbSession) -> SharePublicOut:
map=share_service.load_map_data(share), map=share_service.load_map_data(share),
created_at=share.created_at, created_at=share.created_at,
updated_at=share.updated_at, updated_at=share.updated_at,
expires_at=share.expires_at,
) )
@@ -67,6 +95,8 @@ def update_share(
payload: ShareCreateIn, payload: ShareCreateIn,
session: DbSession, session: DbSession,
management_token: ManagementToken, management_token: ManagementToken,
_sharing: SharingEnabled,
_rate: RateLimitWrite,
) -> ShareUpdatedOut: ) -> ShareUpdatedOut:
share = share_service.update_share( share = share_service.update_share(
session, session,
@@ -80,6 +110,64 @@ def update_share(
share_path=_share_path(share.share_token), share_path=_share_path(share.share_token),
title=share.title, title=share.title,
updated_at=share.updated_at, updated_at=share.updated_at,
expires_at=share.expires_at,
)
@router.patch(
"/{share_token}",
response_model=ShareOwnerOut,
summary="Modifier l'expiration du partage (necessite le jeton de gestion)",
)
def update_expiry(
share_token: str,
payload: ShareExpiryIn,
session: DbSession,
management_token: ManagementToken,
_sharing: SharingEnabled,
_rate: RateLimitWrite,
) -> ShareOwnerOut:
share = share_service.set_expiry(
session,
share_token,
management_token=management_token,
expires_in_days=None if payload.permanent else payload.expires_in_days,
)
return ShareOwnerOut(
share_token=share.share_token,
title=share.title,
created_at=share.created_at,
updated_at=share.updated_at,
expires_at=share.expires_at,
)
@router.post(
"/{share_token}/verify",
response_model=ShareOwnerOut,
summary="Verifier qu'une cle de gestion correspond a ce partage",
)
def verify_share(
share_token: str,
session: DbSession,
management_token: ManagementToken,
_sharing: SharingEnabled,
_rate: RateLimitWrite,
) -> ShareOwnerOut:
"""Permet de reimporter une cle de gestion depuis un autre navigateur.
Ne renvoie aucune donnee de carte : seulement de quoi reconstituer la fiche
de partage locale.
"""
share = share_service.verify_management_token(
session, share_token, management_token=management_token
)
return ShareOwnerOut(
share_token=share.share_token,
title=share.title,
created_at=share.created_at,
updated_at=share.updated_at,
expires_at=share.expires_at,
) )
@@ -93,6 +181,8 @@ def delete_share(
share_token: str, share_token: str,
session: DbSession, session: DbSession,
management_token: ManagementToken, management_token: ManagementToken,
_sharing: SharingEnabled,
_rate: RateLimitWrite,
) -> Response: ) -> Response:
share_service.delete_share(session, share_token, management_token=management_token) share_service.delete_share(session, share_token, management_token=management_token)
return Response(status_code=status.HTTP_204_NO_CONTENT) return Response(status_code=status.HTTP_204_NO_CONTENT)

373
backend/app/cli.py Normal file
View File

@@ -0,0 +1,373 @@
"""Administration locale d'une instance OpenMind.
Volontairement une CLI et non une interface web : il n'existe aucune page
/admin exposee sur Internet, et ces commandes ne s'utilisent qu'avec un acces
au conteneur.
docker compose exec openmind python -m app.cli stats
docker compose exec openmind python -m app.cli shares list --limit 20
docker compose exec openmind python -m app.cli shares delete <share-token>
docker compose exec openmind python -m app.cli shares purge-expired --dry-run
docker compose exec openmind python -m app.cli backup /app/data/backups/openmind.db
Aucune commande n'affiche ni le jeton de gestion, ni son hash, ni le contenu
des cartes.
"""
from __future__ import annotations
import argparse
import sqlite3
import sys
from datetime import datetime
from pathlib import Path
from sqlalchemy import func, select
from app.config import get_settings
from app.db.session import SessionLocal
from app.models.share import SharedMap, utcnow
_SQLITE_PREFIX = "sqlite:///"
def database_path() -> Path | None:
"""Chemin du fichier SQLite, ou None si la base n'est pas un fichier."""
url = get_settings().database_url
if not url.startswith(_SQLITE_PREFIX):
return None
raw = url[len(_SQLITE_PREFIX) :]
if not raw or raw == ":memory:":
return None
return Path(raw)
def _format_size(num_bytes: int) -> str:
value = float(num_bytes)
for unit in ("o", "Ko", "Mo", "Go"):
if value < 1024 or unit == "Go":
return f"{value:.1f} {unit}" if unit != "o" else f"{int(value)} {unit}"
value /= 1024
return f"{value:.1f} Go"
def _format_date(moment: datetime | None) -> str:
if moment is None:
return "jamais"
return moment.strftime("%Y-%m-%d %H:%M")
def _mask(token: str) -> str:
"""N'affiche jamais un jeton en entier : il autorise la lecture."""
if len(token) <= 8:
return f"{token[:2]}...{token[-2:]}"
return f"{token[:4]}...{token[-4:]}"
def _confirm(question: str, assume_yes: bool) -> bool:
if assume_yes:
return True
if not sys.stdin.isatty():
print("Confirmation impossible (entree non interactive). Utilisez --yes.")
return False
answer = input(f"{question} [o/N] ").strip().lower()
return answer in {"o", "oui", "y", "yes"}
def cmd_stats(_args: argparse.Namespace) -> int:
now = utcnow()
with SessionLocal() as session:
total = session.execute(select(func.count()).select_from(SharedMap)).scalar_one()
expired = session.execute(
select(func.count())
.select_from(SharedMap)
.where(SharedMap.expires_at.is_not(None), SharedMap.expires_at <= now)
).scalar_one()
oldest = session.execute(
select(SharedMap.created_at).order_by(SharedMap.created_at.asc()).limit(1)
).scalar_one_or_none()
newest = session.execute(
select(SharedMap.created_at).order_by(SharedMap.created_at.desc()).limit(1)
).scalar_one_or_none()
settings = get_settings()
path = database_path()
size = 0
if path and path.exists():
size = path.stat().st_size
for suffix in ("-wal", "-shm"):
side = path.with_name(path.name + suffix)
if side.exists():
size += side.stat().st_size
limit = settings.max_shared_maps
print(f"Partages : {total}")
print(f" actifs : {total - expired}")
print(f" expires : {expired}")
print(f"Capacite : {'illimitee' if limit <= 0 else limit}")
print(f"Partage active : {'oui' if settings.sharing_enabled else 'non'}")
print(f"Base SQLite : {path or 'non applicable'}")
print(f"Taille (WAL comprise) : {_format_size(size)}")
print(f"Plus ancien : {_format_date(oldest)}")
print(f"Plus recent : {_format_date(newest)}")
return 0
def cmd_shares_list(args: argparse.Namespace) -> int:
now = utcnow()
stmt = select(SharedMap).order_by(SharedMap.created_at.desc())
if args.expired:
stmt = stmt.where(SharedMap.expires_at.is_not(None), SharedMap.expires_at <= now)
elif args.active:
stmt = stmt.where((SharedMap.expires_at.is_(None)) | (SharedMap.expires_at > now))
stmt = stmt.limit(args.limit)
with SessionLocal() as session:
rows = list(session.execute(stmt).scalars())
if not rows:
print("Aucun partage.")
return 0
header = f"{'JETON':<14} {'TITRE':<28} {'CREE':<17} {'MAJ':<17} {'EXPIRE':<17} {'NOEUDS':>7}"
print(header)
print("-" * len(header))
for share in rows:
# `parentId` apparait exactement une fois par noeud, contrairement a
# `id` que portent aussi les liens et les relations.
nodes = share.map_data.count('"parentId"')
title = (share.title or "(sans titre)")[:27]
marker = " (expire)" if share.is_expired(now=now) else ""
print(
f"{_mask(share.share_token):<14} {title:<28} "
f"{_format_date(share.created_at):<17} {_format_date(share.updated_at):<17} "
f"{_format_date(share.expires_at) + marker:<17} {nodes:>7}"
)
return 0
def cmd_shares_delete(args: argparse.Namespace) -> int:
"""Suppression administrative : n'utilise pas le jeton de gestion.
Elle est reservee a qui a deja un acces au conteneur, donc a la base.
"""
with SessionLocal() as session:
share = session.execute(
select(SharedMap).where(SharedMap.share_token == args.share_token)
).scalar_one_or_none()
if share is None:
print("Aucun partage ne correspond a ce jeton.")
return 1
print(f"Partage : {share.title or '(sans titre)'} ({_mask(share.share_token)})")
print(f" cree le : {_format_date(share.created_at)}")
print(f" expire le : {_format_date(share.expires_at)}")
if not _confirm("Supprimer definitivement ce partage ?", args.yes):
print("Annule.")
return 1
session.delete(share)
session.commit()
print("1 partage supprime.")
return 0
def cmd_shares_purge(args: argparse.Namespace) -> int:
now = utcnow()
with SessionLocal() as session:
rows = list(
session.execute(
select(SharedMap).where(
SharedMap.expires_at.is_not(None), SharedMap.expires_at <= now
)
).scalars()
)
if not rows:
print("0 partage(s) supprime(s) : aucun partage expire.")
return 0
print(f"{len(rows)} partage(s) expire(s) :")
for share in rows:
print(
f" {_mask(share.share_token)} {(share.title or '(sans titre)')[:40]}"
f" expire le {_format_date(share.expires_at)}"
)
if args.dry_run:
print("Mode simulation : rien n'a ete supprime.")
return 0
if not _confirm(f"Supprimer ces {len(rows)} partage(s) ?", args.yes):
print("Annule.")
return 1
for share in rows:
session.delete(share)
session.commit()
print(f"{len(rows)} partage(s) supprime(s).")
return 0
def _sqlite_backup(source: Path, destination: Path) -> None:
"""Copie coherente via l'API de sauvegarde SQLite.
Un simple `cp` du fichier n'est pas sur en mode WAL : les ecritures
recentes vivent dans le fichier -wal, et la copie obtenue peut etre
incoherente. L'API backup, elle, produit une base complete et utilisable
pendant que l'application continue de tourner.
"""
destination.parent.mkdir(parents=True, exist_ok=True)
with (
sqlite3.connect(f"file:{source}?mode=ro", uri=True) as origin,
sqlite3.connect(destination) as copy,
):
origin.backup(copy)
def _apply_retention(destination: Path, keep: int) -> list[Path]:
"""Ne garde que les `keep` sauvegardes les plus recentes du meme dossier.
Le filtre est volontairement etroit : meme dossier, meme extension, et le
fichier qui vient d'etre ecrit est toujours conserve. Une commande de
sauvegarde ne doit pas devenir un effaceur de fichiers surprise.
"""
if keep <= 0:
return []
others = sorted(
(
path
for path in destination.parent.glob(f"*{destination.suffix}")
if path.is_file() and path != destination
),
key=lambda path: path.stat().st_mtime,
reverse=True,
)
# La sauvegarde qui vient d'etre ecrite compte comme la plus recente, et
# comme l'une des `keep` conservees : on ne peut donc pas finir avec keep+1.
ordered = [destination, *others]
removed = []
for path in ordered[keep:]:
path.unlink()
removed.append(path)
return removed
def cmd_backup(args: argparse.Namespace) -> int:
source = database_path()
if source is None or not source.exists():
print("Aucun fichier SQLite a sauvegarder.")
return 1
destination = Path(args.destination)
if destination.exists() and not args.yes:
print(f"{destination} existe deja. Utilisez --yes pour l'ecraser.")
return 1
_sqlite_backup(source, destination)
print(f"Sauvegarde ecrite : {destination} ({_format_size(destination.stat().st_size)})")
for removed in _apply_retention(destination, args.keep):
print(f"Ancienne sauvegarde supprimee : {removed.name}")
return 0
def cmd_restore(args: argparse.Namespace) -> int:
"""Restauration prudente : verification, copie de securite, puis remplacement.
Le remplacement passe lui aussi par l'API backup de SQLite, qui ecrit dans
la base cible sous son propre verrou plutot que d'echanger le fichier sous
les pieds d'un processus qui l'a ouvert. Arreter le conteneur avant reste
la facon la plus sure de proceder.
"""
source = Path(args.source)
target = database_path()
if target is None:
print("La base configuree n'est pas un fichier SQLite.")
return 1
if not source.exists():
print(f"Fichier introuvable : {source}")
return 1
try:
with sqlite3.connect(f"file:{source}?mode=ro", uri=True) as check:
tables = {row[0] for row in check.execute("SELECT name FROM sqlite_master")}
except sqlite3.DatabaseError:
print("Ce fichier n'est pas une base SQLite lisible.")
return 1
if "shared_maps" not in tables:
print("Cette base ne contient pas de table shared_maps : restauration refusee.")
return 1
print(f"Source : {source}")
print(f"Destination : {target}")
if not _confirm("Remplacer le contenu de la base actuelle ?", args.yes):
print("Annule.")
return 1
if target.exists():
safety = target.parent / "backups" / f"pre-restore-{utcnow():%Y%m%d-%H%M%S}.db"
_sqlite_backup(target, safety)
print(f"Sauvegarde de securite : {safety}")
_sqlite_backup(source, target)
print("Restauration terminee. Redemarrez le conteneur pour repartir proprement.")
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="python -m app.cli",
description="Administration locale d'OpenMind (partages, sauvegarde).",
)
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("stats", help="Compteurs et taille de la base").set_defaults(func=cmd_stats)
shares = sub.add_parser("shares", help="Gestion des partages")
shares_sub = shares.add_subparsers(dest="shares_command", required=True)
listing = shares_sub.add_parser("list", help="Lister les partages")
listing.add_argument("--limit", type=int, default=50, help="Nombre maximum de lignes")
group = listing.add_mutually_exclusive_group()
group.add_argument("--expired", action="store_true", help="Uniquement les partages expires")
group.add_argument("--active", action="store_true", help="Uniquement les partages actifs")
listing.set_defaults(func=cmd_shares_list)
delete = shares_sub.add_parser("delete", help="Supprimer un partage")
delete.add_argument("share_token", help="Jeton public du partage")
delete.add_argument("--yes", action="store_true", help="Ne pas demander confirmation")
delete.set_defaults(func=cmd_shares_delete)
purge = shares_sub.add_parser("purge-expired", help="Supprimer les partages expires")
purge.add_argument("--dry-run", action="store_true", help="Afficher sans supprimer")
purge.add_argument("--yes", action="store_true", help="Ne pas demander confirmation")
purge.set_defaults(func=cmd_shares_purge)
backup = sub.add_parser("backup", help="Sauvegarder la base pendant que l'app tourne")
backup.add_argument("destination", help="Chemin du fichier de sauvegarde")
backup.add_argument("--yes", action="store_true", help="Ecraser un fichier existant")
backup.add_argument(
"--keep",
type=int,
default=0,
help="Ne garder que les N sauvegardes les plus recentes du dossier (0 = tout garder)",
)
backup.set_defaults(func=cmd_backup)
restore = sub.add_parser("restore", help="Restaurer une sauvegarde (prudent)")
restore.add_argument("source", help="Fichier de sauvegarde a restaurer")
restore.add_argument("--yes", action="store_true", help="Ne pas demander confirmation")
restore.set_defaults(func=cmd_restore)
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
return int(args.func(args))
if __name__ == "__main__": # pragma: no cover - point d'entree
raise SystemExit(main())

View File

@@ -1,12 +1,51 @@
"""Configuration applicative, entierement pilotee par variables d'environnement.""" """Configuration applicative, entierement pilotee par variables d'environnement.
Toute valeur illisible arrete le demarrage avec un message nommant la variable
fautive. Une instance publique ne doit jamais tourner en croyant appliquer une
protection qu'une faute de frappe aurait desactivee.
"""
from __future__ import annotations from __future__ import annotations
import ipaddress
from functools import lru_cache from functools import lru_cache
from pydantic import Field from pydantic import Field, ValidationError, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
from app.services.rate_limit import RateLimitSyntaxError, parse_rate
class ConfigurationError(RuntimeError):
"""Configuration inexploitable : le demarrage doit s'arreter ici."""
def _validate_rate(value: str) -> str:
"""Verifie une regle de debit sans la conserver : elle est relue a l'usage."""
parse_rate(value) # leve RateLimitSyntaxError si la syntaxe est fautive
return value
def parse_cidrs(raw: str) -> list[ipaddress.IPv4Network | ipaddress.IPv6Network]:
"""Lit une liste de reseaux CIDR separes par des virgules.
`strict=False` accepte aussi bien `10.0.0.0/8` qu'une adresse seule comme
`192.0.2.7`, qui devient alors un reseau /32.
"""
networks: list[ipaddress.IPv4Network | ipaddress.IPv6Network] = []
for entry in raw.split(","):
text = entry.strip()
if not text:
continue
try:
networks.append(ipaddress.ip_network(text, strict=False))
except ValueError:
raise ValueError(
f"'{text}' n'est pas un reseau valide. Attendu : une adresse ou un CIDR, "
"par exemple 172.16.0.0/12, 10.0.0.0/8 ou 2001:db8::/32."
) from None
return networks
class Settings(BaseSettings): class Settings(BaseSettings):
"""Parametres du backend. """Parametres du backend.
@@ -28,16 +67,88 @@ class Settings(BaseSettings):
max_nodes: int = Field(default=5000, gt=0) max_nodes: int = Field(default=5000, gt=0)
max_title_length: int = Field(default=200, gt=0) max_title_length: int = Field(default=200, gt=0)
# --- Exploitation publique -------------------------------------------
# Instance entierement privee : le partage serveur peut etre coupe sans
# rien changer au fonctionnement local des cartes.
sharing_enabled: bool = True
# Limites de debit, au format "<nombre>/<fenetre>" : 10/hour, 120/minute,
# 5/second. Seules les valeurs 0, off, none, disabled ou vide desactivent
# une limite ; toute autre valeur illisible arrete le demarrage.
share_create_rate_limit: str = "10/hour"
share_read_rate_limit: str = "120/minute"
share_write_rate_limit: str = "60/hour"
# Nombre total de partages heberges. 0 = illimite.
max_shared_maps: int = Field(default=10000, ge=0)
# Taille maximale d'un partage. 0 = on retombe sur max_map_size_mb.
max_share_size_mb: float = Field(default=0.0, ge=0)
# Duree de vie par defaut d'un nouveau partage, en jours. 0 = permanent.
default_share_expiry_days: int = Field(default=30, ge=0)
# Autorise l'option "Jamais" dans l'interface et via l'API.
allow_permanent_shares: bool = True
# A n'activer que derriere un reverse proxy de confiance, et uniquement avec
# trusted_proxy_cidrs renseigne : sinon un client direct pourrait falsifier
# son adresse et contourner les limites de debit.
trust_proxy_headers: bool = False
# Reseaux depuis lesquels X-Forwarded-For est accepte, separes par des
# virgules. IPv4, IPv6 et CIDR acceptes.
trusted_proxy_cidrs: str = ""
# Liste d'origines autorisees, separees par des virgules. # Liste d'origines autorisees, separees par des virgules.
# Vide = aucune origine externe (l'app est servie par ce meme serveur). # Vide = aucune origine externe (l'app est servie par ce meme serveur).
cors_origins: str = "" cors_origins: str = ""
log_level: str = "INFO" log_level: str = "INFO"
@field_validator("share_create_rate_limit", "share_read_rate_limit", "share_write_rate_limit")
@classmethod
def _check_rate(cls, value: str) -> str:
return _validate_rate(value)
@field_validator("trusted_proxy_cidrs")
@classmethod
def _check_cidrs(cls, value: str) -> str:
parse_cidrs(value)
return value
@model_validator(mode="after")
def _check_proxy_trust(self) -> Settings:
"""Faire confiance a un proxy sans dire lequel ne protege de rien.
Autant le dire au demarrage : sans reseau declare, aucun en-tete ne
serait accepte et l'exploitant croirait sa configuration active.
"""
if self.trust_proxy_headers and not parse_cidrs(self.trusted_proxy_cidrs):
raise ValueError(
"TRUST_PROXY_HEADERS=true exige TRUSTED_PROXY_CIDRS : indiquez les reseaux "
"depuis lesquels X-Forwarded-For peut etre accepte, par exemple "
"172.16.0.0/12,10.0.0.0/8."
)
return self
@property @property
def max_map_size_bytes(self) -> int: def max_map_size_bytes(self) -> int:
return int(self.max_map_size_mb * 1024 * 1024) return int(self.max_map_size_mb * 1024 * 1024)
@property
def max_share_size_bytes(self) -> int:
"""Limite propre aux partages, bornee par la limite HTTP globale."""
if self.max_share_size_mb <= 0:
return self.max_map_size_bytes
return min(int(self.max_share_size_mb * 1024 * 1024), self.max_map_size_bytes)
@property
def max_share_size_mb_effective(self) -> float:
return self.max_share_size_bytes / (1024 * 1024)
@property
def trusted_proxy_networks(self) -> list[ipaddress.IPv4Network | ipaddress.IPv6Network]:
return parse_cidrs(self.trusted_proxy_cidrs)
@property @property
def cors_origin_list(self) -> list[str]: def cors_origin_list(self) -> list[str]:
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()] return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
@@ -47,6 +158,21 @@ class Settings(BaseSettings):
return self.app_env.lower() in {"dev", "development", "debug"} return self.app_env.lower() in {"dev", "development", "debug"}
def _readable_error(error: ValidationError) -> str:
"""Traduit une erreur Pydantic en message nommant la variable fautive."""
lines = []
for item in error.errors():
variable = str(item["loc"][0]).upper() if item.get("loc") else "CONFIGURATION"
reason = str(item.get("msg", "")).replace("Value error, ", "")
lines.append(f" {variable} : {reason}")
return "\n".join(lines)
@lru_cache @lru_cache
def get_settings() -> Settings: def get_settings() -> Settings:
try:
return Settings() return Settings()
except RateLimitSyntaxError as error: # pragma: no cover - filet de securite
raise ConfigurationError(f"Configuration invalide :\n {error}") from None
except ValidationError as error:
raise ConfigurationError(f"Configuration invalide :\n{_readable_error(error)}") from None

View File

@@ -1,8 +1,8 @@
"""Initialisation du schema SQLite. """Mise a niveau du schema SQLite au demarrage.
La V1 n'utilise pas Alembic : le schema tient dans une seule table et Depuis la Phase 2.1, le schema est gere par Alembic : `init_db` se contente de
`create_all` suffit. Toute evolution future du schema devra soit passer par declencher les migrations. Il n'existe volontairement aucun mecanisme de
Alembic, soit par une migration explicite ajoutee ici (voir README). migration maison en parallele.
""" """
from __future__ import annotations from __future__ import annotations
@@ -11,16 +11,12 @@ import logging
from sqlalchemy import Engine from sqlalchemy import Engine
from app.db.base import Base from app.db.migrations import run_migrations
from app.db.session import engine as default_engine from app.db.session import engine as default_engine
from app.models import share as _share_model # noqa: F401 (enregistre la table) from app.models import share as _share_model # noqa: F401 (enregistre la table)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
SCHEMA_VERSION = 1
def init_db(engine: Engine | None = None) -> None: def init_db(engine: Engine | None = None) -> None:
target = engine or default_engine run_migrations(engine or default_engine)
Base.metadata.create_all(bind=target)
logger.info("Schema SQLite initialise (version %s)", SCHEMA_VERSION)

View File

@@ -0,0 +1,79 @@
"""Application des migrations Alembic au demarrage.
Deux situations coexistent et doivent toutes deux fonctionner sans
intervention :
* **installation neuve** : la base est vide, toutes les revisions s'appliquent ;
* **base existante** creee avant Alembic par `create_all` : la table est deja
la, mais `alembic_version` n'existe pas. On estampille alors la revision
initiale (aucune modification de donnees) avant de derouler la suite.
Aucune operation destructive n'est effectuee : la seule migration de cette
phase ajoute une colonne nullable.
"""
from __future__ import annotations
import logging
from pathlib import Path
from alembic import command
from alembic.config import Config
from sqlalchemy import inspect
from app.config import get_settings
logger = logging.getLogger(__name__)
BACKEND_ROOT = Path(__file__).resolve().parents[2]
ALEMBIC_INI = BACKEND_ROOT / "alembic.ini"
INITIAL_REVISION = "0001_initial_schema"
MAIN_TABLE = "shared_maps"
def build_config(database_url: str | None = None, *, configure_logger: bool = False) -> Config:
config = Config(str(ALEMBIC_INI))
config.set_main_option("script_location", str(BACKEND_ROOT / "migrations"))
config.set_main_option("sqlalchemy.url", database_url or get_settings().database_url)
# Les migrations tournent dans le processus applicatif : elles ne doivent pas
# reconfigurer les logs deja en place.
config.attributes["configure_logger"] = configure_logger
return config
def run_migrations(engine=None, database_url: str | None = None) -> None: # type: ignore[no-untyped-def]
# Import tardif : creer le moteur lit la configuration. En le faisant ici
# plutot qu'a l'import du module, une configuration invalide remonte comme
# une erreur attrapable, avec un message d'exploitation et sans traceback.
from app.db.session import engine as default_engine
target = engine or default_engine
config = build_config(database_url)
with target.connect() as connection:
tables = set(inspect(connection).get_table_names())
legacy = MAIN_TABLE in tables and "alembic_version" not in tables
if legacy:
# Base d'avant Alembic : on declare l'etat initial deja atteint plutot
# que de rejouer une creation de table qui echouerait.
logger.info("Base existante detectee : estampillage de %s", INITIAL_REVISION)
command.stamp(config, INITIAL_REVISION)
command.upgrade(config, "head")
logger.info("Migrations appliquees (head)")
if __name__ == "__main__": # pragma: no cover - appele par l'entrypoint Docker
import sys
from app.config import ConfigurationError
logging.basicConfig(level="INFO", format="%(levelname)-8s | %(message)s", stream=sys.stdout)
try:
run_migrations()
except ConfigurationError as error:
# Message d'exploitation, pas de traceback : c'est le fichier .env qu'il
# faut corriger, pas le code.
print(str(error), file=sys.stderr)
raise SystemExit(2) from None

View File

@@ -44,6 +44,10 @@ def create_db_engine(database_url: str | None = None) -> Engine:
# WAL : meilleure resistance aux arrets brutaux du conteneur. # WAL : meilleure resistance aux arrets brutaux du conteneur.
cursor.execute("PRAGMA journal_mode=WAL") cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA foreign_keys=ON") cursor.execute("PRAGMA foreign_keys=ON")
# Attend au lieu d'echouer immediatement si une ecriture concurrente
# tient le verrou : une sauvegarde en cours ne doit pas produire
# d'erreur "database is locked" cote utilisateur.
cursor.execute("PRAGMA busy_timeout=5000")
cursor.close() cursor.close()
return engine return engine

View File

@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import re
import sys import sys
_LOG_FORMAT = "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s" _LOG_FORMAT = "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s"
@@ -11,6 +12,32 @@ _DATE_FORMAT = "%Y-%m-%dT%H:%M:%S"
# Champs qui ne doivent jamais apparaitre dans les logs. # Champs qui ne doivent jamais apparaitre dans les logs.
REDACTED_KEYS = frozenset({"management_token", "managementToken", "authorization"}) REDACTED_KEYS = frozenset({"management_token", "managementToken", "authorization"})
# Un share token est une capacite de lecture : le laisser dans les logs d'acces
# revient a y ecrire le lien complet. On le remplace, sans toucher au reste du
# chemin pour que les autres routes restent diagnosticables.
_TOKEN_PATHS = re.compile(r"(?P<prefix>/(?:s|api/shares)/)(?P<token>[A-Za-z0-9_-]{6,})")
REDACTED_TOKEN = "[REDACTED]"
def redact_tokens(text: str) -> str:
return _TOKEN_PATHS.sub(lambda m: f"{m.group('prefix')}{REDACTED_TOKEN}", text)
class TokenRedactingFilter(logging.Filter):
"""Retire les jetons de partage des messages avant ecriture.
Applique aux logs d'acces uvicorn, ou le chemin complet est journalise.
"""
def filter(self, record: logging.LogRecord) -> bool:
if isinstance(record.args, tuple):
record.args = tuple(
redact_tokens(arg) if isinstance(arg, str) else arg for arg in record.args
)
if isinstance(record.msg, str):
record.msg = redact_tokens(record.msg)
return True
def configure_logging(level: str = "INFO") -> None: def configure_logging(level: str = "INFO") -> None:
handler = logging.StreamHandler(sys.stdout) handler = logging.StreamHandler(sys.stdout)
@@ -21,11 +48,16 @@ def configure_logging(level: str = "INFO") -> None:
root.addHandler(handler) root.addHandler(handler)
root.setLevel(level.upper()) root.setLevel(level.upper())
redactor = TokenRedactingFilter()
handler.addFilter(redactor)
# Uvicorn possede ses propres handlers : on les aligne sur les notres. # Uvicorn possede ses propres handlers : on les aligne sur les notres.
for name in ("uvicorn", "uvicorn.error", "uvicorn.access"): for name in ("uvicorn", "uvicorn.error", "uvicorn.access"):
logger = logging.getLogger(name) logger = logging.getLogger(name)
logger.handlers.clear() logger.handlers.clear()
logger.propagate = True logger.propagate = True
logger.filters.clear()
logger.addFilter(redactor)
def safe_extra(data: dict[str, object]) -> dict[str, object]: def safe_extra(data: dict[str, object]) -> dict[str, object]:

View File

@@ -80,7 +80,11 @@ def register_exception_handlers(app: FastAPI) -> None:
@app.exception_handler(ShareError) @app.exception_handler(ShareError)
async def _share_error(_request: Request, exc: ShareError) -> JSONResponse: async def _share_error(_request: Request, exc: ShareError) -> JSONResponse:
return JSONResponse(status_code=exc.status_code, content={"detail": exc.message}) return JSONResponse(
status_code=exc.status_code,
content={"detail": exc.message},
headers=exc.headers or None,
)
@app.exception_handler(RequestValidationError) @app.exception_handler(RequestValidationError)
async def _validation_error(_request: Request, exc: RequestValidationError) -> JSONResponse: async def _validation_error(_request: Request, exc: RequestValidationError) -> JSONResponse:

View File

@@ -35,11 +35,39 @@ SECURITY_HEADERS = {
} }
# Chemins qui ne doivent jamais etre indexes : ils sont derriere un lien non
# repertorie, ou ne sont pas des pages.
NOINDEX_PREFIXES = ("/s/", "/api/")
ROBOTS_TAG = "noindex, nofollow, noarchive"
class SecurityHeadersMiddleware(BaseHTTPMiddleware): class SecurityHeadersMiddleware(BaseHTTPMiddleware):
"""En-tetes de securite communs.
`Referrer-Policy: no-referrer` est applique a tout le site, pas seulement
aux pages de partage : une URL /s/<token> est une capacite de lecture, et
elle ne doit jamais partir dans l'en-tete Referer d'un site externe sur
lequel le lecteur cliquerait.
"""
async def dispatch(self, request: Request, call_next): # type: ignore[no-untyped-def] async def dispatch(self, request: Request, call_next): # type: ignore[no-untyped-def]
response: Response = await call_next(request) response: Response = await call_next(request)
for header, value in SECURITY_HEADERS.items(): for header, value in SECURITY_HEADERS.items():
response.headers.setdefault(header, value) response.headers.setdefault(header, value)
path = request.url.path
if path.startswith("/assets/"):
# Les fichiers produits par Vite portent un hash de contenu dans leur
# nom : ils ne changent jamais sous une meme URL. index.html et sw.js,
# eux, restent en no-cache (voir web.py) pour qu'une mise a jour de
# l'application soit prise en compte au rechargement suivant.
response.headers.setdefault("Cache-Control", "public, max-age=31536000, immutable")
if path.startswith(NOINDEX_PREFIXES):
# robots.txt reste une simple convention : c'est le jeton qui
# autorise la lecture. Cet en-tete evite seulement qu'un lien
# partage se retrouve dans un index public.
response.headers.setdefault("X-Robots-Tag", ROBOTS_TAG)
return response return response

View File

@@ -37,5 +37,21 @@ class SharedMap(Base):
DateTime(timezone=True), default=utcnow, onupdate=utcnow DateTime(timezone=True), default=utcnow, onupdate=utcnow
) )
# NULL = partage permanent. Les partages crees avant l'introduction de
# l'expiration sont migres a NULL et restent donc valides.
expires_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, default=None, index=True
)
def is_expired(self, *, now: datetime | None = None) -> bool:
if self.expires_at is None:
return False
moment = now or utcnow()
deadline = self.expires_at
# SQLite rend des datetime naifs : on les considere en UTC.
if deadline.tzinfo is None:
deadline = deadline.replace(tzinfo=UTC)
return deadline <= moment
def __repr__(self) -> str: # pragma: no cover - aide au debug uniquement def __repr__(self) -> str: # pragma: no cover - aide au debug uniquement
return f"<SharedMap id={self.id} share_token={self.share_token!r}>" return f"<SharedMap id={self.id} share_token={self.share_token!r}>"

View File

@@ -31,6 +31,29 @@ class MapEdgeIn(BaseModel):
target: str = Field(min_length=1, max_length=128) target: str = Field(min_length=1, max_length=128)
class MapRelationIn(BaseModel):
"""Relation secondaire (format v4).
Purement visuelle : le backend ne l'interprete pas, il verifie seulement
qu'elle est bien formee. Une relation qui boucle sur elle-meme est refusee,
comme cote frontend : les deux validations disent la meme chose.
"""
model_config = ConfigDict(extra="allow")
id: str = Field(min_length=1, max_length=128)
# Les noms suivent le document JSON du frontend, d'ou le camelCase.
sourceId: str = Field(min_length=1, max_length=128)
targetId: str = Field(min_length=1, max_length=128)
label: str | None = Field(default=None, max_length=120)
@model_validator(mode="after")
def _check_endpoints(self) -> MapRelationIn:
if self.sourceId == self.targetId:
raise ValueError("Une relation doit relier deux noeuds differents.")
return self
class MapDocumentIn(BaseModel): class MapDocumentIn(BaseModel):
"""Document carte complet, tel que produit par le frontend.""" """Document carte complet, tel que produit par le frontend."""
@@ -41,6 +64,7 @@ class MapDocumentIn(BaseModel):
name: str = Field(default="", max_length=255) name: str = Field(default="", max_length=255)
nodes: list[MapNodeIn] = Field(default_factory=list) nodes: list[MapNodeIn] = Field(default_factory=list)
edges: list[MapEdgeIn] = Field(default_factory=list) edges: list[MapEdgeIn] = Field(default_factory=list)
relations: list[MapRelationIn] = Field(default_factory=list)
viewport: dict[str, Any] = Field(default_factory=dict) viewport: dict[str, Any] = Field(default_factory=dict)
settings: dict[str, Any] = Field(default_factory=dict) settings: dict[str, Any] = Field(default_factory=dict)
@@ -59,4 +83,8 @@ class MapDocumentIn(BaseModel):
max_edges = get_settings().max_nodes * 4 max_edges = get_settings().max_nodes * 4
if len(self.edges) > max_edges: if len(self.edges) > max_edges:
raise ValueError(f"La carte depasse la limite de {max_edges} liens.") raise ValueError(f"La carte depasse la limite de {max_edges} liens.")
max_relations = get_settings().max_nodes * 2
if len(self.relations) > max_relations:
raise ValueError(f"La carte depasse la limite de {max_relations} relations.")
return self return self

View File

@@ -16,6 +16,19 @@ class ShareCreateIn(BaseModel):
title: str = Field(default="", max_length=255) title: str = Field(default="", max_length=255)
map: MapDocumentIn map: MapDocumentIn
# Absent = valeur par defaut de l'instance ; null explicite = permanent.
expires_in_days: int | None = Field(default=None, ge=1, le=3650)
# Distingue "champ absent" de "null explicite", que Pydantic ne separe pas.
permanent: bool = False
class ShareExpiryIn(BaseModel):
"""Corps de PATCH /api/shares/{token} : uniquement l'expiration."""
model_config = ConfigDict(extra="forbid")
expires_in_days: int | None = Field(default=None, ge=1, le=3650)
permanent: bool = False
class ShareCreatedOut(BaseModel): class ShareCreatedOut(BaseModel):
@@ -31,6 +44,7 @@ class ShareCreatedOut(BaseModel):
title: str title: str
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
expires_at: datetime | None = None
class ShareUpdatedOut(BaseModel): class ShareUpdatedOut(BaseModel):
@@ -38,6 +52,17 @@ class ShareUpdatedOut(BaseModel):
share_path: str share_path: str
title: str title: str
updated_at: datetime updated_at: datetime
expires_at: datetime | None = None
class ShareOwnerOut(BaseModel):
"""Vue reservee au detenteur du jeton de gestion. Aucune donnee de carte."""
share_token: str
title: str
created_at: datetime
updated_at: datetime
expires_at: datetime | None = None
class SharePublicOut(BaseModel): class SharePublicOut(BaseModel):
@@ -49,3 +74,4 @@ class SharePublicOut(BaseModel):
map: dict map: dict
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
expires_at: datetime | None = None

View File

@@ -0,0 +1,86 @@
"""Identification du client pour la limitation de debit.
Le point sensible : `X-Forwarded-For` est un en-tete que n'importe quel client
peut ecrire. Le croire sans condition permettrait a un client direct de changer
d'identite a chaque requete et de contourner toute limite.
La confiance est donc doublement conditionnee :
1. `TRUST_PROXY_HEADERS` doit etre actif ;
2. la connexion doit provenir d'un reseau declare dans `TRUSTED_PROXY_CIDRS`.
Sans ces deux conditions, l'en-tete est ignore et seule l'adresse reelle de la
socket compte. OpenMind suppose **un reverse proxy frontal de confiance** : on
lit la derniere valeur de la chaine, celle que ce proxy a ajoutee, les
precedentes pouvant avoir ete forgees par le client.
"""
from __future__ import annotations
import ipaddress
from collections.abc import Sequence
from starlette.requests import Request
FORWARDED_FOR = "x-forwarded-for"
REAL_IP = "x-real-ip"
UNKNOWN_CLIENT = "unknown"
IpNetwork = ipaddress.IPv4Network | ipaddress.IPv6Network
def normalize_ip(value: str) -> str:
"""Forme canonique d'une adresse, pour que la cle de limitation soit unique.
Sans cela, `2001:db8::1` et `2001:0db8:0000:0000:0000:0000:0000:0001`
designeraient deux clients differents, et il suffirait de reecrire son
adresse autrement pour repartir avec un compteur neuf.
"""
text = value.strip()
if not text:
return UNKNOWN_CLIENT
# Un port peut trainer dans un en-tete mal configure : [::1]:443, 1.2.3.4:80.
if text.startswith("[") and "]" in text:
text = text[1 : text.index("]")]
elif text.count(":") == 1 and "." in text:
text = text.split(":", 1)[0]
try:
return str(ipaddress.ip_address(text))
except ValueError:
# Adresse illisible : on la garde telle quelle, bornee, plutot que de
# regrouper tous les clients douteux sous une meme cle.
return text[:64]
def is_trusted_peer(host: str, networks: Sequence[IpNetwork]) -> bool:
if not networks:
return False
try:
address = ipaddress.ip_address(host)
except ValueError:
return False
return any(address in network for network in networks)
def resolve_client_ip(
request: Request,
*,
trust_proxy_headers: bool,
trusted_networks: Sequence[IpNetwork] = (),
) -> str:
peer = request.client.host if request.client and request.client.host else ""
if trust_proxy_headers and is_trusted_peer(peer, trusted_networks):
forwarded = request.headers.get(FORWARDED_FOR)
if forwarded:
parts = [part.strip() for part in forwarded.split(",") if part.strip()]
if parts:
return normalize_ip(parts[-1])
real_ip = request.headers.get(REAL_IP)
if real_ip and real_ip.strip():
return normalize_ip(real_ip)
return normalize_ip(peer) if peer else UNKNOWN_CLIENT

View File

@@ -17,6 +17,8 @@ class ShareError(Exception):
super().__init__(message or self.message) super().__init__(message or self.message)
if message: if message:
self.message = message self.message = message
# En-tetes additionnels de la reponse (Retry-After, par exemple).
self.headers: dict[str, str] = {}
class ShareNotFoundError(ShareError): class ShareNotFoundError(ShareError):
@@ -32,3 +34,40 @@ class ShareForbiddenError(ShareError):
class SharePayloadTooLargeError(ShareError): class SharePayloadTooLargeError(ShareError):
status_code = 413 status_code = 413
message = "La carte depasse la taille maximale autorisee." message = "La carte depasse la taille maximale autorisee."
class ShareExpiredError(ShareError):
"""Le partage a existe mais sa date d'expiration est passee.
Statut distinct du 404 : c'est ce qui permet a la page publique d'afficher
"Ce partage a expire" plutot qu'un message d'absence. Rien d'autre n'est
revele : ni le titre, ni le contenu, ni la date de creation.
"""
status_code = 410
message = "Ce partage a expire."
class SharingDisabledError(ShareError):
status_code = 503
message = "Le partage est desactive sur cette instance."
class ShareCapacityError(ShareError):
status_code = 503
message = "Cette instance a atteint sa capacite maximale de partages."
class PermanentShareNotAllowedError(ShareError):
status_code = 400
message = "Cette instance n'autorise pas les partages sans expiration."
class RateLimitedError(ShareError):
status_code = 429
message = "Trop de requetes. Reessayez dans un instant."
def __init__(self, retry_after: int, message: str | None = None) -> None:
super().__init__(message)
self.retry_after = retry_after
self.headers["Retry-After"] = str(retry_after)

View File

@@ -0,0 +1,147 @@
"""Limitation de debit en memoire, par client et par famille d'endpoint.
Volontairement sans dependance externe ni Redis : OpenMind n'a qu'un seul
service applicatif, et l'objectif de cette limitation est d'empecher qu'une
instance publique soit triviale a saturer, pas de construire un quota
distribue.
Consequence assumee et documentee : les compteurs vivent dans le processus.
Un redemarrage du conteneur les remet a zero, et plusieurs workers uvicorn
auraient chacun les leurs. C'est acceptable pour l'usage vise.
"""
from __future__ import annotations
import threading
import time
from collections import deque
from dataclasses import dataclass
_WINDOW_SECONDS = {
"second": 1,
"sec": 1,
"s": 1,
"minute": 60,
"min": 60,
"m": 60,
"hour": 3600,
"h": 3600,
"day": 86400,
"d": 86400,
}
# Garde-fou memoire : au-dela, les entrees les plus anciennes sont oubliees.
MAX_TRACKED_CLIENTS = 20000
# Seules ces valeurs desactivent volontairement une limite. Toute autre valeur
# non analysable est une faute de frappe, pas une intention.
DISABLED_VALUES = frozenset({"", "0", "off", "none", "disabled"})
class RateLimitSyntaxError(ValueError):
"""Regle de debit illisible. Remonte comme erreur de configuration."""
@dataclass(frozen=True)
class RateRule:
"""Nombre d'appels autorises sur une fenetre glissante."""
limit: int
window_seconds: int
def describe(self) -> str:
return f"{self.limit} requete(s) par {self.window_seconds} seconde(s)"
def parse_rate(spec: str) -> RateRule | None:
"""Lit une regle "10/hour". Retourne None si la limite est desactivee.
Desactiver une limite se fait explicitement : valeur vide, `0`, `off`,
`none` ou `disabled`. Toute autre valeur illisible leve une erreur plutot
que d'etre silencieusement traitee comme une desactivation : une faute de
frappe comme `10/hours` laisserait sinon l'instance sans protection, sans
que personne ne s'en apercoive.
"""
text = (spec or "").strip().lower()
if text in DISABLED_VALUES:
return None
number, separator, unit = text.partition("/")
try:
limit = int(number.strip())
except ValueError:
raise RateLimitSyntaxError(
f"'{spec}' n'est pas une limite de debit valide : le nombre est illisible. "
"Attendu : <nombre>/<fenetre>, par exemple 10/hour."
) from None
if limit <= 0:
raise RateLimitSyntaxError(
f"'{spec}' n'est pas une limite de debit valide : le nombre doit etre positif. "
"Pour desactiver la limite, utilisez 0, off, ou une valeur vide."
)
# "10" vaut 10/minute ; "10/" est une fenetre oubliee, pas un raccourci.
unit_name = unit.strip() if separator else "minute"
window = _WINDOW_SECONDS.get(unit_name)
if window is None:
allowed = ", ".join(sorted({"second", "minute", "hour", "day"}))
raise RateLimitSyntaxError(
f"'{spec}' n'est pas une limite de debit valide : fenetre '{unit_name}' inconnue. "
f"Fenetres acceptees : {allowed}."
)
return RateRule(limit=limit, window_seconds=window)
class RateLimiter:
"""Fenetre glissante par cle, protegee par un verrou."""
def __init__(self) -> None:
self._hits: dict[str, deque[float]] = {}
self._lock = threading.Lock()
def check(self, key: str, rule: RateRule | None, *, now: float | None = None) -> int | None:
"""Enregistre un appel.
Retourne `None` si l'appel est autorise, sinon le nombre de secondes a
attendre avant le prochain essai (valeur de l'en-tete Retry-After).
"""
if rule is None:
return None
moment = time.monotonic() if now is None else now
floor = moment - rule.window_seconds
with self._lock:
hits = self._hits.get(key)
if hits is None:
if len(self._hits) >= MAX_TRACKED_CLIENTS:
self._forget_oldest_locked(floor)
hits = deque()
self._hits[key] = hits
while hits and hits[0] <= floor:
hits.popleft()
if len(hits) >= rule.limit:
wait = rule.window_seconds - (moment - hits[0])
return max(1, int(wait) + 1)
hits.append(moment)
return None
def _forget_oldest_locked(self, floor: float) -> None:
"""Nettoie les cles dont toutes les traces sont hors fenetre."""
stale = [key for key, hits in self._hits.items() if not hits or hits[-1] <= floor]
for key in stale:
del self._hits[key]
if not stale:
# Aucune cle expiree : on repart de zero plutot que de grossir sans fin.
self._hits.clear()
def reset(self) -> None:
"""Remet tous les compteurs a zero. Utilise par les tests."""
with self._lock:
self._hits.clear()
limiter = RateLimiter()

View File

@@ -2,16 +2,22 @@
from __future__ import annotations from __future__ import annotations
import hashlib
import json import json
import logging import logging
import threading
from datetime import timedelta
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.config import get_settings from app.config import get_settings
from app.models.share import SharedMap, utcnow from app.models.share import SharedMap, utcnow
from app.schemas.map import MapDocumentIn from app.schemas.map import MapDocumentIn
from app.services.errors import ( from app.services.errors import (
PermanentShareNotAllowedError,
ShareCapacityError,
ShareExpiredError,
ShareForbiddenError, ShareForbiddenError,
ShareNotFoundError, ShareNotFoundError,
SharePayloadTooLargeError, SharePayloadTooLargeError,
@@ -25,6 +31,27 @@ from app.services.tokens import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Duree maximale acceptee pour une expiration explicite (10 ans).
MAX_EXPIRY_DAYS = 3650
# Serialise "compter puis inserer" pour que deux creations simultanees ne
# franchissent pas ensemble la derniere place. Suffisant pour l'architecture
# d'OpenMind : un seul processus applicatif devant un SQLite local. Avec
# plusieurs workers, la limite redeviendrait un garde-fou approximatif (voir
# README, limitations connues).
_capacity_lock = threading.Lock()
def share_ref(share_token: str) -> str:
"""Reference courte et non reversible d'un partage, pour les journaux.
Le share token est une capacite de lecture : le recopier dans les logs
reviendrait a y ecrire le lien complet. On journalise donc une empreinte
qui permet de correler des lignes entre elles sans permettre d'ouvrir la
carte.
"""
return hashlib.sha256(share_token.encode("utf-8")).hexdigest()[:12]
def serialize_map(document: MapDocumentIn) -> str: def serialize_map(document: MapDocumentIn) -> str:
"""Serialise le document carte en JSON compact et deterministe.""" """Serialise le document carte en JSON compact et deterministe."""
@@ -36,15 +63,52 @@ def serialize_map(document: MapDocumentIn) -> str:
def _guard_payload_size(payload: str) -> None: def _guard_payload_size(payload: str) -> None:
"""Limite propre aux partages, distincte de la limite HTTP globale."""
settings = get_settings() settings = get_settings()
size = len(payload.encode("utf-8")) size = len(payload.encode("utf-8"))
if size > settings.max_map_size_bytes: if size > settings.max_share_size_bytes:
raise SharePayloadTooLargeError( raise SharePayloadTooLargeError(
f"La carte fait {size / (1024 * 1024):.1f} Mo, " f"La carte fait {size / (1024 * 1024):.1f} Mo, "
f"la limite est de {settings.max_map_size_mb} Mo." f"la limite de partage est de {settings.max_share_size_mb_effective:.0f} Mo."
) )
def _guard_capacity(session: Session) -> None:
"""Refuse une creation quand l'instance est pleine.
Aucun partage existant n'est supprime pour faire de la place : une
suppression silencieuse ferait disparaitre le lien de quelqu'un d'autre.
La mise a jour d'un partage existant reste possible.
"""
limit = get_settings().max_shared_maps
if limit <= 0:
return
total = session.execute(select(func.count()).select_from(SharedMap)).scalar_one()
if total >= limit:
raise ShareCapacityError()
def resolve_expiry(expires_in_days: int | None, *, explicit: bool): # type: ignore[no-untyped-def]
"""Traduit une duree en date d'expiration absolue.
`explicit=False` signifie que le client n'a rien demande : on applique
alors la valeur par defaut de l'instance.
"""
settings = get_settings()
if not explicit:
days = settings.default_share_expiry_days
return None if days <= 0 else utcnow() + timedelta(days=days)
if expires_in_days is None:
if not settings.allow_permanent_shares:
raise PermanentShareNotAllowedError()
return None
days = max(1, min(int(expires_in_days), MAX_EXPIRY_DAYS))
return utcnow() + timedelta(days=days)
def _get_by_token(session: Session, share_token: str) -> SharedMap: def _get_by_token(session: Session, share_token: str) -> SharedMap:
stmt = select(SharedMap).where(SharedMap.share_token == share_token) stmt = select(SharedMap).where(SharedMap.share_token == share_token)
share = session.execute(stmt).scalar_one_or_none() share = session.execute(stmt).scalar_one_or_none()
@@ -53,7 +117,22 @@ def _get_by_token(session: Session, share_token: str) -> SharedMap:
return share return share
def create_share(session: Session, *, title: str, document: MapDocumentIn) -> tuple[SharedMap, str]: def _get_live(session: Session, share_token: str) -> SharedMap:
"""Comme `_get_by_token`, mais un partage expire est considere indisponible."""
share = _get_by_token(session, share_token)
if share.is_expired():
raise ShareExpiredError()
return share
def create_share(
session: Session,
*,
title: str,
document: MapDocumentIn,
expires_in_days: int | None = None,
expiry_explicit: bool = False,
) -> tuple[SharedMap, str]:
"""Cree un partage et retourne (entite, management_token en clair). """Cree un partage et retourne (entite, management_token en clair).
Le management token en clair n'est renvoye qu'ici, une seule fois. Le management token en clair n'est renvoye qu'ici, une seule fois.
@@ -68,17 +147,63 @@ def create_share(session: Session, *, title: str, document: MapDocumentIn) -> tu
title=(title or document.name or "Carte sans titre")[: get_settings().max_title_length], title=(title or document.name or "Carte sans titre")[: get_settings().max_title_length],
map_version=document.version, map_version=document.version,
map_data=payload, map_data=payload,
expires_at=resolve_expiry(expires_in_days, explicit=expiry_explicit),
) )
with _capacity_lock:
_guard_capacity(session)
session.add(share) session.add(share)
session.commit() session.commit()
session.refresh(share) session.refresh(share)
logger.info("Partage cree share_token=%s nodes=%s", share.share_token, len(document.nodes)) logger.info(
"Partage cree share=%s noeuds=%s", share_ref(share.share_token), len(document.nodes)
)
return share, management_token return share, management_token
def get_share(session: Session, share_token: str) -> SharedMap: def get_share(session: Session, share_token: str) -> SharedMap:
return _get_by_token(session, share_token) return _get_live(session, share_token)
def set_expiry(
session: Session,
share_token: str,
*,
management_token: str,
expires_in_days: int | None,
) -> SharedMap:
"""Change la date d'expiration. Reserve au detenteur du jeton de gestion.
Volontairement applicable a un partage deja expire : c'est ce qui permet a
son proprietaire de le reactiver plutot que de devoir tout republier.
"""
share = _get_by_token(session, share_token)
if not verify_token(management_token, share.management_token_hash):
logger.warning("Changement d'expiration refuse share=%s", share_ref(share_token))
raise ShareForbiddenError()
share.expires_at = resolve_expiry(expires_in_days, explicit=True)
share.updated_at = utcnow()
session.commit()
session.refresh(share)
logger.info("Expiration mise a jour share=%s", share_ref(share_token))
return share
def verify_management_token(
session: Session, share_token: str, *, management_token: str
) -> SharedMap:
"""Verifie qu'un jeton de gestion correspond bien a ce partage.
Sert a la reimportation d'une cle de gestion depuis un autre navigateur.
Ne renvoie aucune donnee de carte.
"""
share = _get_by_token(session, share_token)
if not verify_token(management_token, share.management_token_hash):
logger.warning("Verification de cle refusee share=%s", share_ref(share_token))
raise ShareForbiddenError("Cette cle de gestion ne correspond pas a ce partage.")
return share
def load_map_data(share: SharedMap) -> dict: def load_map_data(share: SharedMap) -> dict:
@@ -86,7 +211,7 @@ def load_map_data(share: SharedMap) -> dict:
try: try:
data = json.loads(share.map_data) data = json.loads(share.map_data)
except json.JSONDecodeError: # pragma: no cover - donnee corrompue except json.JSONDecodeError: # pragma: no cover - donnee corrompue
logger.exception("Snapshot illisible pour share_token=%s", share.share_token) logger.exception("Snapshot illisible pour share=%s", share_ref(share.share_token))
raise ShareNotFoundError("Le contenu de ce partage est illisible.") from None raise ShareNotFoundError("Le contenu de ce partage est illisible.") from None
if not isinstance(data, dict): # pragma: no cover - donnee corrompue if not isinstance(data, dict): # pragma: no cover - donnee corrompue
raise ShareNotFoundError("Le contenu de ce partage est illisible.") raise ShareNotFoundError("Le contenu de ce partage est illisible.")
@@ -103,7 +228,7 @@ def update_share(
) -> SharedMap: ) -> SharedMap:
share = _get_by_token(session, share_token) share = _get_by_token(session, share_token)
if not verify_token(management_token, share.management_token_hash): if not verify_token(management_token, share.management_token_hash):
logger.warning("Tentative de mise a jour refusee share_token=%s", share_token) logger.warning("Tentative de mise a jour refusee share=%s", share_ref(share_token))
raise ShareForbiddenError() raise ShareForbiddenError()
payload = serialize_map(document) payload = serialize_map(document)
@@ -116,16 +241,16 @@ def update_share(
session.commit() session.commit()
session.refresh(share) session.refresh(share)
logger.info("Partage mis a jour share_token=%s", share_token) logger.info("Partage mis a jour share=%s", share_ref(share_token))
return share return share
def delete_share(session: Session, share_token: str, *, management_token: str) -> None: def delete_share(session: Session, share_token: str, *, management_token: str) -> None:
share = _get_by_token(session, share_token) share = _get_by_token(session, share_token)
if not verify_token(management_token, share.management_token_hash): if not verify_token(management_token, share.management_token_hash):
logger.warning("Tentative de suppression refusee share_token=%s", share_token) logger.warning("Tentative de suppression refusee share=%s", share_ref(share_token))
raise ShareForbiddenError() raise ShareForbiddenError()
session.delete(share) session.delete(share)
session.commit() session.commit()
logger.info("Partage supprime share_token=%s", share_token) logger.info("Partage supprime share=%s", share_ref(share_token))

View File

@@ -14,8 +14,10 @@ from __future__ import annotations
import hashlib import hashlib
import secrets import secrets
# 12 octets -> 16 caracteres url-safe, ~96 bits d'entropie : non enumerable. # 16 octets -> 22 caracteres url-safe, 128 bits d'entropie.
SHARE_TOKEN_BYTES = 12 # Les jetons plus courts emis avant ce reglage restent valides : la colonne
# accepte n'importe quelle longueur et la recherche se fait par valeur exacte.
SHARE_TOKEN_BYTES = 16
# 32 octets -> ~256 bits d'entropie pour le secret de gestion. # 32 octets -> ~256 bits d'entropie pour le secret de gestion.
MANAGEMENT_TOKEN_BYTES = 32 MANAGEMENT_TOKEN_BYTES = 32

69
backend/migrations/env.py Normal file
View File

@@ -0,0 +1,69 @@
"""Environnement Alembic d'OpenMind.
L'URL de la base vient toujours de la configuration applicative
(`DATABASE_URL`) : il n'y a pas de seconde source de verite dans alembic.ini.
"""
from __future__ import annotations
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from app.config import get_settings
from app.db.base import Base
from app.models import share as _share_model # noqa: F401 (enregistre la table)
config = context.config
# `fileConfig` reconfigure tout le systeme de logs et desactive les loggers
# existants. C'est le comportement voulu quand Alembic est lance en ligne de
# commande, mais destructeur quand les migrations tournent dans le processus de
# l'application : les logs d'acces d'uvicorn disparaitraient. L'appelant
# embarque pose donc `configure_logger = False`.
if config.config_file_name is not None and config.attributes.get("configure_logger", True):
fileConfig(config.config_file_name, disable_existing_loggers=False)
# L'appelant peut imposer une URL (tests, outillage). Sinon, on prend celle de
# la configuration applicative : il n'y a jamais d'URL ecrite dans alembic.ini.
if not config.get_main_option("sqlalchemy.url", None):
config.set_main_option("sqlalchemy.url", get_settings().database_url)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
context.configure(
url=config.get_main_option("sqlalchemy.url"),
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
render_as_batch=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
# SQLite ne sait pas modifier une colonne en place : le mode "batch"
# recree proprement la table quand c'est necessaire.
render_as_batch=True,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,25 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,42 @@
"""Schema initial : table shared_maps telle qu'elle existait avant Alembic.
Cette revision decrit l'etat produit par `Base.metadata.create_all` dans les
versions precedentes d'OpenMind. Sur une base existante, elle n'est jamais
executee : la table est deja la, et le demarrage se contente d'estampiller
cette revision (voir `app/db/migrations.py`). Elle sert donc uniquement aux
installations neuves.
Revision ID: 0001_initial_schema
Revises:
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "0001_initial_schema"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"shared_maps",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("share_token", sa.String(length=64), nullable=False),
sa.Column("management_token_hash", sa.String(length=64), nullable=False),
sa.Column("title", sa.String(length=255), nullable=False),
sa.Column("map_version", sa.Integer(), nullable=False),
sa.Column("map_data", sa.Text(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_shared_maps_share_token", "shared_maps", ["share_token"], unique=True)
def downgrade() -> None:
op.drop_index("ix_shared_maps_share_token", table_name="shared_maps")
op.drop_table("shared_maps")

View File

@@ -0,0 +1,33 @@
"""Ajoute l'expiration facultative des partages.
Migration volontairement additive : une seule colonne nullable et son index.
Aucune ligne n'est reecrite, aucune table n'est recreee, aucune donnee n'est
perdue. Les partages existants recoivent `expires_at = NULL`, c'est-a-dire
"pas d'expiration", et restent donc valides exactement comme avant.
Revision ID: 0002_share_expiry
Revises: 0001_initial_schema
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "0002_share_expiry"
down_revision = "0001_initial_schema"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"shared_maps",
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_index("ix_shared_maps_expires_at", "shared_maps", ["expires_at"])
def downgrade() -> None:
op.drop_index("ix_shared_maps_expires_at", table_name="shared_maps")
op.drop_column("shared_maps", "expires_at")

View File

@@ -1,5 +1,6 @@
fastapi>=0.115,<0.117 fastapi>=0.115,<0.117
uvicorn[standard]>=0.32,<0.36 uvicorn[standard]>=0.32,<0.36
SQLAlchemy>=2.0.36,<2.1 SQLAlchemy>=2.0.36,<2.1
alembic>=1.14,<2
pydantic>=2.9,<3 pydantic>=2.9,<3
pydantic-settings>=2.6,<3 pydantic-settings>=2.6,<3

View File

@@ -15,24 +15,60 @@ os.environ["STATIC_DIR"] = f"{_TMP_DIR}/static"
os.environ["APP_NAME"] = "OpenMind" os.environ["APP_NAME"] = "OpenMind"
os.environ["MAX_NODES"] = "5000" os.environ["MAX_NODES"] = "5000"
os.environ["MAX_MAP_SIZE_MB"] = "10" os.environ["MAX_MAP_SIZE_MB"] = "10"
# Les limites de debit sont desactivees par defaut dans les tests : chaque test
# qui les concerne les active explicitement via la fixture `configure`.
os.environ["SHARE_CREATE_RATE_LIMIT"] = "0"
os.environ["SHARE_READ_RATE_LIMIT"] = "0"
os.environ["SHARE_WRITE_RATE_LIMIT"] = "0"
from fastapi.testclient import TestClient # noqa: E402 from fastapi.testclient import TestClient # noqa: E402
from sqlalchemy import text # noqa: E402
from app.config import get_settings # noqa: E402 from app.config import get_settings # noqa: E402
from app.db.base import Base # noqa: E402 from app.db.base import Base # noqa: E402
from app.db.migrations import run_migrations # noqa: E402
from app.db.session import engine # noqa: E402 from app.db.session import engine # noqa: E402
from app.main import app # noqa: E402 from app.main import app # noqa: E402
from app.services.rate_limit import limiter # noqa: E402
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def _fresh_database() -> Iterator[None]: def _fresh_database() -> Iterator[None]:
"""Base neuve avant chaque test, construite par les vraies migrations.
On passe deliberement par Alembic plutot que par `create_all` : le schema
teste est exactement celui qu'obtiendra une installation reelle.
"""
get_settings.cache_clear() get_settings.cache_clear()
Base.metadata.drop_all(bind=engine) Base.metadata.drop_all(bind=engine)
Base.metadata.create_all(bind=engine) with engine.begin() as connection:
connection.execute(text("DROP TABLE IF EXISTS alembic_version"))
run_migrations(engine)
# Les compteurs de debit vivent dans le processus : sans cette remise a
# zero, les tests se limiteraient les uns les autres.
limiter.reset()
yield yield
get_settings.cache_clear() get_settings.cache_clear()
@pytest.fixture()
def configure(monkeypatch: pytest.MonkeyPatch): # type: ignore[no-untyped-def]
"""Surcharge des reglages le temps d'un test.
Les valeurs sont posees dans l'environnement puis le cache de `get_settings`
est vide, exactement comme au demarrage de l'application.
"""
def _apply(**values: object) -> None:
for key, value in values.items():
monkeypatch.setenv(key.upper(), str(value))
get_settings.cache_clear()
yield _apply
get_settings.cache_clear()
@pytest.fixture() @pytest.fixture()
def client() -> Iterator[TestClient]: def client() -> Iterator[TestClient]:
with TestClient(app) as test_client: with TestClient(app) as test_client:

230
backend/tests/test_cli.py Normal file
View File

@@ -0,0 +1,230 @@
"""CLI d'administration : compteurs, listage, suppression, purge, sauvegarde."""
from __future__ import annotations
import sqlite3
from datetime import timedelta
from pathlib import Path
from fastapi.testclient import TestClient
from sqlalchemy import select
from app import cli
from app.db.session import SessionLocal
from app.models.share import SharedMap, utcnow
from tests.conftest import build_map
def _create(client: TestClient, title: str = "Carte") -> dict:
response = client.post("/api/shares", json={"title": title, "map": build_map()})
assert response.status_code == 201, response.text
return response.json()
def _expire(share_token: str) -> None:
with SessionLocal() as session:
share = session.execute(
select(SharedMap).where(SharedMap.share_token == share_token)
).scalar_one()
share.expires_at = utcnow() - timedelta(days=1)
session.commit()
def _count() -> int:
with SessionLocal() as session:
return len(session.execute(select(SharedMap)).scalars().all())
def test_stats_reports_counts_without_secrets(client: TestClient, capsys) -> None:
created = _create(client, "Infrastructure")
_expire(_create(client, "Ancienne")["share_token"])
assert cli.main(["stats"]) == 0
out = capsys.readouterr().out
assert "Partages : 2" in out
assert "actifs : 1" in out
assert "expires : 1" in out
assert "Taille" in out
# Ni jeton, ni hash, ni contenu de carte.
assert created["management_token"] not in out
assert created["share_token"] not in out
assert "map_data" not in out
def test_shares_list_masks_tokens(client: TestClient, capsys) -> None:
created = _create(client, "Infrastructure")
assert cli.main(["shares", "list"]) == 0
out = capsys.readouterr().out
assert "Infrastructure" in out
assert created["share_token"] not in out
assert created["management_token"] not in out
# Le jeton est identifiable sans etre utilisable.
assert created["share_token"][:4] in out
def test_shares_list_filters(client: TestClient, capsys) -> None:
active = _create(client, "Active")
expired = _create(client, "Expiree")
_expire(expired["share_token"])
cli.main(["shares", "list", "--active"])
out = capsys.readouterr().out
assert "Active" in out and "Expiree" not in out
cli.main(["shares", "list", "--expired"])
out = capsys.readouterr().out
assert "Expiree" in out and "Active" not in out
assert active["share_token"] not in out
def test_shares_list_limit(client: TestClient, capsys) -> None:
for index in range(5):
_create(client, f"Carte {index}")
cli.main(["shares", "list", "--limit", "2"])
body = capsys.readouterr().out.strip().splitlines()
# En-tete, separateur, puis deux lignes.
assert len(body) == 4
def test_shares_delete_requires_confirmation_flag(client: TestClient, capsys) -> None:
created = _create(client)
# Entree non interactive et pas de --yes : rien n'est supprime.
assert cli.main(["shares", "delete", created["share_token"]]) == 1
assert _count() == 1
assert cli.main(["shares", "delete", created["share_token"], "--yes"]) == 0
assert "1 partage supprime." in capsys.readouterr().out
assert _count() == 0
def test_shares_delete_unknown_token(client: TestClient, capsys) -> None:
assert cli.main(["shares", "delete", "inexistant", "--yes"]) == 1
assert "Aucun partage" in capsys.readouterr().out
def test_purge_expired_dry_run_changes_nothing(client: TestClient, capsys) -> None:
_create(client, "Active")
_expire(_create(client, "Expiree")["share_token"])
assert cli.main(["shares", "purge-expired", "--dry-run"]) == 0
out = capsys.readouterr().out
assert "1 partage(s) expire(s)" in out
assert "simulation" in out
assert _count() == 2
def test_purge_expired_removes_only_expired(client: TestClient, capsys) -> None:
_create(client, "Active")
_expire(_create(client, "Expiree")["share_token"])
assert cli.main(["shares", "purge-expired", "--yes"]) == 0
assert "1 partage(s) supprime(s)." in capsys.readouterr().out
with SessionLocal() as session:
remaining = session.execute(select(SharedMap.title)).scalars().all()
assert remaining == ["Active"]
def test_purge_without_expired_shares(client: TestClient, capsys) -> None:
_create(client)
assert cli.main(["shares", "purge-expired", "--yes"]) == 0
assert "0 partage(s) supprime(s)" in capsys.readouterr().out
def test_backup_produces_a_usable_database(client: TestClient, tmp_path: Path, capsys) -> None:
_create(client, "Infrastructure")
destination = tmp_path / "backups" / "openmind.db"
assert cli.main(["backup", str(destination)]) == 0
assert "Sauvegarde ecrite" in capsys.readouterr().out
assert destination.exists()
# La copie contient bien les donnees et se relit sans l'application.
connection = sqlite3.connect(destination)
try:
titles = [row[0] for row in connection.execute("SELECT title FROM shared_maps")]
finally:
connection.close()
assert titles == ["Infrastructure"]
def test_backup_refuses_to_overwrite_without_flag(client: TestClient, tmp_path: Path) -> None:
_create(client)
destination = tmp_path / "openmind.db"
assert cli.main(["backup", str(destination)]) == 0
assert cli.main(["backup", str(destination)]) == 1
assert cli.main(["backup", str(destination), "--yes"]) == 0
def test_restore_refuses_a_foreign_file(client: TestClient, tmp_path: Path, capsys) -> None:
intruder = tmp_path / "autre.db"
sqlite3.connect(intruder).execute("CREATE TABLE autre (id INTEGER)")
assert cli.main(["restore", str(intruder), "--yes"]) == 1
assert "shared_maps" in capsys.readouterr().out
# La base courante est intacte.
assert _count() == 0
def test_restore_puts_back_a_backup(client: TestClient, tmp_path: Path, capsys) -> None:
_create(client, "Avant sauvegarde")
backup = tmp_path / "snapshot.db"
cli.main(["backup", str(backup)])
capsys.readouterr()
# On modifie la base apres la sauvegarde.
with SessionLocal() as session:
for share in session.execute(select(SharedMap)).scalars():
session.delete(share)
session.commit()
assert _count() == 0
assert cli.main(["restore", str(backup), "--yes"]) == 0
out = capsys.readouterr().out
assert "Sauvegarde de securite" in out
assert "Restauration terminee" in out
with SessionLocal() as session:
titles = session.execute(select(SharedMap.title)).scalars().all()
assert titles == ["Avant sauvegarde"]
def test_backup_retention_keeps_the_most_recent(client: TestClient, tmp_path: Path, capsys) -> None:
"""--keep N ne conserve que les N sauvegardes les plus recentes du dossier."""
import os
import time
_create(client, "Infrastructure")
folder = tmp_path / "backups"
for index in range(5):
destination = folder / f"openmind-{index}.db"
assert cli.main(["backup", str(destination), "--yes"]) == 0
# Horodatage distinct : le tri de retention doit etre deterministe.
os.utime(destination, (time.time() + index, time.time() + index))
capsys.readouterr()
latest = folder / "openmind-latest.db"
assert cli.main(["backup", str(latest), "--keep", "3"]) == 0
out = capsys.readouterr().out
assert "Ancienne sauvegarde supprimee" in out
remaining = sorted(path.name for path in folder.glob("*.db"))
assert len(remaining) == 3
# La sauvegarde qui vient d'etre ecrite est toujours conservee, et compte
# dans les trois : on ne finit jamais avec une de plus que demande.
assert "openmind-latest.db" in remaining
def test_backup_without_keep_deletes_nothing(client: TestClient, tmp_path: Path) -> None:
_create(client)
folder = tmp_path / "backups"
for index in range(3):
assert cli.main(["backup", str(folder / f"openmind-{index}.db")]) == 0
assert cli.main(["backup", str(folder / "openmind-3.db")]) == 0
assert len(list(folder.glob("*.db"))) == 4

View File

@@ -0,0 +1,135 @@
"""Identification du client : X-Forwarded-For n'est cru qu'a deux conditions.
`TRUST_PROXY_HEADERS` doit etre actif, ET la connexion doit venir d'un reseau
declare dans `TRUSTED_PROXY_CIDRS`. Ces tests reproduisent les deux chemins :
derriere un proxy approuve, et en connexion directe forgeant le meme en-tete.
"""
from __future__ import annotations
import pytest
from starlette.datastructures import Headers
from starlette.requests import Request
from app.config import parse_cidrs
from app.services.client_ip import is_trusted_peer, normalize_ip, resolve_client_ip
PROXY_NETWORKS = parse_cidrs("172.16.0.0/12,10.0.0.0/8,2001:db8::/32")
def _request(headers: dict[str, str], peer: str | None = "203.0.113.9") -> Request:
scope = {
"type": "http",
"method": "GET",
"path": "/api/shares",
"headers": Headers(headers).raw,
"client": (peer, 12345) if peer else None,
}
return Request(scope)
class TestWithoutTrust:
def test_proxy_headers_are_ignored_by_default(self) -> None:
request = _request({"x-forwarded-for": "1.2.3.4"})
assert resolve_client_ip(request, trust_proxy_headers=False) == "203.0.113.9"
def test_forged_header_cannot_change_identity(self) -> None:
"""Un client direct ne peut pas se demultiplier pour diluer sa limite."""
seen = {
resolve_client_ip(
_request({"x-forwarded-for": f"10.0.0.{i}"}), trust_proxy_headers=False
)
for i in range(20)
}
assert seen == {"203.0.113.9"}
def test_falls_back_to_peer_without_headers(self) -> None:
assert resolve_client_ip(_request({}), trust_proxy_headers=True) == "203.0.113.9"
def test_unknown_client_is_handled(self) -> None:
assert resolve_client_ip(_request({}, peer=None), trust_proxy_headers=False) == "unknown"
class TestTrustedProxy:
def test_approved_proxy_is_believed(self) -> None:
# Le proxy (172.18.0.1) a ajoute l'adresse reellement vue.
request = _request({"x-forwarded-for": "9.9.9.9, 198.51.100.7"}, peer="172.18.0.1")
resolved = resolve_client_ip(
request, trust_proxy_headers=True, trusted_networks=PROXY_NETWORKS
)
assert resolved == "198.51.100.7"
def test_direct_client_forging_the_same_header_is_not_believed(self) -> None:
"""Meme en-tete, connexion directe : c'est l'adresse socket qui compte."""
request = _request({"x-forwarded-for": "9.9.9.9, 198.51.100.7"}, peer="203.0.113.9")
resolved = resolve_client_ip(
request, trust_proxy_headers=True, trusted_networks=PROXY_NETWORKS
)
assert resolved == "203.0.113.9"
def test_approved_proxy_falls_back_to_real_ip(self) -> None:
request = _request({"x-real-ip": "198.51.100.8"}, peer="10.1.2.3")
resolved = resolve_client_ip(
request, trust_proxy_headers=True, trusted_networks=PROXY_NETWORKS
)
assert resolved == "198.51.100.8"
def test_no_declared_network_means_no_trust(self) -> None:
request = _request({"x-forwarded-for": "198.51.100.7"}, peer="172.18.0.1")
resolved = resolve_client_ip(request, trust_proxy_headers=True, trusted_networks=[])
assert resolved == "172.18.0.1"
def test_ipv6_proxy_is_recognised(self) -> None:
request = _request({"x-forwarded-for": "198.51.100.7"}, peer="2001:db8::5")
resolved = resolve_client_ip(
request, trust_proxy_headers=True, trusted_networks=PROXY_NETWORKS
)
assert resolved == "198.51.100.7"
def test_peer_membership(self) -> None:
assert is_trusted_peer("172.18.0.1", PROXY_NETWORKS) is True
assert is_trusted_peer("10.255.255.254", PROXY_NETWORKS) is True
assert is_trusted_peer("203.0.113.9", PROXY_NETWORKS) is False
assert is_trusted_peer("2001:db8::1", PROXY_NETWORKS) is True
assert is_trusted_peer("2001:dead::1", PROXY_NETWORKS) is False
assert is_trusted_peer("pas-une-ip", PROXY_NETWORKS) is False
assert is_trusted_peer("10.0.0.1", []) is False
class TestNormalisation:
@pytest.mark.parametrize(
("raw", "expected"),
[
("2001:db8::1", "2001:db8::1"),
("2001:0db8:0000:0000:0000:0000:0000:0001", "2001:db8::1"),
("2001:DB8::1", "2001:db8::1"),
("2001:0db8::0:1", "2001:db8::1"),
("[2001:db8::1]:443", "2001:db8::1"),
(" 192.0.2.7 ", "192.0.2.7"),
("192.0.2.7:8080", "192.0.2.7"),
],
)
def test_same_address_gives_the_same_key(self, raw: str, expected: str) -> None:
assert normalize_ip(raw) == expected
def test_ipv6_rewriting_cannot_reset_the_counter(self) -> None:
"""Plusieurs ecritures de la meme IPv6 doivent produire une seule cle."""
forms = [
"2001:db8::1",
"2001:0DB8:0000:0000:0000:0000:0000:0001",
"2001:db8:0:0:0:0:0:1",
"[2001:db8::1]:51000",
]
keys = {
resolve_client_ip(
_request({"x-forwarded-for": form}, peer="172.18.0.1"),
trust_proxy_headers=True,
trusted_networks=PROXY_NETWORKS,
)
for form in forms
}
assert keys == {"2001:db8::1"}
def test_unreadable_value_is_kept_bounded(self) -> None:
assert normalize_ip("x" * 500) == "x" * 64
assert normalize_ip(" ") == "unknown"

View File

@@ -0,0 +1,153 @@
"""Validation de la configuration au demarrage.
Une valeur illisible doit arreter l'application avec un message nommant la
variable fautive, jamais desactiver silencieusement une protection.
"""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from app.config import ConfigurationError, Settings, get_settings, parse_cidrs
from app.services.rate_limit import RateLimitSyntaxError, parse_rate
@pytest.mark.parametrize(
("spec", "limit", "window"),
[
("10/hour", 10, 3600),
("120/minute", 120, 60),
("5/second", 5, 1),
("2/day", 2, 86400),
("10/h", 10, 3600),
(" 30/MINUTE ", 30, 60),
("3", 3, 60),
],
)
def test_valid_rate_specs(spec: str, limit: int, window: int) -> None:
rule = parse_rate(spec)
assert rule is not None
assert rule.limit == limit
assert rule.window_seconds == window
@pytest.mark.parametrize("spec", ["", "0", "off", "none", "disabled", " OFF "])
def test_explicit_deactivation(spec: str) -> None:
"""Desactiver une limite reste possible, mais doit etre explicite."""
assert parse_rate(spec) is None
@pytest.mark.parametrize(
"spec",
["10/hours", "abc", "-1/hour", "10/banana", "10/", "/hour", "dix/hour", "10 par heure"],
)
def test_invalid_rate_specs_are_refused(spec: str) -> None:
with pytest.raises(RateLimitSyntaxError):
parse_rate(spec)
def test_invalid_rate_message_is_actionable() -> None:
with pytest.raises(RateLimitSyntaxError) as error:
parse_rate("10/hours")
message = str(error.value)
assert "10/hours" in message
assert "hours" in message
assert "second" in message and "hour" in message
class TestSettingsValidation:
def test_valid_configuration_loads(self) -> None:
settings = Settings(
share_create_rate_limit="10/hour",
share_read_rate_limit="120/minute",
share_write_rate_limit="off",
)
assert settings.share_write_rate_limit == "off"
@pytest.mark.parametrize(
"field",
["share_create_rate_limit", "share_read_rate_limit", "share_write_rate_limit"],
)
def test_each_rate_variable_is_validated(self, field: str) -> None:
with pytest.raises(Exception) as error:
Settings(**{field: "10/banana"})
assert "banana" in str(error.value)
def test_negative_shared_map_limit_is_refused(self) -> None:
with pytest.raises(ValidationError):
Settings(max_shared_maps=-1)
def test_negative_share_size_is_refused(self) -> None:
with pytest.raises(ValidationError):
Settings(max_share_size_mb=-2)
def test_zero_share_size_means_inherit(self) -> None:
settings = Settings(max_share_size_mb=0, max_map_size_mb=10)
assert settings.max_share_size_bytes == settings.max_map_size_bytes
def test_negative_expiry_is_refused(self) -> None:
with pytest.raises(ValidationError):
Settings(default_share_expiry_days=-1)
def test_zero_expiry_is_allowed(self) -> None:
assert Settings(default_share_expiry_days=0).default_share_expiry_days == 0
def test_zero_map_size_is_refused(self) -> None:
with pytest.raises(ValidationError):
Settings(max_map_size_mb=0)
class TestTrustedProxyConfiguration:
def test_valid_cidrs(self) -> None:
networks = parse_cidrs("172.16.0.0/12, 10.0.0.0/8 ,2001:db8::/32")
assert len(networks) == 3
assert str(networks[0]) == "172.16.0.0/12"
def test_single_address_becomes_a_host_network(self) -> None:
assert str(parse_cidrs("192.0.2.7")[0]) == "192.0.2.7/32"
def test_empty_value_is_allowed(self) -> None:
assert parse_cidrs("") == []
assert parse_cidrs(" , ") == []
@pytest.mark.parametrize("value", ["999.1.1.1/8", "10.0.0.0/64", "pas-un-reseau", "10.0.0.0/"])
def test_invalid_cidr_is_refused(self, value: str) -> None:
with pytest.raises(ValueError, match="reseau valide"):
parse_cidrs(value)
def test_invalid_cidr_stops_settings(self) -> None:
with pytest.raises(Exception) as error:
Settings(trusted_proxy_cidrs="10.0.0.0/8,pas-un-reseau")
assert "pas-un-reseau" in str(error.value)
def test_trusting_proxies_without_declaring_them_is_refused(self) -> None:
"""Faire confiance sans dire a qui n'appliquerait aucune confiance."""
with pytest.raises(Exception) as error:
Settings(trust_proxy_headers=True, trusted_proxy_cidrs="")
assert "TRUSTED_PROXY_CIDRS" in str(error.value)
def test_trusting_proxies_with_networks_is_accepted(self) -> None:
settings = Settings(trust_proxy_headers=True, trusted_proxy_cidrs="172.16.0.0/12")
assert len(settings.trusted_proxy_networks) == 1
class TestStartupFailure:
def test_bad_rate_stops_startup_with_a_readable_message(self, monkeypatch) -> None:
monkeypatch.setenv("SHARE_CREATE_RATE_LIMIT", "10/banana")
get_settings.cache_clear()
with pytest.raises(ConfigurationError) as error:
get_settings()
message = str(error.value)
assert "Configuration invalide" in message
assert "SHARE_CREATE_RATE_LIMIT" in message
assert "banana" in message
get_settings.cache_clear()
def test_bad_cidr_stops_startup(self, monkeypatch) -> None:
monkeypatch.setenv("TRUSTED_PROXY_CIDRS", "10.0.0.0/8,nawak")
get_settings.cache_clear()
with pytest.raises(ConfigurationError) as error:
get_settings()
assert "TRUSTED_PROXY_CIDRS" in str(error.value)
get_settings.cache_clear()

View File

@@ -0,0 +1,147 @@
"""Expiration des partages : creation, lecture, prolongation."""
from __future__ import annotations
from datetime import timedelta
from fastapi.testclient import TestClient
from sqlalchemy import select
from app.db.session import SessionLocal
from app.models.share import SharedMap, utcnow
from tests.conftest import build_map
def _create(client: TestClient, **extra: object) -> dict:
body: dict[str, object] = {"title": "Carte", "map": build_map()}
body.update(extra)
response = client.post("/api/shares", json=body)
assert response.status_code == 201, response.text
return response.json()
def _force_expiry(share_token: str, *, delta: timedelta) -> None:
"""Deplace la date d'expiration pour ne pas attendre en test."""
with SessionLocal() as session:
share = session.execute(
select(SharedMap).where(SharedMap.share_token == share_token)
).scalar_one()
share.expires_at = utcnow() + delta
session.commit()
def test_default_expiry_is_applied(client: TestClient, configure) -> None:
configure(default_share_expiry_days=30)
body = _create(client)
assert body["expires_at"] is not None
def test_zero_default_means_permanent(client: TestClient, configure) -> None:
configure(default_share_expiry_days=0)
assert _create(client)["expires_at"] is None
def test_explicit_duration_is_used(client: TestClient, configure) -> None:
configure(default_share_expiry_days=30)
body = _create(client, expires_in_days=7)
assert body["expires_at"] is not None
public = client.get("/api/shares/" + body["share_token"]).json()
assert public["expires_at"] == body["expires_at"]
def test_permanent_can_be_requested_when_allowed(client: TestClient, configure) -> None:
configure(default_share_expiry_days=30, allow_permanent_shares=True)
assert _create(client, permanent=True)["expires_at"] is None
def test_permanent_is_refused_when_forbidden(client: TestClient, configure) -> None:
configure(allow_permanent_shares=False)
response = client.post(
"/api/shares", json={"title": "x", "map": build_map(), "permanent": True}
)
assert response.status_code == 400
assert "expiration" in response.json()["detail"].lower()
def test_expired_share_is_gone(client: TestClient, configure) -> None:
configure(default_share_expiry_days=1)
body = _create(client)
_force_expiry(body["share_token"], delta=timedelta(seconds=-1))
response = client.get("/api/shares/" + body["share_token"])
assert response.status_code == 410
assert response.json()["detail"] == "Ce partage a expire."
assert "map" not in response.json()
def test_expired_share_is_not_deleted_automatically(client: TestClient, configure) -> None:
configure(default_share_expiry_days=1)
body = _create(client)
_force_expiry(body["share_token"], delta=timedelta(seconds=-1))
client.get("/api/shares/" + body["share_token"])
with SessionLocal() as session:
assert session.execute(select(SharedMap)).scalars().all()
def test_owner_can_extend_expiry(client: TestClient, configure) -> None:
configure(default_share_expiry_days=1)
body = _create(client)
_force_expiry(body["share_token"], delta=timedelta(seconds=-1))
response = client.patch(
"/api/shares/" + body["share_token"],
json={"expires_in_days": 30},
headers={"X-Management-Token": body["management_token"]},
)
assert response.status_code == 200
assert response.json()["expires_at"] is not None
assert client.get("/api/shares/" + body["share_token"]).status_code == 200
def test_owner_can_make_share_permanent(client: TestClient, configure) -> None:
configure(default_share_expiry_days=30, allow_permanent_shares=True)
body = _create(client)
response = client.patch(
"/api/shares/" + body["share_token"],
json={"permanent": True},
headers={"X-Management-Token": body["management_token"]},
)
assert response.status_code == 200
assert response.json()["expires_at"] is None
def test_public_link_alone_cannot_change_expiry(client: TestClient) -> None:
body = _create(client)
without = client.patch("/api/shares/" + body["share_token"], json={"expires_in_days": 90})
assert without.status_code == 403
wrong = client.patch(
"/api/shares/" + body["share_token"],
json={"expires_in_days": 90},
headers={"X-Management-Token": "mauvais-jeton"},
)
assert wrong.status_code == 403
def test_management_key_can_be_verified(client: TestClient) -> None:
body = _create(client)
ok = client.post(
f"/api/shares/{body['share_token']}/verify",
headers={"X-Management-Token": body["management_token"]},
)
assert ok.status_code == 200
payload = ok.json()
assert payload["title"] == body["title"]
assert "map" not in payload
assert "management_token" not in payload
ko = client.post(
f"/api/shares/{body['share_token']}/verify",
headers={"X-Management-Token": "mauvaise-cle"},
)
assert ko.status_code == 403

View File

@@ -0,0 +1,120 @@
"""Le backend doit accepter la mise en page v2 sans relacher sa validation."""
from __future__ import annotations
import os
from fastapi.testclient import TestClient
from app.config import get_settings
from tests.conftest import build_map
RICH_NODE = {
"id": "styled",
"type": "standard",
"text": "Firewall",
"position": {"x": 120, "y": 40},
"parentId": "root",
"icon": "server",
"iconStyle": {"kind": "builtin", "position": "top", "size": 24, "gap": 12},
"style": {
"backgroundColor": "#112233",
"textAlign": "center",
"verticalAlign": "middle",
"lineHeight": 1.6,
"fontFamily": "mono",
"shadow": "strong",
},
"layout": {
"widthMode": "fixed",
"width": 320,
"heightMode": "fixed",
"height": 140,
"paddingX": 28,
"paddingY": 18,
"wrap": False,
"maxTextWidth": 400,
},
"branch": {"style": "straight", "width": 4, "color": "#ff8800"},
}
def build_v2_map() -> dict:
payload = build_map(node_count=2, name="Infrastructure")
payload["version"] = 2
payload["nodes"].append(RICH_NODE)
payload["edges"].append({"id": "e-root-styled", "source": "root", "target": "styled"})
payload["settings"]["layoutSpacing"] = {"parentGap": 140, "siblingGap": 80}
payload["settings"]["snapToGrid"] = True
payload["settings"]["snapGrid"] = 10
payload["settings"]["alignmentGuides"] = False
return payload
def test_share_accepts_layout_properties(client: TestClient) -> None:
response = client.post("/api/shares", json={"title": "v2", "map": build_v2_map()})
assert response.status_code == 201, response.text
def test_shared_map_is_returned_unchanged(client: TestClient) -> None:
created = client.post("/api/shares", json={"title": "v2", "map": build_v2_map()})
token = created.json()["share_token"]
public = client.get("/api/shares/" + token).json()
assert public["map_version"] == 2
node = next(item for item in public["map"]["nodes"] if item["id"] == "styled")
assert node["layout"] == RICH_NODE["layout"]
assert node["style"] == RICH_NODE["style"]
assert node["iconStyle"] == RICH_NODE["iconStyle"]
assert node["branch"] == RICH_NODE["branch"]
assert node["icon"] == "server"
assert node["position"] == {"x": 120, "y": 40}
settings = public["map"]["settings"]
assert settings["layoutSpacing"] == {"parentGap": 140, "siblingGap": 80}
assert settings["snapToGrid"] is True
assert settings["alignmentGuides"] is False
def test_version_1_maps_are_still_accepted(client: TestClient) -> None:
response = client.post("/api/shares", json={"title": "v1", "map": build_map()})
assert response.status_code == 201
assert response.json()["share_token"]
def test_node_limit_still_applies_to_v2_maps(client: TestClient) -> None:
os.environ["MAX_NODES"] = "5"
get_settings.cache_clear()
try:
payload = build_v2_map()
for index in range(20):
payload["nodes"].append(
{
"id": f"extra{index}",
"type": "standard",
"text": "x",
"position": {"x": 0, "y": 0},
"parentId": "root",
"layout": {"paddingX": 10},
}
)
response = client.post("/api/shares", json={"title": "x", "map": payload})
assert response.status_code == 422
finally:
os.environ["MAX_NODES"] = "5000"
get_settings.cache_clear()
def test_unknown_top_level_field_is_still_rejected(client: TestClient) -> None:
response = client.post(
"/api/shares", json={"title": "x", "map": build_v2_map(), "premium": True}
)
assert response.status_code == 422
def test_node_without_id_is_still_rejected(client: TestClient) -> None:
payload = build_v2_map()
payload["nodes"].append({"text": "sans id", "layout": {"paddingX": 4}})
response = client.post("/api/shares", json={"title": "x", "map": payload})
assert response.status_code == 422

View File

@@ -0,0 +1,192 @@
"""Protection des liens non repertories : indexation, referrer, journaux."""
from __future__ import annotations
import logging
from fastapi.testclient import TestClient
from app.logging_config import TokenRedactingFilter, redact_tokens
from app.services.tokens import SHARE_TOKEN_BYTES, generate_share_token
from tests.conftest import build_map
def test_robots_txt_disallows_shares(client: TestClient, tmp_path) -> None:
"""Le fichier livre au frontend interdit /s/ et /api/."""
robots = (
__import__("pathlib").Path(__file__).resolve().parents[2]
/ "frontend"
/ "public"
/ "robots.txt"
)
content = robots.read_text(encoding="utf-8")
assert "Disallow: /s/" in content
assert "Disallow: /api/" in content
def test_share_routes_are_marked_noindex(client: TestClient) -> None:
created = client.post("/api/shares", json={"title": "x", "map": build_map()}).json()
public_page = client.get(f"/s/{created['share_token']}")
assert public_page.headers["X-Robots-Tag"] == "noindex, nofollow, noarchive"
api = client.get(f"/api/shares/{created['share_token']}")
assert api.headers["X-Robots-Tag"] == "noindex, nofollow, noarchive"
def test_dashboard_is_not_marked_noindex(client: TestClient) -> None:
assert "X-Robots-Tag" not in client.get("/").headers
def test_referrer_policy_is_no_referrer(client: TestClient) -> None:
created = client.post("/api/shares", json={"title": "x", "map": build_map()}).json()
for path in ("/", f"/s/{created['share_token']}", "/api/health"):
assert client.get(path).headers["Referrer-Policy"] == "no-referrer"
def test_security_headers_are_still_present(client: TestClient) -> None:
headers = client.get("/").headers
assert headers["X-Content-Type-Options"] == "nosniff"
assert "frame-ancestors 'none'" in headers["Content-Security-Policy"]
assert headers["X-Frame-Options"] == "DENY"
assert "geolocation=()" in headers["Permissions-Policy"]
def test_share_tokens_have_at_least_128_bits() -> None:
assert SHARE_TOKEN_BYTES * 8 >= 128
token = generate_share_token()
assert len(token) >= 22
assert len({generate_share_token() for _ in range(200)}) == 200
def test_token_redaction_in_paths() -> None:
assert redact_tokens("GET /api/shares/a8F3xP9kQwZ2 HTTP/1.1") == (
"GET /api/shares/[REDACTED] HTTP/1.1"
)
assert redact_tokens("GET /s/OZGrnetO4RPbqPtS HTTP/1.1") == "GET /s/[REDACTED] HTTP/1.1"
# Les autres routes restent lisibles pour le diagnostic.
assert redact_tokens("GET /api/health HTTP/1.1") == "GET /api/health HTTP/1.1"
assert redact_tokens("GET /api/config HTTP/1.1") == "GET /api/config HTTP/1.1"
def test_redacting_filter_rewrites_arguments() -> None:
record = logging.LogRecord(
name="uvicorn.access",
level=logging.INFO,
pathname=__file__,
lineno=1,
msg='%s - "%s" %d',
args=("1.2.3.4", "GET /api/shares/SECRETTOKEN12 HTTP/1.1", 200),
exc_info=None,
)
TokenRedactingFilter().filter(record)
assert "SECRETTOKEN12" not in record.getMessage()
assert "[REDACTED]" in record.getMessage()
def test_management_token_never_appears_in_logs(client: TestClient, caplog) -> None:
with caplog.at_level(logging.DEBUG):
created = client.post("/api/shares", json={"title": "x", "map": build_map()}).json()
token = created["management_token"]
client.put(
f"/api/shares/{created['share_token']}",
json={"title": "y", "map": build_map()},
headers={"X-Management-Token": token},
)
client.put(
f"/api/shares/{created['share_token']}",
json={"title": "z", "map": build_map()},
headers={"X-Management-Token": "mauvais"},
)
# `httpx`, utilise par le client de test, journalise l'URL qu'il appelle :
# c'est un log de l'outillage de test, pas de l'application. On verifie donc
# uniquement ce qu'OpenMind ecrit lui-meme.
logged = "\n".join(
record.getMessage()
for record in caplog.records
if not record.name.startswith(("httpx", "httpcore"))
)
assert token not in logged
assert created["share_token"] not in logged
# Les messages applicatifs designent un partage par une empreinte courte.
assert "share=" in logged
def test_no_token_leaks_on_any_management_route(client: TestClient, caplog) -> None:
"""PATCH, DELETE et verify manipulent aussi les deux jetons."""
with caplog.at_level(logging.DEBUG):
created = client.post("/api/shares", json={"title": "x", "map": build_map()}).json()
token = created["share_token"]
management = created["management_token"]
headers = {"X-Management-Token": management}
client.patch(f"/api/shares/{token}", json={"expires_in_days": 7}, headers=headers)
client.post(f"/api/shares/{token}/verify", headers=headers)
client.post(f"/api/shares/{token}/verify", headers={"X-Management-Token": "faux"})
client.patch(
f"/api/shares/{token}", json={"permanent": True}, headers={"X-Management-Token": "faux"}
)
client.delete(f"/api/shares/{token}", headers=headers)
logged = "\n".join(
record.getMessage()
for record in caplog.records
if not record.name.startswith(("httpx", "httpcore"))
)
assert management not in logged
assert token not in logged
assert "share=" in logged
def test_access_log_redaction_covers_every_share_route() -> None:
"""Les chemins journalises par uvicorn ne doivent porter aucun jeton."""
token = "sbcY5FwVInYtTSGLNjBCyw"
for method, path in [
("GET", f"/api/shares/{token}"),
("PUT", f"/api/shares/{token}"),
("PATCH", f"/api/shares/{token}"),
("DELETE", f"/api/shares/{token}"),
("POST", f"/api/shares/{token}/verify"),
("GET", f"/s/{token}"),
]:
line = redact_tokens(f"{method} {path} HTTP/1.1")
assert token not in line, line
assert "[REDACTED]" in line
def test_application_logging_survives_migrations(client: TestClient, caplog) -> None:
"""Les migrations ne doivent pas desactiver les loggers de l'application.
Alembic reconfigure les logs quand il est lance en ligne de commande. Lance
dans le processus applicatif, il couperait les journaux d'acces d'uvicorn :
la configuration embarquee doit donc laisser les loggers en place.
"""
from app.db.migrations import run_migrations
from app.db.session import engine
access_logger = logging.getLogger("uvicorn.access")
run_migrations(engine)
assert access_logger.disabled is False
with caplog.at_level(logging.INFO):
access_logger.info('%s - "%s" %d', "1.2.3.4", "GET /api/health HTTP/1.1", 200)
assert any("GET /api/health" in record.getMessage() for record in caplog.records)
def test_entry_points_are_never_cached_long() -> None:
"""index.html, sw.js et le manifeste ne doivent jamais figer une version.
Sans cela, une mise a jour de l'application resterait invisible tant que le
navigateur garde l'ancienne page en cache.
"""
from app.web import _NO_CACHE_FILES
assert {"index.html", "sw.js", "manifest.webmanifest"} <= _NO_CACHE_FILES
def test_hashed_assets_get_a_long_cache(client: TestClient) -> None:
response = client.get("/assets/index-abc123.js")
# Le fichier n'existe pas dans l'environnement de test, mais l'en-tete de
# cache est pose par le middleware, avant le service du fichier.
assert "immutable" in response.headers.get("Cache-Control", "")

View File

@@ -0,0 +1,128 @@
"""Migrations Alembic : installation neuve et reprise d'une base existante."""
from __future__ import annotations
import sqlite3
from pathlib import Path
from sqlalchemy import create_engine, inspect, text
from app.db.migrations import run_migrations
# Schema exact d'avant l'introduction d'Alembic (aucune colonne expires_at).
LEGACY_SCHEMA = """
CREATE TABLE shared_maps (
id INTEGER NOT NULL,
share_token VARCHAR(64) NOT NULL,
management_token_hash VARCHAR(64) NOT NULL,
title VARCHAR(255) NOT NULL,
map_version INTEGER NOT NULL,
map_data TEXT NOT NULL,
created_at DATETIME,
updated_at DATETIME,
PRIMARY KEY (id)
);
CREATE UNIQUE INDEX ix_shared_maps_share_token ON shared_maps (share_token);
"""
LEGACY_ROWS = [
(1, "tokenA", "hashA", "Infrastructure", 3, '{"version":3,"nodes":[]}'),
(2, "tokenB", "hashB", "Projet", 4, '{"version":4,"nodes":[{"id":"a"}]}'),
(3, "tokenC", "hashC", "Reunion", 1, '{"version":1,"nodes":[]}'),
]
def _make_legacy_database(path: Path) -> None:
connection = sqlite3.connect(path)
try:
connection.executescript(LEGACY_SCHEMA)
connection.executemany(
"INSERT INTO shared_maps "
"(id, share_token, management_token_hash, title, map_version, map_data,"
" created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, '2026-01-01 10:00:00', '2026-01-02 11:00:00')",
LEGACY_ROWS,
)
connection.commit()
finally:
connection.close()
def _columns(engine) -> set[str]: # type: ignore[no-untyped-def]
with engine.connect() as connection:
return {column["name"] for column in inspect(connection).get_columns("shared_maps")}
def test_fresh_database_gets_the_full_schema(tmp_path: Path) -> None:
url = f"sqlite:///{tmp_path / 'fresh.db'}"
engine = create_engine(url, future=True)
run_migrations(engine, url)
assert "expires_at" in _columns(engine)
with engine.connect() as connection:
tables = set(inspect(connection).get_table_names())
assert {"shared_maps", "alembic_version"} <= tables
def test_migrations_are_idempotent(tmp_path: Path) -> None:
url = f"sqlite:///{tmp_path / 'twice.db'}"
engine = create_engine(url, future=True)
run_migrations(engine, url)
run_migrations(engine, url)
assert "expires_at" in _columns(engine)
def test_legacy_database_is_upgraded_without_data_loss(tmp_path: Path) -> None:
path = tmp_path / "legacy.db"
_make_legacy_database(path)
url = f"sqlite:///{path}"
engine = create_engine(url, future=True)
# Etat de depart : ancienne structure, sans table de version Alembic.
assert "expires_at" not in _columns(engine)
with engine.connect() as connection:
assert "alembic_version" not in set(inspect(connection).get_table_names())
run_migrations(engine, url)
assert "expires_at" in _columns(engine)
with engine.connect() as connection:
rows = connection.execute(
text(
"SELECT id, share_token, management_token_hash, title, map_version,"
" map_data, expires_at FROM shared_maps ORDER BY id"
)
).all()
assert len(rows) == len(LEGACY_ROWS)
for row, expected in zip(rows, LEGACY_ROWS, strict=True):
assert row.id == expected[0]
assert row.share_token == expected[1]
assert row.management_token_hash == expected[2]
assert row.title == expected[3]
assert row.map_version == expected[4]
assert row.map_data == expected[5]
# Tous les partages existants restent valides : aucune expiration.
assert row.expires_at is None
def test_legacy_database_keeps_working_after_upgrade(tmp_path: Path) -> None:
"""Apres migration, les anciennes lignes restent lisibles par l'ORM."""
path = tmp_path / "legacy2.db"
_make_legacy_database(path)
url = f"sqlite:///{path}"
engine = create_engine(url, future=True)
run_migrations(engine, url)
from sqlalchemy.orm import Session
from app.models.share import SharedMap
with Session(engine) as session:
share = session.query(SharedMap).filter_by(share_token="tokenB").one()
assert share.title == "Projet"
assert share.expires_at is None
assert share.is_expired() is False

View File

@@ -0,0 +1,111 @@
"""Le backend doit accepter et restituer l'ordre des freres (format v3)."""
from __future__ import annotations
from fastapi.testclient import TestClient
from tests.conftest import build_map
def build_v3_map() -> dict:
payload = build_map(node_count=1, name="Reseau")
payload["version"] = 3
payload["nodes"] = [
{
"id": "root",
"type": "root",
"text": "Reseau",
"position": {"x": 0, "y": 0},
"parentId": None,
"order": 0,
},
{
"id": "vlan",
"type": "standard",
"text": "VLAN",
"position": {"x": 200, "y": 0},
"parentId": "root",
"order": 0,
},
{
"id": "vpn",
"type": "standard",
"text": "VPN",
"position": {"x": 200, "y": 60},
"parentId": "root",
"order": 1,
},
{
"id": "dns",
"type": "standard",
"text": "DNS",
"position": {"x": 200, "y": 120},
"parentId": "root",
"order": 2,
},
]
payload["edges"] = [
{"id": "e-root-vlan", "source": "root", "target": "vlan"},
{"id": "e-root-vpn", "source": "root", "target": "vpn"},
{"id": "e-root-dns", "source": "root", "target": "dns"},
]
payload["settings"]["dragMovesBranch"] = True
return payload
def test_share_accepts_v3_map(client: TestClient) -> None:
response = client.post("/api/shares", json={"title": "v3", "map": build_v3_map()})
assert response.status_code == 201, response.text
def test_child_order_is_returned_unchanged(client: TestClient) -> None:
created = client.post("/api/shares", json={"title": "v3", "map": build_v3_map()})
token = created.json()["share_token"]
public = client.get("/api/shares/" + token).json()
assert public["map_version"] == 3
orders = {node["id"]: node.get("order") for node in public["map"]["nodes"]}
assert orders == {"root": 0, "vlan": 0, "vpn": 1, "dns": 2}
assert public["map"]["settings"]["dragMovesBranch"] is True
def test_reordered_update_is_visible_publicly(client: TestClient) -> None:
created = client.post("/api/shares", json={"title": "v3", "map": build_v3_map()})
body = created.json()
reordered = build_v3_map()
for node in reordered["nodes"]:
if node["id"] == "dns":
node["order"] = 0
elif node["id"] == "vlan":
node["order"] = 2
response = client.put(
"/api/shares/" + body["share_token"],
json={"title": "v3", "map": reordered},
headers={"X-Management-Token": body["management_token"]},
)
assert response.status_code == 200
public = client.get("/api/shares/" + body["share_token"]).json()
orders = {node["id"]: node.get("order") for node in public["map"]["nodes"]}
assert orders["dns"] == 0
assert orders["vlan"] == 2
def test_older_versions_are_still_accepted(client: TestClient) -> None:
for version in (1, 2):
payload = build_map()
payload["version"] = version
response = client.post("/api/shares", json={"title": f"v{version}", "map": payload})
assert response.status_code == 201, response.text
def test_invalid_order_type_is_rejected_or_ignored(client: TestClient) -> None:
"""Un ordre non numerique ne doit jamais faire tomber le serveur."""
payload = build_v3_map()
payload["nodes"][1]["order"] = "premier"
response = client.post("/api/shares", json={"title": "x", "map": payload})
assert response.status_code in (201, 422)

View File

@@ -0,0 +1,192 @@
"""Protections d'une instance exposee publiquement.
Couvre la limitation de debit, la coupure du partage, la capacite maximale et
l'identification du client derriere un reverse proxy.
"""
from __future__ import annotations
from fastapi.testclient import TestClient
from app.services.rate_limit import RateLimiter, parse_rate
from tests.conftest import build_map
def _post(client: TestClient) -> int:
return client.post("/api/shares", json={"title": "x", "map": build_map()}).status_code
class TestRateRuleParsing:
def test_reads_usual_syntaxes(self) -> None:
assert parse_rate("10/hour") == parse_rate("10/h")
assert parse_rate("5/minute").limit == 5
assert parse_rate("5/minute").window_seconds == 60
assert parse_rate("2/second").window_seconds == 1
def test_missing_unit_defaults_to_minute(self) -> None:
assert parse_rate("3").window_seconds == 60
def test_disabled_values(self) -> None:
"""Seules ces valeurs desactivent : voir test_configuration.py pour le reste."""
for spec in ("", "0", "off", "none", "disabled"):
assert parse_rate(spec) is None
class TestRateLimiter:
def test_allows_then_blocks_within_window(self) -> None:
limiter = RateLimiter()
rule = parse_rate("2/minute")
assert limiter.check("a", rule, now=0.0) is None
assert limiter.check("a", rule, now=1.0) is None
retry = limiter.check("a", rule, now=2.0)
assert retry is not None and retry > 0
def test_window_slides(self) -> None:
limiter = RateLimiter()
rule = parse_rate("1/minute")
assert limiter.check("a", rule, now=0.0) is None
assert limiter.check("a", rule, now=10.0) is not None
assert limiter.check("a", rule, now=61.0) is None
def test_clients_are_independent(self) -> None:
limiter = RateLimiter()
rule = parse_rate("1/minute")
assert limiter.check("a", rule, now=0.0) is None
assert limiter.check("b", rule, now=0.0) is None
def test_share_creation_is_rate_limited(client: TestClient, configure) -> None:
configure(share_create_rate_limit="3/hour")
assert [_post(client) for _ in range(3)] == [201, 201, 201]
response = client.post("/api/shares", json={"title": "x", "map": build_map()})
assert response.status_code == 429
assert "Retry-After" in response.headers
assert int(response.headers["Retry-After"]) > 0
assert "detail" in response.json()
def test_health_and_ready_are_never_rate_limited(client: TestClient, configure) -> None:
configure(share_create_rate_limit="1/hour", share_read_rate_limit="1/hour")
client.post("/api/shares", json={"title": "x", "map": build_map()})
client.post("/api/shares", json={"title": "x", "map": build_map()})
for _ in range(30):
assert client.get("/api/health").status_code == 200
assert client.get("/api/ready").status_code == 200
def test_reading_a_share_is_rate_limited(client: TestClient, configure) -> None:
created = client.post("/api/shares", json={"title": "x", "map": build_map()}).json()
configure(share_read_rate_limit="2/minute")
token = created["share_token"]
assert client.get(f"/api/shares/{token}").status_code == 200
assert client.get(f"/api/shares/{token}").status_code == 200
assert client.get(f"/api/shares/{token}").status_code == 429
def test_rate_limit_can_be_disabled(client: TestClient, configure) -> None:
configure(share_create_rate_limit="0")
assert all(_post(client) == 201 for _ in range(12))
def test_sharing_disabled_blocks_the_whole_api(client: TestClient, configure) -> None:
created = client.post("/api/shares", json={"title": "x", "map": build_map()}).json()
configure(sharing_enabled=False)
expected = "Le partage est desactive sur cette instance."
assert client.post("/api/shares", json={"title": "x", "map": build_map()}).status_code == 503
assert client.get(f"/api/shares/{created['share_token']}").status_code == 503
assert (
client.put(
f"/api/shares/{created['share_token']}",
json={"title": "x", "map": build_map()},
headers={"X-Management-Token": created["management_token"]},
).status_code
== 503
)
assert (
client.delete(
f"/api/shares/{created['share_token']}",
headers={"X-Management-Token": created["management_token"]},
).status_code
== 503
)
assert (
client.post("/api/shares", json={"title": "x", "map": build_map()}).json()["detail"]
== expected
)
def test_sharing_disabled_is_announced_in_config(client: TestClient, configure) -> None:
configure(sharing_enabled=False)
assert client.get("/api/config").json()["sharingEnabled"] is False
def test_capacity_limit_refuses_new_shares(client: TestClient, configure) -> None:
configure(max_shared_maps=2)
first = client.post("/api/shares", json={"title": "1", "map": build_map()}).json()
assert client.post("/api/shares", json={"title": "2", "map": build_map()}).status_code == 201
refused = client.post("/api/shares", json={"title": "3", "map": build_map()})
assert refused.status_code == 503
assert refused.json()["detail"] == "Cette instance a atteint sa capacite maximale de partages."
# Les partages existants restent lisibles et modifiables.
assert client.get(f"/api/shares/{first['share_token']}").status_code == 200
assert (
client.put(
f"/api/shares/{first['share_token']}",
json={"title": "1 bis", "map": build_map()},
headers={"X-Management-Token": first["management_token"]},
).status_code
== 200
)
def test_capacity_zero_means_unlimited(client: TestClient, configure) -> None:
configure(max_shared_maps=0)
assert all(_post(client) == 201 for _ in range(6))
def test_share_specific_size_limit(client: TestClient, configure) -> None:
configure(max_share_size_mb=0.001)
response = client.post("/api/shares", json={"title": "x", "map": build_map(node_count=50)})
assert response.status_code == 413
assert "limite de partage" in response.json()["detail"]
def test_capacity_is_not_crossed_by_simultaneous_creations(client: TestClient, configure) -> None:
"""La derniere place ne doit pas etre prise deux fois.
Le comptage et l'insertion sont serialises par un verrou de processus :
avec un seul service applicatif devant SQLite, cela suffit a ce que deux
requetes concurrentes ne franchissent pas ensemble la limite.
"""
import threading
configure(max_shared_maps=5)
for _ in range(4):
assert _post(client) == 201
results: list[int] = []
lock = threading.Lock()
def attempt() -> None:
status = client.post("/api/shares", json={"title": "x", "map": build_map()}).status_code
with lock:
results.append(status)
threads = [threading.Thread(target=attempt) for _ in range(8)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
assert results.count(201) == 1
assert results.count(503) == 7
listing = client.get("/api/config")
assert listing.status_code == 200

View File

@@ -0,0 +1,149 @@
"""Le backend doit accepter le format v4 : cotes de branche et relations."""
from __future__ import annotations
from fastapi.testclient import TestClient
from tests.conftest import build_map
def build_v4_map() -> dict:
payload = build_map(node_count=1, name="Infrastructure")
payload["version"] = 4
payload["nodes"] = [
{
"id": "root",
"type": "root",
"text": "Infrastructure",
"position": {"x": 0, "y": 0},
"parentId": None,
"order": 0,
},
{
"id": "reseau",
"type": "standard",
"text": "Reseau",
"position": {"x": 240, "y": 0},
"parentId": "root",
"order": 0,
"side": "right",
},
{
"id": "firewall",
"type": "standard",
"text": "Firewall",
"position": {"x": 460, "y": 0},
"parentId": "reseau",
"order": 0,
},
{
"id": "services",
"type": "standard",
"text": "Services",
"position": {"x": -240, "y": 0},
"parentId": "root",
"order": 1,
"side": "left",
},
{
"id": "web",
"type": "standard",
"text": "Web",
"position": {"x": -460, "y": 0},
"parentId": "services",
"order": 0,
},
]
payload["edges"] = [
{"id": "e-root-reseau", "source": "root", "target": "reseau"},
{"id": "e-reseau-firewall", "source": "reseau", "target": "firewall"},
{"id": "e-root-services", "source": "root", "target": "services"},
{"id": "e-services-web", "source": "services", "target": "web"},
]
payload["relations"] = [
{
"id": "r-1",
"sourceId": "firewall",
"targetId": "web",
"label": "protege",
"style": "bezier",
"color": "#a855f7",
"width": 2,
"dashed": True,
"markerEnd": "arrow",
}
]
payload["settings"]["layout"] = "mindmap-both"
return payload
def test_share_accepts_v4_map(client: TestClient) -> None:
response = client.post("/api/shares", json={"title": "v4", "map": build_v4_map()})
assert response.status_code == 201, response.text
def test_sides_and_relations_are_returned_unchanged(client: TestClient) -> None:
created = client.post("/api/shares", json={"title": "v4", "map": build_v4_map()})
token = created.json()["share_token"]
public = client.get("/api/shares/" + token).json()
assert public["map_version"] == 4
assert public["map"]["settings"]["layout"] == "mindmap-both"
sides = {node["id"]: node.get("side") for node in public["map"]["nodes"]}
assert sides["reseau"] == "right"
assert sides["services"] == "left"
# Le cote n'est porte que par les branches de niveau 1.
assert sides["firewall"] is None
relations = public["map"]["relations"]
assert len(relations) == 1
assert relations[0]["sourceId"] == "firewall"
assert relations[0]["targetId"] == "web"
assert relations[0]["label"] == "protege"
assert relations[0]["dashed"] is True
def test_relation_does_not_alter_hierarchy(client: TestClient) -> None:
"""Une relation ne doit jamais devenir un lien parent / enfant."""
created = client.post("/api/shares", json={"title": "v4", "map": build_v4_map()})
public = client.get("/api/shares/" + created.json()["share_token"]).json()
parents = {node["id"]: node.get("parentId") for node in public["map"]["nodes"]}
assert parents["firewall"] == "reseau"
assert parents["web"] == "services"
edge_pairs = {(edge["source"], edge["target"]) for edge in public["map"]["edges"]}
assert ("firewall", "web") not in edge_pairs
def test_self_relation_is_rejected(client: TestClient) -> None:
payload = build_v4_map()
payload["relations"][0]["targetId"] = payload["relations"][0]["sourceId"]
response = client.post("/api/shares", json={"title": "x", "map": payload})
assert response.status_code == 422
def test_relation_without_endpoints_is_rejected(client: TestClient) -> None:
payload = build_v4_map()
del payload["relations"][0]["targetId"]
response = client.post("/api/shares", json={"title": "x", "map": payload})
assert response.status_code == 422
def test_map_without_relations_is_still_accepted(client: TestClient) -> None:
payload = build_v4_map()
del payload["relations"]
response = client.post("/api/shares", json={"title": "x", "map": payload})
assert response.status_code == 201, response.text
def test_older_versions_are_still_accepted(client: TestClient) -> None:
for version in (1, 2, 3):
payload = build_map()
payload["version"] = version
response = client.post("/api/shares", json={"title": f"v{version}", "map": payload})
assert response.status_code == 201, response.text

View File

@@ -0,0 +1,103 @@
"""Limitation de debit derriere un reverse proxy, de bout en bout.
Deux chemins sont reproduits sur l'application reelle :
* une connexion venant d'un proxy approuve, qui transmet l'adresse du client ;
* une connexion directe qui forge le meme en-tete.
Dans le premier cas la limite doit suivre l'adresse transmise, dans le second
l'adresse reelle de la socket.
"""
from __future__ import annotations
from collections.abc import Iterator
import pytest
from fastapi.testclient import TestClient
from app.main import app
from tests.conftest import build_map
PROXY = "172.18.0.1"
DIRECT = "203.0.113.9"
def _client(peer: str) -> Iterator[TestClient]:
with TestClient(app, client=(peer, 40000)) as test_client:
yield test_client
@pytest.fixture()
def behind_proxy(configure) -> Iterator[TestClient]:
configure(
trust_proxy_headers=True,
trusted_proxy_cidrs="172.16.0.0/12,10.0.0.0/8",
share_create_rate_limit="2/hour",
)
yield from _client(PROXY)
@pytest.fixture()
def direct_client(configure) -> Iterator[TestClient]:
configure(
trust_proxy_headers=True,
trusted_proxy_cidrs="172.16.0.0/12,10.0.0.0/8",
share_create_rate_limit="2/hour",
)
yield from _client(DIRECT)
def _create(client: TestClient, forwarded_for: str | None) -> int:
headers = {"X-Forwarded-For": forwarded_for} if forwarded_for else {}
return client.post(
"/api/shares", json={"title": "x", "map": build_map()}, headers=headers
).status_code
def test_limit_follows_the_address_transmitted_by_an_approved_proxy(
behind_proxy: TestClient,
) -> None:
assert _create(behind_proxy, "198.51.100.10") == 201
assert _create(behind_proxy, "198.51.100.10") == 201
assert _create(behind_proxy, "198.51.100.10") == 429
# Un autre client, derriere le meme proxy, garde son propre compteur.
assert _create(behind_proxy, "198.51.100.11") == 201
def test_approved_proxy_ignores_the_part_forged_by_the_client(
behind_proxy: TestClient,
) -> None:
"""Le client ecrit "9.9.9.9" ; le proxy ajoute l'adresse qu'il a vue."""
assert _create(behind_proxy, "9.9.9.9, 198.51.100.20") == 201
assert _create(behind_proxy, "9.9.9.9, 198.51.100.20") == 201
assert _create(behind_proxy, "9.9.9.9, 198.51.100.20") == 429
# Changer la partie forgee ne remet pas le compteur a zero.
assert _create(behind_proxy, "1.1.1.1, 198.51.100.20") == 429
def test_direct_client_cannot_escape_the_limit_by_forging_the_header(
direct_client: TestClient,
) -> None:
assert _create(direct_client, "10.0.0.1") == 201
assert _create(direct_client, "10.0.0.2") == 201
# Adresse forgee differente a chaque fois : la socket, elle, ne change pas.
assert _create(direct_client, "10.0.0.3") == 429
assert _create(direct_client, "10.0.0.4") == 429
assert _create(direct_client, None) == 429
def test_ipv6_rewriting_does_not_reset_the_counter(behind_proxy: TestClient) -> None:
assert _create(behind_proxy, "2001:db8::1") == 201
assert _create(behind_proxy, "2001:0db8:0000:0000:0000:0000:0000:0001") == 201
assert _create(behind_proxy, "2001:DB8:0:0:0:0:0:1") == 429
def test_headers_are_ignored_when_trust_is_disabled(configure) -> None:
configure(trust_proxy_headers=False, share_create_rate_limit="2/hour")
with TestClient(app, client=(PROXY, 40000)) as client:
assert _create(client, "198.51.100.30") == 201
assert _create(client, "198.51.100.31") == 201
assert _create(client, "198.51.100.32") == 429

View File

@@ -6,19 +6,35 @@ services:
image: openmind:latest image: openmind:latest
container_name: openmind container_name: openmind
restart: unless-stopped restart: unless-stopped
env_file: # Pas de `env_file` : Compose lit deja automatiquement un fichier .env place
- .env # a cote de ce compose pour resoudre les ${...} ci-dessous. Le declarer
# explicitement rendrait ce fichier obligatoire, et un deploiement depuis le
# depot (Portainer, clone neuf) echouerait, .env n'etant pas versionne.
environment: environment:
# Le conteneur ecoute toujours sur 8080 ; APP_PORT ne change que l'hote. # Le conteneur ecoute toujours sur 8080 ; APP_PORT ne change que l'hote.
APP_NAME: ${APP_NAME:-OpenMind} APP_NAME: ${APP_NAME:-OpenMind}
DATABASE_URL: ${DATABASE_URL:-sqlite:////app/data/openmind.db} DATABASE_URL: ${DATABASE_URL:-sqlite:////app/data/openmind.db}
MAX_MAP_SIZE_MB: ${MAX_MAP_SIZE_MB:-10} MAX_MAP_SIZE_MB: ${MAX_MAP_SIZE_MB:-10}
MAX_NODES: ${MAX_NODES:-5000} MAX_NODES: ${MAX_NODES:-5000}
SHARING_ENABLED: ${SHARING_ENABLED:-true}
SHARE_CREATE_RATE_LIMIT: ${SHARE_CREATE_RATE_LIMIT:-10/hour}
SHARE_READ_RATE_LIMIT: ${SHARE_READ_RATE_LIMIT:-120/minute}
SHARE_WRITE_RATE_LIMIT: ${SHARE_WRITE_RATE_LIMIT:-60/hour}
MAX_SHARED_MAPS: ${MAX_SHARED_MAPS:-10000}
MAX_SHARE_SIZE_MB: ${MAX_SHARE_SIZE_MB:-0}
DEFAULT_SHARE_EXPIRY_DAYS: ${DEFAULT_SHARE_EXPIRY_DAYS:-30}
ALLOW_PERMANENT_SHARES: ${ALLOW_PERMANENT_SHARES:-true}
TRUST_PROXY_HEADERS: ${TRUST_PROXY_HEADERS:-false}
TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-}
CORS_ORIGINS: ${CORS_ORIGINS:-} CORS_ORIGINS: ${CORS_ORIGINS:-}
LOG_LEVEL: ${LOG_LEVEL:-INFO} LOG_LEVEL: ${LOG_LEVEL:-INFO}
STATIC_DIR: /app/static STATIC_DIR: /app/static
# En developpement, le port est publie sur toutes les interfaces.
# En production derriere un reverse proxy, restreignez-le a la boucle locale
# en posant APP_BIND=127.0.0.1 dans .env : le service n'est alors joignable
# que par le proxy, jamais directement depuis l'exterieur.
ports: ports:
- "${APP_PORT:-8080}:8080" - "${APP_BIND:-0.0.0.0}:${APP_PORT:-8080}:8080"
volumes: volumes:
# Bind mount explicite : les partages SQLite restent accessibles sur l'hote. # Bind mount explicite : les partages SQLite restent accessibles sur l'hote.
- ./data:/app/data - ./data:/app/data

15
docker-entrypoint.sh Normal file
View File

@@ -0,0 +1,15 @@
#!/bin/sh
# Applique les migrations avant de demarrer FastAPI.
#
# Le script est idempotent : sur une base deja a jour, il ne fait rien. Sur une
# base creee avant l'introduction d'Alembic, il estampille la revision initiale
# puis applique les suivantes, sans jamais supprimer de donnees.
set -e
echo "Migrations de la base..."
if ! python -m app.db.migrations; then
echo "Demarrage interrompu : corrigez la configuration puis relancez." >&2
exit 1
fi
exec "$@"

View File

@@ -14,6 +14,7 @@
"dexie": "^4.0.10", "dexie": "^4.0.10",
"html-to-image": "^1.11.11", "html-to-image": "^1.11.11",
"jspdf": "^2.5.2", "jspdf": "^2.5.2",
"lucide-react": "^0.462.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"zustand": "^5.0.2" "zustand": "^5.0.2"
@@ -3645,6 +3646,15 @@
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
}, },
"node_modules/lucide-react": {
"version": "0.462.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.462.0.tgz",
"integrity": "sha512-NTL7EbAao9IFtuSivSZgrAh4fZd09Lr+6MTkqIxuHaH2nnYiYIzXPo06cOxHg9wKLdj6LL8TByG4qpePqwgx/g==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
}
},
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",

View File

@@ -18,6 +18,7 @@
"dexie": "^4.0.10", "dexie": "^4.0.10",
"html-to-image": "^1.11.11", "html-to-image": "^1.11.11",
"jspdf": "^2.5.2", "jspdf": "^2.5.2",
"lucide-react": "^0.462.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"zustand": "^5.0.2" "zustand": "^5.0.2"

View File

@@ -0,0 +1,7 @@
# Les liens de partage sont non repertories : ils ne doivent pas etre indexes.
# Ce fichier reste une convention respectee par les robots corrects ; ce qui
# autorise reellement la lecture d'une carte, c'est la connaissance du jeton.
User-agent: *
Disallow: /s/
Disallow: /api/
Allow: /

View File

@@ -4,6 +4,7 @@ import { Toasts } from '@/components/Toasts';
import { DashboardPage } from '@/features/maps/DashboardPage'; import { DashboardPage } from '@/features/maps/DashboardPage';
import { EditorPage } from '@/features/editor/EditorPage'; import { EditorPage } from '@/features/editor/EditorPage';
import { SharedMapPage } from '@/features/sharing/SharedMapPage'; import { SharedMapPage } from '@/features/sharing/SharedMapPage';
import { PrivacyPage } from '@/features/privacy/PrivacyPage';
import { useAppConfigStore } from '@/stores/appConfigStore'; import { useAppConfigStore } from '@/stores/appConfigStore';
import { navigate, useRoute } from '@/hooks/useRoute'; import { navigate, useRoute } from '@/hooks/useRoute';
@@ -36,6 +37,7 @@ export function App() {
{route.name === 'dashboard' ? <DashboardPage /> : null} {route.name === 'dashboard' ? <DashboardPage /> : null}
{route.name === 'editor' ? <EditorPage mapId={route.mapId} /> : null} {route.name === 'editor' ? <EditorPage mapId={route.mapId} /> : null}
{route.name === 'shared' ? <SharedMapPage token={route.token} /> : null} {route.name === 'shared' ? <SharedMapPage token={route.token} /> : null}
{route.name === 'privacy' ? <PrivacyPage /> : null}
{route.name === 'notFound' ? <NotFoundPage /> : null} {route.name === 'notFound' ? <NotFoundPage /> : null}
</main> </main>
<Toasts /> <Toasts />

View File

@@ -235,6 +235,14 @@ export const MapIcon = (p: IconProps) => (
</Svg> </Svg>
); );
export const FoldIcon = (p: IconProps) => (
<Svg {...p}>
<path d="M4 6h16M4 18h16" />
<path d="M12 9v-3l-2 2M12 6l2 2" />
<path d="M12 15v3l-2-2M12 18l2-2" />
</Svg>
);
export const SpinnerIcon = ({ className }: IconProps) => ( export const SpinnerIcon = ({ className }: IconProps) => (
<svg <svg
viewBox="0 0 24 24" viewBox="0 0 24 24"

View File

@@ -0,0 +1,94 @@
/**
* Texte de la page Confidentialite.
*
* Centralise ici pour qu'un exploitant puisse l'adapter a son instance sans
* fouiller dans les composants. Volontairement factuel : ce sont des faits
* verifiables sur le fonctionnement de l'application, pas un texte juridique
* invente.
*
* `{app}` est remplace par le nom de l'instance.
*/
export interface PrivacySection {
title: string;
paragraphs: string[];
bullets?: string[];
}
export const PRIVACY_INTRO =
"{app} fonctionne d'abord dans votre navigateur. Cette page decrit ce qui reste sur " +
'votre appareil, ce qui part vers le serveur, et a quel moment.';
export const PRIVACY_SECTIONS: PrivacySection[] = [
{
title: 'Vos cartes restent sur cet appareil',
paragraphs: [
"Les cartes que vous creez sont enregistrees dans l'IndexedDB de ce navigateur. " +
"Elles ne sont pas envoyees au serveur, qui n'en a donc aucune connaissance.",
'Consequence directe : vider les donnees du site, changer de navigateur ou de ' +
'machine vous fait perdre ces cartes. Exportez-les en .mmap pour en garder une ' +
'copie portable.',
],
},
{
title: 'Le partage est une action explicite',
paragraphs: [
"Quand vous creez un lien de partage, et seulement a ce moment, une copie de la " +
'carte est envoyee au serveur et enregistree dans sa base de donnees.',
],
bullets: [
'Le lien obtenu est non repertorie : il ne figure dans aucune liste publique.',
"Il n'est pas protege par un mot de passe : toute personne qui connait le lien " +
'peut consulter la carte.',
'La copie serveur reste figee tant que vous ne demandez pas sa mise a jour.',
"Elle est conservee jusqu'a son expiration, ou jusqu'a ce que vous la supprimiez.",
],
},
{
title: 'La cle de gestion',
paragraphs: [
'Mettre a jour ou supprimer un partage demande une cle de gestion, generee au ' +
'moment du partage. Le serveur ne stocke que son empreinte : il ne peut donc ' +
'pas la retrouver, et personne ne peut vous la redonner.',
"Elle reste dans ce navigateur. Vous pouvez l'exporter dans un fichier pour la " +
'conserver ailleurs, puis la reimporter depuis un autre navigateur.',
],
},
{
title: 'Aucun compte, aucune publicite, aucune telemetrie',
paragraphs: [
"{app} ne demande aucune inscription et ne cree aucun profil utilisateur. " +
"L'application n'affiche aucune publicite et n'envoie aucune statistique " +
"d'usage : il n'existe pas de code de mesure d'audience dans le projet.",
"Les seules requetes reseau sont celles vers cette instance : la configuration " +
"publique, et les partages que vous creez ou consultez. Aucune ressource n'est " +
'chargee depuis un service tiers.',
],
},
{
title: 'Journaux du serveur',
paragraphs: [
"Le serveur journalise les requetes qu'il recoit pour permettre son exploitation. " +
'Les jetons de partage y sont remplaces par [REDACTED], et la cle de gestion ' +
"n'y apparait jamais.",
"Un reverse proxy place devant cette instance possede ses propres journaux, qui " +
"ne dependent pas d'{app} : leur contenu releve de son exploitant.",
],
},
{
title: 'Reprendre la main',
paragraphs: [
'Vous pouvez a tout moment supprimer un partage depuis la carte correspondante : ' +
'le lien cesse alors de fonctionner et la copie serveur est effacee. Une carte ' +
'locale se supprime depuis le tableau de bord.',
],
},
];
/** Phrase affichee discretement sur le tableau de bord. */
export const LOCAL_FIRST_NOTE =
'Vos cartes restent sur cet appareil tant que vous ne les partagez pas.';
export function applyAppName(text: string, appName: string): string {
return text.split('{app}').join(appName);
}

View File

@@ -0,0 +1,177 @@
import { useEffect, useMemo, useState } from 'react';
import { Button } from '@/components/Button';
import { Modal } from '@/components/Modal';
import {
OutlineError,
looksIndented,
parseOutline,
toFlatLines,
type OutlineNode,
} from '@/services/outlineParser';
import { useAppConfigStore } from '@/stores/appConfigStore';
import { useEditorStore } from '@/stores/editorStore';
import { useUiStore } from '@/stores/uiStore';
export interface AddChildrenDialogProps {
open: boolean;
parentId: string | null;
/** Texte pre-rempli, par exemple le contenu colle. */
initialText?: string;
onClose: () => void;
}
interface Analysis {
mode: 'flat' | 'outline';
roots: OutlineNode[];
count: number;
error: string | null;
}
function analyse(text: string, maxNodes: number): Analysis {
if (text.trim().length === 0) {
return { mode: 'flat', roots: [], count: 0, error: null };
}
if (!looksIndented(text)) {
const lines = toFlatLines(text);
return {
mode: 'flat',
roots: lines.map((line) => ({ text: line, children: [] })),
count: lines.length,
error: lines.length > maxNodes ? `Maximum ${maxNodes} noeuds.` : null,
};
}
try {
const result = parseOutline(text, maxNodes);
return { mode: 'outline', roots: result.roots, count: result.count, error: null };
} catch (error) {
return {
mode: 'outline',
roots: [],
count: 0,
error: error instanceof OutlineError ? error.message : "Ce texte n'a pas pu etre analyse.",
};
}
}
function PreviewTree({ nodes, depth = 0 }: { nodes: OutlineNode[]; depth?: number }) {
return (
<ul className={depth === 0 ? 'space-y-0.5' : 'space-y-0.5 border-l border-zinc-200 pl-3 dark:border-zinc-700'}>
{nodes.map((node, index) => (
<li key={`${depth}-${index}-${node.text}`}>
<span className="truncate text-xs text-zinc-600 dark:text-zinc-300">{node.text}</span>
{node.children.length > 0 ? <PreviewTree nodes={node.children} depth={depth + 1} /> : null}
</li>
))}
</ul>
);
}
/**
* Creation rapide de plusieurs noeuds.
*
* Une ligne = un enfant. Si le texte est indente, la hierarchie est reprise
* telle quelle. L'apercu montre exactement ce qui sera cree.
*/
export function AddChildrenDialog({ open, parentId, initialText, onClose }: AddChildrenDialogProps) {
const addChildren = useEditorStore((state) => state.addChildren);
const insertOutline = useEditorStore((state) => state.insertOutline);
const parentName = useEditorStore((state) =>
state.map?.nodes.find((node) => node.id === parentId)?.text,
);
const maxNodes = useAppConfigStore((state) => state.maxNodes);
const pushToast = useUiStore((state) => state.pushToast);
const [text, setText] = useState('');
useEffect(() => {
if (open) setText(initialText ?? '');
}, [open, initialText]);
const analysis = useMemo(() => analyse(text, maxNodes), [text, maxNodes]);
const canSubmit = analysis.count > 0 && analysis.error === null;
const submit = () => {
if (!canSubmit) return;
const created =
analysis.mode === 'outline'
? insertOutline(parentId, analysis.roots)
: addChildren(
parentId,
analysis.roots.map((node) => node.text),
);
if (created > 0) {
pushToast(`${created} noeud(s) ajoute(s).`, 'success');
onClose();
}
};
return (
<Modal
open={open}
title="Ajouter plusieurs enfants"
description={
parentName
? `Une ligne = un enfant de "${parentName}". Indentez pour creer une arborescence.`
: 'Une ligne = un noeud. Indentez pour creer une arborescence.'
}
onClose={onClose}
footer={
<>
<Button onClick={onClose}>Annuler</Button>
<Button variant="primary" onClick={submit} disabled={!canSubmit}>
{analysis.count > 0 ? `Creer ${analysis.count} noeud(s)` : 'Creer'}
</Button>
</>
}
>
<div className="space-y-3">
<div>
<label className="field-label" htmlFor="add-children-text">
Contenu
</label>
<textarea
id="add-children-text"
autoFocus
spellCheck={false}
className="field-input min-h-[180px] resize-y font-mono text-xs leading-relaxed"
placeholder={'VLAN\nVPN\nFirewall\n\nou avec indentation :\nReseau\n VLAN\n VPN'}
value={text}
onChange={(event) => setText(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
submit();
}
}}
/>
<p className="mt-1 text-[11px] text-zinc-400">
Les lignes vides sont ignorees, les puces (-, *, 1.) sont retirees. Ctrl+Entree valide.
</p>
</div>
{analysis.error ? (
<p
role="alert"
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700 dark:border-red-900 dark:bg-red-950/50 dark:text-red-300"
>
{analysis.error}
</p>
) : null}
{analysis.count > 0 && !analysis.error ? (
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-700 dark:bg-zinc-900/60">
<p className="mb-2 text-[11px] font-medium uppercase tracking-wide text-zinc-500">
Apercu {analysis.mode === 'outline' ? '(arborescence)' : '(liste)'}
</p>
<div className="max-h-48 overflow-y-auto">
<PreviewTree nodes={analysis.roots} />
</div>
</div>
) : null}
</div>
</Modal>
);
}

View File

@@ -0,0 +1,50 @@
import { ViewportPortal, useStore } from '@xyflow/react';
import { type AlignmentGuide } from '@/features/editor/alignmentGuides';
const GUIDE_COLOR = '#f59e0b';
/**
* Rendu des guides dans le repere du canvas.
* L'epaisseur est compensee par le zoom pour rester fine a l'ecran.
*/
export function AlignmentGuidesLayer({ guides }: { guides: AlignmentGuide[] }) {
const zoom = useStore((state) => state.transform[2]);
if (guides.length === 0) return null;
const thickness = Math.max(0.5, 1 / zoom);
return (
<ViewportPortal>
{guides.map((guide) => {
const vertical = guide.orientation === 'vertical';
const margin = 24;
return (
<div
key={`${guide.orientation}-${guide.position}`}
aria-hidden="true"
style={{
position: 'absolute',
pointerEvents: 'none',
background: GUIDE_COLOR,
opacity: 0.85,
zIndex: 5,
...(vertical
? {
left: guide.position,
top: guide.start - margin,
width: thickness,
height: guide.end - guide.start + margin * 2,
}
: {
left: guide.start - margin,
top: guide.position,
height: thickness,
width: guide.end - guide.start + margin * 2,
}),
}}
/>
);
})}
</ViewportPortal>
);
}

View File

@@ -10,6 +10,7 @@ import { useAutosave } from '@/features/editor/useAutosave';
import { useEditorShortcuts } from '@/features/editor/useEditorShortcuts'; import { useEditorShortcuts } from '@/features/editor/useEditorShortcuts';
import { ShareDialog } from '@/features/sharing/ShareDialog'; import { ShareDialog } from '@/features/sharing/ShareDialog';
import { getMap } from '@/services/mapRepository'; import { getMap } from '@/services/mapRepository';
import { toFlatLines } from '@/services/outlineParser';
import { useEditorStore } from '@/stores/editorStore'; import { useEditorStore } from '@/stores/editorStore';
import { useUiStore } from '@/stores/uiStore'; import { useUiStore } from '@/stores/uiStore';
import { navigate } from '@/hooks/useRoute'; import { navigate } from '@/hooks/useRoute';
@@ -28,6 +29,7 @@ function EditorPageContent({ mapId }: EditorPageProps) {
const searchPanelOpen = useUiStore((state) => state.searchPanelOpen); const searchPanelOpen = useUiStore((state) => state.searchPanelOpen);
const setSearchPanelOpen = useUiStore((state) => state.setSearchPanelOpen); const setSearchPanelOpen = useUiStore((state) => state.setSearchPanelOpen);
const pushToast = useUiStore((state) => state.pushToast); const pushToast = useUiStore((state) => state.pushToast);
const requestAddChildren = useUiStore((state) => state.requestAddChildren);
const [status, setStatus] = useState<'loading' | 'ready' | 'missing'>('loading'); const [status, setStatus] = useState<'loading' | 'ready' | 'missing'>('loading');
const [shareOpen, setShareOpen] = useState(false); const [shareOpen, setShareOpen] = useState(false);
@@ -39,7 +41,8 @@ function EditorPageContent({ mapId }: EditorPageProps) {
let cancelled = false; let cancelled = false;
setStatus('loading'); setStatus('loading');
void getMap(mapId).then((map) => { getMap(mapId)
.then((map) => {
if (cancelled) return; if (cancelled) return;
if (!map) { if (!map) {
setStatus('missing'); setStatus('missing');
@@ -47,6 +50,10 @@ function EditorPageContent({ mapId }: EditorPageProps) {
} }
loadMap(map); loadMap(map);
setStatus('ready'); setStatus('ready');
})
.catch(() => {
// Document illisible : message propre plutot qu'une erreur technique.
if (!cancelled) setStatus('missing');
}); });
return () => { return () => {
@@ -66,8 +73,42 @@ function EditorPageContent({ mapId }: EditorPageProps) {
}, },
onFit: requestFit, onFit: requestFit,
onFocusSearch: openSearch, onFocusSearch: openSearch,
onAddChildren: (parentId) => requestAddChildren(parentId),
}); });
/*
* Collage intelligent.
*
* On ecoute l'evenement `paste` plutot que de lire le presse-papiers :
* l'evenement porte le texte sans demander de permission. Le presse-papiers
* interne d'OpenMind reste prioritaire (gere par le raccourci Ctrl+V), et
* l'edition d'un texte n'est jamais interceptee.
*/
useEffect(() => {
if (status !== 'ready') return undefined;
const onPaste = (event: ClipboardEvent) => {
const store = useEditorStore.getState();
if (store.readOnly || store.editingNodeId !== null) return;
const target = event.target;
if (target instanceof HTMLElement) {
const tag = target.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || target.isContentEditable) return;
}
if ((store.clipboard?.nodes.length ?? 0) > 0) return;
const text = event.clipboardData?.getData('text/plain') ?? '';
if (toFlatLines(text).length < 2) return;
event.preventDefault();
requestAddChildren(store.selectedNodeIds[0] ?? null, text);
};
window.addEventListener('paste', onPaste);
return () => window.removeEventListener('paste', onPaste);
}, [status, requestAddChildren]);
if (status === 'loading') { if (status === 'loading') {
return ( return (
<div className="flex h-full items-center justify-center text-zinc-500"> <div className="flex h-full items-center justify-center text-zinc-500">

View File

@@ -8,6 +8,7 @@ import {
ArrowLeftIcon, ArrowLeftIcon,
ChevronDownIcon, ChevronDownIcon,
FitIcon, FitIcon,
FoldIcon,
GridIcon, GridIcon,
LayoutIcon, LayoutIcon,
MoonIcon, MoonIcon,
@@ -24,8 +25,14 @@ import {
ZoomOutIcon, ZoomOutIcon,
} from '@/components/Icons'; } from '@/components/Icons';
import { ExportMenu } from '@/features/export/ExportMenu'; import { ExportMenu } from '@/features/export/ExportMenu';
import { LAYOUT_OPTIONS } from '@/features/editor/layout'; import {
LAYOUT_OPTIONS,
SPACING_LIMITS,
SPACING_PRESETS,
resolveSpacing,
} from '@/features/editor/layout';
import { THEMES } from '@/features/editor/themes'; import { THEMES } from '@/features/editor/themes';
import { useAppConfigStore } from '@/stores/appConfigStore';
import { selectCanRedo, selectCanUndo, useEditorStore } from '@/stores/editorStore'; import { selectCanRedo, selectCanUndo, useEditorStore } from '@/stores/editorStore';
import { useUiStore } from '@/stores/uiStore'; import { useUiStore } from '@/stores/uiStore';
import { navigate } from '@/hooks/useRoute'; import { navigate } from '@/hooks/useRoute';
@@ -63,14 +70,22 @@ export function EditorToolbar({ onOpenShare, onToggleSearch }: EditorToolbarProp
const updateSettings = useEditorStore((state) => state.updateSettings); const updateSettings = useEditorStore((state) => state.updateSettings);
const renameMap = useEditorStore((state) => state.renameMap); const renameMap = useEditorStore((state) => state.renameMap);
const requestFit = useEditorStore((state) => state.requestFit); const requestFit = useEditorStore((state) => state.requestFit);
const setAllCollapsed = useEditorStore((state) => state.setAllCollapsed);
const collapseToLevel = useEditorStore((state) => state.collapseToLevel);
const balanceSides = useEditorStore((state) => state.balanceSides);
const sharingEnabled = useAppConfigStore((state) => state.sharingEnabled);
const serverReachable = useAppConfigStore((state) => state.serverReachable);
const colorMode = useUiStore((state) => state.colorMode); const colorMode = useUiStore((state) => state.colorMode);
const toggleColorMode = useUiStore((state) => state.toggleColorMode); const toggleColorMode = useUiStore((state) => state.toggleColorMode);
const pushToast = useUiStore((state) => state.pushToast);
const propertiesPanelOpen = useUiStore((state) => state.propertiesPanelOpen); const propertiesPanelOpen = useUiStore((state) => state.propertiesPanelOpen);
const setPropertiesPanelOpen = useUiStore((state) => state.setPropertiesPanelOpen); const setPropertiesPanelOpen = useUiStore((state) => state.setPropertiesPanelOpen);
const { zoomIn, zoomOut } = useReactFlow(); const { zoomIn, zoomOut } = useReactFlow();
const [name, setName] = useState(map?.name ?? ''); const [name, setName] = useState(map?.name ?? '');
const spacing = resolveSpacing(map?.settings.layoutSpacing);
useEffect(() => setName(map?.name ?? ''), [map?.id, map?.name]); useEffect(() => setName(map?.name ?? ''), [map?.id, map?.name]);
@@ -149,18 +164,102 @@ export function EditorToolbar({ onOpenShare, onToggleSearch }: EditorToolbarProp
{(close) => ( {(close) => (
<> <>
<MenuLabel>Disposition</MenuLabel> <MenuLabel>Disposition</MenuLabel>
{/*
Choisir une disposition ne deplace rien : c'est "Reorganiser" qui
applique. Les positions placees a la main ne bougent donc jamais
sans que l'utilisateur le demande.
*/}
{LAYOUT_OPTIONS.map((option) => ( {LAYOUT_OPTIONS.map((option) => (
<MenuItem <MenuItem
key={option.id} key={option.id}
label={option.label} label={option.label}
active={map.settings.layout === option.id} active={map.settings.layout === option.id}
onSelect={() => updateSettings({ layout: option.id })}
/>
))}
<p className="px-2.5 pb-2 pt-1 text-[11px] leading-snug text-zinc-400">
Choisir une disposition ne deplace rien : utilisez Reorganiser.
</p>
<MenuSeparator />
<MenuLabel>Espacement</MenuLabel>
<div className="flex gap-1 px-2.5 pb-2">
{SPACING_PRESETS.map((preset) => (
<button
key={preset.id}
type="button"
onClick={() => updateSettings({ layoutSpacing: preset.spacing })}
aria-pressed={
spacing.parentGap === preset.spacing.parentGap &&
spacing.siblingGap === preset.spacing.siblingGap
}
className={`flex-1 rounded-md border px-2 py-1 text-xs font-medium transition-colors ${
spacing.parentGap === preset.spacing.parentGap &&
spacing.siblingGap === preset.spacing.siblingGap
? 'border-indigo-400 bg-indigo-50 text-indigo-700 dark:bg-indigo-950/50 dark:text-indigo-300'
: 'border-zinc-200 text-zinc-600 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800'
}`}
>
{preset.label}
</button>
))}
</div>
<div className="space-y-2 px-2.5 pb-2">
<label className="block text-[11px] text-zinc-500" htmlFor="spacing-parent">
Distance parent / enfant : {spacing.parentGap} px
</label>
<input
id="spacing-parent"
type="range"
min={SPACING_LIMITS.parentGap.min}
max={SPACING_LIMITS.parentGap.max}
step={5}
value={spacing.parentGap}
onChange={(event) =>
updateSettings({
layoutSpacing: { ...spacing, parentGap: Number(event.target.value) },
})
}
className="h-1.5 w-full accent-indigo-600"
/>
<label className="block text-[11px] text-zinc-500" htmlFor="spacing-sibling">
Espacement entre branches : {spacing.siblingGap} px
</label>
<input
id="spacing-sibling"
type="range"
min={SPACING_LIMITS.siblingGap.min}
max={SPACING_LIMITS.siblingGap.max}
step={5}
value={spacing.siblingGap}
onChange={(event) =>
updateSettings({
layoutSpacing: { ...spacing, siblingGap: Number(event.target.value) },
})
}
className="h-1.5 w-full accent-indigo-600"
/>
<p className="text-[11px] leading-snug text-zinc-400">
Changer l&apos;espacement ne deplace rien : relancez l&apos;organisation.
</p>
</div>
<MenuSeparator />
<MenuItem
label="Repartir les branches sans cote"
hint="bilaterale"
onSelect={() => { onSelect={() => {
applyAutoLayout(option.id); const changed = balanceSides();
pushToast(
changed > 0
? `${changed} branche(s) reparties. Relancez l'organisation pour les deplacer.`
: 'Toutes les branches ont deja un cote.',
changed > 0 ? 'success' : 'info',
);
close(); close();
}} }}
/> />
))}
<MenuSeparator />
<MenuItem <MenuItem
label="Reorganiser avec la disposition actuelle" label="Reorganiser avec la disposition actuelle"
onSelect={() => { onSelect={() => {
@@ -211,6 +310,68 @@ export function EditorToolbar({ onOpenShare, onToggleSearch }: EditorToolbarProp
label={map.settings.showGrid ? 'Masquer la grille' : 'Afficher la grille'} label={map.settings.showGrid ? 'Masquer la grille' : 'Afficher la grille'}
onSelect={() => updateSettings({ showGrid: !map.settings.showGrid })} onSelect={() => updateSettings({ showGrid: !map.settings.showGrid })}
/> />
<MenuItem
label="Aimanter sur la grille"
active={map.settings.snapToGrid === true}
hint={`${map.settings.snapGrid ?? 20} px`}
onSelect={() => updateSettings({ snapToGrid: !map.settings.snapToGrid })}
/>
<MenuItem
label="Guides d'alignement"
active={map.settings.alignmentGuides !== false}
onSelect={() =>
updateSettings({ alignmentGuides: map.settings.alignmentGuides === false })
}
/>
</>
)}
</Menu>
<Menu
label="Affichage des branches"
trigger={
<Button size="sm">
<FoldIcon className="h-4 w-4" />
<span className="hidden lg:inline">Branches</span>
<ChevronDownIcon className="h-3.5 w-3.5 opacity-60" />
</Button>
}
>
{(close) => (
<>
<MenuItem
label="Tout replier"
onSelect={() => {
setAllCollapsed(true);
close();
}}
/>
<MenuItem
label="Tout developper"
onSelect={() => {
setAllCollapsed(false);
close();
}}
/>
<MenuSeparator />
<MenuLabel>Replier au-dela du niveau</MenuLabel>
{[1, 2, 3].map((level) => (
<MenuItem
key={level}
label={`Niveau ${level}`}
onSelect={() => {
collapseToLevel(level);
close();
}}
/>
))}
<MenuSeparator />
<MenuItem
label="Le glisser-deposer entraine la branche"
active={map.settings.dragMovesBranch === true}
hint="Alt inverse"
onSelect={() => updateSettings({ dragMovesBranch: !map.settings.dragMovesBranch })}
/>
</> </>
)} )}
</Menu> </Menu>
@@ -238,10 +399,24 @@ export function EditorToolbar({ onOpenShare, onToggleSearch }: EditorToolbarProp
<div className="mx-1 h-6 w-px bg-zinc-200 dark:bg-zinc-700" /> <div className="mx-1 h-6 w-px bg-zinc-200 dark:bg-zinc-700" />
<Button size="sm" onClick={onOpenShare}> {/*
Instance privee (le serveur repond que le partage est coupe) : le
bouton disparait. Serveur injoignable : il reste visible mais inactif,
car l'etat du partage est simplement inconnu.
*/}
{sharingEnabled || !serverReachable ? (
<Button
size="sm"
onClick={onOpenShare}
disabled={!serverReachable}
title={
serverReachable ? undefined : 'Serveur de partage injoignable : travail local en cours'
}
>
<ShareIcon className="h-4 w-4" /> <ShareIcon className="h-4 w-4" />
<span className="hidden md:inline">Partager</span> <span className="hidden md:inline">Partager</span>
</Button> </Button>
) : null}
<ExportMenu /> <ExportMenu />
</div> </div>
</header> </header>

View File

@@ -0,0 +1,183 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Button } from '@/components/Button';
import { Modal } from '@/components/Modal';
import { MarkdownError, parseMarkdown } from '@/services/markdown';
import { type OutlineNode } from '@/services/outlineParser';
import { useAppConfigStore } from '@/stores/appConfigStore';
import { useEditorStore } from '@/stores/editorStore';
import { useUiStore } from '@/stores/uiStore';
export interface MarkdownImportDialogProps {
open: boolean;
/** Noeud sous lequel importer. `null` = a la racine de la carte. */
parentId: string | null;
onClose: () => void;
}
interface Analysis {
roots: OutlineNode[];
count: number;
error: string | null;
}
function analyse(text: string, maxNodes: number): Analysis {
if (text.trim().length === 0) return { roots: [], count: 0, error: null };
try {
const result = parseMarkdown(text, maxNodes);
return { roots: result.roots, count: result.count, error: null };
} catch (error) {
return {
roots: [],
count: 0,
error:
error instanceof MarkdownError
? error.message
: "Ce document n'a pas pu etre analyse.",
};
}
}
function PreviewTree({ nodes, depth = 0 }: { nodes: OutlineNode[]; depth?: number }) {
return (
<ul
className={
depth === 0
? 'space-y-0.5'
: 'space-y-0.5 border-l border-zinc-200 pl-3 dark:border-zinc-700'
}
>
{nodes.map((node, index) => (
<li key={`${depth}-${index}-${node.text}`}>
<span className="text-xs text-zinc-600 dark:text-zinc-300">{node.text}</span>
{node.note ? (
<span className="ml-1 text-[10px] uppercase tracking-wide text-indigo-500">note</span>
) : null}
{node.children.length > 0 ? <PreviewTree nodes={node.children} depth={depth + 1} /> : null}
</li>
))}
</ul>
);
}
/**
* Import Markdown dans une carte ouverte.
*
* Le contenu devient un sous-arbre du noeud vise, en une seule entree
* d'historique. Les paragraphes rejoignent la note du noeud correspondant, les
* blocs de code aussi : rien n'est execute, rien n'est rendu en HTML.
*/
export function MarkdownImportDialog({ open, parentId, onClose }: MarkdownImportDialogProps) {
const insertOutline = useEditorStore((state) => state.insertOutline);
const parentName = useEditorStore(
(state) => state.map?.nodes.find((node) => node.id === parentId)?.text,
);
const maxNodes = useAppConfigStore((state) => state.maxNodes);
const pushToast = useUiStore((state) => state.pushToast);
const [text, setText] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (open) setText('');
}, [open]);
const analysis = useMemo(() => analyse(text, maxNodes), [text, maxNodes]);
const canSubmit = analysis.count > 0 && analysis.error === null;
const submit = () => {
if (!canSubmit) return;
const created = insertOutline(parentId, analysis.roots);
if (created > 0) {
pushToast(`${created} noeud(s) importe(s) depuis le Markdown.`, 'success');
onClose();
}
};
const readFile = async (file: File) => {
const content = await file.text();
setText(content);
};
return (
<Modal
open={open}
title="Importer du Markdown"
description={
parentName
? `Le contenu deviendra un sous-arbre de "${parentName}".`
: 'Le contenu sera ajoute a la carte.'
}
onClose={onClose}
footer={
<>
<Button onClick={onClose}>Annuler</Button>
<Button variant="primary" onClick={submit} disabled={!canSubmit}>
{analysis.count > 0 ? `Importer ${analysis.count} noeud(s)` : 'Importer'}
</Button>
</>
}
>
<div className="space-y-3">
<div className="flex items-center gap-2">
<Button size="sm" onClick={() => fileInputRef.current?.click()}>
Choisir un fichier .md
</Button>
<span className="text-[11px] text-zinc-400">ou collez le contenu ci-dessous</span>
<input
ref={fileInputRef}
type="file"
accept=".md,.markdown,text/markdown,text/plain"
className="sr-only"
aria-label="Choisir un fichier Markdown"
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = '';
if (file) void readFile(file);
}}
/>
</div>
<textarea
id="markdown-import-text"
autoFocus
spellCheck={false}
className="field-input min-h-[200px] resize-y font-mono text-xs leading-relaxed"
placeholder={'# Infrastructure\n\n## Reseau\n- VLAN\n- VPN\n\n## Virtualisation\n- Proxmox\n - VM'}
value={text}
onChange={(event) => setText(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
submit();
}
}}
aria-label="Contenu Markdown"
/>
<p className="text-[11px] text-zinc-400">
Titres (#, ##, ###) et listes construisent la hierarchie. Les paragraphes et les blocs de
code deviennent la note du noeud au-dessus. Ctrl+Entree valide.
</p>
{analysis.error ? (
<p
role="alert"
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700 dark:border-red-900 dark:bg-red-950/50 dark:text-red-300"
>
{analysis.error}
</p>
) : null}
{canSubmit ? (
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-700 dark:bg-zinc-900/60">
<p className="mb-2 text-[11px] font-medium uppercase tracking-wide text-zinc-500">
Apercu - {analysis.count} noeud(s) seront crees
</p>
<div className="max-h-48 overflow-y-auto">
<PreviewTree nodes={analysis.roots} />
</div>
</div>
) : null}
</div>
</Modal>
);
}

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
Background, Background,
BackgroundVariant, BackgroundVariant,
@@ -12,9 +12,29 @@ import {
type NodeChange, type NodeChange,
} from '@xyflow/react'; } from '@xyflow/react';
import '@xyflow/react/dist/style.css'; import '@xyflow/react/dist/style.css';
import { type MindFlowNode, toFlowEdges, toFlowNodes } from '@/features/editor/flowAdapters'; import {
type MindFlowNode,
toFlowEdges,
toFlowNodes,
toFlowRelationEdges,
} from '@/features/editor/flowAdapters';
import { MindNodeView } from '@/features/editor/MindNodeView'; import { MindNodeView } from '@/features/editor/MindNodeView';
import { getTheme } from '@/features/editor/themes'; import { getTheme } from '@/features/editor/themes';
import { AlignmentGuidesLayer } from '@/features/editor/AlignmentGuidesLayer';
import {
computeAlignmentGuides,
type AlignmentGuide,
type GuideBox,
} from '@/features/editor/alignmentGuides';
import {
NodeContextMenu,
type NodeContextMenuState,
} from '@/features/editor/NodeContextMenu';
import { AddChildrenDialog } from '@/features/editor/AddChildrenDialog';
import { MoveToDialog } from '@/features/editor/MoveToDialog';
import { RelationDialog } from '@/features/editor/RelationDialog';
import { MarkdownImportDialog } from '@/features/editor/MarkdownImportDialog';
import { SelectionToolbar } from '@/features/editor/SelectionToolbar';
import { useEditorStore } from '@/stores/editorStore'; import { useEditorStore } from '@/stores/editorStore';
import { useUiStore } from '@/stores/uiStore'; import { useUiStore } from '@/stores/uiStore';
import { resolveNodeSize } from '@/utils/nodeSize'; import { resolveNodeSize } from '@/utils/nodeSize';
@@ -39,10 +59,34 @@ export function MindMapCanvas({
const focusRequest = useEditorStore((state) => state.focusRequest); const focusRequest = useEditorStore((state) => state.focusRequest);
const fitRequest = useEditorStore((state) => state.fitRequest); const fitRequest = useEditorStore((state) => state.fitRequest);
const sizeVersion = useEditorStore((state) => state.sizeVersion); const sizeVersion = useEditorStore((state) => state.sizeVersion);
const selectedRelationId = useEditorStore((state) => state.selectedRelationId);
const pushToast = useUiStore((state) => state.pushToast); const pushToast = useUiStore((state) => state.pushToast);
const { fitView, setCenter, screenToFlowPosition } = useReactFlow(); const { fitView, setCenter, screenToFlowPosition } = useReactFlow();
const initialisedFor = useRef<string | null>(null); const initialisedFor = useRef<string | null>(null);
/**
* Vrai pendant un drag qui doit entrainer toute la branche.
* Le reglage de la carte donne le comportement par defaut ; la touche Alt
* l'inverse le temps d'un deplacement.
*/
const branchDragRef = useRef(false);
const [guides, setGuides] = useState<AlignmentGuide[]>([]);
const [contextMenu, setContextMenu] = useState<NodeContextMenuState | null>(null);
const [addChildrenFor, setAddChildrenFor] = useState<{ parentId: string | null; text: string } | null>(
null,
);
const [moveTarget, setMoveTarget] = useState<string | null>(null);
const [relationSource, setRelationSource] = useState<string | null>(null);
const [markdownParent, setMarkdownParent] = useState<string | null>(null);
const addChildrenRequest = useUiStore((state) => state.addChildrenRequest);
const clearAddChildrenRequest = useUiStore((state) => state.clearAddChildrenRequest);
// Ouverture demandee par un raccourci clavier ou par un collage multi-ligne.
useEffect(() => {
if (readOnly || !addChildrenRequest) return;
setAddChildrenFor({ parentId: addChildrenRequest.parentId, text: addChildrenRequest.text });
clearAddChildrenRequest();
}, [addChildrenRequest, clearAddChildrenRequest, readOnly]);
const theme = useMemo(() => getTheme(map?.settings.theme), [map?.settings.theme]); const theme = useMemo(() => getTheme(map?.settings.theme), [map?.settings.theme]);
@@ -53,15 +97,35 @@ export function MindMapCanvas({
searchResults, searchResults,
editingNodeId, editingNodeId,
readOnly, readOnly,
selectedRelationId,
measuredSizes: useEditorStore.getState().measuredSizes, measuredSizes: useEditorStore.getState().measuredSizes,
}), }),
// sizeVersion force le recalcul quand une taille de noeud a change. // sizeVersion force le recalcul quand une taille de noeud a change.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[theme, selectedNodeIds, searchResults, editingNodeId, readOnly, sizeVersion], [
theme,
selectedNodeIds,
searchResults,
editingNodeId,
readOnly,
sizeVersion,
selectedRelationId,
],
); );
const nodes = useMemo(() => (map ? toFlowNodes(map, adapterOptions) : []), [map, adapterOptions]); const nodes = useMemo(() => (map ? toFlowNodes(map, adapterOptions) : []), [map, adapterOptions]);
const edges = useMemo(() => (map ? toFlowEdges(map, adapterOptions) : []), [map, adapterOptions]);
/*
* Les aretes affichees sont l'union de deux choses de nature differente : les
* liens hierarchiques, derives de `parentId`, et les relations secondaires,
* qui sont une donnee du document. Elles ne se melangent qu'ici, a
* l'affichage : le modele, lui, les garde separees.
*/
const edges = useMemo(
() =>
map ? [...toFlowEdges(map, adapterOptions), ...toFlowRelationEdges(map, adapterOptions)] : [],
[map, adapterOptions],
);
// Cadrage initial a l'ouverture d'une carte. // Cadrage initial a l'ouverture d'une carte.
useEffect(() => { useEffect(() => {
@@ -82,12 +146,12 @@ export function MindMapCanvas({
if (!focusRequest || !map) return; if (!focusRequest || !map) return;
const target = map.nodes.find((node) => node.id === focusRequest.nodeId); const target = map.nodes.find((node) => node.id === focusRequest.nodeId);
if (!target) return; if (!target) return;
const size = resolveNodeSize(target, useEditorStore.getState().measuredSizes); const size = resolveNodeSize(target, useEditorStore.getState().measuredSizes, theme);
setCenter(target.position.x + size.width / 2, target.position.y + size.height / 2, { setCenter(target.position.x + size.width / 2, target.position.y + size.height / 2, {
zoom: 1.15, zoom: 1.15,
duration: 400, duration: 400,
}); });
}, [focusRequest, map, setCenter]); }, [focusRequest, map, setCenter, theme]);
const onNodesChange = useCallback( const onNodesChange = useCallback(
(changes: NodeChange<MindFlowNode>[]) => { (changes: NodeChange<MindFlowNode>[]) => {
@@ -112,7 +176,9 @@ export function MindMapCanvas({
} }
} }
if (positions.length > 0 && !readOnly) store.moveNodes(positions); if (positions.length > 0 && !readOnly) {
store.moveNodes(positions, { withDescendants: branchDragRef.current });
}
if (selection) store.setSelection([...selection]); if (selection) store.setSelection([...selection]);
}, },
[readOnly], [readOnly],
@@ -120,10 +186,22 @@ export function MindMapCanvas({
const onEdgesChange = useCallback( const onEdgesChange = useCallback(
(changes: EdgeChange<Edge>[]) => { (changes: EdgeChange<Edge>[]) => {
if (readOnly) return;
const store = useEditorStore.getState(); const store = useEditorStore.getState();
for (const change of changes) { for (const change of changes) {
if (change.type !== 'remove') continue; if (change.type === 'select') {
// Une relation se selectionne ; un lien hierarchique, non : il n'a
// pas de proprietes propres, tout se regle sur le noeud enfant.
const relation = store.map?.relations.find((candidate) => candidate.id === change.id);
if (relation) store.setSelectedRelation(change.selected ? relation.id : null);
continue;
}
if (change.type !== 'remove' || readOnly) continue;
const relation = store.map?.relations.find((candidate) => candidate.id === change.id);
if (relation) {
store.deleteRelation(relation.id);
continue;
}
const edge = store.map?.edges.find((candidate) => candidate.id === change.id); const edge = store.map?.edges.find((candidate) => candidate.id === change.id);
if (edge) store.detachNode(edge.target); if (edge) store.detachNode(edge.target);
} }
@@ -142,6 +220,40 @@ export function MindMapCanvas({
[readOnly, pushToast], [readOnly, pushToast],
); );
/** Guides d'alignement : etat purement local, jamais persiste ni annulable. */
const handleNodeDrag = useCallback(
(_event: MouseEvent | TouchEvent, dragged: MindFlowNode) => {
const store = useEditorStore.getState();
const current = store.map;
if (!current || current.settings.alignmentGuides === false) return;
const sizes = store.measuredSizes;
const toBox = (node: (typeof current.nodes)[number]): GuideBox => {
const size = resolveNodeSize(node, sizes, theme);
return {
id: node.id,
x: node.id === dragged.id ? dragged.position.x : node.position.x,
y: node.id === dragged.id ? dragged.position.y : node.position.y,
width: size.width,
height: size.height,
};
};
const hiddenIds = new Set(nodes.filter((node) => node.hidden).map((node) => node.id));
const boxes = current.nodes.filter((node) => !hiddenIds.has(node.id)).map(toBox);
const moving = boxes.find((box) => box.id === dragged.id);
if (!moving) return;
setGuides(computeAlignmentGuides(moving, boxes));
},
[nodes, theme],
);
const handleNodeDragStop = useCallback(() => {
setGuides([]);
branchDragRef.current = false;
}, []);
const handlePaneDoubleClick = useCallback( const handlePaneDoubleClick = useCallback(
(event: React.MouseEvent) => { (event: React.MouseEvent) => {
if (readOnly) return; if (readOnly) return;
@@ -171,15 +283,36 @@ export function MindMapCanvas({
onNodesChange={onNodesChange} onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange} onEdgesChange={onEdgesChange}
onConnect={onConnect} onConnect={onConnect}
onNodeDragStart={() => useEditorStore.getState().pushHistory()} onNodeDragStart={(event) => {
const store = useEditorStore.getState();
const byDefault = store.map?.settings.dragMovesBranch === true;
const altPressed = 'altKey' in event ? event.altKey : false;
branchDragRef.current = byDefault !== altPressed;
store.pushHistory();
}}
onNodeDrag={handleNodeDrag}
onNodeDragStop={handleNodeDragStop}
onNodeContextMenu={(event, node) => {
if (readOnly) return;
event.preventDefault();
const store = useEditorStore.getState();
if (!store.selectedNodeIds.includes(node.id)) store.setSelection([node.id]);
setContextMenu({ nodeId: node.id, x: event.clientX, y: event.clientY });
}}
snapToGrid={map.settings.snapToGrid === true}
snapGrid={[map.settings.snapGrid ?? 20, map.settings.snapGrid ?? 20]}
onMoveEnd={(_event, viewport) => useEditorStore.getState().setViewport(viewport)} onMoveEnd={(_event, viewport) => useEditorStore.getState().setViewport(viewport)}
onPaneClick={() => { onPaneClick={() => {
const store = useEditorStore.getState(); const store = useEditorStore.getState();
store.setSelection([]); store.setSelection([]);
store.setEditingNode(null); store.setEditingNode(null);
store.setSelectedRelation(null);
setContextMenu(null);
}} }}
defaultViewport={map.viewport} defaultViewport={map.viewport}
minZoom={0.1} // Plancher bas volontairement : sur une grande carte, "Adapter a
// l'ecran" doit pouvoir descendre assez pour tout montrer.
minZoom={0.02}
maxZoom={3} maxZoom={3}
multiSelectionKeyCode={['Shift', 'Meta', 'Control']} multiSelectionKeyCode={['Shift', 'Meta', 'Control']}
selectionKeyCode="Shift" selectionKeyCode="Shift"
@@ -196,6 +329,8 @@ export function MindMapCanvas({
zoomOnDoubleClick={false} zoomOnDoubleClick={false}
aria-label="Canvas de la carte mentale" aria-label="Canvas de la carte mentale"
> >
<AlignmentGuidesLayer guides={guides} />
{!readOnly ? <SelectionToolbar /> : null}
{map.settings.showGrid ? ( {map.settings.showGrid ? (
<Background variant={BackgroundVariant.Dots} gap={22} size={1} color={theme.gridColor} /> <Background variant={BackgroundVariant.Dots} gap={22} size={1} color={theme.gridColor} />
) : null} ) : null}
@@ -214,6 +349,42 @@ export function MindMapCanvas({
/> />
) : null} ) : null}
</ReactFlow> </ReactFlow>
{contextMenu ? (
<NodeContextMenu
state={contextMenu}
onClose={() => setContextMenu(null)}
onAddChildren={(parentId) => setAddChildrenFor({ parentId, text: '' })}
onMoveTo={(nodeId) => setMoveTarget(nodeId)}
onAddRelation={(nodeId) => setRelationSource(nodeId)}
onImportMarkdown={(nodeId) => setMarkdownParent(nodeId)}
/>
) : null}
<AddChildrenDialog
open={addChildrenFor !== null}
parentId={addChildrenFor?.parentId ?? null}
initialText={addChildrenFor?.text}
onClose={() => setAddChildrenFor(null)}
/>
<MoveToDialog
open={moveTarget !== null}
nodeId={moveTarget}
onClose={() => setMoveTarget(null)}
/>
<RelationDialog
open={relationSource !== null}
sourceId={relationSource}
onClose={() => setRelationSource(null)}
/>
<MarkdownImportDialog
open={markdownParent !== null}
parentId={markdownParent}
onClose={() => setMarkdownParent(null)}
/>
</div> </div>
); );
} }

View File

@@ -1,28 +1,63 @@
import { memo, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Handle, type NodeProps } from '@xyflow/react'; import { Handle, NodeResizer, useUpdateNodeInternals, type NodeProps } from '@xyflow/react';
import { type MindFlowNode } from '@/features/editor/flowAdapters'; import { type MindFlowNode } from '@/features/editor/flowAdapters';
import { LAYOUT_LIMITS } from '@/features/editor/nodeStyleResolver';
import { NodeIconGlyph } from '@/features/editor/NodeIconGlyph';
import { useEditorStore } from '@/stores/editorStore'; import { useEditorStore } from '@/stores/editorStore';
import { LinkIcon, NoteIcon } from '@/components/Icons'; import { LinkIcon, NoteIcon } from '@/components/Icons';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
/** Largeur reservee a un indicateur (note / lien), alignee sur `nodeSize.ts`. */
const INDICATOR_WIDTH = 18;
const VERTICAL_ALIGNMENT = {
top: 'flex-start',
middle: 'center',
bottom: 'flex-end',
} as const;
const CROSS_ALIGNMENT = {
left: 'flex-start',
center: 'center',
right: 'flex-end',
} as const;
/** /**
* Rendu d'un noeud. * Rendu d'un noeud.
* *
* Le texte est toujours rendu comme du texte : aucun HTML provenant d'une carte * Le texte est toujours rendu comme du texte : aucun HTML provenant d'une carte
* n'est jamais interprete. * n'est jamais interprete.
*/ */
function MindNodeViewComponent({ data, id }: NodeProps<MindFlowNode>) { function MindNodeViewComponent({ data, id, selected }: NodeProps<MindFlowNode>) {
const { node, appearance, childCount, hiddenCount, isSearchHit, readOnly, isEditing } = data; const { node, appearance, childCount, hiddenCount, isSearchHit, readOnly, isEditing } = data;
const updateNodeText = useEditorStore((state) => state.updateNodeText); const updateNodeText = useEditorStore((state) => state.updateNodeText);
const setEditingNode = useEditorStore((state) => state.setEditingNode); const setEditingNode = useEditorStore((state) => state.setEditingNode);
const toggleCollapse = useEditorStore((state) => state.toggleCollapse); const toggleCollapse = useEditorStore((state) => state.toggleCollapse);
const setMeasuredSize = useEditorStore((state) => state.setMeasuredSize); const setMeasuredSize = useEditorStore((state) => state.setMeasuredSize);
const beginResize = useEditorStore((state) => state.beginResize);
const resizeNode = useEditorStore((state) => state.resizeNode);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const [draft, setDraft] = useState(node.text); const [draft, setDraft] = useState(node.text);
/*
* React Flow mesure les poignees une seule fois, a leur apparition. Quand une
* branche change de cote, ou que la carte passe en bilaterale, les poignees
* changent d'identifiant et de bord : sans cette notification, React Flow
* garde les anciennes mesures et cesse simplement de dessiner les liens
* concernes.
*/
const updateNodeInternals = useUpdateNodeInternals();
const handleSignature = `${data.targetHandle.id}|${data.sourceHandles
.map((handle) => handle.id)
.join(',')}`;
useEffect(() => {
updateNodeInternals(id);
}, [id, handleSignature, updateNodeInternals]);
useEffect(() => { useEffect(() => {
if (isEditing) setDraft(node.text); if (isEditing) setDraft(node.text);
}, [isEditing, node.text]); }, [isEditing, node.text]);
@@ -44,7 +79,7 @@ function MindNodeViewComponent({ data, id }: NodeProps<MindFlowNode>) {
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [isEditing]); }, [isEditing]);
// Les tailles reelles alimentent l'auto-layout et les exports. // Les tailles reelles alimentent l'auto-layout, la minimap et les exports.
useLayoutEffect(() => { useLayoutEffect(() => {
const element = containerRef.current; const element = containerRef.current;
if (!element) return; if (!element) return;
@@ -52,7 +87,7 @@ function MindNodeViewComponent({ data, id }: NodeProps<MindFlowNode>) {
width: Math.round(element.offsetWidth), width: Math.round(element.offsetWidth),
height: Math.round(element.offsetHeight), height: Math.round(element.offsetHeight),
}); });
}, [id, node.text, node.style, node.icon, setMeasuredSize, isEditing]); }, [id, node.text, node.style, node.layout, node.icon, node.iconStyle, setMeasuredSize, isEditing]);
const commitText = () => { const commitText = () => {
const value = draft.trim(); const value = draft.trim();
@@ -68,14 +103,20 @@ function MindNodeViewComponent({ data, id }: NodeProps<MindFlowNode>) {
const isRoot = node.type === 'root'; const isRoot = node.type === 'root';
const label = node.text || (isRoot ? 'Racine sans titre' : 'Noeud sans titre'); const label = node.text || (isRoot ? 'Racine sans titre' : 'Noeud sans titre');
return ( const hasIcon = Boolean(node.icon);
<div const iconKind = node.iconStyle?.kind ?? 'emoji';
ref={containerRef} const iconOnTop = hasIcon && appearance.iconPosition === 'top';
className={cn(
'mind-node relative select-none px-3.5 py-2', const decorations =
isSearchHit && 'mind-node--search-hit', (hasIcon && !iconOnTop ? appearance.iconSize + appearance.iconGap : 0) +
)} (node.note ? INDICATOR_WIDTH : 0) +
style={{ (node.url ? INDICATOR_WIDTH : 0);
const containerStyle = useMemo(() => {
const fixedWidth = appearance.widthMode === 'fixed' ? appearance.width : undefined;
const fixedHeight = appearance.heightMode === 'fixed' ? appearance.height : undefined;
return {
background: appearance.background, background: appearance.background,
color: appearance.color, color: appearance.color,
border: `${appearance.borderWidth}px solid ${appearance.borderColor}`, border: `${appearance.borderWidth}px solid ${appearance.borderColor}`,
@@ -85,14 +126,68 @@ function MindNodeViewComponent({ data, id }: NodeProps<MindFlowNode>) {
fontSize: appearance.fontSize, fontSize: appearance.fontSize,
fontWeight: appearance.fontWeight, fontWeight: appearance.fontWeight,
fontStyle: appearance.fontStyle, fontStyle: appearance.fontStyle,
textAlign: appearance.textAlign, lineHeight: appearance.lineHeight,
minWidth: appearance.circle ? 96 : 88, paddingLeft: appearance.paddingX,
maxWidth: appearance.width ?? 260, paddingRight: appearance.paddingX,
width: appearance.width, paddingTop: appearance.paddingY,
...(appearance.circle paddingBottom: appearance.paddingY,
? { aspectRatio: '1 / 1', display: 'flex', alignItems: 'center', justifyContent: 'center' } boxSizing: 'border-box' as const,
: {}), display: 'flex',
flexDirection: 'column' as const,
justifyContent: VERTICAL_ALIGNMENT[appearance.verticalAlign],
minWidth: appearance.minWidth,
/*
* En mode automatique, `max-content` plutot que la largeur par defaut :
* le noeud est absolument positionne, sa largeur "auto" se resoudrait
* sinon a la largeur min-content (le mot le plus long), ce qui produirait
* une colonne etroite et haute pour une phrase. Avec `max-content` borne
* par `maxWidth`, une phrase longue occupe la largeur maximale autorisee
* puis passe a la ligne, et un texte court reste compact.
*/
...(fixedWidth ? { width: fixedWidth } : { width: 'max-content' }),
...(fixedHeight ? { height: fixedHeight } : { minHeight: appearance.minHeight }),
...(fixedWidth || !appearance.wrap
? {}
: { maxWidth: appearance.maxTextWidth + appearance.paddingX * 2 + decorations }),
...(appearance.circle && !fixedWidth ? { aspectRatio: '1 / 1', alignItems: 'center' } : {}),
};
}, [appearance, decorations]);
const iconElement = hasIcon ? (
<span
className="shrink-0"
style={{
// Aligne l'icone sur la premiere ligne de texte.
marginTop: iconOnTop
? 0
: Math.max(0, (appearance.fontSize * appearance.lineHeight - appearance.iconSize) / 2),
}} }}
>
<NodeIconGlyph
value={node.icon as string}
kind={iconKind}
size={appearance.iconSize}
/>
</span>
) : null;
/*
* Uniquement `overflow-wrap: break-word`.
* `word-break: break-word` (et `overflow-wrap: anywhere`) ramenent la largeur
* min-content a un seul caractere : dans une boite dimensionnee par son
* contenu, tous les noeuds s'effondreraient a leur largeur minimale.
* Avec `break-word`, la largeur naturelle reste celle du mot le plus long, et
* une URL trop longue n'est coupee que si elle depasse la largeur maximale.
*/
const textStyle = appearance.wrap
? ({ whiteSpace: 'pre-wrap', overflowWrap: 'break-word' } as const)
: ({ whiteSpace: 'pre', overflow: 'hidden', textOverflow: 'ellipsis' } as const);
return (
<div
ref={containerRef}
className={cn('mind-node relative select-none', isSearchHit && 'mind-node--search-hit')}
style={containerStyle}
aria-label={label} aria-label={label}
onDoubleClick={(event) => { onDoubleClick={(event) => {
if (readOnly) return; if (readOnly) return;
@@ -100,26 +195,62 @@ function MindNodeViewComponent({ data, id }: NodeProps<MindFlowNode>) {
setEditingNode(id); setEditingNode(id);
}} }}
> >
{!readOnly && selected && !isEditing ? (
<NodeResizer
isVisible
color="#6366f1"
// Un cercle reste circulaire pendant tout le redimensionnement.
keepAspectRatio={appearance.circle}
minWidth={appearance.minWidth}
minHeight={appearance.minHeight}
maxWidth={LAYOUT_LIMITS.width.max}
maxHeight={LAYOUT_LIMITS.height.max}
handleClassName="openmind-resize-handle"
lineClassName="openmind-resize-line"
onResizeStart={() => beginResize()}
onResize={(_event, params) =>
resizeNode(id, {
width: params.width,
height: params.height,
position: { x: params.x, y: params.y },
})
}
/>
) : null}
<Handle <Handle
type="target" type="target"
position={data.targetPosition} id={data.targetHandle.id}
position={data.targetHandle.position}
isConnectable={!readOnly} isConnectable={!readOnly}
aria-label="Point de connexion entrant" aria-label="Point de connexion entrant"
/> />
<div className="flex items-start gap-2"> <div
{node.icon ? ( style={{
<span aria-hidden="true" className="leading-5"> display: 'flex',
{node.icon} flexDirection: iconOnTop ? 'column' : 'row',
</span> gap: hasIcon ? appearance.iconGap : 0,
) : null} alignItems: iconOnTop ? CROSS_ALIGNMENT[appearance.textAlign] : 'flex-start',
width: '100%',
minWidth: 0,
}}
>
{hasIcon && (appearance.iconPosition === 'left' || iconOnTop) ? iconElement : null}
<div style={{ flex: '1 1 auto', minWidth: 0, textAlign: appearance.textAlign }}>
{isEditing ? ( {isEditing ? (
<textarea <textarea
ref={textareaRef} ref={textareaRef}
className="nodrag nowheel w-full resize-none bg-transparent leading-snug outline-none" className="nodrag nowheel w-full resize-none bg-transparent outline-none"
style={{ color: appearance.color, minWidth: 90 }} style={{
rows={Math.min(6, Math.max(1, draft.split('\n').length))} color: appearance.color,
font: 'inherit',
lineHeight: appearance.lineHeight,
textAlign: appearance.textAlign,
minWidth: 80,
}}
rows={Math.min(8, Math.max(1, draft.split('\n').length))}
value={draft} value={draft}
aria-label="Texte du noeud" aria-label="Texte du noeud"
onChange={(event) => setDraft(event.target.value)} onChange={(event) => setDraft(event.target.value)}
@@ -137,10 +268,13 @@ function MindNodeViewComponent({ data, id }: NodeProps<MindFlowNode>) {
}} }}
/> />
) : ( ) : (
<span className="whitespace-pre-wrap break-words leading-snug"> <span style={textStyle}>
{node.text || <span className="opacity-50">Sans titre</span>} {node.text || <span className="opacity-50">Sans titre</span>}
</span> </span>
)} )}
</div>
{hasIcon && appearance.iconPosition === 'right' ? iconElement : null}
{node.note ? ( {node.note ? (
<span title="Ce noeud contient une note" className="mt-0.5 shrink-0 opacity-70"> <span title="Ce noeud contient une note" className="mt-0.5 shrink-0 opacity-70">
@@ -187,12 +321,18 @@ function MindNodeViewComponent({ data, id }: NodeProps<MindFlowNode>) {
</button> </button>
) : null} ) : null}
{/* En mind map bilaterale, la racine porte deux poignees sortantes :
ses branches partent a gauche comme a droite. */}
{data.sourceHandles.map((handle) => (
<Handle <Handle
key={handle.id}
type="source" type="source"
position={data.sourcePosition} id={handle.id}
position={handle.position}
isConnectable={!readOnly} isConnectable={!readOnly}
aria-label="Point de connexion sortant" aria-label="Point de connexion sortant"
/> />
))}
</div> </div>
); );
} }

View File

@@ -0,0 +1,124 @@
import { useMemo, useState } from 'react';
import { Button } from '@/components/Button';
import { Modal } from '@/components/Modal';
import { SearchIcon } from '@/components/Icons';
import { useEditorStore } from '@/stores/editorStore';
import { useUiStore } from '@/stores/uiStore';
import { computeDepths, getDescendantIds } from '@/utils/tree';
import { cn } from '@/utils/cn';
export interface MoveToDialogProps {
open: boolean;
nodeId: string | null;
onClose: () => void;
}
function normalize(value: string): string {
return value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase();
}
/**
* Choix d'un nouveau parent par recherche.
*
* Les cibles impossibles (le noeud lui-meme et ses descendants) sont exclues de
* la liste : la detection de cycle de `reparentNode` reste la regle unique, ce
* filtre ne fait qu'eviter de proposer un choix qui serait refuse.
*/
export function MoveToDialog({ open, nodeId, onClose }: MoveToDialogProps) {
const map = useEditorStore((state) => state.map);
const moveNodeToParent = useEditorStore((state) => state.moveNodeToParent);
const pushToast = useUiStore((state) => state.pushToast);
const [query, setQuery] = useState('');
const node = map?.nodes.find((candidate) => candidate.id === nodeId);
const candidates = useMemo(() => {
if (!map || !node) return [];
const excluded = new Set([node.id, ...getDescendantIds(map.nodes, node.id)]);
const depths = computeDepths(map.nodes);
const needle = normalize(query.trim());
return map.nodes
.filter((candidate) => !excluded.has(candidate.id) && candidate.id !== node.parentId)
.filter((candidate) => !needle || normalize(candidate.text).includes(needle))
.map((candidate) => ({ node: candidate, depth: depths.get(candidate.id) ?? 0 }))
.slice(0, 60);
}, [map, node, query]);
const move = (parentId: string | null) => {
if (!node) return;
if (moveNodeToParent(node.id, parentId)) {
pushToast(
parentId ? 'Branche deplacee.' : 'Noeud detache : il devient une racine.',
'success',
);
onClose();
return;
}
pushToast('Deplacement impossible : cela creerait une boucle.', 'error');
};
return (
<Modal
open={open}
title="Deplacer vers..."
description={node ? `"${node.text || 'Sans titre'}" et sa branche changeront de parent.` : ''}
onClose={onClose}
footer={<Button onClick={onClose}>Annuler</Button>}
>
<div className="space-y-3">
<div className="flex items-center gap-2 rounded-lg border border-zinc-200 px-3 py-2 dark:border-zinc-700">
<SearchIcon className="h-4 w-4 shrink-0 text-zinc-400" />
<input
autoFocus
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Rechercher un noeud parent"
aria-label="Rechercher un noeud parent"
className="w-full bg-transparent text-sm outline-none placeholder:text-zinc-400"
/>
</div>
<button
type="button"
onClick={() => move(null)}
className="w-full rounded-lg border border-dashed border-zinc-300 px-3 py-2 text-left text-sm text-zinc-600 transition-colors hover:bg-zinc-50 dark:border-zinc-600 dark:text-zinc-300 dark:hover:bg-zinc-800"
>
Detacher : en faire une racine
</button>
{candidates.length === 0 ? (
<p className="px-1 py-3 text-sm text-zinc-500">Aucun noeud ne correspond.</p>
) : (
<ul className="max-h-64 overflow-y-auto" aria-label="Nouveaux parents possibles">
{candidates.map(({ node: candidate, depth }) => (
<li key={candidate.id}>
<button
type="button"
onClick={() => move(candidate.id)}
className={cn(
'flex w-full items-center gap-2 rounded-md px-2 py-2 text-left text-sm transition-colors',
'text-zinc-700 hover:bg-zinc-100 dark:text-zinc-200 dark:hover:bg-zinc-800',
)}
>
<span
aria-hidden="true"
className="shrink-0 font-mono text-[11px] text-zinc-400"
style={{ paddingLeft: depth * 10 }}
>
{depth === 0 ? '\u25cf' : '\u2514'}
</span>
<span className="truncate">{candidate.text || 'Sans titre'}</span>
</button>
</li>
))}
</ul>
)}
</div>
</Modal>
);
}

View File

@@ -0,0 +1,311 @@
import { useEffect, useMemo } from 'react';
import { useEditorStore } from '@/stores/editorStore';
import { useUiStore } from '@/stores/uiStore';
import { getOrderedChildren } from '@/utils/tree';
import { cn } from '@/utils/cn';
export interface NodeContextMenuState {
nodeId: string;
x: number;
y: number;
}
export interface NodeContextMenuProps {
state: NodeContextMenuState;
onClose: () => void;
onAddChildren: (parentId: string) => void;
onMoveTo: (nodeId: string) => void;
onAddRelation: (nodeId: string) => void;
onImportMarkdown: (nodeId: string) => void;
}
interface MenuEntry {
kind: 'item';
label: string;
hint?: string;
disabled?: boolean;
danger?: boolean;
run: () => void;
}
interface MenuHeading {
kind: 'heading';
label: string;
}
type MenuLine = MenuEntry | MenuHeading;
/** Menu contextuel d'un noeud, organise par famille d'action. */
export function NodeContextMenu({
state,
onClose,
onAddChildren,
onMoveTo,
onAddRelation,
onImportMarkdown,
}: NodeContextMenuProps) {
const map = useEditorStore((store) => store.map);
const selectedIds = useEditorStore((store) => store.selectedNodeIds);
const hasStyleClipboard = useEditorStore((store) => store.styleClipboard !== null);
const hasNodeClipboard = useEditorStore((store) => (store.clipboard?.nodes.length ?? 0) > 0);
const pushToast = useUiStore((store) => store.pushToast);
useEffect(() => {
const close = () => onClose();
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose();
};
window.addEventListener('click', close);
window.addEventListener('keydown', onKeyDown);
return () => {
window.removeEventListener('click', close);
window.removeEventListener('keydown', onKeyDown);
};
}, [onClose]);
// Le clic droit agit sur la selection si le noeud vise en fait partie,
// sinon sur ce seul noeud.
const targets = useMemo(
() => (selectedIds.includes(state.nodeId) ? selectedIds : [state.nodeId]),
[selectedIds, state.nodeId],
);
const lines = useMemo<MenuLine[]>(() => {
const store = useEditorStore.getState();
const node = map?.nodes.find((candidate) => candidate.id === state.nodeId);
if (!node || !map) return [];
const siblings = getOrderedChildren(map.nodes, node.parentId);
const position = siblings.findIndex((candidate) => candidate.id === node.id);
const hasSiblings = siblings.length > 1;
const hasChildren = map.nodes.some((candidate) => candidate.parentId === node.id);
const notify = (message: string) => pushToast(message, 'success');
// Le cote ne se regle que sur une branche de niveau 1 : ailleurs, il est
// herite de la branche, il n'y a donc rien a choisir.
const parent = node.parentId
? map.nodes.find((candidate) => candidate.id === node.parentId)
: undefined;
const isBranchRoot = Boolean(parent && !parent.parentId);
const bilateral = map.settings.layout === 'mindmap-both';
return [
{ kind: 'heading', label: 'Ajouter' },
{ kind: 'item', label: 'Ajouter un enfant', hint: 'Tab', run: () => store.addChild(node.id) },
{
kind: 'item',
label: 'Ajouter plusieurs enfants...',
hint: 'Maj+Entree',
run: () => onAddChildren(node.id),
},
{
kind: 'item',
label: 'Ajouter avant',
disabled: !node.parentId,
run: () => store.addSiblingAt(node.id, 'before'),
},
{
kind: 'item',
label: 'Ajouter apres',
hint: 'Entree',
disabled: !node.parentId,
run: () => store.addSiblingAt(node.id, 'after'),
},
{
kind: 'item',
label: 'Importer Markdown ici...',
run: () => onImportMarkdown(node.id),
},
{ kind: 'heading', label: 'Relation' },
{
kind: 'item',
label: 'Ajouter une relation...',
run: () => onAddRelation(node.id),
},
{ kind: 'heading', label: 'Branche' },
{
kind: 'item',
label: 'Selectionner la branche',
run: () => notify(`${store.selectBranch(node.id)} noeuds selectionnes.`),
},
{
kind: 'item',
label: 'Dupliquer la branche',
run: () => notify(`Branche dupliquee (${store.duplicateBranch(node.id)} noeuds).`),
},
{
kind: 'item',
label: 'Copier la branche',
run: () => notify(`${store.copyBranch(node.id)} noeuds copies.`),
},
{
kind: 'item',
label: 'Coller comme enfant',
disabled: !hasNodeClipboard,
run: () => notify(`${store.pasteAsChild(node.id)} noeuds colles.`),
},
{ kind: 'item', label: 'Deplacer vers...', run: () => onMoveTo(node.id) },
{
kind: 'item',
label: node.collapsed ? 'Developper la branche' : 'Replier la branche',
disabled: !hasChildren,
run: () => store.setBranchCollapsed(node.id, !node.collapsed),
},
{ kind: 'heading', label: 'Cote' },
{
kind: 'item',
label: 'Placer la branche a gauche',
hint: bilateral ? undefined : 'bilaterale',
disabled: !isBranchRoot || node.side === 'left',
run: () => {
store.setNodeSide(node.id, 'left');
notify('Branche placee a gauche. Relancez l organisation pour la deplacer.');
},
},
{
kind: 'item',
label: 'Placer la branche a droite',
hint: bilateral ? undefined : 'bilaterale',
disabled: !isBranchRoot || node.side === 'right',
run: () => {
store.setNodeSide(node.id, 'right');
notify('Branche placee a droite. Relancez l organisation pour la deplacer.');
},
},
{ kind: 'heading', label: 'Ordre' },
{
kind: 'item',
label: 'Monter',
hint: 'Alt+Haut',
disabled: !hasSiblings || position <= 0,
run: () => store.moveSibling(node.id, 'up'),
},
{
kind: 'item',
label: 'Descendre',
hint: 'Alt+Bas',
disabled: !hasSiblings || position >= siblings.length - 1,
run: () => store.moveSibling(node.id, 'down'),
},
{
kind: 'item',
label: 'Placer en premier',
disabled: !hasSiblings || position <= 0,
run: () => store.moveSibling(node.id, 'first'),
},
{
kind: 'item',
label: 'Placer en dernier',
disabled: !hasSiblings || position >= siblings.length - 1,
run: () => store.moveSibling(node.id, 'last'),
},
{
kind: 'item',
label: 'Remonter d un niveau',
hint: 'Maj+Tab',
disabled: !node.parentId,
run: () => store.outdentNode(node.id),
},
{ kind: 'heading', label: 'Style' },
{
kind: 'item',
label: 'Copier le style',
hint: 'Ctrl+Maj+C',
disabled: targets.length !== 1,
run: () => {
if (store.copyStyle()) pushToast('Style copie.', 'info');
},
},
{
kind: 'item',
label: 'Coller le style',
hint: 'Ctrl+Maj+V',
disabled: !hasStyleClipboard,
run: () => notify(`Style applique a ${store.pasteStyle()} noeud(s).`),
},
{ kind: 'item', label: 'Reinitialiser le style', run: () => store.resetNodesStyle(targets) },
{ kind: 'item', label: 'Adapter au contenu', run: () => store.fitNodesToContent(targets) },
{ kind: 'heading', label: 'Suppression' },
{
kind: 'item',
label: 'Supprimer la branche',
hint: 'Suppr',
danger: true,
run: () => store.deleteNodes(targets),
},
{
kind: 'item',
label: 'Supprimer en conservant les enfants',
danger: true,
disabled: !hasChildren,
run: () => store.deleteNodesKeepChildren(targets),
},
];
}, [
map,
state.nodeId,
targets,
hasStyleClipboard,
hasNodeClipboard,
onAddChildren,
onMoveTo,
onAddRelation,
onImportMarkdown,
pushToast,
]);
return (
<div
role="menu"
aria-label="Actions du noeud"
className="panel fixed z-50 max-h-[80vh] min-w-[15rem] animate-fade-in overflow-y-auto p-1"
style={{ left: state.x, top: state.y }}
onClick={(event) => event.stopPropagation()}
>
{lines.map((line, index) =>
line.kind === 'heading' ? (
<div
key={`heading-${line.label}`}
className={cn(
'px-2.5 pb-1 text-[11px] font-semibold uppercase tracking-wide text-zinc-400',
index === 0 ? 'pt-1.5' : 'mt-1 border-t border-zinc-100 pt-2 dark:border-zinc-800',
)}
>
{line.label}
</div>
) : (
<button
key={line.label}
type="button"
role="menuitem"
disabled={line.disabled}
onClick={() => {
line.run();
onClose();
}}
className={cn(
'flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-sm transition-colors',
'disabled:cursor-not-allowed disabled:opacity-40',
line.danger
? 'text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/40'
: 'text-zinc-700 hover:bg-zinc-100 dark:text-zinc-200 dark:hover:bg-zinc-800',
)}
>
<span className="flex-1">{line.label}</span>
{line.hint ? (
<span className="shrink-0 text-[11px] uppercase tracking-wide text-zinc-400">
{line.hint}
</span>
) : null}
</button>
),
)}
</div>
);
}

View File

@@ -0,0 +1,28 @@
import { BUILTIN_ICONS } from '@/features/editor/nodeIcons';
export interface NodeIconGlyphProps {
value: string;
kind: 'emoji' | 'builtin';
size: number;
className?: string;
}
/** Rendu d'une icone de noeud : emoji brut ou icone integree. */
export function NodeIconGlyph({ value, kind, size, className }: NodeIconGlyphProps) {
if (kind === 'builtin') {
const entry = BUILTIN_ICONS[value];
if (!entry) return null;
const { Icon } = entry;
return <Icon size={size} strokeWidth={1.8} className={className} aria-hidden="true" />;
}
return (
<span
aria-hidden="true"
className={className}
style={{ fontSize: size, lineHeight: 1, display: 'inline-block' }}
>
{value}
</span>
);
}

View File

@@ -1,10 +1,43 @@
import { useEffect, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { type NodeShape, type TextAlign } from '@/types/map'; import {
AlignCenter,
AlignLeft,
AlignRight,
AlignVerticalJustifyCenter,
AlignVerticalJustifyEnd,
AlignVerticalJustifyStart,
} from 'lucide-react';
import {
type BranchStyle,
type FontFamilyId,
type IconPosition,
type NodeShape,
type ShadowStyle,
type TextAlign,
type VerticalAlign,
} from '@/types/map';
import { Button } from '@/components/Button'; import { Button } from '@/components/Button';
import { IconButton } from '@/components/IconButton'; import { IconButton } from '@/components/IconButton';
import { CloseIcon, LinkIcon, TrashIcon } from '@/components/Icons'; import { CloseIcon, CopyIcon, LinkIcon, TrashIcon } from '@/components/Icons';
import { getTheme, paletteForDepth } from '@/features/editor/themes'; import { getTheme, paletteForDepth, themeMetrics } from '@/features/editor/themes';
import { selectSelectedNode, useEditorStore } from '@/stores/editorStore'; import {
FONT_FAMILY_OPTIONS,
LAYOUT_LIMITS,
SHADOW_OPTIONS,
resolveNodeAppearance,
} from '@/features/editor/nodeStyleResolver';
import { BUILTIN_ICONS, BUILTIN_ICON_IDS, EMOJI_ICONS } from '@/features/editor/nodeIcons';
import { PanelSection } from '@/features/editor/panel/PanelSection';
import { RelationProperties } from '@/features/editor/RelationProperties';
import {
ColorField,
NumberField,
SegmentedControl,
SelectField,
ToggleRow,
} from '@/features/editor/panel/controls';
import { sharedValue } from '@/features/editor/panel/sharedValue';
import { useEditorStore } from '@/stores/editorStore';
import { useUiStore } from '@/stores/uiStore'; import { useUiStore } from '@/stores/uiStore';
import { computeDepths } from '@/utils/tree'; import { computeDepths } from '@/utils/tree';
import { sanitizeUrl } from '@/utils/url'; import { sanitizeUrl } from '@/utils/url';
@@ -16,92 +49,106 @@ const SHAPES: Array<{ id: NodeShape; label: string }> = [
{ id: 'circle', label: 'Cercle' }, { id: 'circle', label: 'Cercle' },
]; ];
const ALIGNMENTS: Array<{ id: TextAlign; label: string }> = [ const BRANCH_STYLES: Array<{ id: BranchStyle | 'theme'; label: string }> = [
{ id: 'left', label: 'Gauche' }, { id: 'theme', label: 'Theme' },
{ id: 'center', label: 'Centre' }, { id: 'bezier', label: 'Bezier' },
{ id: 'right', label: 'Droite' }, { id: 'curved', label: 'Courbe' },
{ id: 'straight', label: 'Droite' },
]; ];
const EMOJIS = ['', '💡', '✅', '📌', '⚠️', '🔒', '🌐', '🖥️', '📦', '📄', '🔗', '⭐', '🐳', '🗄️']; const HORIZONTAL_ALIGN = [
{ value: 'left' as TextAlign, label: 'Aligner a gauche', icon: <AlignLeft size={15} /> },
{ value: 'center' as TextAlign, label: 'Centrer', icon: <AlignCenter size={15} /> },
{ value: 'right' as TextAlign, label: 'Aligner a droite', icon: <AlignRight size={15} /> },
];
/** Petit selecteur de couleur : palette native + valeur libre. */ const VERTICAL_ALIGN = [
function ColorField({ {
id, value: 'top' as VerticalAlign,
label, label: 'Aligner en haut',
value, icon: <AlignVerticalJustifyStart size={15} />,
fallback, },
onChange, {
}: { value: 'middle' as VerticalAlign,
id: string; label: 'Centrer verticalement',
label: string; icon: <AlignVerticalJustifyCenter size={15} />,
value: string | undefined; },
fallback: string; {
onChange: (color: string | undefined) => void; value: 'bottom' as VerticalAlign,
}) { label: 'Aligner en bas',
return ( icon: <AlignVerticalJustifyEnd size={15} />,
<div> },
<label className="field-label" htmlFor={id}> ];
{label}
</label>
<div className="flex items-center gap-2">
<input
id={id}
type="color"
value={value ?? normaliseHex(fallback)}
onChange={(event) => onChange(event.target.value)}
className="h-8 w-10 cursor-pointer rounded border border-zinc-200 bg-transparent p-0.5 dark:border-zinc-700"
/>
<span className="flex-1 truncate font-mono text-xs text-zinc-500">
{value ?? `theme (${fallback})`}
</span>
{value ? (
<IconButton
label={`Revenir au theme pour ${label.toLowerCase()}`}
icon={<CloseIcon className="h-3.5 w-3.5" />}
onClick={() => onChange(undefined)}
/>
) : null}
</div>
</div>
);
}
function normaliseHex(color: string): string { const ICON_POSITIONS: Array<{ id: IconPosition; label: string }> = [
return /^#[0-9a-fA-F]{6}$/.test(color) ? color : '#ffffff'; { id: 'left', label: 'Gauche' },
} { id: 'right', label: 'Droite' },
{ id: 'top', label: 'Au-dessus' },
];
const ICON_SIZES = [16, 20, 24, 32];
export function PropertiesPanel() { export function PropertiesPanel() {
const map = useEditorStore((state) => state.map); const map = useEditorStore((state) => state.map);
const node = useEditorStore(selectSelectedNode); const selectedIds = useEditorStore((state) => state.selectedNodeIds);
const selectedCount = useEditorStore((state) => state.selectedNodeIds.length); const selectedRelationId = useEditorStore((state) => state.selectedRelationId);
const updateNode = useEditorStore((state) => state.updateNode); const updateNode = useEditorStore((state) => state.updateNode);
const updateNodeText = useEditorStore((state) => state.updateNodeText); const updateNodeText = useEditorStore((state) => state.updateNodeText);
const updateNodeStyle = useEditorStore((state) => state.updateNodeStyle); const updateNodesStyle = useEditorStore((state) => state.updateNodesStyle);
const resetNodeStyle = useEditorStore((state) => state.resetNodeStyle); const updateNodesLayout = useEditorStore((state) => state.updateNodesLayout);
const updateNodesIconStyle = useEditorStore((state) => state.updateNodesIconStyle);
const updateNodesBranch = useEditorStore((state) => state.updateNodesBranch);
const setNodesIcon = useEditorStore((state) => state.setNodesIcon);
const resetNodesStyle = useEditorStore((state) => state.resetNodesStyle);
const fitNodesToContent = useEditorStore((state) => state.fitNodesToContent);
const copyStyle = useEditorStore((state) => state.copyStyle);
const pasteStyle = useEditorStore((state) => state.pasteStyle);
const hasStyleClipboard = useEditorStore((state) => state.styleClipboard !== null);
const detachNode = useEditorStore((state) => state.detachNode); const detachNode = useEditorStore((state) => state.detachNode);
const deleteNodes = useEditorStore((state) => state.deleteNodes); const deleteNodes = useEditorStore((state) => state.deleteNodes);
const setPropertiesPanelOpen = useUiStore((state) => state.setPropertiesPanelOpen); const setPropertiesPanelOpen = useUiStore((state) => state.setPropertiesPanelOpen);
const pushToast = useUiStore((state) => state.pushToast); const pushToast = useUiStore((state) => state.pushToast);
const nodes = useMemo(() => {
if (!map) return [];
const wanted = new Set(selectedIds);
return map.nodes.filter((node) => wanted.has(node.id));
}, [map, selectedIds]);
const single = nodes.length === 1 ? nodes[0] : undefined;
const ids = useMemo(() => nodes.map((node) => node.id), [nodes]);
const [text, setText] = useState(''); const [text, setText] = useState('');
const [url, setUrl] = useState(''); const [url, setUrl] = useState('');
const [note, setNote] = useState(''); const [note, setNote] = useState('');
useEffect(() => { useEffect(() => {
setText(node?.text ?? ''); setText(single?.text ?? '');
setUrl(node?.url ?? ''); setUrl(single?.url ?? '');
setNote(node?.note ?? ''); setNote(single?.note ?? '');
}, [node?.id, node?.text, node?.url, node?.note]); }, [single?.id, single?.text, single?.url, single?.note]);
const theme = getTheme(map?.settings.theme); const theme = getTheme(map?.settings.theme);
const depth = node && map ? (computeDepths(map.nodes).get(node.id) ?? 0) : 0; const metrics = themeMetrics(theme);
const palette = paletteForDepth(theme, depth); const depths = useMemo(() => (map ? computeDepths(map.nodes) : new Map<string, number>()), [map]);
const style = node?.style ?? {}; const referenceDepth = single ? (depths.get(single.id) ?? 0) : 0;
const palette = paletteForDepth(theme, referenceDepth);
const reference = single ? resolveNodeAppearance(single, referenceDepth, theme) : undefined;
const relation = map?.relations.find((candidate) => candidate.id === selectedRelationId);
const header = ( const header = (
<header className="flex items-center justify-between border-b border-zinc-100 px-4 py-3 dark:border-zinc-800"> <header className="flex items-center justify-between gap-2 border-b border-zinc-100 px-4 py-3 dark:border-zinc-800">
<h2 className="text-sm font-semibold text-zinc-900 dark:text-zinc-50">Proprietes</h2> <h2 className="text-sm font-semibold text-zinc-900 dark:text-zinc-50">
Proprietes
{nodes.length > 1 ? (
<span className="ml-2 rounded-full bg-indigo-50 px-2 py-0.5 text-[11px] font-medium text-indigo-700 dark:bg-indigo-950/60 dark:text-indigo-300">
{nodes.length} noeuds
</span>
) : null}
</h2>
<IconButton <IconButton
label="Masquer le panneau de proprietes" label="Masquer le panneau de proprietes"
icon={<CloseIcon />} icon={<CloseIcon />}
@@ -110,7 +157,21 @@ export function PropertiesPanel() {
</header> </header>
); );
if (!node) { // Une relation selectionnee prend la place du panneau de noeud : les deux
// selections s'excluent, le panneau ne montre donc jamais les deux a la fois.
if (nodes.length === 0 && relation) {
return (
<aside
className="no-print flex h-full w-72 shrink-0 flex-col overflow-y-auto border-l border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900"
aria-label="Panneau de proprietes"
>
{header}
<RelationProperties relationId={relation.id} />
</aside>
);
}
if (nodes.length === 0) {
return ( return (
<aside <aside
className="no-print flex h-full w-72 shrink-0 flex-col border-l border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900" className="no-print flex h-full w-72 shrink-0 flex-col border-l border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900"
@@ -118,28 +179,30 @@ export function PropertiesPanel() {
> >
{header} {header}
<p className="px-4 py-6 text-sm leading-relaxed text-zinc-500 dark:text-zinc-400"> <p className="px-4 py-6 text-sm leading-relaxed text-zinc-500 dark:text-zinc-400">
{selectedCount > 1 Selectionnez un noeud pour modifier son contenu, sa typographie, son apparence et sa mise
? `${selectedCount} noeuds selectionnes. Selectionnez un seul noeud pour modifier son apparence.` en page. Selectionnez-en plusieurs pour appliquer un reglage a tout le lot. Cliquez une
: 'Selectionnez un noeud pour modifier son texte, ses couleurs, sa forme ou y ajouter une note.'} relation pour en modifier le libelle et le trace.
</p> </p>
</aside> </aside>
); );
} }
const styleOf = sharedValue;
const commitUrl = () => { const commitUrl = () => {
if (!single) return;
const trimmed = url.trim(); const trimmed = url.trim();
if (!trimmed) { if (!trimmed) {
updateNode(node.id, { url: undefined }); updateNode(single.id, { url: undefined });
return; return;
} }
const safe = sanitizeUrl(trimmed); const safe = sanitizeUrl(trimmed);
if (!safe) { if (!safe) {
pushToast('Cette adresse est invalide. Utilisez un lien http(s) ou mailto.', 'error'); pushToast('Cette adresse est invalide. Utilisez un lien http(s) ou mailto.', 'error');
setUrl(node.url ?? ''); setUrl(single.url ?? '');
return; return;
} }
setUrl(safe); setUrl(safe);
updateNode(node.id, { url: safe }); updateNode(single.id, { url: safe });
}; };
return ( return (
@@ -149,7 +212,40 @@ export function PropertiesPanel() {
> >
{header} {header}
<div className="space-y-5 px-4 py-4"> <div className="flex flex-wrap gap-1.5 border-b border-zinc-100 px-4 py-2.5 dark:border-zinc-800">
<Button
size="sm"
disabled={!single}
title="Copier le style (Ctrl+Shift+C)"
onClick={() => {
if (copyStyle()) pushToast('Style copie.', 'info');
}}
>
<CopyIcon className="h-3.5 w-3.5" />
Copier le style
</Button>
<Button
size="sm"
disabled={!hasStyleClipboard}
title="Coller le style (Ctrl+Shift+V)"
onClick={() => {
const count = pasteStyle();
if (count > 0) pushToast(`Style applique a ${count} noeud(s).`, 'success');
}}
>
Coller le style
</Button>
<Button size="sm" onClick={() => fitNodesToContent(ids)}>
Adapter au contenu
</Button>
<Button size="sm" onClick={() => resetNodesStyle(ids)}>
Reinitialiser le style
</Button>
</div>
<PanelSection id="content" title="Contenu">
{single ? (
<>
<div> <div>
<label className="field-label" htmlFor="node-text"> <label className="field-label" htmlFor="node-text">
Texte Texte
@@ -159,123 +255,10 @@ export function PropertiesPanel() {
className="field-input min-h-[64px] resize-y" className="field-input min-h-[64px] resize-y"
value={text} value={text}
onChange={(event) => setText(event.target.value)} onChange={(event) => setText(event.target.value)}
onBlur={() => text !== node.text && updateNodeText(node.id, text)} onBlur={() => text !== single.text && updateNodeText(single.id, text)}
/> />
</div> </div>
<div>
<span className="field-label">Icone</span>
<div className="flex flex-wrap gap-1">
{EMOJIS.map((emoji) => (
<button
key={emoji || 'none'}
type="button"
aria-label={emoji ? `Icone ${emoji}` : 'Aucune icone'}
aria-pressed={(node.icon ?? '') === emoji}
onClick={() => updateNode(node.id, { icon: emoji || undefined })}
className={`h-8 w-8 rounded-md border text-base transition-colors ${
(node.icon ?? '') === emoji
? 'border-indigo-400 bg-indigo-50 dark:bg-indigo-950/50'
: 'border-zinc-200 hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-800'
}`}
>
{emoji || '-'}
</button>
))}
</div>
</div>
<ColorField
id="node-bg"
label="Fond"
value={style.backgroundColor}
fallback={palette.background}
onChange={(color) => updateNodeStyle(node.id, { backgroundColor: color })}
/>
<ColorField
id="node-text-color"
label="Texte"
value={style.textColor}
fallback={palette.text}
onChange={(color) => updateNodeStyle(node.id, { textColor: color })}
/>
<ColorField
id="node-border"
label="Bordure"
value={style.borderColor}
fallback={palette.border}
onChange={(color) => updateNodeStyle(node.id, { borderColor: color })}
/>
<div>
<label className="field-label" htmlFor="node-shape">
Forme
</label>
<select
id="node-shape"
className="field-input"
value={style.shape ?? theme.nodeShape}
onChange={(event) => updateNodeStyle(node.id, { shape: event.target.value as NodeShape })}
>
{SHAPES.map((shape) => (
<option key={shape.id} value={shape.id}>
{shape.label}
</option>
))}
</select>
</div>
<div>
<label className="field-label" htmlFor="node-size">
Taille du texte : {style.fontSize ?? (depth === 0 ? 16 : 14)} px
</label>
<input
id="node-size"
type="range"
min={10}
max={32}
step={1}
value={style.fontSize ?? (depth === 0 ? 16 : 14)}
onChange={(event) => updateNodeStyle(node.id, { fontSize: Number(event.target.value) })}
className="w-full accent-indigo-600"
/>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant={style.bold ? 'primary' : 'secondary'}
aria-pressed={Boolean(style.bold)}
onClick={() => updateNodeStyle(node.id, { bold: !style.bold })}
className="font-bold"
>
G
</Button>
<Button
size="sm"
variant={style.italic ? 'primary' : 'secondary'}
aria-pressed={Boolean(style.italic)}
onClick={() => updateNodeStyle(node.id, { italic: !style.italic })}
className="italic"
>
I
</Button>
<div className="ml-auto flex gap-1">
{ALIGNMENTS.map((alignment) => (
<Button
key={alignment.id}
size="sm"
variant={(style.textAlign ?? 'left') === alignment.id ? 'primary' : 'secondary'}
aria-pressed={(style.textAlign ?? 'left') === alignment.id}
aria-label={`Alignement ${alignment.label}`}
onClick={() => updateNodeStyle(node.id, { textAlign: alignment.id })}
>
{alignment.label.charAt(0)}
</Button>
))}
</div>
</div>
<div> <div>
<label className="field-label" htmlFor="node-url"> <label className="field-label" htmlFor="node-url">
Lien Lien
@@ -291,9 +274,9 @@ export function PropertiesPanel() {
onChange={(event) => setUrl(event.target.value)} onChange={(event) => setUrl(event.target.value)}
onBlur={commitUrl} onBlur={commitUrl}
/> />
{node.url ? ( {single.url ? (
<a <a
href={node.url} href={single.url}
target="_blank" target="_blank"
rel="noopener noreferrer nofollow" rel="noopener noreferrer nofollow"
title="Ouvrir dans un nouvel onglet" title="Ouvrir dans un nouvel onglet"
@@ -308,38 +291,371 @@ export function PropertiesPanel() {
<div> <div>
<label className="field-label" htmlFor="node-note"> <label className="field-label" htmlFor="node-note">
Note Notes
</label> </label>
<textarea <textarea
id="node-note" id="node-note"
className="field-input min-h-[80px] resize-y" className="field-input min-h-[70px] resize-y"
placeholder="Visible ici uniquement ; une icone signale la note sur le canvas." placeholder="Visible ici uniquement ; une icone signale la note sur le canvas."
value={note} value={note}
onChange={(event) => setNote(event.target.value)} onChange={(event) => setNote(event.target.value)}
onBlur={() => note !== (node.note ?? '') && updateNode(node.id, { note: note || undefined })} onBlur={() =>
note !== (single.note ?? '') &&
updateNode(single.id, { note: note || undefined })
}
/> />
</div> </div>
</>
) : (
<p className="text-xs leading-relaxed text-zinc-500 dark:text-zinc-400">
Texte, lien et notes se modifient noeud par noeud. Les reglages d&apos;icone et de style
ci-dessous s&apos;appliquent a toute la selection.
</p>
)}
<div className="space-y-2 border-t border-zinc-100 pt-4 dark:border-zinc-800"> <div>
<Button size="sm" className="w-full" onClick={() => resetNodeStyle(node.id)}> <span className="field-label">Icone</span>
Revenir au style du theme <div className="mb-2 flex flex-wrap gap-1">
<button
type="button"
aria-label="Aucune icone"
title="Aucune icone"
onClick={() => setNodesIcon(ids, undefined)}
className="h-8 w-8 rounded-md border border-zinc-200 text-xs text-zinc-500 transition-colors hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-800"
>
-
</button>
{EMOJI_ICONS.map((emoji) => (
<button
key={emoji}
type="button"
aria-label={`Icone ${emoji}`}
aria-pressed={single?.icon === emoji}
onClick={() => setNodesIcon(ids, emoji, 'emoji')}
className={`h-8 w-8 rounded-md border text-base transition-colors ${
single?.icon === emoji
? 'border-indigo-400 bg-indigo-50 dark:bg-indigo-950/50'
: 'border-zinc-200 hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-800'
}`}
>
{emoji}
</button>
))}
</div>
<div className="flex flex-wrap gap-1">
{BUILTIN_ICON_IDS.map((iconId) => {
const { label, Icon } = BUILTIN_ICONS[iconId] as (typeof BUILTIN_ICONS)[string];
return (
<button
key={iconId}
type="button"
aria-label={`Icone ${label}`}
title={label}
aria-pressed={single?.icon === iconId}
onClick={() => setNodesIcon(ids, iconId, 'builtin')}
className={`flex h-8 w-8 items-center justify-center rounded-md border text-zinc-600 transition-colors dark:text-zinc-300 ${
single?.icon === iconId
? 'border-indigo-400 bg-indigo-50 dark:bg-indigo-950/50'
: 'border-zinc-200 hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-800'
}`}
>
<Icon size={16} strokeWidth={1.8} aria-hidden="true" />
</button>
);
})}
</div>
</div>
<SelectField
id="icon-position"
label="Position de l'icone"
value={styleOf(nodes, (node) => node.iconStyle?.position).value ?? 'left'}
mixed={styleOf(nodes, (node) => node.iconStyle?.position).mixed}
options={ICON_POSITIONS}
onChange={(position) => updateNodesIconStyle(ids, { position })}
/>
<SegmentedControl
legend="Taille de l'icone"
mixed={styleOf(nodes, (node) => node.iconStyle?.size).mixed}
selected={String(
styleOf(nodes, (node) => node.iconStyle?.size).value ?? metrics.iconSize,
)}
options={ICON_SIZES.map((size) => ({ value: String(size), label: `${size} px` }))}
onSelect={(value) => updateNodesIconStyle(ids, { size: Number(value) })}
/>
<NumberField
id="icon-gap"
label="Espacement icone / texte"
value={styleOf(nodes, (node) => node.iconStyle?.gap).value ?? metrics.iconGap}
mixed={styleOf(nodes, (node) => node.iconStyle?.gap).mixed}
min={LAYOUT_LIMITS.iconGap.min}
max={LAYOUT_LIMITS.iconGap.max}
onChange={(gap) => updateNodesIconStyle(ids, { gap })}
/>
</PanelSection>
<PanelSection id="typography" title="Typographie">
<SelectField
id="font-family"
label="Police"
value={styleOf(nodes, (node) => node.style?.fontFamily).value ?? 'theme'}
mixed={styleOf(nodes, (node) => node.style?.fontFamily).mixed}
options={FONT_FAMILY_OPTIONS.map((option) => ({ id: option.id, label: option.label }))}
onChange={(fontFamily) =>
updateNodesStyle(ids, {
fontFamily: fontFamily === 'theme' ? undefined : (fontFamily as FontFamilyId),
})
}
/>
<NumberField
id="font-size"
label="Taille du texte"
value={
styleOf(nodes, (node) => node.style?.fontSize).value ?? (reference?.fontSize ?? 14)
}
mixed={styleOf(nodes, (node) => node.style?.fontSize).mixed}
min={LAYOUT_LIMITS.fontSize.min}
max={LAYOUT_LIMITS.fontSize.max}
onChange={(fontSize) => updateNodesStyle(ids, { fontSize })}
/>
<div className="flex items-center gap-1.5">
<Button
size="sm"
variant={styleOf(nodes, (node) => node.style?.bold).value ? 'primary' : 'secondary'}
aria-pressed={Boolean(styleOf(nodes, (node) => node.style?.bold).value)}
aria-label="Texte gras"
title="Gras"
className="font-bold"
onClick={() =>
updateNodesStyle(ids, {
bold: !styleOf(nodes, (node) => node.style?.bold).value,
})
}
>
G
</Button> </Button>
{node.parentId ? ( <Button
<Button size="sm" className="w-full" onClick={() => detachNode(node.id)}> size="sm"
variant={styleOf(nodes, (node) => node.style?.italic).value ? 'primary' : 'secondary'}
aria-pressed={Boolean(styleOf(nodes, (node) => node.style?.italic).value)}
aria-label="Texte italique"
title="Italique"
className="italic"
onClick={() =>
updateNodesStyle(ids, {
italic: !styleOf(nodes, (node) => node.style?.italic).value,
})
}
>
I
</Button>
</div>
<SegmentedControl
legend="Alignement horizontal"
options={HORIZONTAL_ALIGN}
mixed={styleOf(nodes, (node) => node.style?.textAlign).mixed}
selected={styleOf(nodes, (node) => node.style?.textAlign).value ?? 'left'}
onSelect={(textAlign) => updateNodesStyle(ids, { textAlign })}
/>
<SegmentedControl
legend="Alignement vertical"
options={VERTICAL_ALIGN}
mixed={styleOf(nodes, (node) => node.style?.verticalAlign).mixed}
selected={styleOf(nodes, (node) => node.style?.verticalAlign).value ?? 'top'}
onSelect={(verticalAlign) => updateNodesStyle(ids, { verticalAlign })}
/>
<NumberField
id="line-height"
label="Interligne"
unit=""
step={0.1}
value={styleOf(nodes, (node) => node.style?.lineHeight).value ?? metrics.lineHeight}
mixed={styleOf(nodes, (node) => node.style?.lineHeight).mixed}
min={LAYOUT_LIMITS.lineHeight.min}
max={LAYOUT_LIMITS.lineHeight.max}
onChange={(lineHeight) => updateNodesStyle(ids, { lineHeight })}
/>
</PanelSection>
<PanelSection id="appearance" title="Apparence">
<ColorField
id="node-bg"
label="Fond"
shared={styleOf(nodes, (node) => node.style?.backgroundColor)}
fallback={palette.background}
onChange={(backgroundColor) => updateNodesStyle(ids, { backgroundColor })}
/>
<ColorField
id="node-text-color"
label="Couleur du texte"
shared={styleOf(nodes, (node) => node.style?.textColor)}
fallback={palette.text}
onChange={(textColor) => updateNodesStyle(ids, { textColor })}
/>
<ColorField
id="node-border"
label="Bordure"
shared={styleOf(nodes, (node) => node.style?.borderColor)}
fallback={palette.border}
onChange={(borderColor) => updateNodesStyle(ids, { borderColor })}
/>
<SelectField
id="node-shape"
label="Forme"
value={styleOf(nodes, (node) => node.style?.shape).value ?? theme.nodeShape}
mixed={styleOf(nodes, (node) => node.style?.shape).mixed}
options={SHAPES}
onChange={(shape) => updateNodesStyle(ids, { shape })}
/>
<SelectField
id="node-shadow"
label="Ombre"
value={styleOf(nodes, (node) => node.style?.shadow).value ?? 'theme'}
mixed={styleOf(nodes, (node) => node.style?.shadow).mixed}
options={SHADOW_OPTIONS.map((option) => ({ id: option.id, label: option.label }))}
onChange={(shadow) =>
updateNodesStyle(ids, { shadow: shadow === 'theme' ? undefined : (shadow as ShadowStyle) })
}
/>
</PanelSection>
<PanelSection id="layout" title="Mise en page">
<SegmentedControl
legend="Taille du noeud"
mixed={styleOf(nodes, (node) => node.layout?.widthMode ?? 'auto').mixed}
selected={styleOf(nodes, (node) => node.layout?.widthMode ?? 'auto').value ?? 'auto'}
options={[
{ value: 'auto', label: 'Automatique' },
{ value: 'fixed', label: 'Personnalisee' },
]}
onSelect={(widthMode) => {
if (widthMode === 'auto') {
fitNodesToContent(ids);
return;
}
updateNodesLayout(ids, {
widthMode: 'fixed',
width: reference?.width ?? Math.round(reference?.minWidth ?? 160),
});
}}
/>
<NumberField
id="node-width"
label="Largeur"
value={
styleOf(nodes, (node) => node.layout?.width ?? node.style?.width).value ??
(reference?.width ?? 160)
}
mixed={styleOf(nodes, (node) => node.layout?.width ?? node.style?.width).mixed}
min={LAYOUT_LIMITS.width.min}
max={LAYOUT_LIMITS.width.max}
onChange={(width) => updateNodesLayout(ids, { widthMode: 'fixed', width })}
/>
<NumberField
id="node-height"
label="Hauteur"
value={styleOf(nodes, (node) => node.layout?.height).value ?? (reference?.height ?? 48)}
mixed={styleOf(nodes, (node) => node.layout?.height).mixed}
min={LAYOUT_LIMITS.height.min}
max={LAYOUT_LIMITS.height.max}
onChange={(height) => updateNodesLayout(ids, { heightMode: 'fixed', height })}
/>
<NumberField
id="padding-x"
label="Padding horizontal"
value={styleOf(nodes, (node) => node.layout?.paddingX).value ?? metrics.paddingX}
mixed={styleOf(nodes, (node) => node.layout?.paddingX).mixed}
min={LAYOUT_LIMITS.paddingX.min}
max={LAYOUT_LIMITS.paddingX.max}
onChange={(paddingX) => updateNodesLayout(ids, { paddingX })}
/>
<NumberField
id="padding-y"
label="Padding vertical"
value={styleOf(nodes, (node) => node.layout?.paddingY).value ?? metrics.paddingY}
mixed={styleOf(nodes, (node) => node.layout?.paddingY).mixed}
min={LAYOUT_LIMITS.paddingY.min}
max={LAYOUT_LIMITS.paddingY.max}
onChange={(paddingY) => updateNodesLayout(ids, { paddingY })}
/>
<ToggleRow
id="node-wrap"
label="Retour a la ligne automatique"
checked={styleOf(nodes, (node) => node.layout?.wrap ?? true).value ?? true}
mixed={styleOf(nodes, (node) => node.layout?.wrap ?? true).mixed}
onChange={(wrap) => updateNodesLayout(ids, { wrap })}
/>
<NumberField
id="max-text-width"
label="Largeur maximale du texte"
value={styleOf(nodes, (node) => node.layout?.maxTextWidth).value ?? metrics.maxTextWidth}
mixed={styleOf(nodes, (node) => node.layout?.maxTextWidth).mixed}
min={LAYOUT_LIMITS.maxTextWidth.min}
max={LAYOUT_LIMITS.maxTextWidth.max}
disabled={styleOf(nodes, (node) => node.layout?.wrap ?? true).value === false}
onChange={(maxTextWidth) => updateNodesLayout(ids, { maxTextWidth })}
/>
</PanelSection>
<PanelSection id="branch" title="Branche">
<SelectField
id="branch-style"
label="Style"
value={styleOf(nodes, (node) => node.branch?.style).value ?? 'theme'}
mixed={styleOf(nodes, (node) => node.branch?.style).mixed}
options={BRANCH_STYLES}
onChange={(style) =>
updateNodesBranch(ids, {
style: style === 'theme' ? undefined : (style as BranchStyle),
})
}
/>
<NumberField
id="branch-width"
label="Epaisseur"
value={styleOf(nodes, (node) => node.branch?.width).value ?? theme.branchWidth}
mixed={styleOf(nodes, (node) => node.branch?.width).mixed}
min={LAYOUT_LIMITS.branchWidth.min}
max={LAYOUT_LIMITS.branchWidth.max}
onChange={(width) => updateNodesBranch(ids, { width })}
/>
<ColorField
id="branch-color"
label="Couleur"
shared={styleOf(nodes, (node) => node.branch?.color)}
fallback={theme.branchColor}
onChange={(color) => updateNodesBranch(ids, { color })}
/>
</PanelSection>
<div className="space-y-2 px-4 py-4">
{single?.parentId ? (
<Button size="sm" className="w-full" onClick={() => detachNode(single.id)}>
Detacher de son parent Detacher de son parent
</Button> </Button>
) : null} ) : null}
<Button <Button size="sm" variant="danger" className="w-full" onClick={() => deleteNodes(ids)}>
size="sm"
variant="danger"
className="w-full"
onClick={() => deleteNodes([node.id])}
>
<TrashIcon className="h-4 w-4" /> <TrashIcon className="h-4 w-4" />
Supprimer ce noeud {nodes.length > 1 ? `Supprimer ${nodes.length} noeuds` : 'Supprimer ce noeud'}
</Button> </Button>
</div> </div>
</div>
</aside> </aside>
); );
} }

View File

@@ -0,0 +1,139 @@
import { useEffect, useMemo, useState } from 'react';
import { Button } from '@/components/Button';
import { Modal } from '@/components/Modal';
import { SearchIcon } from '@/components/Icons';
import { RELATION_REJECTION_MESSAGE } from '@/services/relations';
import { useEditorStore } from '@/stores/editorStore';
import { useUiStore } from '@/stores/uiStore';
import { computeDepths } from '@/utils/tree';
export interface RelationDialogProps {
open: boolean;
sourceId: string | null;
onClose: () => void;
}
function normalize(value: string): string {
return value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase();
}
/**
* Creation d'une relation secondaire.
*
* Le choix passe par une recherche explicite, et non par un glissement depuis
* une poignee : tirer un lien depuis une poignee cree deja un rattachement
* parent / enfant. Deux gestes differents pour deux natures differentes, il n'y
* a donc aucune ambiguite sur ce que l'on est en train de creer.
*/
export function RelationDialog({ open, sourceId, onClose }: RelationDialogProps) {
const map = useEditorStore((state) => state.map);
const addRelation = useEditorStore((state) => state.addRelation);
const pushToast = useUiStore((state) => state.pushToast);
const [query, setQuery] = useState('');
const [label, setLabel] = useState('');
useEffect(() => {
if (open) {
setQuery('');
setLabel('');
}
}, [open]);
const source = map?.nodes.find((candidate) => candidate.id === sourceId);
const candidates = useMemo(() => {
if (!map || !source) return [];
const depths = computeDepths(map.nodes);
const needle = normalize(query.trim());
return map.nodes
.filter((candidate) => candidate.id !== source.id)
.filter((candidate) => !needle || normalize(candidate.text).includes(needle))
.map((candidate) => ({ node: candidate, depth: depths.get(candidate.id) ?? 0 }))
.slice(0, 60);
}, [map, source, query]);
const create = (targetId: string) => {
if (!source) return;
const trimmed = label.trim();
const rejection = addRelation(source.id, targetId, trimmed ? { label: trimmed } : undefined);
if (rejection) {
pushToast(RELATION_REJECTION_MESSAGE[rejection], 'error');
return;
}
pushToast('Relation creee. La hierarchie est inchangee.', 'success');
onClose();
};
return (
<Modal
open={open}
title="Ajouter une relation"
description={
source
? `Relation visuelle depuis "${source.text || 'Sans titre'}". Elle ne modifie ni le parent ni les enfants.`
: ''
}
onClose={onClose}
footer={<Button onClick={onClose}>Annuler</Button>}
>
<div className="space-y-3">
<div>
<label className="field-label" htmlFor="relation-label">
Libelle (facultatif)
</label>
<input
id="relation-label"
className="field-input"
value={label}
maxLength={120}
placeholder="depend de, utilise, protege..."
onChange={(event) => setLabel(event.target.value)}
/>
</div>
<div className="flex items-center gap-2 rounded-lg border border-zinc-200 px-3 py-2 dark:border-zinc-700">
<SearchIcon className="h-4 w-4 shrink-0 text-zinc-400" />
<input
autoFocus
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Rechercher le noeud cible"
aria-label="Rechercher le noeud cible"
className="w-full bg-transparent text-sm outline-none placeholder:text-zinc-400"
/>
</div>
{candidates.length === 0 ? (
<p className="px-1 py-3 text-sm text-zinc-500">Aucun noeud ne correspond.</p>
) : (
<ul className="max-h-64 overflow-y-auto" aria-label="Cibles possibles">
{candidates.map(({ node: candidate, depth }) => (
<li key={candidate.id}>
<button
type="button"
onClick={() => create(candidate.id)}
className="flex w-full items-center gap-2 rounded-md px-2 py-2 text-left text-sm text-zinc-700 transition-colors hover:bg-zinc-100 dark:text-zinc-200 dark:hover:bg-zinc-800"
>
<span
aria-hidden="true"
className="shrink-0 font-mono text-[11px] text-zinc-400"
style={{ paddingLeft: depth * 10 }}
>
{depth === 0 ? '\u25cf' : '\u2514'}
</span>
<span className="truncate">{candidate.text || 'Sans titre'}</span>
</button>
</li>
))}
</ul>
)}
</div>
</Modal>
);
}

View File

@@ -0,0 +1,138 @@
import { useEffect, useState } from 'react';
import { type BranchStyle, type RelationMarker } from '@/types/map';
import { Button } from '@/components/Button';
import { TrashIcon } from '@/components/Icons';
import { PanelSection } from '@/features/editor/panel/PanelSection';
import {
ColorField,
NumberField,
SegmentedControl,
ToggleRow,
} from '@/features/editor/panel/controls';
import { LAYOUT_LIMITS } from '@/features/editor/nodeStyleResolver';
import { RELATION_DEFAULTS } from '@/features/editor/flowAdapters';
import { getTheme, relationColor } from '@/features/editor/themes';
import { useEditorStore } from '@/stores/editorStore';
const LINE_STYLES: Array<{ value: BranchStyle; label: string }> = [
{ value: 'bezier', label: 'Bezier' },
{ value: 'curved', label: 'Courbe' },
{ value: 'straight', label: 'Droite' },
];
const MARKERS: Array<{ value: RelationMarker; label: string }> = [
{ value: 'none', label: 'Aucune' },
{ value: 'arrow', label: 'Fleche' },
];
/**
* Proprietes d'une relation secondaire.
*
* Volontairement limite a ce qui concerne le trait : une relation n'a ni
* parent, ni enfants, ni ordre. La supprimer ici ne supprime aucun noeud.
*/
export function RelationProperties({ relationId }: { relationId: string }) {
const map = useEditorStore((state) => state.map);
const updateRelation = useEditorStore((state) => state.updateRelation);
const deleteRelation = useEditorStore((state) => state.deleteRelation);
const relation = map?.relations.find((candidate) => candidate.id === relationId);
const [label, setLabel] = useState('');
useEffect(() => setLabel(relation?.label ?? ''), [relation?.id, relation?.label]);
if (!map || !relation) return null;
const theme = getTheme(map.settings.theme);
const sourceText = map.nodes.find((node) => node.id === relation.sourceId)?.text ?? '';
const targetText = map.nodes.find((node) => node.id === relation.targetId)?.text ?? '';
return (
<>
<PanelSection id="relation-content" title="Relation">
<p className="text-xs leading-relaxed text-zinc-500 dark:text-zinc-400">
<span className="font-medium text-zinc-700 dark:text-zinc-200">
{sourceText || 'Sans titre'}
</span>
{' → '}
<span className="font-medium text-zinc-700 dark:text-zinc-200">
{targetText || 'Sans titre'}
</span>
</p>
<p className="text-[11px] leading-snug text-zinc-400">
Connexion visuelle uniquement : la hierarchie de ces deux noeuds est inchangee.
</p>
<div>
<label className="field-label" htmlFor="relation-label-field">
Libelle
</label>
<input
id="relation-label-field"
className="field-input"
value={label}
maxLength={120}
placeholder="depend de, utilise..."
onChange={(event) => setLabel(event.target.value)}
onBlur={() => {
const trimmed = label.trim();
if (trimmed !== (relation.label ?? '')) {
updateRelation(relation.id, trimmed ? { label: trimmed } : { label: undefined });
}
}}
/>
</div>
</PanelSection>
<PanelSection id="relation-style" title="Apparence">
<SegmentedControl
legend="Trace"
selected={relation.style ?? RELATION_DEFAULTS.style}
options={LINE_STYLES}
onSelect={(value) => updateRelation(relation.id, { style: value })}
/>
<ColorField
id="relation-color"
label="Couleur"
shared={{ value: relation.color, mixed: false }}
fallback={relationColor(theme)}
onChange={(value) => updateRelation(relation.id, { color: value })}
/>
<NumberField
id="relation-width"
label="Epaisseur"
value={relation.width ?? RELATION_DEFAULTS.width}
min={LAYOUT_LIMITS.branchWidth.min}
max={LAYOUT_LIMITS.branchWidth.max}
step={0.5}
onChange={(value) => updateRelation(relation.id, { width: value })}
/>
<ToggleRow
id="relation-dashed"
label="Trait pointille"
checked={relation.dashed ?? RELATION_DEFAULTS.dashed}
onChange={(checked) => updateRelation(relation.id, { dashed: checked })}
/>
<SegmentedControl
legend="Extremite d arrivee"
selected={relation.markerEnd ?? RELATION_DEFAULTS.markerEnd}
options={MARKERS}
onSelect={(value) => updateRelation(relation.id, { markerEnd: value })}
/>
<SegmentedControl
legend="Extremite de depart"
selected={relation.markerStart ?? RELATION_DEFAULTS.markerStart}
options={MARKERS}
onSelect={(value) => updateRelation(relation.id, { markerStart: value })}
/>
</PanelSection>
<div className="px-4 py-3">
<Button size="sm" variant="danger" onClick={() => deleteRelation(relation.id)}>
<TrashIcon className="h-3.5 w-3.5" />
Supprimer la relation
</Button>
</div>
</>
);
}

View File

@@ -0,0 +1,112 @@
import {
AlignCenterHorizontal,
AlignCenterVertical,
AlignEndHorizontal,
AlignEndVertical,
AlignHorizontalDistributeCenter,
AlignStartHorizontal,
AlignStartVertical,
AlignVerticalDistributeCenter,
} from 'lucide-react';
import { type AlignAxis, type DistributeAxis, useEditorStore } from '@/stores/editorStore';
import { cn } from '@/utils/cn';
interface ToolButton {
label: string;
icon: React.ReactNode;
run: () => void;
}
/**
* Barre contextuelle de mise en page, visible des qu'au moins deux noeuds sont
* selectionnes. Chaque action produit une seule entree d'historique.
*/
export function SelectionToolbar() {
const count = useEditorStore((state) => state.selectedNodeIds.length);
const readOnly = useEditorStore((state) => state.readOnly);
const alignNodes = useEditorStore((state) => state.alignNodes);
const distributeNodes = useEditorStore((state) => state.distributeNodes);
if (readOnly || count < 2) return null;
const align = (axis: AlignAxis) => () => alignNodes(axis);
const distribute = (axis: DistributeAxis) => () => distributeNodes(axis);
const alignButtons: ToolButton[] = [
{ label: 'Aligner a gauche', icon: <AlignStartVertical size={16} />, run: align('left') },
{
label: 'Aligner horizontalement au centre',
icon: <AlignCenterVertical size={16} />,
run: align('hcenter'),
},
{ label: 'Aligner a droite', icon: <AlignEndVertical size={16} />, run: align('right') },
{ label: 'Aligner en haut', icon: <AlignStartHorizontal size={16} />, run: align('top') },
{
label: 'Aligner verticalement au centre',
icon: <AlignCenterHorizontal size={16} />,
run: align('vcenter'),
},
{ label: 'Aligner en bas', icon: <AlignEndHorizontal size={16} />, run: align('bottom') },
];
const distributeButtons: ToolButton[] = [
{
label: 'Distribuer horizontalement',
icon: <AlignHorizontalDistributeCenter size={16} />,
run: distribute('horizontal'),
},
{
label: 'Distribuer verticalement',
icon: <AlignVerticalDistributeCenter size={16} />,
run: distribute('vertical'),
},
];
const buttonClass = cn(
'flex h-8 w-8 items-center justify-center rounded-md text-zinc-600 transition-colors',
'hover:bg-zinc-100 disabled:cursor-not-allowed disabled:opacity-40',
'dark:text-zinc-300 dark:hover:bg-zinc-800',
);
return (
<div
className="no-print panel absolute left-1/2 top-4 z-20 flex -translate-x-1/2 animate-fade-in items-center gap-1 px-2 py-1.5"
role="toolbar"
aria-label="Mise en page de la selection"
>
<span className="px-1.5 text-xs font-medium text-zinc-400">{count} noeuds</span>
<div className="mx-1 h-5 w-px bg-zinc-200 dark:bg-zinc-700" />
{alignButtons.map((button) => (
<button
key={button.label}
type="button"
className={buttonClass}
title={button.label}
aria-label={button.label}
onClick={button.run}
>
{button.icon}
</button>
))}
<div className="mx-1 h-5 w-px bg-zinc-200 dark:bg-zinc-700" />
{distributeButtons.map((button) => (
<button
key={button.label}
type="button"
className={buttonClass}
title={
count < 3 ? `${button.label} (3 noeuds minimum)` : button.label
}
aria-label={button.label}
disabled={count < 3}
onClick={button.run}
>
{button.icon}
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';
import { computeAlignmentGuides, type GuideBox } from '@/features/editor/alignmentGuides';
const box = (id: string, x: number, y: number, width = 100, height = 40): GuideBox => ({
id,
x,
y,
width,
height,
});
describe('guides d alignement', () => {
it('detecte un alignement des bords gauches', () => {
const moving = box('a', 100, 300);
const guides = computeAlignmentGuides(moving, [moving, box('b', 102, 0)]);
const vertical = guides.find((guide) => guide.orientation === 'vertical');
expect(vertical?.position).toBe(102);
});
it('detecte un alignement des centres horizontaux', () => {
const moving = box('a', 0, 200);
const guides = computeAlignmentGuides(moving, [moving, box('b', 500, 202)]);
const horizontal = guides.find((guide) => guide.orientation === 'horizontal');
expect(horizontal?.position).toBe(202);
});
it('ne propose rien au-dela du seuil', () => {
const moving = box('a', 0, 0);
expect(computeAlignmentGuides(moving, [moving, box('b', 400, 400)])).toHaveLength(0);
});
it('ignore le noeud deplace lui-meme', () => {
const moving = box('a', 0, 0);
expect(computeAlignmentGuides(moving, [moving])).toHaveLength(0);
});
it('produit au plus un guide par axe', () => {
const moving = box('a', 100, 100);
const guides = computeAlignmentGuides(moving, [
moving,
box('b', 100, 300),
box('c', 101, 400),
box('d', 400, 100),
]);
expect(guides.filter((guide) => guide.orientation === 'vertical')).toHaveLength(1);
expect(guides.filter((guide) => guide.orientation === 'horizontal')).toHaveLength(1);
});
});

View File

@@ -0,0 +1,119 @@
/**
* Guides d'alignement affiches pendant un deplacement.
*
* Volontairement simple : on compare les bords et les centres du noeud deplace
* a ceux des autres noeuds visibles, et on ne garde que la meilleure
* correspondance sur chaque axe. Aucun etat global, aucune entree d'historique.
*/
export interface GuideBox {
id: string;
x: number;
y: number;
width: number;
height: number;
}
export interface AlignmentGuide {
orientation: 'vertical' | 'horizontal';
/** Coordonnee de la ligne (x pour verticale, y pour horizontale). */
position: number;
/** Etendue de la ligne, pour ne pas traverser tout le canvas. */
start: number;
end: number;
}
export const GUIDE_THRESHOLD = 6;
type Candidate = { value: number; span: [number, number] };
function verticalCandidates(box: GuideBox): Candidate[] {
const span: [number, number] = [box.y, box.y + box.height];
return [
{ value: box.x, span },
{ value: box.x + box.width / 2, span },
{ value: box.x + box.width, span },
];
}
function horizontalCandidates(box: GuideBox): Candidate[] {
const span: [number, number] = [box.x, box.x + box.width];
return [
{ value: box.y, span },
{ value: box.y + box.height / 2, span },
{ value: box.y + box.height, span },
];
}
function bestMatch(
moving: Candidate[],
others: GuideBox[],
extract: (box: GuideBox) => Candidate[],
threshold: number,
): { position: number; span: [number, number] } | null {
let best: { position: number; span: [number, number]; distance: number } | null = null;
for (const other of others) {
for (const candidate of extract(other)) {
for (const source of moving) {
const distance = Math.abs(source.value - candidate.value);
if (distance > threshold) continue;
if (best && distance >= best.distance) continue;
best = {
position: candidate.value,
span: [
Math.min(source.span[0], candidate.span[0]),
Math.max(source.span[1], candidate.span[1]),
],
distance,
};
}
}
}
return best ? { position: best.position, span: best.span } : null;
}
/** Au plus un guide vertical et un guide horizontal. */
export function computeAlignmentGuides(
moving: GuideBox,
others: GuideBox[],
threshold: number = GUIDE_THRESHOLD,
): AlignmentGuide[] {
const candidates = others.filter((box) => box.id !== moving.id);
if (candidates.length === 0) return [];
const guides: AlignmentGuide[] = [];
const vertical = bestMatch(
verticalCandidates(moving),
candidates,
verticalCandidates,
threshold,
);
if (vertical) {
guides.push({
orientation: 'vertical',
position: vertical.position,
start: vertical.span[0],
end: vertical.span[1],
});
}
const horizontal = bestMatch(
horizontalCandidates(moving),
candidates,
horizontalCandidates,
threshold,
);
if (horizontal) {
guides.push({
orientation: 'horizontal',
position: horizontal.position,
start: horizontal.span[0],
end: horizontal.span[1],
});
}
return guides;
}

View File

@@ -0,0 +1,221 @@
import { describe, expect, it } from 'vitest';
import { type MindMapNode } from '@/types/map';
import {
assignSides,
findBranchRoot,
getLevelOneBranches,
materializeSides,
resolveNodeSides,
} from '@/features/editor/bilateral';
import { computeLayout } from '@/features/editor/layout';
function node(
id: string,
parentId: string | null,
order: number,
extra: Partial<MindMapNode> = {},
): MindMapNode {
return {
id,
type: parentId === null ? 'root' : 'standard',
text: id,
position: { x: 0, y: 0 },
parentId,
order,
...extra,
};
}
/**
* Racine avec quatre branches de poids tres differents :
* A = 25 noeuds, B = 3, C = 12, D = 5.
*/
function unbalancedMap(): MindMapNode[] {
const nodes: MindMapNode[] = [node('root', null, 0)];
const weights: Array<[string, number]> = [
['A', 24],
['B', 2],
['C', 11],
['D', 4],
];
weights.forEach(([branch, children], index) => {
nodes.push(node(branch, 'root', index));
for (let i = 0; i < children; i += 1) nodes.push(node(`${branch}${i}`, branch, i));
});
return nodes;
}
describe('attribution automatique des cotes', () => {
it('repartit les branches sans cote de part et d autre', () => {
const sides = resolveNodeSides(unbalancedMap());
const branches = ['A', 'B', 'C', 'D'].map((id) => sides.get(id));
expect(branches).toContain('left');
expect(branches).toContain('right');
});
it('equilibre les deux cotes en tenant compte de la taille des sous-arbres', () => {
const nodes = unbalancedMap();
const sides = resolveNodeSides(nodes);
const weights: Record<string, number> = { A: 25, B: 3, C: 12, D: 5 };
let left = 0;
let right = 0;
for (const [branch, weight] of Object.entries(weights)) {
if (sides.get(branch) === 'left') left += weight;
else right += weight;
}
// 25 d'un cote, 3 + 12 + 5 = 20 de l'autre : l'ecart reste faible.
expect(Math.abs(left - right)).toBeLessThanOrEqual(5);
});
it('est deterministe : deux appels donnent le meme resultat', () => {
const first = resolveNodeSides(unbalancedMap());
const second = resolveNodeSides(unbalancedMap());
for (const id of ['A', 'B', 'C', 'D']) expect(second.get(id)).toBe(first.get(id));
});
it('respecte un cote choisi a la main et equilibre le reste autour', () => {
const nodes = unbalancedMap().map((candidate) =>
candidate.id === 'A' ? { ...candidate, side: 'left' as const } : candidate,
);
const sides = resolveNodeSides(nodes);
expect(sides.get('A')).toBe('left');
// 25 a gauche : les trois autres branches vont a droite.
expect(sides.get('B')).toBe('right');
expect(sides.get('C')).toBe('right');
expect(sides.get('D')).toBe('right');
});
it('departage a poids egal par l ordre de fratrie', () => {
const result = assignSides([
{ id: 'x', weight: 4, order: 0 },
{ id: 'y', weight: 4, order: 1 },
]);
expect(result.get('x')).toBe('right');
expect(result.get('y')).toBe('left');
});
});
describe('heritage du cote', () => {
it('donne a tous les descendants le cote de leur branche de niveau 1', () => {
const nodes = unbalancedMap();
const sides = resolveNodeSides(nodes);
for (const branch of ['A', 'B', 'C', 'D']) {
const expected = sides.get(branch);
for (const candidate of nodes.filter((item) => item.parentId === branch)) {
expect(sides.get(candidate.id)).toBe(expected);
}
}
});
it('identifie la branche de niveau 1 dont depend un noeud profond', () => {
const nodes = unbalancedMap();
expect(findBranchRoot(nodes, 'A3')?.id).toBe('A');
expect(findBranchRoot(nodes, 'A')?.id).toBe('A');
expect(findBranchRoot(nodes, 'root')).toBeNull();
});
it('ne liste comme branches que les enfants directs des racines', () => {
const branches = getLevelOneBranches(unbalancedMap()).map((branch) => branch.id);
expect(branches).toEqual(['A', 'B', 'C', 'D']);
});
});
describe('materialisation des cotes', () => {
it('ecrit un cote uniquement sur les branches de niveau 1', () => {
const materialized = materializeSides(unbalancedMap());
for (const candidate of materialized) {
const isBranch = ['A', 'B', 'C', 'D'].includes(candidate.id);
expect(candidate.side !== undefined).toBe(isBranch);
}
});
it('ne modifie pas le tableau si tous les cotes sont deja definis', () => {
const once = materializeSides(unbalancedMap());
expect(materializeSides(once)).toBe(once);
});
it('n ecrase jamais un cote choisi a la main', () => {
const nodes = unbalancedMap().map((candidate) =>
candidate.id === 'B' ? { ...candidate, side: 'left' as const } : candidate,
);
const materialized = materializeSides(nodes);
expect(materialized.find((candidate) => candidate.id === 'B')?.side).toBe('left');
});
});
describe('auto-layout bilateral', () => {
it('place la racine entre les deux cotes', () => {
const nodes = materializeSides(unbalancedMap());
const sides = resolveNodeSides(nodes);
const positions = computeLayout(nodes, 'mindmap-both');
const rootX = positions.get('root')?.x as number;
const lefts = nodes
.filter((candidate) => sides.get(candidate.id) === 'left' && candidate.parentId === 'root')
.map((candidate) => positions.get(candidate.id)?.x as number);
const rights = nodes
.filter((candidate) => sides.get(candidate.id) === 'right' && candidate.parentId === 'root')
.map((candidate) => positions.get(candidate.id)?.x as number);
expect(lefts.length).toBeGreaterThan(0);
expect(rights.length).toBeGreaterThan(0);
for (const x of lefts) expect(x).toBeLessThan(rootX);
for (const x of rights) expect(x).toBeGreaterThan(rootX);
});
it('fait progresser chaque cote en s eloignant de la racine', () => {
const nodes = materializeSides(unbalancedMap());
const sides = resolveNodeSides(nodes);
const positions = computeLayout(nodes, 'mindmap-both');
const rootX = positions.get('root')?.x as number;
for (const branch of ['A', 'B', 'C', 'D']) {
const branchX = positions.get(branch)?.x as number;
const child = nodes.find((candidate) => candidate.parentId === branch);
if (!child) continue;
const childX = positions.get(child.id)?.x as number;
if (sides.get(branch) === 'left') {
expect(childX).toBeLessThan(branchX);
expect(branchX).toBeLessThan(rootX);
} else {
expect(childX).toBeGreaterThan(branchX);
expect(branchX).toBeGreaterThan(rootX);
}
}
});
it('respecte l ordre des freres a l interieur d un cote', () => {
const nodes: MindMapNode[] = [
node('root', null, 0),
node('a', 'root', 0, { side: 'right' }),
node('b', 'root', 1, { side: 'right' }),
node('c', 'root', 2, { side: 'right' }),
];
const positions = computeLayout(nodes, 'mindmap-both');
const y = (id: string) => positions.get(id)?.y as number;
expect(y('a')).toBeLessThan(y('b'));
expect(y('b')).toBeLessThan(y('c'));
});
it('deploie vers la droite ou vers la gauche selon la disposition classique', () => {
const nodes: MindMapNode[] = [node('root', null, 0), node('a', 'root', 0)];
const right = computeLayout(nodes, 'mindmap-right');
expect((right.get('a')?.x as number)).toBeGreaterThan(right.get('root')?.x as number);
const left = computeLayout(nodes, 'mindmap-left');
expect((left.get('a')?.x as number)).toBeLessThan(left.get('root')?.x as number);
});
it('ignore les relations secondaires : elles ne sont pas dans le graphe', () => {
// computeLayout ne recoit que des noeuds : aucune relation ne peut
// influencer dagre, par construction de sa signature.
const nodes = materializeSides(unbalancedMap());
const withoutRelations = computeLayout(nodes, 'mindmap-both');
const again = computeLayout(nodes, 'mindmap-both');
for (const [id, position] of withoutRelations) {
expect(again.get(id)).toEqual(position);
}
});
});

View File

@@ -0,0 +1,152 @@
import { type MindMapNode, type NodeSide } from '@/types/map';
import { buildChildrenMap, buildNodeMap, getOrderedChildren, getRootNodes } from '@/utils/tree';
/**
* Cotes gauche / droite d'une mind map bilaterale.
*
* Le modele est volontairement minimal : seul un enfant direct d'une racine
* porte un `side`. Tous ses descendants heritent du cote de leur branche de
* niveau 1, ce qui evite d'entretenir la meme information sur chaque noeud et
* garantit qu'une branche ne peut pas se retrouver a cheval sur les deux cotes.
*
* `order` reste la seule source de verite de l'ordre : on filtre les enfants
* par cote, puis on respecte leur ordre existant. Il n'y a jamais de
* `leftOrder` / `rightOrder` a maintenir en parallele.
*/
/** Poids d'une branche : elle-meme plus tous ses descendants. */
function branchWeight(children: Map<string, string[]>, rootId: string): number {
let total = 1;
const queue = [...(children.get(rootId) ?? [])];
while (queue.length > 0) {
const current = queue.shift() as string;
total += 1;
const next = children.get(current);
if (next) queue.push(...next);
}
return total;
}
/**
* Repartition gloutonne et deterministe.
*
* Les branches sans cote sont traitees de la plus lourde a la plus legere et
* placees du cote le moins charge. C'est suffisant pour equilibrer visuellement
* une carte reelle, sans construire un solveur : a poids egal, l'ordre de
* fratrie tranche, donc le resultat est reproductible.
*/
export function assignSides(
branches: Array<{ id: string; weight: number; order: number; side?: NodeSide }>,
): Map<string, NodeSide> {
const result = new Map<string, NodeSide>();
let leftLoad = 0;
let rightLoad = 0;
for (const branch of branches) {
if (!branch.side) continue;
result.set(branch.id, branch.side);
if (branch.side === 'left') leftLoad += branch.weight;
else rightLoad += branch.weight;
}
const pending = branches
.filter((branch) => !branch.side)
.sort((a, b) => (b.weight !== a.weight ? b.weight - a.weight : a.order - b.order));
for (const branch of pending) {
// A charge egale, la droite d'abord : c'est le cote de lecture naturel.
const side: NodeSide = rightLoad <= leftLoad ? 'right' : 'left';
result.set(branch.id, side);
if (side === 'left') leftLoad += branch.weight;
else rightLoad += branch.weight;
}
return result;
}
/** Branches de niveau 1 : les enfants directs des racines, dans l'ordre. */
export function getLevelOneBranches(nodes: MindMapNode[]): MindMapNode[] {
const branches: MindMapNode[] = [];
for (const root of getRootNodes(nodes)) {
branches.push(...getOrderedChildren(nodes, root.id));
}
return branches;
}
/**
* Cote effectif de chaque noeud.
*
* Fonction pure : elle ne modifie pas le document. Une branche sans cote defini
* en recoit un ici aussi, par le meme algorithme que l'attribution explicite :
* une branche ajoutee apres coup se place donc immediatement du bon cote, sans
* qu'il faille reecrire la carte.
*/
export function resolveNodeSides(nodes: MindMapNode[]): Map<string, NodeSide> {
const children = buildChildrenMap(nodes);
const branches = getLevelOneBranches(nodes).map((node) => ({
id: node.id,
weight: branchWeight(children, node.id),
order: node.order ?? 0,
side: node.side,
}));
const byBranch = assignSides(branches);
const sides = new Map<string, NodeSide>();
// Les racines restent au centre : leur cote ne sert qu'aux cas degenres.
for (const root of getRootNodes(nodes)) sides.set(root.id, 'right');
for (const [branchId, side] of byBranch) {
sides.set(branchId, side);
const queue = [...(children.get(branchId) ?? [])];
while (queue.length > 0) {
const current = queue.shift() as string;
sides.set(current, side);
const next = children.get(current);
if (next) queue.push(...next);
}
}
// Securite : un noeud orphelin d'une donnee incoherente reste positionnable.
for (const node of nodes) if (!sides.has(node.id)) sides.set(node.id, 'right');
return sides;
}
/**
* Materialise les cotes manquants dans le document.
*
* Seules les branches de niveau 1 sans cote sont completees : un cote choisi a
* la main n'est jamais ecrase. Retourne le tableau d'origine si rien ne change.
*/
export function materializeSides(nodes: MindMapNode[]): MindMapNode[] {
const branches = getLevelOneBranches(nodes);
if (branches.every((branch) => branch.side)) return nodes;
const resolved = resolveNodeSides(nodes);
const branchIds = new Set(branches.map((branch) => branch.id));
return nodes.map((node) =>
branchIds.has(node.id) && !node.side
? { ...node, side: resolved.get(node.id) ?? 'right' }
: node,
);
}
/**
* Branche de niveau 1 dont depend un noeud, c'est-a-dire le noeud dont le
* `side` fait foi pour lui. `null` pour une racine.
*/
export function findBranchRoot(nodes: MindMapNode[], nodeId: string): MindMapNode | null {
const byId = buildNodeMap(nodes);
let current = byId.get(nodeId);
if (!current) return null;
const guard = new Set<string>();
while (current.parentId && byId.has(current.parentId) && !guard.has(current.id)) {
guard.add(current.id);
const parent = byId.get(current.parentId) as MindMapNode;
if (!parent.parentId || !byId.has(parent.parentId)) return current;
current = parent;
}
return null;
}

View File

@@ -1,27 +1,23 @@
import { type Edge, type Node } from '@xyflow/react'; import { type Edge, type Node } from '@xyflow/react';
import { Position } from '@xyflow/react'; import { MarkerType, Position } from '@xyflow/react';
import { type BranchStyle, type MindMap, type MindMapNode, type NodeShape } from '@/types/map'; import {
import { type MapTheme, paletteForDepth } from '@/features/editor/themes'; type BranchStyle,
type MindMap,
type MindMapNode,
type NodeSide,
} from '@/types/map';
import { resolveNodeSides } from '@/features/editor/bilateral';
import { relationColor, type MapTheme } from '@/features/editor/themes';
import {
resolveNodeAppearance,
type NodeAppearance,
} from '@/features/editor/nodeStyleResolver';
import { isHorizontalLayout, layoutDirection } from '@/features/editor/layout'; import { isHorizontalLayout, layoutDirection } from '@/features/editor/layout';
import { buildChildrenMap, computeDepths, getHiddenNodeIds } from '@/utils/tree'; import { buildChildrenMap, buildNodeMap, computeDepths, getHiddenNodeIds } from '@/utils/tree';
import { type NodeSize } from '@/utils/nodeSize'; import { type NodeSize } from '@/utils/nodeSize';
/** Style CSS resolu d'un noeud : theme + personnalisations explicites. */ export { resolveNodeAppearance };
export interface NodeAppearance { export type { NodeAppearance };
background: string;
color: string;
borderColor: string;
borderWidth: number;
borderRadius: string;
fontSize: number;
fontFamily: string;
fontWeight: number;
fontStyle: 'normal' | 'italic';
textAlign: 'left' | 'center' | 'right';
boxShadow: string;
width?: number;
circle: boolean;
}
export interface MindNodeData extends Record<string, unknown> { export interface MindNodeData extends Record<string, unknown> {
node: MindMapNode; node: MindMapNode;
@@ -32,57 +28,23 @@ export interface MindNodeData extends Record<string, unknown> {
isSearchHit: boolean; isSearchHit: boolean;
readOnly: boolean; readOnly: boolean;
isEditing: boolean; isEditing: boolean;
sourcePosition: Position; /**
targetPosition: Position; * Poignees sortantes. Une seule dans les dispositions classiques ; en mind map
* bilaterale, la racine en porte deux puisque ses branches partent des deux
* cotes.
*/
sourceHandles: HandleSpec[];
targetHandle: HandleSpec;
}
/** Une poignee de connexion : identifiant stable et cote d'ancrage. */
export interface HandleSpec {
id: string;
position: Position;
} }
export type MindFlowNode = Node<MindNodeData, 'mind'>; export type MindFlowNode = Node<MindNodeData, 'mind'>;
function radiusForShape(shape: NodeShape, themeRadius: number): string {
switch (shape) {
case 'rectangle':
return '3px';
case 'capsule':
return '999px';
case 'circle':
return '50%';
case 'rounded':
default:
return `${themeRadius}px`;
}
}
/**
* Fusionne le theme et les personnalisations du noeud.
* Une valeur definie explicitement sur le noeud gagne toujours : changer de
* theme ne detruit donc jamais une personnalisation.
*/
export function resolveNodeAppearance(
node: MindMapNode,
depth: number,
theme: MapTheme,
): NodeAppearance {
const palette = paletteForDepth(theme, depth);
const style = node.style ?? {};
const shape = style.shape ?? theme.nodeShape;
return {
background: style.backgroundColor ?? palette.background,
color: style.textColor ?? palette.text,
borderColor: style.borderColor ?? palette.border,
borderWidth: theme.borderWidth,
borderRadius: radiusForShape(shape, theme.nodeRadius),
fontSize: style.fontSize ?? (depth === 0 ? 16 : 14),
fontFamily: theme.fontFamily,
fontWeight: style.bold ? 700 : depth === 0 ? 600 : 500,
fontStyle: style.italic ? 'italic' : 'normal',
textAlign: style.textAlign ?? 'left',
boxShadow: theme.shadow,
...(style.width ? { width: style.width } : {}),
circle: shape === 'circle',
};
}
function handlePositions(layout: MindMap['settings']['layout']): { function handlePositions(layout: MindMap['settings']['layout']): {
source: Position; source: Position;
target: Position; target: Position;
@@ -95,6 +57,81 @@ function handlePositions(layout: MindMap['settings']['layout']): {
: { source: Position.Right, target: Position.Left }; : { source: Position.Right, target: Position.Left };
} }
function handleSpec(type: 's' | 't', position: Position): HandleSpec {
return { id: `${type}-${position}`, position };
}
/** Poignees d'un noeud : une entrante, une ou deux sortantes. */
export interface HandlePlan {
sources: HandleSpec[];
target: HandleSpec;
/** Poignee sortante a utiliser pour rejoindre un enfant, par cote. */
sourceBySide: Map<NodeSide, HandleSpec>;
}
const SIDE_POSITION: Record<NodeSide, Position> = {
left: Position.Left,
right: Position.Right,
};
/**
* Repartition des poignees, calculee une fois par carte.
*
* Hors bilaterale, tous les noeuds partagent la meme orientation, comme avant.
* En bilaterale, chaque noeud regarde vers le centre et la racine porte les
* deux poignees sortantes : c'est ce qui permet aux branches de partir a gauche
* comme a droite sans que le trait fasse un detour.
*/
export function buildHandlePlans(map: MindMap): Map<string, HandlePlan> {
const plans = new Map<string, HandlePlan>();
const bilateral = map.settings.layout === 'mindmap-both';
const positions = handlePositions(map.settings.layout);
if (!bilateral) {
const source = handleSpec('s', positions.source);
const target = handleSpec('t', positions.target);
const plan: HandlePlan = {
sources: [source],
target,
sourceBySide: new Map([
['left', source],
['right', source],
]),
};
for (const node of map.nodes) plans.set(node.id, plan);
return plans;
}
const sides = resolveNodeSides(map.nodes);
const children = buildChildrenMap(map.nodes);
for (const node of map.nodes) {
const own = sides.get(node.id) ?? 'right';
// Le parent est toujours du cote du centre : l'entree lui fait face.
const target = handleSpec('t', own === 'left' ? Position.Right : Position.Left);
const childSides = new Set<NodeSide>();
for (const childId of children.get(node.id) ?? []) {
childSides.add(sides.get(childId) ?? 'right');
}
// Un noeud sans enfant garde une poignee : on doit pouvoir tirer un lien.
if (childSides.size === 0) childSides.add(own);
const sources = (['left', 'right'] as NodeSide[])
.filter((side) => childSides.has(side))
.map((side) => handleSpec('s', SIDE_POSITION[side]));
const sourceBySide = new Map<NodeSide, HandleSpec>();
for (const side of ['left', 'right'] as NodeSide[]) {
const match = sources.find((handle) => handle.position === SIDE_POSITION[side]);
sourceBySide.set(side, match ?? (sources[0] as HandleSpec));
}
plans.set(node.id, { sources, target, sourceBySide });
}
return plans;
}
const EDGE_TYPE_BY_BRANCH: Record<BranchStyle, string> = { const EDGE_TYPE_BY_BRANCH: Record<BranchStyle, string> = {
bezier: 'default', bezier: 'default',
curved: 'smoothstep', curved: 'smoothstep',
@@ -117,12 +154,18 @@ export function toFlowNodes(map: MindMap, options: AdapterOptions): MindFlowNode
const children = buildChildrenMap(map.nodes); const children = buildChildrenMap(map.nodes);
const selected = new Set(options.selectedIds); const selected = new Set(options.selectedIds);
const searchHits = new Set(options.searchResults); const searchHits = new Set(options.searchResults);
const positions = handlePositions(map.settings.layout); const plans = buildHandlePlans(map);
const fallback: HandlePlan = {
sources: [handleSpec('s', Position.Right)],
target: handleSpec('t', Position.Left),
sourceBySide: new Map(),
};
return map.nodes.map((node) => { return map.nodes.map((node) => {
const depth = depths.get(node.id) ?? 0; const depth = depths.get(node.id) ?? 0;
const childIds = children.get(node.id) ?? []; const childIds = children.get(node.id) ?? [];
const measured = options.measuredSizes?.get(node.id); const measured = options.measuredSizes?.get(node.id);
const plan = plans.get(node.id) ?? fallback;
return { return {
id: node.id, id: node.id,
@@ -144,8 +187,8 @@ export function toFlowNodes(map: MindMap, options: AdapterOptions): MindFlowNode
isSearchHit: searchHits.has(node.id), isSearchHit: searchHits.has(node.id),
readOnly: options.readOnly, readOnly: options.readOnly,
isEditing: options.editingNodeId === node.id, isEditing: options.editingNodeId === node.id,
sourcePosition: positions.source, sourceHandles: plan.sources,
targetPosition: positions.target, targetHandle: plan.target,
}, },
}; };
}); });
@@ -163,20 +206,128 @@ function countBranch(children: Map<string, string[]>, rootId: string): number {
return total; return total;
} }
/**
* Les liens heritent du style de branche de la carte, sauf si le noeud enfant
* definit sa propre personnalisation (`node.branch`).
*/
export function toFlowEdges(map: MindMap, options: AdapterOptions): Edge[] { export function toFlowEdges(map: MindMap, options: AdapterOptions): Edge[] {
const hidden = getHiddenNodeIds(map.nodes); const hidden = getHiddenNodeIds(map.nodes);
const edgeType = EDGE_TYPE_BY_BRANCH[map.settings.branchStyle] ?? 'default'; const nodeMap = buildNodeMap(map.nodes);
const defaultType = EDGE_TYPE_BY_BRANCH[map.settings.branchStyle] ?? 'default';
const plans = buildHandlePlans(map);
const sides = map.settings.layout === 'mindmap-both' ? resolveNodeSides(map.nodes) : null;
return map.edges.map((edge) => ({ return map.edges.map((edge) => {
const target = nodeMap.get(edge.target);
const branch = target?.branch ?? {};
const type = branch.style ? (EDGE_TYPE_BY_BRANCH[branch.style] ?? defaultType) : defaultType;
const childSide: NodeSide = sides?.get(edge.target) ?? 'right';
return {
id: edge.id, id: edge.id,
source: edge.source, source: edge.source,
target: edge.target, target: edge.target,
type: edgeType, sourceHandle: plans.get(edge.source)?.sourceBySide.get(childSide)?.id,
targetHandle: plans.get(edge.target)?.target.id,
type,
hidden: hidden.has(edge.source) || hidden.has(edge.target), hidden: hidden.has(edge.source) || hidden.has(edge.target),
deletable: !options.readOnly, deletable: !options.readOnly,
style: { style: {
stroke: edge.color ?? options.theme.branchColor, stroke: branch.color ?? edge.color ?? options.theme.branchColor,
strokeWidth: options.theme.branchWidth, strokeWidth: branch.width ?? options.theme.branchWidth,
}, },
})); };
});
}
/** Apparence par defaut d'une relation : pointille flechee, pour la distinguer. */
export const RELATION_DEFAULTS = {
style: 'bezier' as BranchStyle,
width: 2,
dashed: true,
markerEnd: 'arrow' as const,
markerStart: 'none' as const,
};
/** Prefixe des identifiants de relation : ils ne peuvent pas etre confondus
* avec un lien hierarchique, dont l'id est toujours `e-<parent>-<enfant>`. */
export const RELATION_ID_PREFIX = 'r-';
/**
* Relations secondaires converties en aretes React Flow.
*
* Elles ne sont jamais melangees a `map.edges` dans le document : les liens
* hierarchiques restent derives de `parentId`, les relations restent une donnee
* propre. C'est seulement a l'affichage que les deux se rejoignent.
*
* Une relation dont une extremite est masquee par un repli est masquee elle
* aussi : on ne trace pas un trait vers une position qui n'existe pas. Elle
* reste dans le document et reapparait au deploiement.
*/
export function toFlowRelationEdges(
map: MindMap,
options: AdapterOptions & { selectedRelationId?: string | null },
): Edge[] {
if (map.relations.length === 0) return [];
const hidden = getHiddenNodeIds(map.nodes);
const nodeMap = buildNodeMap(map.nodes);
const plans = buildHandlePlans(map);
return map.relations
.filter((relation) => nodeMap.has(relation.sourceId) && nodeMap.has(relation.targetId))
.map((relation) => {
const source = nodeMap.get(relation.sourceId) as MindMapNode;
const target = nodeMap.get(relation.targetId) as MindMapNode;
const type =
EDGE_TYPE_BY_BRANCH[relation.style ?? RELATION_DEFAULTS.style] ?? 'default';
const color = relation.color ?? relationColor(options.theme);
const width = relation.width ?? RELATION_DEFAULTS.width;
const dashed = relation.dashed ?? RELATION_DEFAULTS.dashed;
// La poignee sortante la plus proche de la cible evite un trait qui
// contourne le noeud avant de repartir.
const towards: NodeSide = target.position.x >= source.position.x ? 'right' : 'left';
const sourcePlan = plans.get(relation.sourceId);
const sourceHandle =
sourcePlan?.sources.find(
(handle) => handle.position === SIDE_POSITION[towards],
)?.id ?? sourcePlan?.sources[0]?.id;
return {
id: relation.id,
source: relation.sourceId,
target: relation.targetId,
sourceHandle,
targetHandle: plans.get(relation.targetId)?.target.id,
type,
selected: options.selectedRelationId === relation.id,
hidden: hidden.has(relation.sourceId) || hidden.has(relation.targetId),
deletable: !options.readOnly,
selectable: true,
...(relation.label ? { label: relation.label } : {}),
labelStyle: { fill: color, fontSize: 11, fontWeight: 500 },
labelBgStyle: { fill: options.theme.canvasBackground, fillOpacity: 0.85 },
labelBgPadding: [4, 2] as [number, number],
labelBgBorderRadius: 4,
markerEnd:
(relation.markerEnd ?? RELATION_DEFAULTS.markerEnd) === 'arrow'
? { type: MarkerType.ArrowClosed, color, width: 16, height: 16 }
: undefined,
markerStart:
(relation.markerStart ?? RELATION_DEFAULTS.markerStart) === 'arrow'
? { type: MarkerType.ArrowClosed, color, width: 16, height: 16 }
: undefined,
style: {
stroke: color,
strokeWidth: width,
...(dashed ? { strokeDasharray: '6 5' } : {}),
},
data: { relation },
};
});
}
/** Vrai si cette arete affichee est une relation secondaire. */
export function isRelationEdgeId(map: MindMap, edgeId: string): boolean {
return map.relations.some((relation) => relation.id === edgeId);
} }

View File

@@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { computeLayout, isHorizontalLayout, layoutDirection } from '@/features/editor/layout'; import {
DEFAULT_LAYOUT_SPACING,
computeLayout,
isHorizontalLayout,
layoutDirection,
} from '@/features/editor/layout';
import { createSampleMap } from '@/services/mapFactory'; import { createSampleMap } from '@/services/mapFactory';
describe('auto-layout', () => { describe('auto-layout', () => {
@@ -47,3 +52,68 @@ describe('auto-layout', () => {
expect(computeLayout([], 'mindmap-right').size).toBe(0); expect(computeLayout([], 'mindmap-right').size).toBe(0);
}); });
}); });
describe('espacement de l auto-layout', () => {
it('ecarte davantage les rangs quand parentGap augmente', () => {
const map = createSampleMap();
const root = map.nodes[0] as { id: string };
const child = map.nodes[1] as { id: string };
const compact = computeLayout(map.nodes, 'mindmap-right', {
spacing: { parentGap: 70, siblingGap: 30 },
});
const airy = computeLayout(map.nodes, 'mindmap-right', {
spacing: { parentGap: 200, siblingGap: 30 },
});
const compactDistance = (compact.get(child.id)?.x ?? 0) - (compact.get(root.id)?.x ?? 0);
const airyDistance = (airy.get(child.id)?.x ?? 0) - (airy.get(root.id)?.x ?? 0);
expect(airyDistance).toBeGreaterThan(compactDistance);
});
it('ecarte davantage les freres quand siblingGap augmente', () => {
const map = createSampleMap();
const siblings = map.nodes.filter((node) => node.parentId === map.nodes[0]?.id);
const first = siblings[0] as { id: string };
const second = siblings[1] as { id: string };
const tight = computeLayout(map.nodes, 'mindmap-right', {
spacing: { parentGap: 100, siblingGap: 10 },
});
const loose = computeLayout(map.nodes, 'mindmap-right', {
spacing: { parentGap: 100, siblingGap: 200 },
});
const tightGap = Math.abs((tight.get(second.id)?.y ?? 0) - (tight.get(first.id)?.y ?? 0));
const looseGap = Math.abs((loose.get(second.id)?.y ?? 0) - (loose.get(first.id)?.y ?? 0));
expect(looseGap).toBeGreaterThan(tightGap);
});
it('utilise le preset Normal quand aucun espacement n est fourni', () => {
const map = createSampleMap();
const implicit = computeLayout(map.nodes, 'mindmap-right');
const explicit = computeLayout(map.nodes, 'mindmap-right', {
spacing: DEFAULT_LAYOUT_SPACING,
});
for (const node of map.nodes) {
expect(implicit.get(node.id)).toEqual(explicit.get(node.id));
}
});
it('tient compte des tailles fixes des noeuds', () => {
const map = createSampleMap();
const wide = { ...(map.nodes[1] as (typeof map.nodes)[number]) };
wide.layout = { widthMode: 'fixed', width: 600, heightMode: 'fixed', height: 200 };
const nodes = map.nodes.map((node) => (node.id === wide.id ? wide : node));
const positions = computeLayout(nodes, 'mindmap-right');
const grandChild = nodes.find((node) => node.parentId === wide.id);
if (!grandChild) return;
// Le petit-enfant doit etre place au-dela du noeud large.
expect(positions.get(grandChild.id)?.x ?? 0).toBeGreaterThan(
(positions.get(wide.id)?.x ?? 0) + 400,
);
});
});

View File

@@ -1,5 +1,8 @@
import dagre from 'dagre'; import dagre from 'dagre';
import { type LayoutKind, type MindMapNode, type Position } from '@/types/map'; import { type LayoutKind, type LayoutSpacing, type MindMapNode, type Position } from '@/types/map';
import { type MapTheme } from '@/features/editor/themes';
import { resolveNodeSides } from '@/features/editor/bilateral';
import { getRootNodes } from '@/utils/tree';
import { resolveNodeSize, type NodeSize } from '@/utils/nodeSize'; import { resolveNodeSize, type NodeSize } from '@/utils/nodeSize';
export interface LayoutOption { export interface LayoutOption {
@@ -9,9 +12,9 @@ export interface LayoutOption {
} }
/** /**
* Dispositions proposees dans l'interface de la V1. * Dispositions proposees dans l'interface.
* `mindmap-both`, `organization` et `freeform` existent dans le modele mais ne * `organization` et `freeform` existent dans le modele mais ne sont pas encore
* sont pas encore implementees : elles restent disponibles pour une V2. * implementees : elles restent disponibles pour une version ulterieure.
*/ */
export const LAYOUT_OPTIONS: LayoutOption[] = [ export const LAYOUT_OPTIONS: LayoutOption[] = [
{ {
@@ -24,6 +27,11 @@ export const LAYOUT_OPTIONS: LayoutOption[] = [
label: 'Mind map gauche', label: 'Mind map gauche',
description: 'Les branches se deploient vers la gauche.', description: 'Les branches se deploient vers la gauche.',
}, },
{
id: 'mindmap-both',
label: 'Mind map bilaterale',
description: 'Racine centree, branches reparties a gauche et a droite.',
},
{ {
id: 'tree-horizontal', id: 'tree-horizontal',
label: 'Arbre horizontal', label: 'Arbre horizontal',
@@ -36,21 +44,45 @@ export const LAYOUT_OPTIONS: LayoutOption[] = [
}, },
]; ];
/** Espacement par defaut quand la carte n'en definit pas (preset "Normal"). */
export const DEFAULT_LAYOUT_SPACING: LayoutSpacing = { parentGap: 100, siblingGap: 50 };
export const SPACING_PRESETS: Array<{ id: string; label: string; spacing: LayoutSpacing }> = [
{ id: 'compact', label: 'Compact', spacing: { parentGap: 70, siblingGap: 30 } },
{ id: 'normal', label: 'Normal', spacing: DEFAULT_LAYOUT_SPACING },
{ id: 'airy', label: 'Aere', spacing: { parentGap: 140, siblingGap: 80 } },
];
export const SPACING_LIMITS = {
parentGap: { min: 30, max: 320 },
siblingGap: { min: 10, max: 240 },
} as const;
export function resolveSpacing(spacing?: LayoutSpacing): LayoutSpacing {
return {
parentGap: spacing?.parentGap ?? DEFAULT_LAYOUT_SPACING.parentGap,
siblingGap: spacing?.siblingGap ?? DEFAULT_LAYOUT_SPACING.siblingGap,
};
}
interface DagreParams { interface DagreParams {
rankdir: 'LR' | 'RL' | 'TB'; rankdir: 'LR' | 'RL' | 'TB';
ranksep: number; /** Multiplicateurs appliques a l'espacement demande par l'utilisateur. */
nodesep: number; rankFactor: number;
nodeFactor: number;
} }
const LAYOUT_PARAMS: Record<string, DagreParams> = { const LAYOUT_PARAMS: Record<string, DagreParams> = {
'mindmap-right': { rankdir: 'LR', ranksep: 90, nodesep: 22 }, 'mindmap-right': { rankdir: 'LR', rankFactor: 0.9, nodeFactor: 0.44 },
'mindmap-left': { rankdir: 'RL', ranksep: 90, nodesep: 22 }, 'mindmap-left': { rankdir: 'RL', rankFactor: 0.9, nodeFactor: 0.44 },
'tree-horizontal': { rankdir: 'LR', ranksep: 130, nodesep: 44 }, 'tree-horizontal': { rankdir: 'LR', rankFactor: 1.3, nodeFactor: 0.88 },
'tree-vertical': { rankdir: 'TB', ranksep: 90, nodesep: 48 }, 'tree-vertical': { rankdir: 'TB', rankFactor: 0.9, nodeFactor: 0.96 },
// La bilaterale ne passe pas par dagre directement : elle combine les deux
// dispositions ci-dessus. L'entree sert a l'orientation des poignees.
'mindmap-both': { rankdir: 'LR', rankFactor: 0.9, nodeFactor: 0.44 },
// Replis pour les dispositions reservees. // Replis pour les dispositions reservees.
'mindmap-both': { rankdir: 'LR', ranksep: 90, nodesep: 22 }, organization: { rankdir: 'TB', rankFactor: 0.9, nodeFactor: 0.96 },
organization: { rankdir: 'TB', ranksep: 90, nodesep: 48 }, freeform: { rankdir: 'LR', rankFactor: 0.9, nodeFactor: 0.44 },
freeform: { rankdir: 'LR', ranksep: 90, nodesep: 22 },
}; };
/** Orientation d'un layout : sert a placer les poignees de connexion. */ /** Orientation d'un layout : sert a placer les poignees de connexion. */
@@ -70,20 +102,92 @@ export function layoutDirection(layout: LayoutKind): 1 | -1 {
* par le bouton "Organisation automatique", ce qui preserve les deplacements * par le bouton "Organisation automatique", ce qui preserve les deplacements
* manuels de l'utilisateur. * manuels de l'utilisateur.
*/ */
export interface LayoutOptions {
measuredSizes?: Map<string, NodeSize>;
spacing?: LayoutSpacing;
theme?: MapTheme;
}
/**
* Calcule les positions via dagre.
*
* Le layout n'est jamais applique automatiquement : il est declenche seulement
* par le bouton "Organisation automatique", ce qui preserve les deplacements
* manuels de l'utilisateur. Modifier l'espacement ne deplace donc rien tant que
* l'utilisateur n'a pas relance l'organisation.
*/
export function computeLayout( export function computeLayout(
nodes: MindMapNode[], nodes: MindMapNode[],
layout: LayoutKind, layout: LayoutKind,
measuredSizes?: Map<string, NodeSize>, options: LayoutOptions = {},
): Map<string, Position> {
if (nodes.length === 0) return new Map<string, Position>();
if (layout === 'mindmap-both') return computeBilateralLayout(nodes, options);
const params = LAYOUT_PARAMS[layout] ?? LAYOUT_PARAMS['mindmap-right'];
return runDagre(nodes, params, options);
}
/**
* Mind map bilaterale : deux passes dagre, une par cote, puis recalage.
*
* Chaque passe contient les racines et les branches de son cote ; on aligne
* ensuite le graphe gauche sur le graphe droit en superposant la racine. La
* racine se retrouve donc au centre, avec ses branches de part et d'autre, et
* chaque cote reste centre verticalement sur elle.
*
* Les relations secondaires n'entrent jamais dans ces graphes : seul `parentId`
* construit les aretes, ici comme dans les autres dispositions.
*/
function computeBilateralLayout(
nodes: MindMapNode[],
options: LayoutOptions,
): Map<string, Position> {
const sides = resolveNodeSides(nodes);
const roots = getRootNodes(nodes);
const rootIds = new Set(roots.map((node) => node.id));
const rightNodes = nodes.filter(
(node) => rootIds.has(node.id) || sides.get(node.id) === 'right',
);
const leftNodes = nodes.filter((node) => rootIds.has(node.id) || sides.get(node.id) === 'left');
const rightParams = LAYOUT_PARAMS['mindmap-right'] as DagreParams;
const leftParams = LAYOUT_PARAMS['mindmap-left'] as DagreParams;
const right = runDagre(rightNodes, rightParams, options);
const left = runDagre(leftNodes, leftParams, options);
const anchor = roots[0];
const anchorRight = anchor ? right.get(anchor.id) : undefined;
const anchorLeft = anchor ? left.get(anchor.id) : undefined;
const shiftX = anchorRight && anchorLeft ? anchorRight.x - anchorLeft.x : 0;
const shiftY = anchorRight && anchorLeft ? anchorRight.y - anchorLeft.y : 0;
const positions = new Map<string, Position>(right);
for (const [id, position] of left) {
if (rootIds.has(id)) continue; // la racine garde la position du graphe droit
positions.set(id, { x: Math.round(position.x + shiftX), y: Math.round(position.y + shiftY) });
}
return positions;
}
/** Une passe dagre sur un sous-ensemble de noeuds. */
function runDagre(
nodes: MindMapNode[],
params: DagreParams,
options: LayoutOptions,
): Map<string, Position> { ): Map<string, Position> {
const positions = new Map<string, Position>(); const positions = new Map<string, Position>();
if (nodes.length === 0) return positions; if (nodes.length === 0) return positions;
const params = LAYOUT_PARAMS[layout] ?? LAYOUT_PARAMS['mindmap-right']; const spacing = resolveSpacing(options.spacing);
const graph = new dagre.graphlib.Graph(); const graph = new dagre.graphlib.Graph();
graph.setGraph({ graph.setGraph({
rankdir: params?.rankdir ?? 'LR', rankdir: params?.rankdir ?? 'LR',
ranksep: params?.ranksep ?? 90, ranksep: Math.max(20, Math.round(spacing.parentGap * (params?.rankFactor ?? 0.9))),
nodesep: params?.nodesep ?? 22, nodesep: Math.max(8, Math.round(spacing.siblingGap * (params?.nodeFactor ?? 0.44))),
marginx: 40, marginx: 40,
marginy: 40, marginy: 40,
}); });
@@ -93,7 +197,7 @@ export function computeLayout(
const sizes = new Map<string, NodeSize>(); const sizes = new Map<string, NodeSize>();
for (const node of nodes) { for (const node of nodes) {
const size = resolveNodeSize(node, measuredSizes); const size = resolveNodeSize(node, options.measuredSizes, options.theme);
sizes.set(node.id, size); sizes.set(node.id, size);
graph.setNode(node.id, { width: size.width, height: size.height }); graph.setNode(node.id, { width: size.width, height: size.height });
} }

View File

@@ -0,0 +1,90 @@
import {
AlertTriangle,
Boxes,
CheckCircle2,
Cloud,
Container,
Cpu,
Database,
FileText,
Flag,
Folder,
Globe,
HardDrive,
Key,
Layers,
Lightbulb,
Link2,
Lock,
type LucideIcon,
Mail,
Monitor,
Network,
Router,
Server,
Settings,
Shield,
Star,
Terminal,
Timer,
Users,
Wifi,
} from 'lucide-react';
/**
* Bibliotheque d'icones integrees (Lucide, licence ISC).
*
* Volontairement restreinte et orientee schema technique : aucune icone n'est
* chargee dynamiquement, et aucun SVG fourni par l'utilisateur n'est stocke.
*/
export const BUILTIN_ICONS: Record<string, { label: string; Icon: LucideIcon }> = {
server: { label: 'Serveur', Icon: Server },
network: { label: 'Reseau', Icon: Network },
router: { label: 'Routeur', Icon: Router },
wifi: { label: 'Wi-Fi', Icon: Wifi },
shield: { label: 'Securite', Icon: Shield },
lock: { label: 'Verrou', Icon: Lock },
key: { label: 'Cle', Icon: Key },
database: { label: 'Base de donnees', Icon: Database },
harddrive: { label: 'Stockage', Icon: HardDrive },
container: { label: 'Conteneur', Icon: Container },
cpu: { label: 'Processeur', Icon: Cpu },
monitor: { label: 'Poste', Icon: Monitor },
cloud: { label: 'Cloud', Icon: Cloud },
globe: { label: 'Internet', Icon: Globe },
terminal: { label: 'Terminal', Icon: Terminal },
settings: { label: 'Configuration', Icon: Settings },
layers: { label: 'Couches', Icon: Layers },
boxes: { label: 'Services', Icon: Boxes },
folder: { label: 'Dossier', Icon: Folder },
file: { label: 'Document', Icon: FileText },
link: { label: 'Lien', Icon: Link2 },
mail: { label: 'Messagerie', Icon: Mail },
users: { label: 'Utilisateurs', Icon: Users },
check: { label: 'Termine', Icon: CheckCircle2 },
warning: { label: 'Attention', Icon: AlertTriangle },
flag: { label: 'Jalon', Icon: Flag },
star: { label: 'Important', Icon: Star },
timer: { label: 'Echeance', Icon: Timer },
idea: { label: 'Idee', Icon: Lightbulb },
};
export const BUILTIN_ICON_IDS = Object.keys(BUILTIN_ICONS);
/** Emojis proposes, conserves depuis la V1 pour ne rien casser. */
export const EMOJI_ICONS = [
'💡',
'✅',
'📌',
'⚠️',
'🔒',
'🌐',
'🖥️',
'📦',
'📄',
'🔗',
'⭐',
'🐳',
'🗄️',
'🚀',
];

View File

@@ -0,0 +1,110 @@
import { describe, expect, it } from 'vitest';
import { type MindMapNode } from '@/types/map';
import { getTheme, themeMetrics } from '@/features/editor/themes';
import { resolveNodeAppearance } from '@/features/editor/nodeStyleResolver';
function node(partial: Partial<MindMapNode> = {}): MindMapNode {
return {
id: 'n1',
type: 'standard',
text: 'Reseau',
position: { x: 0, y: 0 },
parentId: null,
...partial,
};
}
const theme = getTheme('default');
const metrics = themeMetrics(theme);
describe('resolution du style d un noeud', () => {
it('applique les valeurs du theme quand le noeud ne precise rien', () => {
const appearance = resolveNodeAppearance(node(), 1, theme);
expect(appearance.paddingX).toBe(metrics.paddingX);
expect(appearance.paddingY).toBe(metrics.paddingY);
expect(appearance.lineHeight).toBe(metrics.lineHeight);
expect(appearance.maxTextWidth).toBe(metrics.maxTextWidth);
expect(appearance.textAlign).toBe('left');
expect(appearance.verticalAlign).toBe('top');
expect(appearance.widthMode).toBe('auto');
expect(appearance.heightMode).toBe('auto');
expect(appearance.wrap).toBe(true);
expect(appearance.iconPosition).toBe('left');
});
it('donne la priorite aux personnalisations du noeud', () => {
const appearance = resolveNodeAppearance(
node({
style: {
backgroundColor: '#123456',
textAlign: 'center',
verticalAlign: 'middle',
lineHeight: 1.8,
fontFamily: 'mono',
shadow: 'none',
},
layout: { paddingX: 30, paddingY: 20, wrap: false, maxTextWidth: 400 },
}),
1,
theme,
);
expect(appearance.background).toBe('#123456');
expect(appearance.textAlign).toBe('center');
expect(appearance.verticalAlign).toBe('middle');
expect(appearance.lineHeight).toBe(1.8);
expect(appearance.fontFamily).toContain('monospace');
expect(appearance.boxShadow).toBe('none');
expect(appearance.paddingX).toBe(30);
expect(appearance.paddingY).toBe(20);
expect(appearance.wrap).toBe(false);
expect(appearance.maxTextWidth).toBe(400);
});
it('change de theme sans perdre les personnalisations', () => {
const customised = node({ style: { backgroundColor: '#ff8800', textAlign: 'right' } });
const light = resolveNodeAppearance(customised, 1, getTheme('default'));
const dark = resolveNodeAppearance(customised, 1, getTheme('midnight'));
expect(light.background).toBe('#ff8800');
expect(dark.background).toBe('#ff8800');
expect(dark.textAlign).toBe('right');
// Les valeurs non personnalisees suivent bien le nouveau theme.
expect(dark.color).not.toBe(light.color);
});
it('lit la largeur fixe du format v1 (style.width)', () => {
const legacy = resolveNodeAppearance(node({ style: { width: 240 } }), 1, theme);
expect(legacy.widthMode).toBe('fixed');
expect(legacy.width).toBe(240);
});
it('donne la priorite a layout.width sur la largeur v1', () => {
const appearance = resolveNodeAppearance(
node({ style: { width: 240 }, layout: { widthMode: 'fixed', width: 320 } }),
1,
theme,
);
expect(appearance.width).toBe(320);
});
it('herite des metriques propres a chaque theme', () => {
const pastel = resolveNodeAppearance(node(), 1, getTheme('pastel'));
const minimal = resolveNodeAppearance(node(), 1, getTheme('minimal'));
expect(pastel.paddingX).toBe(18);
expect(minimal.paddingX).toBe(12);
});
it('ignore une hauteur fixe declaree sans valeur', () => {
const appearance = resolveNodeAppearance(
node({ layout: { heightMode: 'fixed' } }),
1,
theme,
);
expect(appearance.heightMode).toBe('auto');
});
});

View File

@@ -0,0 +1,187 @@
import {
type FontFamilyId,
type IconPosition,
type MindMapNode,
type NodeShape,
type ShadowStyle,
type TextAlign,
type VerticalAlign,
} from '@/types/map';
import { type MapTheme, paletteForDepth, themeMetrics } from '@/features/editor/themes';
/**
* Fusion theme + personnalisations explicites du noeud.
*
* Regle unique : un champ absent du noeud herite du theme. Rien n'est jamais
* ecrit dans le document pour "materialiser" un defaut, ce qui garantit que les
* cartes v1 deja stockees restent lisibles et que changer de theme n'ecrase
* aucune personnalisation.
*/
export const FONT_FAMILIES: Record<Exclude<FontFamilyId, 'theme'>, string> = {
sans: "Inter, ui-sans-serif, system-ui, 'Segoe UI', Roboto, Arial, sans-serif",
serif: "Georgia, 'Times New Roman', Times, serif",
mono: "ui-monospace, SFMono-Regular, 'JetBrains Mono', Menlo, Consolas, monospace",
rounded: "'Segoe UI', 'Trebuchet MS', ui-rounded, system-ui, sans-serif",
};
export const FONT_FAMILY_OPTIONS: Array<{ id: FontFamilyId; label: string }> = [
{ id: 'theme', label: 'Theme' },
{ id: 'sans', label: 'Sans-serif' },
{ id: 'serif', label: 'Serif' },
{ id: 'mono', label: 'Monospace' },
{ id: 'rounded', label: 'Arrondie' },
];
export const SHADOW_OPTIONS: Array<{ id: ShadowStyle; label: string }> = [
{ id: 'theme', label: 'Theme' },
{ id: 'none', label: 'Aucune' },
{ id: 'soft', label: 'Douce' },
{ id: 'strong', label: 'Marquee' },
];
const SHADOWS: Record<Exclude<ShadowStyle, 'theme'>, string> = {
none: 'none',
soft: '0 1px 2px rgba(15,23,42,0.06), 0 6px 16px -10px rgba(15,23,42,0.35)',
strong: '0 2px 4px rgba(15,23,42,0.10), 0 14px 28px -12px rgba(15,23,42,0.55)',
};
/** Bornes appliquees a toute saisie utilisateur. */
export const LAYOUT_LIMITS = {
width: { min: 60, max: 900 },
height: { min: 32, max: 900 },
paddingX: { min: 0, max: 64 },
paddingY: { min: 0, max: 48 },
maxTextWidth: { min: 80, max: 900 },
fontSize: { min: 10, max: 48 },
lineHeight: { min: 1, max: 2.4 },
iconSize: { min: 12, max: 48 },
iconGap: { min: 0, max: 32 },
branchWidth: { min: 1, max: 10 },
} as const;
export function clampTo(value: number, bounds: { min: number; max: number }): number {
return Math.min(bounds.max, Math.max(bounds.min, Math.round(value)));
}
/** Apparence entierement resolue d'un noeud, prete a etre appliquee en CSS. */
export interface NodeAppearance {
background: string;
color: string;
borderColor: string;
borderWidth: number;
borderRadius: string;
shape: NodeShape;
circle: boolean;
boxShadow: string;
fontFamily: string;
fontSize: number;
fontWeight: number;
fontStyle: 'normal' | 'italic';
lineHeight: number;
textAlign: TextAlign;
verticalAlign: VerticalAlign;
paddingX: number;
paddingY: number;
wrap: boolean;
widthMode: 'auto' | 'fixed';
heightMode: 'auto' | 'fixed';
width?: number;
height?: number;
maxTextWidth: number;
minWidth: number;
minHeight: number;
iconSize: number;
iconGap: number;
iconPosition: IconPosition;
}
function radiusForShape(shape: NodeShape, themeRadius: number): string {
switch (shape) {
case 'rectangle':
return '3px';
case 'capsule':
return '999px';
case 'circle':
return '50%';
case 'rounded':
default:
return `${themeRadius}px`;
}
}
export function resolveNodeAppearance(
node: MindMapNode,
depth: number,
theme: MapTheme,
): NodeAppearance {
const palette = paletteForDepth(theme, depth);
const metrics = themeMetrics(theme);
const style = node.style ?? {};
const layout = node.layout ?? {};
const iconStyle = node.iconStyle ?? {};
const shape = style.shape ?? theme.nodeShape;
const fontFamily =
style.fontFamily && style.fontFamily !== 'theme'
? FONT_FAMILIES[style.fontFamily]
: theme.fontFamily;
const shadow = style.shadow && style.shadow !== 'theme' ? SHADOWS[style.shadow] : theme.shadow;
// `style.width` est l'ancien champ v1 : il vaut largeur fixe.
const legacyWidth = style.width;
const widthMode = layout.widthMode ?? (layout.width ?? legacyWidth ? 'fixed' : 'auto');
const width = layout.width ?? legacyWidth;
const heightMode = layout.heightMode ?? (layout.height ? 'fixed' : 'auto');
return {
background: style.backgroundColor ?? palette.background,
color: style.textColor ?? palette.text,
borderColor: style.borderColor ?? palette.border,
borderWidth: theme.borderWidth,
borderRadius: radiusForShape(shape, theme.nodeRadius),
shape,
circle: shape === 'circle',
boxShadow: shadow,
fontFamily,
fontSize: style.fontSize ?? (depth === 0 ? 16 : 14),
fontWeight: style.bold ? 700 : depth === 0 ? 600 : 500,
fontStyle: style.italic ? 'italic' : 'normal',
lineHeight: style.lineHeight ?? metrics.lineHeight,
textAlign: style.textAlign ?? 'left',
verticalAlign: style.verticalAlign ?? 'top',
paddingX: layout.paddingX ?? metrics.paddingX,
paddingY: layout.paddingY ?? metrics.paddingY,
wrap: layout.wrap ?? true,
widthMode: widthMode === 'fixed' && width ? 'fixed' : 'auto',
heightMode: heightMode === 'fixed' && layout.height ? 'fixed' : 'auto',
...(width ? { width } : {}),
...(layout.height ? { height: layout.height } : {}),
maxTextWidth: layout.maxTextWidth ?? metrics.maxTextWidth,
minWidth: metrics.minNodeWidth,
minHeight: metrics.minNodeHeight,
iconSize: iconStyle.size ?? metrics.iconSize,
iconGap: iconStyle.gap ?? metrics.iconGap,
iconPosition: iconStyle.position ?? 'left',
};
}
/** Style effectif de la branche entrante d'un noeud. */
export function resolveBranchAppearance(
node: MindMapNode,
theme: MapTheme,
fallbackStyle: MindMapNode['branch'],
): { style: NonNullable<MindMapNode['branch']>['style']; width: number; color: string } {
const branch = node.branch ?? {};
return {
style: branch.style ?? fallbackStyle?.style,
width: branch.width ?? theme.branchWidth,
color: branch.color ?? theme.branchColor,
};
}

View File

@@ -0,0 +1,56 @@
import { type ReactNode } from 'react';
import { ChevronDownIcon, ChevronRightIcon } from '@/components/Icons';
import { useUiStore } from '@/stores/uiStore';
import { cn } from '@/utils/cn';
export interface PanelSectionProps {
id: string;
title: string;
icon?: ReactNode;
children: ReactNode;
}
/**
* Section repliable du panneau Proprietes.
* L'etat ouvert/ferme est conserve pendant la session (voir `uiStore`).
*/
export function PanelSection({ id, title, icon, children }: PanelSectionProps) {
const open = useUiStore((state) => state.panelSections[id] ?? true);
const togglePanelSection = useUiStore((state) => state.togglePanelSection);
const contentId = `panel-section-${id}`;
return (
<section className="border-b border-zinc-100 last:border-b-0 dark:border-zinc-800">
<h3>
<button
type="button"
onClick={() => togglePanelSection(id)}
aria-expanded={open}
aria-controls={contentId}
className={cn(
'flex w-full items-center gap-2 px-4 py-2.5 text-left text-xs font-semibold uppercase tracking-wide',
'text-zinc-500 transition-colors hover:text-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-100',
)}
>
<span aria-hidden="true" className="text-zinc-400">
{open ? (
<ChevronDownIcon className="h-3.5 w-3.5" />
) : (
<ChevronRightIcon className="h-3.5 w-3.5" />
)}
</span>
{icon ? (
<span aria-hidden="true" className="text-zinc-400">
{icon}
</span>
) : null}
<span className="flex-1">{title}</span>
</button>
</h3>
<div id={contentId} hidden={!open} className="space-y-3 px-4 pb-4">
{children}
</div>
</section>
);
}

View File

@@ -0,0 +1,288 @@
import { useEffect, useState, type ReactNode } from 'react';
import { CloseIcon } from '@/components/Icons';
import { IconButton } from '@/components/IconButton';
import { MIXED_LABEL, type SharedValue } from '@/features/editor/panel/sharedValue';
import { cn } from '@/utils/cn';
export function Field({
label,
htmlFor,
hint,
children,
}: {
label: string;
htmlFor?: string;
hint?: string;
children: ReactNode;
}) {
return (
<div>
<label className="field-label" htmlFor={htmlFor}>
{label}
{hint ? <span className="ml-1 font-normal normal-case text-zinc-400">{hint}</span> : null}
</label>
{children}
</div>
);
}
export interface SegmentOption<T> {
value: T;
label: string;
icon?: ReactNode;
}
/** Groupe de boutons compact. Chaque bouton porte un libelle accessible. */
export function SegmentedControl<T extends string>({
legend,
options,
selected,
onSelect,
mixed = false,
}: {
legend: string;
options: Array<SegmentOption<T>>;
selected: T | undefined;
onSelect: (value: T) => void;
mixed?: boolean;
}) {
return (
<fieldset>
<legend className="field-label">
{legend}
{mixed ? <span className="ml-1 font-normal normal-case text-amber-500">{MIXED_LABEL}</span> : null}
</legend>
<div className="flex gap-1 rounded-lg bg-zinc-100 p-1 dark:bg-zinc-800/60" role="group">
{options.map((option) => {
const active = !mixed && selected === option.value;
return (
<button
key={option.value}
type="button"
title={option.label}
aria-label={option.label}
aria-pressed={active}
onClick={() => onSelect(option.value)}
className={cn(
'flex flex-1 items-center justify-center rounded-md px-2 py-1.5 text-xs font-medium transition-colors',
active
? 'bg-white text-indigo-700 shadow-sm dark:bg-zinc-900 dark:text-indigo-300'
: 'text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100',
)}
>
{option.icon ?? option.label}
</button>
);
})}
</div>
</fieldset>
);
}
/** Champ numerique borne, avec slider optionnel. La saisie est validee au blur. */
export function NumberField({
id,
label,
value,
min,
max,
step = 1,
unit = 'px',
slider = true,
mixed = false,
disabled = false,
onChange,
}: {
id: string;
label: string;
value: number;
min: number;
max: number;
step?: number;
unit?: string;
slider?: boolean;
mixed?: boolean;
disabled?: boolean;
onChange: (value: number) => void;
}) {
const [draft, setDraft] = useState(String(value));
useEffect(() => setDraft(String(value)), [value]);
const commit = (raw: string) => {
const parsed = Number(raw);
if (!Number.isFinite(parsed)) {
setDraft(String(value));
return;
}
const clamped = Math.min(max, Math.max(min, Math.round(parsed / step) * step));
setDraft(String(clamped));
if (clamped !== value) onChange(clamped);
};
return (
<div>
<label className="field-label" htmlFor={id}>
{label}
{mixed ? <span className="ml-1 font-normal normal-case text-amber-500">{MIXED_LABEL}</span> : null}
</label>
<div className="flex items-center gap-2">
{slider ? (
<input
type="range"
aria-label={`${label} (curseur)`}
min={min}
max={max}
step={step}
value={value}
disabled={disabled}
onChange={(event) => onChange(Number(event.target.value))}
className="h-1.5 flex-1 accent-indigo-600 disabled:opacity-40"
/>
) : null}
<input
id={id}
type="number"
inputMode="numeric"
min={min}
max={max}
step={step}
value={draft}
disabled={disabled}
onChange={(event) => setDraft(event.target.value)}
onBlur={(event) => commit(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') event.currentTarget.blur();
}}
className="field-input w-20 px-2 py-1 text-right text-xs tabular-nums disabled:opacity-40"
/>
<span className="w-5 shrink-0 text-xs text-zinc-400">{unit}</span>
</div>
</div>
);
}
function normaliseHex(color: string): string {
return /^#[0-9a-fA-F]{6}$/.test(color) ? color : '#ffffff';
}
/**
* Selecteur de couleur : palette native, valeur hexadecimale lisible et retour
* explicite a la valeur du theme.
*/
export function ColorField({
id,
label,
shared,
fallback,
onChange,
}: {
id: string;
label: string;
shared: SharedValue<string>;
fallback: string;
onChange: (color: string | undefined) => void;
}) {
const current = shared.mixed ? undefined : shared.value;
return (
<div>
<label className="field-label" htmlFor={id}>
{label}
{shared.mixed ? (
<span className="ml-1 font-normal normal-case text-amber-500">{MIXED_LABEL}</span>
) : null}
</label>
<div className="flex items-center gap-2">
<input
id={id}
type="color"
value={normaliseHex(current ?? fallback)}
onChange={(event) => onChange(event.target.value)}
className="h-7 w-9 cursor-pointer rounded border border-zinc-200 bg-transparent p-0.5 dark:border-zinc-700"
/>
<span className="flex-1 truncate font-mono text-[11px] text-zinc-500">
{shared.mixed ? MIXED_LABEL : (current ?? `theme (${fallback})`)}
</span>
{current ? (
<IconButton
label={`Revenir au theme pour ${label.toLowerCase()}`}
icon={<CloseIcon className="h-3.5 w-3.5" />}
onClick={() => onChange(undefined)}
/>
) : null}
</div>
</div>
);
}
/** Case a cocher avec libelle, alignee sur le reste du panneau. */
export function ToggleRow({
id,
label,
checked,
mixed = false,
onChange,
}: {
id: string;
label: string;
checked: boolean;
mixed?: boolean;
onChange: (checked: boolean) => void;
}) {
return (
<label htmlFor={id} className="flex items-center gap-2.5 text-sm">
<input
id={id}
type="checkbox"
checked={checked}
ref={(element) => {
if (element) element.indeterminate = mixed;
}}
onChange={(event) => onChange(event.target.checked)}
className="h-4 w-4 rounded border-zinc-300 accent-indigo-600"
/>
<span className="text-zinc-700 dark:text-zinc-200">{label}</span>
{mixed ? <span className="text-xs text-amber-500">{MIXED_LABEL}</span> : null}
</label>
);
}
/** Liste deroulante compacte. */
export function SelectField<T extends string>({
id,
label,
value,
options,
mixed = false,
onChange,
}: {
id: string;
label: string;
value: T;
options: Array<{ id: T; label: string }>;
mixed?: boolean;
onChange: (value: T) => void;
}) {
return (
<div>
<label className="field-label" htmlFor={id}>
{label}
{mixed ? <span className="ml-1 font-normal normal-case text-amber-500">{MIXED_LABEL}</span> : null}
</label>
<select
id={id}
className="field-input py-1.5 text-sm"
value={mixed ? '' : value}
onChange={(event) => onChange(event.target.value as T)}
>
{mixed ? <option value="">{MIXED_LABEL}</option> : null}
{options.map((option) => (
<option key={option.id} value={option.id}>
{option.label}
</option>
))}
</select>
</div>
);
}

View File

@@ -0,0 +1,35 @@
import { type MindMapNode } from '@/types/map';
/**
* Valeur commune a une selection.
*
* `mixed` indique que les noeuds selectionnes n'ont pas la meme valeur : le
* panneau affiche alors "Mixte" plutot que de faire passer la valeur du premier
* noeud pour une valeur commune.
*/
export interface SharedValue<T> {
value: T | undefined;
mixed: boolean;
}
export function sharedValue<T>(
nodes: MindMapNode[],
read: (node: MindMapNode) => T | undefined,
): SharedValue<T> {
if (nodes.length === 0) return { value: undefined, mixed: false };
const first = read(nodes[0] as MindMapNode);
for (let index = 1; index < nodes.length; index += 1) {
if (read(nodes[index] as MindMapNode) !== first) {
return { value: undefined, mixed: true };
}
}
return { value: first, mixed: false };
}
/** Valeur a afficher dans un controle : la valeur commune, sinon le defaut. */
export function displayValue<T>(shared: SharedValue<T>, fallback: T): T {
return shared.mixed ? fallback : (shared.value ?? fallback);
}
export const MIXED_LABEL = 'Mixte';

View File

@@ -24,8 +24,56 @@ export interface MapTheme {
shadow: string; shadow: string;
branchColor: string; branchColor: string;
branchWidth: number; branchWidth: number;
/**
* Couleur par defaut des relations secondaires (v4). Optionnelle : sans
* valeur, `relationColor()` choisit une teinte volontairement distincte des
* branches, pour qu'une relation ne se confonde jamais avec la hierarchie.
*/
relationColor?: string;
root: NodePalette; root: NodePalette;
levels: NodePalette[]; levels: NodePalette[];
/*
* Metriques de mise en page (v2). Optionnelles : `themeMetrics()` applique
* les valeurs par defaut, ce qui evite de dupliquer les memes chiffres dans
* les neuf themes.
*/
paddingX?: number;
paddingY?: number;
lineHeight?: number;
minNodeWidth?: number;
minNodeHeight?: number;
maxTextWidth?: number;
iconSize?: number;
iconGap?: number;
}
/** Valeurs de mise en page utilisees quand le theme ne precise rien. */
export const THEME_METRIC_DEFAULTS = {
paddingX: 14,
paddingY: 8,
lineHeight: 1.35,
minNodeWidth: 72,
minNodeHeight: 36,
maxTextWidth: 260,
iconSize: 16,
iconGap: 8,
} as const;
export type ThemeMetrics = { -readonly [K in keyof typeof THEME_METRIC_DEFAULTS]: number };
/** Metriques effectives d'un theme : ses valeurs, completees par les defauts. */
export function themeMetrics(theme: MapTheme): ThemeMetrics {
return {
paddingX: theme.paddingX ?? THEME_METRIC_DEFAULTS.paddingX,
paddingY: theme.paddingY ?? THEME_METRIC_DEFAULTS.paddingY,
lineHeight: theme.lineHeight ?? THEME_METRIC_DEFAULTS.lineHeight,
minNodeWidth: theme.minNodeWidth ?? THEME_METRIC_DEFAULTS.minNodeWidth,
minNodeHeight: theme.minNodeHeight ?? THEME_METRIC_DEFAULTS.minNodeHeight,
maxTextWidth: theme.maxTextWidth ?? THEME_METRIC_DEFAULTS.maxTextWidth,
iconSize: theme.iconSize ?? THEME_METRIC_DEFAULTS.iconSize,
iconGap: theme.iconGap ?? THEME_METRIC_DEFAULTS.iconGap,
};
} }
const SANS = "Inter, ui-sans-serif, system-ui, 'Segoe UI', Roboto, sans-serif"; const SANS = "Inter, ui-sans-serif, system-ui, 'Segoe UI', Roboto, sans-serif";
@@ -61,6 +109,8 @@ export const THEMES: MapTheme[] = [
{ {
id: 'minimal', id: 'minimal',
name: 'Minimal', name: 'Minimal',
paddingX: 12,
paddingY: 6,
isDark: false, isDark: false,
canvasBackground: '#ffffff', canvasBackground: '#ffffff',
gridColor: '#e5e7eb', gridColor: '#e5e7eb',
@@ -161,6 +211,8 @@ export const THEMES: MapTheme[] = [
{ {
id: 'pastel', id: 'pastel',
name: 'Pastel', name: 'Pastel',
paddingX: 18,
paddingY: 10,
isDark: false, isDark: false,
canvasBackground: '#fffdf9', canvasBackground: '#fffdf9',
gridColor: '#f1e7dc', gridColor: '#f1e7dc',
@@ -202,6 +254,9 @@ export const THEMES: MapTheme[] = [
{ {
id: 'blueprint', id: 'blueprint',
name: 'Blueprint', name: 'Blueprint',
paddingX: 12,
paddingY: 7,
lineHeight: 1.25,
isDark: true, isDark: true,
canvasBackground: '#0d2b45', canvasBackground: '#0d2b45',
gridColor: '#1d4666', gridColor: '#1d4666',
@@ -227,6 +282,15 @@ export function getTheme(themeId: string | undefined): MapTheme {
return THEMES.find((theme) => theme.id === themeId) ?? (THEMES[0] as MapTheme); return THEMES.find((theme) => theme.id === themeId) ?? (THEMES[0] as MapTheme);
} }
/**
* Couleur d'une relation secondaire.
* Volontairement differente des branches : le lecteur doit voir au premier
* coup d'oeil qu'un trait n'appartient pas a la hierarchie.
*/
export function relationColor(theme: MapTheme): string {
return theme.relationColor ?? (theme.isDark ? '#f0abfc' : '#a855f7');
}
/** Palette appliquee a un noeud selon sa profondeur (cycle si besoin). */ /** Palette appliquee a un noeud selon sa profondeur (cycle si besoin). */
export function paletteForDepth(theme: MapTheme, depth: number): NodePalette { export function paletteForDepth(theme: MapTheme, depth: number): NodePalette {
if (depth <= 0) return theme.root; if (depth <= 0) return theme.root;

View File

@@ -15,6 +15,8 @@ export interface ShortcutHandlers {
onSave: () => void; onSave: () => void;
onFit: () => void; onFit: () => void;
onFocusSearch: () => void; onFocusSearch: () => void;
/** Ouvre le dialogue de creation multiple sur le parent donne. */
onAddChildren: (parentId: string | null) => void;
} }
/** /**
@@ -49,6 +51,19 @@ export function useEditorShortcuts(enabled: boolean, handlers: ShortcutHandlers)
if (typing) return; if (typing) return;
if (modifier && event.shiftKey && (key === 'c' || key === 'v')) {
event.preventDefault();
if (key === 'c') {
if (store.copyStyle()) useUiStore.getState().pushToast('Style copie.', 'info');
return;
}
const applied = store.pasteStyle();
if (applied > 0) {
useUiStore.getState().pushToast(`Style applique a ${applied} noeud(s).`, 'success');
}
return;
}
if (modifier) { if (modifier) {
switch (key) { switch (key) {
case 'z': case 'z':
@@ -66,8 +81,15 @@ export function useEditorShortcuts(enabled: boolean, handlers: ShortcutHandlers)
} }
return; return;
case 'v': case 'v':
/*
* Priorite au presse-papiers interne d'OpenMind. S'il est vide, on
* laisse l'evenement `paste` du navigateur remonter : il porte le
* texte du systeme et declenche la creation d'enfants.
*/
if ((store.clipboard?.nodes.length ?? 0) > 0) {
event.preventDefault(); event.preventDefault();
store.paste(); store.paste();
}
return; return;
case 'd': case 'd':
event.preventDefault(); event.preventDefault();
@@ -82,18 +104,43 @@ export function useEditorShortcuts(enabled: boolean, handlers: ShortcutHandlers)
} }
} }
// Alt + fleches : ordre du noeud parmi ses freres.
if (event.altKey && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) {
const selected = store.selectedNodeIds[0];
if (selected) {
event.preventDefault();
store.moveSibling(selected, event.key === 'ArrowUp' ? 'up' : 'down');
}
return;
}
switch (event.key) { switch (event.key) {
case 'Tab': case 'Tab':
event.preventDefault(); event.preventDefault();
// Maj+Tab remonte le noeud d'un niveau, Tab cree un enfant.
if (event.shiftKey) {
const selected = store.selectedNodeIds[0];
if (selected) store.outdentNode(selected);
} else {
store.addChild(); store.addChild();
}
break; break;
case 'Enter': case 'Enter':
event.preventDefault(); event.preventDefault();
if (event.shiftKey) {
handlersRef.current.onAddChildren(store.selectedNodeIds[0] ?? null);
} else {
store.addSibling(); store.addSibling();
}
break; break;
case 'Delete': case 'Delete':
case 'Backspace': case 'Backspace':
if (store.selectedNodeIds.length > 0) { // Une relation selectionnee se supprime seule : aucun noeud n'est
// touche. C'est la contrepartie du fait qu'elle n'est pas structurelle.
if (store.selectedRelationId) {
event.preventDefault();
store.deleteRelation(store.selectedRelationId);
} else if (store.selectedNodeIds.length > 0) {
event.preventDefault(); event.preventDefault();
store.deleteNodes(store.selectedNodeIds); store.deleteNodes(store.selectedNodeIds);
} }
@@ -112,6 +159,7 @@ export function useEditorShortcuts(enabled: boolean, handlers: ShortcutHandlers)
case 'Escape': case 'Escape':
store.setEditingNode(null); store.setEditingNode(null);
store.setSelection([]); store.setSelection([]);
store.setSelectedRelation(null);
break; break;
default: default:
break; break;

View File

@@ -14,6 +14,7 @@ import { PrintDialog } from '@/features/export/PrintDialog';
import { useEditorStore } from '@/stores/editorStore'; import { useEditorStore } from '@/stores/editorStore';
import { useUiStore } from '@/stores/uiStore'; import { useUiStore } from '@/stores/uiStore';
import { serializeMap } from '@/services/mapSerialization'; import { serializeMap } from '@/services/mapSerialization';
import { exportMarkdown, hasUnexportableRelations } from '@/services/markdown';
import { downloadBlob, toSafeFilename } from '@/utils/download'; import { downloadBlob, toSafeFilename } from '@/utils/download';
export interface ExportMenuProps { export interface ExportMenuProps {
@@ -37,6 +38,25 @@ export function ExportMenu({ allowNativeExport = true }: ExportMenuProps) {
pushToast('Carte exportee.', 'success'); pushToast('Carte exportee.', 'success');
}; };
/**
* Export Markdown : contenu et hierarchie.
* Les relations secondaires n'y ont pas de representation fidele ; plutot que
* d'inventer une hierarchie, on previent l'utilisateur et on exporte le reste.
*/
const exportMarkdownFile = () => {
if (!map) return;
const blob = new Blob([exportMarkdown(map)], { type: 'text/markdown;charset=utf-8' });
downloadBlob(blob, toSafeFilename(map.name, '.md'));
if (hasUnexportableRelations(map)) {
pushToast(
'Export Markdown termine. Les relations visuelles secondaires n y sont pas representees.',
'info',
);
} else {
pushToast('Export Markdown termine.', 'success');
}
};
const exportImage = async (format: 'png' | 'svg', pixelRatio: number, transparent: boolean) => { const exportImage = async (format: 'png' | 'svg', pixelRatio: number, transparent: boolean) => {
if (!map || busy) return; if (!map || busy) return;
setBusy(true); setBusy(true);
@@ -91,6 +111,17 @@ export function ExportMenu({ allowNativeExport = true }: ExportMenuProps) {
</> </>
) : null} ) : null}
<MenuLabel>Texte</MenuLabel>
<MenuItem
label="Markdown (.md)"
hint="hierarchie"
onSelect={() => {
exportMarkdownFile();
close();
}}
/>
<MenuSeparator />
<MenuLabel>Image</MenuLabel> <MenuLabel>Image</MenuLabel>
<MenuItem <MenuItem
label="PNG - resolution normale" label="PNG - resolution normale"

View File

@@ -29,13 +29,16 @@ export function computeMapBounds(map: MindMap, sizes?: Map<string, NodeSize>): M
const visible = map.nodes.filter((node) => !hidden.has(node.id)); const visible = map.nodes.filter((node) => !hidden.has(node.id));
if (visible.length === 0) return { x: 0, y: 0, width: 400, height: 300 }; if (visible.length === 0) return { x: 0, y: 0, width: 400, height: 300 };
// Le theme influence padding et typographie, donc la taille estimee.
const theme = getTheme(map.settings.theme);
let minX = Infinity; let minX = Infinity;
let minY = Infinity; let minY = Infinity;
let maxX = -Infinity; let maxX = -Infinity;
let maxY = -Infinity; let maxY = -Infinity;
for (const node of visible) { for (const node of visible) {
const size = resolveNodeSize(node, sizes); const size = resolveNodeSize(node, sizes, theme);
minX = Math.min(minX, node.position.x); minX = Math.min(minX, node.position.x);
minY = Math.min(minY, node.position.y); minY = Math.min(minY, node.position.y);
maxX = Math.max(maxX, node.position.x + size.width); maxX = Math.max(maxX, node.position.x + size.width);

View File

@@ -5,10 +5,20 @@ import { Button } from '@/components/Button';
import { IconButton } from '@/components/IconButton'; import { IconButton } from '@/components/IconButton';
import { ConfirmDialog } from '@/components/ConfirmDialog'; import { ConfirmDialog } from '@/components/ConfirmDialog';
import { Modal } from '@/components/Modal'; import { Modal } from '@/components/Modal';
import { ImportIcon, MoonIcon, PlusIcon, SearchIcon, SunIcon } from '@/components/Icons'; import {
ChevronDownIcon,
ImportIcon,
MoonIcon,
PlusIcon,
SearchIcon,
SunIcon,
} from '@/components/Icons';
import { Menu, MenuItem, MenuLabel } from '@/components/Menu';
import { TemplateGallery } from '@/features/templates/TemplateGallery';
import { buildTemplateMap, type MapTemplate } from '@/features/templates/templates';
import { MarkdownError, buildMapFromMarkdown } from '@/services/markdown';
import { MapCard } from '@/features/maps/MapCard'; import { MapCard } from '@/features/maps/MapCard';
import { import {
createMap,
deleteMap, deleteMap,
duplicateMap, duplicateMap,
getMap, getMap,
@@ -20,12 +30,14 @@ import {
import { MapFormatError, readMapFile, serializeMap } from '@/services/mapSerialization'; import { MapFormatError, readMapFile, serializeMap } from '@/services/mapSerialization';
import { useAppConfigStore } from '@/stores/appConfigStore'; import { useAppConfigStore } from '@/stores/appConfigStore';
import { useUiStore } from '@/stores/uiStore'; import { useUiStore } from '@/stores/uiStore';
import { buildMapPath, navigate } from '@/hooks/useRoute'; import { LOCAL_FIRST_NOTE } from '@/config/privacy';
import { buildMapPath, navigate, PRIVACY_PATH } from '@/hooks/useRoute';
import { downloadBlob, toSafeFilename } from '@/utils/download'; import { downloadBlob, toSafeFilename } from '@/utils/download';
export function DashboardPage() { export function DashboardPage() {
const appName = useAppConfigStore((state) => state.name); const appName = useAppConfigStore((state) => state.name);
const maxMapSizeMb = useAppConfigStore((state) => state.maxMapSizeMb); const maxMapSizeMb = useAppConfigStore((state) => state.maxMapSizeMb);
const maxNodes = useAppConfigStore((state) => state.maxNodes);
const colorMode = useUiStore((state) => state.colorMode); const colorMode = useUiStore((state) => state.colorMode);
const toggleColorMode = useUiStore((state) => state.toggleColorMode); const toggleColorMode = useUiStore((state) => state.toggleColorMode);
const pushToast = useUiStore((state) => state.pushToast); const pushToast = useUiStore((state) => state.pushToast);
@@ -36,7 +48,9 @@ export function DashboardPage() {
const [renaming, setRenaming] = useState<MapSummary | null>(null); const [renaming, setRenaming] = useState<MapSummary | null>(null);
const [renameValue, setRenameValue] = useState(''); const [renameValue, setRenameValue] = useState('');
const [deleting, setDeleting] = useState<MapSummary | null>(null); const [deleting, setDeleting] = useState<MapSummary | null>(null);
const [galleryOpen, setGalleryOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const markdownInputRef = useRef<HTMLInputElement>(null);
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -56,9 +70,28 @@ export function DashboardPage() {
return maps.filter((map) => map.name.toLowerCase().includes(needle)); return maps.filter((map) => map.name.toLowerCase().includes(needle));
}, [maps, query]); }, [maps, query]);
const handleCreate = async () => { /** Un modele est toujours copie : l'original reste une constante du code. */
const map = await createMap('Nouvelle carte'); const handleChooseTemplate = async (template: MapTemplate) => {
navigate(buildMapPath(map.id)); setGalleryOpen(false);
const stored = await importMap(buildTemplateMap(template));
navigate(buildMapPath(stored.id));
};
const handleImportMarkdown = async (file: File) => {
try {
const name = file.name.replace(/\.(md|markdown|txt)$/i, '');
const map = buildMapFromMarkdown(await file.text(), name || 'Carte importee', maxNodes);
const stored = await importMap(map);
pushToast(`Carte "${stored.name}" creee depuis le Markdown.`, 'success');
navigate(buildMapPath(stored.id));
} catch (error) {
pushToast(
error instanceof MarkdownError
? error.message
: "Ce fichier Markdown n'a pas pu etre lu.",
'error',
);
}
}; };
const handleImportFile = async (file: File) => { const handleImportFile = async (file: File) => {
@@ -125,6 +158,7 @@ export function DashboardPage() {
<p className="mt-1 max-w-xl text-sm text-zinc-500 dark:text-zinc-400"> <p className="mt-1 max-w-xl text-sm text-zinc-500 dark:text-zinc-400">
{APP_CONFIG.tagline} {APP_CONFIG.tagline}
</p> </p>
<p className="mt-1 text-xs text-zinc-400">{LOCAL_FIRST_NOTE}</p>
</div> </div>
<IconButton <IconButton
label={colorMode === 'dark' ? 'Passer en mode clair' : 'Passer en mode sombre'} label={colorMode === 'dark' ? 'Passer en mode clair' : 'Passer en mode sombre'}
@@ -134,14 +168,43 @@ export function DashboardPage() {
</header> </header>
<div className="mb-6 flex flex-wrap items-center gap-2"> <div className="mb-6 flex flex-wrap items-center gap-2">
<Button variant="primary" onClick={() => void handleCreate()}> <Button variant="primary" onClick={() => setGalleryOpen(true)}>
<PlusIcon className="h-4 w-4" /> <PlusIcon className="h-4 w-4" />
Nouvelle carte Nouvelle carte
</Button> </Button>
<Button onClick={() => fileInputRef.current?.click()}>
<Menu
label="Menu d'import"
trigger={
<Button>
<ImportIcon className="h-4 w-4" /> <ImportIcon className="h-4 w-4" />
Importer Importer
<ChevronDownIcon className="h-3.5 w-3.5 opacity-60" />
</Button> </Button>
}
>
{(close) => (
<>
<MenuLabel>Depuis un fichier</MenuLabel>
<MenuItem
label={`Carte ${APP_CONFIG.fileExtension}`}
onSelect={() => {
fileInputRef.current?.click();
close();
}}
/>
<MenuItem
label="Markdown (.md)"
hint="nouvelle carte"
onSelect={() => {
markdownInputRef.current?.click();
close();
}}
/>
</>
)}
</Menu>
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
@@ -154,6 +217,18 @@ export function DashboardPage() {
if (file) void handleImportFile(file); if (file) void handleImportFile(file);
}} }}
/> />
<input
ref={markdownInputRef}
type="file"
accept=".md,.markdown,text/markdown,text/plain"
className="sr-only"
aria-label="Importer un fichier Markdown"
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = '';
if (file) void handleImportMarkdown(file);
}}
/>
<div className="ml-auto flex w-full items-center gap-2 rounded-lg border border-zinc-200 bg-white px-3 py-2 sm:w-64 dark:border-zinc-700 dark:bg-zinc-900"> <div className="ml-auto flex w-full items-center gap-2 rounded-lg border border-zinc-200 bg-white px-3 py-2 sm:w-64 dark:border-zinc-700 dark:bg-zinc-900">
<SearchIcon className="h-4 w-4 shrink-0 text-zinc-400" /> <SearchIcon className="h-4 w-4 shrink-0 text-zinc-400" />
@@ -181,7 +256,7 @@ export function DashboardPage() {
: `Creez votre premiere carte ou importez un fichier ${APP_CONFIG.fileExtension}.`} : `Creez votre premiere carte ou importez un fichier ${APP_CONFIG.fileExtension}.`}
</p> </p>
{!query ? ( {!query ? (
<Button variant="primary" onClick={() => void handleCreate()}> <Button variant="primary" onClick={() => setGalleryOpen(true)}>
<PlusIcon className="h-4 w-4" /> <PlusIcon className="h-4 w-4" />
Nouvelle carte Nouvelle carte
</Button> </Button>
@@ -210,9 +285,22 @@ export function DashboardPage() {
<footer className="mt-10 border-t border-zinc-200 pt-4 text-xs leading-relaxed text-zinc-500 dark:border-zinc-800 dark:text-zinc-400"> <footer className="mt-10 border-t border-zinc-200 pt-4 text-xs leading-relaxed text-zinc-500 dark:border-zinc-800 dark:text-zinc-400">
Vos cartes sont enregistrees <strong>dans ce navigateur</strong> (IndexedDB). Elles ne sont Vos cartes sont enregistrees <strong>dans ce navigateur</strong> (IndexedDB). Elles ne sont
envoyees au serveur que si vous creez explicitement un lien de partage. Exportez-les en{' '} envoyees au serveur que si vous creez explicitement un lien de partage. Exportez-les en{' '}
{APP_CONFIG.fileExtension} pour en conserver une sauvegarde portable. {APP_CONFIG.fileExtension} pour en conserver une sauvegarde portable.{' '}
<button
type="button"
className="underline underline-offset-2 hover:text-zinc-700 dark:hover:text-zinc-200"
onClick={() => navigate(PRIVACY_PATH)}
>
En savoir plus sur la confidentialite
</button>
</footer> </footer>
<TemplateGallery
open={galleryOpen}
onClose={() => setGalleryOpen(false)}
onSelect={(template) => void handleChooseTemplate(template)}
/>
<Modal <Modal
open={renaming !== null} open={renaming !== null}
title="Renommer la carte" title="Renommer la carte"

View File

@@ -0,0 +1,58 @@
import { Button } from '@/components/Button';
import { ArrowLeftIcon } from '@/components/Icons';
import { PRIVACY_INTRO, PRIVACY_SECTIONS, applyAppName } from '@/config/privacy';
import { useAppConfigStore } from '@/stores/appConfigStore';
import { navigate } from '@/hooks/useRoute';
/**
* Page /privacy.
*
* Le texte vit dans `config/privacy.ts` : cette page ne fait que le mettre en
* forme, pour qu'un exploitant puisse l'adapter a son instance en un seul
* endroit.
*/
export function PrivacyPage() {
const appName = useAppConfigStore((state) => state.name);
return (
<div className="mx-auto flex h-full w-full max-w-3xl flex-col overflow-y-auto px-5 py-8 sm:px-8">
<header className="mb-6">
<Button onClick={() => navigate('/')}>
<ArrowLeftIcon className="h-4 w-4" />
Retour
</Button>
<h1 className="mt-5 text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
Confidentialite
</h1>
<p className="mt-2 text-sm leading-relaxed text-zinc-600 dark:text-zinc-300">
{applyAppName(PRIVACY_INTRO, appName)}
</p>
</header>
<div className="space-y-7 pb-10">
{PRIVACY_SECTIONS.map((section) => (
<section key={section.title}>
<h2 className="text-base font-semibold text-zinc-900 dark:text-zinc-50">
{section.title}
</h2>
{section.paragraphs.map((paragraph) => (
<p
key={paragraph.slice(0, 40)}
className="mt-2 text-sm leading-relaxed text-zinc-600 dark:text-zinc-300"
>
{applyAppName(paragraph, appName)}
</p>
))}
{section.bullets ? (
<ul className="mt-2 list-inside list-disc space-y-1 text-sm leading-relaxed text-zinc-500 dark:text-zinc-400">
{section.bullets.map((bullet) => (
<li key={bullet.slice(0, 40)}>{applyAppName(bullet, appName)}</li>
))}
</ul>
) : null}
</section>
))}
</div>
</div>
);
}

View File

@@ -1,19 +1,25 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { Button } from '@/components/Button'; import { Button } from '@/components/Button';
import { Modal } from '@/components/Modal'; import { Modal } from '@/components/Modal';
import { ConfirmDialog } from '@/components/ConfirmDialog'; import { ConfirmDialog } from '@/components/ConfirmDialog';
import { CheckIcon, CopyIcon, SpinnerIcon } from '@/components/Icons'; import { CheckIcon, CopyIcon, DownloadIcon, ImportIcon, SpinnerIcon } from '@/components/Icons';
import { type ShareRecord } from '@/types/map'; import { type ShareRecord } from '@/types/map';
import { ShareApiError, createShare, deleteShare, updateShare } from '@/services/shareApi';
import { import {
deleteShareRecord, ShareApiError,
getShareRecord, createShare,
saveShareRecord, deleteShare,
} from '@/services/shareRepository'; setShareExpiry,
updateShare,
verifyManagementToken,
} from '@/services/shareApi';
import { ShareKeyError, SHARE_KEY_EXTENSION, readShareKeyFile, serializeShareKey } from '@/services/shareKey';
import { deleteShareRecord, getShareRecord, saveShareRecord } from '@/services/shareRepository';
import { useAppConfigStore } from '@/stores/appConfigStore';
import { useEditorStore } from '@/stores/editorStore'; import { useEditorStore } from '@/stores/editorStore';
import { useUiStore } from '@/stores/uiStore'; import { useUiStore } from '@/stores/uiStore';
import { buildShareUrl } from '@/hooks/useRoute'; import { buildShareUrl, navigate, PRIVACY_PATH } from '@/hooks/useRoute';
import { formatDateTime } from '@/utils/date'; import { formatDateTime } from '@/utils/date';
import { downloadBlob, toSafeFilename } from '@/utils/download';
export interface ShareDialogProps { export interface ShareDialogProps {
open: boolean; open: boolean;
@@ -21,15 +27,63 @@ export interface ShareDialogProps {
onShareStateChange?: (shared: boolean) => void; onShareStateChange?: (shared: boolean) => void;
} }
/** `null` represente l'option "Jamais". */
type ExpiryChoice = number | null;
function expiryLabel(days: ExpiryChoice): string {
if (days === null) return 'Jamais';
if (days === 1) return '24 heures';
if (days === 7) return '7 jours';
if (days === 30) return '30 jours';
if (days === 90) return '90 jours';
return `${days} jours`;
}
function serializeChoice(days: ExpiryChoice): string {
return days === null ? 'never' : String(days);
}
function parseChoice(value: string): ExpiryChoice {
return value === 'never' ? null : Number(value);
}
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* Duree a afficher pour un partage existant.
*
* Le serveur ne stocke qu'une date absolue : la duree choisie a l'origine est
* perdue. On rapproche donc le temps restant de l'option la plus proche, la
* date exacte etant de toute facon rappelee juste en dessous du menu.
*/
function choiceForExpiry(expiresAt: string | null, options: ExpiryChoice[]): ExpiryChoice {
if (!expiresAt) return null;
const remaining = (new Date(expiresAt).getTime() - Date.now()) / DAY_MS;
const durations = options.filter((value): value is number => value !== null);
if (durations.length === 0) return null;
return durations.find((value) => value >= remaining) ?? (durations.at(-1) as number);
}
export function ShareDialog({ open, onClose, onShareStateChange }: ShareDialogProps) { export function ShareDialog({ open, onClose, onShareStateChange }: ShareDialogProps) {
const map = useEditorStore((state) => state.map); const map = useEditorStore((state) => state.map);
const pushToast = useUiStore((state) => state.pushToast); const pushToast = useUiStore((state) => state.pushToast);
const appName = useAppConfigStore((state) => state.name);
const sharingEnabled = useAppConfigStore((state) => state.sharingEnabled);
const allowPermanent = useAppConfigStore((state) => state.allowPermanentShares);
const defaultExpiryDays = useAppConfigStore((state) => state.defaultShareExpiryDays);
const expiryOptions = useAppConfigStore((state) => state.shareExpiryOptions);
const [record, setRecord] = useState<ShareRecord | null>(null); const [record, setRecord] = useState<ShareRecord | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false);
const [choice, setChoice] = useState<ExpiryChoice>(null);
const keyInputRef = useRef<HTMLInputElement>(null);
// Les options refusees par l'instance ne sont pas proposees.
const options = expiryOptions.filter((value) => value !== null || allowPermanent);
const refresh = useCallback(async (mapId: string) => { const refresh = useCallback(async (mapId: string) => {
setLoading(true); setLoading(true);
@@ -43,16 +97,32 @@ export function ShareDialog({ open, onClose, onShareStateChange }: ShareDialogPr
void refresh(map.id); void refresh(map.id);
}, [open, map, refresh]); }, [open, map, refresh]);
useEffect(() => {
if (!open) return;
if (record) {
setChoice(choiceForExpiry(record.expiresAt ?? null, options));
return;
}
setChoice(defaultExpiryDays > 0 ? defaultExpiryDays : allowPermanent ? null : 30);
// `options` est recalcule a chaque rendu : le comparer relancerait l'effet en boucle.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, record, defaultExpiryDays, allowPermanent]);
const handleError = (error: unknown, fallback: string) => { const handleError = (error: unknown, fallback: string) => {
pushToast(error instanceof ShareApiError ? error.message : fallback, 'error'); pushToast(error instanceof ShareApiError ? error.message : fallback, 'error');
}; };
const persist = async (next: ShareRecord) => {
await saveShareRecord(next);
setRecord(next);
};
const handleCreate = async () => { const handleCreate = async () => {
if (!map || busy) return; if (!map || busy) return;
setBusy(true); setBusy(true);
try { try {
const response = await createShare(map, map.name); const response = await createShare(map, map.name, choice);
const created: ShareRecord = { await persist({
mapId: map.id, mapId: map.id,
shareToken: response.shareToken, shareToken: response.shareToken,
managementToken: response.managementToken, managementToken: response.managementToken,
@@ -60,9 +130,8 @@ export function ShareDialog({ open, onClose, onShareStateChange }: ShareDialogPr
title: response.title, title: response.title,
createdAt: response.createdAt, createdAt: response.createdAt,
updatedAt: response.updatedAt, updatedAt: response.updatedAt,
}; expiresAt: response.expiresAt,
await saveShareRecord(created); });
setRecord(created);
onShareStateChange?.(true); onShareStateChange?.(true);
pushToast('Lien de partage cree.', 'success'); pushToast('Lien de partage cree.', 'success');
} catch (error) { } catch (error) {
@@ -76,15 +145,13 @@ export function ShareDialog({ open, onClose, onShareStateChange }: ShareDialogPr
if (!map || !record || busy) return; if (!map || !record || busy) return;
setBusy(true); setBusy(true);
try { try {
const response = await updateShare( const response = await updateShare(record.shareToken, record.managementToken, map, map.name);
record.shareToken, await persist({
record.managementToken, ...record,
map, title: map.name,
map.name, updatedAt: response.updatedAt,
); expiresAt: response.expiresAt,
const updated = { ...record, title: map.name, updatedAt: response.updatedAt }; });
await saveShareRecord(updated);
setRecord(updated);
pushToast('Le lien public affiche maintenant la version actuelle.', 'success'); pushToast('Le lien public affiche maintenant la version actuelle.', 'success');
} catch (error) { } catch (error) {
handleError(error, "La mise a jour du partage n'a pas abouti."); handleError(error, "La mise a jour du partage n'a pas abouti.");
@@ -93,6 +160,24 @@ export function ShareDialog({ open, onClose, onShareStateChange }: ShareDialogPr
} }
}; };
const handleExpiryChange = async (next: ExpiryChoice) => {
setChoice(next);
if (!record || busy) return;
setBusy(true);
try {
const response = await setShareExpiry(record.shareToken, record.managementToken, next);
await persist({ ...record, expiresAt: response.expiresAt, updatedAt: response.updatedAt });
pushToast(
next === null ? "Ce partage n'expire plus." : `Expiration fixee a ${expiryLabel(next)}.`,
'success',
);
} catch (error) {
handleError(error, "L'expiration n'a pas pu etre modifiee.");
} finally {
setBusy(false);
}
};
const handleDelete = async () => { const handleDelete = async () => {
if (!map || !record || busy) return; if (!map || !record || busy) return;
setConfirmDelete(false); setConfirmDelete(false);
@@ -116,9 +201,8 @@ export function ShareDialog({ open, onClose, onShareStateChange }: ShareDialogPr
const handleCopy = async () => { const handleCopy = async () => {
if (!record) return; if (!record) return;
const url = buildShareUrl(record.shareToken);
try { try {
await navigator.clipboard.writeText(url); await navigator.clipboard.writeText(buildShareUrl(record.shareToken));
setCopied(true); setCopied(true);
window.setTimeout(() => setCopied(false), 2000); window.setTimeout(() => setCopied(false), 2000);
} catch { } catch {
@@ -126,8 +210,55 @@ export function ShareDialog({ open, onClose, onShareStateChange }: ShareDialogPr
} }
}; };
/** Sauvegarde volontaire du secret de gestion, hors du fichier .mmap. */
const handleExportKey = () => {
if (!record) return;
const blob = new Blob([serializeShareKey(record, window.location.origin)], {
type: 'application/json;charset=utf-8',
});
downloadBlob(blob, toSafeFilename(record.title || 'partage', SHARE_KEY_EXTENSION));
pushToast('Cle de gestion exportee. Conservez ce fichier en lieu sur.', 'success');
};
const handleImportKey = async (file: File) => {
if (!map || busy) return;
setBusy(true);
try {
const key = await readShareKeyFile(file);
// La cle n'est acceptee qu'apres verification par le serveur concerne.
const owner = await verifyManagementToken(key.shareToken, key.managementToken);
await persist({
mapId: map.id,
shareToken: owner.shareToken,
managementToken: key.managementToken,
sharePath: `/s/${owner.shareToken}`,
title: owner.title,
createdAt: owner.createdAt,
updatedAt: owner.updatedAt,
expiresAt: owner.expiresAt,
});
onShareStateChange?.(true);
pushToast('Cle de gestion importee : vous pouvez a nouveau gerer ce partage.', 'success');
} catch (error) {
if (error instanceof ShareKeyError) pushToast(error.message, 'error');
else handleError(error, "Cette cle de gestion n'a pas pu etre verifiee.");
} finally {
setBusy(false);
}
};
const shareUrl = record ? buildShareUrl(record.shareToken) : ''; const shareUrl = record ? buildShareUrl(record.shareToken) : '';
const disabledBody = (
<div className="space-y-3 text-sm leading-relaxed text-zinc-600 dark:text-zinc-300">
<p>Le partage est desactive sur cette instance.</p>
<p className="text-zinc-500 dark:text-zinc-400">
Vos cartes continuent de fonctionner normalement : elles restent enregistrees dans ce
navigateur, et vous pouvez toujours les exporter en fichier.
</p>
</div>
);
return ( return (
<> <>
<Modal <Modal
@@ -135,13 +266,11 @@ export function ShareDialog({ open, onClose, onShareStateChange }: ShareDialogPr
title={record ? 'Carte partagee' : 'Partager cette carte'} title={record ? 'Carte partagee' : 'Partager cette carte'}
onClose={onClose} onClose={onClose}
footer={ footer={
record ? ( !sharingEnabled ? (
<Button onClick={onClose}>Fermer</Button>
) : record ? (
<> <>
<Button <Button variant="danger" onClick={() => setConfirmDelete(true)} disabled={busy}>
variant="danger"
onClick={() => setConfirmDelete(true)}
disabled={busy}
>
Supprimer le partage Supprimer le partage
</Button> </Button>
<Button variant="primary" onClick={() => void handleUpdate()} disabled={busy}> <Button variant="primary" onClick={() => void handleUpdate()} disabled={busy}>
@@ -162,12 +291,23 @@ export function ShareDialog({ open, onClose, onShareStateChange }: ShareDialogPr
) )
} }
> >
{loading ? ( {!sharingEnabled ? (
disabledBody
) : loading ? (
<p className="text-sm text-zinc-500">Chargement...</p> <p className="text-sm text-zinc-500">Chargement...</p>
) : record ? ( ) : record ? (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex flex-wrap gap-1.5">
<span className="rounded-full bg-zinc-100 px-2 py-0.5 text-[11px] font-medium text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">
Partage non repertorie
</span>
<span className="rounded-full bg-zinc-100 px-2 py-0.5 text-[11px] font-medium text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">
Lecture seule
</span>
</div>
<div> <div>
<span className="field-label">Lien public (lecture seule)</span> <span className="field-label">Lien public</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
readOnly readOnly
@@ -183,6 +323,30 @@ export function ShareDialog({ open, onClose, onShareStateChange }: ShareDialogPr
</div> </div>
</div> </div>
<div>
<label className="field-label" htmlFor="share-expiry">
Expiration
</label>
<select
id="share-expiry"
className="field-input"
disabled={busy}
value={serializeChoice(choice)}
onChange={(event) => void handleExpiryChange(parseChoice(event.target.value))}
>
{options.map((value) => (
<option key={serializeChoice(value)} value={serializeChoice(value)}>
{expiryLabel(value)}
</option>
))}
</select>
<p className="mt-1 text-[11px] text-zinc-400">
{record.expiresAt
? `Le lien cessera de fonctionner le ${formatDateTime(record.expiresAt)}.`
: "Ce partage n'expire pas automatiquement."}
</p>
</div>
<dl className="rounded-lg border border-zinc-100 bg-zinc-50 p-3 text-xs dark:border-zinc-800 dark:bg-zinc-900/60"> <dl className="rounded-lg border border-zinc-100 bg-zinc-50 p-3 text-xs dark:border-zinc-800 dark:bg-zinc-900/60">
<div className="flex justify-between gap-4 py-0.5"> <div className="flex justify-between gap-4 py-0.5">
<dt className="text-zinc-500">Publie le</dt> <dt className="text-zinc-500">Publie le</dt>
@@ -198,29 +362,93 @@ export function ShareDialog({ open, onClose, onShareStateChange }: ShareDialogPr
</div> </div>
</dl> </dl>
<div>
<Button size="sm" onClick={handleExportKey} disabled={busy}>
<DownloadIcon className="h-3.5 w-3.5" />
Exporter la cle de gestion
</Button>
<p className="mt-1 text-[11px] leading-snug text-zinc-400">
Ce fichier contient le secret qui permet de mettre a jour ou de supprimer ce
partage. Il n&apos;est jamais inclus dans un export {'.mmap'}. Conservez-le si vous
comptez gerer ce partage depuis un autre navigateur.
</p>
</div>
<p className="text-xs leading-relaxed text-zinc-500 dark:text-zinc-400"> <p className="text-xs leading-relaxed text-zinc-500 dark:text-zinc-400">
Le lien est <strong>non repertorie</strong> : il n&apos;apparait dans aucune liste <strong>Toute personne disposant de ce lien peut consulter cette carte.</strong> Les
publique, mais toute personne qui le connait peut consulter la carte. Les
modifications faites ici ne sont visibles publiquement qu&apos;apres un modifications faites ici ne sont visibles publiquement qu&apos;apres un
<em> Mettre a jour le partage</em>. <em> Mettre a jour le partage</em>.
</p> </p>
</div> </div>
) : ( ) : (
<div className="space-y-3 text-sm leading-relaxed text-zinc-600 dark:text-zinc-300"> <div className="space-y-4 text-sm leading-relaxed text-zinc-600 dark:text-zinc-300">
<p> <p>
Cette carte est actuellement <strong>uniquement dans votre navigateur</strong>. Pour Cette carte est actuellement <strong>uniquement dans votre navigateur</strong>. Pour
generer un lien, une copie de la carte sera envoyee au serveur et stockee dans sa base generer un lien, une copie de la carte sera envoyee au serveur et stockee dans sa base
de donnees. de donnees.
</p> </p>
<div>
<label className="field-label" htmlFor="share-expiry-new">
Expiration
</label>
<select
id="share-expiry-new"
className="field-input"
disabled={busy}
value={serializeChoice(choice)}
onChange={(event) => setChoice(parseChoice(event.target.value))}
>
{options.map((value) => (
<option key={serializeChoice(value)} value={serializeChoice(value)}>
{expiryLabel(value)}
</option>
))}
</select>
</div>
<ul className="list-inside list-disc space-y-1 text-zinc-500 dark:text-zinc-400"> <ul className="list-inside list-disc space-y-1 text-zinc-500 dark:text-zinc-400">
<li>Le lien obtenu est non repertorie et en lecture seule.</li> <li>Le lien obtenu est non repertorie et en lecture seule.</li>
<li>Aucun compte n&apos;est necessaire pour le consulter.</li> <li>Toute personne disposant de ce lien peut consulter cette carte.</li>
<li>Les cartes non partagees restent uniquement dans votre navigateur.</li>
<li>Vous pourrez mettre a jour ou supprimer ce partage a tout moment.</li> <li>Vous pourrez mettre a jour ou supprimer ce partage a tout moment.</li>
<li>
Le secret permettant de le modifier reste dans ce navigateur : depuis un autre
appareil, vous ne pourrez pas gerer ce partage.
</li>
</ul> </ul>
<div className="border-t border-zinc-100 pt-3 dark:border-zinc-800">
<Button size="sm" onClick={() => keyInputRef.current?.click()} disabled={busy}>
<ImportIcon className="h-3.5 w-3.5" />
Importer une cle de gestion
</Button>
<input
ref={keyInputRef}
type="file"
accept={`${SHARE_KEY_EXTENSION},application/json`}
className="sr-only"
aria-label="Importer une cle de gestion"
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = '';
if (file) void handleImportKey(file);
}}
/>
<p className="mt-1 text-[11px] leading-snug text-zinc-400">
Cette carte a deja ete partagee depuis un autre navigateur ? Reimportez le fichier{' '}
{SHARE_KEY_EXTENSION} exporte a l&apos;epoque pour reprendre la main dessus.
</p>
</div>
<p className="text-[11px] text-zinc-400">
<button
type="button"
className="underline underline-offset-2 hover:text-zinc-600 dark:hover:text-zinc-300"
onClick={() => {
onClose();
navigate(PRIVACY_PATH);
}}
>
Comment {appName} traite vos donnees
</button>
</p>
</div> </div>
)} )}
</Modal> </Modal>

View File

@@ -18,8 +18,8 @@ import { MapFormatError } from '@/services/mapSerialization';
import { useAppConfigStore } from '@/stores/appConfigStore'; import { useAppConfigStore } from '@/stores/appConfigStore';
import { useEditorStore } from '@/stores/editorStore'; import { useEditorStore } from '@/stores/editorStore';
import { useUiStore } from '@/stores/uiStore'; import { useUiStore } from '@/stores/uiStore';
import { navigate } from '@/hooks/useRoute'; import { navigate, PRIVACY_PATH } from '@/hooks/useRoute';
import { formatDateTime } from '@/utils/date'; import { formatDate, formatDateTime } from '@/utils/date';
export interface SharedMapPageProps { export interface SharedMapPageProps {
token: string; token: string;
@@ -27,8 +27,25 @@ export interface SharedMapPageProps {
type Status = type Status =
| { kind: 'loading' } | { kind: 'loading' }
| { kind: 'ready'; title: string; updatedAt: string } | { kind: 'ready'; title: string; updatedAt: string; expiresAt: string | null }
| { kind: 'error'; message: string }; | { kind: 'error'; message: string; expired?: boolean };
/**
* Un lien de partage est une capacite de lecture : il ne doit pas se retrouver
* dans un index public. L'en-tete X-Robots-Tag est pose par le serveur ; cette
* balise couvre le cas d'un robot qui ne lirait que le document.
*/
function useNoIndex(): void {
useEffect(() => {
const meta = document.createElement('meta');
meta.name = 'robots';
meta.content = 'noindex, nofollow, noarchive';
document.head.appendChild(meta);
return () => {
meta.remove();
};
}, []);
}
function SharedMapContent({ token }: SharedMapPageProps) { function SharedMapContent({ token }: SharedMapPageProps) {
const appName = useAppConfigStore((state) => state.name); const appName = useAppConfigStore((state) => state.name);
@@ -40,6 +57,7 @@ function SharedMapContent({ token }: SharedMapPageProps) {
const { zoomIn, zoomOut } = useReactFlow(); const { zoomIn, zoomOut } = useReactFlow();
const [status, setStatus] = useState<Status>({ kind: 'loading' }); const [status, setStatus] = useState<Status>({ kind: 'loading' });
useNoIndex();
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -49,7 +67,12 @@ function SharedMapContent({ token }: SharedMapPageProps) {
.then((response) => { .then((response) => {
if (cancelled) return; if (cancelled) return;
loadMap(response.map, { readOnly: true }); loadMap(response.map, { readOnly: true });
setStatus({ kind: 'ready', title: response.title, updatedAt: response.updatedAt }); setStatus({
kind: 'ready',
title: response.title,
updatedAt: response.updatedAt,
expiresAt: response.expiresAt,
});
document.title = `${response.title} - ${appName}`; document.title = `${response.title} - ${appName}`;
}) })
.catch((error: unknown) => { .catch((error: unknown) => {
@@ -58,7 +81,8 @@ function SharedMapContent({ token }: SharedMapPageProps) {
error instanceof ShareApiError || error instanceof MapFormatError error instanceof ShareApiError || error instanceof MapFormatError
? error.message ? error.message
: 'Cette carte partagee est introuvable.'; : 'Cette carte partagee est introuvable.';
setStatus({ kind: 'error', message }); const expired = error instanceof ShareApiError && error.status === 410;
setStatus({ kind: 'error', message, expired });
}); });
return () => { return () => {
@@ -82,7 +106,9 @@ function SharedMapContent({ token }: SharedMapPageProps) {
<MapIcon className="h-10 w-10 text-zinc-300 dark:text-zinc-600" /> <MapIcon className="h-10 w-10 text-zinc-300 dark:text-zinc-600" />
<h1 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">{status.message}</h1> <h1 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">{status.message}</h1>
<p className="max-w-md text-sm text-zinc-500 dark:text-zinc-400"> <p className="max-w-md text-sm text-zinc-500 dark:text-zinc-400">
Le lien a peut-etre ete supprime par son auteur, ou l&apos;adresse est incorrecte. {status.expired
? "Son auteur avait fixe une date de fin. Lui seul peut le prolonger ou le republier."
: "Le lien a peut-etre ete supprime par son auteur, ou l'adresse est incorrecte."}
</p> </p>
<Button variant="primary" onClick={() => navigate('/')}> <Button variant="primary" onClick={() => navigate('/')}>
Ouvrir {appName} Ouvrir {appName}
@@ -100,7 +126,8 @@ function SharedMapContent({ token }: SharedMapPageProps) {
{status.title} {status.title}
</h1> </h1>
<p className="truncate text-[11px] text-zinc-400"> <p className="truncate text-[11px] text-zinc-400">
Carte partagee - lecture seule - mise a jour le {formatDateTime(status.updatedAt)} Carte partagee en lecture seule - mise a jour le {formatDateTime(status.updatedAt)}
{status.expiresAt ? ` - expire le ${formatDate(status.expiresAt)}` : ''}
</p> </p>
</div> </div>
@@ -122,8 +149,11 @@ function SharedMapContent({ token }: SharedMapPageProps) {
<MindMapCanvas readOnly showMiniMap={false} /> <MindMapCanvas readOnly showMiniMap={false} />
</div> </div>
<footer className="no-print flex h-8 shrink-0 items-center justify-center border-t border-zinc-200 bg-white text-[11px] text-zinc-400 dark:border-zinc-800 dark:bg-zinc-900"> <footer className="no-print flex h-8 shrink-0 items-center justify-center gap-2 border-t border-zinc-200 bg-white text-[11px] text-zinc-400 dark:border-zinc-800 dark:bg-zinc-900">
Carte partagee via {appName} - aucune inscription necessaire <span>Carte partagee via {appName} - aucune inscription necessaire</span>
<a href={PRIVACY_PATH} className="underline underline-offset-2 hover:text-zinc-600">
Confidentialite
</a>
</footer> </footer>
</div> </div>
); );

View File

@@ -0,0 +1,68 @@
import { Modal } from '@/components/Modal';
import { Button } from '@/components/Button';
import { MAP_TEMPLATES, type MapTemplate, type TemplateNode } from '@/features/templates/templates';
export interface TemplateGalleryProps {
open: boolean;
onClose: () => void;
onSelect: (template: MapTemplate) => void;
}
/** Apercu textuel : les premieres branches du modele, sans aucune image. */
function outline(node: TemplateNode): string {
const branches = (node.children ?? []).slice(0, 5).map((child) => child.text);
if (branches.length === 0) return 'Une racine, rien d autre.';
const extra = (node.children ?? []).length - branches.length;
return branches.join(' - ') + (extra > 0 ? ` - +${extra}` : '');
}
function countNodes(node: TemplateNode): number {
return 1 + (node.children ?? []).reduce((total, child) => total + countNodes(child), 0);
}
/**
* Galerie de modeles.
*
* Tout est local : aucun appel reseau, aucune image distante. Choisir un modele
* cree une copie ; le modele lui-meme n'est jamais modifie.
*/
export function TemplateGallery({ open, onClose, onSelect }: TemplateGalleryProps) {
return (
<Modal
open={open}
title="Nouvelle carte"
description="Partez d'une carte vide ou d'un modele. Le modele est copie : vous le modifiez librement."
onClose={onClose}
size="lg"
footer={<Button onClick={onClose}>Annuler</Button>}
>
<ul className="grid gap-2 sm:grid-cols-2" aria-label="Modeles disponibles">
{MAP_TEMPLATES.map((template) => (
<li key={template.id}>
<button
type="button"
onClick={() => onSelect(template)}
className="flex h-full w-full flex-col gap-1 rounded-lg border border-zinc-200 p-3 text-left transition-colors hover:border-indigo-300 hover:bg-indigo-50/50 dark:border-zinc-700 dark:hover:border-indigo-700 dark:hover:bg-indigo-950/30"
>
<span className="flex items-center gap-2">
<span aria-hidden="true" className="text-lg">
{template.icon}
</span>
<span className="text-sm font-semibold text-zinc-900 dark:text-zinc-50">
{template.name}
</span>
<span className="ml-auto shrink-0 text-[11px] text-zinc-400">
{countNodes(template.root)} noeuds
</span>
</span>
<span className="text-xs text-zinc-500 dark:text-zinc-400">
{template.description}
</span>
<span className="truncate text-[11px] text-zinc-400">{outline(template.root)}</span>
</button>
</li>
))}
</ul>
</Modal>
);
}

View File

@@ -0,0 +1,122 @@
import { describe, expect, it } from 'vitest';
import { MAP_TEMPLATES, buildTemplateMap, getTemplate } from '@/features/templates/templates';
import { normalizeMapDocument, serializeMap } from '@/services/mapSerialization';
import { getLevelOneBranches } from '@/features/editor/bilateral';
import { getOrderedChildren } from '@/utils/tree';
describe('galerie de modeles', () => {
it('propose les huit modeles annonces', () => {
expect(MAP_TEMPLATES.map((template) => template.id)).toEqual([
'blank',
'brainstorming',
'project',
'it-architecture',
'meeting',
'swot',
'study',
'personal',
]);
});
it('donne a chaque modele un nom et une description', () => {
for (const template of MAP_TEMPLATES) {
expect(template.name.length).toBeGreaterThan(0);
expect(template.description.length).toBeGreaterThan(0);
expect(template.icon.length).toBeGreaterThan(0);
}
});
it('retrouve un modele par son identifiant', () => {
expect(getTemplate('swot')?.name).toBe('SWOT');
expect(getTemplate('inexistant')).toBeUndefined();
});
});
describe('creation depuis un modele', () => {
it('produit une carte valide pour chaque modele', () => {
for (const template of MAP_TEMPLATES) {
const map = buildTemplateMap(template);
expect(map.nodes.length).toBeGreaterThan(0);
expect(map.nodes[0]?.parentId).toBeNull();
expect(map.nodes[0]?.type).toBe('root');
expect(map.settings.theme).toBe(template.theme);
expect(map.settings.layout).toBe(template.layout);
expect(map.relations).toEqual([]);
}
});
it('regenere des identifiants uniques a chaque creation', () => {
const template = getTemplate('it-architecture');
const first = buildTemplateMap(template as never);
const second = buildTemplateMap(template as never);
const firstIds = new Set(first.nodes.map((node) => node.id));
expect(firstIds.size).toBe(first.nodes.length);
for (const node of second.nodes) expect(firstIds.has(node.id)).toBe(false);
expect(first.id).not.toBe(second.id);
});
it('ne modifie jamais le modele source', () => {
const template = getTemplate('it-architecture') as never as {
root: { text: string; children?: unknown[] };
};
const before = JSON.stringify(template);
const map = buildTemplateMap(template as never);
const root = map.nodes[0];
if (root) root.text = 'Modifie';
expect(JSON.stringify(template)).toBe(before);
});
it("reproduit l'arborescence du modele Architecture IT", () => {
const map = buildTemplateMap(getTemplate('it-architecture') as never);
const root = map.nodes[0];
expect(root?.text).toBe('Architecture IT');
const branches = getOrderedChildren(map.nodes, root?.id as string).map((node) => node.text);
expect(branches).toEqual(['Reseau', 'Compute', 'Stockage', 'Services', 'Supervision']);
const reseau = map.nodes.find((node) => node.text === 'Reseau');
expect(getOrderedChildren(map.nodes, reseau?.id as string).map((node) => node.text)).toEqual([
'Firewall',
'Switch',
'VLAN',
'VPN',
]);
expect(map.nodes).toHaveLength(20);
});
it('n ecrit un cote que sur les branches de niveau 1', () => {
const map = buildTemplateMap(getTemplate('it-architecture') as never);
const branchIds = new Set(getLevelOneBranches(map.nodes).map((node) => node.id));
for (const node of map.nodes) {
if (node.side !== undefined) expect(branchIds.has(node.id)).toBe(true);
}
expect(branchIds.size).toBe(5);
});
it('positionne les noeuds sans les empiler', () => {
const map = buildTemplateMap(getTemplate('project') as never);
const distinct = new Set(map.nodes.map((node) => `${node.position.x}:${node.position.y}`));
expect(distinct.size).toBe(map.nodes.length);
});
});
describe('modele et format de fichier', () => {
it('survit a un aller-retour de serialisation', () => {
const map = buildTemplateMap(getTemplate('swot') as never);
const again = normalizeMapDocument(JSON.parse(serializeMap(map)), { source: 'file' });
expect(again.nodes).toHaveLength(map.nodes.length);
expect(again.settings.layout).toBe(map.settings.layout);
const sides = new Map(again.nodes.map((node) => [node.text, node.side]));
for (const node of map.nodes) {
expect(sides.get(node.text)).toBe(node.side);
}
});
it('est ecrit au format courant', () => {
const map = buildTemplateMap(getTemplate('blank') as never);
expect(JSON.parse(serializeMap(map)).version).toBe(4);
});
});

View File

@@ -0,0 +1,395 @@
import {
MAP_FORMAT_VERSION,
type BranchStyle,
type LayoutKind,
type MindMap,
type MindMapNode,
type NodeSide,
} from '@/types/map';
import { computeLayout } from '@/features/editor/layout';
import { getTheme } from '@/features/editor/themes';
import { createNode, DEFAULT_SETTINGS } from '@/services/mapFactory';
import { createId } from '@/utils/id';
import { rebuildEdges } from '@/utils/tree';
/**
* Galerie de modeles, entierement locale.
*
* Un modele est une description figee : `buildTemplateMap` en fabrique une
* carte neuve, avec de nouveaux identifiants, a chaque appel. L'original n'est
* jamais modifie, et la carte creee est ensuite une carte comme les autres.
* Aucune ressource distante n'est chargee : tout est ecrit ici.
*/
export interface TemplateNode {
text: string;
icon?: string;
note?: string;
/** Cote impose pour une branche de niveau 1, en disposition bilaterale. */
side?: NodeSide;
children?: TemplateNode[];
}
export interface MapTemplate {
id: string;
name: string;
description: string;
/** Emoji d'illustration : aucune image distante. */
icon: string;
theme: string;
layout: LayoutKind;
branchStyle?: BranchStyle;
root: TemplateNode;
}
export const MAP_TEMPLATES: MapTemplate[] = [
{
id: 'blank',
name: 'Carte vide',
description: 'Une seule racine, a vous de construire.',
icon: '⬜',
theme: 'default',
layout: 'mindmap-right',
root: { text: 'Nouvelle carte' },
},
{
id: 'brainstorming',
name: 'Brainstorming',
description: 'Collecter des idees, les trier, decider.',
icon: '💡',
theme: 'sunset',
layout: 'mindmap-both',
root: {
text: 'Brainstorming',
icon: '💡',
children: [
{
text: 'Question de depart',
side: 'left',
children: [{ text: 'Contexte' }, { text: 'Contraintes' }],
},
{
text: 'Idees brutes',
side: 'right',
children: [{ text: 'Idee 1' }, { text: 'Idee 2' }, { text: 'Idee 3' }],
},
{
text: 'A creuser',
side: 'right',
children: [{ text: 'Pistes prometteuses' }, { text: 'Questions ouvertes' }],
},
{
text: 'Ecartees',
side: 'left',
children: [{ text: 'Trop couteux' }, { text: 'Hors sujet' }],
},
{ text: 'Prochaines etapes', side: 'right', children: [{ text: 'Qui fait quoi' }] },
],
},
},
{
id: 'project',
name: 'Gestion de projet',
description: 'Objectifs, lots de travail, jalons et risques.',
icon: '📋',
theme: 'ocean',
layout: 'mindmap-both',
root: {
text: 'Projet',
icon: '📋',
children: [
{
text: 'Objectifs',
icon: '🎯',
side: 'left',
children: [{ text: 'Resultat attendu' }, { text: 'Criteres de reussite' }],
},
{
text: 'Perimetre',
side: 'left',
children: [{ text: 'Inclus' }, { text: 'Exclu' }],
},
{
text: 'Lots de travail',
icon: '🧩',
side: 'right',
children: [
{ text: 'Cadrage' },
{ text: 'Realisation' },
{ text: 'Recette' },
{ text: 'Mise en production' },
],
},
{
text: 'Jalons',
icon: '📅',
side: 'right',
children: [{ text: 'Lancement' }, { text: 'Livraison' }],
},
{
text: 'Equipe',
side: 'left',
children: [{ text: 'Sponsor' }, { text: 'Chef de projet' }, { text: 'Contributeurs' }],
},
{
text: 'Risques',
icon: '⚠️',
side: 'right',
children: [{ text: 'Identifies' }, { text: 'Plan de repli' }],
},
],
},
},
{
id: 'it-architecture',
name: 'Architecture IT',
description: 'Reseau, compute, stockage, services et supervision.',
icon: '🖧',
theme: 'midnight',
layout: 'mindmap-both',
branchStyle: 'curved',
root: {
text: 'Architecture IT',
icon: '🖧',
note: "Vue d'ensemble d'un systeme d'information : adaptez chaque branche a votre contexte.",
children: [
{
text: 'Reseau',
icon: '🌐',
side: 'left',
children: [
{ text: 'Firewall', note: "Filtrage entrant et sortant, regles par zone." },
{ text: 'Switch' },
{ text: 'VLAN' },
{ text: 'VPN', note: "Acces distant des utilisateurs aux ressources internes." },
],
},
{
text: 'Compute',
icon: '🖥️',
side: 'right',
children: [
{ text: 'Hyperviseurs' },
{ text: 'VM' },
{ text: 'Conteneurs' },
],
},
{
text: 'Stockage',
icon: '💾',
side: 'left',
children: [{ text: 'NAS' }, { text: 'Sauvegarde' }],
},
{
text: 'Services',
icon: '⚙️',
side: 'right',
children: [{ text: 'DNS' }, { text: 'DHCP' }, { text: 'Applications' }],
},
{
text: 'Supervision',
icon: '📈',
side: 'left',
children: [{ text: 'Monitoring' }, { text: 'Logs' }],
},
],
},
},
{
id: 'meeting',
name: 'Reunion / prise de notes',
description: 'Ordre du jour, decisions, actions a suivre.',
icon: '📝',
theme: 'paper',
layout: 'mindmap-right',
root: {
text: 'Reunion',
icon: '📝',
children: [
{ text: 'Contexte', children: [{ text: 'Date' }, { text: 'Participants' }] },
{
text: 'Ordre du jour',
children: [{ text: 'Point 1' }, { text: 'Point 2' }, { text: 'Point 3' }],
},
{ text: 'Decisions', icon: '✅', children: [{ text: 'Decision 1' }] },
{
text: 'Actions',
icon: '➡️',
children: [{ text: 'Qui' }, { text: 'Quoi' }, { text: 'Pour quand' }],
},
{ text: 'Points en suspens' },
],
},
},
{
id: 'swot',
name: 'SWOT',
description: 'Forces, faiblesses, opportunites, menaces.',
icon: '🧭',
theme: 'forest',
layout: 'mindmap-both',
root: {
text: 'Analyse SWOT',
icon: '🧭',
children: [
{
text: 'Forces',
icon: '💪',
side: 'left',
children: [{ text: 'Interne, positif' }],
},
{
text: 'Faiblesses',
icon: '🪫',
side: 'left',
children: [{ text: 'Interne, a ameliorer' }],
},
{
text: 'Opportunites',
icon: '🚀',
side: 'right',
children: [{ text: 'Externe, favorable' }],
},
{
text: 'Menaces',
icon: '⛈️',
side: 'right',
children: [{ text: 'Externe, a surveiller' }],
},
],
},
},
{
id: 'study',
name: "Plan d'etude",
description: 'Programme de revision par matiere et par echeance.',
icon: '🎓',
theme: 'lavender',
layout: 'mindmap-right',
root: {
text: 'Plan de revision',
icon: '🎓',
children: [
{
text: 'Matieres',
children: [{ text: 'Matiere 1' }, { text: 'Matiere 2' }, { text: 'Matiere 3' }],
},
{
text: 'Chapitres a revoir',
children: [{ text: 'A maitriser' }, { text: 'A consolider' }],
},
{
text: 'Methodes',
children: [{ text: 'Fiches' }, { text: 'Exercices' }, { text: 'Annales' }],
},
{
text: 'Planning',
icon: '📅',
children: [{ text: 'Cette semaine' }, { text: 'Avant l examen' }],
},
{ text: 'Evaluation', children: [{ text: 'Points acquis' }, { text: 'Points fragiles' }] },
],
},
},
{
id: 'personal',
name: 'Planification personnelle',
description: 'Priorites, habitudes et projets personnels.',
icon: '🌱',
theme: 'default',
layout: 'mindmap-both',
root: {
text: 'Mon organisation',
icon: '🌱',
children: [
{
text: 'Priorites',
icon: '⭐',
side: 'right',
children: [{ text: 'Cette semaine' }, { text: 'Ce mois-ci' }],
},
{
text: 'Sante',
side: 'left',
children: [{ text: 'Sport' }, { text: 'Sommeil' }, { text: 'Alimentation' }],
},
{
text: 'Projets',
side: 'right',
children: [{ text: 'En cours' }, { text: 'A demarrer' }],
},
{
text: 'Habitudes',
side: 'left',
children: [{ text: 'A garder' }, { text: 'A changer' }],
},
{ text: 'Envies', icon: '✨', side: 'right', children: [{ text: 'A explorer' }] },
],
},
},
];
export function getTemplate(id: string): MapTemplate | undefined {
return MAP_TEMPLATES.find((template) => template.id === id);
}
/**
* Fabrique une carte a partir d'un modele.
*
* Chaque appel regenere tous les identifiants et recopie les objets : le modele
* d'origine, qui est une constante du module, n'est jamais modifie. La carte
* produite est ensuite une carte ordinaire, modifiable et supprimable comme
* n'importe quelle autre.
*
* Les positions initiales viennent de l'auto-layout, calcule sur les tailles
* estimees : le modele s'ouvre deja bien dispose, sans attendre une mesure du
* DOM qui n'existe pas encore.
*/
export function buildTemplateMap(template: MapTemplate, name?: string): MindMap {
const timestamp = new Date().toISOString();
const nodes: MindMapNode[] = [];
const walk = (spec: TemplateNode, parentId: string | null, order: number, depth: number): void => {
const node = createNode({
text: spec.text,
type: parentId === null ? 'root' : 'standard',
parentId,
order,
...(spec.icon ? { icon: spec.icon } : {}),
...(spec.note ? { note: spec.note } : {}),
// Le cote n'a de sens que sur une branche de niveau 1.
...(depth === 1 && spec.side ? { side: spec.side } : {}),
position: { x: 0, y: 0 },
});
nodes.push(node);
(spec.children ?? []).forEach((child, index) => walk(child, node.id, index, depth + 1));
};
walk(template.root, null, 0, 0);
const settings = {
...DEFAULT_SETTINGS,
theme: template.theme,
layout: template.layout,
...(template.branchStyle ? { branchStyle: template.branchStyle } : {}),
};
const positions = computeLayout(nodes, template.layout, { theme: getTheme(template.theme) });
const placed = nodes.map((node) => ({
...node,
position: positions.get(node.id) ?? node.position,
}));
return {
version: MAP_FORMAT_VERSION,
id: createId(),
name: name?.trim() || template.root.text || template.name,
createdAt: timestamp,
updatedAt: timestamp,
nodes: placed,
edges: rebuildEdges(placed),
relations: [],
viewport: { x: 0, y: 0, zoom: 1 },
settings,
};
}

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { PRIVACY_PATH, parseRoute } from '@/hooks/useRoute';
describe('routes', () => {
it('reconnait le tableau de bord', () => {
expect(parseRoute('/')).toEqual({ name: 'dashboard' });
expect(parseRoute('')).toEqual({ name: 'dashboard' });
});
it('reconnait la page de confidentialite', () => {
expect(parseRoute(PRIVACY_PATH)).toEqual({ name: 'privacy' });
expect(parseRoute('/privacy/')).toEqual({ name: 'privacy' });
});
it('reconnait une carte locale', () => {
expect(parseRoute('/map/abc-123')).toEqual({ name: 'editor', mapId: 'abc-123' });
});
it('reconnait un partage', () => {
expect(parseRoute('/s/OZGrnetO4RPbqPtS')).toEqual({
name: 'shared',
token: 'OZGrnetO4RPbqPtS',
});
});
it('rejette le reste', () => {
expect(parseRoute('/inconnu')).toEqual({ name: 'notFound' });
expect(parseRoute('/s/')).toEqual({ name: 'notFound' });
});
});

View File

@@ -5,14 +5,18 @@ export type Route =
| { name: 'dashboard' } | { name: 'dashboard' }
| { name: 'editor'; mapId: string } | { name: 'editor'; mapId: string }
| { name: 'shared'; token: string } | { name: 'shared'; token: string }
| { name: 'privacy' }
| { name: 'notFound' }; | { name: 'notFound' };
const NAVIGATION_EVENT = 'openmind:navigate'; const NAVIGATION_EVENT = 'openmind:navigate';
export const PRIVACY_PATH = '/privacy';
export function parseRoute(pathname: string): Route { export function parseRoute(pathname: string): Route {
const clean = pathname.replace(/\/+$/, '') || '/'; const clean = pathname.replace(/\/+$/, '') || '/';
if (clean === '/' || clean === '') return { name: 'dashboard' }; if (clean === '/' || clean === '') return { name: 'dashboard' };
if (clean === PRIVACY_PATH) return { name: 'privacy' };
const editorMatch = clean.match(/^\/map\/([^/]+)$/); const editorMatch = clean.match(/^\/map\/([^/]+)$/);
if (editorMatch?.[1]) return { name: 'editor', mapId: decodeURIComponent(editorMatch[1]) }; if (editorMatch?.[1]) return { name: 'editor', mapId: decodeURIComponent(editorMatch[1]) };

View File

@@ -1,4 +1,5 @@
import { MAP_FORMAT_VERSION, type MindMap, type MindMapNode } from '@/types/map'; import { MAP_FORMAT_VERSION, type MindMap, type MindMapNode } from '@/types/map';
import { remapRelations } from '@/services/relations';
import { createId } from '@/utils/id'; import { createId } from '@/utils/id';
import { rebuildEdges } from '@/utils/tree'; import { rebuildEdges } from '@/utils/tree';
@@ -20,6 +21,8 @@ export function createNode(partial: Partial<MindMapNode> & { text: string }): Mi
text: partial.text, text: partial.text,
position: partial.position ?? { x: 0, y: 0 }, position: partial.position ?? { x: 0, y: 0 },
parentId: partial.parentId ?? null, parentId: partial.parentId ?? null,
...(partial.order !== undefined ? { order: partial.order } : {}),
...(partial.side !== undefined ? { side: partial.side } : {}),
...(partial.collapsed !== undefined ? { collapsed: partial.collapsed } : {}), ...(partial.collapsed !== undefined ? { collapsed: partial.collapsed } : {}),
...(partial.icon ? { icon: partial.icon } : {}), ...(partial.icon ? { icon: partial.icon } : {}),
...(partial.url ? { url: partial.url } : {}), ...(partial.url ? { url: partial.url } : {}),
@@ -44,6 +47,7 @@ export function createEmptyMap(name: string): MindMap {
updatedAt: timestamp, updatedAt: timestamp,
nodes: [root], nodes: [root],
edges: [], edges: [],
relations: [],
viewport: { x: 0, y: 0, zoom: 1 }, viewport: { x: 0, y: 0, zoom: 1 },
settings: { ...DEFAULT_SETTINGS }, settings: { ...DEFAULT_SETTINGS },
}; };
@@ -71,6 +75,9 @@ export function cloneMap(map: MindMap, name?: string): MindMap {
updatedAt: timestamp, updatedAt: timestamp,
nodes, nodes,
edges: rebuildEdges(nodes), edges: rebuildEdges(nodes),
// Les relations suivent la copie : leurs extremites pointent vers les
// nouveaux identifiants, jamais vers ceux de la carte d'origine.
relations: remapRelations(map.relations ?? [], idMapping),
viewport: { ...map.viewport }, viewport: { ...map.viewport },
settings: { ...map.settings }, settings: { ...map.settings },
}; };
@@ -125,6 +132,7 @@ export function createSampleMap(): MindMap {
updatedAt: timestamp, updatedAt: timestamp,
nodes, nodes,
edges: rebuildEdges(nodes), edges: rebuildEdges(nodes),
relations: [],
viewport: { x: 0, y: 0, zoom: 1 }, viewport: { x: 0, y: 0, zoom: 1 },
settings: { ...DEFAULT_SETTINGS }, settings: { ...DEFAULT_SETTINGS },
}; };

View File

@@ -0,0 +1,171 @@
import { describe, expect, it } from 'vitest';
import { MAP_FORMAT_VERSION, type MindMap } from '@/types/map';
import { createEmptyMap } from '@/services/mapFactory';
import { normalizeMapDocument, serializeMap } from '@/services/mapSerialization';
/** Carte v1 telle qu'elle etait exportee avant la phase 1.1. */
const LEGACY_V1 = {
version: 1,
id: '11111111-1111-4111-8111-111111111111',
name: 'Infrastructure v1',
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
nodes: [
{
id: 'root',
type: 'root',
text: 'Infrastructure',
position: { x: 0, y: 0 },
parentId: null,
icon: '🖥️',
style: { backgroundColor: '#eef2ff', width: 240, textAlign: 'center' },
},
{ id: 'n1', type: 'standard', text: 'Reseau', position: { x: 260, y: 0 }, parentId: 'root' },
],
edges: [{ id: 'e-root-n1', source: 'root', target: 'n1' }],
viewport: { x: 0, y: 0, zoom: 1 },
settings: { theme: 'default', layout: 'mindmap-right', branchStyle: 'bezier', showGrid: true },
};
describe('compatibilite du format', () => {
it('importe une carte v1 sans propriete de mise en page', () => {
const map = normalizeMapDocument(LEGACY_V1);
expect(map.version).toBe(MAP_FORMAT_VERSION);
expect(map.nodes).toHaveLength(2);
expect(map.nodes[1]?.layout).toBeUndefined();
expect(map.nodes[1]?.iconStyle).toBeUndefined();
expect(map.settings.layoutSpacing).toBeUndefined();
});
it('migre la largeur v1 vers layout.width', () => {
const map = normalizeMapDocument(LEGACY_V1);
const root = map.nodes[0];
expect(root?.layout?.widthMode).toBe('fixed');
expect(root?.layout?.width).toBe(240);
expect(root?.style?.width).toBeUndefined();
// Les autres personnalisations v1 sont conservees.
expect(root?.style?.backgroundColor).toBe('#eef2ff');
expect(root?.style?.textAlign).toBe('center');
expect(root?.icon).toBe('🖥️');
});
it('refuse une version plus recente que celle supportee', () => {
expect(() => normalizeMapDocument({ ...LEGACY_V1, version: MAP_FORMAT_VERSION + 1 })).toThrow(
/plus recente/i,
);
});
});
describe('aller-retour des nouvelles proprietes', () => {
function richMap(): MindMap {
const map = createEmptyMap('Mise en page');
const root = map.nodes[0] as MindMap['nodes'][number];
return {
...map,
nodes: [
{
...root,
icon: 'server',
iconStyle: { kind: 'builtin', position: 'top', size: 24, gap: 12 },
style: {
backgroundColor: '#112233',
textColor: '#ffffff',
textAlign: 'center',
verticalAlign: 'middle',
lineHeight: 1.6,
fontFamily: 'mono',
shadow: 'strong',
fontSize: 18,
bold: true,
italic: true,
shape: 'capsule',
},
layout: {
widthMode: 'fixed',
width: 320,
heightMode: 'fixed',
height: 140,
paddingX: 28,
paddingY: 18,
wrap: false,
maxTextWidth: 400,
},
branch: { style: 'straight', width: 4, color: '#ff8800' },
},
],
settings: {
...map.settings,
layoutSpacing: { parentGap: 140, siblingGap: 80 },
snapToGrid: true,
snapGrid: 10,
alignmentGuides: false,
},
};
}
it('conserve toute la mise en page apres export puis import', () => {
const original = richMap();
const restored = normalizeMapDocument(JSON.parse(serializeMap(original)));
const node = restored.nodes[0];
const source = original.nodes[0];
expect(node?.style).toEqual(source?.style);
expect(node?.layout).toEqual(source?.layout);
expect(node?.iconStyle).toEqual(source?.iconStyle);
expect(node?.branch).toEqual(source?.branch);
expect(node?.icon).toBe('server');
expect(node?.position).toEqual(source?.position);
expect(restored.settings.layoutSpacing).toEqual({ parentGap: 140, siblingGap: 80 });
expect(restored.settings.snapToGrid).toBe(true);
expect(restored.settings.snapGrid).toBe(10);
expect(restored.settings.alignmentGuides).toBe(false);
});
it('borne les valeurs aberrantes a l import', () => {
const map = normalizeMapDocument({
version: 2,
id: 'x',
nodes: [
{
id: 'a',
type: 'root',
text: 'Racine',
layout: { width: 99999, paddingX: -20, maxTextWidth: 5 },
branch: { width: 500 },
iconStyle: { size: 900, gap: -5 },
},
],
});
const node = map.nodes[0];
expect(node?.layout?.width).toBeLessThanOrEqual(900);
expect(node?.layout?.paddingX).toBeGreaterThanOrEqual(0);
expect(node?.layout?.maxTextWidth).toBeGreaterThanOrEqual(80);
expect(node?.branch?.width).toBeLessThanOrEqual(10);
expect(node?.iconStyle?.size).toBeLessThanOrEqual(48);
expect(node?.iconStyle?.gap).toBeGreaterThanOrEqual(0);
});
it('rejette les valeurs de mise en page invalides', () => {
const map = normalizeMapDocument({
version: 2,
id: 'x',
nodes: [
{
id: 'a',
type: 'root',
text: 'Racine',
layout: { widthMode: 'elastique', wrap: 'oui' },
iconStyle: { kind: 'svg', position: 'diagonale' },
style: { verticalAlign: 'flottant', fontFamily: 'comic-sans' },
},
],
});
expect(map.nodes[0]?.layout).toBeUndefined();
expect(map.nodes[0]?.iconStyle).toBeUndefined();
expect(map.nodes[0]?.style).toBeUndefined();
});
});

View File

@@ -0,0 +1,137 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { MAP_FORMAT_VERSION, type MindMap } from '@/types/map';
import { db } from '@/db/database';
import { createMap, getMap, importMap } from '@/services/mapRepository';
import {
MapFormatError,
normalizeMapDocument,
serializeMap,
} from '@/services/mapSerialization';
/** Carte v1 telle qu'elle pouvait etre enregistree en IndexedDB avant la v2. */
function legacyStoredMap(id: string): Record<string, unknown> {
return {
version: 1,
id,
name: 'Homelab v1',
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-02T00:00:00.000Z',
nodes: [
{
id: 'root',
type: 'root',
text: 'Infrastructure',
position: { x: 0, y: 0 },
parentId: null,
icon: '🖥️',
style: { backgroundColor: '#eef2ff', width: 240, textAlign: 'center' },
},
{
id: 'n1',
type: 'standard',
text: 'Reseau',
position: { x: 260, y: 0 },
parentId: 'root',
},
],
edges: [{ id: 'e-root-n1', source: 'root', target: 'n1' }],
viewport: { x: 12, y: 34, zoom: 1.5 },
settings: { theme: 'ocean', layout: 'tree-vertical', branchStyle: 'curved', showGrid: false },
};
}
beforeEach(async () => {
await db.maps.clear();
await db.shares.clear();
await db.meta.clear();
});
describe('pipeline de normalisation', () => {
it('normalise une carte v1 venue d IndexedDB comme un fichier v1', async () => {
await db.maps.put(legacyStoredMap('stored-1') as unknown as MindMap);
const fromStorage = await getMap('stored-1');
const fromFile = normalizeMapDocument(legacyStoredMap('stored-1'), { source: 'file' });
expect(fromStorage).toBeDefined();
expect(fromStorage?.nodes).toEqual(fromFile.nodes);
expect(fromStorage?.settings).toEqual(fromFile.settings);
expect(fromStorage?.version).toBe(MAP_FORMAT_VERSION);
});
it('migre la largeur v1 d une carte stockee sans la reecrire en base', async () => {
await db.maps.put(legacyStoredMap('stored-2') as unknown as MindMap);
const opened = await getMap('stored-2');
expect(opened?.nodes[0]?.layout?.width).toBe(240);
expect(opened?.nodes[0]?.layout?.widthMode).toBe('fixed');
expect(opened?.nodes[0]?.style?.width).toBeUndefined();
// La base contient toujours le document d'origine : rien n'a ete reecrit.
const raw = (await db.maps.get('stored-2')) as unknown as Record<string, unknown>;
expect(raw.version).toBe(1);
expect((raw.nodes as Array<Record<string, unknown>>)[0]?.style).toMatchObject({ width: 240 });
});
it('conserve le contenu et les reglages d une carte v1', async () => {
await db.maps.put(legacyStoredMap('stored-3') as unknown as MindMap);
const opened = await getMap('stored-3');
expect(opened?.name).toBe('Homelab v1');
expect(opened?.createdAt).toBe('2026-01-01T00:00:00.000Z');
expect(opened?.updatedAt).toBe('2026-01-02T00:00:00.000Z');
expect(opened?.nodes[0]?.icon).toBe('🖥️');
expect(opened?.nodes[0]?.style?.textAlign).toBe('center');
expect(opened?.settings.theme).toBe('ocean');
expect(opened?.settings.layout).toBe('tree-vertical');
expect(opened?.viewport).toEqual({ x: 12, y: 34, zoom: 1.5 });
});
it('ne modifie jamais le document fourni', () => {
const original = legacyStoredMap('immutable');
const copy = JSON.parse(JSON.stringify(original));
normalizeMapDocument(original, { source: 'storage' });
expect(original).toEqual(copy);
});
it('laisse une carte v2 inchangee', () => {
const v2 = normalizeMapDocument(legacyStoredMap('x'), { source: 'file' });
const again = normalizeMapDocument(JSON.parse(serializeMap(v2)), { source: 'file' });
expect(again.nodes).toEqual(v2.nodes);
expect(again.settings).toEqual(v2.settings);
expect(again.version).toBe(MAP_FORMAT_VERSION);
});
it('accepte une carte locale videe de ses noeuds', async () => {
const map = await createMap('Carte videe');
await db.maps.put({ ...map, nodes: [], edges: [] });
const opened = await getMap(map.id);
expect(opened?.nodes).toHaveLength(0);
expect(opened?.name).toBe('Carte videe');
});
it('refuse un fichier sans noeud exploitable', () => {
expect(() => normalizeMapDocument({ version: 1, id: 'x', nodes: [] })).toThrow(MapFormatError);
});
it('adapte le message d erreur a la provenance', () => {
expect(() => normalizeMapDocument('pas un objet', { source: 'file' })).toThrow(/fichier/i);
expect(() => normalizeMapDocument('pas un objet', { source: 'storage' })).toThrow(
/enregistree/i,
);
expect(() => normalizeMapDocument('pas un objet', { source: 'share' })).toThrow(/partagee/i);
});
it('fait passer un import par le meme pipeline', async () => {
const normalised = normalizeMapDocument(legacyStoredMap('file-1'), { source: 'file' });
const stored = await importMap(normalised);
const reopened = await getMap(stored.id);
expect(reopened?.nodes).toEqual(normalised.nodes);
expect(reopened?.version).toBe(MAP_FORMAT_VERSION);
});
});

View File

@@ -0,0 +1,151 @@
import { describe, expect, it } from 'vitest';
import { MAP_FORMAT_VERSION, type MindMap } from '@/types/map';
import { normalizeMapDocument, serializeMap } from '@/services/mapSerialization';
import { getOrderedChildren } from '@/utils/tree';
/** Carte v2 : pas de champ `order`, l'ordre est celui du tableau. */
const V2_MAP = {
version: 2,
id: 'v2-map',
name: 'Reseau v2',
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
nodes: [
{ id: 'root', type: 'root', text: 'Reseau', position: { x: 0, y: 0 }, parentId: null },
{ id: 'dns', type: 'standard', text: 'DNS', position: { x: 200, y: 0 }, parentId: 'root' },
{ id: 'dhcp', type: 'standard', text: 'DHCP', position: { x: 200, y: 60 }, parentId: 'root' },
{ id: 'vlan', type: 'standard', text: 'VLAN', position: { x: 200, y: 120 }, parentId: 'root' },
],
edges: [
{ id: 'e-root-dns', source: 'root', target: 'dns' },
{ id: 'e-root-dhcp', source: 'root', target: 'dhcp' },
{ id: 'e-root-vlan', source: 'root', target: 'vlan' },
],
viewport: { x: 0, y: 0, zoom: 1 },
settings: { theme: 'default', layout: 'mindmap-right', branchStyle: 'bezier', showGrid: true },
};
const V1_MAP = {
...V2_MAP,
version: 1,
id: 'v1-map',
nodes: V2_MAP.nodes.map((node) =>
node.id === 'root' ? { ...node, style: { width: 240, textAlign: 'center' } } : node,
),
};
function texts(map: MindMap, parentId: string | null): string[] {
return getOrderedChildren(map.nodes, parentId).map((node) => node.text);
}
describe('migration vers la v3', () => {
it('materialise l ordre d une carte v2 depuis la position dans le tableau', () => {
const map = normalizeMapDocument(V2_MAP);
expect(map.version).toBe(MAP_FORMAT_VERSION);
expect(texts(map, 'root')).toEqual(['DNS', 'DHCP', 'VLAN']);
expect(map.nodes.map((node) => node.order)).toEqual([0, 0, 1, 2]);
});
it('migre une carte v1 jusqu a la v3 sans perdre la mise en page', () => {
const map = normalizeMapDocument(V1_MAP);
expect(map.version).toBe(MAP_FORMAT_VERSION);
expect(texts(map, 'root')).toEqual(['DNS', 'DHCP', 'VLAN']);
expect(map.nodes[0]?.layout?.width).toBe(240);
expect(map.nodes[0]?.style?.textAlign).toBe('center');
});
it('respecte un ordre explicite plutot que la position dans le tableau', () => {
const desordonne = {
...V2_MAP,
version: 3,
nodes: [
V2_MAP.nodes[0],
{ ...V2_MAP.nodes[1], order: 2 },
{ ...V2_MAP.nodes[2], order: 1 },
{ ...V2_MAP.nodes[3], order: 0 },
],
};
expect(texts(normalizeMapDocument(desordonne), 'root')).toEqual(['VLAN', 'DHCP', 'DNS']);
});
it('refuse une version plus recente', () => {
expect(() => normalizeMapDocument({ ...V2_MAP, version: MAP_FORMAT_VERSION + 1 })).toThrow(
/plus recente/i,
);
});
});
describe('aller-retour de l ordre', () => {
it('conserve un ordre reordonne apres export puis import', () => {
const map = normalizeMapDocument(V2_MAP);
const reordonne: MindMap = {
...map,
nodes: map.nodes.map((node) => {
if (node.id === 'vlan') return { ...node, order: 0 };
if (node.id === 'dns') return { ...node, order: 1 };
if (node.id === 'dhcp') return { ...node, order: 2 };
return node;
}),
};
const restaure = normalizeMapDocument(JSON.parse(serializeMap(reordonne)));
expect(texts(restaure, 'root')).toEqual(['VLAN', 'DNS', 'DHCP']);
});
/** VLAN en tete, comme le ferait "Placer en premier". */
function withVlanFirst(): MindMap {
const map = normalizeMapDocument(V2_MAP);
const ordres: Record<string, number> = { vlan: 0, dns: 1, dhcp: 2 };
return {
...map,
nodes: map.nodes.map((node) =>
ordres[node.id] === undefined ? node : { ...node, order: ordres[node.id] as number },
),
};
}
it('ecrit les noeuds dans l ordre hierarchique', () => {
const serialise = JSON.parse(serializeMap(withVlanFirst())) as MindMap;
// Un lecteur anterieur a la v3, qui ignore `order`, retrouve le bon ordre.
expect(serialise.nodes.map((node) => node.id)).toEqual(['root', 'vlan', 'dns', 'dhcp']);
});
it('emet les liens dans l ordre de fratrie', () => {
const serialise = JSON.parse(serializeMap(withVlanFirst())) as MindMap;
expect(serialise.edges.map((edge) => edge.target)).toEqual(['vlan', 'dns', 'dhcp']);
});
it('departage deux freres de meme rang par leur position dans le tableau', () => {
const map = normalizeMapDocument(V2_MAP);
const ambigu: MindMap = {
...map,
nodes: map.nodes.map((node) => (node.id === 'vlan' ? { ...node, order: 0 } : node)),
};
// `dns` et `vlan` ont tous deux le rang 0 : le tableau tranche, sans erreur.
expect(texts(normalizeMapDocument(JSON.parse(serializeMap(ambigu))), 'root')).toEqual([
'DNS',
'VLAN',
'DHCP',
]);
});
it('conserve l ordre d une arborescence sur plusieurs niveaux', () => {
const map = normalizeMapDocument({
version: 3,
id: 'profond',
nodes: [
{ id: 'r', type: 'root', text: 'R', parentId: null, position: { x: 0, y: 0 } },
{ id: 'a', text: 'A', parentId: 'r', order: 1, position: { x: 0, y: 0 } },
{ id: 'b', text: 'B', parentId: 'r', order: 0, position: { x: 0, y: 0 } },
{ id: 'a1', text: 'A1', parentId: 'a', order: 1, position: { x: 0, y: 0 } },
{ id: 'a2', text: 'A2', parentId: 'a', order: 0, position: { x: 0, y: 0 } },
],
});
expect(texts(map, 'r')).toEqual(['B', 'A']);
expect(texts(map, 'a')).toEqual(['A2', 'A1']);
});
});

View File

@@ -0,0 +1,255 @@
import { describe, expect, it } from 'vitest';
import { MAP_FORMAT_VERSION, type MindMap } from '@/types/map';
import { MapFormatError, normalizeMapDocument, serializeMap } from '@/services/mapSerialization';
/** Document v4 minimal : deux branches opposees et une relation entre feuilles. */
function documentV4(): Record<string, unknown> {
return {
version: 4,
id: 'map-1',
name: 'Infrastructure',
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-02T00:00:00.000Z',
nodes: [
{ id: 'root', type: 'root', text: 'Infrastructure', position: { x: 0, y: 0 }, parentId: null },
{
id: 'reseau',
type: 'standard',
text: 'Reseau',
position: { x: 240, y: 0 },
parentId: 'root',
order: 0,
side: 'right',
},
{
id: 'firewall',
type: 'standard',
text: 'Firewall',
position: { x: 480, y: 0 },
parentId: 'reseau',
order: 0,
},
{
id: 'services',
type: 'standard',
text: 'Services',
position: { x: -240, y: 0 },
parentId: 'root',
order: 1,
side: 'left',
},
{
id: 'web',
type: 'standard',
text: 'Web',
position: { x: -480, y: 0 },
parentId: 'services',
order: 0,
},
],
edges: [],
relations: [
{
id: 'r-1',
sourceId: 'firewall',
targetId: 'web',
label: 'protege',
style: 'bezier',
color: '#a855f7',
width: 2,
dashed: true,
markerEnd: 'arrow',
},
],
viewport: { x: 0, y: 0, zoom: 1 },
settings: { theme: 'default', layout: 'mindmap-both', branchStyle: 'bezier', showGrid: true },
};
}
describe('lecture d un document v4', () => {
it('conserve les cotes et les relations', () => {
const map = normalizeMapDocument(documentV4(), { source: 'file' });
expect(map.version).toBe(MAP_FORMAT_VERSION);
const sides = new Map(map.nodes.map((node) => [node.id, node.side]));
expect(sides.get('reseau')).toBe('right');
expect(sides.get('services')).toBe('left');
expect(map.relations).toHaveLength(1);
expect(map.relations[0]?.label).toBe('protege');
expect(map.relations[0]?.dashed).toBe(true);
expect(map.relations[0]?.markerEnd).toBe('arrow');
expect(map.settings.layout).toBe('mindmap-both');
});
it('ne modifie pas le document fourni', () => {
const raw = documentV4();
const before = JSON.stringify(raw);
normalizeMapDocument(raw, { source: 'file' });
expect(JSON.stringify(raw)).toBe(before);
});
it('est idempotent : renormaliser ne change plus rien', () => {
const once = normalizeMapDocument(documentV4(), { source: 'file' });
const twice = normalizeMapDocument(JSON.parse(JSON.stringify(once)), { source: 'file' });
expect(twice.nodes).toEqual(once.nodes);
expect(twice.relations).toEqual(once.relations);
});
it('survit a un aller-retour complet', () => {
const map = normalizeMapDocument(documentV4(), { source: 'file' });
const again = normalizeMapDocument(JSON.parse(serializeMap(map)), { source: 'file' });
expect(again.relations).toEqual(map.relations);
expect(again.nodes.map((node) => [node.id, node.side])).toEqual(
map.nodes.map((node) => [node.id, node.side]),
);
});
});
describe('relations incoherentes', () => {
it('retire une relation dont la source n existe pas', () => {
const raw = documentV4();
(raw.relations as Array<Record<string, unknown>>)[0]!.sourceId = 'fantome';
expect(normalizeMapDocument(raw, { source: 'file' }).relations).toHaveLength(0);
});
it('retire une relation dont la cible n existe pas', () => {
const raw = documentV4();
(raw.relations as Array<Record<string, unknown>>)[0]!.targetId = 'fantome';
expect(normalizeMapDocument(raw, { source: 'file' }).relations).toHaveLength(0);
});
it('retire une relation bouclant sur elle-meme', () => {
const raw = documentV4();
(raw.relations as Array<Record<string, unknown>>)[0]!.targetId = 'firewall';
expect(normalizeMapDocument(raw, { source: 'file' }).relations).toHaveLength(0);
});
it('ne garde qu une relation par couple source / cible', () => {
const raw = documentV4();
const relations = raw.relations as Array<Record<string, unknown>>;
relations.push({ ...relations[0], id: 'r-2' });
expect(normalizeMapDocument(raw, { source: 'file' }).relations).toHaveLength(1);
});
it('ignore les champs de style invalides sans perdre la relation', () => {
const raw = documentV4();
const relation = (raw.relations as Array<Record<string, unknown>>)[0] as Record<string, unknown>;
relation.style = 'zigzag';
relation.color = 'javascript:alert(1)';
relation.markerEnd = 'etoile';
const parsed = normalizeMapDocument(raw, { source: 'file' }).relations[0];
expect(parsed?.style).toBeUndefined();
expect(parsed?.color).toBeUndefined();
expect(parsed?.markerEnd).toBeUndefined();
expect(parsed?.label).toBe('protege');
});
it('ignore un cote invalide', () => {
const raw = documentV4();
(raw.nodes as Array<Record<string, unknown>>)[1]!.side = 'haut';
const map = normalizeMapDocument(raw, { source: 'file' });
expect(map.nodes.find((node) => node.id === 'reseau')?.side).toBeUndefined();
});
it('refuse un document depassant la limite de relations', () => {
const raw = documentV4();
raw.relations = Array.from({ length: 30 }, (_item, index) => ({
id: `r-${index}`,
sourceId: 'firewall',
targetId: 'web',
}));
expect(() => normalizeMapDocument(raw, { source: 'file', maxNodes: 10 })).toThrow(
MapFormatError,
);
});
});
describe('migrations vers la v4', () => {
function legacyDocument(version: number): Record<string, unknown> {
return {
version,
id: `map-v${version}`,
name: `Carte v${version}`,
createdAt: '2025-01-01T00:00:00.000Z',
updatedAt: '2025-01-01T00:00:00.000Z',
nodes: [
{
id: 'root',
type: 'root',
text: 'Racine',
position: { x: 0, y: 0 },
parentId: null,
style: version === 1 ? { width: 220, backgroundColor: '#ffffff' } : undefined,
},
{
id: 'a',
type: 'standard',
text: 'A',
position: { x: 200, y: 0 },
parentId: 'root',
},
{
id: 'b',
type: 'standard',
text: 'B',
position: { x: 200, y: 80 },
parentId: 'root',
},
],
edges: [
{ id: 'e-root-a', source: 'root', target: 'a' },
{ id: 'e-root-b', source: 'root', target: 'b' },
],
viewport: { x: 0, y: 0, zoom: 1 },
settings: { theme: 'default', layout: 'mindmap-right', branchStyle: 'bezier' },
};
}
it('ouvre une carte v1 et la ramene au format courant', () => {
const map = normalizeMapDocument(legacyDocument(1), { source: 'file' });
expect(map.version).toBe(4);
expect(map.relations).toEqual([]);
// La largeur fixe de la v1 a rejoint layout.width.
const root = map.nodes.find((node) => node.id === 'root');
expect(root?.layout?.width).toBe(220);
expect(root?.layout?.widthMode).toBe('fixed');
expect(root?.style?.backgroundColor).toBe('#ffffff');
});
it('ouvre une carte v2 en materialisant l ordre puis la v4', () => {
const map = normalizeMapDocument(legacyDocument(2), { source: 'file' });
expect(map.version).toBe(4);
expect(map.nodes.find((node) => node.id === 'a')?.order).toBe(0);
expect(map.nodes.find((node) => node.id === 'b')?.order).toBe(1);
expect(map.relations).toEqual([]);
});
it('ouvre une carte v3 sans rien inventer', () => {
const map = normalizeMapDocument(legacyDocument(3), { source: 'file' });
expect(map.version).toBe(4);
expect(map.relations).toEqual([]);
// Aucun cote : la carte reste exactement telle qu'elle etait.
for (const node of map.nodes) expect(node.side).toBeUndefined();
});
it('accepte une carte historique lue depuis IndexedDB', () => {
const stored = legacyDocument(2) as unknown as MindMap;
const map = normalizeMapDocument(stored, { source: 'storage', allowEmpty: true });
expect(map.version).toBe(4);
expect(map.nodes).toHaveLength(3);
expect(map.relations).toEqual([]);
});
it('refuse un document plus recent que le format connu', () => {
const raw = { ...documentV4(), version: 99 };
expect(() => normalizeMapDocument(raw, { source: 'file' })).toThrow(MapFormatError);
});
it('accepte une carte v4 sans champ relations', () => {
const raw = documentV4();
delete raw.relations;
expect(normalizeMapDocument(raw, { source: 'file' }).relations).toEqual([]);
});
});

View File

@@ -8,6 +8,7 @@ import {
importMap, importMap,
listMaps, listMaps,
renameMap, renameMap,
saveMap,
seedSampleMapOnce, seedSampleMapOnce,
} from '@/services/mapRepository'; } from '@/services/mapRepository';
import { saveShareRecord } from '@/services/shareRepository'; import { saveShareRecord } from '@/services/shareRepository';
@@ -103,3 +104,49 @@ describe('stockage IndexedDB des cartes', () => {
expect((await getMap(map.id))?.name).toBe('Persistante'); expect((await getMap(map.id))?.name).toBe('Persistante');
}); });
}); });
describe('persistance de la mise en page', () => {
it('conserve taille, padding et alignement apres reouverture', async () => {
const map = await createMap('Mise en page');
const rootNodeId = map.nodes[0]?.id as string;
await saveMap({
...map,
nodes: map.nodes.map((node) =>
node.id === rootNodeId
? {
...node,
style: { textAlign: 'center', verticalAlign: 'middle', lineHeight: 1.6 },
layout: {
widthMode: 'fixed',
width: 280,
heightMode: 'fixed',
height: 120,
paddingX: 26,
paddingY: 16,
wrap: false,
},
iconStyle: { kind: 'builtin', position: 'top', size: 24 },
icon: 'server',
}
: node,
),
});
// Fermeture puis reouverture de la base, comme un redemarrage du navigateur.
db.close();
await db.open();
const stored = await getMap(map.id);
const node = stored?.nodes.find((candidate) => candidate.id === rootNodeId);
expect(node?.layout?.width).toBe(280);
expect(node?.layout?.height).toBe(120);
expect(node?.layout?.paddingX).toBe(26);
expect(node?.layout?.wrap).toBe(false);
expect(node?.style?.textAlign).toBe('center');
expect(node?.style?.verticalAlign).toBe('middle');
expect(node?.icon).toBe('server');
expect(node?.iconStyle?.position).toBe('top');
});
});

View File

@@ -3,6 +3,7 @@ import { type MindMap, type MapSummary } from '@/types/map';
import { cloneMap, createEmptyMap, createSampleMap } from '@/services/mapFactory'; import { cloneMap, createEmptyMap, createSampleMap } from '@/services/mapFactory';
import { createId } from '@/utils/id'; import { createId } from '@/utils/id';
import { deleteShareRecord, getShareRecord, listShareRecords } from '@/services/shareRepository'; import { deleteShareRecord, getShareRecord, listShareRecords } from '@/services/shareRepository';
import { normalizeMapDocument } from '@/services/mapSerialization';
/** Toutes les cartes locales, de la plus recemment modifiee a la plus ancienne. */ /** Toutes les cartes locales, de la plus recemment modifiee a la plus ancienne. */
export async function listMaps(): Promise<MapSummary[]> { export async function listMaps(): Promise<MapSummary[]> {
@@ -21,8 +22,17 @@ export async function listMaps(): Promise<MapSummary[]> {
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
} }
/**
* Lit une carte locale et la fait passer par le pipeline de normalisation.
*
* Les cartes enregistrees avant une evolution de format sont ainsi ramenees au
* modele courant a l'ouverture, sans etre reecrites en base : la migration
* n'est persistee que si l'utilisateur modifie la carte.
*/
export async function getMap(id: string): Promise<MindMap | undefined> { export async function getMap(id: string): Promise<MindMap | undefined> {
return db.maps.get(id); const stored = await db.maps.get(id);
if (!stored) return undefined;
return normalizeMapDocument(stored, { source: 'storage', allowEmpty: true });
} }
export async function saveMap(map: MindMap): Promise<void> { export async function saveMap(map: MindMap): Promise<void> {

View File

@@ -3,14 +3,14 @@ import { MAP_FORMAT_VERSION } from '@/types/map';
import { createSampleMap } from '@/services/mapFactory'; import { createSampleMap } from '@/services/mapFactory';
import { import {
MapFormatError, MapFormatError,
parseMapDocument, normalizeMapDocument,
serializeMap, serializeMap,
} from '@/services/mapSerialization'; } from '@/services/mapSerialization';
describe('serialisation des cartes', () => { describe('serialisation des cartes', () => {
it('effectue un aller-retour sans perte', () => { it('effectue un aller-retour sans perte', () => {
const original = createSampleMap(); const original = createSampleMap();
const restored = parseMapDocument(JSON.parse(serializeMap(original))); const restored = normalizeMapDocument(JSON.parse(serializeMap(original)));
expect(restored.name).toBe(original.name); expect(restored.name).toBe(original.name);
expect(restored.nodes).toHaveLength(original.nodes.length); expect(restored.nodes).toHaveLength(original.nodes.length);
@@ -20,25 +20,25 @@ describe('serialisation des cartes', () => {
}); });
it('rejette un contenu qui n est pas un objet', () => { it('rejette un contenu qui n est pas un objet', () => {
expect(() => parseMapDocument('pas une carte')).toThrow(MapFormatError); expect(() => normalizeMapDocument('pas une carte')).toThrow(MapFormatError);
expect(() => parseMapDocument(null)).toThrow(MapFormatError); expect(() => normalizeMapDocument(null)).toThrow(MapFormatError);
expect(() => parseMapDocument([1, 2, 3])).toThrow(MapFormatError); expect(() => normalizeMapDocument([1, 2, 3])).toThrow(MapFormatError);
}); });
it('rejette une carte sans version', () => { it('rejette une carte sans version', () => {
const map = createSampleMap() as unknown as Record<string, unknown>; const map = createSampleMap() as unknown as Record<string, unknown>;
delete map.version; delete map.version;
expect(() => parseMapDocument(map)).toThrow(/version/i); expect(() => normalizeMapDocument(map)).toThrow(/version/i);
}); });
it('rejette une version de format plus recente', () => { it('rejette une version de format plus recente', () => {
const map = { ...createSampleMap(), version: MAP_FORMAT_VERSION + 5 }; const map = { ...createSampleMap(), version: MAP_FORMAT_VERSION + 5 };
expect(() => parseMapDocument(map)).toThrow(/plus recente/i); expect(() => normalizeMapDocument(map)).toThrow(/plus recente/i);
}); });
it('rejette une carte sans noeud exploitable', () => { it('rejette une carte sans noeud exploitable', () => {
expect(() => parseMapDocument({ version: 1, id: 'x', nodes: [] })).toThrow(MapFormatError); expect(() => normalizeMapDocument({ version: 1, id: 'x', nodes: [] })).toThrow(MapFormatError);
expect(() => parseMapDocument({ version: 1, id: 'x', nodes: [{ text: 'sans id' }] })).toThrow( expect(() => normalizeMapDocument({ version: 1, id: 'x', nodes: [{ text: 'sans id' }] })).toThrow(
MapFormatError, MapFormatError,
); );
}); });
@@ -49,13 +49,13 @@ describe('serialisation des cartes', () => {
text: `Noeud ${index}`, text: `Noeud ${index}`,
parentId: index === 0 ? null : 'n0', parentId: index === 0 ? null : 'n0',
})); }));
expect(() => parseMapDocument({ version: 1, id: 'x', nodes }, { maxNodes: 5 })).toThrow( expect(() => normalizeMapDocument({ version: 1, id: 'x', nodes }, { maxNodes: 5 })).toThrow(
/limite/i, /limite/i,
); );
}); });
it('neutralise les URL dangereuses', () => { it('neutralise les URL dangereuses', () => {
const parsed = parseMapDocument({ const parsed = normalizeMapDocument({
version: 1, version: 1,
id: 'x', id: 'x',
name: 'Test', name: 'Test',
@@ -72,7 +72,7 @@ describe('serialisation des cartes', () => {
}); });
it('conserve le texte tel quel sans interpreter le HTML', () => { it('conserve le texte tel quel sans interpreter le HTML', () => {
const parsed = parseMapDocument({ const parsed = normalizeMapDocument({
version: 1, version: 1,
id: 'x', id: 'x',
nodes: [{ id: 'a', type: 'root', text: '<img src=x onerror=alert(1)>' }], nodes: [{ id: 'a', type: 'root', text: '<img src=x onerror=alert(1)>' }],
@@ -81,7 +81,7 @@ describe('serialisation des cartes', () => {
}); });
it('reconstruit la hierarchie a partir des liens', () => { it('reconstruit la hierarchie a partir des liens', () => {
const parsed = parseMapDocument({ const parsed = normalizeMapDocument({
version: 1, version: 1,
id: 'x', id: 'x',
nodes: [ nodes: [
@@ -97,7 +97,7 @@ describe('serialisation des cartes', () => {
}); });
it('ignore les liens pointant vers des noeuds inexistants', () => { it('ignore les liens pointant vers des noeuds inexistants', () => {
const parsed = parseMapDocument({ const parsed = normalizeMapDocument({
version: 1, version: 1,
id: 'x', id: 'x',
nodes: [{ id: 'a', type: 'root', text: 'Racine' }], nodes: [{ id: 'a', type: 'root', text: 'Racine' }],
@@ -107,7 +107,7 @@ describe('serialisation des cartes', () => {
}); });
it('ignore les styles invalides mais garde les styles valides', () => { it('ignore les styles invalides mais garde les styles valides', () => {
const parsed = parseMapDocument({ const parsed = normalizeMapDocument({
version: 1, version: 1,
id: 'x', id: 'x',
nodes: [ nodes: [

View File

@@ -2,18 +2,37 @@ import { APP_CONFIG } from '@/config/app';
import { import {
MAP_FORMAT_VERSION, MAP_FORMAT_VERSION,
type BranchStyle, type BranchStyle,
type FontFamilyId,
type IconPosition,
type LayoutKind, type LayoutKind,
type MapSettings, type MapSettings,
type MindMap, type MindMap,
type MindMapNode, type MindMapNode,
type MindMapRelation,
type NodeBranchStyle,
type NodeIconStyle,
type NodeKind, type NodeKind,
type NodeLayout,
type NodeShape, type NodeShape,
type NodeSide,
type NodeStyle, type NodeStyle,
type RelationMarker,
type ShadowStyle,
type SizeMode,
type TextAlign, type TextAlign,
type VerticalAlign,
} from '@/types/map'; } from '@/types/map';
import { LAYOUT_LIMITS, clampTo } from '@/features/editor/nodeStyleResolver';
import { SPACING_LIMITS } from '@/features/editor/layout';
import { DEFAULT_SETTINGS } from '@/services/mapFactory'; import { DEFAULT_SETTINGS } from '@/services/mapFactory';
import { createRelationId, pruneRelations } from '@/services/relations';
import { createId } from '@/utils/id'; import { createId } from '@/utils/id';
import { rebuildEdges, rebuildHierarchyFromEdges } from '@/utils/tree'; import {
flattenInOrder,
normalizeChildOrders,
rebuildEdges,
rebuildHierarchyFromEdges,
} from '@/utils/tree';
import { sanitizeUrl } from '@/utils/url'; import { sanitizeUrl } from '@/utils/url';
/** Erreur d'import lisible par l'utilisateur : aucun detail technique. */ /** Erreur d'import lisible par l'utilisateur : aucun detail technique. */
@@ -31,7 +50,16 @@ const MAX_NAME_LENGTH = 200;
const NODE_KINDS: NodeKind[] = ['root', 'standard', 'note', 'image', 'group', 'link', 'task']; const NODE_KINDS: NodeKind[] = ['root', 'standard', 'note', 'image', 'group', 'link', 'task'];
const NODE_SHAPES: NodeShape[] = ['rectangle', 'rounded', 'capsule', 'circle']; const NODE_SHAPES: NodeShape[] = ['rectangle', 'rounded', 'capsule', 'circle'];
const TEXT_ALIGNS: TextAlign[] = ['left', 'center', 'right']; const TEXT_ALIGNS: TextAlign[] = ['left', 'center', 'right'];
const VERTICAL_ALIGNS: VerticalAlign[] = ['top', 'middle', 'bottom'];
const SIZE_MODES: SizeMode[] = ['auto', 'fixed'];
const SHADOW_STYLES: ShadowStyle[] = ['theme', 'none', 'soft', 'strong'];
const FONT_FAMILIES: FontFamilyId[] = ['theme', 'sans', 'serif', 'mono', 'rounded'];
const ICON_POSITIONS: IconPosition[] = ['left', 'right', 'top'];
const ICON_KINDS = ['emoji', 'builtin'] as const;
const BRANCH_STYLES: BranchStyle[] = ['straight', 'curved', 'bezier']; const BRANCH_STYLES: BranchStyle[] = ['straight', 'curved', 'bezier'];
const NODE_SIDES: NodeSide[] = ['left', 'right'];
const RELATION_MARKERS: RelationMarker[] = ['none', 'arrow'];
const MAX_LABEL_LENGTH = 120;
const LAYOUTS: LayoutKind[] = [ const LAYOUTS: LayoutKind[] = [
'mindmap-right', 'mindmap-right',
'mindmap-left', 'mindmap-left',
@@ -42,9 +70,16 @@ const LAYOUTS: LayoutKind[] = [
'freeform', 'freeform',
]; ];
/** Serialisation du format natif .mmap (JSON UTF-8, lisible et diffable). */ /**
* Serialisation du format natif .mmap (JSON UTF-8, lisible et diffable).
*
* Les noeuds sont ecrits en parcours de profondeur, freres dans l'ordre : le
* fichier reste lisible a l'oeil, et un lecteur anterieur a la v3, qui ignore
* `order`, retrouve malgre tout le bon ordre via la position dans le tableau.
*/
export function serializeMap(map: MindMap): string { export function serializeMap(map: MindMap): string {
return JSON.stringify(map, null, 2); const nodes = flattenInOrder(normalizeChildOrders(map.nodes));
return JSON.stringify({ ...map, nodes, edges: rebuildEdges(nodes) }, null, 2);
} }
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
@@ -95,10 +130,107 @@ function parseStyle(value: unknown): NodeStyle | undefined {
if (typeof value.textAlign === 'string' && TEXT_ALIGNS.includes(value.textAlign as TextAlign)) { if (typeof value.textAlign === 'string' && TEXT_ALIGNS.includes(value.textAlign as TextAlign)) {
style.textAlign = value.textAlign as TextAlign; style.textAlign = value.textAlign as TextAlign;
} }
if (
typeof value.verticalAlign === 'string' &&
VERTICAL_ALIGNS.includes(value.verticalAlign as VerticalAlign)
) {
style.verticalAlign = value.verticalAlign as VerticalAlign;
}
if (
typeof value.fontFamily === 'string' &&
FONT_FAMILIES.includes(value.fontFamily as FontFamilyId)
) {
style.fontFamily = value.fontFamily as FontFamilyId;
}
if (typeof value.shadow === 'string' && SHADOW_STYLES.includes(value.shadow as ShadowStyle)) {
style.shadow = value.shadow as ShadowStyle;
}
if (typeof value.lineHeight === 'number' && Number.isFinite(value.lineHeight)) {
style.lineHeight = Math.min(
LAYOUT_LIMITS.lineHeight.max,
Math.max(LAYOUT_LIMITS.lineHeight.min, Math.round(value.lineHeight * 100) / 100),
);
}
return Object.keys(style).length > 0 ? style : undefined; return Object.keys(style).length > 0 ? style : undefined;
} }
/** Mise en page d'un noeud (v2). Chaque valeur est bornee. */
function parseLayout(value: unknown): NodeLayout | undefined {
if (!isRecord(value)) return undefined;
const layout: NodeLayout = {};
if (typeof value.widthMode === 'string' && SIZE_MODES.includes(value.widthMode as SizeMode)) {
layout.widthMode = value.widthMode as SizeMode;
}
if (typeof value.heightMode === 'string' && SIZE_MODES.includes(value.heightMode as SizeMode)) {
layout.heightMode = value.heightMode as SizeMode;
}
if (typeof value.width === 'number' && Number.isFinite(value.width)) {
layout.width = clampTo(value.width, LAYOUT_LIMITS.width);
}
if (typeof value.height === 'number' && Number.isFinite(value.height)) {
layout.height = clampTo(value.height, LAYOUT_LIMITS.height);
}
if (typeof value.paddingX === 'number' && Number.isFinite(value.paddingX)) {
layout.paddingX = clampTo(value.paddingX, LAYOUT_LIMITS.paddingX);
}
if (typeof value.paddingY === 'number' && Number.isFinite(value.paddingY)) {
layout.paddingY = clampTo(value.paddingY, LAYOUT_LIMITS.paddingY);
}
if (typeof value.maxTextWidth === 'number' && Number.isFinite(value.maxTextWidth)) {
layout.maxTextWidth = clampTo(value.maxTextWidth, LAYOUT_LIMITS.maxTextWidth);
}
if (typeof value.wrap === 'boolean') layout.wrap = value.wrap;
return Object.keys(layout).length > 0 ? layout : undefined;
}
function parseIconStyle(value: unknown): NodeIconStyle | undefined {
if (!isRecord(value)) return undefined;
const iconStyle: NodeIconStyle = {};
if (
typeof value.kind === 'string' &&
(ICON_KINDS as readonly string[]).includes(value.kind)
) {
iconStyle.kind = value.kind as NodeIconStyle['kind'];
}
if (
typeof value.position === 'string' &&
ICON_POSITIONS.includes(value.position as IconPosition)
) {
iconStyle.position = value.position as IconPosition;
}
if (typeof value.size === 'number' && Number.isFinite(value.size)) {
iconStyle.size = clampTo(value.size, LAYOUT_LIMITS.iconSize);
}
if (typeof value.gap === 'number' && Number.isFinite(value.gap)) {
iconStyle.gap = clampTo(value.gap, LAYOUT_LIMITS.iconGap);
}
return Object.keys(iconStyle).length > 0 ? iconStyle : undefined;
}
function parseBranch(value: unknown): NodeBranchStyle | undefined {
if (!isRecord(value)) return undefined;
const branch: NodeBranchStyle = {};
if (
typeof value.style === 'string' &&
BRANCH_STYLES.includes(value.style as BranchStyle)
) {
branch.style = value.style as BranchStyle;
}
if (typeof value.width === 'number' && Number.isFinite(value.width)) {
branch.width = clampTo(value.width, LAYOUT_LIMITS.branchWidth);
}
const color = asColor(value.color);
if (color) branch.color = color;
return Object.keys(branch).length > 0 ? branch : undefined;
}
function parseNode(value: unknown): MindMapNode | null { function parseNode(value: unknown): MindMapNode | null {
if (!isRecord(value)) return null; if (!isRecord(value)) return null;
@@ -122,11 +254,23 @@ function parseNode(value: unknown): MindMapNode | null {
parentId: asString(value.parentId, 128) ?? null, parentId: asString(value.parentId, 128) ?? null,
}; };
if (typeof value.order === 'number' && Number.isFinite(value.order)) {
node.order = Math.max(0, Math.round(value.order));
}
if (typeof value.side === 'string' && NODE_SIDES.includes(value.side as NodeSide)) {
node.side = value.side as NodeSide;
}
if (value.collapsed === true) node.collapsed = true; if (value.collapsed === true) node.collapsed = true;
const icon = asString(value.icon, 8); // 32 caracteres : suffisant pour un emoji compose ou un identifiant d'icone integree.
const icon = asString(value.icon, 32);
if (icon) node.icon = icon; if (icon) node.icon = icon;
const iconStyle = parseIconStyle(value.iconStyle);
if (iconStyle) node.iconStyle = iconStyle;
// Les URL sont assainies des l'import : aucun javascript: ne peut survivre. // Les URL sont assainies des l'import : aucun javascript: ne peut survivre.
const url = sanitizeUrl(asString(value.url, 2048)); const url = sanitizeUrl(asString(value.url, 2048));
if (url) node.url = url; if (url) node.url = url;
@@ -137,11 +281,65 @@ function parseNode(value: unknown): MindMapNode | null {
const style = parseStyle(value.style); const style = parseStyle(value.style);
if (style) node.style = style; if (style) node.style = style;
const layout = parseLayout(value.layout);
if (layout) node.layout = layout;
const branch = parseBranch(value.branch);
if (branch) node.branch = branch;
if (isRecord(value.meta)) node.meta = value.meta; if (isRecord(value.meta)) node.meta = value.meta;
return node; return node;
} }
/**
* Relation secondaire (v4).
*
* Validee comme le reste du document : identifiants bornes, couleur filtree,
* enumerations verifiees. Une relation incoherente est ignoree plutot que
* corrigee au jugement.
*/
function parseRelation(value: unknown): MindMapRelation | null {
if (!isRecord(value)) return null;
const sourceId = asString(value.sourceId, 128);
const targetId = asString(value.targetId, 128);
if (!sourceId || !targetId || sourceId === targetId) return null;
const relation: MindMapRelation = {
id: asString(value.id, 128) ?? createRelationId(),
sourceId,
targetId,
};
const label = asString(value.label, MAX_LABEL_LENGTH)?.trim();
if (label) relation.label = label;
if (typeof value.style === 'string' && BRANCH_STYLES.includes(value.style as BranchStyle)) {
relation.style = value.style as BranchStyle;
}
const color = asColor(value.color);
if (color) relation.color = color;
if (typeof value.width === 'number' && Number.isFinite(value.width)) {
relation.width = clampTo(value.width, LAYOUT_LIMITS.branchWidth);
}
if (typeof value.dashed === 'boolean') relation.dashed = value.dashed;
if (
typeof value.markerStart === 'string' &&
RELATION_MARKERS.includes(value.markerStart as RelationMarker)
) {
relation.markerStart = value.markerStart as RelationMarker;
}
if (
typeof value.markerEnd === 'string' &&
RELATION_MARKERS.includes(value.markerEnd as RelationMarker)
) {
relation.markerEnd = value.markerEnd as RelationMarker;
}
return relation;
}
function parseSettings(value: unknown): MapSettings { function parseSettings(value: unknown): MapSettings {
const settings: MapSettings = { ...DEFAULT_SETTINGS }; const settings: MapSettings = { ...DEFAULT_SETTINGS };
if (!isRecord(value)) return settings; if (!isRecord(value)) return settings;
@@ -157,35 +355,155 @@ function parseSettings(value: unknown): MapSettings {
settings.branchStyle = value.branchStyle as BranchStyle; settings.branchStyle = value.branchStyle as BranchStyle;
} }
if (typeof value.showGrid === 'boolean') settings.showGrid = value.showGrid; if (typeof value.showGrid === 'boolean') settings.showGrid = value.showGrid;
if (typeof value.snapToGrid === 'boolean') settings.snapToGrid = value.snapToGrid;
if (typeof value.alignmentGuides === 'boolean') settings.alignmentGuides = value.alignmentGuides;
if (typeof value.dragMovesBranch === 'boolean') settings.dragMovesBranch = value.dragMovesBranch;
if (typeof value.snapGrid === 'number' && Number.isFinite(value.snapGrid)) {
settings.snapGrid = Math.min(100, Math.max(2, Math.round(value.snapGrid)));
}
if (isRecord(value.layoutSpacing)) {
const raw = value.layoutSpacing;
const parentGap = typeof raw.parentGap === 'number' ? raw.parentGap : undefined;
const siblingGap = typeof raw.siblingGap === 'number' ? raw.siblingGap : undefined;
if (parentGap !== undefined && siblingGap !== undefined) {
settings.layoutSpacing = {
parentGap: clampTo(parentGap, SPACING_LIMITS.parentGap),
siblingGap: clampTo(siblingGap, SPACING_LIMITS.siblingGap),
};
}
}
return settings; return settings;
} }
/** /**
* Point d'extension des migrations de format. * Migrations de format, appliquees de la plus ancienne a la plus recente.
* Chaque version future ajoutera son etape ici, de la plus ancienne a la plus *
* recente, avant la normalisation finale. * v1 -> v2 : la largeur fixe vivait dans `style.width`. Elle rejoint
* `layout.width` avec un `widthMode` explicite. Les autres nouveautes de la v2
* sont facultatives : leur absence signifie "herite du theme", il n'y a donc
* rien a ecrire dans le document.
*/ */
function migrate(raw: Record<string, unknown>, version: number): Record<string, unknown> { function migrateV1ToV2(document: Record<string, unknown>): Record<string, unknown> {
const document = raw; if (!Array.isArray(document.nodes)) return { ...document, version: 2 };
const current = version;
// Exemple d'evolution future : const nodes = document.nodes.map((rawNode) => {
// if (current === 1) { document = migrateV1toV2(document); current = 2; } if (!isRecord(rawNode) || !isRecord(rawNode.style)) return rawNode;
const { width, ...styleRest } = rawNode.style;
if (typeof width !== 'number' || !Number.isFinite(width)) return rawNode;
const existingLayout = isRecord(rawNode.layout) ? rawNode.layout : {};
return {
...rawNode,
style: styleRest,
layout: { widthMode: 'fixed', width, ...existingLayout },
};
});
return { ...document, nodes, version: 2 };
}
/**
* v2 -> v3 : materialise l'ordre des freres depuis leur position dans le
* tableau `nodes`, ce qui reproduit exactement l'affichage de la v2.
*/
function migrateV2ToV3(document: Record<string, unknown>): Record<string, unknown> {
if (!Array.isArray(document.nodes)) return { ...document, version: 3 };
const counters = new Map<string, number>();
const nodes = document.nodes.map((rawNode) => {
if (!isRecord(rawNode)) return rawNode;
if (typeof rawNode.order === 'number') return rawNode;
const parentId = typeof rawNode.parentId === 'string' ? rawNode.parentId : '';
const next = counters.get(parentId) ?? 0;
counters.set(parentId, next + 1);
return { ...rawNode, order: next };
});
return { ...document, nodes, version: 3 };
}
/**
* v3 -> v4 : rien a reecrire.
*
* Une carte v3 n'a ni cote ni relation. Absence de cote signifie "a repartir
* automatiquement", et absence de relation signifie "aucune" : la carte
* s'affiche donc exactement comme avant tant que l'utilisateur ne demande pas
* la disposition bilaterale. La migration se contente d'estampiller la version.
*/
function migrateV3ToV4(document: Record<string, unknown>): Record<string, unknown> {
return { ...document, version: 4 };
}
function migrate(raw: Record<string, unknown>, version: number): Record<string, unknown> {
let document = raw;
let current = version;
if (current === 1) {
document = migrateV1ToV2(document);
current = 2;
}
if (current === 2) {
document = migrateV2ToV3(document);
current = 3;
}
if (current === 3) {
document = migrateV3ToV4(document);
current = 4;
}
if (current !== MAP_FORMAT_VERSION) { if (current !== MAP_FORMAT_VERSION) {
// Version intermediaire inconnue : lecture au mieux, sans perdre de donnee.
return { ...document, version: MAP_FORMAT_VERSION }; return { ...document, version: MAP_FORMAT_VERSION };
} }
return document; return document;
} }
/** Provenance d'un document : ne sert qu'a formuler les messages d'erreur. */
export type MapDocumentSource = 'file' | 'storage' | 'share';
export interface NormalizeOptions {
maxNodes?: number;
source?: MapDocumentSource;
/** /**
* Valide et normalise un document carte provenant d'un fichier ou du serveur. * Autorise un document sans noeud. Vrai pour les cartes deja enregistrees :
* Cette fonction ne fait qu'analyser des donnees : rien n'est jamais evalue, * l'utilisateur a le droit d'avoir vide sa carte, et la rejeter reviendrait a
* ni injecte en HTML. * la lui rendre inaccessible.
*/ */
export function parseMapDocument(raw: unknown, options: { maxNodes?: number } = {}): MindMap { allowEmpty?: boolean;
}
const INVALID_DOCUMENT_MESSAGE: Record<MapDocumentSource, string> = {
file: "Le fichier selectionne n'est pas une carte valide.",
storage: "Cette carte enregistree est illisible et n'a pas pu etre ouverte.",
share: "Cette carte partagee est illisible.",
};
/**
* Point d'entree unique des documents carte.
*
* Toute carte qui entre dans l'editeur passe par ici, quelle que soit sa
* provenance : fichier .mmap, IndexedDB ou partage serveur. Le pipeline est
* toujours le meme :
*
* source -> validation -> migration de version -> normalisation -> modele courant
*
* La normalisation est non destructive : le document d'origine n'est jamais
* modifie, et rien n'est reecrit en base. Une carte v1 restee en IndexedDB
* continue donc de s'ouvrir exactement comme avant, et n'est reecrite que si
* l'utilisateur la modifie.
*
* Cette fonction ne fait qu'analyser des donnees : rien n'est jamais evalue ni
* injecte en HTML.
*/
export function normalizeMapDocument(raw: unknown, options: NormalizeOptions = {}): MindMap {
const source = options.source ?? 'file';
if (!isRecord(raw)) { if (!isRecord(raw)) {
throw new MapFormatError("Le fichier selectionne n'est pas une carte valide."); throw new MapFormatError(INVALID_DOCUMENT_MESSAGE[source]);
} }
const version = raw.version; const version = raw.version;
@@ -205,7 +523,7 @@ export function parseMapDocument(raw: unknown, options: { maxNodes?: number } =
const document = migrate(raw, version); const document = migrate(raw, version);
if (!Array.isArray(document.nodes)) { if (!Array.isArray(document.nodes)) {
throw new MapFormatError("Le fichier selectionne n'est pas une carte valide (noeuds absents)."); throw new MapFormatError(`${INVALID_DOCUMENT_MESSAGE[source]} (noeuds absents)`);
} }
const maxNodes = options.maxNodes ?? APP_CONFIG.maxNodes; const maxNodes = options.maxNodes ?? APP_CONFIG.maxNodes;
@@ -228,7 +546,7 @@ export function parseMapDocument(raw: unknown, options: { maxNodes?: number } =
nodes.push(node); nodes.push(node);
} }
if (nodes.length === 0) { if (nodes.length === 0 && !options.allowEmpty) {
throw new MapFormatError('Cette carte ne contient aucun noeud exploitable.'); throw new MapFormatError('Cette carte ne contient aucun noeud exploitable.');
} }
@@ -249,25 +567,59 @@ export function parseMapDocument(raw: unknown, options: { maxNodes?: number } =
})); }));
// Au moins une racine explicite pour que l'editeur reste coherent. // Au moins une racine explicite pour que l'editeur reste coherent.
if (!linked.some((node) => node.type === 'root') && linked[0]) { if (linked.length > 0 && !linked.some((node) => node.type === 'root') && linked[0]) {
linked[0] = { ...linked[0], type: 'root', parentId: null }; linked[0] = { ...linked[0], type: 'root', parentId: null };
} }
const rawViewport = isRecord(document.viewport) ? document.viewport : {}; const rawViewport = isRecord(document.viewport) ? document.viewport : {};
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
// Ordre de fratrie dense et tableau range en profondeur : le document sortant
// est toujours dans la meme forme canonique, quelle que soit sa provenance.
const normalised = flattenInOrder(normalizeChildOrders(linked));
// Les relations sont une donnee propre : rien ne les deduit de la hierarchie,
// et rien dans la hierarchie ne doit dependre d'elles. On se contente de
// retirer celles dont une extremite n'existe plus.
const rawRelations = Array.isArray(document.relations) ? document.relations : [];
if (rawRelations.length > maxNodes * 2) {
throw new MapFormatError(
'Cette carte contient ' +
rawRelations.length +
' relations : la limite est de ' +
maxNodes * 2 +
'.',
);
}
const seenRelations = new Set<string>();
const relations = pruneRelations(
rawRelations
.map(parseRelation)
.filter((relation): relation is MindMapRelation => relation !== null)
.filter((relation) => {
// Un doublon exact serait invisible : le second trait se superposerait.
const key = `${relation.sourceId}>${relation.targetId}`;
if (seenRelations.has(key)) return false;
seenRelations.add(key);
return true;
}),
normalised,
);
return { return {
version: MAP_FORMAT_VERSION, version: MAP_FORMAT_VERSION,
id: asString(document.id, 128) ?? createId(), id: asString(document.id, 128) ?? createId(),
name: asString(document.name, MAX_NAME_LENGTH)?.trim() || 'Carte importee', name: asString(document.name, MAX_NAME_LENGTH)?.trim() || 'Carte importee',
createdAt: asString(document.createdAt, 40) ?? timestamp, createdAt: asString(document.createdAt, 40) ?? timestamp,
updatedAt: asString(document.updatedAt, 40) ?? timestamp, updatedAt: asString(document.updatedAt, 40) ?? timestamp,
nodes: linked, nodes: normalised,
edges: rebuildEdges(linked), edges: rebuildEdges(normalised),
relations,
viewport: { viewport: {
x: asFiniteNumber(rawViewport.x, 0), x: asFiniteNumber(rawViewport.x, 0),
y: asFiniteNumber(rawViewport.y, 0), y: asFiniteNumber(rawViewport.y, 0),
zoom: Math.min(4, Math.max(0.1, asFiniteNumber(rawViewport.zoom, 1))), zoom: Math.min(4, Math.max(0.02, asFiniteNumber(rawViewport.zoom, 1))),
}, },
settings: parseSettings(document.settings), settings: parseSettings(document.settings),
}; };
@@ -289,5 +641,5 @@ export async function readMapFile(
} catch { } catch {
throw new MapFormatError("Le fichier selectionne n'est pas une carte valide (JSON illisible)."); throw new MapFormatError("Le fichier selectionne n'est pas une carte valide (JSON illisible).");
} }
return parseMapDocument(raw); return normalizeMapDocument(raw, { source: 'file' });
} }

View File

@@ -0,0 +1,262 @@
import { describe, expect, it } from 'vitest';
import {
MarkdownError,
buildMapFromMarkdown,
exportMarkdown,
parseMarkdown,
} from '@/services/markdown';
import { createEmptyMap } from '@/services/mapFactory';
import { useEditorStore } from '@/stores/editorStore';
import { getOrderedChildren } from '@/utils/tree';
const SAMPLE = `# Infrastructure
## Reseau
- VLAN
- VPN
- Firewall
## Virtualisation
- Proxmox
- VM
- LXC
- Docker
`;
function texts(nodes: Array<{ text: string }>): string[] {
return nodes.map((node) => node.text);
}
describe('analyse Markdown', () => {
it('construit la hierarchie a partir des titres et des listes', () => {
const result = parseMarkdown(SAMPLE);
expect(result.title).toBe('Infrastructure');
expect(result.roots).toHaveLength(1);
const root = result.roots[0];
expect(texts(root?.children ?? [])).toEqual(['Reseau', 'Virtualisation']);
const reseau = root?.children[0];
expect(texts(reseau?.children ?? [])).toEqual(['VLAN', 'VPN', 'Firewall']);
const virtualisation = root?.children[1];
expect(texts(virtualisation?.children ?? [])).toEqual(['Proxmox', 'Docker']);
expect(texts(virtualisation?.children[0]?.children ?? [])).toEqual(['VM', 'LXC']);
expect(result.count).toBe(10);
});
it('gere trois niveaux de titres', () => {
const result = parseMarkdown('# A\n## B\n### C\n');
expect(result.roots[0]?.children[0]?.children[0]?.text).toBe('C');
});
it('accepte un niveau de titre saute', () => {
// Un H3 directement sous un H1 se rattache au titre ouvert le plus proche.
const result = parseMarkdown('# A\n### C\n');
expect(result.roots[0]?.children[0]?.text).toBe('C');
});
it('reprend un titre de niveau superieur apres un plus profond', () => {
const result = parseMarkdown('# A\n### C\n## B\n');
expect(texts(result.roots[0]?.children ?? [])).toEqual(['C', 'B']);
});
it('lit les listes numerotees', () => {
const result = parseMarkdown('# A\n1. un\n2. deux\n');
expect(texts(result.roots[0]?.children ?? [])).toEqual(['un', 'deux']);
});
it('accepte les puces * et +', () => {
const result = parseMarkdown('# A\n* un\n+ deux\n');
expect(texts(result.roots[0]?.children ?? [])).toEqual(['un', 'deux']);
});
it('accepte un document fait uniquement de listes', () => {
const result = parseMarkdown('- un\n - un.a\n- deux\n');
expect(texts(result.roots)).toEqual(['un', 'deux']);
expect(texts(result.roots[0]?.children ?? [])).toEqual(['un.a']);
expect(result.title).toBeUndefined();
});
it('conserve les caracteres Unicode', () => {
const result = parseMarkdown('# Sécurité réseau\n- Pare-feu «actif»\n- 日本語\n');
expect(result.roots[0]?.text).toBe('Sécurité réseau');
expect(texts(result.roots[0]?.children ?? [])).toEqual(['Pare-feu «actif»', '日本語']);
});
});
describe('paragraphes et blocs de code', () => {
it('met un paragraphe dans la note du titre au-dessus', () => {
const result = parseMarkdown(
'# A\n\n## VPN\n\nLe VPN permet aux utilisateurs distants\nd acceder aux ressources internes.\n',
);
const vpn = result.roots[0]?.children[0];
expect(vpn?.text).toBe('VPN');
expect(vpn?.note).toContain('Le VPN permet aux utilisateurs distants');
expect(vpn?.note).toContain('ressources internes');
});
it('met un bloc de code dans la note, sans creer de noeuds', () => {
const result = parseMarkdown('# A\n\n## Docker\n\n```bash\ndocker compose up -d\n```\n');
const docker = result.roots[0]?.children[0];
expect(docker?.note).toContain('docker compose up -d');
expect(docker?.children).toHaveLength(0);
expect(result.count).toBe(2);
});
it('n interprete jamais le contenu : la note reste du texte', () => {
const result = parseMarkdown('# A\n\n<script>alert(1)</script>\n');
expect(result.roots[0]?.note).toBe('<script>alert(1)</script>');
});
it('attache un paragraphe qui suit une liste au dernier element cree', () => {
const result = parseMarkdown('# A\n- un\n\nDetail de un.\n');
expect(result.roots[0]?.children[0]?.note).toBe('Detail de un.');
});
});
describe('documents refuses', () => {
it('refuse un document vide', () => {
expect(() => parseMarkdown('')).toThrow(MarkdownError);
});
it('refuse un document sans titre ni liste', () => {
expect(() => parseMarkdown('juste du texte\net encore du texte')).toThrow(MarkdownError);
});
it('signale la ligne exacte d une indentation incoherente', () => {
const text = '# A\n- un\n - trop profond\n - incoherent\n';
try {
parseMarkdown(text);
throw new Error('aurait du echouer');
} catch (error) {
expect(error).toBeInstanceOf(MarkdownError);
expect((error as MarkdownError).line).toBe(4);
expect((error as MarkdownError).message).toContain('Ligne 4');
}
});
it('refuse un document au-dela de la limite de noeuds', () => {
const many = Array.from({ length: 12 }, (_item, index) => `- item ${index}`).join('\n');
expect(() => parseMarkdown(`# A\n${many}`, 5)).toThrow(MarkdownError);
});
});
describe('import comme nouvelle carte', () => {
it('fait du titre H1 la racine et le nom de la carte', () => {
const map = buildMapFromMarkdown(SAMPLE, 'fichier');
expect(map.name).toBe('Infrastructure');
expect(map.nodes[0]?.text).toBe('Infrastructure');
expect(map.nodes).toHaveLength(10);
expect(map.relations).toEqual([]);
});
it('utilise le nom du fichier quand il n y a pas de H1 unique', () => {
const map = buildMapFromMarkdown('- un\n- deux\n', 'mes notes');
expect(map.name).toBe('mes notes');
expect(texts(getOrderedChildren(map.nodes, map.nodes[0]?.id as string))).toEqual([
'un',
'deux',
]);
});
it('reconstruit la hierarchie et positionne les noeuds', () => {
const map = buildMapFromMarkdown(SAMPLE, 'fichier');
const root = map.nodes[0];
const branches = getOrderedChildren(map.nodes, root?.id as string);
expect(texts(branches)).toEqual(['Reseau', 'Virtualisation']);
const proxmox = map.nodes.find((node) => node.text === 'Proxmox');
expect(texts(getOrderedChildren(map.nodes, proxmox?.id as string))).toEqual(['VM', 'LXC']);
// Les positions viennent de l'auto-layout : rien n'est empile a l'origine.
const distinct = new Set(map.nodes.map((node) => `${node.position.x}:${node.position.y}`));
expect(distinct.size).toBe(map.nodes.length);
});
});
describe('import comme sous-arbre', () => {
it('insere le contenu sous le noeud vise en une seule entree d historique', () => {
const store = useEditorStore.getState();
store.loadMap(createEmptyMap('Carte'));
const rootId = useEditorStore.getState().map?.nodes[0]?.id as string;
const parsed = parseMarkdown(SAMPLE);
const created = useEditorStore.getState().insertOutline(rootId, parsed.roots);
expect(created).toBe(10);
expect(useEditorStore.getState().map?.nodes).toHaveLength(11);
useEditorStore.getState().undo();
expect(useEditorStore.getState().map?.nodes).toHaveLength(1);
});
it('reporte les notes des paragraphes sur les noeuds crees', () => {
const store = useEditorStore.getState();
store.loadMap(createEmptyMap('Carte'));
const rootId = useEditorStore.getState().map?.nodes[0]?.id as string;
const parsed = parseMarkdown('## VPN\n\nAcces distant securise.\n');
useEditorStore.getState().insertOutline(rootId, parsed.roots);
const vpn = useEditorStore.getState().map?.nodes.find((node) => node.text === 'VPN');
expect(vpn?.note).toBe('Acces distant securise.');
});
});
describe('export Markdown', () => {
it('ecrit les trois premiers niveaux en titres et la suite en listes', () => {
const map = buildMapFromMarkdown(SAMPLE, 'fichier');
const markdown = exportMarkdown(map);
expect(markdown).toContain('# Infrastructure');
expect(markdown).toContain('## Reseau');
expect(markdown).toContain('### VLAN');
expect(markdown).toContain('### Proxmox');
// VM et LXC sont freres sous Proxmox : meme niveau de liste.
expect(markdown).toContain('- VM');
expect(markdown).toContain('- LXC');
// Un titre est toujours precede d'une ligne vide.
expect(markdown).toContain('\n\n### Docker');
});
it('ecrit les notes sous leur titre', () => {
const map = buildMapFromMarkdown('# A\n\n## VPN\n\nAcces distant.\n', 'fichier');
expect(exportMarkdown(map)).toContain('Acces distant.');
});
it('ne represente pas les relations secondaires', () => {
const map = buildMapFromMarkdown(SAMPLE, 'fichier');
const firewall = map.nodes.find((node) => node.text === 'Firewall') as { id: string };
const docker = map.nodes.find((node) => node.text === 'Docker') as { id: string };
const withRelation = {
...map,
relations: [
{ id: 'r-1', sourceId: firewall.id, targetId: docker.id, label: 'protege' },
],
};
const markdown = exportMarkdown(withRelation);
expect(markdown).not.toContain('protege');
expect(markdown).toContain('# Infrastructure');
});
it('conserve la hierarchie lors d un aller-retour', () => {
const map = buildMapFromMarkdown(SAMPLE, 'fichier');
const again = buildMapFromMarkdown(exportMarkdown(map), 'fichier');
const flatten = (source: typeof map, parentId: string | null, depth: number): string[] =>
getOrderedChildren(source.nodes, parentId).flatMap((node) => [
`${depth}:${node.text}`,
...flatten(source, node.id, depth + 1),
]);
expect(flatten(again, null, 0)).toEqual(flatten(map, null, 0));
});
it('respecte l ordre des freres', () => {
const map = buildMapFromMarkdown('# A\n- premier\n- deuxieme\n- troisieme\n', 'fichier');
const markdown = exportMarkdown(map);
expect(markdown.indexOf('premier')).toBeLessThan(markdown.indexOf('deuxieme'));
expect(markdown.indexOf('deuxieme')).toBeLessThan(markdown.indexOf('troisieme'));
});
});

View File

@@ -0,0 +1,319 @@
import { APP_CONFIG } from '@/config/app';
import { type MindMap, type MindMapNode } from '@/types/map';
import {
OutlineError,
isListItem,
parseOutline,
type OutlineNode,
} from '@/services/outlineParser';
import { computeLayout } from '@/features/editor/layout';
import { getTheme } from '@/features/editor/themes';
import { createEmptyMap, createNode } from '@/services/mapFactory';
import { getOrderedChildren, rebuildEdges } from '@/utils/tree';
/**
* Markdown <-> mind map.
*
* L'import ne cherche pas a etre un lecteur Markdown complet : il retient ce
* qui porte une hierarchie (titres et listes) et met le reste (paragraphes,
* blocs de code) dans la note du noeud courant. Rien n'est interprete comme du
* HTML, rien n'est execute : le texte reste du texte.
*
* Les listes sont deleguees a `parseOutline`, deja utilise par l'import
* d'outline : il n'y a qu'un seul analyseur d'indentation dans le projet.
*/
const MAX_NOTE_LENGTH = 20000;
const MAX_HEADING_DEPTH = 6;
export class MarkdownError extends Error {
readonly line: number;
constructor(message: string, line: number) {
super(message);
this.name = 'MarkdownError';
this.line = line;
}
}
export interface MarkdownResult {
roots: OutlineNode[];
/** Nombre total de noeuds produits. */
count: number;
/** Texte du premier titre de niveau 1, s'il existe. */
title?: string;
}
const HEADING = /^(#{1,6})\s+(.*)$/;
const FENCE = /^\s*(```|~~~)/;
function countNodes(nodes: OutlineNode[]): number {
let total = 0;
for (const node of nodes) total += 1 + countNodes(node.children);
return total;
}
/** Dernier noeud cree en profondeur : celui auquel rattacher la suite. */
function lastLeaf(nodes: OutlineNode[]): OutlineNode | null {
const last = nodes[nodes.length - 1];
if (!last) return null;
return lastLeaf(last.children) ?? last;
}
function appendNote(node: OutlineNode | null, block: string): void {
if (!node) return;
const text = block.trim();
if (!text) return;
const merged = node.note ? `${node.note}\n\n${text}` : text;
node.note = merged.slice(0, MAX_NOTE_LENGTH);
}
/**
* Analyse un document Markdown.
*
* Titres et listes construisent la hierarchie ; un titre de niveau saute est
* accepte et rattache au titre ouvert le plus proche, ce qui evite de rejeter
* un document par ailleurs parfaitement lisible.
*/
export function parseMarkdown(
text: string,
maxNodes: number = APP_CONFIG.maxNodes,
): MarkdownResult {
const lines = text.replace(/\r\n?/g, '\n').split('\n');
const roots: OutlineNode[] = [];
const headings: Array<{ level: number; node: OutlineNode }> = [];
let title: string | undefined;
let current: OutlineNode | null = null;
let paragraph: string[] = [];
let listBlock: Array<{ raw: string; line: number }> = [];
let fence: string | null = null;
let fenced: string[] = [];
const flushParagraph = (): void => {
if (paragraph.length === 0) return;
appendNote(current, paragraph.join('\n'));
paragraph = [];
};
const flushList = (): void => {
if (listBlock.length === 0) return;
const offset = (listBlock[0] as { line: number }).line - 1;
let parsed;
try {
parsed = parseOutline(listBlock.map((entry) => entry.raw).join('\n'), maxNodes);
} catch (error) {
if (error instanceof OutlineError) {
// On repositionne le numero de ligne sur le document complet.
throw new MarkdownError(
error.message.replace(/^Ligne \d+/, `Ligne ${error.line + offset}`),
error.line + offset,
);
}
throw error;
}
listBlock = [];
const parent = headings[headings.length - 1]?.node;
if (parent) parent.children.push(...parsed.roots);
else roots.push(...parsed.roots);
current = lastLeaf(parsed.roots) ?? current;
};
lines.forEach((raw, index) => {
const lineNumber = index + 1;
// Bloc de code : recopie telle quelle dans la note, jamais en noeuds.
if (fence !== null) {
fenced.push(raw);
if (raw.trimStart().startsWith(fence)) {
flushList();
appendNote(current, fenced.join('\n'));
fenced = [];
fence = null;
}
return;
}
const fenceMatch = FENCE.exec(raw);
if (fenceMatch) {
flushParagraph();
fence = fenceMatch[1] as string;
fenced = [raw];
return;
}
if (raw.trim().length === 0) {
flushParagraph();
return;
}
const heading = HEADING.exec(raw.trim());
if (heading) {
flushParagraph();
flushList();
const level = Math.min(MAX_HEADING_DEPTH, (heading[1] as string).length);
const label = (heading[2] as string).trim();
const node: OutlineNode = { text: label, children: [] };
while (headings.length > 0 && (headings[headings.length - 1] as { level: number }).level >= level) {
headings.pop();
}
const parent = headings[headings.length - 1]?.node;
if (parent) parent.children.push(node);
else roots.push(node);
headings.push({ level, node });
current = node;
if (level === 1 && !title) title = label;
return;
}
if (isListItem(raw)) {
flushParagraph();
listBlock.push({ raw, line: lineNumber });
return;
}
// Toute autre ligne est du texte : elle rejoint la note du noeud courant.
flushList();
paragraph.push(raw.trim());
});
if (fence !== null) appendNote(current, fenced.join('\n'));
flushList();
flushParagraph();
const count = countNodes(roots);
if (count === 0) {
throw new MarkdownError('Ce document Markdown ne contient aucun titre ni aucune liste.', 0);
}
if (count > maxNodes) {
throw new MarkdownError(
`Ce document produirait ${count} noeuds : la limite est de ${maxNodes}.`,
0,
);
}
return { roots, count, ...(title ? { title } : {}) };
}
const HEADING_DEPTH = 3;
/** Titre d'un noeud sur une seule ligne : un titre Markdown ne se coupe pas. */
function singleLine(text: string): string {
return text.replace(/\s*\n+\s*/g, ' ').trim();
}
function writeNode(
node: MindMapNode,
depth: number,
nodes: MindMapNode[],
out: string[],
): void {
const label = singleLine(node.text) || '(sans titre)';
if (depth < HEADING_DEPTH) {
out.push(`${'#'.repeat(depth + 1)} ${label}`, '');
} else {
out.push(`${' '.repeat(depth - HEADING_DEPTH)}- ${label}`);
}
if (node.note) {
// La note est ecrite sous son titre, separee par une ligne vide : elle
// revient au meme endroit lors d'un reimport.
if (depth >= HEADING_DEPTH) out.push('');
out.push(node.note.trim(), '');
}
for (const child of getOrderedChildren(nodes, node.id)) {
writeNode(child, depth + 1, nodes, out);
}
if (depth >= HEADING_DEPTH && node.note) out.push('');
}
/**
* Export Markdown : contenu et hierarchie uniquement.
*
* Ni positions, ni couleurs, ni dimensions, ni relations secondaires : ce
* format decrit ce que la carte dit, pas a quoi elle ressemble. Les trois
* premiers niveaux deviennent des titres, les suivants des listes imbriquees,
* ce qui redonne exactement la meme arborescence a la relecture.
*/
export function exportMarkdown(map: MindMap): string {
const out: string[] = [];
// `getOrderedChildren(nodes, null)` rend les racines dans l'ordre du document.
for (const root of getOrderedChildren(map.nodes, null)) {
writeNode(root, 0, map.nodes, out);
}
// Un titre est toujours precede d'une ligne vide : c'est ce qui separe une
// liste du titre suivant, et ce qui garde le fichier relisable tel quel.
const spaced: string[] = [];
for (const line of out) {
if (line.startsWith('#') && spaced.length > 0 && spaced[spaced.length - 1] !== '') {
spaced.push('');
}
spaced.push(line);
}
return `${spaced.join('\n').replace(/\n{3,}/g, '\n\n').trim()}\n`;
}
/** Vrai si la carte contient des relations que l'export Markdown ne peut pas rendre. */
export function hasUnexportableRelations(map: MindMap): boolean {
return map.relations.length > 0;
}
/**
* Cree une carte complete a partir d'un document Markdown.
*
* Si le document commence par un unique titre de niveau 1, il devient la racine
* et le nom de la carte. Sinon, une racine est creee avec le nom fourni (celui
* du fichier) : rien n'est invente au-dela.
*/
export function buildMapFromMarkdown(
text: string,
fallbackName: string,
maxNodes: number = APP_CONFIG.maxNodes,
): MindMap {
const parsed = parseMarkdown(text, maxNodes);
const single = parsed.roots.length === 1 ? (parsed.roots[0] as OutlineNode) : null;
const rootSpec: OutlineNode = single ?? {
text: fallbackName,
children: parsed.roots,
};
const map = createEmptyMap(rootSpec.text || fallbackName);
const root = map.nodes[0] as MindMapNode;
const nodes: MindMapNode[] = [
{ ...root, text: rootSpec.text || fallbackName, ...(rootSpec.note ? { note: rootSpec.note } : {}) },
];
const walk = (children: OutlineNode[], parentId: string): void => {
children.forEach((child, index) => {
const node = createNode({
text: child.text,
parentId,
order: index,
...(child.note ? { note: child.note } : {}),
position: { x: 0, y: 0 },
});
nodes.push(node);
walk(child.children, node.id);
});
};
walk(rootSpec.children, root.id);
const positions = computeLayout(nodes, map.settings.layout, {
theme: getTheme(map.settings.theme),
});
const placed = nodes.map((node) => ({
...node,
position: positions.get(node.id) ?? node.position,
}));
return { ...map, nodes: placed, edges: rebuildEdges(placed) };
}

View File

@@ -0,0 +1,131 @@
import { describe, expect, it } from 'vitest';
import {
OutlineError,
looksIndented,
parseOutline,
toFlatLines,
} from '@/services/outlineParser';
describe('lecture d une liste simple', () => {
it('produit un noeud par ligne', () => {
const result = parseOutline('VLAN\nVPN\nFirewall');
expect(result.count).toBe(3);
expect(result.depth).toBe(1);
expect(result.roots.map((node) => node.text)).toEqual(['VLAN', 'VPN', 'Firewall']);
expect(result.roots.every((node) => node.children.length === 0)).toBe(true);
});
it('ignore les lignes vides et les espaces superflus', () => {
const result = parseOutline('VLAN\n\n \nVPN\n\n');
expect(result.roots.map((node) => node.text)).toEqual(['VLAN', 'VPN']);
});
it('retire les puces et les numerotations', () => {
const result = parseOutline('- VLAN\n* VPN\n1. Firewall\n2) Switch');
expect(result.roots.map((node) => node.text)).toEqual(['VLAN', 'VPN', 'Firewall', 'Switch']);
});
it('refuse un texte sans ligne exploitable', () => {
expect(() => parseOutline(' \n\n')).toThrow(OutlineError);
});
it('applique la limite de noeuds', () => {
const texte = Array.from({ length: 12 }, (_unused, index) => `N${index}`).join('\n');
expect(() => parseOutline(texte, 5)).toThrow(/limite/i);
});
});
describe('lecture d une arborescence indentee', () => {
const OUTLINE = [
'Infrastructure',
' Reseau',
' VLAN',
' VPN',
' Firewall',
' Virtualisation',
' Proxmox',
' Docker',
' Services',
' GLPI',
' Grafana',
].join('\n');
it('reconstruit la hierarchie complete', () => {
const result = parseOutline(OUTLINE);
expect(result.count).toBe(11);
expect(result.depth).toBe(3);
expect(result.roots).toHaveLength(1);
const racine = result.roots[0];
expect(racine?.text).toBe('Infrastructure');
expect(racine?.children.map((node) => node.text)).toEqual([
'Reseau',
'Virtualisation',
'Services',
]);
expect(racine?.children[0]?.children.map((node) => node.text)).toEqual([
'VLAN',
'VPN',
'Firewall',
]);
expect(racine?.children[2]?.children).toHaveLength(2);
});
it('accepte les tabulations', () => {
const result = parseOutline('Reseau\n\tVLAN\n\tVPN\n\t\tSite A');
expect(result.roots[0]?.children.map((node) => node.text)).toEqual(['VLAN', 'VPN']);
expect(result.roots[0]?.children[1]?.children[0]?.text).toBe('Site A');
});
it('accepte plusieurs racines', () => {
const result = parseOutline('Reseau\n VLAN\nServices\n GLPI');
expect(result.roots.map((node) => node.text)).toEqual(['Reseau', 'Services']);
});
it('tolere un texte entierement indente', () => {
const result = parseOutline(' Reseau\n VLAN');
expect(result.roots[0]?.text).toBe('Reseau');
expect(result.roots[0]?.children[0]?.text).toBe('VLAN');
});
it('refuse une indentation incoherente', () => {
// "C" ne correspond a aucun niveau ouvert : ni 0, ni 4.
expect(() => parseOutline('A\n B\n C')).toThrow(/incoherente/i);
});
it('refuse une ligne moins indentee que la premiere', () => {
expect(() => parseOutline(' A\nB')).toThrow(/moins indentee/i);
});
it('refuse une arborescence trop profonde', () => {
const profond = Array.from({ length: 25 }, (_unused, index) =>
`${' '.repeat(index * 2)}N${index}`,
).join('\n');
expect(() => parseOutline(profond)).toThrow(/profonde/i);
});
it('signale la ligne fautive', () => {
try {
parseOutline('A\n B\n C');
expect.unreachable('une erreur etait attendue');
} catch (error) {
expect(error).toBeInstanceOf(OutlineError);
expect((error as OutlineError).line).toBe(3);
}
});
});
describe('detection du mode', () => {
it('reconnait un texte indente', () => {
expect(looksIndented('A\n B')).toBe(true);
expect(looksIndented('A\n\tB')).toBe(true);
expect(looksIndented('A\nB')).toBe(false);
});
it('extrait les lignes utiles', () => {
expect(toFlatLines('- A\n\n \n2. B\r\nC')).toEqual(['A', 'B', 'C']);
});
});

Some files were not shown because too many files have changed in this diff Show More