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:
131
frontend/src/services/mapFactory.ts
Normal file
131
frontend/src/services/mapFactory.ts
Normal 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 },
|
||||
};
|
||||
}
|
||||
105
frontend/src/services/mapRepository.test.ts
Normal file
105
frontend/src/services/mapRepository.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
98
frontend/src/services/mapRepository.ts
Normal file
98
frontend/src/services/mapRepository.ts
Normal 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;
|
||||
}
|
||||
127
frontend/src/services/mapSerialization.test.ts
Normal file
127
frontend/src/services/mapSerialization.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
293
frontend/src/services/mapSerialization.ts
Normal file
293
frontend/src/services/mapSerialization.ts
Normal 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);
|
||||
}
|
||||
164
frontend/src/services/shareApi.ts
Normal file
164
frontend/src/services/shareApi.ts
Normal 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');
|
||||
}
|
||||
22
frontend/src/services/shareRepository.ts
Normal file
22
frontend/src/services/shareRepository.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user