structure refacto & file documentation
All checks were successful
Deploy Sokko G / deploy (push) Successful in 7s

This commit is contained in:
Shinuwa 2026-07-25 17:51:53 +02:00
parent d7871736c8
commit 895fec2b40
84 changed files with 2629 additions and 2313 deletions

View file

@ -0,0 +1,446 @@
// 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", calc: "r", marker: "k" };
const ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
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",
calculator: "Calculateur",
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;
}
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 legacyItems = (data?.items || []).map(normalizeChecklistItem).filter((item) => item.label);
if (!sections.length && legacyItems.length) return { hideCompletedSections, hideCompletedSectionsFully, sections: [{ id: uid("section"), title: "", items: legacyItems }], items: legacyItems };
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 normalizeCalculatorValue(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? 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: "" })
};
}
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 || data?.dataUrl || "");
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 };
}
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 text = String(value?.text || "");
return text ? { text } : 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;
return {
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 (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 === "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 };
}
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") {
return {
...compact,
markers: compact.markers.map((marker) => ({ ...marker, id: nextId("marker") }))
};
}
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 === "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;
}
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
};
}