structure refacto & file documentation
All checks were successful
Deploy Sokko G / deploy (push) Successful in 7s
All checks were successful
Deploy Sokko G / deploy (push) Successful in 7s
This commit is contained in:
parent
d7871736c8
commit
895fec2b40
84 changed files with 2629 additions and 2313 deletions
446
website/src/features/toolboxes/storage/toolboxStorage.js
Normal file
446
website/src/features/toolboxes/storage/toolboxStorage.js
Normal 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
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue