Add text import export for toolbox modules

This commit is contained in:
Shinuwa 2026-08-09 15:26:10 +02:00
parent a9b5ca423a
commit 117b7b79e1
27 changed files with 1430 additions and 181 deletions

View file

@ -58,6 +58,9 @@ export function validateSiteContent(site) {
"library.toolsLabel",
"library.categoryLabel",
"library.featureHeading",
"library.advancedHeading",
"library.copyExampleLabel",
"library.copyExampleSuccess",
"library.controlLegendHeading",
"library.categories.notesTracking",
"library.categories.references",
@ -488,11 +491,9 @@ export function validateSiteContent(site) {
});
}
});
if (docs.importFormat) {
assertNonEmptyString({ importFormat: docs.importFormat }, "importFormat.title");
assertNonEmptyString({ importFormat: docs.importFormat }, "importFormat.text");
assertNonEmptyString({ importFormat: docs.importFormat }, "importFormat.example");
}
assert.equal(typeof docs.importFormat, "object", `library.toolDocs.${toolType}.importFormat must be an object`);
assertNonEmptyString({ importFormat: docs.importFormat }, "importFormat.text");
assertNonEmptyString({ importFormat: docs.importFormat }, "importFormat.example");
});
const aboutLimits = valueAt(site, "about.limits");

View file

