1023 lines
44 KiB
JavaScript
1023 lines
44 KiB
JavaScript
// 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 ? `<p>${escapeHtml(line)}</p>` : "<p><br></p>")
|
|
.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) });
|
|
}
|