OpenMind v1 : cartes mentales libres, local-first et dockerisees

Premiere version fonctionnelle, demarrable avec `docker compose up -d --build`
sur http://localhost:8080.

Architecture
- Frontend React 18 / TypeScript strict / Vite / Tailwind, canvas @xyflow/react,
  etat Zustand, persistance IndexedDB via Dexie, auto-layout dagre.
- Backend FastAPI / SQLAlchemy 2 / Pydantic v2 / SQLite, qui sert aussi les
  fichiers statiques du frontend compile.
- Image Docker multi-stage, un seul service, bind mount ./data pour SQLite.

Local-first
Les cartes restent dans le navigateur. Le serveur ne recoit une carte que sur
demande explicite de partage.

Partage
Deux secrets distincts : share_token public en lecture seule dans l'URL, et
management_token conserve uniquement cote client, transmis en en-tete pour les
modifications et stocke hashe en base.

Fonctionnalites
Tableau de bord, editeur complet (undo/redo, copier/coller, raccourcis,
branches repliables, recherche), 9 themes, 4 dispositions automatiques,
panneau de proprietes, import/export .mmap, export PNG/SVG/PDF, impression,
page publique en lecture seule, PWA.

Qualite
25 tests pytest et 43 tests vitest au vert, ruff et ESLint sans erreur,
TypeScript strict sans erreur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-08 22:19:18 +02:00
commit 0b2e5e326d
113 changed files with 14576 additions and 0 deletions

View File

@@ -0,0 +1,131 @@
import { MAP_FORMAT_VERSION, type MindMap, type MindMapNode } from '@/types/map';
import { createId } from '@/utils/id';
import { rebuildEdges } from '@/utils/tree';
export const DEFAULT_SETTINGS = {
theme: 'default',
layout: 'mindmap-right',
branchStyle: 'bezier',
showGrid: true,
} as const;
function nowIso(): string {
return new Date().toISOString();
}
export function createNode(partial: Partial<MindMapNode> & { text: string }): MindMapNode {
return {
id: partial.id ?? createId(),
type: partial.type ?? 'standard',
text: partial.text,
position: partial.position ?? { x: 0, y: 0 },
parentId: partial.parentId ?? null,
...(partial.collapsed !== undefined ? { collapsed: partial.collapsed } : {}),
...(partial.icon ? { icon: partial.icon } : {}),
...(partial.url ? { url: partial.url } : {}),
...(partial.note ? { note: partial.note } : {}),
...(partial.style ? { style: partial.style } : {}),
};
}
export function createEmptyMap(name: string): MindMap {
const timestamp = nowIso();
const root = createNode({
type: 'root',
text: name || 'Nouvelle carte',
position: { x: 0, y: 0 },
});
return {
version: MAP_FORMAT_VERSION,
id: createId(),
name: name || 'Nouvelle carte',
createdAt: timestamp,
updatedAt: timestamp,
nodes: [root],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
settings: { ...DEFAULT_SETTINGS },
};
}
/** Copie profonde avec nouveaux identifiants : aucune collision avec l'original. */
export function cloneMap(map: MindMap, name?: string): MindMap {
const timestamp = nowIso();
const idMapping = new Map<string, string>();
for (const node of map.nodes) idMapping.set(node.id, createId());
const nodes = map.nodes.map((node) => ({
...node,
id: idMapping.get(node.id) as string,
parentId: node.parentId ? (idMapping.get(node.parentId) ?? null) : null,
position: { ...node.position },
...(node.style ? { style: { ...node.style } } : {}),
}));
return {
...map,
id: createId(),
name: name ?? map.name,
createdAt: timestamp,
updatedAt: timestamp,
nodes,
edges: rebuildEdges(nodes),
viewport: { ...map.viewport },
settings: { ...map.settings },
};
}
/**
* Carte d'exemple creee au premier lancement.
* Volontairement minimale : elle sert a comprendre l'outil en dix secondes.
*/
export function createSampleMap(): MindMap {
const timestamp = nowIso();
const root = createNode({
id: createId(),
type: 'root',
text: 'Mon projet',
position: { x: 0, y: 0 },
});
const branches: Array<{ text: string; icon: string; children?: string[] }> = [
{ text: 'Idees', icon: '💡', children: ['Brainstorming', 'Inspirations'] },
{ text: 'Taches', icon: '✅', children: ['A faire', 'En cours'] },
{ text: 'Documentation', icon: '📄' },
{ text: 'Ressources', icon: '🔗' },
];
const nodes: MindMapNode[] = [root];
branches.forEach((branch, index) => {
const node = createNode({
text: branch.text,
icon: branch.icon,
parentId: root.id,
position: { x: 260, y: index * 90 - 135 },
});
nodes.push(node);
branch.children?.forEach((childText, childIndex) => {
nodes.push(
createNode({
text: childText,
parentId: node.id,
position: { x: 500, y: index * 90 - 135 + childIndex * 46 - 23 },
}),
);
});
});
return {
version: MAP_FORMAT_VERSION,
id: createId(),
name: 'Exemple : Mon projet',
createdAt: timestamp,
updatedAt: timestamp,
nodes,
edges: rebuildEdges(nodes),
viewport: { x: 0, y: 0, zoom: 1 },
settings: { ...DEFAULT_SETTINGS },
};
}

