Compare commits
8 Commits
0b2e5e326d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 55e5b359a1 | |||
| 9ede2c5e68 | |||
| 49870dcd56 | |||
| e3c530348e | |||
| 2afbf637ce | |||
| a23a68629d | |||
| bf83292e65 | |||
| af6fd519c5 |
48
.env.example
48
.env.example
@@ -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=
|
||||||
|
|||||||
@@ -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
751
README.md
@@ -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
42
backend/alembic.ini
Normal 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
|
||||||
@@ -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"))]
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
373
backend/app/cli.py
Normal 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())
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
79
backend/app/db/migrations.py
Normal file
79
backend/app/db/migrations.py
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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}>"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
86
backend/app/services/client_ip.py
Normal file
86
backend/app/services/client_ip.py
Normal 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
|
||||||
@@ -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)
|
||||||
|
|||||||
147
backend/app/services/rate_limit.py
Normal file
147
backend/app/services/rate_limit.py
Normal 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()
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
69
backend/migrations/env.py
Normal 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()
|
||||||
25
backend/migrations/script.py.mako
Normal file
25
backend/migrations/script.py.mako
Normal 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"}
|
||||||
42
backend/migrations/versions/0001_initial_schema.py
Normal file
42
backend/migrations/versions/0001_initial_schema.py
Normal 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")
|
||||||
33
backend/migrations/versions/0002_share_expiry.py
Normal file
33
backend/migrations/versions/0002_share_expiry.py
Normal 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")
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
230
backend/tests/test_cli.py
Normal 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
|
||||||
135
backend/tests/test_client_ip.py
Normal file
135
backend/tests/test_client_ip.py
Normal 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"
|
||||||
153
backend/tests/test_configuration.py
Normal file
153
backend/tests/test_configuration.py
Normal 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()
|
||||||
147
backend/tests/test_expiry.py
Normal file
147
backend/tests/test_expiry.py
Normal 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
|
||||||
120
backend/tests/test_layout_payload.py
Normal file
120
backend/tests/test_layout_payload.py
Normal 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
|
||||||
192
backend/tests/test_link_privacy.py
Normal file
192
backend/tests/test_link_privacy.py
Normal 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", "")
|
||||||
128
backend/tests/test_migrations.py
Normal file
128
backend/tests/test_migrations.py
Normal 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
|
||||||
111
backend/tests/test_order_payload.py
Normal file
111
backend/tests/test_order_payload.py
Normal 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)
|
||||||
192
backend/tests/test_public_exposure.py
Normal file
192
backend/tests/test_public_exposure.py
Normal 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
|
||||||
149
backend/tests/test_relation_payload.py
Normal file
149
backend/tests/test_relation_payload.py
Normal 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
|
||||||
103
backend/tests/test_reverse_proxy.py
Normal file
103
backend/tests/test_reverse_proxy.py
Normal 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
|
||||||
@@ -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
15
docker-entrypoint.sh
Normal 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 "$@"
|
||||||
10
frontend/package-lock.json
generated
10
frontend/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
7
frontend/public/robots.txt
Normal file
7
frontend/public/robots.txt
Normal 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: /
|
||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
94
frontend/src/config/privacy.ts
Normal file
94
frontend/src/config/privacy.ts
Normal 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);
|
||||||
|
}
|
||||||
177
frontend/src/features/editor/AddChildrenDialog.tsx
Normal file
177
frontend/src/features/editor/AddChildrenDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
frontend/src/features/editor/AlignmentGuidesLayer.tsx
Normal file
50
frontend/src/features/editor/AlignmentGuidesLayer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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'espacement ne deplace rien : relancez l'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>
|
||||||
|
|||||||
183
frontend/src/features/editor/MarkdownImportDialog.tsx
Normal file
183
frontend/src/features/editor/MarkdownImportDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
124
frontend/src/features/editor/MoveToDialog.tsx
Normal file
124
frontend/src/features/editor/MoveToDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
311
frontend/src/features/editor/NodeContextMenu.tsx
Normal file
311
frontend/src/features/editor/NodeContextMenu.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
frontend/src/features/editor/NodeIconGlyph.tsx
Normal file
28
frontend/src/features/editor/NodeIconGlyph.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'icone et de style
|
||||||
|
ci-dessous s'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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
139
frontend/src/features/editor/RelationDialog.tsx
Normal file
139
frontend/src/features/editor/RelationDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
138
frontend/src/features/editor/RelationProperties.tsx
Normal file
138
frontend/src/features/editor/RelationProperties.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
112
frontend/src/features/editor/SelectionToolbar.tsx
Normal file
112
frontend/src/features/editor/SelectionToolbar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
frontend/src/features/editor/alignmentGuides.test.ts
Normal file
51
frontend/src/features/editor/alignmentGuides.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
119
frontend/src/features/editor/alignmentGuides.ts
Normal file
119
frontend/src/features/editor/alignmentGuides.ts
Normal 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;
|
||||||
|
}
|
||||||
221
frontend/src/features/editor/bilateral.test.ts
Normal file
221
frontend/src/features/editor/bilateral.test.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
152
frontend/src/features/editor/bilateral.ts
Normal file
152
frontend/src/features/editor/bilateral.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
90
frontend/src/features/editor/nodeIcons.ts
Normal file
90
frontend/src/features/editor/nodeIcons.ts
Normal 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 = [
|
||||||
|
'💡',
|
||||||
|
'✅',
|
||||||
|
'📌',
|
||||||
|
'⚠️',
|
||||||
|
'🔒',
|
||||||
|
'🌐',
|
||||||
|
'🖥️',
|
||||||
|
'📦',
|
||||||
|
'📄',
|
||||||
|
'🔗',
|
||||||
|
'⭐',
|
||||||
|
'🐳',
|
||||||
|
'🗄️',
|
||||||
|
'🚀',
|
||||||
|
];
|
||||||
110
frontend/src/features/editor/nodeStyleResolver.test.ts
Normal file
110
frontend/src/features/editor/nodeStyleResolver.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
187
frontend/src/features/editor/nodeStyleResolver.ts
Normal file
187
frontend/src/features/editor/nodeStyleResolver.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
56
frontend/src/features/editor/panel/PanelSection.tsx
Normal file
56
frontend/src/features/editor/panel/PanelSection.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
288
frontend/src/features/editor/panel/controls.tsx
Normal file
288
frontend/src/features/editor/panel/controls.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
frontend/src/features/editor/panel/sharedValue.ts
Normal file
35
frontend/src/features/editor/panel/sharedValue.ts
Normal 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';
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
58
frontend/src/features/privacy/PrivacyPage.tsx
Normal file
58
frontend/src/features/privacy/PrivacyPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'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'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'apres un
|
modifications faites ici ne sont visibles publiquement qu'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'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'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>
|
||||||
|
|||||||
@@ -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'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>
|
||||||
);
|
);
|
||||||
|
|||||||
68
frontend/src/features/templates/TemplateGallery.tsx
Normal file
68
frontend/src/features/templates/TemplateGallery.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
122
frontend/src/features/templates/templates.test.ts
Normal file
122
frontend/src/features/templates/templates.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
395
frontend/src/features/templates/templates.ts
Normal file
395
frontend/src/features/templates/templates.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
30
frontend/src/hooks/useRoute.test.ts
Normal file
30
frontend/src/hooks/useRoute.test.ts
Normal 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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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]) };
|
||||||
|
|||||||
@@ -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 },
|
||||||
};
|
};
|
||||||
|
|||||||
171
frontend/src/services/mapLayoutSerialization.test.ts
Normal file
171
frontend/src/services/mapLayoutSerialization.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
137
frontend/src/services/mapNormalization.test.ts
Normal file
137
frontend/src/services/mapNormalization.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
151
frontend/src/services/mapOrderSerialization.test.ts
Normal file
151
frontend/src/services/mapOrderSerialization.test.ts
Normal 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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
255
frontend/src/services/mapRelationSerialization.test.ts
Normal file
255
frontend/src/services/mapRelationSerialization.test.ts
Normal 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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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. */
|
||||||
* Valide et normalise un document carte provenant d'un fichier ou du serveur.
|
export type MapDocumentSource = 'file' | 'storage' | 'share';
|
||||||
* Cette fonction ne fait qu'analyser des donnees : rien n'est jamais evalue,
|
|
||||||
* ni injecte en HTML.
|
export interface NormalizeOptions {
|
||||||
|
maxNodes?: number;
|
||||||
|
source?: MapDocumentSource;
|
||||||
|
/**
|
||||||
|
* Autorise un document sans noeud. Vrai pour les cartes deja enregistrees :
|
||||||
|
* l'utilisateur a le droit d'avoir vide sa carte, et la rejeter reviendrait a
|
||||||
|
* 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' });
|
||||||
}
|
}
|
||||||
|
|||||||
262
frontend/src/services/markdown.test.ts
Normal file
262
frontend/src/services/markdown.test.ts
Normal 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'));
|
||||||
|
});
|
||||||
|
});
|
||||||
319
frontend/src/services/markdown.ts
Normal file
319
frontend/src/services/markdown.ts
Normal 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) };
|
||||||
|
}
|
||||||
131
frontend/src/services/outlineParser.test.ts
Normal file
131
frontend/src/services/outlineParser.test.ts
Normal 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
Reference in New Issue
Block a user