From 588c1298cd0ecb2369fcf708a856ff820e1d163a Mon Sep 17 00:00:00 2001 From: Shinuwa Date: Fri, 14 Aug 2026 09:45:48 +0200 Subject: [PATCH] Split toolbox storage by module --- docs/FEATURE_CHECKLIST.md | 2 +- tests/static-toolboxes.test.mjs | 40 +- .../toolboxes/storage/moduleDataCompactors.js | 173 --- .../toolboxes/storage/modules/calculator.js | 33 + .../toolboxes/storage/modules/checklist.js | 74 ++ .../toolboxes/storage/modules/combos.js | 86 ++ .../toolboxes/storage/modules/counters.js | 28 + .../toolboxes/storage/modules/drawing.js | 34 + .../storage/modules/equipmentPlanner.js | 345 ++++++ .../storage/modules/imageAnnotation.js | 52 + .../toolboxes/storage/modules/index.js | 27 + .../toolboxes/storage/modules/links.js | 48 + .../toolboxes/storage/modules/notepad.js | 119 ++ .../toolboxes/storage/modules/shared.js | 56 + .../toolboxes/storage/modules/table.js | 63 ++ .../toolboxes/storage/modules/taskPlanner.js | 129 +++ .../toolboxes/storage/modules/timer.js | 114 ++ .../toolboxes/storage/toolboxStorage.js | 1005 +---------------- 18 files changed, 1248 insertions(+), 1180 deletions(-) delete mode 100644 website/src/features/toolboxes/storage/moduleDataCompactors.js create mode 100644 website/src/features/toolboxes/storage/modules/calculator.js create mode 100644 website/src/features/toolboxes/storage/modules/checklist.js create mode 100644 website/src/features/toolboxes/storage/modules/combos.js create mode 100644 website/src/features/toolboxes/storage/modules/counters.js create mode 100644 website/src/features/toolboxes/storage/modules/drawing.js create mode 100644 website/src/features/toolboxes/storage/modules/equipmentPlanner.js create mode 100644 website/src/features/toolboxes/storage/modules/imageAnnotation.js create mode 100644 website/src/features/toolboxes/storage/modules/index.js create mode 100644 website/src/features/toolboxes/storage/modules/links.js create mode 100644 website/src/features/toolboxes/storage/modules/notepad.js create mode 100644 website/src/features/toolboxes/storage/modules/shared.js create mode 100644 website/src/features/toolboxes/storage/modules/table.js create mode 100644 website/src/features/toolboxes/storage/modules/taskPlanner.js create mode 100644 website/src/features/toolboxes/storage/modules/timer.js diff --git a/docs/FEATURE_CHECKLIST.md b/docs/FEATURE_CHECKLIST.md index c06eb46..4ebfd03 100644 --- a/docs/FEATURE_CHECKLIST.md +++ b/docs/FEATURE_CHECKLIST.md @@ -8,7 +8,7 @@ Checklist à suivre lors de l'ajout ou de la modification d'un outil, d'une page - Déclarer l'outil dans `website/src/features/toolboxes/modules/index.jsx`. - Ajouter les textes nécessaires dans `website/public/data/site.json`. - Mettre à jour la validation de `site.json` dans `tests/helpers/data-validation.mjs`. -- Mettre à jour la normalisation et le stockage compact dans `website/src/features/toolboxes/storage/toolboxStorage.js`. +- Mettre à jour la normalisation et le stockage compact dans `website/src/features/toolboxes/storage/modules/.js`, puis vérifier l'export public dans `website/src/features/toolboxes/storage/toolboxStorage.js`. - Mettre à jour `docs/STORAGE_SCHEMA.md`. - Vérifier l'import et l'export si l'outil stocke des données. - Si l'outil a un contenu représentable en texte, ajouter ou vérifier l'import/export texte round-trip dans le panneau d'ajout. diff --git a/tests/static-toolboxes.test.mjs b/tests/static-toolboxes.test.mjs index 500c63a..f8d0099 100644 --- a/tests/static-toolboxes.test.mjs +++ b/tests/static-toolboxes.test.mjs @@ -11,6 +11,10 @@ test("toolbox storage, cards and pages are wired", async () => { const toolboxPages = await readFile("website/src/features/toolboxes/ToolboxPages.jsx", "utf8"); const toolboxActions = await readFile("website/src/features/toolboxes/useToolboxActions.js", "utf8"); const toolboxStorage = await readFile("website/src/features/toolboxes/storage/toolboxStorage.js", "utf8"); + const storageCompactorRegistry = await readFile("website/src/features/toolboxes/storage/modules/index.js", "utf8"); + const checklistStorage = await readFile("website/src/features/toolboxes/storage/modules/checklist.js", "utf8"); + const timerStorage = await readFile("website/src/features/toolboxes/storage/modules/timer.js", "utf8"); + const equipmentStorage = await readFile("website/src/features/toolboxes/storage/modules/equipmentPlanner.js", "utf8"); const indexedToolboxesHook = await readFile("website/src/features/toolboxes/storage/useIndexedToolboxes.js", "utf8"); const storageQuota = await readFile("website/src/components/StorageQuota.jsx", "utf8"); const importButton = await readFile("website/src/components/ImportButton.jsx", "utf8"); @@ -31,25 +35,27 @@ test("toolbox storage, cards and pages are wired", async () => { assert.match(toolboxStorage, /TOOLBOX_ICON_FILES/); assert.match(toolboxStorage, /export function normalizeToolboxIcon/); assert.match(toolboxStorage, /export function createGlobalExportPayload/); - assert.match(toolboxStorage, /qtyTarget/); - assert.match(toolboxStorage, /qtyCurrent/); - assert.match(toolboxStorage, /hideCompletedSections/); - assert.match(toolboxStorage, /hideCompletedSectionsFully/); - assert.match(toolboxStorage, /hideWhenComplete/); - assert.match(toolboxStorage, /collapsed/); + assert.match(checklistStorage, /qtyTarget/); + assert.match(checklistStorage, /qtyCurrent/); + assert.match(checklistStorage, /hideCompletedSections/); + assert.match(checklistStorage, /hideCompletedSectionsFully/); + assert.match(checklistStorage, /hideWhenComplete/); + assert.match(equipmentStorage, /collapsed/); assert.match(toolboxStorage, /module\.scrollable/); - assert.match(toolboxStorage, /export function normalizeLinksData/); - assert.match(toolboxStorage, /export function normalizeCountersData/); - assert.match(toolboxStorage, /export function normalizeCombosData/); - assert.match(toolboxStorage, /export function normalizeCalculatorData/); - assert.match(toolboxStorage, /export function normalizeTableData/); - assert.match(toolboxStorage, /export function normalizeTimerData/); - assert.match(toolboxStorage, /export function normalizeTaskPlannerData/); - assert.match(toolboxStorage, /export function normalizeEquipmentPlannerData/); - assert.match(toolboxStorage, /export function summarizeEquipmentPlannerData/); - assert.match(toolboxStorage, /export function normalizeImageAnnotationData/); + assert.match(toolboxStorage, /normalizeLinksData/); + assert.match(toolboxStorage, /normalizeCountersData/); + assert.match(toolboxStorage, /normalizeCombosData/); + assert.match(toolboxStorage, /normalizeCalculatorData/); + assert.match(toolboxStorage, /normalizeTableData/); + assert.match(toolboxStorage, /normalizeTimerData/); + assert.match(toolboxStorage, /normalizeTaskPlannerData/); + assert.match(toolboxStorage, /normalizeEquipmentPlannerData/); + assert.match(toolboxStorage, /summarizeEquipmentPlannerData/); + assert.match(toolboxStorage, /normalizeImageAnnotationData/); assert.match(toolboxStorage, /export function compactModuleDataForStorage\(type, value\)/); - assert.match(toolboxStorage, /scrollResults/); + assert.match(storageCompactorRegistry, /export const MODULE_DATA_COMPACTORS/); + assert.match(storageCompactorRegistry, /timer: compactTimerDataForStorage/); + assert.match(timerStorage, /scrollResults/); assert.match(toolboxCard, /DEFAULT_TOOLBOX_ICON/); assert.match(toolboxCard, /export function ToolboxCard/); assert.match(toolboxCard, /export function ToolboxIconPicker/); diff --git a/website/src/features/toolboxes/storage/moduleDataCompactors.js b/website/src/features/toolboxes/storage/moduleDataCompactors.js deleted file mode 100644 index 0310dcf..0000000 --- a/website/src/features/toolboxes/storage/moduleDataCompactors.js +++ /dev/null @@ -1,173 +0,0 @@ -// Rôle : fournit les compacteurs de données par type d'outil toolbox. -export function createModuleDataCompactors(deps) { - return { - notepad(value) { - const normalized = deps.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; - }, - checklist(value) { - const normalized = deps.normalizeChecklistData(value); - const sections = normalized.sections.map(deps.compactChecklistSectionForStorage).filter((section) => section.items.length); - 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 }; - }, - images(value) { - const images = (Array.isArray(value?.images) ? value.images : []) - .filter((image) => image?.dataUrl) - .map((image) => { - const compact = { id: image.id || deps.uid("image"), dataUrl: image.dataUrl }; - if (image.label) compact.label = image.label; - return compact; - }); - return images.length ? { images } : null; - }, - imageAnnotation(value) { - const normalized = deps.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; - }, - links(value) { - const links = deps.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; - }, - counters(value) { - const counters = deps.normalizeCountersData(value).counters.map((counter) => ({ - id: counter.id, - label: counter.label, - value: counter.value - })); - return counters.length ? { counters } : null; - }, - combos(value) { - const normalized = deps.normalizeCombosData(value); - const compact = { - combos: normalized.combos.map((combo) => { - const compactCombo = { id: combo.id, name: combo.name, inputs: combo.inputs }; - if (combo.category) compactCombo.category = combo.category; - if (combo.device) compactCombo.device = combo.device; - return compactCombo; - }) - }; - if (normalized.device !== "playstation") compact.device = normalized.device; - if (normalized.collapsedCategories.length) compact.collapsedCategories = normalized.collapsedCategories; - if (normalized.categoryOrder.length) compact.categoryOrder = normalized.categoryOrder; - if (!compact.combos.length) delete compact.combos; - return compact.combos ? compact : null; - }, - calculator(value) { - const normalized = deps.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 }; - }, - table(value) { - const normalized = deps.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; - }, - timer(value) { - const normalized = deps.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) => { - const { alertMode, autoRefresh, startMode, startTime, ...compactCountdown } = countdown; - if (alertMode !== "off") compactCountdown.alertMode = alertMode; - if (autoRefresh) compactCountdown.autoRefresh = true; - if (startMode === "time" && startTime) { - compactCountdown.startMode = startMode; - compactCountdown.startTime = startTime; - } - return compactCountdown; - }); - } - return Object.keys(compact).length ? compact : null; - }, - taskPlanner(value) { - const normalized = deps.normalizeTaskPlannerData(value); - const compact = {}; - if (normalized.weeklyResetDay !== deps.defaultTaskPlannerWeeklyResetDay) compact.weeklyResetDay = normalized.weeklyResetDay; - if (normalized.resetTime !== deps.defaultTaskPlannerResetTime) 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 === "daily" && task.dailyResetTime && task.dailyResetTime !== normalized.resetTime) compactTask.dailyResetTime = task.dailyResetTime; - 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; - }, - equipmentPlanner(value) { - const normalized = deps.normalizeEquipmentPlannerData(value); - if (!normalized.equipments.length) return null; - const compact = { - types: normalized.types.map((type) => { - const compactType = { id: type.id, title: type.title }; - if (type.icon !== "shield") compactType.icon = type.icon; - if (type.collapsed) compactType.collapsed = true; - return compactType; - }), - equipments: normalized.equipments.map(deps.compactEquipmentPlannerEquipmentForStorage) - }; - if (normalized.typeOrder.length) compact.typeOrder = normalized.typeOrder; - return compact; - } - }; -} diff --git a/website/src/features/toolboxes/storage/modules/calculator.js b/website/src/features/toolboxes/storage/modules/calculator.js new file mode 100644 index 0000000..eebfeac --- /dev/null +++ b/website/src/features/toolboxes/storage/modules/calculator.js @@ -0,0 +1,33 @@ +// Rôle : normalise et compacte les données du calculateur. +import { uid } from "./shared.js"; + +function normalizeCalculatorValue(value) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +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 compactCalculatorDataForStorage(value) { + 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 }; +} diff --git a/website/src/features/toolboxes/storage/modules/checklist.js b/website/src/features/toolboxes/storage/modules/checklist.js new file mode 100644 index 0000000..96122a8 --- /dev/null +++ b/website/src/features/toolboxes/storage/modules/checklist.js @@ -0,0 +1,74 @@ +// Rôle : normalise et compacte les données de checklist. +import { parsePositiveInt, uid } from "./shared.js"; + +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) + : []; + 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 }; +} + +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; +} + +export 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 compactChecklistDataForStorage(value) { + const normalized = normalizeChecklistData(value); + const sections = normalized.sections.map(compactChecklistSectionForStorage).filter((section) => section.items.length); + 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 }; +} diff --git a/website/src/features/toolboxes/storage/modules/combos.js b/website/src/features/toolboxes/storage/modules/combos.js new file mode 100644 index 0000000..90c861c --- /dev/null +++ b/website/src/features/toolboxes/storage/modules/combos.js @@ -0,0 +1,86 @@ +// Rôle : normalise et compacte les combos d'inputs. +import { uid } from "./shared.js"; + +const COMBO_DEVICES = new Set(["playstation", "xbox", "switch", "n64", "keyboardMouse"]); +const COMBO_INPUT_KINDS = new Set(["button", "direction", "key", "mouse"]); + +function normalizeComboInput(input) { + const kind = COMBO_INPUT_KINDS.has(input?.kind) ? input.kind : ""; + const value = String(input?.value || "").trim().slice(0, 24); + if (!kind || !value) return null; + const holdMs = Number(input?.holdMs); + const normalizedHoldMs = Number.isFinite(holdMs) && holdMs > 0 + ? Math.min(99000, Math.round(holdMs / 1000) * 1000) + : 0; + const normalized = { kind, value }; + if (input?.hold === true || normalizedHoldMs > 0) normalized.hold = true; + if (normalizedHoldMs > 0) normalized.holdMs = normalizedHoldMs; + return normalized; +} + +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 category = String(combo?.category || "").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 (category) normalized.category = category; + if (device) normalized.device = device; + return normalized; +} + +function normalizeComboCategoryOrder(value, combos) { + const categories = combos.map((combo) => combo.category).filter(Boolean); + const orderedCategories = (Array.isArray(value) ? value : []) + .map((category) => String(category || "").trim().slice(0, 80)) + .filter((category, index, order) => category && categories.includes(category) && order.indexOf(category) === index); + return [ + ...orderedCategories, + ...categories.filter((category) => !orderedCategories.includes(category)) + ]; +} + +export function normalizeCombosData(data) { + const combos = (Array.isArray(data?.combos) ? data.combos : []).map(normalizeCombo).filter(Boolean); + const categoryOrder = normalizeComboCategoryOrder(data?.categoryOrder, combos); + return { + device: COMBO_DEVICES.has(data?.device) ? data.device : "playstation", + combos, + collapsedCategories: (Array.isArray(data?.collapsedCategories) ? data.collapsedCategories : []) + .map((category) => String(category || "").trim().slice(0, 80)) + .filter((category, index, categories) => category && categoryOrder.includes(category) && categories.indexOf(category) === index), + categoryOrder + }; +} + +export function compactCombosDataForStorage(value) { + const normalized = normalizeCombosData(value); + const compact = { + combos: normalized.combos.map((combo) => { + const compactCombo = { id: combo.id, name: combo.name, inputs: combo.inputs }; + if (combo.category) compactCombo.category = combo.category; + if (combo.device) compactCombo.device = combo.device; + return compactCombo; + }) + }; + if (normalized.device !== "playstation") compact.device = normalized.device; + if (normalized.collapsedCategories.length) compact.collapsedCategories = normalized.collapsedCategories; + if (normalized.categoryOrder.length) compact.categoryOrder = normalized.categoryOrder; + if (!compact.combos.length) delete compact.combos; + return compact.combos ? compact : null; +} diff --git a/website/src/features/toolboxes/storage/modules/counters.js b/website/src/features/toolboxes/storage/modules/counters.js new file mode 100644 index 0000000..707f0db --- /dev/null +++ b/website/src/features/toolboxes/storage/modules/counters.js @@ -0,0 +1,28 @@ +// Rôle : normalise et compacte les compteurs toolbox. +import { uid } from "./shared.js"; + +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) + }; +} + +export function compactCountersDataForStorage(value) { + const counters = normalizeCountersData(value).counters.map((counter) => ({ + id: counter.id, + label: counter.label, + value: counter.value + })); + return counters.length ? { counters } : null; +} diff --git a/website/src/features/toolboxes/storage/modules/drawing.js b/website/src/features/toolboxes/storage/modules/drawing.js new file mode 100644 index 0000000..a757992 --- /dev/null +++ b/website/src/features/toolboxes/storage/modules/drawing.js @@ -0,0 +1,34 @@ +// Rôle : normalise les traits de dessin persistés par les outils annotables. +import { uid } from "./shared.js"; + +export const NOTEPAD_ALLOWED_COLORS = new Set(["#f5f7ff", "#b4bdd3", "#f6c453", "#22d3ee", "#8b5cf6", "#d946ef", "#101426", "#202745"]); + +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) + }; +} diff --git a/website/src/features/toolboxes/storage/modules/equipmentPlanner.js b/website/src/features/toolboxes/storage/modules/equipmentPlanner.js new file mode 100644 index 0000000..cad896a --- /dev/null +++ b/website/src/features/toolboxes/storage/modules/equipmentPlanner.js @@ -0,0 +1,345 @@ +// Rôle : normalise, résume et compacte les données du planificateur d'équipements. +import { parsePositiveInt, uid } from "./shared.js"; + +const EQUIPMENT_SOCKET_SHAPES = new Set(["ball", "jewel"]); +const EQUIPMENT_SOCKET_COLORS = new Set(["red", "orange", "amber", "yellow", "lime", "green", "teal", "cyan", "blue", "indigo", "violet", "purple", "pink", "rose", "white", "gray", "black"]); +const EQUIPMENT_TYPE_ICONS = new Set(["boot", "chest-armor", "helmet", "glove", "shield", "sword", "gun", "earring", "necklace", "ring"]); +const EQUIPMENT_TRAIT_ICONS = new Set(["sword", "shield"]); + +function normalizeEquipmentPlannerType(type) { + const title = String(type?.title || "").trim().slice(0, 80); + if (!title) return null; + const icon = EQUIPMENT_TYPE_ICONS.has(type?.icon) ? type.icon : "shield"; + return { + id: type?.id || uid("equipmentType"), + title, + icon, + collapsed: type?.collapsed === true + }; +} + +function normalizeEquipmentPlannerValue(value) { + if (typeof value === "number") return Number.isFinite(value) ? Math.round(value * 1000) / 1000 : ""; + const text = String(value ?? "").trim().slice(0, 80); + if (!text) return ""; + const numeric = Number(text.replace(",", ".")); + return Number.isFinite(numeric) && /^[-+]?\d+(?:[,.]\d+)?$/.test(text) ? Math.round(numeric * 1000) / 1000 : text; +} + +function normalizeEquipmentPlannerTrait(trait, prefix = "trait") { + const name = String(trait?.name || "").trim().slice(0, 80); + if (!name) return null; + return { + id: trait?.id || uid(prefix), + category: String(trait?.category || "").trim().slice(0, 80), + icon: EQUIPMENT_TRAIT_ICONS.has(trait?.icon) ? trait.icon : "sword", + name, + value: normalizeEquipmentPlannerValue(trait?.value) + }; +} + +function normalizeEquipmentPlannerMaterial(material) { + const name = String(material?.name || "").trim().slice(0, 80); + if (!name) return null; + return { + id: material?.id || uid("material"), + name, + qty: Math.max(1, parsePositiveInt(material?.qty, 1)) + }; +} + +function normalizeEquipmentPlannerSocketItem(socketItem) { + const name = String(socketItem?.name || "").trim().slice(0, 80); + if (!name) return null; + const shape = EQUIPMENT_SOCKET_SHAPES.has(socketItem?.shape) ? socketItem.shape : "jewel"; + const color = EQUIPMENT_SOCKET_COLORS.has(socketItem?.color) ? socketItem.color : "yellow"; + return { + id: socketItem?.id || uid("socketItem"), + name, + shape, + color, + bonuses: (Array.isArray(socketItem?.bonuses) ? socketItem.bonuses : []) + .map((bonus) => normalizeEquipmentPlannerTrait(bonus, "socketBonus")) + .filter(Boolean) + }; +} + +function normalizeEquipmentPlannerCategoryOrder(value, categories) { + const available = new Set(categories.filter(Boolean)); + const ordered = []; + (Array.isArray(value) ? value : []).forEach((category) => { + const normalized = String(category || "").trim().slice(0, 80); + if (normalized && available.has(normalized) && !ordered.includes(normalized)) ordered.push(normalized); + }); + categories.forEach((category) => { + if (category && !ordered.includes(category)) ordered.push(category); + }); + return ordered; +} + +function normalizeEquipmentPlannerEquipment(equipment, typeIds) { + const name = String(equipment?.name || "").trim().slice(0, 100); + const typeId = String(equipment?.typeId || ""); + if (!name || !typeIds.has(typeId)) return null; + const characteristics = (Array.isArray(equipment?.characteristics) ? equipment.characteristics : []) + .map((trait) => normalizeEquipmentPlannerTrait(trait, "trait")) + .filter(Boolean); + const socketItems = (Array.isArray(equipment?.socketItems) ? equipment.socketItems : []) + .map(normalizeEquipmentPlannerSocketItem) + .filter(Boolean); + const socketItemIds = new Set(socketItems.map((socketItem) => socketItem.id)); + const socketLinks = (Array.isArray(equipment?.socketLinks) ? equipment.socketLinks : []) + .map((link) => { + const fromSocketItemId = String(link?.fromSocketItemId || ""); + const toSocketItemId = String(link?.toSocketItemId || ""); + if (!socketItemIds.has(fromSocketItemId) || !socketItemIds.has(toSocketItemId) || fromSocketItemId === toSocketItemId) return null; + const pairKey = [fromSocketItemId, toSocketItemId].sort().join(":"); + return { + id: link?.id || uid("socketLink"), + fromSocketItemId, + toSocketItemId, + pairKey + }; + }) + .filter(Boolean) + .filter((link, index, links) => links.findIndex((item) => item.pairKey === link.pairKey) === index) + .map(({ pairKey: _pairKey, ...link }) => link); + const categories = [ + ...characteristics.map((trait) => trait.category), + ...socketItems.flatMap((socketItem) => socketItem.bonuses.map((bonus) => bonus.category)) + ].filter(Boolean); + const categoryOrder = normalizeEquipmentPlannerCategoryOrder(equipment?.categoryOrder, categories); + return { + id: equipment?.id || uid("equipment"), + typeId, + name, + icon: EQUIPMENT_TYPE_ICONS.has(equipment?.icon) ? equipment.icon : "shield", + active: equipment?.active !== false, + obtain: String(equipment?.obtain || "").trim().slice(0, 500), + characteristics, + socketItems, + socketLinks, + materials: (Array.isArray(equipment?.materials) ? equipment.materials : []) + .map(normalizeEquipmentPlannerMaterial) + .filter(Boolean), + categoryOrder, + collapsedCategories: (Array.isArray(equipment?.collapsedCategories) ? equipment.collapsedCategories : []) + .map((category) => String(category || "").trim().slice(0, 80)) + .filter((category, index, collapsedCategories) => category && categoryOrder.includes(category) && collapsedCategories.indexOf(category) === index) + }; +} + +export function normalizeEquipmentPlannerData(data) { + const rawTypes = (Array.isArray(data?.types) ? data.types : []).map(normalizeEquipmentPlannerType).filter(Boolean); + const typeIds = new Set(rawTypes.map((type) => type.id)); + const equipments = (Array.isArray(data?.equipments) ? data.equipments : []) + .map((equipment) => normalizeEquipmentPlannerEquipment(equipment, typeIds)) + .filter(Boolean); + const usedTypeIds = new Set(equipments.map((equipment) => equipment.typeId)); + const types = rawTypes.filter((type) => usedTypeIds.has(type.id)); + const orderedTypeIds = (Array.isArray(data?.typeOrder) ? data.typeOrder : []) + .map((typeId) => String(typeId || "")) + .filter((typeId, index, order) => usedTypeIds.has(typeId) && order.indexOf(typeId) === index); + return { + types, + equipments, + typeOrder: [ + ...orderedTypeIds, + ...types.map((type) => type.id).filter((typeId) => !orderedTypeIds.includes(typeId)) + ] + }; +} + +export function summarizeEquipmentPlannerData(data) { + const normalized = normalizeEquipmentPlannerData(data); + const totals = new Map(); + const textBonuses = []; + const activeEquipments = normalized.equipments.filter((equipment) => equipment.active); + + function incrementCount(counts, key) { + counts.set(key, (counts.get(key) || 0) + 1); + } + + function pickMostUsedKey(counts) { + return [...counts.entries()].reduce((picked, [key, count]) => { + if (!picked) return { key, count }; + if (count !== picked.count) return count > picked.count ? { key, count } : picked; + return key.localeCompare(picked.key) < 0 ? { key, count } : picked; + }, null); + } + + function parseIconKey(key) { + const [icon, color = ""] = String(key || "").split(":"); + return { icon: icon || "sword", color }; + } + + function pickSummaryIcon(iconSources) { + return parseIconKey(pickMostUsedKey(iconSources.direct)?.key || pickMostUsedKey(iconSources.socket)?.key || "sword:"); + } + + function getSummarySourceKind(sourceKinds) { + if (sourceKinds.size !== 1) return "mixed"; + return sourceKinds.has("direct") ? "direct" : "socket"; + } + + function collectEntry(entry, contribution, icon, sourceKind, color = "") { + if (typeof entry.value === "number") { + const existing = totals.get(entry.name); + const iconKey = icon || "sword"; + const colorKey = sourceKind === "socket" ? color || "gray" : ""; + const iconSources = existing?.iconSources || { direct: new Map(), socket: new Map() }; + const sourceKinds = existing?.sourceKinds || new Set(); + const contributions = existing?.contributions || []; + sourceKinds.add(sourceKind); + incrementCount(iconSources[sourceKind], `${iconKey}:${colorKey}`); + const pickedIcon = pickSummaryIcon(iconSources); + totals.set(entry.name, { + name: entry.name, + icon: pickedIcon.icon, + color: pickedIcon.color, + sourceKind: getSummarySourceKind(sourceKinds), + iconSources, + sourceKinds, + contributions: [ + ...contributions, + { + id: entry.id, + sourceKind, + equipmentIcon: contribution.equipmentIcon, + equipmentName: contribution.equipmentName, + sourceName: contribution.sourceName, + value: entry.value + } + ], + value: (existing?.value || 0) + entry.value + }); + return; + } + textBonuses.push({ + id: entry.id, + category: entry.category, + name: entry.name, + value: entry.value, + source: contribution.sourceName + }); + } + + activeEquipments.forEach((equipment) => { + equipment.characteristics.forEach((trait) => collectEntry( + trait, + { equipmentIcon: equipment.icon || "shield", equipmentName: equipment.name, sourceName: equipment.name }, + trait.icon || "sword", + "direct" + )); + equipment.socketItems.forEach((socketItem) => { + socketItem.bonuses.forEach((bonus) => collectEntry( + bonus, + { equipmentIcon: equipment.icon || "shield", equipmentName: equipment.name, sourceName: `${equipment.name} · ${socketItem.name}` }, + socketItem.shape || "jewel", + "socket", + socketItem.color + )); + }); + }); + + const numericTotals = [...totals.values()] + .map(({ iconSources: _iconSources, sourceKinds: _sourceKinds, color, sourceKind, ...total }) => ({ + ...total, + ...(color ? { color } : {}), + sourceKind + })) + .sort((a, b) => a.name.localeCompare(b.name)); + + return { + activeEquipmentCount: activeEquipments.length, + totals: numericTotals, + equipmentTotals: numericTotals.filter((total) => total.sourceKind === "direct"), + socketTotals: numericTotals.filter((total) => total.sourceKind === "socket"), + mixedTotals: numericTotals.filter((total) => total.sourceKind === "mixed"), + textBonuses, + socketItems: activeEquipments.flatMap((equipment) => equipment.socketItems.map((socketItem) => ({ + ...socketItem, + equipmentId: equipment.id, + equipmentName: equipment.name + }))), + socketLinks: activeEquipments.flatMap((equipment) => equipment.socketLinks.map((link) => ({ + ...link, + equipmentId: equipment.id, + equipmentName: equipment.name + }))) + }; +} + +function compactEquipmentPlannerTraitForStorage(trait, prefix = "trait") { + const normalized = normalizeEquipmentPlannerTrait(trait, prefix); + if (!normalized) return null; + const compact = { id: normalized.id, name: normalized.name }; + if (normalized.category) compact.category = normalized.category; + if (normalized.icon !== "sword") compact.icon = normalized.icon; + if (normalized.value !== "") compact.value = normalized.value; + return compact; +} + +function compactEquipmentPlannerMaterialForStorage(material) { + const normalized = normalizeEquipmentPlannerMaterial(material); + return normalized ? normalized : null; +} + +function compactEquipmentPlannerSocketItemForStorage(socketItem) { + const normalized = normalizeEquipmentPlannerSocketItem(socketItem); + if (!normalized) return null; + const compact = { + id: normalized.id, + name: normalized.name, + shape: normalized.shape, + color: normalized.color + }; + if (normalized.bonuses.length) { + compact.bonuses = normalized.bonuses + .map((bonus) => compactEquipmentPlannerTraitForStorage(bonus, "socketBonus")) + .filter(Boolean); + } + return compact; +} + +function compactEquipmentPlannerEquipmentForStorage(equipment) { + const compact = { + id: equipment.id, + typeId: equipment.typeId, + name: equipment.name + }; + if (equipment.icon !== "shield") compact.icon = equipment.icon; + if (equipment.active === false) compact.active = false; + if (equipment.obtain) compact.obtain = equipment.obtain; + if (equipment.characteristics.length) { + compact.characteristics = equipment.characteristics + .map((trait) => compactEquipmentPlannerTraitForStorage(trait, "trait")) + .filter(Boolean); + } + if (equipment.socketItems.length) { + compact.socketItems = equipment.socketItems + .map(compactEquipmentPlannerSocketItemForStorage) + .filter(Boolean); + } + if (equipment.socketLinks.length) compact.socketLinks = equipment.socketLinks; + if (equipment.materials.length) compact.materials = equipment.materials.map(compactEquipmentPlannerMaterialForStorage).filter(Boolean); + if (equipment.categoryOrder.length) compact.categoryOrder = equipment.categoryOrder; + if (equipment.collapsedCategories.length) compact.collapsedCategories = equipment.collapsedCategories; + return compact; +} + +export function compactEquipmentPlannerDataForStorage(value) { + const normalized = normalizeEquipmentPlannerData(value); + if (!normalized.equipments.length) return null; + const compact = { + types: normalized.types.map((type) => { + const compactType = { id: type.id, title: type.title }; + if (type.icon !== "shield") compactType.icon = type.icon; + if (type.collapsed) compactType.collapsed = true; + return compactType; + }), + equipments: normalized.equipments.map(compactEquipmentPlannerEquipmentForStorage) + }; + if (normalized.typeOrder.length) compact.typeOrder = normalized.typeOrder; + return compact; +} diff --git a/website/src/features/toolboxes/storage/modules/imageAnnotation.js b/website/src/features/toolboxes/storage/modules/imageAnnotation.js new file mode 100644 index 0000000..39091da --- /dev/null +++ b/website/src/features/toolboxes/storage/modules/imageAnnotation.js @@ -0,0 +1,52 @@ +// Rôle : normalise et compacte les images simples et annotations d'image. +import { normalizeDrawingData } from "./drawing.js"; +import { uid } from "./shared.js"; + +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 }) }; +} + +export function compactImagesDataForStorage(value) { + 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; +} + +export function compactImageAnnotationDataForStorage(value) { + 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; +} + +export function labelFromFileName(name) { + if (!name || name === "image.png") return ""; + return name.replace(/\.[^.]+$/, "").trim(); +} diff --git a/website/src/features/toolboxes/storage/modules/index.js b/website/src/features/toolboxes/storage/modules/index.js new file mode 100644 index 0000000..0da827b --- /dev/null +++ b/website/src/features/toolboxes/storage/modules/index.js @@ -0,0 +1,27 @@ +// Rôle : expose le registre strict des compacteurs par type d'outil toolbox. +import { compactCalculatorDataForStorage } from "./calculator.js"; +import { compactChecklistDataForStorage } from "./checklist.js"; +import { compactCombosDataForStorage } from "./combos.js"; +import { compactCountersDataForStorage } from "./counters.js"; +import { compactEquipmentPlannerDataForStorage } from "./equipmentPlanner.js"; +import { compactImageAnnotationDataForStorage, compactImagesDataForStorage } from "./imageAnnotation.js"; +import { compactLinksDataForStorage } from "./links.js"; +import { compactNotepadDataForStorage } from "./notepad.js"; +import { compactTableDataForStorage } from "./table.js"; +import { compactTaskPlannerDataForStorage } from "./taskPlanner.js"; +import { compactTimerDataForStorage } from "./timer.js"; + +export const MODULE_DATA_COMPACTORS = { + notepad: compactNotepadDataForStorage, + checklist: compactChecklistDataForStorage, + images: compactImagesDataForStorage, + imageAnnotation: compactImageAnnotationDataForStorage, + links: compactLinksDataForStorage, + counters: compactCountersDataForStorage, + combos: compactCombosDataForStorage, + calculator: compactCalculatorDataForStorage, + table: compactTableDataForStorage, + timer: compactTimerDataForStorage, + taskPlanner: compactTaskPlannerDataForStorage, + equipmentPlanner: compactEquipmentPlannerDataForStorage +}; diff --git a/website/src/features/toolboxes/storage/modules/links.js b/website/src/features/toolboxes/storage/modules/links.js new file mode 100644 index 0000000..944fd04 --- /dev/null +++ b/website/src/features/toolboxes/storage/modules/links.js @@ -0,0 +1,48 @@ +// Rôle : normalise et compacte les liens toolbox. +import { uid } from "./shared.js"; + +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) + }; +} + +export function compactLinksDataForStorage(value) { + 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; +} diff --git a/website/src/features/toolboxes/storage/modules/notepad.js b/website/src/features/toolboxes/storage/modules/notepad.js new file mode 100644 index 0000000..1c70db9 --- /dev/null +++ b/website/src/features/toolboxes/storage/modules/notepad.js @@ -0,0 +1,119 @@ +// Rôle : normalise, nettoie et compacte les données du bloc-notes riche. +import { NOTEPAD_ALLOWED_COLORS, normalizeDrawingData } from "./drawing.js"; +import { normalizeIsoDate } from "./shared.js"; + +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" +}; + +function escapeHtml(value) { + return String(value || "") + .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(//gi, "") + .replace(//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("`; + return ""; + } + if (tag === "br") { + html += "
"; + 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 ? `