View File

@@ -0,0 +1,105 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { db } from '@/db/database';
import {
createMap,
deleteMap,
duplicateMap,
getMap,
importMap,
listMaps,
renameMap,
seedSampleMapOnce,
} from '@/services/mapRepository';
import { saveShareRecord } from '@/services/shareRepository';
import { createSampleMap } from '@/services/mapFactory';
beforeEach(async () => {
await db.maps.clear();
await db.shares.clear();
await db.meta.clear();
});
describe('stockage IndexedDB des cartes', () => {
it('cree, relit et liste une carte', async () => {
const map = await createMap('Homelab');
const stored = await getMap(map.id);
expect(stored?.name).toBe('Homelab');
expect(stored?.nodes).toHaveLength(1);
const list = await listMaps();
expect(list).toHaveLength(1);
expect(list[0]?.nodeCount).toBe(1);
expect(list[0]?.isShared).toBe(false);
});
it('renomme une carte', async () => {
const map = await createMap('Avant');
await renameMap(map.id, 'Apres');
expect((await getMap(map.id))?.name).toBe('Apres');
});
it('duplique une carte avec de nouveaux identifiants', async () => {
const map = await createMap('Original');
const copy = await duplicateMap(map.id);
expect(copy?.id).not.toBe(map.id);
expect(copy?.name).toBe('Original (copie)');
expect(copy?.nodes[0]?.id).not.toBe(map.nodes[0]?.id);
expect(await listMaps()).toHaveLength(2);
});
it('supprime une carte et sa reference de partage locale', async () => {
const map = await createMap('A supprimer');
await saveShareRecord({
mapId: map.id,
shareToken: 'public-token',
managementToken: 'secret-token',
sharePath: '/s/public-token',
title: map.name,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
expect((await listMaps())[0]?.isShared).toBe(true);
await deleteMap(map.id);
expect(await getMap(map.id)).toBeUndefined();
expect(await db.shares.get(map.id)).toBeUndefined();
});
it('importe une carte sous un nouvel identifiant', async () => {
const source = createSampleMap();
const first = await importMap(source);
const second = await importMap(source);
expect(first.id).not.toBe(source.id);
expect(second.id).not.toBe(first.id);
expect(await listMaps()).toHaveLength(2);
});
it('ajoute la carte d exemple une seule fois', async () => {
await seedSampleMapOnce();
expect(await listMaps()).toHaveLength(1);
await seedSampleMapOnce();
expect(await listMaps()).toHaveLength(1);
});
it('n ajoute pas d exemple si des cartes existent deja', async () => {
await createMap('Deja la');
await seedSampleMapOnce();
const list = await listMaps();
expect(list).toHaveLength(1);
expect(list[0]?.name).toBe('Deja la');
});
it('conserve les cartes apres reouverture de la base', async () => {
const map = await createMap('Persistante');
db.close();
await db.open();
expect((await getMap(map.id))?.name).toBe('Persistante');
});
});

View File

@@ -0,0 +1,98 @@
import { db, META_KEYS } from '@/db/database';
import { type MindMap, type MapSummary } from '@/types/map';
import { cloneMap, createEmptyMap, createSampleMap } from '@/services/mapFactory';
import { createId } from '@/utils/id';
import { deleteShareRecord, getShareRecord, listShareRecords } from '@/services/shareRepository';
/** Toutes les cartes locales, de la plus recemment modifiee a la plus ancienne. */
export async function listMaps(): Promise<MapSummary[]> {
const [maps, shares] = await Promise.all([db.maps.toArray(), listShareRecords()]);
const sharedIds = new Set(shares.map((share) => share.mapId));
return maps
.map((map) => ({
id: map.id,
name: map.name,
createdAt: map.createdAt,
updatedAt: map.updatedAt,
nodeCount: map.nodes.length,
isShared: sharedIds.has(map.id),
}))
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
}
export async function getMap(id: string): Promise<MindMap | undefined> {
return db.maps.get(id);
}
export async function saveMap(map: MindMap): Promise<void> {
await db.maps.put(map);
}
export async function createMap(name: string): Promise<MindMap> {
const map = createEmptyMap(name);
await db.maps.put(map);
return map;
}
export async function renameMap(id: string, name: string): Promise<void> {
const map = await db.maps.get(id);
if (!map) return;
await db.maps.put({ ...map, name, updatedAt: new Date().toISOString() });
}
export async function duplicateMap(id: string): Promise<MindMap | undefined> {
const map = await db.maps.get(id);
if (!map) return undefined;
const copy = cloneMap(map, `${map.name} (copie)`);
await db.maps.put(copy);
return copy;
}
/** Supprime la carte locale. Le partage serveur eventuel n'est pas touche ici. */
export async function deleteMap(id: string): Promise<void> {
await db.maps.delete(id);
await deleteShareRecord(id);
}
export async function importMap(map: MindMap): Promise<MindMap> {
// Un import cree toujours une nouvelle carte : jamais d'ecrasement silencieux.
const stored: MindMap = {
...map,
id: createId(),
updatedAt: new Date().toISOString(),
};
await db.maps.put(stored);
return stored;
}
export async function isShared(mapId: string): Promise<boolean> {
return (await getShareRecord(mapId)) !== undefined;
}
/**
* Ajoute une carte d'exemple au tout premier lancement, pour que l'outil soit
* comprehensible immediatement. Ne s'execute qu'une seule fois.
*
* La promesse est memorisee : plusieurs appels concurrents (StrictMode, montage
* simultane de composants) partagent la meme execution et attendent son
* resultat, afin que la liste des cartes ne soit jamais lue trop tot.
*/
let seedingPromise: Promise<void> | null = null;
async function runSeed(): Promise<void> {
const flag = await db.meta.get(META_KEYS.sampleSeeded);
if (flag) return;
const count = await db.maps.count();
if (count === 0) {
await db.maps.put(createSampleMap());
}
await db.meta.put({ key: META_KEYS.sampleSeeded, value: true });
}
export function seedSampleMapOnce(): Promise<void> {
seedingPromise ??= runSeed();
return seedingPromise;
}

View File

@@ -0,0 +1,127 @@
import { describe, expect, it } from 'vitest';
import { MAP_FORMAT_VERSION } from '@/types/map';
import { createSampleMap } from '@/services/mapFactory';
import {
MapFormatError,
parseMapDocument,
serializeMap,
} from '@/services/mapSerialization';
describe('serialisation des cartes', () => {
it('effectue un aller-retour sans perte', () => {
const original = createSampleMap();
const restored = parseMapDocument(JSON.parse(serializeMap(original)));
expect(restored.name).toBe(original.name);
expect(restored.nodes).toHaveLength(original.nodes.length);
expect(restored.edges).toHaveLength(original.edges.length);
expect(restored.settings.theme).toBe(original.settings.theme);
expect(restored.nodes[0]?.type).toBe('root');
});
it('rejette un contenu qui n est pas un objet', () => {
expect(() => parseMapDocument('pas une carte')).toThrow(MapFormatError);
expect(() => parseMapDocument(null)).toThrow(MapFormatError);
expect(() => parseMapDocument([1, 2, 3])).toThrow(MapFormatError);
});
it('rejette une carte sans version', () => {
const map = createSampleMap() as unknown as Record<string, unknown>;
delete map.version;
expect(() => parseMapDocument(map)).toThrow(/version/i);
});
it('rejette une version de format plus recente', () => {
const map = { ...createSampleMap(), version: MAP_FORMAT_VERSION + 5 };
expect(() => parseMapDocument(map)).toThrow(/plus recente/i);
});
it('rejette une carte sans noeud exploitable', () => {
expect(() => parseMapDocument({ version: 1, id: 'x', nodes: [] })).toThrow(MapFormatError);
expect(() => parseMapDocument({ version: 1, id: 'x', nodes: [{ text: 'sans id' }] })).toThrow(
MapFormatError,
);
});
it('applique la limite de noeuds', () => {
const nodes = Array.from({ length: 12 }, (_, index) => ({
id: `n${index}`,
text: `Noeud ${index}`,
parentId: index === 0 ? null : 'n0',
}));
expect(() => parseMapDocument({ version: 1, id: 'x', nodes }, { maxNodes: 5 })).toThrow(
/limite/i,
);
});
it('neutralise les URL dangereuses', () => {
const parsed = parseMapDocument({
version: 1,
id: 'x',
name: 'Test',
nodes: [
{ id: 'a', type: 'root', text: 'Racine', url: 'javascript:alert(1)' },
{ id: 'b', text: 'Lien', parentId: 'a', url: 'exemple.fr' },
{ id: 'c', text: 'Data', parentId: 'a', url: 'data:text/html,<script>' },
],
});
expect(parsed.nodes[0]?.url).toBeUndefined();
expect(parsed.nodes[1]?.url).toBe('https://exemple.fr/');
expect(parsed.nodes[2]?.url).toBeUndefined();
});
it('conserve le texte tel quel sans interpreter le HTML', () => {
const parsed = parseMapDocument({
version: 1,
id: 'x',
nodes: [{ id: 'a', type: 'root', text: '<img src=x onerror=alert(1)>' }],
});
expect(parsed.nodes[0]?.text).toBe('<img src=x onerror=alert(1)>');
});
it('reconstruit la hierarchie a partir des liens', () => {
const parsed = parseMapDocument({
version: 1,
id: 'x',
nodes: [
{ id: 'a', type: 'root', text: 'Racine' },
{ id: 'b', text: 'Enfant' },
],
edges: [{ id: 'e1', source: 'a', target: 'b' }],
});
expect(parsed.nodes.find((node) => node.id === 'b')?.parentId).toBe('a');
expect(parsed.edges).toHaveLength(1);
expect(parsed.edges[0]?.source).toBe('a');
});
it('ignore les liens pointant vers des noeuds inexistants', () => {
const parsed = parseMapDocument({
version: 1,
id: 'x',
nodes: [{ id: 'a', type: 'root', text: 'Racine' }],
edges: [{ id: 'e1', source: 'a', target: 'fantome' }],
});
expect(parsed.edges).toHaveLength(0);
});
it('ignore les styles invalides mais garde les styles valides', () => {
const parsed = parseMapDocument({
version: 1,
id: 'x',
nodes: [
{
id: 'a',
type: 'root',
text: 'Racine',
style: { backgroundColor: 'url(javascript:1)', textColor: '#ff0000', shape: 'triangle' },
},
],
});
expect(parsed.nodes[0]?.style?.backgroundColor).toBeUndefined();
expect(parsed.nodes[0]?.style?.textColor).toBe('#ff0000');
expect(parsed.nodes[0]?.style?.shape).toBeUndefined();
});
});

View File

@@ -0,0 +1,293 @@
import { APP_CONFIG } from '@/config/app';
import {
MAP_FORMAT_VERSION,
type BranchStyle,
type LayoutKind,
type MapSettings,
type MindMap,
type MindMapNode,
type NodeKind,
type NodeShape,
type NodeStyle,
type TextAlign,
} from '@/types/map';
import { DEFAULT_SETTINGS } from '@/services/mapFactory';
import { createId } from '@/utils/id';
import { rebuildEdges, rebuildHierarchyFromEdges } from '@/utils/tree';
import { sanitizeUrl } from '@/utils/url';
/** Erreur d'import lisible par l'utilisateur : aucun detail technique. */
export class MapFormatError extends Error {
constructor(message: string) {
super(message);
this.name = 'MapFormatError';
}
}
const MAX_TEXT_LENGTH = 5000;
const MAX_NOTE_LENGTH = 20000;
const MAX_NAME_LENGTH = 200;
const NODE_KINDS: NodeKind[] = ['root', 'standard', 'note', 'image', 'group', 'link', 'task'];
const NODE_SHAPES: NodeShape[] = ['rectangle', 'rounded', 'capsule', 'circle'];
const TEXT_ALIGNS: TextAlign[] = ['left', 'center', 'right'];
const BRANCH_STYLES: BranchStyle[] = ['straight', 'curved', 'bezier'];
const LAYOUTS: LayoutKind[] = [
'mindmap-right',
'mindmap-left',
'tree-horizontal',
'tree-vertical',
'mindmap-both',
'organization',
'freeform',
];
/** Serialisation du format natif .mmap (JSON UTF-8, lisible et diffable). */
export function serializeMap(map: MindMap): string {
return JSON.stringify(map, null, 2);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function asString(value: unknown, maxLength: number): string | undefined {
if (typeof value !== 'string') return undefined;
return value.slice(0, maxLength);
}
function asFiniteNumber(value: unknown, fallback: number): number {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
}
/** Une couleur CSS acceptee : hex, rgb(a) ou nom simple. Rien d'autre. */
function asColor(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim().slice(0, 40);
if (/^#[0-9a-fA-F]{3,8}$/.test(trimmed)) return trimmed;
if (/^rgba?\(\s*[\d.\s,%/]+\)$/.test(trimmed)) return trimmed;
if (/^[a-zA-Z]{3,20}$/.test(trimmed)) return trimmed;
return undefined;
}
function parseStyle(value: unknown): NodeStyle | undefined {
if (!isRecord(value)) return undefined;
const style: NodeStyle = {};
const background = asColor(value.backgroundColor);
if (background) style.backgroundColor = background;
const text = asColor(value.textColor);
if (text) style.textColor = text;
const border = asColor(value.borderColor);
if (border) style.borderColor = border;
if (typeof value.shape === 'string' && NODE_SHAPES.includes(value.shape as NodeShape)) {
style.shape = value.shape as NodeShape;
}
if (typeof value.fontSize === 'number' && Number.isFinite(value.fontSize)) {
style.fontSize = Math.min(48, Math.max(10, Math.round(value.fontSize)));
}
if (typeof value.width === 'number' && Number.isFinite(value.width)) {
style.width = Math.min(600, Math.max(80, Math.round(value.width)));
}
if (typeof value.bold === 'boolean') style.bold = value.bold;
if (typeof value.italic === 'boolean') style.italic = value.italic;
if (typeof value.textAlign === 'string' && TEXT_ALIGNS.includes(value.textAlign as TextAlign)) {
style.textAlign = value.textAlign as TextAlign;
}
return Object.keys(style).length > 0 ? style : undefined;
}
function parseNode(value: unknown): MindMapNode | null {
if (!isRecord(value)) return null;
const id = asString(value.id, 128);
if (!id) return null;
const type =
typeof value.type === 'string' && NODE_KINDS.includes(value.type as NodeKind)
? (value.type as NodeKind)
: 'standard';
const rawPosition = isRecord(value.position) ? value.position : {};
const node: MindMapNode = {
id,
type,
text: asString(value.text, MAX_TEXT_LENGTH) ?? '',
position: {
x: asFiniteNumber(rawPosition.x, 0),
y: asFiniteNumber(rawPosition.y, 0),
},
parentId: asString(value.parentId, 128) ?? null,
};
if (value.collapsed === true) node.collapsed = true;
const icon = asString(value.icon, 8);
if (icon) node.icon = icon;
// Les URL sont assainies des l'import : aucun javascript: ne peut survivre.
const url = sanitizeUrl(asString(value.url, 2048));
if (url) node.url = url;
const note = asString(value.note, MAX_NOTE_LENGTH);
if (note) node.note = note;
const style = parseStyle(value.style);
if (style) node.style = style;
if (isRecord(value.meta)) node.meta = value.meta;
return node;
}
function parseSettings(value: unknown): MapSettings {
const settings: MapSettings = { ...DEFAULT_SETTINGS };
if (!isRecord(value)) return settings;
if (typeof value.theme === 'string') settings.theme = value.theme.slice(0, 40);
if (typeof value.layout === 'string' && LAYOUTS.includes(value.layout as LayoutKind)) {
settings.layout = value.layout as LayoutKind;
}
if (
typeof value.branchStyle === 'string' &&
BRANCH_STYLES.includes(value.branchStyle as BranchStyle)
) {
settings.branchStyle = value.branchStyle as BranchStyle;
}
if (typeof value.showGrid === 'boolean') settings.showGrid = value.showGrid;
return settings;
}
/**
* Point d'extension des migrations de format.
* Chaque version future ajoutera son etape ici, de la plus ancienne a la plus
* recente, avant la normalisation finale.
*/
function migrate(raw: Record<string, unknown>, version: number): Record<string, unknown> {
const document = raw;
const current = version;
// Exemple d'evolution future :
// if (current === 1) { document = migrateV1toV2(document); current = 2; }
if (current !== MAP_FORMAT_VERSION) {
return { ...document, version: MAP_FORMAT_VERSION };
}
return document;
}
/**
* Valide et normalise un document carte provenant d'un fichier ou du serveur.
* Cette fonction ne fait qu'analyser des donnees : rien n'est jamais evalue,
* ni injecte en HTML.
*/
export function parseMapDocument(raw: unknown, options: { maxNodes?: number } = {}): MindMap {
if (!isRecord(raw)) {
throw new MapFormatError("Le fichier selectionne n'est pas une carte valide.");
}
const version = raw.version;
if (typeof version !== 'number' || !Number.isFinite(version) || version < 1) {
throw new MapFormatError(
"Ce fichier ne precise aucune version de format : il n'est pas reconnu comme une carte.",
);
}
if (version > MAP_FORMAT_VERSION) {
throw new MapFormatError(
'Cette carte a ete creee avec une version plus recente du format (v' +
version +
"). Mettez a jour l'application pour l'ouvrir.",
);
}
const document = migrate(raw, version);
if (!Array.isArray(document.nodes)) {
throw new MapFormatError("Le fichier selectionne n'est pas une carte valide (noeuds absents).");
}
const maxNodes = options.maxNodes ?? APP_CONFIG.maxNodes;
if (document.nodes.length > maxNodes) {
throw new MapFormatError(
'Cette carte contient ' +
document.nodes.length +
' noeuds : la limite est de ' +
maxNodes +
'.',
);
}
const seen = new Set<string>();
const nodes: MindMapNode[] = [];
for (const rawNode of document.nodes) {
const node = parseNode(rawNode);
if (!node || seen.has(node.id)) continue;
seen.add(node.id);
nodes.push(node);
}
if (nodes.length === 0) {
throw new MapFormatError('Cette carte ne contient aucun noeud exploitable.');
}
const rawEdges = Array.isArray(document.edges) ? document.edges : [];
const parsedEdges = rawEdges
.filter(isRecord)
.map((edge) => ({
id: asString(edge.id, 256) ?? createId(),
source: asString(edge.source, 128) ?? '',
target: asString(edge.target, 128) ?? '',
}))
.filter((edge) => seen.has(edge.source) && seen.has(edge.target));
// La hierarchie fait autorite ; les liens en sont deduits.
const linked = rebuildHierarchyFromEdges(nodes, parsedEdges).map((node) => ({
...node,
parentId: node.parentId && seen.has(node.parentId) ? node.parentId : null,
}));
// Au moins une racine explicite pour que l'editeur reste coherent.
if (!linked.some((node) => node.type === 'root') && linked[0]) {
linked[0] = { ...linked[0], type: 'root', parentId: null };
}
const rawViewport = isRecord(document.viewport) ? document.viewport : {};
const timestamp = new Date().toISOString();
return {
version: MAP_FORMAT_VERSION,
id: asString(document.id, 128) ?? createId(),
name: asString(document.name, MAX_NAME_LENGTH)?.trim() || 'Carte importee',
createdAt: asString(document.createdAt, 40) ?? timestamp,
updatedAt: asString(document.updatedAt, 40) ?? timestamp,
nodes: linked,
edges: rebuildEdges(linked),
viewport: {
x: asFiniteNumber(rawViewport.x, 0),
y: asFiniteNumber(rawViewport.y, 0),
zoom: Math.min(4, Math.max(0.1, asFiniteNumber(rawViewport.zoom, 1))),
},
settings: parseSettings(document.settings),
};
}
/** Lit un fichier .mmap choisi par l'utilisateur et retourne la carte validee. */
export async function readMapFile(
file: File,
maxSizeMb: number = APP_CONFIG.maxMapSizeMb,
): Promise<MindMap> {
if (file.size > maxSizeMb * 1024 * 1024) {
throw new MapFormatError('Ce fichier depasse la taille maximale de ' + maxSizeMb + ' Mo.');
}
const text = await file.text();
let raw: unknown;
try {
raw = JSON.parse(text);
} catch {
throw new MapFormatError("Le fichier selectionne n'est pas une carte valide (JSON illisible).");
}
return parseMapDocument(raw);
}

View File

@@ -0,0 +1,164 @@
import { type MindMap } from '@/types/map';
import { parseMapDocument } from '@/services/mapSerialization';
/** Erreur reseau/serveur deja traduite en message utilisateur. */
export class ShareApiError extends Error {
readonly status: number;
constructor(message: string, status = 0) {
super(message);
this.name = 'ShareApiError';
this.status = status;
}
}
export interface ShareCreatedResponse {
shareToken: string;
managementToken: string;
sharePath: string;
title: string;
createdAt: string;
updatedAt: string;
}
export interface SharedMapResponse {
shareToken: string;
title: string;
map: MindMap;
createdAt: string;
updatedAt: string;
}
const MANAGEMENT_TOKEN_HEADER = 'X-Management-Token';
function messageForStatus(status: number, serverDetail?: string): string {
if (status === 404) return "Ce partage n'existe plus.";
if (status === 403) return "Vous n'avez pas le droit de modifier ce partage depuis ce navigateur.";
if (status === 413) return serverDetail || 'Cette carte est trop volumineuse pour etre partagee.';
if (status === 422) return serverDetail || "Cette carte n'a pas pu etre validee par le serveur.";
if (status >= 500) return 'Le serveur de partage est momentanement indisponible.';
return serverDetail || 'Le partage a echoue. Reessayez dans un instant.';
}
async function request<T>(url: string, init: RequestInit = {}): Promise<T> {
let response: Response;
try {
response = await fetch(url, {
...init,
headers: {
Accept: 'application/json',
...(init.body ? { 'Content-Type': 'application/json' } : {}),
...init.headers,
},
});
} catch {
// Hors ligne, ou backend injoignable : l'application locale reste utilisable.
throw new ShareApiError('Le serveur de partage est momentanement indisponible.', 0);
}
if (response.status === 204) {
return undefined as T;
}
let payload: unknown = null;
const text = await response.text();
if (text) {
try {
payload = JSON.parse(text);
} catch {
payload = null;
}
}
if (!response.ok) {
const detail =
payload && typeof payload === 'object' && 'detail' in payload
? String((payload as { detail: unknown }).detail)
: undefined;
throw new ShareApiError(messageForStatus(response.status, detail), response.status);
}
return payload as T;
}
interface RawShareCreated {
share_token: string;
management_token: string;
share_path: string;
title: string;
created_at: string;
updated_at: string;
}
interface RawSharePublic {
share_token: string;
title: string;
map_version: number;
map: unknown;
created_at: string;
updated_at: string;
}
/** Publie un snapshot de la carte. Appele uniquement sur action explicite. */
export async function createShare(map: MindMap, title: string): Promise<ShareCreatedResponse> {
const raw = await request<RawShareCreated>('/api/shares', {
method: 'POST',
body: JSON.stringify({ title, map }),
});
return {
shareToken: raw.share_token,
managementToken: raw.management_token,
sharePath: raw.share_path,
title: raw.title,
createdAt: raw.created_at,
updatedAt: raw.updated_at,
};
}
export async function updateShare(
shareToken: string,
managementToken: string,
map: MindMap,
title: string,
): Promise<{ updatedAt: string }> {
const raw = await request<{ updated_at: string }>(
`/api/shares/${encodeURIComponent(shareToken)}`,
{
method: 'PUT',
headers: { [MANAGEMENT_TOKEN_HEADER]: managementToken },
body: JSON.stringify({ title, map }),
},
);
return { updatedAt: raw.updated_at };
}
export async function deleteShare(shareToken: string, managementToken: string): Promise<void> {
await request<void>(`/api/shares/${encodeURIComponent(shareToken)}`, {
method: 'DELETE',
headers: { [MANAGEMENT_TOKEN_HEADER]: managementToken },
});
}
/** Lecture publique : le document recu est valide comme un fichier importe. */
export async function fetchSharedMap(shareToken: string): Promise<SharedMapResponse> {
const raw = await request<RawSharePublic>(`/api/shares/${encodeURIComponent(shareToken)}`);
return {
shareToken: raw.share_token,
title: raw.title,
map: parseMapDocument(raw.map),
createdAt: raw.created_at,
updatedAt: raw.updated_at,
};
}
export interface PublicAppConfig {
appName: string;
maxNodes: number;
maxMapSizeMb: number;
sharingEnabled: boolean;
}
export async function fetchAppConfig(): Promise<PublicAppConfig> {
return request<PublicAppConfig>('/api/config');
}

View File

@@ -0,0 +1,22 @@
import { db } from '@/db/database';
import { type ShareRecord } from '@/types/map';
/**
* Les jetons de partage vivent uniquement dans l'IndexedDB du proprietaire.
* Le `managementToken` n'est jamais place dans une URL ni exporte en .mmap.
*/
export async function getShareRecord(mapId: string): Promise<ShareRecord | undefined> {
return db.shares.get(mapId);
}
export async function listShareRecords(): Promise<ShareRecord[]> {
return db.shares.toArray();
}
export async function saveShareRecord(record: ShareRecord): Promise<void> {
await db.shares.put(record);
}
export async function deleteShareRecord(mapId: string): Promise<void> {
await db.shares.delete(mapId);
}