1028 lines
37 KiB
JavaScript
1028 lines
37 KiB
JavaScript
// Rôle : normalise, compacte et sérialise les données toolbox persistées.
|
|
const ID_PREFIXES = { tbx: "t", mod: "m", item: "i", section: "g", image: "s", link: "l", counter: "c", combo: "o", calc: "r", marker: "k", timer: "z", task: "a", relation: "e", stroke: "d" };
|
|
const ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
const NOTEPAD_ALLOWED_COLORS = new Set(["#f5f7ff", "#b4bdd3", "#f6c453", "#22d3ee", "#8b5cf6", "#d946ef", "#101426", "#202745"]);
|
|
const NOTEPAD_ALLOWED_TAGS = new Set(["p", "div", "br", "strong", "b", "em", "i", "u", "s", "strike", "ul", "ol", "li", "h3", "h4", "span", "mark"]);
|
|
const NOTEPAD_RGB_COLOR_MAP = {
|
|
"rgb(245, 247, 255)": "#f5f7ff",
|
|
"rgb(180, 189, 211)": "#b4bdd3",
|
|
"rgb(246, 196, 83)": "#f6c453",
|
|
"rgb(34, 211, 238)": "#22d3ee",
|
|
"rgb(139, 92, 246)": "#8b5cf6",
|
|
"rgb(217, 70, 239)": "#d946ef",
|
|
"rgb(16, 20, 38)": "#101426",
|
|
"rgb(32, 39, 69)": "#202745"
|
|
};
|
|
const TIMER_TABS = new Set(["stopwatch", "countdown"]);
|
|
const COUNTDOWN_TYPES = new Set(["duration", "daily_time", "time_pattern", "interval"]);
|
|
const TIMER_ALERT_MODES = new Set(["off", "visible", "site"]);
|
|
const TASK_TYPES = new Set(["unique", "daily", "weekly"]);
|
|
const COMBO_DEVICES = new Set(["playstation", "xbox", "switch", "n64", "keyboardMouse"]);
|
|
const COMBO_INPUT_KINDS = new Set(["button", "direction", "key", "mouse"]);
|
|
const DEFAULT_TASK_PLANNER_WEEKLY_RESET_DAY = 1;
|
|
const DEFAULT_TASK_PLANNER_RESET_TIME = "00:00";
|
|
const TOOLBOX_ICON_BASE = "/static/img/toolbox-icons/";
|
|
const TOOLBOX_ICON_FILES = [
|
|
"toolbox.png",
|
|
"ball.png",
|
|
"capture.png",
|
|
"card.png",
|
|
"city.png",
|
|
"compass.png",
|
|
"crash.png",
|
|
"detective.png",
|
|
"fire.png",
|
|
"horror.png",
|
|
"jump.png",
|
|
"mining.png",
|
|
"parachute.png",
|
|
"puzzle.png",
|
|
"shield.png",
|
|
"sword.png",
|
|
"target.png",
|
|
"tower.png",
|
|
"wheel.png"
|
|
];
|
|
const DEFAULT_MODULE_TITLES = {
|
|
notepad: "Bloc notes",
|
|
checklist: "Checklist",
|
|
images: "Images",
|
|
links: "Liens",
|
|
counters: "Compteurs",
|
|
combos: "Combos",
|
|
calculator: "Calculateur",
|
|
table: "Tableau",
|
|
timer: "Timer",
|
|
taskPlanner: "Planificateur de tâches",
|
|
imageAnnotation: "Annotation d'images"
|
|
};
|
|
|
|
export const TOOLBOX_ICONS = TOOLBOX_ICON_FILES.map((file) => `${TOOLBOX_ICON_BASE}${file}`);
|
|
export const DEFAULT_TOOLBOX_ICON = `${TOOLBOX_ICON_BASE}toolbox.png`;
|
|
|
|
function randomToken(length = 6) {
|
|
const bytes = new Uint8Array(length);
|
|
if (globalThis.crypto?.getRandomValues) {
|
|
globalThis.crypto.getRandomValues(bytes);
|
|
return [...bytes].map((byte) => ID_ALPHABET[byte % ID_ALPHABET.length]).join("");
|
|
}
|
|
return Math.random().toString(36).slice(2, 2 + length).padEnd(length, "0");
|
|
}
|
|
|
|
export function uid(prefix) {
|
|
return `${ID_PREFIXES[prefix] || "x"}${randomToken()}`;
|
|
}
|
|
|
|
function getDefaultModuleTitle(type) {
|
|
return DEFAULT_MODULE_TITLES[type] || "Outil";
|
|
}
|
|
|
|
export function normalizeToolboxIcon(icon) {
|
|
const value = String(icon || "").trim();
|
|
const file = value.replace(TOOLBOX_ICON_BASE, "").split("/").pop();
|
|
return TOOLBOX_ICON_FILES.includes(file) ? `${TOOLBOX_ICON_BASE}${file}` : DEFAULT_TOOLBOX_ICON;
|
|
}
|
|
|
|
function normalizeToolboxModule(module) {
|
|
if (!module || typeof module !== "object" || !module.type) return null;
|
|
const title = String(module.title || "").trim();
|
|
const normalized = { id: module.id || uid("mod"), type: module.type };
|
|
if (title && title !== getDefaultModuleTitle(module.type)) normalized.title = title;
|
|
if (module.scrollable === true) normalized.scrollable = true;
|
|
return normalized;
|
|
}
|
|
|
|
export function normalizeToolbox(toolbox) {
|
|
if (!toolbox || typeof toolbox !== "object") return null;
|
|
return {
|
|
id: toolbox.id || uid("tbx"),
|
|
name: String(toolbox.name || "Nouvelle toolbox").trim() || "Nouvelle toolbox",
|
|
icon: normalizeToolboxIcon(toolbox.icon),
|
|
moduleColumns: Number(toolbox.moduleColumns) === 1 ? 1 : 2,
|
|
modules: (Array.isArray(toolbox.modules) ? toolbox.modules : []).map(normalizeToolboxModule).filter(Boolean),
|
|
updatedAt: toolbox.updatedAt || new Date().toISOString()
|
|
};
|
|
}
|
|
|
|
export function compactToolboxForStorage(toolbox) {
|
|
const normalized = normalizeToolbox(toolbox);
|
|
if (!normalized) return null;
|
|
const compact = {
|
|
id: normalized.id,
|
|
name: normalized.name,
|
|
modules: normalized.modules,
|
|
updatedAt: normalized.updatedAt
|
|
};
|
|
if (normalized.moduleColumns === 1) compact.moduleColumns = 1;
|
|
if (normalized.icon !== DEFAULT_TOOLBOX_ICON) compact.icon = normalized.icon;
|
|
return compact;
|
|
}
|
|
|
|
export function compactToolboxesForStorage(toolboxes) {
|
|
return (Array.isArray(toolboxes) ? toolboxes : []).map(compactToolboxForStorage).filter(Boolean);
|
|
}
|
|
|
|
export function moduleStorageKey(toolboxId, moduleId) {
|
|
return `${toolboxId}:${moduleId}`;
|
|
}
|
|
|
|
export function globalModuleKey(toolboxId, moduleId) {
|
|
return `${toolboxId}:${moduleId}`;
|
|
}
|
|
|
|
function parsePositiveInt(value, fallback = 1) {
|
|
const parsed = Number.parseInt(value, 10);
|
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return String(value || "")
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """);
|
|
}
|
|
|
|
function decodeBasicEntities(value) {
|
|
return String(value || "")
|
|
.replace(/ /gi, " ")
|
|
.replace(/</gi, "<")
|
|
.replace(/>/gi, ">")
|
|
.replace(/"/gi, "\"")
|
|
.replace(/'/g, "'")
|
|
.replace(/&/gi, "&");
|
|
}
|
|
|
|
function normalizeStyleAttribute(value) {
|
|
const declarations = String(value || "")
|
|
.split(";")
|
|
.map((item) => item.trim())
|
|
.filter(Boolean);
|
|
const allowed = [];
|
|
declarations.forEach((declaration) => {
|
|
const [property, rawValue] = declaration.split(":").map((item) => item?.trim().toLowerCase());
|
|
if (!["color", "background-color"].includes(property)) return;
|
|
const color = NOTEPAD_RGB_COLOR_MAP[rawValue] || rawValue;
|
|
if (!NOTEPAD_ALLOWED_COLORS.has(color)) return;
|
|
allowed.push(`${property}: ${color}`);
|
|
});
|
|
return allowed.join("; ");
|
|
}
|
|
|
|
export function sanitizeNotepadHtml(value) {
|
|
const source = String(value || "")
|
|
.replace(/<script[\s\S]*?<\/script>/gi, "")
|
|
.replace(/<style[\s\S]*?<\/style>/gi, "");
|
|
let html = "";
|
|
let cursor = 0;
|
|
source.replace(/<\/?([a-z0-9]+)([^>]*)>/gi, (match, rawTag, rawAttributes, offset) => {
|
|
html += escapeHtml(source.slice(cursor, offset));
|
|
cursor = offset + match.length;
|
|
const tag = rawTag.toLowerCase();
|
|
if (!NOTEPAD_ALLOWED_TAGS.has(tag)) return "";
|
|
if (match.startsWith("</")) {
|
|
if (tag !== "br") html += `</${tag}>`;
|
|
return "";
|
|
}
|
|
if (tag === "br") {
|
|
html += "<br>";
|
|
return "";
|
|
}
|
|
const styleMatch = String(rawAttributes || "").match(/\sstyle=(?:"([^"]*)"|'([^']*)')/i);
|
|
const style = normalizeStyleAttribute(styleMatch?.[1] || styleMatch?.[2] || "");
|
|
html += style ? `<${tag} style="${style}">` : `<${tag}>`;
|
|
return "";
|
|
});
|
|
html += escapeHtml(source.slice(cursor));
|
|
return html.trim();
|
|
}
|
|
|
|
function textToNotepadHtml(value) {
|
|
return String(value || "")
|
|
.split(/\r?\n/)
|
|
.map((line) => line ? `<p>${escapeHtml(line)}</p>` : "<p><br></p>")
|
|
.join("");
|
|
}
|
|
|
|
function notepadHtmlToText(value) {
|
|
const withBreaks = String(value || "")
|
|
.replace(/<br\s*\/?>/gi, "\n")
|
|
.replace(/<\/(p|div|h3|h4|li)>/gi, "\n")
|
|
.replace(/<li[^>]*>/gi, "- ");
|
|
return decodeBasicEntities(withBreaks.replace(/<[^>]*>/g, ""))
|
|
.split("\n")
|
|
.map((line) => line.trimEnd())
|
|
.join("\n")
|
|
.replace(/\n{3,}/g, "\n\n")
|
|
.trim();
|
|
}
|
|
|
|
function normalizeIsoDate(value) {
|
|
const text = String(value || "").trim();
|
|
if (!text) return "";
|
|
const timestamp = Date.parse(text);
|
|
return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : "";
|
|
}
|
|
|
|
function normalizeDrawingPoint(point, options = {}) {
|
|
const x = Number(point?.x);
|
|
const y = Number(point?.y);
|
|
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
|
|
const max = Number(options.max);
|
|
return {
|
|
x: Math.min(Number.isFinite(max) ? max : Number.POSITIVE_INFINITY, Math.max(0, Math.round(x * 100) / 100)),
|
|
y: Math.min(Number.isFinite(max) ? max : Number.POSITIVE_INFINITY, Math.max(0, Math.round(y * 100) / 100))
|
|
};
|
|
}
|
|
|
|
function normalizeDrawingStroke(stroke, options = {}) {
|
|
const points = (Array.isArray(stroke?.points) ? stroke.points : []).map((point) => normalizeDrawingPoint(point, options)).filter(Boolean);
|
|
if (points.length < 2) return null;
|
|
const color = NOTEPAD_ALLOWED_COLORS.has(String(stroke?.color || "").toLowerCase()) ? String(stroke.color).toLowerCase() : "#f6c453";
|
|
const width = Math.min(24, Math.max(1, Number(stroke?.width) || 4));
|
|
return {
|
|
id: stroke?.id || uid("stroke"),
|
|
color,
|
|
width: Math.round(width * 10) / 10,
|
|
points
|
|
};
|
|
}
|
|
|
|
export function normalizeDrawingData(data, options = {}) {
|
|
return {
|
|
strokes: (Array.isArray(data?.strokes) ? data.strokes : []).map((stroke) => normalizeDrawingStroke(stroke, options)).filter(Boolean)
|
|
};
|
|
}
|
|
|
|
export function normalizeNotepadData(data) {
|
|
const source = data && typeof data === "object" ? data : {};
|
|
const legacyText = String(source.text || "");
|
|
const html = sanitizeNotepadHtml(source.html ? source.html : textToNotepadHtml(legacyText));
|
|
const text = notepadHtmlToText(html) || legacyText.trim();
|
|
const updatedAt = normalizeIsoDate(source.updatedAt);
|
|
const drawingMode = source.drawingMode === "permanent" ? "permanent" : "temporary";
|
|
const drawings = normalizeDrawingData(source.drawings);
|
|
return { html, text, updatedAt, drawingMode, drawings };
|
|
}
|
|
|
|
export function clampQty(value, target) {
|
|
const parsed = Number.parseInt(value, 10);
|
|
const safeValue = Number.isFinite(parsed) ? parsed : 0;
|
|
return Math.min(safeValue, Math.max(1, target));
|
|
}
|
|
|
|
function normalizeChecklistItem(item) {
|
|
const qtyTarget = Math.max(1, parsePositiveInt(item?.qtyTarget, 1));
|
|
const qtyCurrent = clampQty(item?.qtyCurrent, qtyTarget);
|
|
return {
|
|
id: item?.id || uid("item"),
|
|
label: String(item?.label || "").trim(),
|
|
qtyTarget,
|
|
qtyCurrent
|
|
};
|
|
}
|
|
|
|
function normalizeChecklistSection(section, fallbackTitle = "") {
|
|
const normalized = {
|
|
id: section?.id || uid("section"),
|
|
title: String(section?.title ?? fallbackTitle).trim(),
|
|
items: (section?.items || []).map(normalizeChecklistItem).filter((item) => item.label)
|
|
};
|
|
if (typeof section?.hideWhenComplete === "boolean") normalized.hideWhenComplete = section.hideWhenComplete;
|
|
if (section?.collapsed === true) normalized.collapsed = true;
|
|
return normalized;
|
|
}
|
|
|
|
export function normalizeChecklistData(data) {
|
|
const hideCompletedSections = Boolean(data?.hideCompletedSections);
|
|
const hideCompletedSectionsFully = Boolean(data?.hideCompletedSectionsFully);
|
|
const sections = Array.isArray(data?.sections)
|
|
? data.sections.map((section) => normalizeChecklistSection(section)).filter((section) => section.items.length || section.title)
|
|
: [];
|
|
const simpleItems = (data?.items || []).map(normalizeChecklistItem).filter((item) => item.label);
|
|
if (!sections.length && simpleItems.length) return { hideCompletedSections, hideCompletedSectionsFully, sections: [{ id: uid("section"), title: "", items: simpleItems }], items: simpleItems };
|
|
const items = sections.flatMap((section) => section.items);
|
|
return { hideCompletedSections, hideCompletedSectionsFully, sections, items };
|
|
}
|
|
|
|
export function normalizeUrl(value) {
|
|
const cleanValue = String(value || "").trim();
|
|
if (!cleanValue) return "";
|
|
|
|
try {
|
|
const url = new URL(cleanValue.includes("://") ? cleanValue : `https://${cleanValue}`);
|
|
if (url.protocol !== "http:" && url.protocol !== "https:") return "";
|
|
return url.href;
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
export function hostnameFromUrl(value) {
|
|
try {
|
|
return new URL(value).hostname.replace(/^www\./, "");
|
|
} catch {
|
|
return value;
|
|
}
|
|
}
|
|
|
|
export function normalizeLinksData(data) {
|
|
return {
|
|
links: (data?.links || [])
|
|
.map((link) => {
|
|
const url = normalizeUrl(link?.url);
|
|
if (!url) return null;
|
|
return {
|
|
id: link?.id || uid("link"),
|
|
title: String(link?.title || "").trim(),
|
|
url
|
|
};
|
|
})
|
|
.filter(Boolean)
|
|
};
|
|
}
|
|
|
|
function normalizeCounterValue(value) {
|
|
const parsed = Number.parseInt(value, 10);
|
|
return Number.isFinite(parsed) ? parsed : 0;
|
|
}
|
|
|
|
export function normalizeCountersData(data) {
|
|
return {
|
|
counters: (data?.counters || [])
|
|
.map((counter) => ({
|
|
id: counter?.id || uid("counter"),
|
|
label: String(counter?.label || "").trim(),
|
|
value: normalizeCounterValue(counter?.value)
|
|
}))
|
|
.filter((counter) => counter.label)
|
|
};
|
|
}
|
|
|
|
function normalizeComboInput(input) {
|
|
const kind = COMBO_INPUT_KINDS.has(input?.kind) ? input.kind : "";
|
|
const value = String(input?.value || "").trim().slice(0, 24);
|
|
return kind && value ? { kind, value } : null;
|
|
}
|
|
|
|
function normalizeComboSteps(inputs) {
|
|
return (Array.isArray(inputs) ? inputs : [])
|
|
.slice(0, 64)
|
|
.map((step) => (Array.isArray(step) ? step : [])
|
|
.map(normalizeComboInput)
|
|
.filter(Boolean)
|
|
.slice(0, 8))
|
|
.filter((step) => step.length);
|
|
}
|
|
|
|
function normalizeCombo(combo) {
|
|
const name = String(combo?.name || "").trim().slice(0, 80);
|
|
const device = COMBO_DEVICES.has(combo?.device) ? combo.device : "";
|
|
const inputs = normalizeComboSteps(combo?.inputs);
|
|
if (!name && !inputs.length) return null;
|
|
const normalized = {
|
|
id: combo?.id || uid("combo"),
|
|
name: name || "Combo",
|
|
inputs
|
|
};
|
|
if (device) normalized.device = device;
|
|
return normalized;
|
|
}
|
|
|
|
function normalizeComboCategory(category) {
|
|
const title = String(category?.title || "").trim().slice(0, 80);
|
|
const combos = (Array.isArray(category?.combos) ? category.combos : []).map(normalizeCombo).filter(Boolean);
|
|
if (!combos.length) return null;
|
|
return {
|
|
id: category?.id || uid("section"),
|
|
title: title || "Catégorie",
|
|
collapsed: category?.collapsed === true,
|
|
combos
|
|
};
|
|
}
|
|
|
|
export function normalizeCombosData(data) {
|
|
return {
|
|
device: COMBO_DEVICES.has(data?.device) ? data.device : "playstation",
|
|
combos: (Array.isArray(data?.combos) ? data.combos : []).map(normalizeCombo).filter(Boolean),
|
|
categories: (Array.isArray(data?.categories) ? data.categories : []).map(normalizeComboCategory).filter(Boolean)
|
|
};
|
|
}
|
|
|
|
function normalizeCalculatorValue(value) {
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) ? parsed : 0;
|
|
}
|
|
|
|
function clampTableSize(value, fallback, max) {
|
|
const parsed = Number.parseInt(value, 10);
|
|
return Number.isInteger(parsed) ? Math.min(max, Math.max(1, parsed)) : fallback;
|
|
}
|
|
|
|
function columnIndexToName(index) {
|
|
let value = Math.max(0, Number(index) || 0) + 1;
|
|
let name = "";
|
|
while (value > 0) {
|
|
const remainder = (value - 1) % 26;
|
|
name = String.fromCharCode(65 + remainder) + name;
|
|
value = Math.floor((value - 1) / 26);
|
|
}
|
|
return name;
|
|
}
|
|
|
|
function tableCellAddress(rowIndex, columnIndex) {
|
|
return `${columnIndexToName(columnIndex)}${rowIndex + 1}`;
|
|
}
|
|
|
|
function normalizeTableLabels(labels, length, getFallback) {
|
|
const sourceLabels = labels && typeof labels === "object" ? labels : {};
|
|
const normalized = {};
|
|
for (let index = 0; index < length; index += 1) {
|
|
const value = String(sourceLabels[index] || "").slice(0, 80);
|
|
if (value.trim() && value !== String(getFallback(index))) normalized[index] = value;
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function isTimeString(value, allowWildcard = false) {
|
|
const pattern = allowWildcard ? /^(?:\d{2}|X):(?:\d{2}|X):(?:\d{2}|X)$/ : /^\d{2}:\d{2}:\d{2}$/;
|
|
if (!pattern.test(String(value || ""))) return false;
|
|
return String(value).split(":").every((part, index) => {
|
|
if (part === "X") return allowWildcard;
|
|
const number = Number(part);
|
|
return Number.isInteger(number) && number >= 0 && number <= (index === 0 ? 23 : 59);
|
|
});
|
|
}
|
|
|
|
function isClockTimeString(value) {
|
|
if (!/^\d{2}:\d{2}$/.test(String(value || ""))) return false;
|
|
const [hours, minutes] = String(value).split(":").map(Number);
|
|
return Number.isInteger(hours) && Number.isInteger(minutes) && hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59;
|
|
}
|
|
|
|
function normalizeWeekDay(value, fallback = DEFAULT_TASK_PLANNER_WEEKLY_RESET_DAY) {
|
|
const parsed = Number.parseInt(value, 10);
|
|
return Number.isInteger(parsed) && parsed >= 0 && parsed <= 6 ? parsed : fallback;
|
|
}
|
|
|
|
function normalizeTimestamp(value) {
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
|
}
|
|
|
|
export function labelFromFileName(name) {
|
|
if (!name || name === "image.png") return "";
|
|
return name.replace(/\.[^.]+$/, "").trim();
|
|
}
|
|
|
|
export function normalizeCalculatorData(data) {
|
|
const entries = (data?.entries || [])
|
|
.map((entry) => ({
|
|
id: entry?.id || uid("calc"),
|
|
parentId: String(entry?.parentId || ""),
|
|
label: String(entry?.label || "").trim(),
|
|
value: normalizeCalculatorValue(entry?.value)
|
|
}));
|
|
const entryIds = new Set(entries.map((entry) => entry.id));
|
|
return {
|
|
scrollResults: data?.scrollResults === true,
|
|
entries: entries.map((entry) => entryIds.has(entry.parentId) ? entry : { ...entry, parentId: "" })
|
|
};
|
|
}
|
|
|
|
export function normalizeTableData(data) {
|
|
const rows = clampTableSize(data?.rows, 10, 50);
|
|
const columns = clampTableSize(data?.columns, 6, 20);
|
|
const sourceCells = data?.cells && typeof data.cells === "object" ? data.cells : {};
|
|
const cells = {};
|
|
|
|
for (let rowIndex = 0; rowIndex < rows; rowIndex += 1) {
|
|
for (let columnIndex = 0; columnIndex < columns; columnIndex += 1) {
|
|
const address = tableCellAddress(rowIndex, columnIndex);
|
|
const value = String(sourceCells[address] || "").trim();
|
|
if (value) cells[address] = value.slice(0, 500);
|
|
}
|
|
}
|
|
|
|
return {
|
|
rows,
|
|
columns,
|
|
cells,
|
|
rowLabels: normalizeTableLabels(data?.rowLabels, rows, (rowIndex) => rowIndex + 1),
|
|
columnLabels: normalizeTableLabels(data?.columnLabels, columns, columnIndexToName)
|
|
};
|
|
}
|
|
|
|
export function normalizeTimerData(data) {
|
|
const stopwatch = data?.stopwatch || {};
|
|
const laps = (Array.isArray(stopwatch.laps) ? stopwatch.laps : [])
|
|
.map((lap) => ({
|
|
id: lap?.id || uid("timer"),
|
|
label: String(lap?.label || "").trim(),
|
|
elapsedMs: Math.max(0, Number(lap?.elapsedMs) || 0)
|
|
}))
|
|
.filter((lap) => lap.elapsedMs > 0);
|
|
const countdowns = (Array.isArray(data?.countdowns) ? data.countdowns : [])
|
|
.map((countdown) => {
|
|
const type = COUNTDOWN_TYPES.has(countdown?.type) ? countdown.type : "duration";
|
|
const normalized = {
|
|
id: countdown?.id || uid("timer"),
|
|
label: String(countdown?.label || "Timer").trim() || "Timer",
|
|
type,
|
|
alertMode: TIMER_ALERT_MODES.has(countdown?.alertMode) ? countdown.alertMode : "off"
|
|
};
|
|
if (type === "duration") {
|
|
const durationMs = Math.max(0, Number(countdown?.durationMs) || 0);
|
|
const targetAt = Math.max(0, Number(countdown?.targetAt) || 0);
|
|
return durationMs > 0 && targetAt > 0 ? { ...normalized, durationMs, targetAt } : null;
|
|
}
|
|
if (type === "daily_time") {
|
|
const time = String(countdown?.time || "").trim();
|
|
const targetAt = Math.max(0, Number(countdown?.targetAt) || 0);
|
|
return isTimeString(time) && targetAt > 0 ? { ...normalized, time, targetAt } : null;
|
|
}
|
|
if (type === "time_pattern") {
|
|
const pattern = String(countdown?.pattern || "").trim().toUpperCase();
|
|
return isTimeString(pattern, true) ? { ...normalized, pattern } : null;
|
|
}
|
|
if (type === "interval") {
|
|
const intervalMs = Math.max(0, Number(countdown?.intervalMs) || 0);
|
|
const anchorAt = Math.max(0, Number(countdown?.anchorAt) || 0);
|
|
return intervalMs > 0 ? { ...normalized, intervalMs, anchorAt } : null;
|
|
}
|
|
return null;
|
|
})
|
|
.filter(Boolean);
|
|
|
|
return {
|
|
activeTab: TIMER_TABS.has(data?.activeTab) ? data.activeTab : "stopwatch",
|
|
scrollResults: data?.scrollResults === true,
|
|
sortResults: data?.sortResults === true,
|
|
stopwatch: {
|
|
elapsedMs: Math.max(0, Number(stopwatch.elapsedMs) || 0),
|
|
startedAt: Math.max(0, Number(stopwatch.startedAt) || 0),
|
|
laps
|
|
},
|
|
countdowns
|
|
};
|
|
}
|
|
|
|
function normalizeTaskPlannerTask(task) {
|
|
const title = String(task?.title || "").trim() || "Tâche";
|
|
const type = TASK_TYPES.has(task?.type) ? task.type : "unique";
|
|
const category = String(task?.category || "").trim();
|
|
const normalized = {
|
|
id: task?.id || uid("task"),
|
|
title,
|
|
description: String(task?.description || "").trim(),
|
|
type,
|
|
checked: task?.checked === true,
|
|
checkedAt: normalizeTimestamp(task?.checkedAt)
|
|
};
|
|
if (category) normalized.category = category;
|
|
if (type === "weekly" && task?.weeklyResetDay !== undefined) normalized.weeklyResetDay = normalizeWeekDay(task.weeklyResetDay);
|
|
if (!normalized.checked) normalized.checkedAt = 0;
|
|
return normalized;
|
|
}
|
|
|
|
function normalizeTaskPlannerCategoryOrder(value, tasks) {
|
|
const categories = new Set(tasks.map((task) => task.category).filter(Boolean));
|
|
const ordered = [];
|
|
(Array.isArray(value) ? value : []).forEach((category) => {
|
|
const normalized = String(category || "").trim();
|
|
if (normalized && categories.has(normalized) && !ordered.includes(normalized)) ordered.push(normalized);
|
|
});
|
|
tasks.forEach((task) => {
|
|
if (task.category && !ordered.includes(task.category)) ordered.push(task.category);
|
|
});
|
|
return ordered;
|
|
}
|
|
|
|
function normalizeTaskPlannerRelations(data, taskIds) {
|
|
const seen = new Set();
|
|
const seenChildren = new Set();
|
|
return (Array.isArray(data?.relations) ? data.relations : [])
|
|
.map((relation) => {
|
|
const fromTaskId = String(relation?.fromTaskId || "");
|
|
const toTaskId = String(relation?.toTaskId || "");
|
|
if (!taskIds.has(fromTaskId) || !taskIds.has(toTaskId) || fromTaskId === toTaskId) return null;
|
|
const key = `${fromTaskId}:${toTaskId}`;
|
|
if (seen.has(key) || seenChildren.has(fromTaskId)) return null;
|
|
seen.add(key);
|
|
seenChildren.add(fromTaskId);
|
|
return {
|
|
id: relation?.id || uid("relation"),
|
|
fromTaskId,
|
|
toTaskId,
|
|
prerequisite: relation?.prerequisite === true
|
|
};
|
|
})
|
|
.filter(Boolean);
|
|
}
|
|
|
|
export function normalizeTaskPlannerData(data) {
|
|
const rawTasks = (Array.isArray(data?.tasks) ? data.tasks : []).map(normalizeTaskPlannerTask).filter(Boolean);
|
|
const rawTaskIds = new Set(rawTasks.map((task) => task.id));
|
|
const relations = normalizeTaskPlannerRelations(data, rawTaskIds);
|
|
const childTaskIds = new Set(relations.map((relation) => relation.fromTaskId));
|
|
const tasks = rawTasks.map((task) => {
|
|
if (!childTaskIds.has(task.id) || !task.category) return task;
|
|
const normalized = { ...task };
|
|
delete normalized.category;
|
|
return normalized;
|
|
});
|
|
const taskIds = new Set(tasks.map((task) => task.id));
|
|
const categoryOrder = normalizeTaskPlannerCategoryOrder(data?.categoryOrder, tasks);
|
|
return {
|
|
weeklyResetDay: normalizeWeekDay(data?.weeklyResetDay),
|
|
resetTime: isClockTimeString(data?.resetTime) ? data.resetTime : DEFAULT_TASK_PLANNER_RESET_TIME,
|
|
lastResetAt: normalizeTimestamp(data?.lastResetAt),
|
|
hideCompleted: data?.hideCompleted === true,
|
|
collapsedCategories: (Array.isArray(data?.collapsedCategories) ? data.collapsedCategories : [])
|
|
.map((category) => String(category || "").trim())
|
|
.filter((category, index, categories) => category && categoryOrder.includes(category) && categories.indexOf(category) === index),
|
|
categoryOrder,
|
|
tasks,
|
|
relations: relations.filter((relation) => taskIds.has(relation.fromTaskId) && taskIds.has(relation.toTaskId))
|
|
};
|
|
}
|
|
|
|
function clampPercent(value) {
|
|
const parsed = Number(value);
|
|
if (!Number.isFinite(parsed)) return 0;
|
|
return Math.min(100, Math.max(0, parsed));
|
|
}
|
|
|
|
export function normalizeImageAnnotationData(data) {
|
|
const image = String(data?.image || "");
|
|
const markers = (data?.markers || [])
|
|
.map((marker) => ({
|
|
id: marker?.id || uid("marker"),
|
|
x: clampPercent(marker?.x),
|
|
y: clampPercent(marker?.y),
|
|
label: String(marker?.label || "").trim()
|
|
}));
|
|
return { image, markers, drawings: normalizeDrawingData(data?.drawings, { max: 100 }) };
|
|
}
|
|
|
|
function compactChecklistItemForStorage(item) {
|
|
const normalized = normalizeChecklistItem(item);
|
|
const compact = { id: normalized.id, label: normalized.label };
|
|
if (normalized.qtyTarget !== 1) compact.qtyTarget = normalized.qtyTarget;
|
|
if (normalized.qtyCurrent !== 0) compact.qtyCurrent = normalized.qtyCurrent;
|
|
return compact;
|
|
}
|
|
|
|
function compactChecklistSectionForStorage(section) {
|
|
const normalized = normalizeChecklistSection(section);
|
|
const compact = {
|
|
id: normalized.id,
|
|
items: normalized.items.map(compactChecklistItemForStorage)
|
|
};
|
|
if (normalized.title) compact.title = normalized.title;
|
|
if (typeof normalized.hideWhenComplete === "boolean") compact.hideWhenComplete = normalized.hideWhenComplete;
|
|
if (normalized.collapsed === true) compact.collapsed = true;
|
|
return compact;
|
|
}
|
|
|
|
export function compactModuleDataForStorage(type, value) {
|
|
if (type === "notepad") {
|
|
const normalized = normalizeNotepadData(value);
|
|
const compact = {};
|
|
if (normalized.html && normalized.text) compact.html = normalized.html;
|
|
if (normalized.text) compact.text = normalized.text;
|
|
if (normalized.updatedAt) compact.updatedAt = normalized.updatedAt;
|
|
if (normalized.drawingMode === "permanent") compact.drawingMode = "permanent";
|
|
if (normalized.drawingMode === "permanent" && normalized.drawings.strokes.length) compact.drawings = normalized.drawings;
|
|
return Object.keys(compact).length ? compact : null;
|
|
}
|
|
if (type === "checklist") {
|
|
const normalized = normalizeChecklistData(value);
|
|
const sections = normalized.sections.map(compactChecklistSectionForStorage).filter((section) => section.items.length || section.title);
|
|
if (!sections.length) return null;
|
|
const settings = {
|
|
...(normalized.hideCompletedSections ? { hideCompletedSections: true } : {}),
|
|
...(normalized.hideCompletedSectionsFully ? { hideCompletedSectionsFully: true } : {})
|
|
};
|
|
if (sections.length === 1 && !sections[0].title && typeof sections[0].hideWhenComplete !== "boolean") return { ...settings, items: sections[0].items };
|
|
return { ...settings, sections };
|
|
}
|
|
if (type === "images") {
|
|
const images = (Array.isArray(value?.images) ? value.images : [])
|
|
.filter((image) => image?.dataUrl)
|
|
.map((image) => {
|
|
const compact = { id: image.id || uid("image"), dataUrl: image.dataUrl };
|
|
if (image.label) compact.label = image.label;
|
|
return compact;
|
|
});
|
|
return images.length ? { images } : null;
|
|
}
|
|
if (type === "imageAnnotation") {
|
|
const normalized = normalizeImageAnnotationData(value);
|
|
if (!normalized.image) return null;
|
|
const compact = {
|
|
image: normalized.image,
|
|
markers: normalized.markers.map((marker) => {
|
|
const compact = { id: marker.id, x: marker.x, y: marker.y };
|
|
if (marker.label) compact.label = marker.label;
|
|
return compact;
|
|
})
|
|
};
|
|
if (normalized.drawings.strokes.length) compact.drawings = normalized.drawings;
|
|
return compact;
|
|
}
|
|
if (type === "links") {
|
|
const links = normalizeLinksData(value).links.map((link) => {
|
|
const compact = {
|
|
id: link.id,
|
|
url: link.url
|
|
};
|
|
if (link.title) compact.title = link.title;
|
|
return compact;
|
|
});
|
|
return links.length ? { links } : null;
|
|
}
|
|
if (type === "counters") {
|
|
const counters = normalizeCountersData(value).counters.map((counter) => ({
|
|
id: counter.id,
|
|
label: counter.label,
|
|
value: counter.value
|
|
}));
|
|
return counters.length ? { counters } : null;
|
|
}
|
|
if (type === "combos") {
|
|
const normalized = normalizeCombosData(value);
|
|
const compact = {
|
|
combos: normalized.combos.map((combo) => {
|
|
const compactCombo = {
|
|
id: combo.id,
|
|
name: combo.name,
|
|
inputs: combo.inputs
|
|
};
|
|
if (combo.device) compactCombo.device = combo.device;
|
|
return compactCombo;
|
|
}),
|
|
categories: normalized.categories.map((category) => {
|
|
const compactCategory = {
|
|
id: category.id,
|
|
title: category.title,
|
|
combos: category.combos.map((combo) => {
|
|
const compactCombo = {
|
|
id: combo.id,
|
|
name: combo.name,
|
|
inputs: combo.inputs
|
|
};
|
|
if (combo.device) compactCombo.device = combo.device;
|
|
return compactCombo;
|
|
})
|
|
};
|
|
if (category.collapsed) compactCategory.collapsed = true;
|
|
return compactCategory;
|
|
})
|
|
};
|
|
if (normalized.device !== "playstation") compact.device = normalized.device;
|
|
if (!compact.combos.length) delete compact.combos;
|
|
if (!compact.categories.length) delete compact.categories;
|
|
return compact.combos || compact.categories ? compact : null;
|
|
}
|
|
if (type === "calculator") {
|
|
const normalized = normalizeCalculatorData(value);
|
|
const entries = normalized.entries.map((entry) => {
|
|
const compact = {
|
|
id: entry.id,
|
|
label: entry.label,
|
|
value: entry.value
|
|
};
|
|
if (entry.parentId) compact.parentId = entry.parentId;
|
|
return compact;
|
|
});
|
|
if (!entries.length && !normalized.scrollResults) return null;
|
|
return normalized.scrollResults ? { entries, scrollResults: true } : { entries };
|
|
}
|
|
if (type === "table") {
|
|
const normalized = normalizeTableData(value);
|
|
const compact = { cells: normalized.cells };
|
|
if (normalized.rows !== 10) compact.rows = normalized.rows;
|
|
if (normalized.columns !== 6) compact.columns = normalized.columns;
|
|
if (Object.keys(normalized.rowLabels).length) compact.rowLabels = normalized.rowLabels;
|
|
if (Object.keys(normalized.columnLabels).length) compact.columnLabels = normalized.columnLabels;
|
|
return Object.keys(compact.cells).length || compact.rows || compact.columns || compact.rowLabels || compact.columnLabels ? compact : null;
|
|
}
|
|
if (type === "timer") {
|
|
const normalized = normalizeTimerData(value);
|
|
const compact = {};
|
|
if (normalized.activeTab !== "stopwatch") compact.activeTab = normalized.activeTab;
|
|
if (normalized.scrollResults) compact.scrollResults = true;
|
|
if (normalized.sortResults) compact.sortResults = true;
|
|
if (normalized.stopwatch.elapsedMs || normalized.stopwatch.startedAt || normalized.stopwatch.laps.length) {
|
|
compact.stopwatch = {
|
|
...(normalized.stopwatch.elapsedMs ? { elapsedMs: normalized.stopwatch.elapsedMs } : {}),
|
|
...(normalized.stopwatch.startedAt ? { startedAt: normalized.stopwatch.startedAt } : {}),
|
|
...(normalized.stopwatch.laps.length ? { laps: normalized.stopwatch.laps } : {})
|
|
};
|
|
}
|
|
if (normalized.countdowns.length) {
|
|
compact.countdowns = normalized.countdowns.map((countdown) => {
|
|
if (countdown.alertMode === "off") {
|
|
const { alertMode, ...compactCountdown } = countdown;
|
|
return compactCountdown;
|
|
}
|
|
return countdown;
|
|
});
|
|
}
|
|
return Object.keys(compact).length ? compact : null;
|
|
}
|
|
if (type === "taskPlanner") {
|
|
const normalized = normalizeTaskPlannerData(value);
|
|
const compact = {};
|
|
if (normalized.weeklyResetDay !== DEFAULT_TASK_PLANNER_WEEKLY_RESET_DAY) compact.weeklyResetDay = normalized.weeklyResetDay;
|
|
if (normalized.resetTime !== DEFAULT_TASK_PLANNER_RESET_TIME) compact.resetTime = normalized.resetTime;
|
|
if (normalized.lastResetAt) compact.lastResetAt = normalized.lastResetAt;
|
|
if (normalized.hideCompleted) compact.hideCompleted = true;
|
|
if (normalized.collapsedCategories.length) compact.collapsedCategories = normalized.collapsedCategories;
|
|
if (normalized.tasks.length) {
|
|
compact.tasks = normalized.tasks.map((task) => {
|
|
const compactTask = {
|
|
id: task.id,
|
|
title: task.title,
|
|
type: task.type
|
|
};
|
|
if (task.description) compactTask.description = task.description;
|
|
if (task.category) compactTask.category = task.category;
|
|
if (task.checked) compactTask.checked = true;
|
|
if (task.checked && task.checkedAt) compactTask.checkedAt = task.checkedAt;
|
|
if (task.type === "weekly" && task.weeklyResetDay !== undefined) compactTask.weeklyResetDay = task.weeklyResetDay;
|
|
return compactTask;
|
|
});
|
|
}
|
|
if (normalized.categoryOrder.length) compact.categoryOrder = normalized.categoryOrder;
|
|
if (normalized.relations.length) {
|
|
compact.relations = normalized.relations.map((relation) => {
|
|
const compactRelation = {
|
|
id: relation.id,
|
|
fromTaskId: relation.fromTaskId,
|
|
toTaskId: relation.toTaskId
|
|
};
|
|
if (relation.prerequisite) compactRelation.prerequisite = true;
|
|
return compactRelation;
|
|
});
|
|
}
|
|
return Object.keys(compact).length ? compact : null;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
export function prepareModuleDataForStorage(toolboxes, toolboxId, moduleId, value, moduleType = "") {
|
|
const module = toolboxes.find((toolbox) => toolbox.id === toolboxId)?.modules.find((item) => item.id === moduleId);
|
|
return compactModuleDataForStorage(moduleType || module?.type, value);
|
|
}
|
|
|
|
function createExportIdFactory() {
|
|
const counts = {};
|
|
return (prefix) => {
|
|
counts[prefix] = (counts[prefix] || 0) + 1;
|
|
return `${ID_PREFIXES[prefix] || "x"}${counts[prefix].toString(36)}`;
|
|
};
|
|
}
|
|
|
|
function remapModuleDataForExport(type, data, nextId) {
|
|
const compact = compactModuleDataForStorage(type, data);
|
|
if (!compact) return null;
|
|
if (type === "checklist") {
|
|
if (compact.sections) {
|
|
return {
|
|
sections: compact.sections.map((section) => ({
|
|
...section,
|
|
id: nextId("section"),
|
|
items: section.items.map((item) => ({ ...item, id: nextId("item") }))
|
|
}))
|
|
};
|
|
}
|
|
return { items: compact.items.map((item) => ({ ...item, id: nextId("item") })) };
|
|
}
|
|
if (type === "images") {
|
|
return { images: compact.images.map((image) => ({ ...image, id: nextId("image") })) };
|
|
}
|
|
|
|
if (type === "imageAnnotation") {
|
|
const remapped = {
|
|
...compact,
|
|
markers: compact.markers.map((marker) => ({ ...marker, id: nextId("marker") }))
|
|
};
|
|
if (compact.drawings?.strokes) {
|
|
remapped.drawings = {
|
|
strokes: compact.drawings.strokes.map((stroke) => ({ ...stroke, id: nextId("stroke") }))
|
|
};
|
|
}
|
|
return remapped;
|
|
}
|
|
|
|
if (type === "links") {
|
|
return { links: compact.links.map((link) => ({ ...link, id: nextId("link") })) };
|
|
}
|
|
|
|
if (type === "counters") {
|
|
return { counters: compact.counters.map((counter) => ({ ...counter, id: nextId("counter") })) };
|
|
}
|
|
|
|
if (type === "combos") {
|
|
const remapped = {};
|
|
if (compact.device) remapped.device = compact.device;
|
|
if (compact.combos) remapped.combos = compact.combos.map((combo) => ({ ...combo, id: nextId("combo") }));
|
|
if (compact.categories) {
|
|
remapped.categories = compact.categories.map((category) => ({
|
|
...category,
|
|
id: nextId("section"),
|
|
combos: category.combos.map((combo) => ({ ...combo, id: nextId("combo") }))
|
|
}));
|
|
}
|
|
return remapped;
|
|
}
|
|
|
|
if (type === "calculator") {
|
|
const entryIdMap = new Map();
|
|
compact.entries.forEach((entry) => entryIdMap.set(entry.id, nextId("calc")));
|
|
const remapped = {
|
|
entries: compact.entries.map((entry) => {
|
|
const remapped = { ...entry, id: entryIdMap.get(entry.id) };
|
|
if (entry.parentId) remapped.parentId = entryIdMap.get(entry.parentId) || "";
|
|
return remapped;
|
|
})
|
|
};
|
|
if (compact.scrollResults) remapped.scrollResults = true;
|
|
return remapped;
|
|
}
|
|
|
|
if (type === "timer") {
|
|
const remapped = { ...compact };
|
|
if (compact.stopwatch?.laps) {
|
|
remapped.stopwatch = {
|
|
...compact.stopwatch,
|
|
laps: compact.stopwatch.laps.map((lap) => ({ ...lap, id: nextId("timer") }))
|
|
};
|
|
}
|
|
if (compact.countdowns) {
|
|
remapped.countdowns = compact.countdowns.map((countdown) => ({ ...countdown, id: nextId("timer") }));
|
|
}
|
|
return remapped;
|
|
}
|
|
|
|
if (type === "taskPlanner") {
|
|
const taskIdMap = new Map();
|
|
compact.tasks?.forEach((task) => taskIdMap.set(task.id, nextId("task")));
|
|
const remapped = { ...compact };
|
|
if (compact.tasks) remapped.tasks = compact.tasks.map((task) => ({ ...task, id: taskIdMap.get(task.id) }));
|
|
if (compact.relations) {
|
|
remapped.relations = compact.relations
|
|
.map((relation) => ({
|
|
...relation,
|
|
id: nextId("relation"),
|
|
fromTaskId: taskIdMap.get(relation.fromTaskId),
|
|
toTaskId: taskIdMap.get(relation.toTaskId)
|
|
}))
|
|
.filter((relation) => relation.fromTaskId && relation.toTaskId);
|
|
}
|
|
return remapped;
|
|
}
|
|
|
|
return compact;
|
|
}
|
|
|
|
export function createToolboxExportPayload(toolbox, moduleData) {
|
|
const source = normalizeToolbox(toolbox);
|
|
const nextId = createExportIdFactory();
|
|
const moduleIdMap = new Map();
|
|
const exportedToolbox = compactToolboxForStorage({
|
|
...source,
|
|
id: nextId("tbx"),
|
|
modules: source.modules.map((module) => {
|
|
const id = nextId("mod");
|
|
moduleIdMap.set(module.id, id);
|
|
return { ...module, id };
|
|
})
|
|
});
|
|
const modules = Object.fromEntries(source.modules
|
|
.map((module) => [
|
|
moduleIdMap.get(module.id),
|
|
remapModuleDataForExport(module.type, moduleData[moduleStorageKey(source.id, module.id)] || null, nextId)
|
|
])
|
|
.filter(([, data]) => data != null));
|
|
return { toolbox: exportedToolbox, modules };
|
|
}
|
|
|
|
export function createGlobalExportPayload(toolboxes, links, moduleData) {
|
|
const modules = {};
|
|
toolboxes.forEach((toolbox) => {
|
|
toolbox.modules.forEach((module) => {
|
|
const data = compactModuleDataForStorage(module.type, moduleData[moduleStorageKey(toolbox.id, module.id)] || null);
|
|
if (data) modules[globalModuleKey(toolbox.id, module.id)] = data;
|
|
});
|
|
});
|
|
return {
|
|
version: 1,
|
|
exportedAt: new Date().toISOString(),
|
|
toolboxes: compactToolboxesForStorage(toolboxes),
|
|
modules,
|
|
links
|
|
};
|
|
}
|