@ -116,6 +116,8 @@ test("toolbox module registry and modules expose expected behavior", async () =>
const imageAnnotationModule = await readFile("website/src/features/toolboxes/modules/ImageAnnotationModule.jsx", "utf8");
const linksModule = await readFile("website/src/features/toolboxes/modules/LinksModule.jsx", "utf8");
const textImportModal = await readFile("website/src/features/toolboxes/modules/TextImportModal.jsx", "utf8");
const textExchangeActions = await readFile("website/src/features/toolboxes/modules/TextExchangeActions.jsx", "utf8");
const textExchangeHook = await readFile("website/src/features/toolboxes/modules/useTextExchange.js", "utf8");
const countersModule = await readFile("website/src/features/toolboxes/modules/CountersModule.jsx", "utf8");
const textImport = await readFile("website/src/features/toolboxes/modules/textImport.js", "utf8");
const imageViewer = await readFile("website/src/components/ImageViewer.jsx", "utf8");
@ -133,7 +135,7 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(moduleRegistry, /CombosModule/);
assert.match(moduleRegistry, /calculator:/);
assert.match(moduleRegistry, /table:/);
assert.match(moduleRegistry, /table: \{ label: "Tableau", icon: "table", Component: TableModule, editable: false, scrollable: true \}/);
assert.match(moduleRegistry, /table: \{ label: "Tableau", icon: "table", Component: TableModule, editable: true, scrollable: true \}/);
assert.match(moduleRegistry, /timer:/);
assert.match(moduleRegistry, /taskPlanner:/);
assert.match(moduleRegistry, /equipmentPlanner:/);
@ -224,6 +226,7 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(tableModule, /ArrowRight/);
assert.match(tableModule, /function HeaderLabelInput/);
assert.match(tableModule, /useInlineEdit/);
assert.match(tableModule, /TextExchangeActions/);
assert.match(tableModule, /event\.target\.select/);
assert.match(tableFormulaEngine, /export function evaluateTableCell/);
assert.doesNotMatch(tableFormulaEngine, /Function\(/);
@ -235,6 +238,7 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(timerModule, /useInlineEdit/);
assert.match(timerModule, /useDraftForm/);
assert.match(timerModule, /normalizeTimerData/);
assert.match(timerModule, /TextExchangeActions/);
assert.match(taskPlannerModule, /export function TaskPlannerModule/);
assert.match(taskPlannerModule, /normalizeTaskPlannerData/);
assert.match(taskPlannerModule, /applyDueResets/);
@ -247,6 +251,7 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(taskPlannerModule, /useInlineEdit/);
assert.match(taskPlannerModule, /useDraftForm/);
assert.match(taskPlannerModule, /commitOnEnter: false/);
assert.match(taskPlannerModule, /TextExchangeActions/);
assert.match(taskPlannerModule, /reorderFeatures/);
assert.doesNotMatch(taskPlannerModule, /canMoveItem:/);
assert.doesNotMatch(taskPlannerModule, /canMoveGroup:/);
@ -257,18 +262,14 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(equipmentPlannerModule, /socketLinks/);
assert.match(equipmentPlannerModule, /useGroupedReorder/);
assert.match(equipmentPlannerModule, /reorderFeatures/);
assert.match(equipmentPlannerModule, /TextExchangeActions/);
assert.match(checklistModule, /export function ChecklistModule/);
assert.match(checklistModule, /TextImportModal/);
assert.match(checklistModule, /Icon name="import"/);
assert.match(checklistModule, /TextExchangeActions/);
assert.match(checklistModule, /editing &&/);
assert.match(checklistModule, /ChecklistItem/);
assert.match(checklistModule, /ChecklistSection/);
assert.match(checklistModule, /parseChecklistImport/);
assert.match(checklistModule, /parseColonImportLines/);
assert.match(checklistModule, /startsWith\("#"\)/);
assert.match(checklistModule, /chevron-down/);
assert.match(checklistModule, /chevron-up/);
assert.match(checklistModule, /Number\.parseInt/);
assert.match(checklistModule, /checklist-qty-current/);
assert.match(checklistModule, /checklist-delete-button danger/);
assert.match(checklistModule, /useGroupedReorder/);
@ -282,12 +283,21 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(checklistModule, /moveChecklistItemToCategory/);
assert.match(textImportModal, /createPortal/);
assert.match(textImportModal, /lockBodyScroll/);
assert.match(textImportModal, /has-error/);
assert.match(textExchangeActions, /export function TextExchangeActions/);
assert.match(textExchangeActions, /Icon name="import"/);
assert.match(textExchangeActions, /Icon name="export"/);
assert.match(textExchangeActions, /useTextExchange/);
assert.match(textExchangeHook, /export function useTextExchange/);
assert.match(textExchangeHook, /importModuleText/);
assert.match(textExchangeHook, /exportModuleText/);
assert.match(imagesModule, /export function ImagesModule/);
assert.match(imagesModule, /useGroupedReorder/);
assert.match(imagesModule, /orientation: "horizontal"/);
assert.match(imagesModule, /editing &&/);
assert.match(imagesModule, /clipboardData/);
assert.match(imagesModule, /createImageAnnotationModule/);
assert.match(imagesModule, /TextExchangeActions/);
assert.match(imagesModule, /image-annotate-button/);
assert.match(imagesModule, /dataTransfer\.files/);
assert.match(imageAnnotationModule, /export function ImageAnnotationModule/);
@ -297,14 +307,12 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(imageAnnotationModule, /context\.setImage/);
assert.match(imageAnnotationModule, /createMarkerId/);
assert.match(imageAnnotationModule, /<Icon name="zoom"/);
assert.match(imageAnnotationModule, /TextExchangeActions/);
assert.match(linksModule, /export function LinksModule/);
assert.match(linksModule, /TextImportModal/);
assert.match(linksModule, /Icon name="import"/);
assert.match(linksModule, /TextExchangeActions/);
assert.match(linksModule, /editing &&/);
assert.match(linksModule, /normalizeUrl/);
assert.match(linksModule, /copyText/);
assert.match(linksModule, /parseColonImportLines/);
assert.match(linksModule, /context\.normalizeUrl/);
assert.match(linksModule, /useGroupedReorder/);
assert.match(linksModule, /useDraftForm/);
assert.match(linksModule, /linkDraft\.getFieldProps/);
@ -317,11 +325,15 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(countersModule, /counterDraft\.getFieldProps/);
assert.match(countersModule, /orientation: "horizontal"/);
assert.match(countersModule, /counter-drag-handle/);
assert.match(countersModule, /TextExchangeActions/);
assert.match(timerModule, /useGroupedReorder/);
assert.match(timerModule, /reorderEnabled=\{!data\.sortResults\}/);
assert.match(timerModule, /parentId=\{moduleId\}/);
assert.match(timerModule, /timer-drag-handle/);
assert.match(textImport, /export function parseColonImportLines/);
assert.match(textImport, /export function exportModuleText/);
assert.match(textImport, /export function importModuleText/);
assert.match(textImport, /export function hasTextExchangeContent/);
assert.match(textImport, /line\.indexOf\(":\"\)/);
assert.match(imageViewer, /image-viewer-media/);
assert.match(imageViewer, /image-viewer-image-frame/);

View file

@ -2,11 +2,29 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { evaluateTableCell } from "../website/src/features/toolboxes/modules/tableFormulaEngine.js";
import { parseColonImportLines } from "../website/src/features/toolboxes/modules/textImport.js";
import { exportModuleText, importModuleText, parseColonImportLines } from "../website/src/features/toolboxes/modules/textImport.js";
import { getTimePatternRecurrenceMs, getTimePatternTargetMs } from "../website/src/features/toolboxes/modules/timerUtils.js";
import { compactModuleDataForStorage, createToolboxExportPayload, normalizeChecklistData, normalizeCombosData, normalizeEquipmentPlannerData, normalizeNotepadData, normalizeTableData, normalizeTaskPlannerData, summarizeEquipmentPlannerData } from "../website/src/features/toolboxes/storage/toolboxStorage.js";
import { compactModuleDataForStorage, createToolboxExportPayload, normalizeCalculatorData, normalizeChecklistData, normalizeCombosData, normalizeCountersData, normalizeEquipmentPlannerData, normalizeImageAnnotationData, normalizeLinksData, normalizeNotepadData, normalizeTableData, normalizeTaskPlannerData, normalizeTimerData, normalizeUrl, summarizeEquipmentPlannerData } from "../website/src/features/toolboxes/storage/toolboxStorage.js";
import { applyGroupedReorderOperation, completeGroupOrder, getBoundaryItemId, getGroupedEntries, moveGroupOrder, moveGroupOrderToEnd, moveGroupOrderToStart, moveItem, moveItemGroup } from "../website/src/hooks/useGroupedReorder.js";
function createTextImportContext() {
let index = 0;
return {
uid: (prefix) => `${prefix}-${index += 1}`,
normalizeChecklistData,
normalizeLinksData,
normalizeCountersData,
normalizeCombosData,
normalizeCalculatorData,
normalizeTableData,
normalizeTimerData,
normalizeTaskPlannerData,
normalizeEquipmentPlannerData,
normalizeImageAnnotationData,
normalizeUrl
};
}
test("colon text import keeps urls intact after the first separator", () => {
assert.deepEqual(parseColonImportLines("Build:https://example.com/build?class=rogue\nPotion:10"), [
{ label: "Build", value: "https://example.com/build?class=rogue" },
@ -20,6 +38,140 @@ test("colon text import accepts labels without value", () => {
]);
});
test("text exchange round trips simple toolbox modules and resets checklist progress", () => {
const context = createTextImportContext();
const checklist = normalizeChecklistData({
sections: [
{
id: "s1",
title: "Potions",
items: [
{ id: "i1", label: "Potion", qtyTarget: 10, qtyCurrent: 7 },
{ id: "i2", label: "Mega potion", qtyTarget: 5, qtyCurrent: 5 }
]
}
]
});
const checklistImport = importModuleText("checklist", exportModuleText("checklist", checklist), context);
assert.equal(checklistImport.ok, true);
assert.deepEqual(checklistImport.data.sections[0].items.map((item) => [item.label, item.qtyTarget, item.qtyCurrent]), [
["Potion", 10, 0],
["Mega potion", 5, 0]
]);
const links = normalizeLinksData({ links: [{ id: "l1", title: "Build", url: "https://example.com/build" }] });
const linksImport = importModuleText("links", exportModuleText("links", links), context);
assert.equal(linksImport.ok, true);
assert.equal(linksImport.data.links[0].url, "https://example.com/build");
const counters = normalizeCountersData({ counters: [{ id: "c1", label: "Win", value: 12 }, { id: "c2", label: "Loss", value: -2 }] });
const countersImport = importModuleText("counters", exportModuleText("counters", counters), context);
assert.equal(countersImport.ok, true);
assert.deepEqual(countersImport.data.counters.map((counter) => [counter.label, counter.value]), [["Win", 12], ["Loss", -2]]);
});
test("text exchange imports table TSV and calculator hierarchy", () => {
const context = createTextImportContext();
const tableImport = importModuleText("table", "@size: 2x2\n@columns: Item\tTotal\n@rows: One\tTwo\n@tsv\nPotion\t=A1\nMega\t5", context);
assert.equal(tableImport.ok, true);
assert.deepEqual(normalizeTableData(tableImport.data), {
rows: 2,
columns: 2,
cells: { A1: "Potion", B1: "=A1", A2: "Mega", B2: "5" },
rowLabels: { 0: "One", 1: "Two" },
columnLabels: { 0: "Item", 1: "Total" }
});
const pureTsvImport = importModuleText("table", "A\tB\n1\t2", context);
assert.equal(pureTsvImport.ok, true);
assert.equal(pureTsvImport.data.rows, 2);
assert.equal(pureTsvImport.data.columns, 2);
const calculator = normalizeCalculatorData({
scrollResults: true,
entries: [
{ id: "a", label: "Ore", value: 100 },
{ id: "b", parentId: "a", label: "Shard", value: 500 }
]
});
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);
});
test("text exchange round trips combos, tasks, equipment, images and timers", () => {
const context = createTextImportContext();
const combos = normalizeCombosData({
device: "xbox",
combos: [{ id: "o1", name: "Anti air", category: "Neutral", device: "xbox", inputs: [[{ kind: "direction", value: "down" }], [{ kind: "button", value: "a", holdMs: 2000 }]] }]
});
const combosImport = importModuleText("combos", exportModuleText("combos", combos), context);
assert.equal(combosImport.ok, true);
assert.equal(combosImport.data.combos[0].inputs[1][0].holdMs, 2000);
const tasks = normalizeTaskPlannerData({
resetTime: "06:00",
tasks: [
{ id: "a", title: "Root", type: "daily", category: "Raid", checked: true, checkedAt: 99 },
{ id: "b", title: "Child", type: "unique", checked: true, checkedAt: 99 }
],
relations: [{ id: "r", fromTaskId: "b", toTaskId: "a", prerequisite: true }]
});
const tasksImport = importModuleText("taskPlanner", exportModuleText("taskPlanner", tasks), context);
assert.equal(tasksImport.ok, true);
assert.deepEqual(tasksImport.data.tasks.map((task) => [task.title, task.checked, task.checkedAt]), [["Root", false, 0], ["Child", false, 0]]);
assert.equal(tasksImport.data.relations[0].prerequisite, true);
const equipment = normalizeEquipmentPlannerData({
types: [{ id: "t", title: "Weapons", icon: "sword" }],
equipments: [{
id: "e",
typeId: "t",
name: "Blade",
icon: "sword",
characteristics: [{ id: "v", category: "Stats", icon: "sword", name: "Attack", value: 12 }],
socketItems: [
{ id: "j", name: "Jewel", shape: "jewel", color: "yellow", bonuses: [{ id: "b", category: "Skill", name: "Focus", value: 1 }] },
{ id: "k", name: "Materia", shape: "ball", color: "blue", bonuses: [] }
],
socketLinks: [{ id: "u", fromSocketItemId: "j", toSocketItemId: "k" }],
materials: [{ id: "m", name: "Ore", qty: 4 }]
}]
});
const equipmentImport = importModuleText("equipmentPlanner", exportModuleText("equipmentPlanner", equipment), context);
assert.equal(equipmentImport.ok, true);
assert.equal(equipmentImport.data.equipments[0].socketItems[0].bonuses[0].name, "Focus");
assert.equal(equipmentImport.data.equipments[0].socketLinks.length, 1);
const imageDataUrl = "data:image/png;base64,QUJD";
const imagesImport = importModuleText("images", `# Map\n${imageDataUrl}`, context);
assert.equal(imagesImport.ok, true);
assert.equal(imagesImport.data.images[0].dataUrl, imageDataUrl);
const annotationImport = importModuleText("imageAnnotation", `${imageDataUrl}\n@marker: 42.5,68,Entry\n@drawings: {"strokes":[]}`, context);
assert.equal(annotationImport.ok, true);
assert.equal(annotationImport.data.markers[0].label, "Entry");
const timer = normalizeTimerData({ countdowns: [{ id: "z", label: "Boss", type: "duration", durationMs: 300000, targetAt: Date.now() + 300000 }] });
const timerImport = importModuleText("timer", exportModuleText("timer", timer), context);
assert.equal(timerImport.ok, true);
assert.equal(timerImport.data.countdowns[0].durationMs, 300000);
assert.ok(timerImport.data.countdowns[0].targetAt > Date.now());
});
test("text exchange rejects invalid imports without data", () => {
const context = createTextImportContext();
const invalidLink = importModuleText("links", "Not a url", context);
const invalidImage = importModuleText("images", "data:text/plain;base64,QUJD", context);
const invalidMarker = importModuleText("imageAnnotation", "data:image/png;base64,QUJD\n@marker: 120,0,Bad", context);
assert.equal(invalidLink.ok, false);
assert.match(invalidLink.error, /^Ligne 1:/);
assert.equal(invalidImage.ok, false);
assert.match(invalidImage.error, /^Ligne 1:/);
assert.equal(invalidMarker.ok, false);
assert.match(invalidMarker.error, /^Ligne 2:/);
assert.equal(importModuleText("table", "\n", context).ok, false);
});
test("time pattern recurrence uses configured frequency instead of next remaining delay", () => {
const now = new Date(2026, 0, 1, 13, 23, 45, 0).getTime();
const target = getTimePatternTargetMs("X:24:X", now);