${escapeHtml(line)}

` : "


") + .join(""); +} + +function notepadHtmlToText(value) { + const withBreaks = String(value || "") + .replace(//gi, "\n") + .replace(/<\/(p|div|h3|h4|li)>/gi, "\n") + .replace(/]*>/gi, "- "); + return decodeBasicEntities(withBreaks.replace(/<[^>]*>/g, "")) + .split("\n") + .map((line) => line.trimEnd()) + .join("\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + +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 compactNotepadDataForStorage(value) { + 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; +} diff --git a/website/src/features/toolboxes/storage/modules/shared.js b/website/src/features/toolboxes/storage/modules/shared.js new file mode 100644 index 0000000..a614f80 --- /dev/null +++ b/website/src/features/toolboxes/storage/modules/shared.js @@ -0,0 +1,56 @@ +// Rôle : fournit les primitives communes de normalisation et d'identifiants du stockage toolbox. +export 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", + equipmentType: "y", + equipment: "q", + trait: "v", + material: "n", + socketItem: "j", + socketLink: "u", + socketBonus: "b" +}; + +const ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789"; + +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()}`; +} + +export function parsePositiveInt(value, fallback = 1) { + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +export function normalizeIsoDate(value) { + const text = String(value || "").trim(); + if (!text) return ""; + const timestamp = Date.parse(text); + return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : ""; +} + +export function normalizeTimestamp(value) { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; +} diff --git a/website/src/features/toolboxes/storage/modules/table.js b/website/src/features/toolboxes/storage/modules/table.js new file mode 100644 index 0000000..395733a --- /dev/null +++ b/website/src/features/toolboxes/storage/modules/table.js @@ -0,0 +1,63 @@ +// Rôle : normalise et compacte les cellules et libellés de l'outil table. +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; +} + +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] || ""); + if (value.trim()) 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 compactTableDataForStorage(value) { + 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; +} diff --git a/website/src/features/toolboxes/storage/modules/taskPlanner.js b/website/src/features/toolboxes/storage/modules/taskPlanner.js new file mode 100644 index 0000000..4f338b7 --- /dev/null +++ b/website/src/features/toolboxes/storage/modules/taskPlanner.js @@ -0,0 +1,129 @@ +// Rôle : normalise et compacte les données persistées du task planner. +import { normalizeTimestamp, uid } from "./shared.js"; + +export const DEFAULT_TASK_PLANNER_WEEKLY_RESET_DAY = 1; +export const DEFAULT_TASK_PLANNER_RESET_TIME = "00:00"; +const TASK_TYPES = new Set(["unique", "daily", "weekly"]); + +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 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 === "daily" && isClockTimeString(task?.dailyResetTime)) normalized.dailyResetTime = task.dailyResetTime; + 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)) + }; +} + +export function compactTaskPlannerDataForStorage(value) { + 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 === "daily" && task.dailyResetTime && task.dailyResetTime !== normalized.resetTime) compactTask.dailyResetTime = task.dailyResetTime; + 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; +} diff --git a/website/src/features/toolboxes/storage/modules/timer.js b/website/src/features/toolboxes/storage/modules/timer.js new file mode 100644 index 0000000..ecbc5d3 --- /dev/null +++ b/website/src/features/toolboxes/storage/modules/timer.js @@ -0,0 +1,114 @@ +// Rôle : normalise et compacte les chronomètres et comptes à rebours. +import { uid } from "./shared.js"; + +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 TIMER_INTERVAL_START_MODES = new Set(["now", "time"]); +const TIMER_MIN_AUTO_REFRESH_MS = 5 * 60 * 1000; + +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); + }); +} + +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, autoRefresh: countdown?.autoRefresh === true } : 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); + const targetAt = Math.max(0, Number(countdown?.targetAt) || 0); + const startMode = TIMER_INTERVAL_START_MODES.has(countdown?.startMode) ? countdown.startMode : "now"; + const startTime = String(countdown?.startTime || "").trim(); + const autoRefresh = countdown?.autoRefresh === true || (countdown?.autoRefresh !== false && !targetAt && intervalMs >= TIMER_MIN_AUTO_REFRESH_MS); + return intervalMs > 0 + ? { + ...normalized, + intervalMs, + anchorAt, + targetAt: targetAt || anchorAt + intervalMs, + startMode: startMode === "time" && isTimeString(startTime) ? "time" : "now", + ...(startMode === "time" && isTimeString(startTime) ? { startTime } : {}), + autoRefresh: autoRefresh && intervalMs >= TIMER_MIN_AUTO_REFRESH_MS + } + : 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 + }; +} + +export function compactTimerDataForStorage(value) { + 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) => { + const { alertMode, autoRefresh, startMode, startTime, ...compactCountdown } = countdown; + if (alertMode !== "off") compactCountdown.alertMode = alertMode; + if (autoRefresh) compactCountdown.autoRefresh = true; + if (startMode === "time" && startTime) { + compactCountdown.startMode = startMode; + compactCountdown.startTime = startTime; + } + return compactCountdown; + }); + } + return Object.keys(compact).length ? compact : null; +} diff --git a/website/src/features/toolboxes/storage/toolboxStorage.js b/website/src/features/toolboxes/storage/toolboxStorage.js index 2d6fa8d..d78d01f 100644 --- a/website/src/features/toolboxes/storage/toolboxStorage.js +++ b/website/src/features/toolboxes/storage/toolboxStorage.js @@ -1,57 +1,22 @@ // Rôle : normalise, compacte et sérialise les données toolbox persistées. import { getDefaultModuleTitle } from "../toolboxModuleRegistry.js"; -import { createModuleDataCompactors } from "./moduleDataCompactors.js"; +import { MODULE_DATA_COMPACTORS } from "./modules/index.js"; +import { ID_PREFIXES, uid } from "./modules/shared.js"; + +export { normalizeCalculatorData } from "./modules/calculator.js"; +export { clampQty, normalizeChecklistData } from "./modules/checklist.js"; +export { normalizeCombosData } from "./modules/combos.js"; +export { normalizeCountersData } from "./modules/counters.js"; +export { normalizeDrawingData } from "./modules/drawing.js"; +export { normalizeEquipmentPlannerData, summarizeEquipmentPlannerData } from "./modules/equipmentPlanner.js"; +export { labelFromFileName, normalizeImageAnnotationData } from "./modules/imageAnnotation.js"; +export { hostnameFromUrl, normalizeLinksData, normalizeUrl } from "./modules/links.js"; +export { normalizeNotepadData, sanitizeNotepadHtml } from "./modules/notepad.js"; +export { uid } from "./modules/shared.js"; +export { normalizeTableData } from "./modules/table.js"; +export { normalizeTaskPlannerData } from "./modules/taskPlanner.js"; +export { normalizeTimerData } from "./modules/timer.js"; -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", - equipmentType: "y", - equipment: "q", - trait: "v", - material: "n", - socketItem: "j", - socketLink: "u", - socketBonus: "b" -}; -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 TIMER_INTERVAL_START_MODES = new Set(["now", "time"]); -const TIMER_MIN_AUTO_REFRESH_MS = 5 * 60 * 1000; -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 EQUIPMENT_SOCKET_SHAPES = new Set(["ball", "jewel"]); -const EQUIPMENT_SOCKET_COLORS = new Set(["red", "orange", "amber", "yellow", "lime", "green", "teal", "cyan", "blue", "indigo", "violet", "purple", "pink", "rose", "white", "gray", "black"]); -const EQUIPMENT_TYPE_ICONS = new Set(["boot", "chest-armor", "helmet", "glove", "shield", "sword", "gun", "earring", "necklace", "ring"]); -const EQUIPMENT_TRAIT_ICONS = new Set(["sword", "shield"]); -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", @@ -77,19 +42,6 @@ const TOOLBOX_ICON_FILES = [ 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()}`; -} - export function normalizeToolboxIcon(icon) { const value = String(icon || "").trim(); const file = value.replace(TOOLBOX_ICON_BASE, "").split("/").pop(); @@ -200,931 +152,6 @@ 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, """); -} - -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(//gi, "") - .replace(//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("`; - return ""; - } - if (tag === "br") { - html += "
"; - 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 ? `

${escapeHtml(line)}

` : "


") - .join(""); -} - -function notepadHtmlToText(value) { - const withBreaks = String(value || "") - .replace(//gi, "\n") - .replace(/<\/(p|div|h3|h4|li)>/gi, "\n") - .replace(/]*>/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) - : []; - 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); - if (!kind || !value) return null; - const holdMs = Number(input?.holdMs); - const normalizedHoldMs = Number.isFinite(holdMs) && holdMs > 0 - ? Math.min(99000, Math.round(holdMs / 1000) * 1000) - : 0; - const normalized = { kind, value }; - if (input?.hold === true || normalizedHoldMs > 0) normalized.hold = true; - if (normalizedHoldMs > 0) normalized.holdMs = normalizedHoldMs; - return normalized; -} - -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 category = String(combo?.category || "").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 (category) normalized.category = category; - if (device) normalized.device = device; - return normalized; -} - -function normalizeComboCategoryOrder(value, combos) { - const categories = combos.map((combo) => combo.category).filter(Boolean); - const orderedCategories = (Array.isArray(value) ? value : []) - .map((category) => String(category || "").trim().slice(0, 80)) - .filter((category, index, order) => category && categories.includes(category) && order.indexOf(category) === index); - return [ - ...orderedCategories, - ...categories.filter((category) => !orderedCategories.includes(category)) - ]; -} - -export function normalizeCombosData(data) { - const combos = (Array.isArray(data?.combos) ? data.combos : []).map(normalizeCombo).filter(Boolean); - const categoryOrder = normalizeComboCategoryOrder(data?.categoryOrder, combos); - return { - device: COMBO_DEVICES.has(data?.device) ? data.device : "playstation", - combos, - collapsedCategories: (Array.isArray(data?.collapsedCategories) ? data.collapsedCategories : []) - .map((category) => String(category || "").trim().slice(0, 80)) - .filter((category, index, categories) => category && categoryOrder.includes(category) && categories.indexOf(category) === index), - categoryOrder - }; -} - -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] || ""); - if (value.trim()) 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, autoRefresh: countdown?.autoRefresh === true } : 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); - const targetAt = Math.max(0, Number(countdown?.targetAt) || 0); - const startMode = TIMER_INTERVAL_START_MODES.has(countdown?.startMode) ? countdown.startMode : "now"; - const startTime = String(countdown?.startTime || "").trim(); - const autoRefresh = countdown?.autoRefresh === true || (countdown?.autoRefresh !== false && !targetAt && intervalMs >= TIMER_MIN_AUTO_REFRESH_MS); - return intervalMs > 0 - ? { - ...normalized, - intervalMs, - anchorAt, - targetAt: targetAt || anchorAt + intervalMs, - startMode: startMode === "time" && isTimeString(startTime) ? "time" : "now", - ...(startMode === "time" && isTimeString(startTime) ? { startTime } : {}), - autoRefresh: autoRefresh && intervalMs >= TIMER_MIN_AUTO_REFRESH_MS - } - : 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 === "daily" && isClockTimeString(task?.dailyResetTime)) normalized.dailyResetTime = task.dailyResetTime; - 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 normalizeEquipmentPlannerType(type) { - const title = String(type?.title || "").trim().slice(0, 80); - if (!title) return null; - const icon = EQUIPMENT_TYPE_ICONS.has(type?.icon) ? type.icon : "shield"; - return { - id: type?.id || uid("equipmentType"), - title, - icon, - collapsed: type?.collapsed === true - }; -} - -function normalizeEquipmentPlannerValue(value) { - if (typeof value === "number") return Number.isFinite(value) ? Math.round(value * 1000) / 1000 : ""; - const text = String(value ?? "").trim().slice(0, 80); - if (!text) return ""; - const numeric = Number(text.replace(",", ".")); - return Number.isFinite(numeric) && /^[-+]?\d+(?:[,.]\d+)?$/.test(text) ? Math.round(numeric * 1000) / 1000 : text; -} - -function normalizeEquipmentPlannerTrait(trait, prefix = "trait") { - const name = String(trait?.name || "").trim().slice(0, 80); - if (!name) return null; - return { - id: trait?.id || uid(prefix), - category: String(trait?.category || "").trim().slice(0, 80), - icon: EQUIPMENT_TRAIT_ICONS.has(trait?.icon) ? trait.icon : "sword", - name, - value: normalizeEquipmentPlannerValue(trait?.value) - }; -} - -function normalizeEquipmentPlannerMaterial(material) { - const name = String(material?.name || "").trim().slice(0, 80); - if (!name) return null; - return { - id: material?.id || uid("material"), - name, - qty: Math.max(1, parsePositiveInt(material?.qty, 1)) - }; -} - -function normalizeEquipmentPlannerSocketItem(socketItem) { - const name = String(socketItem?.name || "").trim().slice(0, 80); - if (!name) return null; - const shape = EQUIPMENT_SOCKET_SHAPES.has(socketItem?.shape) ? socketItem.shape : "jewel"; - const color = EQUIPMENT_SOCKET_COLORS.has(socketItem?.color) ? socketItem.color : "yellow"; - return { - id: socketItem?.id || uid("socketItem"), - name, - shape, - color, - bonuses: (Array.isArray(socketItem?.bonuses) ? socketItem.bonuses : []) - .map((bonus) => normalizeEquipmentPlannerTrait(bonus, "socketBonus")) - .filter(Boolean) - }; -} - -function normalizeEquipmentPlannerCategoryOrder(value, categories) { - const available = new Set(categories.filter(Boolean)); - const ordered = []; - (Array.isArray(value) ? value : []).forEach((category) => { - const normalized = String(category || "").trim().slice(0, 80); - if (normalized && available.has(normalized) && !ordered.includes(normalized)) ordered.push(normalized); - }); - categories.forEach((category) => { - if (category && !ordered.includes(category)) ordered.push(category); - }); - return ordered; -} - -function normalizeEquipmentPlannerEquipment(equipment, typeIds) { - const name = String(equipment?.name || "").trim().slice(0, 100); - const typeId = String(equipment?.typeId || ""); - if (!name || !typeIds.has(typeId)) return null; - const characteristics = (Array.isArray(equipment?.characteristics) ? equipment.characteristics : []) - .map((trait) => normalizeEquipmentPlannerTrait(trait, "trait")) - .filter(Boolean); - const socketItems = (Array.isArray(equipment?.socketItems) ? equipment.socketItems : []) - .map(normalizeEquipmentPlannerSocketItem) - .filter(Boolean); - const socketItemIds = new Set(socketItems.map((socketItem) => socketItem.id)); - const socketLinks = (Array.isArray(equipment?.socketLinks) ? equipment.socketLinks : []) - .map((link) => { - const fromSocketItemId = String(link?.fromSocketItemId || ""); - const toSocketItemId = String(link?.toSocketItemId || ""); - if (!socketItemIds.has(fromSocketItemId) || !socketItemIds.has(toSocketItemId) || fromSocketItemId === toSocketItemId) return null; - const pairKey = [fromSocketItemId, toSocketItemId].sort().join(":"); - return { - id: link?.id || uid("socketLink"), - fromSocketItemId, - toSocketItemId, - pairKey - }; - }) - .filter(Boolean) - .filter((link, index, links) => links.findIndex((item) => item.pairKey === link.pairKey) === index) - .map(({ pairKey: _pairKey, ...link }) => link); - const categories = [ - ...characteristics.map((trait) => trait.category), - ...socketItems.flatMap((socketItem) => socketItem.bonuses.map((bonus) => bonus.category)) - ].filter(Boolean); - const categoryOrder = normalizeEquipmentPlannerCategoryOrder(equipment?.categoryOrder, categories); - return { - id: equipment?.id || uid("equipment"), - typeId, - name, - icon: EQUIPMENT_TYPE_ICONS.has(equipment?.icon) ? equipment.icon : "shield", - active: equipment?.active !== false, - obtain: String(equipment?.obtain || "").trim().slice(0, 500), - characteristics, - socketItems, - socketLinks, - materials: (Array.isArray(equipment?.materials) ? equipment.materials : []) - .map(normalizeEquipmentPlannerMaterial) - .filter(Boolean), - categoryOrder, - collapsedCategories: (Array.isArray(equipment?.collapsedCategories) ? equipment.collapsedCategories : []) - .map((category) => String(category || "").trim().slice(0, 80)) - .filter((category, index, collapsedCategories) => category && categoryOrder.includes(category) && collapsedCategories.indexOf(category) === index) - }; -} - -export function normalizeEquipmentPlannerData(data) { - const rawTypes = (Array.isArray(data?.types) ? data.types : []).map(normalizeEquipmentPlannerType).filter(Boolean); - const typeIds = new Set(rawTypes.map((type) => type.id)); - const equipments = (Array.isArray(data?.equipments) ? data.equipments : []) - .map((equipment) => normalizeEquipmentPlannerEquipment(equipment, typeIds)) - .filter(Boolean); - const usedTypeIds = new Set(equipments.map((equipment) => equipment.typeId)); - const types = rawTypes.filter((type) => usedTypeIds.has(type.id)); - const orderedTypeIds = (Array.isArray(data?.typeOrder) ? data.typeOrder : []) - .map((typeId) => String(typeId || "")) - .filter((typeId, index, order) => usedTypeIds.has(typeId) && order.indexOf(typeId) === index); - return { - types, - equipments, - typeOrder: [ - ...orderedTypeIds, - ...types.map((type) => type.id).filter((typeId) => !orderedTypeIds.includes(typeId)) - ] - }; -} - -export function summarizeEquipmentPlannerData(data) { - const normalized = normalizeEquipmentPlannerData(data); - const totals = new Map(); - const textBonuses = []; - const activeEquipments = normalized.equipments.filter((equipment) => equipment.active); - - function incrementCount(counts, key) { - counts.set(key, (counts.get(key) || 0) + 1); - } - - function pickMostUsedKey(counts) { - return [...counts.entries()].reduce((picked, [key, count]) => { - if (!picked) return { key, count }; - if (count !== picked.count) return count > picked.count ? { key, count } : picked; - return key.localeCompare(picked.key) < 0 ? { key, count } : picked; - }, null); - } - - function parseIconKey(key) { - const [icon, color = ""] = String(key || "").split(":"); - return { icon: icon || "sword", color }; - } - - function pickSummaryIcon(iconSources) { - return parseIconKey(pickMostUsedKey(iconSources.direct)?.key || pickMostUsedKey(iconSources.socket)?.key || "sword:"); - } - - function getSummarySourceKind(sourceKinds) { - if (sourceKinds.size !== 1) return "mixed"; - return sourceKinds.has("direct") ? "direct" : "socket"; - } - - function collectEntry(entry, contribution, icon, sourceKind, color = "") { - if (typeof entry.value === "number") { - const existing = totals.get(entry.name); - const iconKey = icon || "sword"; - const colorKey = sourceKind === "socket" ? color || "gray" : ""; - const iconSources = existing?.iconSources || { direct: new Map(), socket: new Map() }; - const sourceKinds = existing?.sourceKinds || new Set(); - const contributions = existing?.contributions || []; - sourceKinds.add(sourceKind); - incrementCount(iconSources[sourceKind], `${iconKey}:${colorKey}`); - const pickedIcon = pickSummaryIcon(iconSources); - totals.set(entry.name, { - name: entry.name, - icon: pickedIcon.icon, - color: pickedIcon.color, - sourceKind: getSummarySourceKind(sourceKinds), - iconSources, - sourceKinds, - contributions: [ - ...contributions, - { - id: entry.id, - sourceKind, - equipmentIcon: contribution.equipmentIcon, - equipmentName: contribution.equipmentName, - sourceName: contribution.sourceName, - value: entry.value - } - ], - value: (existing?.value || 0) + entry.value - }); - return; - } - textBonuses.push({ - id: entry.id, - category: entry.category, - name: entry.name, - value: entry.value, - source: contribution.sourceName - }); - } - - activeEquipments.forEach((equipment) => { - equipment.characteristics.forEach((trait) => collectEntry( - trait, - { equipmentIcon: equipment.icon || "shield", equipmentName: equipment.name, sourceName: equipment.name }, - trait.icon || "sword", - "direct" - )); - equipment.socketItems.forEach((socketItem) => { - socketItem.bonuses.forEach((bonus) => collectEntry( - bonus, - { equipmentIcon: equipment.icon || "shield", equipmentName: equipment.name, sourceName: `${equipment.name} · ${socketItem.name}` }, - socketItem.shape || "jewel", - "socket", - socketItem.color - )); - }); - }); - - const numericTotals = [...totals.values()] - .map(({ iconSources: _iconSources, sourceKinds: _sourceKinds, color, sourceKind, ...total }) => ({ - ...total, - ...(color ? { color } : {}), - sourceKind - })) - .sort((a, b) => a.name.localeCompare(b.name)); - - return { - activeEquipmentCount: activeEquipments.length, - totals: numericTotals, - equipmentTotals: numericTotals.filter((total) => total.sourceKind === "direct"), - socketTotals: numericTotals.filter((total) => total.sourceKind === "socket"), - mixedTotals: numericTotals.filter((total) => total.sourceKind === "mixed"), - textBonuses, - socketItems: activeEquipments.flatMap((equipment) => equipment.socketItems.map((socketItem) => ({ - ...socketItem, - equipmentId: equipment.id, - equipmentName: equipment.name - }))), - socketLinks: activeEquipments.flatMap((equipment) => equipment.socketLinks.map((link) => ({ - ...link, - equipmentId: equipment.id, - equipmentName: equipment.name - }))) - }; -} - -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; -} - -function compactEquipmentPlannerTraitForStorage(trait, prefix = "trait") { - const normalized = normalizeEquipmentPlannerTrait(trait, prefix); - if (!normalized) return null; - const compact = { id: normalized.id, name: normalized.name }; - if (normalized.category) compact.category = normalized.category; - if (normalized.icon !== "sword") compact.icon = normalized.icon; - if (normalized.value !== "") compact.value = normalized.value; - return compact; -} - -function compactEquipmentPlannerMaterialForStorage(material) { - const normalized = normalizeEquipmentPlannerMaterial(material); - return normalized ? normalized : null; -} - -function compactEquipmentPlannerSocketItemForStorage(socketItem) { - const normalized = normalizeEquipmentPlannerSocketItem(socketItem); - if (!normalized) return null; - const compact = { - id: normalized.id, - name: normalized.name, - shape: normalized.shape, - color: normalized.color - }; - if (normalized.bonuses.length) { - compact.bonuses = normalized.bonuses - .map((bonus) => compactEquipmentPlannerTraitForStorage(bonus, "socketBonus")) - .filter(Boolean); - } - return compact; -} - -function compactEquipmentPlannerEquipmentForStorage(equipment) { - const compact = { - id: equipment.id, - typeId: equipment.typeId, - name: equipment.name - }; - if (equipment.icon !== "shield") compact.icon = equipment.icon; - if (equipment.active === false) compact.active = false; - if (equipment.obtain) compact.obtain = equipment.obtain; - if (equipment.characteristics.length) { - compact.characteristics = equipment.characteristics - .map((trait) => compactEquipmentPlannerTraitForStorage(trait, "trait")) - .filter(Boolean); - } - if (equipment.socketItems.length) { - compact.socketItems = equipment.socketItems - .map(compactEquipmentPlannerSocketItemForStorage) - .filter(Boolean); - } - if (equipment.socketLinks.length) compact.socketLinks = equipment.socketLinks; - if (equipment.materials.length) compact.materials = equipment.materials.map(compactEquipmentPlannerMaterialForStorage).filter(Boolean); - if (equipment.categoryOrder.length) compact.categoryOrder = equipment.categoryOrder; - if (equipment.collapsedCategories.length) compact.collapsedCategories = equipment.collapsedCategories; - return compact; -} - -const MODULE_DATA_COMPACTORS = createModuleDataCompactors({ - uid, - normalizeNotepadData, - normalizeChecklistData, - normalizeLinksData, - normalizeCountersData, - normalizeCombosData, - normalizeCalculatorData, - normalizeTableData, - normalizeTimerData, - normalizeTaskPlannerData, - normalizeEquipmentPlannerData, - normalizeImageAnnotationData, - compactChecklistSectionForStorage, - compactEquipmentPlannerEquipmentForStorage, - defaultTaskPlannerWeeklyResetDay: DEFAULT_TASK_PLANNER_WEEKLY_RESET_DAY, - defaultTaskPlannerResetTime: DEFAULT_TASK_PLANNER_RESET_TIME -}); - export function compactModuleDataForStorage(type, value) { const compactor = MODULE_DATA_COMPACTORS[type]; return compactor ? compactor(value) : value;