diff --git a/tests/static-toolboxes.test.mjs b/tests/static-toolboxes.test.mjs
index 2f7f18a..91fea27 100644
--- a/tests/static-toolboxes.test.mjs
+++ b/tests/static-toolboxes.test.mjs
@@ -174,6 +174,9 @@ test("toolbox module registry and modules expose expected behavior", async () =>
const textExchangeHook = await readFile("website/src/features/toolboxes/modules/useTextExchange.js", "utf8");
const countersModule = await readFile("website/src/features/toolboxes/modules/CountersModule.jsx", "utf8");
const textImport = await readFile("website/src/features/toolboxes/modules/textImport.js", "utf8");
+ const textImportShared = await readFile("website/src/features/toolboxes/modules/textImport/shared.js", "utf8");
+ const textImportPlanning = await readFile("website/src/features/toolboxes/modules/textImport/planning.js", "utf8");
+ const textImportTime = await readFile("website/src/features/toolboxes/modules/textImport/time.js", "utf8");
const imageViewer = await readFile("website/src/components/ImageViewer.jsx", "utf8");
assert.match(moduleMetadataRegistry, /export const TOOLBOX_MODULES/);
@@ -483,16 +486,18 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(timerParts, /export function CountdownControls/);
assert.match(timerParts, /export function CountdownList/);
assert.match(timerOptions, /export const ALERT_MIN_DELAY_MS/);
- assert.match(textImport, /export function parseColonImportLines/);
+ assert.match(textImport, /export \{ parseColonImportLines \}/);
assert.match(textImport, /export function exportModuleText/);
assert.match(textImport, /export function importModuleText/);
assert.match(textImport, /export function hasTextExchangeContent/);
- assert.match(textImport, /line\.indexOf\(":"\)/);
- assert.match(textImport, /exportCalendarText/);
- assert.match(textImport, /importCalendarText/);
- assert.match(textImport, /exportGoalTreeText/);
- assert.match(textImport, /importGoalTreeText/);
- assert.match(textImport, /CALENDAR_WEEKDAYS/);
+ assert.match(textImport, /TEXT_EXCHANGE_HANDLERS/);
+ assert.match(textImportShared, /export function parseColonImportLines/);
+ assert.match(textImportShared, /line\.indexOf\(":"\)/);
+ assert.match(textImportTime, /export function exportCalendarText/);
+ assert.match(textImportTime, /export function importCalendarText/);
+ assert.match(textImportPlanning, /export function exportGoalTreeText/);
+ assert.match(textImportPlanning, /export function importGoalTreeText/);
+ assert.match(textImportTime, /CALENDAR_WEEKDAYS/);
assert.match(imageViewer, /image-viewer-media/);
assert.match(imageViewer, /image-viewer-image-frame/);
assert.match(imageViewer, /image-viewer-marker/);
diff --git a/website/src/features/toolboxes/modules/textImport.js b/website/src/features/toolboxes/modules/textImport.js
index 5bf7570..429e954 100644
--- a/website/src/features/toolboxes/modules/textImport.js
+++ b/website/src/features/toolboxes/modules/textImport.js
@@ -1,1023 +1,122 @@
-// Rôle : parse et sérialise les formats texte/TSV d'échange des outils toolbox.
-import { CALENDAR_ALERT_MODES, CALENDAR_ALERT_OFFSETS, CALENDAR_COLORS, CALENDAR_MONTHLY_REPEAT_BY, CALENDAR_MONTH_REPEAT_MODES, minutesToTimeValue, timeValueToMinutes } from "./calendarUtils.js";
-import { cellAddress, columnIndexToName } from "./tableFormulaEngine.js";
-import { getCountdownTargetMs, getDailyTargetMs, getIntervalAnchorMs } from "./timerUtils.js";
+// Rôle : expose la façade publique d'import/export texte des outils toolbox.
+import {
+ exportCalculatorText,
+ exportChecklistText,
+ exportCountersText,
+ exportLinksText,
+ exportNotepadText,
+ exportTableTsv,
+ importCalculatorText,
+ importChecklistText,
+ importCountersText,
+ importLinksText,
+ importNotepadText,
+ importTableTsv
+} from "./textImport/basic.js";
+import { exportCombosText, importCombosText } from "./textImport/combos.js";
+import { exportEquipmentPlannerText, importEquipmentPlannerText } from "./textImport/equipment.js";
+import { exportImageAnnotationText, exportImagesText, importImageAnnotationText, importImagesText } from "./textImport/media.js";
+import { exportGoalTreeText, exportTaskPlannerText, importGoalTreeText, importTaskPlannerText } from "./textImport/planning.js";
+import { fail, parseColonImportLines } from "./textImport/shared.js";
+import { exportCalendarText, exportTimerText, importCalendarText, importTimerText } from "./textImport/time.js";
-export function parseColonImportLines(text) {
- return String(text || "")
- .split(/\r?\n/)
- .map((line) => line.trim())
- .filter(Boolean)
- .map((line) => {
- const separatorIndex = line.indexOf(":");
- if (separatorIndex === -1) return { label: line, value: "" };
- return {
- label: line.slice(0, separatorIndex).trim(),
- value: line.slice(separatorIndex + 1).trim()
- };
- })
- .filter((entry) => entry.label);
-}
+export { parseColonImportLines };
-const SUPPORTED_TEXT_EXCHANGE_TYPES = new Set([
- "notepad",
- "checklist",
- "links",
- "counters",
- "combos",
- "calculator",
- "table",
- "timer",
- "calendar",
- "taskPlanner",
- "goalTree",
- "equipmentPlanner",
- "images",
- "imageAnnotation"
-]);
-const COMBO_DEVICES = new Set(["playstation", "xbox", "switch", "n64", "keyboardMouse"]);
-const COMBO_INPUT_KINDS = new Set(["button", "direction", "key", "mouse"]);
-const TIMER_TYPES = new Set(["duration", "daily_time", "time_pattern", "interval"]);
-const ALERT_MODES = new Set(["off", "visible", "site"]);
-const CALENDAR_ALERT_MODE_SET = new Set(CALENDAR_ALERT_MODES);
-const CALENDAR_ALERT_OFFSET_SET = new Set(CALENDAR_ALERT_OFFSETS);
-const CALENDAR_COLOR_SET = new Set(CALENDAR_COLORS);
-const CALENDAR_MONTHLY_REPEAT_BY_SET = new Set(CALENDAR_MONTHLY_REPEAT_BY);
-const CALENDAR_MONTH_REPEAT_MODE_SET = new Set(CALENDAR_MONTH_REPEAT_MODES);
-const CALENDAR_WEEKDAYS = ["lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche"];
-const IMAGE_DATA_URL_PATTERN = /^data:image\/[a-z0-9.+-]+;base64,[a-z0-9+/=\s]+$/i;
+const TEXT_EXCHANGE_HANDLERS = {
+ notepad: {
+ hasContent: (data) => Boolean(data?.text || data?.html || data?.drawings?.strokes?.length),
+ exportText: exportNotepadText,
+ importText: importNotepadText
+ },
+ checklist: {
+ hasContent: (data) => Boolean(data?.sections?.some((section) => section.items?.length)),
+ exportText: exportChecklistText,
+ importText: importChecklistText
+ },
+ links: {
+ hasContent: (data) => Boolean(data?.links?.length),
+ exportText: exportLinksText,
+ importText: importLinksText
+ },
+ counters: {
+ hasContent: (data) => Boolean(data?.counters?.length),
+ exportText: exportCountersText,
+ importText: importCountersText
+ },
+ combos: {
+ hasContent: (data) => Boolean(data?.combos?.length),
+ exportText: exportCombosText,
+ importText: importCombosText
+ },
+ calculator: {
+ hasContent: (data) => Boolean(data?.entries?.length || data?.scrollResults),
+ exportText: exportCalculatorText,
+ importText: importCalculatorText
+ },
+ table: {
+ hasContent: (data) => Boolean(Object.keys(data?.cells || {}).length || Object.keys(data?.rowLabels || {}).length || Object.keys(data?.columnLabels || {}).length),
+ exportText: exportTableTsv,
+ importText: importTableTsv
+ },
+ timer: {
+ hasContent: (data) => Boolean(data?.stopwatch?.laps?.length || data?.countdowns?.length || data?.stopwatch?.elapsedMs),
+ exportText: exportTimerText,
+ importText: importTimerText
+ },
+ calendar: {
+ hasContent: (data) => Boolean(data?.events?.length),
+ exportText: exportCalendarText,
+ importText: importCalendarText
+ },
+ taskPlanner: {
+ hasContent: (data) => Boolean(data?.tasks?.length),
+ exportText: exportTaskPlannerText,
+ importText: importTaskPlannerText
+ },
+ goalTree: {
+ hasContent: (data) => Boolean(data?.nodes?.length),
+ exportText: exportGoalTreeText,
+ importText: importGoalTreeText
+ },
+ equipmentPlanner: {
+ hasContent: (data) => Boolean(data?.equipments?.length),
+ exportText: exportEquipmentPlannerText,
+ importText: importEquipmentPlannerText
+ },
+ images: {
+ hasContent: (data) => Boolean(data?.images?.length),
+ exportText: exportImagesText,
+ importText: importImagesText
+ },
+ imageAnnotation: {
+ hasContent: (data) => Boolean(data?.image),
+ exportText: exportImageAnnotationText,
+ importText: importImageAnnotationText
+ }
+};
export function supportsTextExchange(type) {
- return SUPPORTED_TEXT_EXCHANGE_TYPES.has(type);
+ return Boolean(TEXT_EXCHANGE_HANDLERS[type]);
}
export function hasTextExchangeContent(type, data) {
- if (type === "notepad") return Boolean(data?.text || data?.html || data?.drawings?.strokes?.length);
- if (type === "checklist") return Boolean(data?.sections?.some((section) => section.items?.length));
- if (type === "links") return Boolean(data?.links?.length);
- if (type === "counters") return Boolean(data?.counters?.length);
- if (type === "combos") return Boolean(data?.combos?.length);
- if (type === "calculator") return Boolean(data?.entries?.length || data?.scrollResults);
- if (type === "table") return Boolean(Object.keys(data?.cells || {}).length || Object.keys(data?.rowLabels || {}).length || Object.keys(data?.columnLabels || {}).length);
- if (type === "timer") return Boolean(data?.stopwatch?.laps?.length || data?.countdowns?.length || data?.stopwatch?.elapsedMs);
- if (type === "calendar") return Boolean(data?.events?.length);
- if (type === "taskPlanner") return Boolean(data?.tasks?.length);
- if (type === "goalTree") return Boolean(data?.nodes?.length);
- if (type === "equipmentPlanner") return Boolean(data?.equipments?.length);
- if (type === "images") return Boolean(data?.images?.length);
- if (type === "imageAnnotation") return Boolean(data?.image);
- return false;
+ return Boolean(TEXT_EXCHANGE_HANDLERS[type]?.hasContent(data));
}
export function exportModuleText(type, data) {
- if (type === "notepad") return exportNotepadText(data);
- if (type === "checklist") return exportChecklistText(data);
- if (type === "links") return exportLinksText(data);
- if (type === "counters") return exportCountersText(data);
- if (type === "combos") return exportCombosText(data);
- if (type === "calculator") return exportCalculatorText(data);
- if (type === "table") return exportTableTsv(data);
- if (type === "timer") return exportTimerText(data);
- if (type === "calendar") return exportCalendarText(data);
- if (type === "taskPlanner") return exportTaskPlannerText(data);
- if (type === "goalTree") return exportGoalTreeText(data);
- if (type === "equipmentPlanner") return exportEquipmentPlannerText(data);
- if (type === "images") return exportImagesText(data);
- if (type === "imageAnnotation") return exportImageAnnotationText(data);
- return "";
+ return TEXT_EXCHANGE_HANDLERS[type]?.exportText(data) || "";
}
export function importModuleText(type, text, context) {
try {
const value = String(text || "");
if (!value.trim()) return fail("Le texte à importer est vide.");
- let data = null;
- if (type === "notepad") data = importNotepadText(value, context);
- else if (type === "checklist") data = importChecklistText(value, context);
- else if (type === "links") data = importLinksText(value, context);
- else if (type === "counters") data = importCountersText(value, context);
- else if (type === "combos") data = importCombosText(value, context);
- else if (type === "calculator") data = importCalculatorText(value, context);
- else if (type === "table") data = importTableTsv(value);
- else if (type === "timer") data = importTimerText(value, context);
- else if (type === "calendar") data = importCalendarText(value, context);
- else if (type === "taskPlanner") data = importTaskPlannerText(value, context);
- else if (type === "goalTree") data = importGoalTreeText(value, context);
- else if (type === "equipmentPlanner") data = importEquipmentPlannerText(value, context);
- else if (type === "images") data = importImagesText(value, context);
- else if (type === "imageAnnotation") data = importImageAnnotationText(value, context);
- else return fail("Cet outil ne supporte pas l'import texte.");
- if (!hasTextExchangeContent(type, data)) return fail("Aucun contenu valide détecté.");
+ const handler = TEXT_EXCHANGE_HANDLERS[type];
+ if (!handler) return fail("Cet outil ne supporte pas l'import texte.");
+ const data = handler.importText(value, context);
+ if (!handler.hasContent(data)) return fail("Aucun contenu valide détecté.");
return { ok: true, data };
} catch (error) {
return fail(error?.message || "Format invalide.");
}
}
-
-function fail(error) {
- return { ok: false, error };
-}
-
-function splitLines(text) {
- return String(text || "").split(/\r?\n/);
-}
-
-function parseMetaLine(line) {
- const match = String(line || "").trim().match(/^@([a-zA-Z][\w-]*)\s*:\s*(.*)$/);
- return match ? { key: match[1], value: match[2] } : null;
-}
-
-function parsePipeParts(line) {
- const [title = "", ...parts] = String(line || "").split("|").map((part) => part.trim());
- const meta = {};
- parts.forEach((part) => {
- const separatorIndex = part.indexOf("=");
- if (separatorIndex < 0) return;
- meta[part.slice(0, separatorIndex).trim()] = part.slice(separatorIndex + 1).trim();
- });
- return { title, meta };
-}
-
-function parseBoolean(value) {
- return String(value || "").trim().toLowerCase() === "true";
-}
-
-function lineError(index, message) {
- return new Error(`Ligne ${index + 1}: ${message}`);
-}
-
-function parseNumber(value, fallback = 0) {
- const parsed = Number(String(value || "").replace(",", "."));
- return Number.isFinite(parsed) ? parsed : fallback;
-}
-
-function parseInteger(value, fallback = 0) {
- const parsed = Number.parseInt(value, 10);
- return Number.isFinite(parsed) ? parsed : fallback;
-}
-
-function formatMeta(meta) {
- return Object.entries(meta)
- .filter(([, value]) => value !== "" && value !== undefined && value !== false)
- .map(([key, value]) => `${key}=${value}`)
- .join(" | ");
-}
-
-function isValidImageDataUrl(value) {
- return IMAGE_DATA_URL_PATTERN.test(String(value || "").trim());
-}
-
-function cloneDrawingData(drawings) {
- return {
- strokes: (drawings?.strokes || []).map((stroke) => ({
- ...stroke,
- points: (stroke.points || []).map((point) => ({ ...point }))
- }))
- };
-}
-
-function escapeHtml(value) {
- return String(value || "")
- .replaceAll("&", "&")
- .replaceAll("<", "<")
- .replaceAll(">", ">");
-}
-
-function textToNotepadExchangeHtml(value) {
- return String(value || "")
- .split(/\r?\n/)
- .map((line) => line ? `
${escapeHtml(line)}
` : "
")
- .join("");
-}
-
-function exportNotepadText(data) {
- const html = String(data?.html || "").trim() || textToNotepadExchangeHtml(data?.text || "");
- const lines = ["@notepad", "@html", html, "@endhtml"];
- if (data?.drawingMode === "permanent") lines.push("@drawingMode: permanent");
- if (data?.drawings?.strokes?.length) lines.push(`@drawings: ${JSON.stringify(cloneDrawingData(data.drawings))}`);
- return lines.join("\n");
-}
-
-function cloneImportedNotepadDrawings(drawings, context) {
- if (!drawings || typeof drawings !== "object" || !Array.isArray(drawings.strokes)) throw new Error("dessins invalides.");
- return {
- strokes: drawings.strokes.map((stroke) => {
- if (!stroke || typeof stroke !== "object" || !Array.isArray(stroke.points) || stroke.points.length < 2) throw new Error("trait de dessin invalide.");
- return {
- ...stroke,
- id: context.uid("stroke"),
- points: stroke.points.map((point) => {
- const x = Number(point?.x);
- const y = Number(point?.y);
- if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error("point de dessin invalide.");
- return { x, y };
- })
- };
- })
- };
-}
-
-function importNotepadText(text, context) {
- const lines = splitLines(text);
- const hasStructuredMarker = lines.some((line) => ["@notepad", "@html", "@endhtml"].includes(line.trim()) || parseMetaLine(line));
- if (!hasStructuredMarker) return context.normalizeNotepadData({ text });
-
- let readingHtml = false;
- let htmlWasOpened = false;
- let htmlWasClosed = false;
- const htmlLines = [];
- let drawingMode = "temporary";
- let drawings = { strokes: [] };
- lines.forEach((line, index) => {
- const cleanLine = line.trim();
- if (readingHtml) {
- if (cleanLine === "@endhtml") {
- readingHtml = false;
- htmlWasClosed = true;
- return;
- }
- htmlLines.push(line);
- return;
- }
- if (!cleanLine || cleanLine === "@notepad") return;
- if (cleanLine === "@html") {
- if (htmlWasOpened) throw lineError(index, "bloc html dupliqué.");
- readingHtml = true;
- htmlWasOpened = true;
- return;
- }
- const meta = parseMetaLine(cleanLine);
- if (meta?.key === "drawingMode") {
- if (!["temporary", "permanent"].includes(meta.value)) throw lineError(index, "mode de dessin invalide.");
- drawingMode = meta.value;
- return;
- }
- if (meta?.key === "drawings") {
- try {
- drawings = cloneImportedNotepadDrawings(JSON.parse(meta.value), context);
- } catch (error) {
- throw lineError(index, error?.message || "dessins invalides.");
- }
- if (drawings.strokes.length) drawingMode = "permanent";
- return;
- }
- throw lineError(index, `ligne bloc notes invalide: ${cleanLine}`);
- });
- if (readingHtml || (htmlWasOpened && !htmlWasClosed)) throw new Error("Bloc HTML non terminé.");
- return context.normalizeNotepadData({ html: htmlLines.join("\n").trim(), drawingMode, drawings });
-}
-
-function exportChecklistText(data) {
- return (data.sections || []).flatMap((section) => [
- section.title ? `# ${section.title}` : "",
- ...(section.items || []).map((item) => `${item.label}: ${Math.max(1, parseInteger(item.qtyTarget, 1))}`)
- ]).filter((line, index, lines) => line || lines[index + 1]).join("\n").trim();
-}
-
-function importChecklistText(text, context) {
- const sections = [];
- let current = { id: context.uid("section"), title: "", items: [] };
- splitLines(text).forEach((line) => {
- const cleanLine = line.trim();
- if (!cleanLine) return;
- if (cleanLine.startsWith("#")) {
- if (current.items.length) sections.push(current);
- current = { id: context.uid("section"), title: cleanLine.replace(/^#+/, "").trim(), items: [] };
- return;
- }
- parseColonImportLines(cleanLine).forEach((entry) => {
- current.items.push({
- id: context.uid("item"),
- label: entry.label,
- qtyTarget: Math.max(1, parseInteger(String(entry.value).split("/").pop(), 1)),
- qtyCurrent: 0
- });
- });
- });
- if (current.items.length) sections.push(current);
- return context.normalizeChecklistData({ sections });
-}
-
-function exportLinksText(data) {
- return (data.links || []).map((link) => link.title ? `${link.title}: ${link.url}` : link.url).join("\n");
-}
-
-function importLinksText(text, context) {
- const links = [];
- splitLines(text).forEach((rawLine, index) => {
- const line = rawLine.trim();
- if (!line) return;
- const separatorIndex = line.indexOf(":");
- const maybeUrl = separatorIndex >= 0 ? line.slice(separatorIndex + 1).trim() : line;
- const title = separatorIndex >= 0 && !/^[a-z][a-z0-9+.-]*:\/\//i.test(line) ? line.slice(0, separatorIndex).trim() : "";
- const url = context.normalizeUrl(title ? maybeUrl : line);
- if (!url) throw lineError(index, `URL invalide: ${line}`);
- links.push({ id: context.uid("link"), title, url });
- });
- return context.normalizeLinksData({ links });
-}
-
-function exportCountersText(data) {
- return (data.counters || []).map((counter) => `${counter.label}: ${counter.value}`).join("\n");
-}
-
-function importCountersText(text, context) {
- const counters = [];
- splitLines(text).forEach((line, index) => {
- const cleanLine = line.trim();
- if (!cleanLine) return;
- const separatorIndex = cleanLine.indexOf(":");
- const entry = separatorIndex === -1
- ? { label: cleanLine, value: "" }
- : { label: cleanLine.slice(0, separatorIndex).trim(), value: cleanLine.slice(separatorIndex + 1).trim() };
- if (!entry.label) return;
- if (!entry.value && entry.value !== "0") throw lineError(index, `valeur manquante pour ${entry.label}.`);
- const value = Number.parseInt(entry.value, 10);
- if (!Number.isFinite(value)) throw lineError(index, `valeur invalide pour ${entry.label}.`);
- counters.push({ id: context.uid("counter"), label: entry.label, value });
- });
- return context.normalizeCountersData({ counters });
-}
-
-function exportCalculatorText(data) {
- const lines = [];
- if (data.scrollResults) lines.push("@scrollResults: true");
- function visit(parentId = "", depth = 0) {
- (data.entries || []).filter((entry) => (entry.parentId || "") === parentId).forEach((entry) => {
- const formula = String(entry.formula || "").trim();
- lines.push(`${" ".repeat(depth)}${entry.label || ""}: ${entry.value}${formula ? ` | formula=${formula}` : ""}`);
- visit(entry.id, depth + 1);
- });
- }
- visit();
- return lines.join("\n");
-}
-
-function importCalculatorText(text, context) {
- const entries = [];
- const parentStack = [];
- let scrollResults = false;
- splitLines(text).forEach((line, index) => {
- if (!line.trim()) return;
- const meta = parseMetaLine(line);
- if (meta?.key === "scrollResults") {
- scrollResults = parseBoolean(meta.value);
- return;
- }
- const depth = Math.floor((line.match(/^ */)?.[0].length || 0) / 2);
- const separatorIndex = line.indexOf(":");
- if (separatorIndex < 0) throw lineError(index, `ligne calculateur invalide: ${line.trim()}`);
- const label = line.slice(0, separatorIndex).trim();
- const [rawValue, rawFormula = ""] = line.slice(separatorIndex + 1).split(/\s+\|\s+formula=/);
- const value = parseNumber(rawValue, Number.NaN);
- if (!Number.isFinite(value)) throw lineError(index, `valeur calculateur invalide: ${line.trim()}`);
- const entry = { id: context.uid("calc"), parentId: parentStack[depth - 1] || "", label, value, formula: rawFormula.trim() };
- entries.push(entry);
- parentStack[depth] = entry.id;
- parentStack.length = depth + 1;
- });
- return context.normalizeCalculatorData({ entries, scrollResults });
-}
-
-function exportTableTsv(data) {
- const lines = [];
- if (data.rows !== 10 || data.columns !== 6) lines.push(`@size: ${data.rows}x${data.columns}`);
- if (Object.keys(data.columnLabels || {}).length) lines.push(`@columns: ${Array.from({ length: data.columns }, (_, index) => data.columnLabels?.[index] || columnIndexToName(index)).join("\t")}`);
- if (Object.keys(data.rowLabels || {}).length) lines.push(`@rows: ${Array.from({ length: data.rows }, (_, index) => data.rowLabels?.[index] || index + 1).join("\t")}`);
- if (lines.length) lines.push("@tsv");
- for (let rowIndex = 0; rowIndex < data.rows; rowIndex += 1) {
- lines.push(Array.from({ length: data.columns }, (_, columnIndex) => data.cells?.[cellAddress(rowIndex, columnIndex)] || "").join("\t"));
- }
- return lines.join("\n").replace(/\n+$/, "");
-}
-
-function importTableTsv(text) {
- const lines = splitLines(text);
- let rows = 0;
- let columns = 0;
- let rowLabels = {};
- let columnLabels = {};
- let dataLines = lines;
- const metaLines = [];
- const tsvIndex = lines.findIndex((line) => line.trim() === "@tsv");
- if (tsvIndex >= 0) {
- metaLines.push(...lines.slice(0, tsvIndex));
- dataLines = lines.slice(tsvIndex + 1);
- }
- metaLines.forEach((line, index) => {
- const meta = parseMetaLine(line);
- if (!meta) throw lineError(index, `meta tableau invalide: ${line}`);
- if (meta.key === "size") {
- const match = meta.value.match(/^(\d+)x(\d+)$/);
- if (!match) throw lineError(index, "taille de tableau invalide.");
- rows = parseInteger(match[1], 0);
- columns = parseInteger(match[2], 0);
- }
- if (meta.key === "columns") {
- columnLabels = Object.fromEntries(meta.value.split("\t").map((value, index) => [index, value]).filter(([index, value]) => value && value !== columnIndexToName(index)));
- }
- if (meta.key === "rows") {
- rowLabels = Object.fromEntries(meta.value.split("\t").map((value, index) => [index, value]).filter(([index, value]) => value && value !== String(index + 1)));
- }
- });
- const tableRows = dataLines.filter((line) => line.trim() || line.includes("\t")).map((line) => line.split("\t"));
- if (!tableRows.length) throw new Error("TSV vide.");
- rows = rows || tableRows.length;
- columns = columns || Math.max(...tableRows.map((row) => row.length));
- const cells = {};
- for (let rowIndex = 0; rowIndex < Math.min(rows, 50); rowIndex += 1) {
- for (let columnIndex = 0; columnIndex < Math.min(columns, 20); columnIndex += 1) {
- const value = tableRows[rowIndex]?.[columnIndex] || "";
- if (value.trim()) cells[cellAddress(rowIndex, columnIndex)] = value;
- }
- }
- return { rows, columns, cells, rowLabels, columnLabels };
-}
-
-function exportComboInput(input) {
- let suffix = "";
- if (input.holdMs) suffix = `[${Math.round(input.holdMs / 1000)}s]`;
- else if (input.hold) suffix = "[hold]";
- return `${input.kind}:${input.value}${suffix}`;
-}
-
-function parseComboInput(text) {
- const match = String(text || "").trim().match(/^([a-zA-Z]+):([^[]+?)(?:\[(hold|\d+s)\])?$/);
- if (!match || !COMBO_INPUT_KINDS.has(match[1])) throw new Error(`Input combo invalide: ${text}`);
- const input = { kind: match[1], value: match[2].trim() };
- if (!input.value) throw new Error(`Input combo invalide: ${text}`);
- if (match[3] === "hold") input.hold = true;
- if (match[3]?.endsWith("s")) {
- input.hold = true;
- input.holdMs = Math.max(1000, Math.min(99000, parseInteger(match[3], 1) * 1000));
- }
- return input;
-}
-
-function exportCombosText(data) {
- const lines = [];
- if (data.device && data.device !== "playstation") lines.push(`@device: ${data.device}`);
- if (data.collapsedCategories?.length) lines.push(`@collapsed: ${data.collapsedCategories.join(", ")}`);
- let lastCategory = null;
- (data.combos || []).forEach((combo) => {
- const category = combo.category || "";
- if (category && category !== lastCategory) {
- lines.push(`# ${category}`);
- lastCategory = category;
- }
- const meta = formatMeta({ device: combo.device || "" });
- const sequence = (combo.inputs || []).map((step) => step.map(exportComboInput).join("+")).join(" > ");
- lines.push([combo.name, meta, sequence].filter(Boolean).join(" | "));
- });
- return lines.join("\n");
-}
-
-function importCombosText(text, context) {
- const combos = [];
- let device = "playstation";
- let collapsedCategories = [];
- let category = "";
- splitLines(text).forEach((line, index) => {
- const cleanLine = line.trim();
- if (!cleanLine) return;
- const meta = parseMetaLine(cleanLine);
- if (meta?.key === "device") {
- if (!COMBO_DEVICES.has(meta.value)) throw lineError(index, "périphérique combo invalide.");
- device = meta.value;
- return;
- }
- if (meta?.key === "collapsed") {
- collapsedCategories = meta.value.split(",").map((item) => item.trim()).filter(Boolean);
- return;
- }
- if (cleanLine.startsWith("#")) {
- category = cleanLine.replace(/^#+/, "").trim();
- return;
- }
- const { title, meta: lineMeta } = parsePipeParts(cleanLine);
- const sequence = cleanLine.split("|").map((part) => part.trim()).filter((part, index) => index > 0 && !part.includes("=")).join(" | ");
- const comboDevice = lineMeta.device || device;
- if (!COMBO_DEVICES.has(comboDevice)) throw lineError(index, `périphérique combo invalide: ${comboDevice}`);
- let inputs;
- try {
- inputs = sequence ? String(sequence).split(">").map((step) => step.split("+").map(parseComboInput)) : [];
- } catch (error) {
- throw lineError(index, error?.message || "input combo invalide.");
- }
- combos.push({ id: context.uid("combo"), name: title || "Combo", category, device: comboDevice, inputs });
- });
- return context.normalizeCombosData({ device, collapsedCategories, combos });
-}
-
-function exportTaskPlannerText(data) {
- const lines = [];
- if (data.weeklyResetDay !== 1) lines.push(`@weeklyResetDay: ${data.weeklyResetDay}`);
- if (data.resetTime && data.resetTime !== "00:00") lines.push(`@resetTime: ${data.resetTime}`);
- if (data.hideCompleted) lines.push("@hideCompleted: true");
- if (data.collapsedCategories?.length) lines.push(`@collapsed: ${data.collapsedCategories.join(", ")}`);
- const parentByChild = new Map((data.relations || []).map((relation) => [relation.fromTaskId, relation]));
- const childrenByParent = new Map();
- (data.tasks || []).forEach((task) => {
- const parentId = parentByChild.get(task.id)?.toTaskId || "";
- childrenByParent.set(parentId, [...(childrenByParent.get(parentId) || []), task]);
- });
- function writeTask(task, depth = 0) {
- const prefix = " ".repeat(depth);
- lines.push(`${prefix}- ${task.type || "unique"} ${task.title}`);
- if (task.description) lines.push(`${prefix} @description: ${task.description.replace(/\n/g, "\\n")}`);
- if (task.dailyResetTime) lines.push(`${prefix} @dailyResetTime: ${task.dailyResetTime}`);
- if (task.weeklyResetDay !== undefined) lines.push(`${prefix} @weeklyResetDay: ${task.weeklyResetDay}`);
- if (parentByChild.get(task.id)?.prerequisite) lines.push(`${prefix} @prerequisite: true`);
- (childrenByParent.get(task.id) || []).forEach((child) => writeTask(child, depth + 1));
- }
- let lastCategory = null;
- (childrenByParent.get("") || []).forEach((task) => {
- const category = task.category || "";
- if (category && category !== lastCategory) {
- lines.push(`# ${category}`);
- lastCategory = category;
- }
- writeTask(task);
- });
- return lines.join("\n");
-}
-
-function importTaskPlannerText(text, context) {
- const tasks = [];
- const relations = [];
- const stack = [];
- let category = "";
- const data = { weeklyResetDay: 1, resetTime: "00:00", hideCompleted: false, collapsedCategories: [], tasks, relations };
- splitLines(text).forEach((line, index) => {
- if (!line.trim()) return;
- const cleanLine = line.trim();
- const rootMeta = parseMetaLine(cleanLine);
- if (rootMeta && !stack.length) {
- if (rootMeta.key === "weeklyResetDay") data.weeklyResetDay = parseInteger(rootMeta.value, 1);
- if (rootMeta.key === "resetTime") data.resetTime = rootMeta.value;
- if (rootMeta.key === "hideCompleted") data.hideCompleted = parseBoolean(rootMeta.value);
- if (rootMeta.key === "collapsed") data.collapsedCategories = rootMeta.value.split(",").map((item) => item.trim()).filter(Boolean);
- return;
- }
- if (cleanLine.startsWith("#")) {
- category = cleanLine.replace(/^#+/, "").trim();
- return;
- }
- const depth = Math.floor((line.match(/^ */)?.[0].length || 0) / 2);
- const meta = parseMetaLine(cleanLine);
- if (meta) {
- const task = stack[Math.max(0, depth - 1)];
- if (!task) throw lineError(index, `meta de tâche sans tâche: ${cleanLine}`);
- if (meta.key === "description") task.description = meta.value.replace(/\\n/g, "\n");
- if (meta.key === "dailyResetTime") task.dailyResetTime = meta.value;
- if (meta.key === "weeklyResetDay") task.weeklyResetDay = parseInteger(meta.value, 1);
- if (meta.key === "prerequisite") {
- const relation = relations.find((item) => item.fromTaskId === task.id);
- if (relation) relation.prerequisite = parseBoolean(meta.value);
- }
- return;
- }
- const match = cleanLine.match(/^-\s+(unique|daily|weekly)\s+(.+)$/);
- if (!match) throw lineError(index, `ligne tâche invalide: ${cleanLine}`);
- const task = { id: context.uid("task"), title: match[2].trim(), description: "", type: match[1], checked: false, checkedAt: 0 };
- const parent = stack[depth - 1];
- if (parent) relations.push({ id: context.uid("relation"), fromTaskId: task.id, toTaskId: parent.id, prerequisite: false });
- else if (category) task.category = category;
- tasks.push(task);
- stack[depth] = task;
- stack.length = depth + 1;
- });
- return context.normalizeTaskPlannerData(data);
-}
-
-function exportGoalTreeText(data) {
- const lines = [];
- if (data.lastResetAt) lines.push(`@lastResetAt: ${data.lastResetAt}`);
- function visit(parentId = "", depth = 0) {
- (data.nodes || []).filter((node) => (node.parentId || "") === parentId).forEach((node) => {
- const prefix = " ".repeat(depth);
- const formula = String(node.formula || "").trim();
- const qty = Number.isFinite(node.computedQtyTarget) ? node.computedQtyTarget : node.qtyTarget;
- const suffix = node.kind === "step" ? (formula ? ` = ${formula}` : ` x${qty}`) : "";
- const meta = formatMeta({
- kind: node.kind !== "step" ? node.kind : "",
- icon: node.kind === "step" && node.icon && node.icon !== "cube" ? node.icon : "",
- color: node.kind === "step" && node.color && node.color !== "gold" ? node.color : "",
- items: node.kind !== "step" && node.items?.length ? encodeURIComponent(JSON.stringify(node.items)) : "",
- current: node.qtyCurrent || "",
- reset: node.resetType !== "none" ? node.resetType : "",
- resetTime: node.resetTime && node.resetTime !== "00:00" ? node.resetTime : "",
- weeklyResetDay: node.weeklyResetDay !== 1 ? node.weeklyResetDay : "",
- collapsed: node.collapsed
- });
- lines.push(`${prefix}- ${node.title}${suffix}${meta ? ` | ${meta}` : ""}`);
- visit(node.id, depth + 1);
- });
- }
- visit();
- return lines.join("\n");
-}
-
-function importGoalTreeText(text, context) {
- const nodes = [];
- const stack = [];
- const data = { lastResetAt: 0, nodes };
- splitLines(text).forEach((line, index) => {
- if (!line.trim()) return;
- const cleanLine = line.trim();
- const rootMeta = parseMetaLine(cleanLine);
- if (rootMeta && !stack.length) {
- if (rootMeta.key === "lastResetAt") data.lastResetAt = parseInteger(rootMeta.value, 0);
- return;
- }
- const depth = Math.floor((line.match(/^ */)?.[0].length || 0) / 2);
- const match = cleanLine.match(/^-\s+(.+)$/);
- if (!match) throw lineError(index, `ligne arbre d'objectifs invalide: ${cleanLine}`);
- const { title: rawTitle, meta } = parsePipeParts(match[1]);
- const formulaIndex = rawTitle.lastIndexOf("=");
- const quantityMatch = rawTitle.match(/\s+x(\d+(?:[,.]\d+)?)\s*$/i);
- const hasFormula = formulaIndex >= 0;
- const title = (hasFormula ? rawTitle.slice(0, formulaIndex) : rawTitle.replace(/\s+x(\d+(?:[,.]\d+)?)\s*$/i, "")).trim();
- if (!title) throw lineError(index, "titre manquant.");
- const parent = stack[depth - 1];
- const node = {
- id: context.uid("goal"),
- parentId: parent?.id || "",
- title,
- kind: meta.kind || "step",
- icon: meta.icon || "cube",
- color: meta.color || "gold",
- qtyTarget: hasFormula ? 1 : Math.max(0, parseNumber(quantityMatch?.[1], 1)) || 1,
- qtyCurrent: 0,
- formula: hasFormula ? rawTitle.slice(formulaIndex + 1).trim() : "",
- items: parseGoalTreeItemsMeta(meta.items),
- resetType: meta.reset || "none",
- resetTime: meta.resetTime || "00:00",
- weeklyResetDay: parseInteger(meta.weeklyResetDay, 1),
- checkedAt: 0,
- collapsed: parseBoolean(meta.collapsed)
- };
- nodes.push(node);
- stack[depth] = node;
- stack.length = depth + 1;
- });
- return context.normalizeGoalTreeData(data);
-}
-
-function parseGoalTreeItemsMeta(value) {
- if (!value) return [];
- try {
- const parsed = JSON.parse(decodeURIComponent(value));
- return Array.isArray(parsed) ? parsed : [];
- } catch {
- return [];
- }
-}
-
-function exportTimerText(data) {
- const lines = [];
- if (data.activeTab && data.activeTab !== "stopwatch") lines.push(`@activeTab: ${data.activeTab}`);
- if (data.scrollResults) lines.push("@scrollResults: true");
- if (data.sortResults) lines.push("@sortResults: true");
- if (data.stopwatch?.elapsedMs) lines.push(`@stopwatchElapsedMs: ${data.stopwatch.elapsedMs}`);
- (data.stopwatch?.laps || []).forEach((lap) => lines.push(`lap | label=${lap.label || ""} | elapsedMs=${lap.elapsedMs}`));
- (data.countdowns || []).forEach((countdown) => {
- const meta = { label: countdown.label, type: countdown.type, alertMode: countdown.alertMode || "", autoRefresh: countdown.autoRefresh === true };
- if (countdown.type === "duration") meta.durationMs = countdown.durationMs;
- if (countdown.type === "daily_time") meta.time = countdown.time;
- if (countdown.type === "time_pattern") meta.pattern = countdown.pattern;
- if (countdown.type === "interval") {
- meta.intervalMs = countdown.intervalMs;
- meta.startMode = countdown.startMode || "now";
- meta.startTime = countdown.startTime || "";
- }
- lines.push(`countdown | ${formatMeta(meta)}`);
- });
- return lines.join("\n");
-}
-
-function importTimerText(text, context) {
- const now = Date.now();
- const data = { activeTab: "stopwatch", scrollResults: false, sortResults: false, stopwatch: { elapsedMs: 0, startedAt: 0, laps: [] }, countdowns: [] };
- splitLines(text).forEach((line, index) => {
- const cleanLine = line.trim();
- if (!cleanLine) return;
- const meta = parseMetaLine(cleanLine);
- if (meta) {
- if (meta.key === "activeTab") data.activeTab = meta.value === "countdown" ? "countdown" : "stopwatch";
- if (meta.key === "scrollResults") data.scrollResults = parseBoolean(meta.value);
- if (meta.key === "sortResults") data.sortResults = parseBoolean(meta.value);
- if (meta.key === "stopwatchElapsedMs") data.stopwatch.elapsedMs = Math.max(0, parseInteger(meta.value, 0));
- return;
- }
- const { title, meta: lineMeta } = parsePipeParts(cleanLine);
- if (title === "lap") {
- data.stopwatch.laps.push({ id: context.uid("timer"), label: lineMeta.label || "", elapsedMs: Math.max(0, parseInteger(lineMeta.elapsedMs, 0)) });
- return;
- }
- if (title !== "countdown") throw lineError(index, `ligne timer invalide: ${cleanLine}`);
- const type = lineMeta.type;
- if (!TIMER_TYPES.has(type)) throw lineError(index, `type timer invalide: ${type}`);
- const countdown = { id: context.uid("timer"), label: lineMeta.label || "Timer", type, alertMode: ALERT_MODES.has(lineMeta.alertMode) ? lineMeta.alertMode : "off" };
- if (type === "duration") {
- countdown.durationMs = Math.max(1, parseInteger(lineMeta.durationMs, 0));
- countdown.targetAt = now + countdown.durationMs;
- }
- if (type === "daily_time") {
- countdown.time = lineMeta.time;
- countdown.targetAt = getDailyTargetMs(countdown.time, now);
- countdown.autoRefresh = parseBoolean(lineMeta.autoRefresh);
- }
- if (type === "time_pattern") {
- countdown.pattern = lineMeta.pattern;
- countdown.autoRefresh = parseBoolean(lineMeta.autoRefresh);
- }
- if (type === "interval") {
- countdown.intervalMs = Math.max(1, parseInteger(lineMeta.intervalMs, 0));
- countdown.startMode = lineMeta.startMode === "time" && lineMeta.startTime ? "time" : "now";
- if (countdown.startMode === "time") countdown.startTime = lineMeta.startTime;
- countdown.anchorAt = getIntervalAnchorMs(countdown.startMode, countdown.startTime, now);
- countdown.targetAt = getCountdownTargetMs({ ...countdown, autoRefresh: true }, now);
- countdown.autoRefresh = parseBoolean(lineMeta.autoRefresh);
- }
- data.countdowns.push(countdown);
- });
- return context.normalizeTimerData(data);
-}
-
-function parseCalendarDayIndex(value) {
- const normalized = String(value || "").trim().toLowerCase();
- const weekdayIndex = CALENDAR_WEEKDAYS.indexOf(normalized);
- if (weekdayIndex >= 0) return weekdayIndex;
- const parsed = parseInteger(normalized, -1);
- return parsed >= 0 && parsed <= 6 ? parsed : -1;
-}
-
-function parseCalendarAlertOffsets(value) {
- return String(value || "")
- .split(",")
- .map((item) => parseInteger(item.trim(), Number.NaN))
- .filter((offset, index, offsets) => CALENDAR_ALERT_OFFSET_SET.has(offset) && offsets.indexOf(offset) === index)
- .sort((a, b) => b - a);
-}
-
-function formatCalendarEventMeta(event, viewMode) {
- const alertMode = CALENDAR_ALERT_MODE_SET.has(event.alertMode) ? event.alertMode : "off";
- const meta = {
- title: event.title,
- color: event.color && event.color !== "violet" ? event.color : "",
- repeatable: event.repeatable === false ? "false" : "",
- alertMode: alertMode !== "off" ? alertMode : "",
- alerts: alertMode !== "off" ? (event.alertOffsetMinutes || [10]).join(",") : ""
- };
- if (viewMode === "month") {
- const monthlyRepeatBy = CALENDAR_MONTHLY_REPEAT_BY_SET.has(event.monthlyRepeatBy) ? event.monthlyRepeatBy : "monthDay";
- if (monthlyRepeatBy === "weekday") {
- return {
- ...meta,
- repeatBy: "weekday",
- repeat: event.repeat || "none",
- startDate: event.startDate,
- endDate: event.endDate && event.endDate !== event.startDate ? event.endDate : ""
- };
- }
- return {
- ...meta,
- repeatBy: "monthDay",
- repeat: event.repeat || "none",
- startDate: event.startDate,
- endDate: event.endDate && event.endDate !== event.startDate ? event.endDate : ""
- };
- }
- return {
- ...meta,
- day: CALENDAR_WEEKDAYS[event.dayIndex] || "",
- start: minutesToTimeValue(event.startMinutes),
- end: event.endMinutes ? minutesToTimeValue(event.endMinutes) : ""
- };
-}
-
-function exportCalendarText(data) {
- const viewMode = data?.viewMode === "month" ? "month" : "week";
- const lines = [`@viewMode: ${viewMode}`];
- if (data?.weekStartsOn === 0) lines.push("@weekStartsOn: 0");
- (data?.events || []).forEach((event) => {
- lines.push(`event | ${formatMeta(formatCalendarEventMeta(event, viewMode))}`);
- });
- return lines.join("\n");
-}
-
-function importCalendarText(text, context) {
- const trimmed = String(text || "").trim();
- if (trimmed.startsWith("{")) {
- return context.normalizeCalendarData(JSON.parse(trimmed));
- }
-
- const data = { initialized: true, viewMode: "week", weekStartsOn: 1, events: [] };
- splitLines(text).forEach((line, index) => {
- const cleanLine = line.trim();
- if (!cleanLine) return;
- const meta = parseMetaLine(cleanLine);
- if (meta) {
- if (meta.key === "viewMode") data.viewMode = meta.value === "month" ? "month" : "week";
- if (meta.key === "weekStartsOn") data.weekStartsOn = parseInteger(meta.value, 1) === 0 ? 0 : 1;
- return;
- }
-
- const { title, meta: lineMeta } = parsePipeParts(cleanLine);
- if (title !== "event") throw lineError(index, `ligne calendrier invalide: ${cleanLine}`);
- const event = {
- id: context.uid("calendar"),
- title: lineMeta.title || "",
- repeatable: lineMeta.repeatable !== "false",
- alertMode: CALENDAR_ALERT_MODE_SET.has(lineMeta.alertMode) ? lineMeta.alertMode : "off",
- alertOffsetMinutes: parseCalendarAlertOffsets(lineMeta.alerts),
- color: CALENDAR_COLOR_SET.has(lineMeta.color) ? lineMeta.color : "violet"
- };
- if (data.viewMode === "month") {
- const monthlyRepeatBy = CALENDAR_MONTHLY_REPEAT_BY_SET.has(lineMeta.repeatBy) ? lineMeta.repeatBy : "monthDay";
- event.startMinutes = 0;
- event.monthlyRepeatBy = monthlyRepeatBy;
- event.repeat = CALENDAR_MONTH_REPEAT_MODE_SET.has(lineMeta.repeat) ? lineMeta.repeat : "none";
- event.startDate = lineMeta.startDate || "";
- event.endDate = lineMeta.endDate || event.startDate;
- } else {
- event.dayIndex = parseCalendarDayIndex(lineMeta.day);
- event.startMinutes = timeValueToMinutes(lineMeta.start);
- if (lineMeta.end) event.endMinutes = timeValueToMinutes(lineMeta.end);
- }
- data.events.push(event);
- });
- return context.normalizeCalendarData(data);
-}
-
-function exportImagesText(data) {
- return (data.images || []).flatMap((image) => [
- image.label ? `# ${image.label}` : "#",
- image.dataUrl
- ]).join("\n");
-}
-
-function importImagesText(text, context) {
- const images = [];
- let label = "";
- splitLines(text).forEach((line, index) => {
- const cleanLine = line.trim();
- if (!cleanLine) return;
- if (cleanLine.startsWith("#")) {
- label = cleanLine.replace(/^#+/, "").trim();
- return;
- }
- if (!isValidImageDataUrl(cleanLine)) throw lineError(index, "image base64 invalide.");
- images.push({ id: context.uid("image"), label, dataUrl: cleanLine });
- label = "";
- });
- return { images };
-}
-
-function exportImageAnnotationText(data) {
- const lines = [data.image];
- (data.markers || []).forEach((marker) => lines.push(`@marker: ${marker.x},${marker.y},${marker.label || ""}`));
- if (data.drawings?.strokes?.length) lines.push(`@drawings: ${JSON.stringify(cloneDrawingData(data.drawings))}`);
- return lines.join("\n");
-}
-
-function importImageAnnotationText(text, context) {
- const markers = [];
- let image = "";
- let drawings = { strokes: [] };
- splitLines(text).forEach((line, index) => {
- const cleanLine = line.trim();
- if (!cleanLine) return;
- const meta = parseMetaLine(cleanLine);
- if (meta?.key === "marker") {
- const [x, y, ...labelParts] = meta.value.split(",");
- const marker = { id: context.uid("marker"), x: parseNumber(x, Number.NaN), y: parseNumber(y, Number.NaN), label: labelParts.join(",").trim() };
- if (!Number.isFinite(marker.x) || !Number.isFinite(marker.y) || marker.x < 0 || marker.x > 100 || marker.y < 0 || marker.y > 100) throw lineError(index, "marqueur invalide.");
- markers.push(marker);
- return;
- }
- if (meta?.key === "drawings") {
- try {
- drawings = JSON.parse(meta.value);
- } catch {
- throw lineError(index, "dessins invalides.");
- }
- return;
- }
- if (image) throw lineError(index, "une seule image peut être importée.");
- if (!isValidImageDataUrl(cleanLine)) throw lineError(index, "image base64 invalide.");
- image = cleanLine;
- });
- return context.normalizeImageAnnotationData({ image, markers, drawings });
-}
-
-function exportEquipmentPlannerText(data) {
- const lines = [];
- const typesById = new Map((data.types || []).map((type) => [type.id, type]));
- (data.types || [{ id: "", title: "Équipements", icon: "shield", collapsed: false }]).forEach((type) => {
- const equipments = (data.equipments || []).filter((equipment) => type.id ? equipment.typeId === type.id : !typesById.has(equipment.typeId));
- if (!equipments.length) return;
- lines.push(`# ${type.title} | ${formatMeta({ icon: type.icon, collapsed: type.collapsed })}`);
- equipments.forEach((equipment) => {
- lines.push(`## ${equipment.name} | ${formatMeta({ icon: equipment.icon, active: equipment.active !== false })}`);
- if (equipment.characteristics?.length) {
- lines.push("Stats");
- equipment.characteristics.forEach((trait) => lines.push(`${trait.category || ""} | ${trait.icon || "sword"} | ${trait.name} | ${trait.value ?? ""}`));
- }
- if (equipment.socketItems?.length) {
- lines.push("Sockets");
- equipment.socketItems.forEach((socketItem) => {
- lines.push(`${socketItem.name} | shape=${socketItem.shape} | color=${socketItem.color}`);
- (socketItem.bonuses || []).forEach((bonus) => lines.push(` bonus | ${bonus.category || ""} | ${bonus.icon || "sword"} | ${bonus.name} | ${bonus.value ?? ""}`));
- });
- if (equipment.socketLinks?.length) {
- const socketItemsById = new Map(equipment.socketItems.map((socketItem) => [socketItem.id, socketItem.name]));
- equipment.socketLinks.forEach((link) => {
- const fromName = socketItemsById.get(link.fromSocketItemId);
- const toName = socketItemsById.get(link.toSocketItemId);
- if (fromName && toName) lines.push(` link | ${fromName} | ${toName}`);
- });
- }
- }
- if (equipment.obtain || equipment.materials?.length) {
- lines.push("Craft");
- if (equipment.obtain) lines.push(`@obtain: ${equipment.obtain.replace(/\n/g, "\\n")}`);
- (equipment.materials || []).forEach((material) => lines.push(`${material.name}: ${material.qty}`));
- }
- });
- });
- return lines.join("\n");
-}
-
-function importEquipmentPlannerText(text, context) {
- const types = [];
- const equipments = [];
- let currentType = null;
- let currentEquipment = null;
- let section = "";
- let currentSocket = null;
- const socketsByName = new Map();
- splitLines(text).forEach((line, index) => {
- const cleanLine = line.trim();
- if (!cleanLine) return;
- if (cleanLine.startsWith("##")) {
- if (!currentType) throw lineError(index, "équipement sans type.");
- const { title, meta } = parsePipeParts(cleanLine.replace(/^##+/, "").trim());
- currentEquipment = { id: context.uid("equipment"), typeId: currentType.id, name: title, icon: meta.icon || "shield", active: meta.active !== "false", obtain: "", characteristics: [], socketItems: [], socketLinks: [], materials: [], categoryOrder: [], collapsedCategories: [] };
- equipments.push(currentEquipment);
- section = "";
- currentSocket = null;
- socketsByName.clear();
- return;
- }
- if (cleanLine.startsWith("#")) {
- const { title, meta } = parsePipeParts(cleanLine.replace(/^#+/, "").trim());
- currentType = { id: context.uid("equipmentType"), title, icon: meta.icon || "shield", collapsed: parseBoolean(meta.collapsed) };
- types.push(currentType);
- currentEquipment = null;
- section = "";
- return;
- }
- if (["Stats", "Sockets", "Craft"].includes(cleanLine)) {
- if (!currentEquipment) throw lineError(index, `${cleanLine} sans équipement.`);
- section = cleanLine;
- currentSocket = null;
- return;
- }
- if (!currentEquipment || !section) throw lineError(index, `ligne équipement invalide: ${cleanLine}`);
- if (section === "Stats") {
- const [category = "", icon = "sword", name = "", value = ""] = cleanLine.split("|").map((part) => part.trim());
- currentEquipment.characteristics.push({ id: context.uid("trait"), category, icon, name, value });
- }
- if (section === "Sockets") {
- if (cleanLine.startsWith("bonus")) {
- if (!currentSocket) throw lineError(index, "bonus sans objet serti.");
- const [, category = "", icon = "sword", name = "", value = ""] = cleanLine.split("|").map((part) => part.trim());
- currentSocket.bonuses.push({ id: context.uid("socketBonus"), category, icon, name, value });
- } else if (cleanLine.startsWith("link")) {
- const [, fromName = "", toName = ""] = cleanLine.split("|").map((part) => part.trim());
- const fromSocketItemId = socketsByName.get(fromName);
- const toSocketItemId = socketsByName.get(toName);
- if (!fromSocketItemId || !toSocketItemId) throw lineError(index, "lien de sertissage invalide.");
- currentEquipment.socketLinks.push({ id: context.uid("socketLink"), fromSocketItemId, toSocketItemId });
- } else {
- const { title, meta } = parsePipeParts(cleanLine);
- currentSocket = { id: context.uid("socketItem"), name: title, shape: meta.shape || "jewel", color: meta.color || "yellow", bonuses: [] };
- currentEquipment.socketItems.push(currentSocket);
- socketsByName.set(title, currentSocket.id);
- }
- }
- if (section === "Craft") {
- const meta = parseMetaLine(cleanLine);
- if (meta?.key === "obtain") {
- currentEquipment.obtain = meta.value.replace(/\\n/g, "\n");
- } else {
- const [entry] = parseColonImportLines(cleanLine);
- if (!entry) throw lineError(index, `matériau invalide: ${cleanLine}`);
- currentEquipment.materials.push({ id: context.uid("material"), name: entry.label, qty: Math.max(1, parseInteger(entry.value, 1)) });
- }
- }
- });
- return context.normalizeEquipmentPlannerData({ types, equipments, typeOrder: types.map((type) => type.id) });
-}
diff --git a/website/src/features/toolboxes/modules/textImport/basic.js b/website/src/features/toolboxes/modules/textImport/basic.js
new file mode 100644
index 0000000..d9871e2
--- /dev/null
+++ b/website/src/features/toolboxes/modules/textImport/basic.js
@@ -0,0 +1,259 @@
+// Rôle : gère l'import/export texte des outils simples et tabulaires.
+import { cellAddress, columnIndexToName } from "../tableFormulaEngine.js";
+import { cloneDrawingData, lineError, parseBoolean, parseColonImportLines, parseInteger, parseMetaLine, parseNumber, splitLines, textToNotepadExchangeHtml } from "./shared.js";
+
+export function exportNotepadText(data) {
+ const html = String(data?.html || "").trim() || textToNotepadExchangeHtml(data?.text || "");
+ const lines = ["@notepad", "@html", html, "@endhtml"];
+ if (data?.drawingMode === "permanent") lines.push("@drawingMode: permanent");
+ if (data?.drawings?.strokes?.length) lines.push(`@drawings: ${JSON.stringify(cloneDrawingData(data.drawings))}`);
+ return lines.join("\n");
+}
+
+
+export function cloneImportedNotepadDrawings(drawings, context) {
+ if (!drawings || typeof drawings !== "object" || !Array.isArray(drawings.strokes)) throw new Error("dessins invalides.");
+ return {
+ strokes: drawings.strokes.map((stroke) => {
+ if (!stroke || typeof stroke !== "object" || !Array.isArray(stroke.points) || stroke.points.length < 2) throw new Error("trait de dessin invalide.");
+ return {
+ ...stroke,
+ id: context.uid("stroke"),
+ points: stroke.points.map((point) => {
+ const x = Number(point?.x);
+ const y = Number(point?.y);
+ if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error("point de dessin invalide.");
+ return { x, y };
+ })
+ };
+ })
+ };
+}
+
+
+export function importNotepadText(text, context) {
+ const lines = splitLines(text);
+ const hasStructuredMarker = lines.some((line) => ["@notepad", "@html", "@endhtml"].includes(line.trim()) || parseMetaLine(line));
+ if (!hasStructuredMarker) return context.normalizeNotepadData({ text });
+
+ let readingHtml = false;
+ let htmlWasOpened = false;
+ let htmlWasClosed = false;
+ const htmlLines = [];
+ let drawingMode = "temporary";
+ let drawings = { strokes: [] };
+ lines.forEach((line, index) => {
+ const cleanLine = line.trim();
+ if (readingHtml) {
+ if (cleanLine === "@endhtml") {
+ readingHtml = false;
+ htmlWasClosed = true;
+ return;
+ }
+ htmlLines.push(line);
+ return;
+ }
+ if (!cleanLine || cleanLine === "@notepad") return;
+ if (cleanLine === "@html") {
+ if (htmlWasOpened) throw lineError(index, "bloc html dupliqué.");
+ readingHtml = true;
+ htmlWasOpened = true;
+ return;
+ }
+ const meta = parseMetaLine(cleanLine);
+ if (meta?.key === "drawingMode") {
+ if (!["temporary", "permanent"].includes(meta.value)) throw lineError(index, "mode de dessin invalide.");
+ drawingMode = meta.value;
+ return;
+ }
+ if (meta?.key === "drawings") {
+ try {
+ drawings = cloneImportedNotepadDrawings(JSON.parse(meta.value), context);
+ } catch (error) {
+ throw lineError(index, error?.message || "dessins invalides.");
+ }
+ if (drawings.strokes.length) drawingMode = "permanent";
+ return;
+ }
+ throw lineError(index, `ligne bloc notes invalide: ${cleanLine}`);
+ });
+ if (readingHtml || (htmlWasOpened && !htmlWasClosed)) throw new Error("Bloc HTML non terminé.");
+ return context.normalizeNotepadData({ html: htmlLines.join("\n").trim(), drawingMode, drawings });
+}
+
+
+export function exportChecklistText(data) {
+ return (data.sections || []).flatMap((section) => [
+ section.title ? `# ${section.title}` : "",
+ ...(section.items || []).map((item) => `${item.label}: ${Math.max(1, parseInteger(item.qtyTarget, 1))}`)
+ ]).filter((line, index, lines) => line || lines[index + 1]).join("\n").trim();
+}
+
+
+export function importChecklistText(text, context) {
+ const sections = [];
+ let current = { id: context.uid("section"), title: "", items: [] };
+ splitLines(text).forEach((line) => {
+ const cleanLine = line.trim();
+ if (!cleanLine) return;
+ if (cleanLine.startsWith("#")) {
+ if (current.items.length) sections.push(current);
+ current = { id: context.uid("section"), title: cleanLine.replace(/^#+/, "").trim(), items: [] };
+ return;
+ }
+ parseColonImportLines(cleanLine).forEach((entry) => {
+ current.items.push({
+ id: context.uid("item"),
+ label: entry.label,
+ qtyTarget: Math.max(1, parseInteger(String(entry.value).split("/").pop(), 1)),
+ qtyCurrent: 0
+ });
+ });
+ });
+ if (current.items.length) sections.push(current);
+ return context.normalizeChecklistData({ sections });
+}
+
+
+export function exportLinksText(data) {
+ return (data.links || []).map((link) => link.title ? `${link.title}: ${link.url}` : link.url).join("\n");
+}
+
+
+export function importLinksText(text, context) {
+ const links = [];
+ splitLines(text).forEach((rawLine, index) => {
+ const line = rawLine.trim();
+ if (!line) return;
+ const separatorIndex = line.indexOf(":");
+ const maybeUrl = separatorIndex >= 0 ? line.slice(separatorIndex + 1).trim() : line;
+ const title = separatorIndex >= 0 && !/^[a-z][a-z0-9+.-]*:\/\//i.test(line) ? line.slice(0, separatorIndex).trim() : "";
+ const url = context.normalizeUrl(title ? maybeUrl : line);
+ if (!url) throw lineError(index, `URL invalide: ${line}`);
+ links.push({ id: context.uid("link"), title, url });
+ });
+ return context.normalizeLinksData({ links });
+}
+
+
+export function exportCountersText(data) {
+ return (data.counters || []).map((counter) => `${counter.label}: ${counter.value}`).join("\n");
+}
+
+
+export function importCountersText(text, context) {
+ const counters = [];
+ splitLines(text).forEach((line, index) => {
+ const cleanLine = line.trim();
+ if (!cleanLine) return;
+ const separatorIndex = cleanLine.indexOf(":");
+ const entry = separatorIndex === -1
+ ? { label: cleanLine, value: "" }
+ : { label: cleanLine.slice(0, separatorIndex).trim(), value: cleanLine.slice(separatorIndex + 1).trim() };
+ if (!entry.label) return;
+ if (!entry.value && entry.value !== "0") throw lineError(index, `valeur manquante pour ${entry.label}.`);
+ const value = Number.parseInt(entry.value, 10);
+ if (!Number.isFinite(value)) throw lineError(index, `valeur invalide pour ${entry.label}.`);
+ counters.push({ id: context.uid("counter"), label: entry.label, value });
+ });
+ return context.normalizeCountersData({ counters });
+}
+
+
+export function exportCalculatorText(data) {
+ const lines = [];
+ if (data.scrollResults) lines.push("@scrollResults: true");
+ function visit(parentId = "", depth = 0) {
+ (data.entries || []).filter((entry) => (entry.parentId || "") === parentId).forEach((entry) => {
+ const formula = String(entry.formula || "").trim();
+ lines.push(`${" ".repeat(depth)}${entry.label || ""}: ${entry.value}${formula ? ` | formula=${formula}` : ""}`);
+ visit(entry.id, depth + 1);
+ });
+ }
+ visit();
+ return lines.join("\n");
+}
+
+
+export function importCalculatorText(text, context) {
+ const entries = [];
+ const parentStack = [];
+ let scrollResults = false;
+ splitLines(text).forEach((line, index) => {
+ if (!line.trim()) return;
+ const meta = parseMetaLine(line);
+ if (meta?.key === "scrollResults") {
+ scrollResults = parseBoolean(meta.value);
+ return;
+ }
+ const depth = Math.floor((line.match(/^ */)?.[0].length || 0) / 2);
+ const separatorIndex = line.indexOf(":");
+ if (separatorIndex < 0) throw lineError(index, `ligne calculateur invalide: ${line.trim()}`);
+ const label = line.slice(0, separatorIndex).trim();
+ const [rawValue, rawFormula = ""] = line.slice(separatorIndex + 1).split(/\s+\|\s+formula=/);
+ const value = parseNumber(rawValue, Number.NaN);
+ if (!Number.isFinite(value)) throw lineError(index, `valeur calculateur invalide: ${line.trim()}`);
+ const entry = { id: context.uid("calc"), parentId: parentStack[depth - 1] || "", label, value, formula: rawFormula.trim() };
+ entries.push(entry);
+ parentStack[depth] = entry.id;
+ parentStack.length = depth + 1;
+ });
+ return context.normalizeCalculatorData({ entries, scrollResults });
+}
+
+
+export function exportTableTsv(data) {
+ const lines = [];
+ if (data.rows !== 10 || data.columns !== 6) lines.push(`@size: ${data.rows}x${data.columns}`);
+ if (Object.keys(data.columnLabels || {}).length) lines.push(`@columns: ${Array.from({ length: data.columns }, (_, index) => data.columnLabels?.[index] || columnIndexToName(index)).join("\t")}`);
+ if (Object.keys(data.rowLabels || {}).length) lines.push(`@rows: ${Array.from({ length: data.rows }, (_, index) => data.rowLabels?.[index] || index + 1).join("\t")}`);
+ if (lines.length) lines.push("@tsv");
+ for (let rowIndex = 0; rowIndex < data.rows; rowIndex += 1) {
+ lines.push(Array.from({ length: data.columns }, (_, columnIndex) => data.cells?.[cellAddress(rowIndex, columnIndex)] || "").join("\t"));
+ }
+ return lines.join("\n").replace(/\n+$/, "");
+}
+
+
+export function importTableTsv(text) {
+ const lines = splitLines(text);
+ let rows = 0;
+ let columns = 0;
+ let rowLabels = {};
+ let columnLabels = {};
+ let dataLines = lines;
+ const metaLines = [];
+ const tsvIndex = lines.findIndex((line) => line.trim() === "@tsv");
+ if (tsvIndex >= 0) {
+ metaLines.push(...lines.slice(0, tsvIndex));
+ dataLines = lines.slice(tsvIndex + 1);
+ }
+ metaLines.forEach((line, index) => {
+ const meta = parseMetaLine(line);
+ if (!meta) throw lineError(index, `meta tableau invalide: ${line}`);
+ if (meta.key === "size") {
+ const match = meta.value.match(/^(\d+)x(\d+)$/);
+ if (!match) throw lineError(index, "taille de tableau invalide.");
+ rows = parseInteger(match[1], 0);
+ columns = parseInteger(match[2], 0);
+ }
+ if (meta.key === "columns") {
+ columnLabels = Object.fromEntries(meta.value.split("\t").map((value, index) => [index, value]).filter(([index, value]) => value && value !== columnIndexToName(index)));
+ }
+ if (meta.key === "rows") {
+ rowLabels = Object.fromEntries(meta.value.split("\t").map((value, index) => [index, value]).filter(([index, value]) => value && value !== String(index + 1)));
+ }
+ });
+ const tableRows = dataLines.filter((line) => line.trim() || line.includes("\t")).map((line) => line.split("\t"));
+ if (!tableRows.length) throw new Error("TSV vide.");
+ rows = rows || tableRows.length;
+ columns = columns || Math.max(...tableRows.map((row) => row.length));
+ const cells = {};
+ for (let rowIndex = 0; rowIndex < Math.min(rows, 50); rowIndex += 1) {
+ for (let columnIndex = 0; columnIndex < Math.min(columns, 20); columnIndex += 1) {
+ const value = tableRows[rowIndex]?.[columnIndex] || "";
+ if (value.trim()) cells[cellAddress(rowIndex, columnIndex)] = value;
+ }
+ }
+ return { rows, columns, cells, rowLabels, columnLabels };
+}
diff --git a/website/src/features/toolboxes/modules/textImport/combos.js b/website/src/features/toolboxes/modules/textImport/combos.js
new file mode 100644
index 0000000..2ac8d04
--- /dev/null
+++ b/website/src/features/toolboxes/modules/textImport/combos.js
@@ -0,0 +1,84 @@
+// Rôle : gère l'import/export texte des combos de commandes.
+import { formatMeta, lineError, parseInteger, parseMetaLine, parsePipeParts, splitLines } from "./shared.js";
+
+const COMBO_DEVICES = new Set(["playstation", "xbox", "switch", "n64", "keyboardMouse"]);
+const COMBO_INPUT_KINDS = new Set(["button", "direction", "key", "mouse"]);
+
+export function exportComboInput(input) {
+ let suffix = "";
+ if (input.holdMs) suffix = `[${Math.round(input.holdMs / 1000)}s]`;
+ else if (input.hold) suffix = "[hold]";
+ return `${input.kind}:${input.value}${suffix}`;
+}
+
+
+export function parseComboInput(text) {
+ const match = String(text || "").trim().match(/^([a-zA-Z]+):([^[]+?)(?:\[(hold|\d+s)\])?$/);
+ if (!match || !COMBO_INPUT_KINDS.has(match[1])) throw new Error(`Input combo invalide: ${text}`);
+ const input = { kind: match[1], value: match[2].trim() };
+ if (!input.value) throw new Error(`Input combo invalide: ${text}`);
+ if (match[3] === "hold") input.hold = true;
+ if (match[3]?.endsWith("s")) {
+ input.hold = true;
+ input.holdMs = Math.max(1000, Math.min(99000, parseInteger(match[3], 1) * 1000));
+ }
+ return input;
+}
+
+
+export function exportCombosText(data) {
+ const lines = [];
+ if (data.device && data.device !== "playstation") lines.push(`@device: ${data.device}`);
+ if (data.collapsedCategories?.length) lines.push(`@collapsed: ${data.collapsedCategories.join(", ")}`);
+ let lastCategory = null;
+ (data.combos || []).forEach((combo) => {
+ const category = combo.category || "";
+ if (category && category !== lastCategory) {
+ lines.push(`# ${category}`);
+ lastCategory = category;
+ }
+ const meta = formatMeta({ device: combo.device || "" });
+ const sequence = (combo.inputs || []).map((step) => step.map(exportComboInput).join("+")).join(" > ");
+ lines.push([combo.name, meta, sequence].filter(Boolean).join(" | "));
+ });
+ return lines.join("\n");
+}
+
+
+export function importCombosText(text, context) {
+ const combos = [];
+ let device = "playstation";
+ let collapsedCategories = [];
+ let category = "";
+ splitLines(text).forEach((line, index) => {
+ const cleanLine = line.trim();
+ if (!cleanLine) return;
+ const meta = parseMetaLine(cleanLine);
+ if (meta?.key === "device") {
+ if (!COMBO_DEVICES.has(meta.value)) throw lineError(index, "périphérique combo invalide.");
+ device = meta.value;
+ return;
+ }
+ if (meta?.key === "collapsed") {
+ collapsedCategories = meta.value.split(",").map((item) => item.trim()).filter(Boolean);
+ return;
+ }
+ if (cleanLine.startsWith("#")) {
+ category = cleanLine.replace(/^#+/, "").trim();
+ return;
+ }
+ const { title, meta: lineMeta } = parsePipeParts(cleanLine);
+ const sequence = cleanLine.split("|").map((part) => part.trim()).filter((part, index) => index > 0 && !part.includes("=")).join(" | ");
+ const comboDevice = lineMeta.device || device;
+ if (!COMBO_DEVICES.has(comboDevice)) throw lineError(index, `périphérique combo invalide: ${comboDevice}`);
+ let inputs;
+ try {
+ inputs = sequence ? String(sequence).split(">").map((step) => step.split("+").map(parseComboInput)) : [];
+ } catch (error) {
+ throw lineError(index, error?.message || "input combo invalide.");
+ }
+ combos.push({ id: context.uid("combo"), name: title || "Combo", category, device: comboDevice, inputs });
+ });
+ return context.normalizeCombosData({ device, collapsedCategories, combos });
+}
+
diff --git a/website/src/features/toolboxes/modules/textImport/equipment.js b/website/src/features/toolboxes/modules/textImport/equipment.js
new file mode 100644
index 0000000..b759f63
--- /dev/null
+++ b/website/src/features/toolboxes/modules/textImport/equipment.js
@@ -0,0 +1,114 @@
+// Rôle : gère l'import/export texte du planificateur d'équipements.
+import { formatMeta, lineError, parseBoolean, parseColonImportLines, parseInteger, parseMetaLine, parsePipeParts, splitLines } from "./shared.js";
+
+export function exportEquipmentPlannerText(data) {
+ const lines = [];
+ const typesById = new Map((data.types || []).map((type) => [type.id, type]));
+ (data.types || [{ id: "", title: "Équipements", icon: "shield", collapsed: false }]).forEach((type) => {
+ const equipments = (data.equipments || []).filter((equipment) => type.id ? equipment.typeId === type.id : !typesById.has(equipment.typeId));
+ if (!equipments.length) return;
+ lines.push(`# ${type.title} | ${formatMeta({ icon: type.icon, collapsed: type.collapsed })}`);
+ equipments.forEach((equipment) => {
+ lines.push(`## ${equipment.name} | ${formatMeta({ icon: equipment.icon, active: equipment.active !== false })}`);
+ if (equipment.characteristics?.length) {
+ lines.push("Stats");
+ equipment.characteristics.forEach((trait) => lines.push(`${trait.category || ""} | ${trait.icon || "sword"} | ${trait.name} | ${trait.value ?? ""}`));
+ }
+ if (equipment.socketItems?.length) {
+ lines.push("Sockets");
+ equipment.socketItems.forEach((socketItem) => {
+ lines.push(`${socketItem.name} | shape=${socketItem.shape} | color=${socketItem.color}`);
+ (socketItem.bonuses || []).forEach((bonus) => lines.push(` bonus | ${bonus.category || ""} | ${bonus.icon || "sword"} | ${bonus.name} | ${bonus.value ?? ""}`));
+ });
+ if (equipment.socketLinks?.length) {
+ const socketItemsById = new Map(equipment.socketItems.map((socketItem) => [socketItem.id, socketItem.name]));
+ equipment.socketLinks.forEach((link) => {
+ const fromName = socketItemsById.get(link.fromSocketItemId);
+ const toName = socketItemsById.get(link.toSocketItemId);
+ if (fromName && toName) lines.push(` link | ${fromName} | ${toName}`);
+ });
+ }
+ }
+ if (equipment.obtain || equipment.materials?.length) {
+ lines.push("Craft");
+ if (equipment.obtain) lines.push(`@obtain: ${equipment.obtain.replace(/\n/g, "\\n")}`);
+ (equipment.materials || []).forEach((material) => lines.push(`${material.name}: ${material.qty}`));
+ }
+ });
+ });
+ return lines.join("\n");
+}
+
+
+export function importEquipmentPlannerText(text, context) {
+ const types = [];
+ const equipments = [];
+ let currentType = null;
+ let currentEquipment = null;
+ let section = "";
+ let currentSocket = null;
+ const socketsByName = new Map();
+ splitLines(text).forEach((line, index) => {
+ const cleanLine = line.trim();
+ if (!cleanLine) return;
+ if (cleanLine.startsWith("##")) {
+ if (!currentType) throw lineError(index, "équipement sans type.");
+ const { title, meta } = parsePipeParts(cleanLine.replace(/^##+/, "").trim());
+ currentEquipment = { id: context.uid("equipment"), typeId: currentType.id, name: title, icon: meta.icon || "shield", active: meta.active !== "false", obtain: "", characteristics: [], socketItems: [], socketLinks: [], materials: [], categoryOrder: [], collapsedCategories: [] };
+ equipments.push(currentEquipment);
+ section = "";
+ currentSocket = null;
+ socketsByName.clear();
+ return;
+ }
+ if (cleanLine.startsWith("#")) {
+ const { title, meta } = parsePipeParts(cleanLine.replace(/^#+/, "").trim());
+ currentType = { id: context.uid("equipmentType"), title, icon: meta.icon || "shield", collapsed: parseBoolean(meta.collapsed) };
+ types.push(currentType);
+ currentEquipment = null;
+ section = "";
+ return;
+ }
+ if (["Stats", "Sockets", "Craft"].includes(cleanLine)) {
+ if (!currentEquipment) throw lineError(index, `${cleanLine} sans équipement.`);
+ section = cleanLine;
+ currentSocket = null;
+ return;
+ }
+ if (!currentEquipment || !section) throw lineError(index, `ligne équipement invalide: ${cleanLine}`);
+ if (section === "Stats") {
+ const [category = "", icon = "sword", name = "", value = ""] = cleanLine.split("|").map((part) => part.trim());
+ currentEquipment.characteristics.push({ id: context.uid("trait"), category, icon, name, value });
+ }
+ if (section === "Sockets") {
+ if (cleanLine.startsWith("bonus")) {
+ if (!currentSocket) throw lineError(index, "bonus sans objet serti.");
+ const [, category = "", icon = "sword", name = "", value = ""] = cleanLine.split("|").map((part) => part.trim());
+ currentSocket.bonuses.push({ id: context.uid("socketBonus"), category, icon, name, value });
+ } else if (cleanLine.startsWith("link")) {
+ const [, fromName = "", toName = ""] = cleanLine.split("|").map((part) => part.trim());
+ const fromSocketItemId = socketsByName.get(fromName);
+ const toSocketItemId = socketsByName.get(toName);
+ if (!fromSocketItemId || !toSocketItemId) throw lineError(index, "lien de sertissage invalide.");
+ currentEquipment.socketLinks.push({ id: context.uid("socketLink"), fromSocketItemId, toSocketItemId });
+ } else {
+ const { title, meta } = parsePipeParts(cleanLine);
+ currentSocket = { id: context.uid("socketItem"), name: title, shape: meta.shape || "jewel", color: meta.color || "yellow", bonuses: [] };
+ currentEquipment.socketItems.push(currentSocket);
+ socketsByName.set(title, currentSocket.id);
+ }
+ }
+ if (section === "Craft") {
+ const meta = parseMetaLine(cleanLine);
+ if (meta?.key === "obtain") {
+ currentEquipment.obtain = meta.value.replace(/\\n/g, "\n");
+ } else {
+ const [entry] = parseColonImportLines(cleanLine);
+ if (!entry) throw lineError(index, `matériau invalide: ${cleanLine}`);
+ currentEquipment.materials.push({ id: context.uid("material"), name: entry.label, qty: Math.max(1, parseInteger(entry.value, 1)) });
+ }
+ }
+ });
+ return context.normalizeEquipmentPlannerData({ types, equipments, typeOrder: types.map((type) => type.id) });
+}
+
diff --git a/website/src/features/toolboxes/modules/textImport/media.js b/website/src/features/toolboxes/modules/textImport/media.js
new file mode 100644
index 0000000..ef1dff6
--- /dev/null
+++ b/website/src/features/toolboxes/modules/textImport/media.js
@@ -0,0 +1,67 @@
+// Rôle : gère l'import/export texte des outils images et annotations.
+import { cloneDrawingData, isValidImageDataUrl, lineError, parseMetaLine, parseNumber, splitLines } from "./shared.js";
+
+export function exportImagesText(data) {
+ return (data.images || []).flatMap((image) => [
+ image.label ? `# ${image.label}` : "#",
+ image.dataUrl
+ ]).join("\n");
+}
+
+
+export function importImagesText(text, context) {
+ const images = [];
+ let label = "";
+ splitLines(text).forEach((line, index) => {
+ const cleanLine = line.trim();
+ if (!cleanLine) return;
+ if (cleanLine.startsWith("#")) {
+ label = cleanLine.replace(/^#+/, "").trim();
+ return;
+ }
+ if (!isValidImageDataUrl(cleanLine)) throw lineError(index, "image base64 invalide.");
+ images.push({ id: context.uid("image"), label, dataUrl: cleanLine });
+ label = "";
+ });
+ return { images };
+}
+
+
+export function exportImageAnnotationText(data) {
+ const lines = [data.image];
+ (data.markers || []).forEach((marker) => lines.push(`@marker: ${marker.x},${marker.y},${marker.label || ""}`));
+ if (data.drawings?.strokes?.length) lines.push(`@drawings: ${JSON.stringify(cloneDrawingData(data.drawings))}`);
+ return lines.join("\n");
+}
+
+
+export function importImageAnnotationText(text, context) {
+ const markers = [];
+ let image = "";
+ let drawings = { strokes: [] };
+ splitLines(text).forEach((line, index) => {
+ const cleanLine = line.trim();
+ if (!cleanLine) return;
+ const meta = parseMetaLine(cleanLine);
+ if (meta?.key === "marker") {
+ const [x, y, ...labelParts] = meta.value.split(",");
+ const marker = { id: context.uid("marker"), x: parseNumber(x, Number.NaN), y: parseNumber(y, Number.NaN), label: labelParts.join(",").trim() };
+ if (!Number.isFinite(marker.x) || !Number.isFinite(marker.y) || marker.x < 0 || marker.x > 100 || marker.y < 0 || marker.y > 100) throw lineError(index, "marqueur invalide.");
+ markers.push(marker);
+ return;
+ }
+ if (meta?.key === "drawings") {
+ try {
+ drawings = JSON.parse(meta.value);
+ } catch {
+ throw lineError(index, "dessins invalides.");
+ }
+ return;
+ }
+ if (image) throw lineError(index, "une seule image peut être importée.");
+ if (!isValidImageDataUrl(cleanLine)) throw lineError(index, "image base64 invalide.");
+ image = cleanLine;
+ });
+ return context.normalizeImageAnnotationData({ image, markers, drawings });
+}
+
diff --git a/website/src/features/toolboxes/modules/textImport/planning.js b/website/src/features/toolboxes/modules/textImport/planning.js
new file mode 100644
index 0000000..ac5724c
--- /dev/null
+++ b/website/src/features/toolboxes/modules/textImport/planning.js
@@ -0,0 +1,172 @@
+// Rôle : gère l'import/export texte des plans de tâches et arbres d'objectifs.
+import { formatMeta, lineError, parseBoolean, parseInteger, parseMetaLine, parseNumber, parsePipeParts, splitLines } from "./shared.js";
+
+export function exportTaskPlannerText(data) {
+ const lines = [];
+ if (data.weeklyResetDay !== 1) lines.push(`@weeklyResetDay: ${data.weeklyResetDay}`);
+ if (data.resetTime && data.resetTime !== "00:00") lines.push(`@resetTime: ${data.resetTime}`);
+ if (data.hideCompleted) lines.push("@hideCompleted: true");
+ if (data.collapsedCategories?.length) lines.push(`@collapsed: ${data.collapsedCategories.join(", ")}`);
+ const parentByChild = new Map((data.relations || []).map((relation) => [relation.fromTaskId, relation]));
+ const childrenByParent = new Map();
+ (data.tasks || []).forEach((task) => {
+ const parentId = parentByChild.get(task.id)?.toTaskId || "";
+ childrenByParent.set(parentId, [...(childrenByParent.get(parentId) || []), task]);
+ });
+ function writeTask(task, depth = 0) {
+ const prefix = " ".repeat(depth);
+ lines.push(`${prefix}- ${task.type || "unique"} ${task.title}`);
+ if (task.description) lines.push(`${prefix} @description: ${task.description.replace(/\n/g, "\\n")}`);
+ if (task.dailyResetTime) lines.push(`${prefix} @dailyResetTime: ${task.dailyResetTime}`);
+ if (task.weeklyResetDay !== undefined) lines.push(`${prefix} @weeklyResetDay: ${task.weeklyResetDay}`);
+ if (parentByChild.get(task.id)?.prerequisite) lines.push(`${prefix} @prerequisite: true`);
+ (childrenByParent.get(task.id) || []).forEach((child) => writeTask(child, depth + 1));
+ }
+ let lastCategory = null;
+ (childrenByParent.get("") || []).forEach((task) => {
+ const category = task.category || "";
+ if (category && category !== lastCategory) {
+ lines.push(`# ${category}`);
+ lastCategory = category;
+ }
+ writeTask(task);
+ });
+ return lines.join("\n");
+}
+
+
+export function importTaskPlannerText(text, context) {
+ const tasks = [];
+ const relations = [];
+ const stack = [];
+ let category = "";
+ const data = { weeklyResetDay: 1, resetTime: "00:00", hideCompleted: false, collapsedCategories: [], tasks, relations };
+ splitLines(text).forEach((line, index) => {
+ if (!line.trim()) return;
+ const cleanLine = line.trim();
+ const rootMeta = parseMetaLine(cleanLine);
+ if (rootMeta && !stack.length) {
+ if (rootMeta.key === "weeklyResetDay") data.weeklyResetDay = parseInteger(rootMeta.value, 1);
+ if (rootMeta.key === "resetTime") data.resetTime = rootMeta.value;
+ if (rootMeta.key === "hideCompleted") data.hideCompleted = parseBoolean(rootMeta.value);
+ if (rootMeta.key === "collapsed") data.collapsedCategories = rootMeta.value.split(",").map((item) => item.trim()).filter(Boolean);
+ return;
+ }
+ if (cleanLine.startsWith("#")) {
+ category = cleanLine.replace(/^#+/, "").trim();
+ return;
+ }
+ const depth = Math.floor((line.match(/^ */)?.[0].length || 0) / 2);
+ const meta = parseMetaLine(cleanLine);
+ if (meta) {
+ const task = stack[Math.max(0, depth - 1)];
+ if (!task) throw lineError(index, `meta de tâche sans tâche: ${cleanLine}`);
+ if (meta.key === "description") task.description = meta.value.replace(/\\n/g, "\n");
+ if (meta.key === "dailyResetTime") task.dailyResetTime = meta.value;
+ if (meta.key === "weeklyResetDay") task.weeklyResetDay = parseInteger(meta.value, 1);
+ if (meta.key === "prerequisite") {
+ const relation = relations.find((item) => item.fromTaskId === task.id);
+ if (relation) relation.prerequisite = parseBoolean(meta.value);
+ }
+ return;
+ }
+ const match = cleanLine.match(/^-\s+(unique|daily|weekly)\s+(.+)$/);
+ if (!match) throw lineError(index, `ligne tâche invalide: ${cleanLine}`);
+ const task = { id: context.uid("task"), title: match[2].trim(), description: "", type: match[1], checked: false, checkedAt: 0 };
+ const parent = stack[depth - 1];
+ if (parent) relations.push({ id: context.uid("relation"), fromTaskId: task.id, toTaskId: parent.id, prerequisite: false });
+ else if (category) task.category = category;
+ tasks.push(task);
+ stack[depth] = task;
+ stack.length = depth + 1;
+ });
+ return context.normalizeTaskPlannerData(data);
+}
+
+
+export function exportGoalTreeText(data) {
+ const lines = [];
+ if (data.lastResetAt) lines.push(`@lastResetAt: ${data.lastResetAt}`);
+ function visit(parentId = "", depth = 0) {
+ (data.nodes || []).filter((node) => (node.parentId || "") === parentId).forEach((node) => {
+ const prefix = " ".repeat(depth);
+ const formula = String(node.formula || "").trim();
+ const qty = Number.isFinite(node.computedQtyTarget) ? node.computedQtyTarget : node.qtyTarget;
+ const suffix = node.kind === "step" ? (formula ? ` = ${formula}` : ` x${qty}`) : "";
+ const meta = formatMeta({
+ kind: node.kind !== "step" ? node.kind : "",
+ icon: node.kind === "step" && node.icon && node.icon !== "cube" ? node.icon : "",
+ color: node.kind === "step" && node.color && node.color !== "gold" ? node.color : "",
+ items: node.kind !== "step" && node.items?.length ? encodeURIComponent(JSON.stringify(node.items)) : "",
+ current: node.qtyCurrent || "",
+ reset: node.resetType !== "none" ? node.resetType : "",
+ resetTime: node.resetTime && node.resetTime !== "00:00" ? node.resetTime : "",
+ weeklyResetDay: node.weeklyResetDay !== 1 ? node.weeklyResetDay : "",
+ collapsed: node.collapsed
+ });
+ lines.push(`${prefix}- ${node.title}${suffix}${meta ? ` | ${meta}` : ""}`);
+ visit(node.id, depth + 1);
+ });
+ }
+ visit();
+ return lines.join("\n");
+}
+
+
+export function importGoalTreeText(text, context) {
+ const nodes = [];
+ const stack = [];
+ const data = { lastResetAt: 0, nodes };
+ splitLines(text).forEach((line, index) => {
+ if (!line.trim()) return;
+ const cleanLine = line.trim();
+ const rootMeta = parseMetaLine(cleanLine);
+ if (rootMeta && !stack.length) {
+ if (rootMeta.key === "lastResetAt") data.lastResetAt = parseInteger(rootMeta.value, 0);
+ return;
+ }
+ const depth = Math.floor((line.match(/^ */)?.[0].length || 0) / 2);
+ const match = cleanLine.match(/^-\s+(.+)$/);
+ if (!match) throw lineError(index, `ligne arbre d'objectifs invalide: ${cleanLine}`);
+ const { title: rawTitle, meta } = parsePipeParts(match[1]);
+ const formulaIndex = rawTitle.lastIndexOf("=");
+ const quantityMatch = rawTitle.match(/\s+x(\d+(?:[,.]\d+)?)\s*$/i);
+ const hasFormula = formulaIndex >= 0;
+ const title = (hasFormula ? rawTitle.slice(0, formulaIndex) : rawTitle.replace(/\s+x(\d+(?:[,.]\d+)?)\s*$/i, "")).trim();
+ if (!title) throw lineError(index, "titre manquant.");
+ const parent = stack[depth - 1];
+ const node = {
+ id: context.uid("goal"),
+ parentId: parent?.id || "",
+ title,
+ kind: meta.kind || "step",
+ icon: meta.icon || "cube",
+ color: meta.color || "gold",
+ qtyTarget: hasFormula ? 1 : Math.max(0, parseNumber(quantityMatch?.[1], 1)) || 1,
+ qtyCurrent: 0,
+ formula: hasFormula ? rawTitle.slice(formulaIndex + 1).trim() : "",
+ items: parseGoalTreeItemsMeta(meta.items),
+ resetType: meta.reset || "none",
+ resetTime: meta.resetTime || "00:00",
+ weeklyResetDay: parseInteger(meta.weeklyResetDay, 1),
+ checkedAt: 0,
+ collapsed: parseBoolean(meta.collapsed)
+ };
+ nodes.push(node);
+ stack[depth] = node;
+ stack.length = depth + 1;
+ });
+ return context.normalizeGoalTreeData(data);
+}
+
+
+export function parseGoalTreeItemsMeta(value) {
+ if (!value) return [];
+ try {
+ const parsed = JSON.parse(decodeURIComponent(value));
+ return Array.isArray(parsed) ? parsed : [];
+ } catch {
+ return [];
+ }
+}
+
diff --git a/website/src/features/toolboxes/modules/textImport/shared.js b/website/src/features/toolboxes/modules/textImport/shared.js
new file mode 100644
index 0000000..7b647bb
--- /dev/null
+++ b/website/src/features/toolboxes/modules/textImport/shared.js
@@ -0,0 +1,94 @@
+// Rôle : fournit les helpers communs aux formats texte des outils toolbox.
+const IMAGE_DATA_URL_PATTERN = /^data:image\/[a-z0-9.+-]+;base64,[a-z0-9+/=\s]+$/i;
+
+export function parseColonImportLines(text) {
+ return String(text || "")
+ .split(/\r?\n/)
+ .map((line) => line.trim())
+ .filter(Boolean)
+ .map((line) => {
+ const separatorIndex = line.indexOf(":");
+ if (separatorIndex === -1) return { label: line, value: "" };
+ return {
+ label: line.slice(0, separatorIndex).trim(),
+ value: line.slice(separatorIndex + 1).trim()
+ };
+ })
+ .filter((entry) => entry.label);
+}
+
+export function fail(error) {
+ return { ok: false, error };
+}
+
+export function splitLines(text) {
+ return String(text || "").split(/\r?\n/);
+}
+
+export function parseMetaLine(line) {
+ const match = String(line || "").trim().match(/^@([a-zA-Z][\w-]*)\s*:\s*(.*)$/);
+ return match ? { key: match[1], value: match[2] } : null;
+}
+
+export function parsePipeParts(line) {
+ const [title = "", ...parts] = String(line || "").split("|").map((part) => part.trim());
+ const meta = {};
+ parts.forEach((part) => {
+ const separatorIndex = part.indexOf("=");
+ if (separatorIndex < 0) return;
+ meta[part.slice(0, separatorIndex).trim()] = part.slice(separatorIndex + 1).trim();
+ });
+ return { title, meta };
+}
+
+export function parseBoolean(value) {
+ return String(value || "").trim().toLowerCase() === "true";
+}
+
+export function lineError(index, message) {
+ return new Error(`Ligne ${index + 1}: ${message}`);
+}
+
+export function parseNumber(value, fallback = 0) {
+ const parsed = Number(String(value || "").replace(",", "."));
+ return Number.isFinite(parsed) ? parsed : fallback;
+}
+
+export function parseInteger(value, fallback = 0) {
+ const parsed = Number.parseInt(value, 10);
+ return Number.isFinite(parsed) ? parsed : fallback;
+}
+
+export function formatMeta(meta) {
+ return Object.entries(meta)
+ .filter(([, value]) => value !== "" && value !== undefined && value !== false)
+ .map(([key, value]) => `${key}=${value}`)
+ .join(" | ");
+}
+
+export function isValidImageDataUrl(value) {
+ return IMAGE_DATA_URL_PATTERN.test(String(value || "").trim());
+}
+
+export function cloneDrawingData(drawings) {
+ return {
+ strokes: (drawings?.strokes || []).map((stroke) => ({
+ ...stroke,
+ points: (stroke.points || []).map((point) => ({ ...point }))
+ }))
+ };
+}
+
+function escapeHtml(value) {
+ return String(value || "")
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">");
+}
+
+export function textToNotepadExchangeHtml(value) {
+ return String(value || "")
+ .split(/\r?\n/)
+ .map((line) => line ? `${escapeHtml(line)}
` : "
")
+ .join("");
+}
diff --git a/website/src/features/toolboxes/modules/textImport/time.js b/website/src/features/toolboxes/modules/textImport/time.js
new file mode 100644
index 0000000..55d329f
--- /dev/null
+++ b/website/src/features/toolboxes/modules/textImport/time.js
@@ -0,0 +1,197 @@
+// Rôle : gère l'import/export texte des outils temporels timer et calendrier.
+import { CALENDAR_ALERT_MODES, CALENDAR_ALERT_OFFSETS, CALENDAR_COLORS, CALENDAR_MONTHLY_REPEAT_BY, CALENDAR_MONTH_REPEAT_MODES, minutesToTimeValue, timeValueToMinutes } from "../calendarUtils.js";
+import { getCountdownTargetMs, getDailyTargetMs, getIntervalAnchorMs } from "../timerUtils.js";
+import { formatMeta, lineError, parseBoolean, parseInteger, parseMetaLine, parsePipeParts, splitLines } from "./shared.js";
+
+const TIMER_TYPES = new Set(["duration", "daily_time", "time_pattern", "interval"]);
+const ALERT_MODES = new Set(["off", "visible", "site"]);
+const CALENDAR_ALERT_MODE_SET = new Set(CALENDAR_ALERT_MODES);
+const CALENDAR_ALERT_OFFSET_SET = new Set(CALENDAR_ALERT_OFFSETS);
+const CALENDAR_COLOR_SET = new Set(CALENDAR_COLORS);
+const CALENDAR_MONTHLY_REPEAT_BY_SET = new Set(CALENDAR_MONTHLY_REPEAT_BY);
+const CALENDAR_MONTH_REPEAT_MODE_SET = new Set(CALENDAR_MONTH_REPEAT_MODES);
+const CALENDAR_WEEKDAYS = ["lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche"];
+
+export function exportTimerText(data) {
+ const lines = [];
+ if (data.activeTab && data.activeTab !== "stopwatch") lines.push(`@activeTab: ${data.activeTab}`);
+ if (data.scrollResults) lines.push("@scrollResults: true");
+ if (data.sortResults) lines.push("@sortResults: true");
+ if (data.stopwatch?.elapsedMs) lines.push(`@stopwatchElapsedMs: ${data.stopwatch.elapsedMs}`);
+ (data.stopwatch?.laps || []).forEach((lap) => lines.push(`lap | label=${lap.label || ""} | elapsedMs=${lap.elapsedMs}`));
+ (data.countdowns || []).forEach((countdown) => {
+ const meta = { label: countdown.label, type: countdown.type, alertMode: countdown.alertMode || "", autoRefresh: countdown.autoRefresh === true };
+ if (countdown.type === "duration") meta.durationMs = countdown.durationMs;
+ if (countdown.type === "daily_time") meta.time = countdown.time;
+ if (countdown.type === "time_pattern") meta.pattern = countdown.pattern;
+ if (countdown.type === "interval") {
+ meta.intervalMs = countdown.intervalMs;
+ meta.startMode = countdown.startMode || "now";
+ meta.startTime = countdown.startTime || "";
+ }
+ lines.push(`countdown | ${formatMeta(meta)}`);
+ });
+ return lines.join("\n");
+}
+
+
+export function importTimerText(text, context) {
+ const now = Date.now();
+ const data = { activeTab: "stopwatch", scrollResults: false, sortResults: false, stopwatch: { elapsedMs: 0, startedAt: 0, laps: [] }, countdowns: [] };
+ splitLines(text).forEach((line, index) => {
+ const cleanLine = line.trim();
+ if (!cleanLine) return;
+ const meta = parseMetaLine(cleanLine);
+ if (meta) {
+ if (meta.key === "activeTab") data.activeTab = meta.value === "countdown" ? "countdown" : "stopwatch";
+ if (meta.key === "scrollResults") data.scrollResults = parseBoolean(meta.value);
+ if (meta.key === "sortResults") data.sortResults = parseBoolean(meta.value);
+ if (meta.key === "stopwatchElapsedMs") data.stopwatch.elapsedMs = Math.max(0, parseInteger(meta.value, 0));
+ return;
+ }
+ const { title, meta: lineMeta } = parsePipeParts(cleanLine);
+ if (title === "lap") {
+ data.stopwatch.laps.push({ id: context.uid("timer"), label: lineMeta.label || "", elapsedMs: Math.max(0, parseInteger(lineMeta.elapsedMs, 0)) });
+ return;
+ }
+ if (title !== "countdown") throw lineError(index, `ligne timer invalide: ${cleanLine}`);
+ const type = lineMeta.type;
+ if (!TIMER_TYPES.has(type)) throw lineError(index, `type timer invalide: ${type}`);
+ const countdown = { id: context.uid("timer"), label: lineMeta.label || "Timer", type, alertMode: ALERT_MODES.has(lineMeta.alertMode) ? lineMeta.alertMode : "off" };
+ if (type === "duration") {
+ countdown.durationMs = Math.max(1, parseInteger(lineMeta.durationMs, 0));
+ countdown.targetAt = now + countdown.durationMs;
+ }
+ if (type === "daily_time") {
+ countdown.time = lineMeta.time;
+ countdown.targetAt = getDailyTargetMs(countdown.time, now);
+ countdown.autoRefresh = parseBoolean(lineMeta.autoRefresh);
+ }
+ if (type === "time_pattern") {
+ countdown.pattern = lineMeta.pattern;
+ countdown.autoRefresh = parseBoolean(lineMeta.autoRefresh);
+ }
+ if (type === "interval") {
+ countdown.intervalMs = Math.max(1, parseInteger(lineMeta.intervalMs, 0));
+ countdown.startMode = lineMeta.startMode === "time" && lineMeta.startTime ? "time" : "now";
+ if (countdown.startMode === "time") countdown.startTime = lineMeta.startTime;
+ countdown.anchorAt = getIntervalAnchorMs(countdown.startMode, countdown.startTime, now);
+ countdown.targetAt = getCountdownTargetMs({ ...countdown, autoRefresh: true }, now);
+ countdown.autoRefresh = parseBoolean(lineMeta.autoRefresh);
+ }
+ data.countdowns.push(countdown);
+ });
+ return context.normalizeTimerData(data);
+}
+
+
+export function parseCalendarDayIndex(value) {
+ const normalized = String(value || "").trim().toLowerCase();
+ const weekdayIndex = CALENDAR_WEEKDAYS.indexOf(normalized);
+ if (weekdayIndex >= 0) return weekdayIndex;
+ const parsed = parseInteger(normalized, -1);
+ return parsed >= 0 && parsed <= 6 ? parsed : -1;
+}
+
+
+export function parseCalendarAlertOffsets(value) {
+ return String(value || "")
+ .split(",")
+ .map((item) => parseInteger(item.trim(), Number.NaN))
+ .filter((offset, index, offsets) => CALENDAR_ALERT_OFFSET_SET.has(offset) && offsets.indexOf(offset) === index)
+ .sort((a, b) => b - a);
+}
+
+
+export function formatCalendarEventMeta(event, viewMode) {
+ const alertMode = CALENDAR_ALERT_MODE_SET.has(event.alertMode) ? event.alertMode : "off";
+ const meta = {
+ title: event.title,
+ color: event.color && event.color !== "violet" ? event.color : "",
+ repeatable: event.repeatable === false ? "false" : "",
+ alertMode: alertMode !== "off" ? alertMode : "",
+ alerts: alertMode !== "off" ? (event.alertOffsetMinutes || [10]).join(",") : ""
+ };
+ if (viewMode === "month") {
+ const monthlyRepeatBy = CALENDAR_MONTHLY_REPEAT_BY_SET.has(event.monthlyRepeatBy) ? event.monthlyRepeatBy : "monthDay";
+ if (monthlyRepeatBy === "weekday") {
+ return {
+ ...meta,
+ repeatBy: "weekday",
+ repeat: event.repeat || "none",
+ startDate: event.startDate,
+ endDate: event.endDate && event.endDate !== event.startDate ? event.endDate : ""
+ };
+ }
+ return {
+ ...meta,
+ repeatBy: "monthDay",
+ repeat: event.repeat || "none",
+ startDate: event.startDate,
+ endDate: event.endDate && event.endDate !== event.startDate ? event.endDate : ""
+ };
+ }
+ return {
+ ...meta,
+ day: CALENDAR_WEEKDAYS[event.dayIndex] || "",
+ start: minutesToTimeValue(event.startMinutes),
+ end: event.endMinutes ? minutesToTimeValue(event.endMinutes) : ""
+ };
+}
+
+
+export function exportCalendarText(data) {
+ const viewMode = data?.viewMode === "month" ? "month" : "week";
+ const lines = [`@viewMode: ${viewMode}`];
+ if (data?.weekStartsOn === 0) lines.push("@weekStartsOn: 0");
+ (data?.events || []).forEach((event) => {
+ lines.push(`event | ${formatMeta(formatCalendarEventMeta(event, viewMode))}`);
+ });
+ return lines.join("\n");
+}
+
+
+export function importCalendarText(text, context) {
+ const trimmed = String(text || "").trim();
+ if (trimmed.startsWith("{")) {
+ return context.normalizeCalendarData(JSON.parse(trimmed));
+ }
+
+ const data = { initialized: true, viewMode: "week", weekStartsOn: 1, events: [] };
+ splitLines(text).forEach((line, index) => {
+ const cleanLine = line.trim();
+ if (!cleanLine) return;
+ const meta = parseMetaLine(cleanLine);
+ if (meta) {
+ if (meta.key === "viewMode") data.viewMode = meta.value === "month" ? "month" : "week";
+ if (meta.key === "weekStartsOn") data.weekStartsOn = parseInteger(meta.value, 1) === 0 ? 0 : 1;
+ return;
+ }
+
+ const { title, meta: lineMeta } = parsePipeParts(cleanLine);
+ if (title !== "event") throw lineError(index, `ligne calendrier invalide: ${cleanLine}`);
+ const event = {
+ id: context.uid("calendar"),
+ title: lineMeta.title || "",
+ repeatable: lineMeta.repeatable !== "false",
+ alertMode: CALENDAR_ALERT_MODE_SET.has(lineMeta.alertMode) ? lineMeta.alertMode : "off",
+ alertOffsetMinutes: parseCalendarAlertOffsets(lineMeta.alerts),
+ color: CALENDAR_COLOR_SET.has(lineMeta.color) ? lineMeta.color : "violet"
+ };
+ if (data.viewMode === "month") {
+ const monthlyRepeatBy = CALENDAR_MONTHLY_REPEAT_BY_SET.has(lineMeta.repeatBy) ? lineMeta.repeatBy : "monthDay";
+ event.startMinutes = 0;
+ event.monthlyRepeatBy = monthlyRepeatBy;
+ event.repeat = CALENDAR_MONTH_REPEAT_MODE_SET.has(lineMeta.repeat) ? lineMeta.repeat : "none";
+ event.startDate = lineMeta.startDate || "";
+ event.endDate = lineMeta.endDate || event.startDate;
+ } else {
+ event.dayIndex = parseCalendarDayIndex(lineMeta.day);
+ event.startMinutes = timeValueToMinutes(lineMeta.start);
+ if (lineMeta.end) event.endMinutes = timeValueToMinutes(lineMeta.end);
+ }
+ data.events.push(event);
+ });
+ return context.normalizeCalendarData(data);
+}
+
diff --git a/website/src/styles/toolboxes/_goal-tree.scss b/website/src/styles/toolboxes/_goal-tree.scss
index 3aa50f2..0cd6514 100644
--- a/website/src/styles/toolboxes/_goal-tree.scss
+++ b/website/src/styles/toolboxes/_goal-tree.scss
@@ -1,1194 +1,4 @@
-// Rôle : stylise l'outil arbre d'objectifs et ses branches compactes.
-.goal-tree-module {
- display: grid;
- grid-template-columns: minmax(320px, 40fr) minmax(0, 60fr);
- column-gap: var(--space-6);
- row-gap: var(--space-4);
- align-items: start;
- overflow: hidden;
- padding: var(--space-4);
-}
-
-.module[data-module-type="goalTree"] {
- contain: paint;
- overflow: hidden;
-}
-
-.module[data-module-type="goalTree"] > .module-content {
- min-width: 0;
-}
-
-.module[data-module-type="goalTree"] > .module-content.is-scrollable {
- overflow: auto;
-}
-
-.goal-tree-editor {
- align-content: start;
- gap: var(--space-3);
-}
-
-.goal-tree-editor label,
-.goal-tree-selected-form label {
- display: grid;
- gap: 5px;
- min-width: 0;
- color: var(--color-text-secondary);
- font-size: 0.82rem;
- font-weight: 700;
-}
-
-.goal-tree-editor input,
-.goal-tree-editor select {
- min-width: 0;
- width: 100%;
-}
-
-.goal-tree-field-row {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 8px;
-}
-
-.goal-tree-selected-form > button {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- gap: 7px;
-}
-
-.goal-tree-selected-form {
- display: grid;
- gap: var(--space-3);
- padding-top: var(--space-2);
- border-top: 1px solid rgba(150, 165, 205, 0.14);
-}
-
-.goal-tree-form-tabs {
- width: 100%;
- grid-template-columns: repeat(auto-fit, minmax(72px, 1fr));
-}
-
-.goal-tree-form-tabs button {
- min-height: 34px;
- gap: 6px;
- padding: 0 8px;
- font-size: 0.78rem;
-}
-
-.goal-tree-form-tabs .module-icon-svg {
- width: 15px;
- height: 15px;
- flex: 0 0 15px;
-}
-
-.goal-tree-form-tabs .module-icon-settings {
- width: 16px;
- height: 16px;
- flex-basis: 16px;
- transform: scale(1.12);
-}
-
-.goal-tree-form-tabs button > span:not(.module-icon-svg) {
- overflow: hidden;
- min-width: 0;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.goal-tree-form-panel {
- display: grid;
- gap: var(--space-3);
-}
-
-.goal-tree-form-section-title,
-.goal-tree-color-field > span,
-.goal-tree-icon-field > span {
- color: var(--color-text-secondary);
- font-size: 0.78rem;
- font-weight: 800;
- text-transform: uppercase;
-}
-
-.goal-tree-content-editor {
- display: grid;
- gap: 8px;
-}
-
-.goal-tree-content-editor-row {
- display: grid;
- grid-template-columns: minmax(0, 1fr) 30px;
- gap: 6px;
- align-items: center;
-}
-
-.goal-tree-content-editor-row.is-checklist {
- grid-template-columns: minmax(0, 1fr) 30px;
- grid-template-areas:
- "total delete"
- "label delete";
-}
-
-.goal-tree-content-label-input {
- grid-area: label;
-}
-
-.goal-tree-content-formula-input,
-.goal-tree-content-editor-row.is-checklist .mix-tags-input-wrap {
- grid-area: total;
-}
-
-.goal-tree-content-editor-row.is-checklist button {
- grid-area: delete;
- align-self: stretch;
-}
-
-.goal-tree-content-editor-row > input[type="checkbox"] {
- width: 18px;
- height: 18px;
-}
-
-.goal-tree-content-editor-row button {
- min-width: 30px;
- min-height: 30px;
- padding: 0;
-}
-
-.goal-tree-content-qty-stepper {
- display: grid;
- grid-template-columns: 30px minmax(42px, auto) 30px;
- gap: 4px;
- align-items: center;
- color: var(--color-text-secondary);
- font-size: 0.78rem;
- font-weight: 800;
- white-space: nowrap;
-}
-
-.goal-tree-content-qty-stepper > span {
- text-align: center;
-}
-
-.goal-tree-content-hint {
- margin: 0;
- color: var(--color-text-muted);
- font-size: 0.82rem;
-}
-
-.goal-tree-content-add-button {
- display: inline-flex;
- justify-content: center;
- gap: 7px;
- border-style: dashed;
-}
-
-.tool-split-scroll-toggle.goal-tree-progress-mode-toggle {
- grid-template-columns: 18px minmax(0, 1fr) 34px;
- width: 100%;
- justify-items: start;
-}
-
-.tool-split-scroll-toggle.goal-tree-progress-mode-toggle span:not(.ui-icon) {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.goal-tree-progress-mode-hint {
- margin: -2px 0 0;
- color: var(--color-text-muted);
- font-size: 0.76rem;
- line-height: 1.35;
-}
-
-.goal-tree-prerequisite-field,
-.goal-tree-prerequisite-list {
- display: grid;
- gap: 7px;
-}
-
-.goal-tree-prerequisite-list {
- max-height: 150px;
- overflow-y: auto;
- padding-right: 4px;
-}
-
-.goal-tree-prerequisite-item {
- display: grid;
- grid-template-columns: auto minmax(0, 1fr);
- gap: 8px;
- align-items: center;
- min-height: 32px;
- padding: 6px 8px;
- border: 1px solid rgba(165, 180, 252, 0.12);
- border-radius: var(--radius-sm);
- background: rgba(7, 10, 24, 0.28);
-}
-
-.goal-tree-prerequisite-item input[type="checkbox"] {
- display: grid;
- width: 14px;
- min-width: 14px;
- height: 14px;
- min-height: 14px;
- margin: 0;
- place-items: center;
- appearance: none;
- border: 1px solid rgba(165, 180, 252, 0.28);
- border-radius: 3px;
- background: rgba(7, 10, 24, 0.56);
- accent-color: var(--color-accent-gold);
-}
-
-.goal-tree-prerequisite-item input[type="checkbox"]:checked {
- border-color: rgba(246, 196, 83, 0.66);
- background: rgba(246, 196, 83, 0.22);
-}
-
-.goal-tree-prerequisite-item input[type="checkbox"]:checked::before {
- color: var(--color-text-primary);
- font-size: 0.72rem;
- font-weight: 900;
- line-height: 1;
- content: "✓";
-}
-
-.goal-tree-prerequisite-item input[type="checkbox"]:disabled {
- opacity: 0.72;
-}
-
-.goal-tree-prerequisite-item.is-primary {
- border-color: rgba(246, 196, 83, 0.22);
- background: rgba(246, 196, 83, 0.08);
-}
-
-.goal-tree-prerequisite-item span {
- overflow: hidden;
- min-width: 0;
- color: var(--color-text-secondary);
- font-size: 0.78rem;
- font-weight: 800;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.goal-tree-color-field,
-.goal-tree-icon-field {
- display: grid;
- gap: 7px;
-}
-
-.goal-tree-icon-picker {
- position: relative;
-}
-
-.goal-tree-icon-picker-button {
- display: grid;
- width: 100%;
- grid-template-columns: auto minmax(0, 1fr) auto;
- gap: 8px;
- align-items: center;
- justify-content: start;
- min-height: 42px;
- text-align: left;
-}
-
-.goal-tree-icon-picker-button > span:not(.goal-tree-icon-preview) {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.goal-tree-icon-preview {
- display: grid;
- width: 30px;
- height: 30px;
- place-items: center;
- border: 1px solid rgba(246, 196, 83, 0.24);
- border-radius: var(--radius-sm);
- background: rgba(246, 196, 83, 0.08);
- color: var(--color-accent-gold);
-}
-
-.goal-tree-icon-picker-menu {
- position: absolute;
- z-index: 12;
- top: calc(100% + 6px);
- left: 0;
- display: grid;
- width: min(260px, 72vw);
- grid-template-columns: repeat(5, minmax(0, 1fr));
- gap: 6px;
- padding: 8px;
- border: 1px solid rgba(165, 180, 252, 0.14);
- border-radius: var(--radius-md);
- background: rgba(7, 10, 24, 0.98);
- box-shadow: var(--shadow-md);
-}
-
-.goal-tree-icon-picker-menu button {
- display: grid;
- min-width: 0;
- min-height: 38px;
- place-items: center;
- padding: 0;
-}
-
-.goal-tree-icon-picker-menu button.active {
- border-color: rgba(246, 196, 83, 0.62);
- background: rgba(246, 196, 83, 0.1);
- color: var(--color-accent-gold);
-}
-
-.goal-tree-color-swatches {
- display: flex;
- flex-wrap: wrap;
- gap: 7px;
-}
-
-.goal-tree-color-swatch {
- width: 28px;
- min-width: 28px;
- min-height: 28px;
- padding: 0;
- border-color: rgba(150, 165, 205, 0.18);
- border-radius: var(--radius-sm);
- background:
- linear-gradient(135deg, rgba(255, 255, 255, 0.16), transparent 42%),
- var(--goal-tree-swatch-color);
-}
-
-.goal-tree-color-swatch.active {
- border-color: rgba(246, 196, 83, 0.76);
- box-shadow: 0 0 0 2px rgba(246, 196, 83, 0.15);
-}
-
-.goal-tree-form-actions {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
- align-items: center;
- justify-content: space-between;
- margin-top: var(--space-1);
-}
-
-.goal-tree-form-actions button {
- display: inline-flex;
- min-height: 36px;
- align-items: center;
- justify-content: center;
- gap: 6px;
- padding: 0 12px;
- font-size: 0.82rem;
- font-weight: 800;
- line-height: 1.2;
- white-space: nowrap;
-}
-
-.goal-tree-form-actions button:not(.danger) {
- flex: 0 0 auto;
- border-color: rgba(165, 180, 252, 0.14);
- background: rgba(7, 10, 24, 0.28);
- color: var(--color-text-secondary);
-}
-
-.goal-tree-form-actions button.danger {
- flex: 1 1 150px;
-}
-
-.goal-tree-form-actions .ui-icon {
- width: 15px;
- height: 15px;
-}
-
-.goal-tree-view.tool-split-results {
- position: relative;
- min-width: 0;
- width: 100%;
- max-width: 100%;
- min-height: 260px;
- overflow: hidden;
- padding: 0;
-}
-
-.goal-tree-scroll-area {
- display: grid;
- grid-template-columns: minmax(0, max-content);
- grid-template-rows: auto minmax(0, max-content);
- width: 100%;
- height: 100%;
- min-height: inherit;
- align-content: start;
- justify-content: start;
- overflow-x: auto;
- overflow-y: auto;
- overscroll-behavior-x: contain;
- overscroll-behavior-y: auto;
- padding: var(--space-4);
- background:
- linear-gradient(rgba(150, 165, 205, 0.035) 1px, transparent 1px),
- linear-gradient(90deg, rgba(150, 165, 205, 0.035) 1px, transparent 1px),
- radial-gradient(circle at 50% 0%, rgba(139, 92, 246, 0.08), transparent 34%),
- rgba(7, 10, 24, 0.2);
- background-size:
- 28px 28px,
- 28px 28px,
- auto,
- auto;
-}
-
-.goal-tree-view.is-horizontal.is-editing .goal-tree-scroll-area {
- grid-template-columns: 44px minmax(0, max-content);
- grid-template-rows: minmax(260px, max-content);
- column-gap: var(--space-3);
-}
-
-.goal-tree-view.is-horizontal.is-editing .goal-tree-view-controls {
- grid-column: 1;
- grid-row: 1;
-}
-
-.goal-tree-view.is-horizontal.is-editing .goal-tree-canvas-frame,
-.goal-tree-view.is-horizontal.is-editing .goal-tree-empty {
- grid-column: 2;
- grid-row: 1;
-}
-
-.goal-tree-view.is-vertical.is-editing .goal-tree-scroll-area {
- grid-template-columns: minmax(0, 1fr);
-}
-
-.goal-tree-view.is-vertical.is-editing .goal-tree-view-controls {
- grid-column: 1;
- grid-row: 1;
- width: 100%;
- max-width: none;
- box-sizing: border-box;
- padding-right: 84px;
-}
-
-.goal-tree-view.is-vertical.is-editing .goal-tree-canvas-frame,
-.goal-tree-view.is-vertical.is-editing .goal-tree-empty {
- grid-column: 1;
- grid-row: 2;
-}
-
-.goal-tree-view:not(.is-editing) .goal-tree-canvas-frame {
- margin-inline: auto;
-}
-
-.goal-tree-view-controls {
- position: relative;
- z-index: 5;
- display: flex;
- align-items: flex-start;
- gap: var(--space-2);
- min-width: 0;
- margin-bottom: var(--space-2);
-}
-
-.goal-tree-view:not(.is-editing) .goal-tree-view-controls {
- display: none;
-}
-
-.goal-tree-view.is-horizontal .goal-tree-view-controls {
- flex-direction: column;
- align-self: stretch;
- min-height: 100%;
- margin-bottom: 0;
-}
-
-.goal-tree-view.is-vertical .goal-tree-view-controls {
- width: 100%;
- max-width: 100%;
-}
-
-.goal-tree-orientation-switch {
- position: absolute;
- z-index: 7;
- top: var(--space-4);
- right: var(--space-4);
- display: inline-grid;
- grid-template-columns: 16px 30px;
- width: fit-content;
- min-width: 70px;
- min-height: 40px;
- align-items: center;
- flex: 0 0 auto;
- gap: 7px;
- padding: 0 7px 0 10px;
- border-radius: var(--radius-pill);
- box-shadow: 0 10px 18px rgba(0, 0, 0, 0.16);
-}
-
-.goal-tree-orientation-switch .ui-icon {
- width: 16px;
- height: 16px;
-}
-
-.goal-tree-module.is-view-only {
- grid-template-columns: 1fr;
-}
-
-.goal-tree-canvas-frame {
- position: relative;
- width: var(--goal-tree-canvas-width, 100%);
- min-width: min(100%, var(--goal-tree-canvas-width, 100%));
- height: var(--goal-tree-canvas-height, 1px);
-}
-
-.goal-tree-canvas {
- position: relative;
- display: grid;
- width: max-content;
- min-width: 100%;
- max-width: none;
- align-items: start;
- justify-items: center;
- isolation: isolate;
- transform: scale(var(--goal-tree-view-scale, 1));
- transform-origin: top left;
-}
-
-.goal-tree-canvas-frame > .goal-tree-canvas {
- position: absolute;
- top: 0;
- left: 0;
-}
-
-.goal-tree-canvas.is-horizontal {
- display: flex;
- align-items: flex-start;
- justify-items: start;
-}
-
-.diagram-connectors {
- position: absolute;
- inset: 0;
- width: 100%;
- height: 100%;
- overflow: visible;
- pointer-events: none;
-}
-
-.goal-tree-connectors {
- z-index: 1;
- color: rgba(246, 196, 83, 0.58);
-}
-
-.goal-tree-connectors path {
- fill: none;
- stroke-linecap: round;
- stroke-linejoin: round;
- stroke-width: 1.35;
- vector-effect: non-scaling-stroke;
-}
-
-.goal-tree-list {
- position: relative;
- z-index: 2;
- display: flex;
- align-items: flex-start;
- justify-content: center;
- min-width: max-content;
- margin: 0;
- padding: 0;
- gap: 24px;
- list-style: none;
-}
-
-.goal-tree-list.is-root-list {
- padding: 8px;
-}
-
-.goal-tree-list.is-child-list {
- margin-top: 28px;
- padding-top: 28px;
-}
-
-.goal-tree-canvas.is-horizontal .goal-tree-list {
- flex-direction: column;
- align-items: flex-start;
- gap: 20px;
-}
-
-.goal-tree-canvas.is-horizontal .goal-tree-list.is-root-list {
- padding: 8px 48px 8px 8px;
-}
-
-.goal-tree-add-root-button {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- gap: 7px;
- padding: 0;
- border-style: dashed;
- background: rgba(7, 10, 24, 0.72);
- color: var(--color-text-secondary);
- font-size: 0.84rem;
- font-weight: 900;
- white-space: nowrap;
- box-shadow: 0 10px 18px rgba(0, 0, 0, 0.16);
-}
-
-.goal-tree-view.is-horizontal .goal-tree-add-root-button {
- width: 44px;
- min-width: 44px;
- min-height: 360px;
- flex: 1 1 360px;
- flex-direction: row-reverse;
- padding: 16px 0;
- text-align: center;
- writing-mode: vertical-rl;
-}
-
-.goal-tree-view.is-horizontal .goal-tree-add-root-button span {
- transform: rotate(180deg);
-}
-
-.goal-tree-view.is-horizontal .goal-tree-add-root-button .ui-icon {
- transform: rotate(180deg);
-}
-
-.goal-tree-view.is-vertical .goal-tree-add-root-button {
- width: min(520px, 100%);
- max-width: 100%;
- min-width: min(390px, 100%);
- min-height: 40px;
- flex: 0 1 520px;
- padding: 0 16px;
-}
-
-.goal-tree-add-root-button .ui-icon {
- width: 15px;
- height: 15px;
-}
-
-.goal-tree-canvas.is-horizontal .goal-tree-list.is-child-list {
- margin-top: 0;
- margin-left: 34px;
- padding-top: 0;
- padding-left: 34px;
-}
-
-.goal-tree-node {
- position: relative;
- display: flex;
- --goal-tree-card-color: var(--color-accent-gold);
- min-width: 104px;
- flex-direction: column;
- align-items: center;
-}
-
-.goal-tree-canvas.is-horizontal .goal-tree-node {
- flex-direction: row;
- align-items: center;
-}
-
-.goal-tree-node.is-text {
- --goal-tree-card-color: var(--color-accent-cyan);
-}
-
-.goal-tree-node.is-checklist {
- --goal-tree-card-color: #34d399;
-}
-
-.goal-tree-node-card {
- position: relative;
- z-index: 1;
- display: grid;
- width: 104px;
- grid-template-columns: minmax(0, 1fr);
- grid-template-rows: minmax(0, 1fr);
- grid-template-areas:
- "main";
- gap: 6px;
- align-items: stretch;
- align-content: stretch;
- min-width: 0;
- min-height: 104px;
- padding: 7px;
- border: 1px solid color-mix(in srgb, var(--goal-tree-card-color) 38%, rgba(150, 165, 205, 0.16));
- border-radius: var(--radius-lg);
- background:
- linear-gradient(135deg, color-mix(in srgb, var(--goal-tree-card-color) 10%, transparent), transparent 44%),
- linear-gradient(160deg, rgba(139, 92, 246, 0.12), rgba(16, 20, 38, 0.72));
- cursor: pointer;
- box-shadow:
- 0 12px 22px rgba(0, 0, 0, 0.18),
- inset 0 1px 0 rgba(255, 255, 255, 0.04);
- transition:
- border-color var(--duration-fast) var(--ease-standard),
- background var(--duration-fast) var(--ease-standard),
- box-shadow var(--duration-fast) var(--ease-standard);
-}
-
-.goal-tree-node-card.has-actions {
- grid-template-rows: minmax(0, 1fr) 22px;
- grid-template-areas:
- "main"
- "actions";
-}
-
-.goal-tree-node-hover-actions {
- position: absolute;
- z-index: 3;
- right: 6px;
- bottom: 7px;
- left: 6px;
- display: flex;
- justify-content: space-between;
- gap: 4px;
- opacity: 0;
- pointer-events: none;
- transition: opacity var(--duration-fast) var(--ease-standard);
-}
-
-.goal-tree-node-card:hover .goal-tree-node-hover-actions,
-.goal-tree-node-card:focus-within .goal-tree-node-hover-actions {
- opacity: 1;
-}
-
-.goal-tree-node-hover-actions button {
- width: 24px;
- min-width: 24px;
- min-height: 24px;
- padding: 0;
- border-color: rgba(165, 180, 252, 0.14);
- background: rgba(7, 10, 24, 0.78);
- pointer-events: none;
-}
-
-.goal-tree-node-card:hover .goal-tree-node-hover-actions button,
-.goal-tree-node-card:focus-within .goal-tree-node-hover-actions button {
- pointer-events: auto;
-}
-
-.goal-tree-node-hover-actions .ui-icon {
- width: 13px;
- height: 13px;
-}
-
-.goal-tree-node.is-text,
-.goal-tree-node.is-checklist {
- min-width: 184px;
-}
-
-.goal-tree-node.is-text > .goal-tree-node-card,
-.goal-tree-node.is-checklist > .goal-tree-node-card {
- width: 184px;
-}
-
-.goal-tree-node-card.is-viewing {
- grid-template-columns: minmax(0, 1fr);
-}
-
-.goal-tree-node.is-selected > .goal-tree-node-card {
- border-color: color-mix(in srgb, var(--goal-tree-card-color) 72%, rgba(246, 196, 83, 0.24));
- box-shadow:
- 0 0 0 1px color-mix(in srgb, var(--goal-tree-card-color) 22%, transparent),
- 0 12px 22px rgba(0, 0, 0, 0.18);
-}
-
-.goal-tree-node.is-selected > .goal-tree-node-card.is-editing {
- border-color: color-mix(in srgb, var(--goal-tree-card-color) 86%, var(--color-accent-gold));
- background:
- linear-gradient(135deg, color-mix(in srgb, var(--goal-tree-card-color) 20%, transparent), transparent 48%),
- linear-gradient(160deg, rgba(139, 92, 246, 0.18), rgba(16, 20, 38, 0.82));
- box-shadow:
- 0 0 0 2px color-mix(in srgb, var(--goal-tree-card-color) 36%, transparent),
- 0 0 22px color-mix(in srgb, var(--goal-tree-card-color) 18%, transparent),
- 0 16px 28px rgba(0, 0, 0, 0.22);
-}
-
-.goal-tree-node.is-complete > .goal-tree-node-card {
- border-color: color-mix(in srgb, var(--goal-tree-card-color) 54%, rgba(34, 211, 238, 0.3));
- background:
- linear-gradient(135deg, color-mix(in srgb, var(--goal-tree-card-color) 12%, transparent), transparent 44%),
- linear-gradient(160deg, rgba(139, 92, 246, 0.1), rgba(16, 20, 38, 0.72));
-}
-
-.goal-tree-node-card:hover,
-.goal-tree-node-card:focus-within {
- border-color: color-mix(in srgb, var(--goal-tree-card-color) 68%, rgba(246, 196, 83, 0.24));
- background:
- linear-gradient(135deg, color-mix(in srgb, var(--goal-tree-card-color) 16%, transparent), transparent 48%),
- linear-gradient(160deg, rgba(139, 92, 246, 0.14), rgba(16, 20, 38, 0.78));
- box-shadow:
- 0 0 0 1px color-mix(in srgb, var(--goal-tree-card-color) 16%, transparent),
- 0 14px 26px rgba(0, 0, 0, 0.2);
-}
-
-.goal-tree-node-main {
- grid-area: main;
- display: grid;
- align-content: start;
- justify-items: stretch;
- gap: 5px;
- min-width: 0;
- min-height: 46px;
- padding: 5px;
- border: 0;
- background: transparent;
- color: var(--color-text-primary);
- cursor: pointer;
- text-align: center;
- box-shadow: none;
-}
-
-.goal-tree-node.is-step > .goal-tree-node-card > .goal-tree-node-main {
- justify-items: center;
- min-height: 58px;
-}
-
-.goal-tree-node-main:hover,
-.goal-tree-node-main:focus {
- border-color: transparent;
- background: transparent;
- box-shadow: none;
-}
-
-.goal-tree-node-heading {
- display: grid;
- grid-template-columns: auto minmax(0, 1fr);
- gap: 6px;
- align-items: center;
- min-width: 0;
-}
-
-.goal-tree-node-icon {
- display: grid;
- width: 24px;
- height: 24px;
- place-items: center;
- border: 1px solid color-mix(in srgb, var(--goal-tree-node-color, var(--color-accent-gold)) 45%, transparent);
- border-radius: var(--radius-sm);
- background: color-mix(in srgb, var(--goal-tree-node-color, var(--color-accent-gold)) 14%, transparent);
- color: var(--goal-tree-node-color, var(--color-accent-gold));
-}
-
-.goal-tree-node-icon .ui-icon {
- width: 15px;
- height: 15px;
-}
-
-.goal-tree-node.is-step > .goal-tree-node-card > .goal-tree-node-main > .goal-tree-node-icon {
- width: 42px;
- height: 42px;
-}
-
-.goal-tree-node.is-step > .goal-tree-node-card > .goal-tree-node-main > .goal-tree-node-icon .ui-icon {
- width: 27px;
- height: 27px;
-}
-
-.goal-tree-node-title {
- overflow: hidden;
- min-width: 0;
- color: var(--color-text-primary);
- font-size: 0.78rem;
- font-weight: 900;
- text-align: left;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.goal-tree-node-content-preview {
- display: grid;
- width: 100%;
- min-height: 0;
- gap: 3px;
- justify-items: stretch;
- color: var(--color-text-primary);
-}
-
-.goal-tree-node-content-preview:empty {
- display: none;
-}
-
-.goal-tree-node-content-row {
- display: grid;
- grid-template-columns: auto minmax(0, 1fr);
- gap: 4px;
- align-items: center;
- min-width: 0;
- font-size: 0.72rem;
- font-weight: 800;
- text-align: left;
-}
-
-.goal-tree-node.is-checklist .goal-tree-node-content-row {
- grid-template-columns: auto minmax(0, 1fr);
-}
-
-.goal-tree-node.is-text .goal-tree-node-content-row {
- grid-template-columns: auto minmax(0, 1fr);
-}
-
-.goal-tree-node.is-text .goal-tree-node-content-row::before {
- content: "";
- width: 3px;
- height: 3px;
- border-radius: 50%;
- background: currentColor;
-}
-
-.goal-tree-node-content-row strong {
- color: var(--color-accent-gold);
- font-size: 0.72rem;
-}
-
-.goal-tree-node-content-label {
- overflow: hidden;
- min-width: 0;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.goal-tree-node-content-add-button {
- display: grid;
- width: 100%;
- min-height: 20px;
- padding: 0;
- place-items: center;
- border-style: dashed;
- border-color: color-mix(in srgb, var(--goal-tree-card-color, var(--color-accent-gold)) 56%, transparent);
- border-radius: var(--radius-sm);
- background: rgba(7, 10, 24, 0.2);
- color: var(--color-text-secondary);
-}
-
-.goal-tree-node-content-add-button:hover,
-.goal-tree-node-content-add-button:focus-visible {
- border-color: color-mix(in srgb, var(--goal-tree-card-color, var(--color-accent-gold)) 78%, transparent);
- background: color-mix(in srgb, var(--goal-tree-card-color, var(--color-accent-gold)) 14%, transparent);
- color: var(--color-text-primary);
-}
-
-.goal-tree-node-content-add-button .ui-icon {
- width: 12px;
- height: 12px;
-}
-
-.goal-tree-node.is-checklist .goal-tree-node-content-label {
- padding-left: 2px;
-}
-
-.goal-tree-node-check {
- display: grid;
- width: 10px;
- height: 10px;
- margin: 0;
- accent-color: var(--color-accent-cyan);
-}
-
-.goal-tree-step-check {
- justify-self: center;
- width: 16px;
- height: 16px;
- margin: 0;
- accent-color: var(--color-accent-gold);
-}
-
-.goal-tree-step-qty-stepper {
- justify-self: center;
- grid-template-columns: 18px minmax(34px, auto) 18px;
- gap: 2px;
- font-size: 0.68rem;
-}
-
-.goal-tree-step-qty-stepper button {
- display: grid;
- width: 18px;
- min-width: 18px;
- height: 18px;
- min-height: 18px;
- padding: 0;
- place-items: center;
-}
-
-.goal-tree-step-qty-stepper .ui-icon {
- width: 10px;
- height: 10px;
-}
-
-.goal-tree-node-content-row .goal-tree-content-qty-stepper {
- justify-self: start;
- grid-template-columns: 16px minmax(30px, auto) 16px;
- gap: 2px;
- font-size: 0.64rem;
-}
-
-.goal-tree-node-content-row .goal-tree-content-qty-stepper button {
- min-width: 16px;
- min-height: 16px;
- padding: 0;
-}
-
-.goal-tree-node-content-row .goal-tree-content-qty-stepper .ui-icon {
- width: 9px;
- height: 9px;
-}
-
-.goal-tree-progress-track {
- grid-column: 1 / -1;
- justify-self: stretch;
- width: 100%;
- height: 5px;
- overflow: hidden;
- border-radius: var(--radius-pill);
- background: rgba(150, 165, 205, 0.13);
-}
-
-.goal-tree-progress-track span {
- display: block;
- height: 100%;
- border-radius: inherit;
- background: linear-gradient(90deg, var(--color-accent-gold), var(--color-accent-cyan));
-}
-
-.goal-tree-node-actions {
- grid-area: actions;
- display: flex;
- min-height: 22px;
- justify-content: center;
- align-items: center;
- gap: 4px;
- padding-top: 0;
-}
-
-.goal-tree-node-actions button,
-.goal-tree-drag-handle {
- width: 22px;
- min-width: 22px;
- height: 22px;
- min-height: 22px;
- padding: 0;
-}
-
-.goal-tree-node-actions .goal-tree-branch-toggle {
- width: 22px;
- min-width: 22px;
- height: 22px;
- min-height: 22px;
-}
-
-.goal-tree-canvas.is-horizontal .goal-tree-branch-toggle .ui-icon-chevron-down,
-.goal-tree-canvas.is-horizontal .goal-tree-branch-toggle .ui-icon-chevron-up {
- transform: rotate(-90deg);
-}
-
-.goal-tree-drag-handle {
- position: absolute;
- z-index: 3;
- top: 6px;
- left: 6px;
- border-color: rgba(165, 180, 252, 0.34);
- background: rgba(7, 10, 24, 0.82);
- color: var(--color-text-primary);
- cursor: grab;
- opacity: 1;
- box-shadow:
- 0 0 0 1px rgba(7, 10, 24, 0.72),
- 0 6px 12px rgba(0, 0, 0, 0.22);
-}
-
-.goal-tree-drag-handle:hover {
- border-color: rgba(246, 196, 83, 0.42);
- background: rgba(7, 10, 24, 0.92);
- color: var(--color-text-primary);
- box-shadow:
- 0 0 0 1px rgba(246, 196, 83, 0.12),
- 0 8px 14px rgba(0, 0, 0, 0.26);
-}
-
-.goal-tree-drag-handle .ui-icon {
- width: 12px;
- height: 12px;
-}
-
-.goal-tree-node-tooltip {
- max-width: 220px;
-}
-
-.goal-tree-node.is-dragging {
- opacity: 0.55;
-}
-
-.goal-tree-node.is-drop-target > .goal-tree-node-card {
- border-color: rgba(246, 196, 83, 0.48);
-}
-
-.goal-tree-node.is-drop-target > .goal-tree-node-card::after {
- position: absolute;
- border-radius: var(--radius-pill);
- background: rgba(246, 196, 83, 0.9);
- box-shadow: 0 0 12px rgba(246, 196, 83, 0.24);
- content: "";
- pointer-events: none;
-}
-
-.goal-tree-node[data-reorder-orientation="horizontal"].is-drop-target > .goal-tree-node-card::after {
- top: 8px;
- bottom: 8px;
- left: -8px;
- width: 3px;
-}
-
-.goal-tree-node[data-reorder-orientation="horizontal"].is-drop-target.drop-after > .goal-tree-node-card::after {
- right: -8px;
- left: auto;
-}
-
-.goal-tree-node[data-reorder-orientation="vertical"].is-drop-target > .goal-tree-node-card::after {
- top: -8px;
- right: 8px;
- left: 8px;
- height: 3px;
-}
-
-.goal-tree-node[data-reorder-orientation="vertical"].is-drop-target.drop-after > .goal-tree-node-card::after {
- top: auto;
- bottom: -8px;
-}
-
-.goal-tree-empty {
- padding: var(--space-4);
- color: var(--color-text-secondary);
- text-align: center;
-}
-
-.goal-tree-module .text-import-actions {
- padding-top: var(--space-2);
-}
-
-@media (max-width: 860px) {
- .goal-tree-module {
- grid-template-columns: 1fr;
- }
-
- .goal-tree-view.tool-split-results {
- min-height: 220px;
- }
-
- .goal-tree-list {
- gap: 16px;
- }
-
- .goal-tree-node {
- min-width: 96px;
- }
-
- .goal-tree-node-card {
- width: 96px;
- }
-
- .goal-tree-node.is-text,
- .goal-tree-node.is-checklist {
- min-width: 160px;
- }
-
- .goal-tree-node.is-text > .goal-tree-node-card,
- .goal-tree-node.is-checklist > .goal-tree-node-card {
- width: 160px;
- }
-}
+// Rôle : assemble les styles de l'outil arbre d'objectifs par responsabilité.
+@use "goal-tree/editor";
+@use "goal-tree/view";
+@use "goal-tree/nodes";
diff --git a/website/src/styles/toolboxes/goal-tree/_editor.scss b/website/src/styles/toolboxes/goal-tree/_editor.scss
new file mode 100644
index 0000000..df539d7
--- /dev/null
+++ b/website/src/styles/toolboxes/goal-tree/_editor.scss
@@ -0,0 +1,401 @@
+// Rôle : stylise les panneaux d'édition et contrôles de l'arbre d'objectifs.
+.goal-tree-module {
+ display: grid;
+ grid-template-columns: minmax(320px, 40fr) minmax(0, 60fr);
+ column-gap: var(--space-6);
+ row-gap: var(--space-4);
+ align-items: start;
+ overflow: hidden;
+ padding: var(--space-4);
+}
+
+.module[data-module-type="goalTree"] {
+ contain: paint;
+ overflow: hidden;
+}
+
+.module[data-module-type="goalTree"] > .module-content {
+ min-width: 0;
+}
+
+.module[data-module-type="goalTree"] > .module-content.is-scrollable {
+ overflow: auto;
+}
+
+.goal-tree-editor {
+ align-content: start;
+ gap: var(--space-3);
+}
+
+.goal-tree-editor label,
+.goal-tree-selected-form label {
+ display: grid;
+ gap: 5px;
+ min-width: 0;
+ color: var(--color-text-secondary);
+ font-size: 0.82rem;
+ font-weight: 700;
+}
+
+.goal-tree-editor input,
+.goal-tree-editor select {
+ min-width: 0;
+ width: 100%;
+}
+
+.goal-tree-field-row {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px;
+}
+
+.goal-tree-selected-form > button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 7px;
+}
+
+.goal-tree-selected-form {
+ display: grid;
+ gap: var(--space-3);
+ padding-top: var(--space-2);
+ border-top: 1px solid rgba(150, 165, 205, 0.14);
+}
+
+.goal-tree-form-tabs {
+ width: 100%;
+ grid-template-columns: repeat(auto-fit, minmax(72px, 1fr));
+}
+
+.goal-tree-form-tabs button {
+ min-height: 34px;
+ gap: 6px;
+ padding: 0 8px;
+ font-size: 0.78rem;
+}
+
+.goal-tree-form-tabs .module-icon-svg {
+ width: 15px;
+ height: 15px;
+ flex: 0 0 15px;
+}
+
+.goal-tree-form-tabs .module-icon-settings {
+ width: 16px;
+ height: 16px;
+ flex-basis: 16px;
+ transform: scale(1.12);
+}
+
+.goal-tree-form-tabs button > span:not(.module-icon-svg) {
+ overflow: hidden;
+ min-width: 0;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.goal-tree-form-panel {
+ display: grid;
+ gap: var(--space-3);
+}
+
+.goal-tree-form-section-title,
+.goal-tree-color-field > span,
+.goal-tree-icon-field > span {
+ color: var(--color-text-secondary);
+ font-size: 0.78rem;
+ font-weight: 800;
+ text-transform: uppercase;
+}
+
+.goal-tree-content-editor {
+ display: grid;
+ gap: 8px;
+}
+
+.goal-tree-content-editor-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 30px;
+ gap: 6px;
+ align-items: center;
+}
+
+.goal-tree-content-editor-row.is-checklist {
+ grid-template-columns: minmax(0, 1fr) 30px;
+ grid-template-areas:
+ "total delete"
+ "label delete";
+}
+
+.goal-tree-content-label-input {
+ grid-area: label;
+}
+
+.goal-tree-content-formula-input,
+.goal-tree-content-editor-row.is-checklist .mix-tags-input-wrap {
+ grid-area: total;
+}
+
+.goal-tree-content-editor-row.is-checklist button {
+ grid-area: delete;
+ align-self: stretch;
+}
+
+.goal-tree-content-editor-row > input[type="checkbox"] {
+ width: 18px;
+ height: 18px;
+}
+
+.goal-tree-content-editor-row button {
+ min-width: 30px;
+ min-height: 30px;
+ padding: 0;
+}
+
+.goal-tree-content-qty-stepper {
+ display: grid;
+ grid-template-columns: 30px minmax(42px, auto) 30px;
+ gap: 4px;
+ align-items: center;
+ color: var(--color-text-secondary);
+ font-size: 0.78rem;
+ font-weight: 800;
+ white-space: nowrap;
+}
+
+.goal-tree-content-qty-stepper > span {
+ text-align: center;
+}
+
+.goal-tree-content-hint {
+ margin: 0;
+ color: var(--color-text-muted);
+ font-size: 0.82rem;
+}
+
+.goal-tree-content-add-button {
+ display: inline-flex;
+ justify-content: center;
+ gap: 7px;
+ border-style: dashed;
+}
+
+.tool-split-scroll-toggle.goal-tree-progress-mode-toggle {
+ grid-template-columns: 18px minmax(0, 1fr) 34px;
+ width: 100%;
+ justify-items: start;
+}
+
+.tool-split-scroll-toggle.goal-tree-progress-mode-toggle span:not(.ui-icon) {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.goal-tree-progress-mode-hint {
+ margin: -2px 0 0;
+ color: var(--color-text-muted);
+ font-size: 0.76rem;
+ line-height: 1.35;
+}
+
+.goal-tree-prerequisite-field,
+.goal-tree-prerequisite-list {
+ display: grid;
+ gap: 7px;
+}
+
+.goal-tree-prerequisite-list {
+ max-height: 150px;
+ overflow-y: auto;
+ padding-right: 4px;
+}
+
+.goal-tree-prerequisite-item {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ gap: 8px;
+ align-items: center;
+ min-height: 32px;
+ padding: 6px 8px;
+ border: 1px solid rgba(165, 180, 252, 0.12);
+ border-radius: var(--radius-sm);
+ background: rgba(7, 10, 24, 0.28);
+}
+
+.goal-tree-prerequisite-item input[type="checkbox"] {
+ display: grid;
+ width: 14px;
+ min-width: 14px;
+ height: 14px;
+ min-height: 14px;
+ margin: 0;
+ place-items: center;
+ appearance: none;
+ border: 1px solid rgba(165, 180, 252, 0.28);
+ border-radius: 3px;
+ background: rgba(7, 10, 24, 0.56);
+ accent-color: var(--color-accent-gold);
+}
+
+.goal-tree-prerequisite-item input[type="checkbox"]:checked {
+ border-color: rgba(246, 196, 83, 0.66);
+ background: rgba(246, 196, 83, 0.22);
+}
+
+.goal-tree-prerequisite-item input[type="checkbox"]:checked::before {
+ color: var(--color-text-primary);
+ font-size: 0.72rem;
+ font-weight: 900;
+ line-height: 1;
+ content: "✓";
+}
+
+.goal-tree-prerequisite-item input[type="checkbox"]:disabled {
+ opacity: 0.72;
+}
+
+.goal-tree-prerequisite-item.is-primary {
+ border-color: rgba(246, 196, 83, 0.22);
+ background: rgba(246, 196, 83, 0.08);
+}
+
+.goal-tree-prerequisite-item span {
+ overflow: hidden;
+ min-width: 0;
+ color: var(--color-text-secondary);
+ font-size: 0.78rem;
+ font-weight: 800;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.goal-tree-color-field,
+.goal-tree-icon-field {
+ display: grid;
+ gap: 7px;
+}
+
+.goal-tree-icon-picker {
+ position: relative;
+}
+
+.goal-tree-icon-picker-button {
+ display: grid;
+ width: 100%;
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ gap: 8px;
+ align-items: center;
+ justify-content: start;
+ min-height: 42px;
+ text-align: left;
+}
+
+.goal-tree-icon-picker-button > span:not(.goal-tree-icon-preview) {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.goal-tree-icon-preview {
+ display: grid;
+ width: 30px;
+ height: 30px;
+ place-items: center;
+ border: 1px solid rgba(246, 196, 83, 0.24);
+ border-radius: var(--radius-sm);
+ background: rgba(246, 196, 83, 0.08);
+ color: var(--color-accent-gold);
+}
+
+.goal-tree-icon-picker-menu {
+ position: absolute;
+ z-index: 12;
+ top: calc(100% + 6px);
+ left: 0;
+ display: grid;
+ width: min(260px, 72vw);
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+ gap: 6px;
+ padding: 8px;
+ border: 1px solid rgba(165, 180, 252, 0.14);
+ border-radius: var(--radius-md);
+ background: rgba(7, 10, 24, 0.98);
+ box-shadow: var(--shadow-md);
+}
+
+.goal-tree-icon-picker-menu button {
+ display: grid;
+ min-width: 0;
+ min-height: 38px;
+ place-items: center;
+ padding: 0;
+}
+
+.goal-tree-icon-picker-menu button.active {
+ border-color: rgba(246, 196, 83, 0.62);
+ background: rgba(246, 196, 83, 0.1);
+ color: var(--color-accent-gold);
+}
+
+.goal-tree-color-swatches {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 7px;
+}
+
+.goal-tree-color-swatch {
+ width: 28px;
+ min-width: 28px;
+ min-height: 28px;
+ padding: 0;
+ border-color: rgba(150, 165, 205, 0.18);
+ border-radius: var(--radius-sm);
+ background:
+ linear-gradient(135deg, rgba(255, 255, 255, 0.16), transparent 42%),
+ var(--goal-tree-swatch-color);
+}
+
+.goal-tree-color-swatch.active {
+ border-color: rgba(246, 196, 83, 0.76);
+ box-shadow: 0 0 0 2px rgba(246, 196, 83, 0.15);
+}
+
+.goal-tree-form-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ align-items: center;
+ justify-content: space-between;
+ margin-top: var(--space-1);
+}
+
+.goal-tree-form-actions button {
+ display: inline-flex;
+ min-height: 36px;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ padding: 0 12px;
+ font-size: 0.82rem;
+ font-weight: 800;
+ line-height: 1.2;
+ white-space: nowrap;
+}
+
+.goal-tree-form-actions button:not(.danger) {
+ flex: 0 0 auto;
+ border-color: rgba(165, 180, 252, 0.14);
+ background: rgba(7, 10, 24, 0.28);
+ color: var(--color-text-secondary);
+}
+
+.goal-tree-form-actions button.danger {
+ flex: 1 1 150px;
+}
+
+.goal-tree-form-actions .ui-icon {
+ width: 15px;
+ height: 15px;
+}
diff --git a/website/src/styles/toolboxes/goal-tree/_nodes.scss b/website/src/styles/toolboxes/goal-tree/_nodes.scss
new file mode 100644
index 0000000..aa553db
--- /dev/null
+++ b/website/src/styles/toolboxes/goal-tree/_nodes.scss
@@ -0,0 +1,523 @@
+// Rôle : stylise les cartes, contenus et états de drag de l'arbre d'objectifs.
+.goal-tree-node {
+ position: relative;
+ display: flex;
+ --goal-tree-card-color: var(--color-accent-gold);
+ min-width: 104px;
+ flex-direction: column;
+ align-items: center;
+}
+
+.goal-tree-canvas.is-horizontal .goal-tree-node {
+ flex-direction: row;
+ align-items: center;
+}
+
+.goal-tree-node.is-text {
+ --goal-tree-card-color: var(--color-accent-cyan);
+}
+
+.goal-tree-node.is-checklist {
+ --goal-tree-card-color: #34d399;
+}
+
+.goal-tree-node-card {
+ position: relative;
+ z-index: 1;
+ display: grid;
+ width: 104px;
+ grid-template-columns: minmax(0, 1fr);
+ grid-template-rows: minmax(0, 1fr);
+ grid-template-areas:
+ "main";
+ gap: 6px;
+ align-items: stretch;
+ align-content: stretch;
+ min-width: 0;
+ min-height: 104px;
+ padding: 7px;
+ border: 1px solid color-mix(in srgb, var(--goal-tree-card-color) 38%, rgba(150, 165, 205, 0.16));
+ border-radius: var(--radius-lg);
+ background:
+ linear-gradient(135deg, color-mix(in srgb, var(--goal-tree-card-color) 10%, transparent), transparent 44%),
+ linear-gradient(160deg, rgba(139, 92, 246, 0.12), rgba(16, 20, 38, 0.72));
+ cursor: pointer;
+ box-shadow:
+ 0 12px 22px rgba(0, 0, 0, 0.18),
+ inset 0 1px 0 rgba(255, 255, 255, 0.04);
+ transition:
+ border-color var(--duration-fast) var(--ease-standard),
+ background var(--duration-fast) var(--ease-standard),
+ box-shadow var(--duration-fast) var(--ease-standard);
+}
+
+.goal-tree-node-card.has-actions {
+ grid-template-rows: minmax(0, 1fr) 22px;
+ grid-template-areas:
+ "main"
+ "actions";
+}
+
+.goal-tree-node-hover-actions {
+ position: absolute;
+ z-index: 3;
+ right: 6px;
+ bottom: 7px;
+ left: 6px;
+ display: flex;
+ justify-content: space-between;
+ gap: 4px;
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity var(--duration-fast) var(--ease-standard);
+}
+
+.goal-tree-node-card:hover .goal-tree-node-hover-actions,
+.goal-tree-node-card:focus-within .goal-tree-node-hover-actions {
+ opacity: 1;
+}
+
+.goal-tree-node-hover-actions button {
+ width: 24px;
+ min-width: 24px;
+ min-height: 24px;
+ padding: 0;
+ border-color: rgba(165, 180, 252, 0.14);
+ background: rgba(7, 10, 24, 0.78);
+ pointer-events: none;
+}
+
+.goal-tree-node-card:hover .goal-tree-node-hover-actions button,
+.goal-tree-node-card:focus-within .goal-tree-node-hover-actions button {
+ pointer-events: auto;
+}
+
+.goal-tree-node-hover-actions .ui-icon {
+ width: 13px;
+ height: 13px;
+}
+
+.goal-tree-node.is-text,
+.goal-tree-node.is-checklist {
+ min-width: 184px;
+}
+
+.goal-tree-node.is-text > .goal-tree-node-card,
+.goal-tree-node.is-checklist > .goal-tree-node-card {
+ width: 184px;
+}
+
+.goal-tree-node-card.is-viewing {
+ grid-template-columns: minmax(0, 1fr);
+}
+
+.goal-tree-node.is-selected > .goal-tree-node-card {
+ border-color: color-mix(in srgb, var(--goal-tree-card-color) 72%, rgba(246, 196, 83, 0.24));
+ box-shadow:
+ 0 0 0 1px color-mix(in srgb, var(--goal-tree-card-color) 22%, transparent),
+ 0 12px 22px rgba(0, 0, 0, 0.18);
+}
+
+.goal-tree-node.is-selected > .goal-tree-node-card.is-editing {
+ border-color: color-mix(in srgb, var(--goal-tree-card-color) 86%, var(--color-accent-gold));
+ background:
+ linear-gradient(135deg, color-mix(in srgb, var(--goal-tree-card-color) 20%, transparent), transparent 48%),
+ linear-gradient(160deg, rgba(139, 92, 246, 0.18), rgba(16, 20, 38, 0.82));
+ box-shadow:
+ 0 0 0 2px color-mix(in srgb, var(--goal-tree-card-color) 36%, transparent),
+ 0 0 22px color-mix(in srgb, var(--goal-tree-card-color) 18%, transparent),
+ 0 16px 28px rgba(0, 0, 0, 0.22);
+}
+
+.goal-tree-node.is-complete > .goal-tree-node-card {
+ border-color: color-mix(in srgb, var(--goal-tree-card-color) 54%, rgba(34, 211, 238, 0.3));
+ background:
+ linear-gradient(135deg, color-mix(in srgb, var(--goal-tree-card-color) 12%, transparent), transparent 44%),
+ linear-gradient(160deg, rgba(139, 92, 246, 0.1), rgba(16, 20, 38, 0.72));
+}
+
+.goal-tree-node-card:hover,
+.goal-tree-node-card:focus-within {
+ border-color: color-mix(in srgb, var(--goal-tree-card-color) 68%, rgba(246, 196, 83, 0.24));
+ background:
+ linear-gradient(135deg, color-mix(in srgb, var(--goal-tree-card-color) 16%, transparent), transparent 48%),
+ linear-gradient(160deg, rgba(139, 92, 246, 0.14), rgba(16, 20, 38, 0.78));
+ box-shadow:
+ 0 0 0 1px color-mix(in srgb, var(--goal-tree-card-color) 16%, transparent),
+ 0 14px 26px rgba(0, 0, 0, 0.2);
+}
+
+.goal-tree-node-main {
+ grid-area: main;
+ display: grid;
+ align-content: start;
+ justify-items: stretch;
+ gap: 5px;
+ min-width: 0;
+ min-height: 46px;
+ padding: 5px;
+ border: 0;
+ background: transparent;
+ color: var(--color-text-primary);
+ cursor: pointer;
+ text-align: center;
+ box-shadow: none;
+}
+
+.goal-tree-node.is-step > .goal-tree-node-card > .goal-tree-node-main {
+ justify-items: center;
+ min-height: 58px;
+}
+
+.goal-tree-node-main:hover,
+.goal-tree-node-main:focus {
+ border-color: transparent;
+ background: transparent;
+ box-shadow: none;
+}
+
+.goal-tree-node-heading {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ gap: 6px;
+ align-items: center;
+ min-width: 0;
+}
+
+.goal-tree-node-icon {
+ display: grid;
+ width: 24px;
+ height: 24px;
+ place-items: center;
+ border: 1px solid color-mix(in srgb, var(--goal-tree-node-color, var(--color-accent-gold)) 45%, transparent);
+ border-radius: var(--radius-sm);
+ background: color-mix(in srgb, var(--goal-tree-node-color, var(--color-accent-gold)) 14%, transparent);
+ color: var(--goal-tree-node-color, var(--color-accent-gold));
+}
+
+.goal-tree-node-icon .ui-icon {
+ width: 15px;
+ height: 15px;
+}
+
+.goal-tree-node.is-step > .goal-tree-node-card > .goal-tree-node-main > .goal-tree-node-icon {
+ width: 42px;
+ height: 42px;
+}
+
+.goal-tree-node.is-step > .goal-tree-node-card > .goal-tree-node-main > .goal-tree-node-icon .ui-icon {
+ width: 27px;
+ height: 27px;
+}
+
+.goal-tree-node-title {
+ overflow: hidden;
+ min-width: 0;
+ color: var(--color-text-primary);
+ font-size: 0.78rem;
+ font-weight: 900;
+ text-align: left;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.goal-tree-node-content-preview {
+ display: grid;
+ width: 100%;
+ min-height: 0;
+ gap: 3px;
+ justify-items: stretch;
+ color: var(--color-text-primary);
+}
+
+.goal-tree-node-content-preview:empty {
+ display: none;
+}
+
+.goal-tree-node-content-row {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ gap: 4px;
+ align-items: center;
+ min-width: 0;
+ font-size: 0.72rem;
+ font-weight: 800;
+ text-align: left;
+}
+
+.goal-tree-node.is-checklist .goal-tree-node-content-row {
+ grid-template-columns: auto minmax(0, 1fr);
+}
+
+.goal-tree-node.is-text .goal-tree-node-content-row {
+ grid-template-columns: auto minmax(0, 1fr);
+}
+
+.goal-tree-node.is-text .goal-tree-node-content-row::before {
+ content: "";
+ width: 3px;
+ height: 3px;
+ border-radius: 50%;
+ background: currentColor;
+}
+
+.goal-tree-node-content-row strong {
+ color: var(--color-accent-gold);
+ font-size: 0.72rem;
+}
+
+.goal-tree-node-content-label {
+ overflow: hidden;
+ min-width: 0;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.goal-tree-node-content-add-button {
+ display: grid;
+ width: 100%;
+ min-height: 20px;
+ padding: 0;
+ place-items: center;
+ border-style: dashed;
+ border-color: color-mix(in srgb, var(--goal-tree-card-color, var(--color-accent-gold)) 56%, transparent);
+ border-radius: var(--radius-sm);
+ background: rgba(7, 10, 24, 0.2);
+ color: var(--color-text-secondary);
+}
+
+.goal-tree-node-content-add-button:hover,
+.goal-tree-node-content-add-button:focus-visible {
+ border-color: color-mix(in srgb, var(--goal-tree-card-color, var(--color-accent-gold)) 78%, transparent);
+ background: color-mix(in srgb, var(--goal-tree-card-color, var(--color-accent-gold)) 14%, transparent);
+ color: var(--color-text-primary);
+}
+
+.goal-tree-node-content-add-button .ui-icon {
+ width: 12px;
+ height: 12px;
+}
+
+.goal-tree-node.is-checklist .goal-tree-node-content-label {
+ padding-left: 2px;
+}
+
+.goal-tree-node-check {
+ display: grid;
+ width: 10px;
+ height: 10px;
+ margin: 0;
+ accent-color: var(--color-accent-cyan);
+}
+
+.goal-tree-step-check {
+ justify-self: center;
+ width: 16px;
+ height: 16px;
+ margin: 0;
+ accent-color: var(--color-accent-gold);
+}
+
+.goal-tree-step-qty-stepper {
+ justify-self: center;
+ grid-template-columns: 18px minmax(34px, auto) 18px;
+ gap: 2px;
+ font-size: 0.68rem;
+}
+
+.goal-tree-step-qty-stepper button {
+ display: grid;
+ width: 18px;
+ min-width: 18px;
+ height: 18px;
+ min-height: 18px;
+ padding: 0;
+ place-items: center;
+}
+
+.goal-tree-step-qty-stepper .ui-icon {
+ width: 10px;
+ height: 10px;
+}
+
+.goal-tree-node-content-row .goal-tree-content-qty-stepper {
+ justify-self: start;
+ grid-template-columns: 16px minmax(30px, auto) 16px;
+ gap: 2px;
+ font-size: 0.64rem;
+}
+
+.goal-tree-node-content-row .goal-tree-content-qty-stepper button {
+ min-width: 16px;
+ min-height: 16px;
+ padding: 0;
+}
+
+.goal-tree-node-content-row .goal-tree-content-qty-stepper .ui-icon {
+ width: 9px;
+ height: 9px;
+}
+
+.goal-tree-progress-track {
+ grid-column: 1 / -1;
+ justify-self: stretch;
+ width: 100%;
+ height: 5px;
+ overflow: hidden;
+ border-radius: var(--radius-pill);
+ background: rgba(150, 165, 205, 0.13);
+}
+
+.goal-tree-progress-track span {
+ display: block;
+ height: 100%;
+ border-radius: inherit;
+ background: linear-gradient(90deg, var(--color-accent-gold), var(--color-accent-cyan));
+}
+
+.goal-tree-node-actions {
+ grid-area: actions;
+ display: flex;
+ min-height: 22px;
+ justify-content: center;
+ align-items: center;
+ gap: 4px;
+ padding-top: 0;
+}
+
+.goal-tree-node-actions button,
+.goal-tree-drag-handle {
+ width: 22px;
+ min-width: 22px;
+ height: 22px;
+ min-height: 22px;
+ padding: 0;
+}
+
+.goal-tree-node-actions .goal-tree-branch-toggle {
+ width: 22px;
+ min-width: 22px;
+ height: 22px;
+ min-height: 22px;
+}
+
+.goal-tree-canvas.is-horizontal .goal-tree-branch-toggle .ui-icon-chevron-down,
+.goal-tree-canvas.is-horizontal .goal-tree-branch-toggle .ui-icon-chevron-up {
+ transform: rotate(-90deg);
+}
+
+.goal-tree-drag-handle {
+ position: absolute;
+ z-index: 3;
+ top: 6px;
+ left: 6px;
+ border-color: rgba(165, 180, 252, 0.34);
+ background: rgba(7, 10, 24, 0.82);
+ color: var(--color-text-primary);
+ cursor: grab;
+ opacity: 1;
+ box-shadow:
+ 0 0 0 1px rgba(7, 10, 24, 0.72),
+ 0 6px 12px rgba(0, 0, 0, 0.22);
+}
+
+.goal-tree-drag-handle:hover {
+ border-color: rgba(246, 196, 83, 0.42);
+ background: rgba(7, 10, 24, 0.92);
+ color: var(--color-text-primary);
+ box-shadow:
+ 0 0 0 1px rgba(246, 196, 83, 0.12),
+ 0 8px 14px rgba(0, 0, 0, 0.26);
+}
+
+.goal-tree-drag-handle .ui-icon {
+ width: 12px;
+ height: 12px;
+}
+
+.goal-tree-node-tooltip {
+ max-width: 220px;
+}
+
+.goal-tree-node.is-dragging {
+ opacity: 0.55;
+}
+
+.goal-tree-node.is-drop-target > .goal-tree-node-card {
+ border-color: rgba(246, 196, 83, 0.48);
+}
+
+.goal-tree-node.is-drop-target > .goal-tree-node-card::after {
+ position: absolute;
+ border-radius: var(--radius-pill);
+ background: rgba(246, 196, 83, 0.9);
+ box-shadow: 0 0 12px rgba(246, 196, 83, 0.24);
+ content: "";
+ pointer-events: none;
+}
+
+.goal-tree-node[data-reorder-orientation="horizontal"].is-drop-target > .goal-tree-node-card::after {
+ top: 8px;
+ bottom: 8px;
+ left: -8px;
+ width: 3px;
+}
+
+.goal-tree-node[data-reorder-orientation="horizontal"].is-drop-target.drop-after > .goal-tree-node-card::after {
+ right: -8px;
+ left: auto;
+}
+
+.goal-tree-node[data-reorder-orientation="vertical"].is-drop-target > .goal-tree-node-card::after {
+ top: -8px;
+ right: 8px;
+ left: 8px;
+ height: 3px;
+}
+
+.goal-tree-node[data-reorder-orientation="vertical"].is-drop-target.drop-after > .goal-tree-node-card::after {
+ top: auto;
+ bottom: -8px;
+}
+
+.goal-tree-empty {
+ padding: var(--space-4);
+ color: var(--color-text-secondary);
+ text-align: center;
+}
+
+.goal-tree-module .text-import-actions {
+ padding-top: var(--space-2);
+}
+
+@media (max-width: 860px) {
+ .goal-tree-module {
+ grid-template-columns: 1fr;
+ }
+
+ .goal-tree-view.tool-split-results {
+ min-height: 220px;
+ }
+
+ .goal-tree-list {
+ gap: 16px;
+ }
+
+ .goal-tree-node {
+ min-width: 96px;
+ }
+
+ .goal-tree-node-card {
+ width: 96px;
+ }
+
+ .goal-tree-node.is-text,
+ .goal-tree-node.is-checklist {
+ min-width: 160px;
+ }
+
+ .goal-tree-node.is-text > .goal-tree-node-card,
+ .goal-tree-node.is-checklist > .goal-tree-node-card {
+ width: 160px;
+ }
+}
diff --git a/website/src/styles/toolboxes/goal-tree/_view.scss b/website/src/styles/toolboxes/goal-tree/_view.scss
new file mode 100644
index 0000000..01f2028
--- /dev/null
+++ b/website/src/styles/toolboxes/goal-tree/_view.scss
@@ -0,0 +1,270 @@
+// Rôle : stylise la zone de rendu, le canevas et les connecteurs de l'arbre d'objectifs.
+.goal-tree-view.tool-split-results {
+ position: relative;
+ min-width: 0;
+ width: 100%;
+ max-width: 100%;
+ min-height: 260px;
+ overflow: hidden;
+ padding: 0;
+}
+
+.goal-tree-scroll-area {
+ display: grid;
+ grid-template-columns: minmax(0, max-content);
+ grid-template-rows: auto minmax(0, max-content);
+ width: 100%;
+ height: 100%;
+ min-height: inherit;
+ align-content: start;
+ justify-content: start;
+ overflow-x: auto;
+ overflow-y: auto;
+ overscroll-behavior-x: contain;
+ overscroll-behavior-y: auto;
+ padding: var(--space-4);
+ background:
+ linear-gradient(rgba(150, 165, 205, 0.035) 1px, transparent 1px),
+ linear-gradient(90deg, rgba(150, 165, 205, 0.035) 1px, transparent 1px),
+ radial-gradient(circle at 50% 0%, rgba(139, 92, 246, 0.08), transparent 34%),
+ rgba(7, 10, 24, 0.2);
+ background-size:
+ 28px 28px,
+ 28px 28px,
+ auto,
+ auto;
+}
+
+.goal-tree-view.is-horizontal.is-editing .goal-tree-scroll-area {
+ grid-template-columns: 44px minmax(0, max-content);
+ grid-template-rows: minmax(260px, max-content);
+ column-gap: var(--space-3);
+}
+
+.goal-tree-view.is-horizontal.is-editing .goal-tree-view-controls {
+ grid-column: 1;
+ grid-row: 1;
+}
+
+.goal-tree-view.is-horizontal.is-editing .goal-tree-canvas-frame,
+.goal-tree-view.is-horizontal.is-editing .goal-tree-empty {
+ grid-column: 2;
+ grid-row: 1;
+}
+
+.goal-tree-view.is-vertical.is-editing .goal-tree-scroll-area {
+ grid-template-columns: minmax(0, 1fr);
+}
+
+.goal-tree-view.is-vertical.is-editing .goal-tree-view-controls {
+ grid-column: 1;
+ grid-row: 1;
+ width: 100%;
+ max-width: none;
+ box-sizing: border-box;
+ padding-right: 84px;
+}
+
+.goal-tree-view.is-vertical.is-editing .goal-tree-canvas-frame,
+.goal-tree-view.is-vertical.is-editing .goal-tree-empty {
+ grid-column: 1;
+ grid-row: 2;
+}
+
+.goal-tree-view:not(.is-editing) .goal-tree-canvas-frame {
+ margin-inline: auto;
+}
+
+.goal-tree-view-controls {
+ position: relative;
+ z-index: 5;
+ display: flex;
+ align-items: flex-start;
+ gap: var(--space-2);
+ min-width: 0;
+ margin-bottom: var(--space-2);
+}
+
+.goal-tree-view:not(.is-editing) .goal-tree-view-controls {
+ display: none;
+}
+
+.goal-tree-view.is-horizontal .goal-tree-view-controls {
+ flex-direction: column;
+ align-self: stretch;
+ min-height: 100%;
+ margin-bottom: 0;
+}
+
+.goal-tree-view.is-vertical .goal-tree-view-controls {
+ width: 100%;
+ max-width: 100%;
+}
+
+.goal-tree-orientation-switch {
+ position: absolute;
+ z-index: 7;
+ top: var(--space-4);
+ right: var(--space-4);
+ display: inline-grid;
+ grid-template-columns: 16px 30px;
+ width: fit-content;
+ min-width: 70px;
+ min-height: 40px;
+ align-items: center;
+ flex: 0 0 auto;
+ gap: 7px;
+ padding: 0 7px 0 10px;
+ border-radius: var(--radius-pill);
+ box-shadow: 0 10px 18px rgba(0, 0, 0, 0.16);
+}
+
+.goal-tree-orientation-switch .ui-icon {
+ width: 16px;
+ height: 16px;
+}
+
+.goal-tree-module.is-view-only {
+ grid-template-columns: 1fr;
+}
+
+.goal-tree-canvas-frame {
+ position: relative;
+ width: var(--goal-tree-canvas-width, 100%);
+ min-width: min(100%, var(--goal-tree-canvas-width, 100%));
+ height: var(--goal-tree-canvas-height, 1px);
+}
+
+.goal-tree-canvas {
+ position: relative;
+ display: grid;
+ width: max-content;
+ min-width: 100%;
+ max-width: none;
+ align-items: start;
+ justify-items: center;
+ isolation: isolate;
+ transform: scale(var(--goal-tree-view-scale, 1));
+ transform-origin: top left;
+}
+
+.goal-tree-canvas-frame > .goal-tree-canvas {
+ position: absolute;
+ top: 0;
+ left: 0;
+}
+
+.goal-tree-canvas.is-horizontal {
+ display: flex;
+ align-items: flex-start;
+ justify-items: start;
+}
+
+.diagram-connectors {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ overflow: visible;
+ pointer-events: none;
+}
+
+.goal-tree-connectors {
+ z-index: 1;
+ color: rgba(246, 196, 83, 0.58);
+}
+
+.goal-tree-connectors path {
+ fill: none;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ stroke-width: 1.35;
+ vector-effect: non-scaling-stroke;
+}
+
+.goal-tree-list {
+ position: relative;
+ z-index: 2;
+ display: flex;
+ align-items: flex-start;
+ justify-content: center;
+ min-width: max-content;
+ margin: 0;
+ padding: 0;
+ gap: 24px;
+ list-style: none;
+}
+
+.goal-tree-list.is-root-list {
+ padding: 8px;
+}
+
+.goal-tree-list.is-child-list {
+ margin-top: 28px;
+ padding-top: 28px;
+}
+
+.goal-tree-canvas.is-horizontal .goal-tree-list {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 20px;
+}
+
+.goal-tree-canvas.is-horizontal .goal-tree-list.is-root-list {
+ padding: 8px 48px 8px 8px;
+}
+
+.goal-tree-add-root-button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 7px;
+ padding: 0;
+ border-style: dashed;
+ background: rgba(7, 10, 24, 0.72);
+ color: var(--color-text-secondary);
+ font-size: 0.84rem;
+ font-weight: 900;
+ white-space: nowrap;
+ box-shadow: 0 10px 18px rgba(0, 0, 0, 0.16);
+}
+
+.goal-tree-view.is-horizontal .goal-tree-add-root-button {
+ width: 44px;
+ min-width: 44px;
+ min-height: 360px;
+ flex: 1 1 360px;
+ flex-direction: row-reverse;
+ padding: 16px 0;
+ text-align: center;
+ writing-mode: vertical-rl;
+}
+
+.goal-tree-view.is-horizontal .goal-tree-add-root-button span {
+ transform: rotate(180deg);
+}
+
+.goal-tree-view.is-horizontal .goal-tree-add-root-button .ui-icon {
+ transform: rotate(180deg);
+}
+
+.goal-tree-view.is-vertical .goal-tree-add-root-button {
+ width: min(520px, 100%);
+ max-width: 100%;
+ min-width: min(390px, 100%);
+ min-height: 40px;
+ flex: 0 1 520px;
+ padding: 0 16px;
+}
+
+.goal-tree-add-root-button .ui-icon {
+ width: 15px;
+ height: 15px;
+}
+
+.goal-tree-canvas.is-horizontal .goal-tree-list.is-child-list {
+ margin-top: 0;
+ margin-left: 34px;
+ padding-top: 0;
+ padding-left: 34px;
+}