// Rôle : parse et sérialise les formats texte/TSV d'échange des outils toolbox. 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", "taskPlanner", "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 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 === "taskPlanner") return Boolean(data?.tasks?.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 === "taskPlanner") return exportTaskPlannerText(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 === "taskPlanner") data = importTaskPlannerText(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) => { lines.push(`${" ".repeat(depth)}${entry.label || ""}: ${entry.value}`); 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 value = parseNumber(line.slice(separatorIndex + 1), 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 }; 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 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 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) }); }