diff --git a/docs/STORAGE_SCHEMA.md b/docs/STORAGE_SCHEMA.md index 1222319..9ae6f21 100644 --- a/docs/STORAGE_SCHEMA.md +++ b/docs/STORAGE_SCHEMA.md @@ -661,13 +661,15 @@ Type : `calculator` { "id": "calc1", "label": "Lingots de fer", - "value": 100 + "value": 100, + "formula": "100" }, { "id": "calc2", "parentId": "calc1", "label": "Minerais de fer", - "value": 500 + "value": 500, + "formula": "base*5" } ] } @@ -679,6 +681,7 @@ Stockage compact : - `parentId` est omis si vide. - `label` peut être vide. - `value` est numérique. +- `formula` est omis si vide ; dans un calcul enfant, le mot `base` référence la valeur recalculée du parent. - Si un `parentId` ne pointe vers aucune entrée existante, il est réinitialisé à vide. ## Outil Tableau @@ -770,7 +773,7 @@ Formats : - `checklist` : sections `# Catégorie`, lignes `Item: quantité cible`. - `links` : lignes `Titre: https://...` ou URL seule. - `counters` : lignes `Libellé: valeur`, valeurs négatives acceptées. -- `calculator` : lignes `Libellé: valeur`, indentation de deux espaces pour les enfants, meta `@scrollResults: true`. +- `calculator` : lignes `Libellé: valeur | formula=base*5`, indentation de deux espaces pour les enfants, meta `@scrollResults: true`. Le suffixe `formula=...` est optionnel et les anciens exports `Libellé: valeur` restent acceptés. - `table` : TSV pur accepté ; l'export peut ajouter `@size`, `@columns`, `@rows` puis `@tsv` pour préserver dimensions et intitulés. - `combos` : sections `# Catégorie`, lignes `Nom | device=... | kind:value > kind:value+kind:value`, avec suffixes `[hold]` ou `[2s]`. - `taskPlanner` : sections `# Catégorie`, lignes indentées `- daily Titre`, `- weekly Titre` ou `- unique Titre`, metas `@description`, `@dailyResetTime`, `@weeklyResetDay`, `@prerequisite`. diff --git a/tests/helpers/data-validation.mjs b/tests/helpers/data-validation.mjs index 5074726..cb0d658 100644 --- a/tests/helpers/data-validation.mjs +++ b/tests/helpers/data-validation.mjs @@ -263,7 +263,9 @@ export function validateSiteContent(site) { "toolboxes.modules.calculator.copiedTitle", "toolboxes.modules.calculator.scrollableTitle", "toolboxes.modules.calculator.emptyResults", - "toolboxes.modules.calculator.readonlyValueTitle", + "toolboxes.modules.calculator.formulaTitle", + "toolboxes.modules.calculator.childFormulaHelp", + "toolboxes.modules.calculator.rootFormulaHelp", "toolboxes.modules.calculator.useEntryTitle", "toolboxes.modules.calculator.renameTitle", "toolboxes.modules.calculator.deleteTitle", diff --git a/tests/static-toolboxes.test.mjs b/tests/static-toolboxes.test.mjs index 96aab94..d9f384e 100644 --- a/tests/static-toolboxes.test.mjs +++ b/tests/static-toolboxes.test.mjs @@ -251,7 +251,7 @@ test("toolbox module registry and modules expose expected behavior", async () => assert.match(calculatorModule, /resetCalculator/); assert.match(calculatorModule, /returnToRoot/); assert.match(calculatorModule, /setExpression\(activeParent \? formatResult\(activeParent\.value\) : ""\)/); - assert.match(calculatorModule, /Quantité non modifiable/); + assert.match(calculatorModule, /EditableCalculatorFormula/); assert.match(calculatorModule, /entry\.label \|\| formatResult\(entry\.value\)/); assert.match(calculatorModule, /ResizeObserver/); assert.match(calculatorModule, /--calculator-scroll-height/); diff --git a/tests/toolbox-modules.test.mjs b/tests/toolbox-modules.test.mjs index fd89646..7d5cc48 100644 --- a/tests/toolbox-modules.test.mjs +++ b/tests/toolbox-modules.test.mjs @@ -107,14 +107,15 @@ test("text exchange imports table TSV and calculator hierarchy", () => { const calculator = normalizeCalculatorData({ scrollResults: true, entries: [ - { id: "a", label: "Ore", value: 100 }, - { id: "b", parentId: "a", label: "Shard", value: 500 } + { id: "a", label: "Ore", value: 100, formula: "100" }, + { id: "b", parentId: "a", label: "Shard", value: 500, formula: "base*5" } ] }); const calculatorImport = importModuleText("calculator", exportModuleText("calculator", calculator), context); assert.equal(calculatorImport.ok, true); assert.equal(calculatorImport.data.scrollResults, true); assert.equal(calculatorImport.data.entries[1].parentId, calculatorImport.data.entries[0].id); + assert.equal(calculatorImport.data.entries[1].formula, "base*5"); }); test("text exchange round trips combos, tasks, equipment, images and timers", () => { diff --git a/website/public/data/site.json b/website/public/data/site.json index 13b9bc6..ef70b05 100644 --- a/website/public/data/site.json +++ b/website/public/data/site.json @@ -1763,7 +1763,7 @@ "deleteComboTitle": "Supprimer le combo" }, "calculator": { - "importPlaceholder": "@scrollResults: true\nLingots de fer: 100\n Minerais de fer: 500", + "importPlaceholder": "@scrollResults: true\nLingots de fer: 100 | formula=100\n Minerais de fer: 500 | formula=base*5", "importOpenButton": "Importer du texte", "exportOpenButton": "Exporter en format texte", "importModalTitle": "Importer des résultats", @@ -1785,7 +1785,9 @@ "copiedTitle": "Copié", "scrollableTitle": "Liste scrollable", "emptyResults": "Aucun résultat enregistré.", - "readonlyValueTitle": "Quantité non modifiable", + "formulaTitle": "Modifier la formule", + "childFormulaHelp": "Utilisez base pour référencer le parent.", + "rootFormulaHelp": "Valeur ou formule racine.", "useEntryTitle": "Utiliser comme base", "renameTitle": "Renommer", "deleteTitle": "Supprimer" diff --git a/website/src/features/toolboxes/modules/CalculatorModule.jsx b/website/src/features/toolboxes/modules/CalculatorModule.jsx index 88b8563..edbb970 100644 --- a/website/src/features/toolboxes/modules/CalculatorModule.jsx +++ b/website/src/features/toolboxes/modules/CalculatorModule.jsx @@ -1,18 +1,21 @@ // Rôle : fournit l'outil calculateur avec résultats enregistrés en arborescence et échange texte. -import { useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { Icon } from "../../../components/Icon.jsx"; import { useInlineEdit } from "../../../hooks/useInlineEdit.js"; import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js"; import { TextExchangeActions } from "./TextExchangeActions.jsx"; const DISPLAY_NUMBER_FORMATTER = new Intl.NumberFormat("fr-FR", { maximumFractionDigits: 6 }); +const BASE_TOKEN = "base"; -function calculateExpression(expression) { +function calculateExpression(expression, baseValue = null) { const normalized = String(expression || "").replaceAll(",", ".").trim(); - if (!normalized || !/^[\d\s+\-*/().]+$/.test(normalized)) return null; + if (!normalized) return null; + const withBase = normalized.replace(/\bbase\b/gi, () => Number.isFinite(baseValue) ? `(${formatResult(baseValue)})` : "NaN"); + if (!/^[\d\s+\-*/().]+$/.test(withBase)) return null; try { - const value = Function(`"use strict"; return (${normalized})`)(); + const value = Function(`"use strict"; return (${withBase})`)(); return Number.isFinite(value) ? value : null; } catch { return null; @@ -56,6 +59,48 @@ function getEntriesInTreeOrder(entries, parentId = "") { return getChildren(entries, parentId).flatMap((entry) => [entry, ...getEntriesInTreeOrder(entries, entry.id)]); } +function getEntryFormula(entry) { + return String(entry?.formula || "").trim(); +} + +function getEditableFormula(entry) { + return getEntryFormula(entry) || formatResult(entry.value); +} + +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function createStoredFormula(expression, parentValue) { + const normalized = String(expression || "").replaceAll(",", ".").trim(); + if (!Number.isFinite(parentValue)) return normalized; + const parentText = formatResult(parentValue); + if (/\bbase\b/i.test(normalized)) return normalized; + const withBase = normalized.replace(new RegExp(`(^|[^\\d.])${escapeRegExp(parentText)}(?=$|[^\\d.])`, "g"), `$1${BASE_TOKEN}`); + if (withBase !== normalized) return withBase; + if (/^[+\-*/]/.test(normalized)) return `${BASE_TOKEN}${normalized}`; + return normalized; +} + +function evaluateCalculatorEntries(entries) { + const byParent = new Map(); + entries.forEach((entry) => { + const parentId = entry.parentId || ""; + byParent.set(parentId, [...(byParent.get(parentId) || []), entry]); + }); + + function visit(parentId = "", parentValue = null) { + return (byParent.get(parentId) || []).flatMap((entry) => { + const formula = getEntryFormula(entry); + const calculatedValue = formula ? calculateExpression(formula, parentValue) : entry.value; + const nextEntry = { ...entry, value: calculatedValue ?? entry.value }; + return [nextEntry, ...visit(entry.id, nextEntry.value)]; + }); + } + + return visit(); +} + function FormattedCalculatorNumber({ value }) { if (!Number.isFinite(value)) return "-"; let integerGroupIndex = 0; @@ -73,6 +118,8 @@ function FormattedCalculatorNumber({ value }) { export function CalculatorModule({ toolboxId, moduleId, context, editing }) { const data = context.normalizeCalculatorData(context.getModuleData(toolboxId, moduleId, { entries: [] })); + const calculatedEntries = useMemo(() => evaluateCalculatorEntries(data.entries), [data.entries]); + const calculatedData = useMemo(() => ({ ...data, entries: calculatedEntries }), [data, calculatedEntries]); const textContent = context.moduleText?.calculator || {}; const [expression, setExpression] = useState(""); const [label, setLabel] = useState(""); @@ -80,16 +127,16 @@ export function CalculatorModule({ toolboxId, moduleId, context, editing }) { const [calculatorHeight, setCalculatorHeight] = useState(0); const [copied, setCopied] = useState(false); const calculatorCardRef = useRef(null); - const result = useMemo(() => calculateExpression(expression), [expression]); - const activeParent = data.entries.find((entry) => entry.id === activeParentId); + const activeParent = calculatedEntries.find((entry) => entry.id === activeParentId); + const result = useMemo(() => calculateExpression(expression, activeParent?.value), [activeParent?.value, expression]); const scopeParentId = (parentId = "") => `${moduleId}:${parentId}`; const reorder = useGroupedReorder({ namespace: "calculator", - items: data.entries, + items: calculatedEntries, getItemId: (entry) => entry.id, getParentId: (entry) => scopeParentId(entry.parentId || ""), onItemMove: (operation) => { - save(moveItem(data.entries, operation.sourceId, operation.targetId, operation.placement)); + save(moveItem(calculatedEntries, operation.sourceId, operation.targetId, operation.placement)); }, hierarchy: { enabled: true, stickyParents: true } }); @@ -109,7 +156,7 @@ export function CalculatorModule({ toolboxId, moduleId, context, editing }) { }, []); function save(entries) { - context.setModuleData(toolboxId, moduleId, { ...data, entries }); + context.setModuleData(toolboxId, moduleId, { ...data, entries: evaluateCalculatorEntries(entries) }); } function setScrollResults(scrollResults) { @@ -127,7 +174,8 @@ export function CalculatorModule({ toolboxId, moduleId, context, editing }) { id: context.uid("calc"), parentId: activeParentId, label: cleanLabel, - value: result + value: result, + formula: createStoredFormula(expression, activeParent?.value) } ]); setExpression(activeParent ? formatResult(activeParent.value) : ""); @@ -140,17 +188,28 @@ export function CalculatorModule({ toolboxId, moduleId, context, editing }) { } function deleteEntry(entryId) { - const deletedIds = new Set([entryId, ...getDescendantIds(data.entries, entryId)]); + const deletedIds = new Set([entryId, ...getDescendantIds(calculatedEntries, entryId)]); if (deletedIds.has(activeParentId)) { setActiveParentId(""); setExpression(""); } - save(data.entries.filter((entry) => !deletedIds.has(entry.id))); + save(calculatedEntries.filter((entry) => !deletedIds.has(entry.id))); } function renameEntry(entryId, label) { const cleanLabel = label.trim(); - save(data.entries.map((entry) => entry.id === entryId ? { ...entry, label: cleanLabel } : entry)); + save(calculatedEntries.map((entry) => entry.id === entryId ? { ...entry, label: cleanLabel } : entry)); + } + + function updateEntryFormula(entryId, formula) { + const nextEntries = calculatedEntries.map((entry) => { + if (entry.id !== entryId) return entry; + const parent = calculatedEntries.find((candidate) => candidate.id === entry.parentId); + const nextFormula = createStoredFormula(formula, parent?.value); + const nextValue = calculateExpression(nextFormula, parent?.value) ?? entry.value; + return { ...entry, formula: nextFormula, value: nextValue }; + }); + save(nextEntries); } function resetCalculator() { @@ -165,7 +224,7 @@ export function CalculatorModule({ toolboxId, moduleId, context, editing }) { } async function copyChecklistImport() { - const text = getEntriesInTreeOrder(data.entries) + const text = getEntriesInTreeOrder(calculatedEntries) .map((entry) => `${entry.label || formatResult(entry.value)}:${formatResult(entry.value)}`) .join("\n"); if (!text || !await context.copyText(text)) return; @@ -178,7 +237,7 @@ export function CalculatorModule({ toolboxId, moduleId, context, editing }) {
{textContent.emptyResults || "Aucun résultat enregistré."}
)} @@ -240,9 +299,21 @@ export function CalculatorModule({ toolboxId, moduleId, context, editing }) { ); } -function CalculatorEntries({ entries, parentId, getScopedParentId, activeParentId, textContent, reorder, onUse, onRename, onDelete }) { +function CalculatorEntries({ entries, parentId, getScopedParentId, activeParentId, textContent, editing, reorder, onUse, onRename, onUpdateFormula, onDelete }) { const children = getChildren(entries, parentId); const [editingId, setEditingId] = useState(""); + useEffect(() => { + if (!editingId) return undefined; + + function closeOnOutsidePointer(event) { + if (event.target?.closest?.(`[data-calculator-edit-fields="${editingId}"]`)) return; + setEditingId(""); + } + + document.addEventListener("pointerdown", closeOnOutsidePointer); + return () => document.removeEventListener("pointerdown", closeOnOutsidePointer); + }, [editingId]); + if (!children.length) return null; return ( @@ -251,6 +322,7 @@ function CalculatorEntries({ entries, parentId, getScopedParentId, activeParentI const className = [ entry.id === activeParentId ? "active" : "", entry.id === editingId ? "is-editing" : "", + "can-edit-formula", reorder.isItemDragging(entry.id) ? "is-dragging" : "", reorder.isItemDropTarget(entry.id) ? "is-drop-target" : "", reorder.getDropPlacement("item", entry.id, parentId) === "after" ? "drop-after" : "" @@ -269,14 +341,10 @@ function CalculatorEntries({ entries, parentId, getScopedParentId, activeParentI