// 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";
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);
}
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;
export function supportsTextExchange(type) {
return SUPPORTED_TEXT_EXCHANGE_TYPES.has(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;
}
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 "";
}
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é.");
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) });
}