Compare commits
No commits in common. "bdd9949fcc6ad56493cccb6ca314b8f7ebcded8b" and "a9b5ca423a001baaeb5a0b0d1a80bfa1064fc338" have entirely different histories.
bdd9949fcc
...
a9b5ca423a
27 changed files with 179 additions and 1428 deletions
|
|
@ -11,8 +11,6 @@ Checklist à suivre lors de l'ajout ou de la modification d'un outil, d'une page
|
||||||
- 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/toolboxStorage.js`.
|
||||||
- Mettre à jour `docs/STORAGE_SCHEMA.md`.
|
- Mettre à jour `docs/STORAGE_SCHEMA.md`.
|
||||||
- Vérifier l'import et l'export si l'outil stocke des données.
|
- 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.
|
|
||||||
- Si l'outil porte un état de progression, vérifier que l'import texte repart de l'état initial attendu.
|
|
||||||
- Vérifier l'affichage dans la page toolbox complète.
|
- Vérifier l'affichage dans la page toolbox complète.
|
||||||
- Vérifier l'affichage dans le panneau latéral.
|
- Vérifier l'affichage dans le panneau latéral.
|
||||||
- Vérifier le mode une colonne et deux colonnes.
|
- Vérifier le mode une colonne et deux colonnes.
|
||||||
|
|
|
||||||
|
|
@ -686,32 +686,6 @@ Exemple :
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Import / Export texte des outils
|
|
||||||
|
|
||||||
Les outils compatibles proposent aussi un échange par copier-coller depuis leur panneau d'ajout. Ce format est un format UI distinct du stockage IndexedDB et de l'export JSON global.
|
|
||||||
|
|
||||||
Règles communes :
|
|
||||||
|
|
||||||
- l'import valide tout le texte avant de sauvegarder ;
|
|
||||||
- une erreur de format ne modifie pas les données existantes ;
|
|
||||||
- les IDs internes sont recréés à l'import ;
|
|
||||||
- l'identité de contenu est évaluée après normalisation ;
|
|
||||||
- les états de progression ne sont pas repris : checklist à `qtyCurrent: 0`, tâches non effectuées et `checkedAt: 0`.
|
|
||||||
|
|
||||||
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`.
|
|
||||||
- `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`.
|
|
||||||
- `equipmentPlanner` : types `# Type | icon=...`, équipements `## Nom | icon=... | active=true`, sous-sections `Stats`, `Sockets`, `Craft`.
|
|
||||||
- `images` : blocs `# Libellé` puis `data:image/...;base64,...`.
|
|
||||||
- `imageAnnotation` : image base64, lignes `@marker: x,y,label` et `@drawings: {...}` pour les dessins permanents.
|
|
||||||
- `timer` : metas globales, lignes `lap | ...` et `countdown | ...`; les échéances temporelles sont recalculées à l'import.
|
|
||||||
|
|
||||||
## A maintenir à chaque update
|
## A maintenir à chaque update
|
||||||
|
|
||||||
Quand un outil change de structure :
|
Quand un outil change de structure :
|
||||||
|
|
|
||||||
|
|
@ -58,9 +58,6 @@ export function validateSiteContent(site) {
|
||||||
"library.toolsLabel",
|
"library.toolsLabel",
|
||||||
"library.categoryLabel",
|
"library.categoryLabel",
|
||||||
"library.featureHeading",
|
"library.featureHeading",
|
||||||
"library.advancedHeading",
|
|
||||||
"library.copyExampleLabel",
|
|
||||||
"library.copyExampleSuccess",
|
|
||||||
"library.controlLegendHeading",
|
"library.controlLegendHeading",
|
||||||
"library.categories.notesTracking",
|
"library.categories.notesTracking",
|
||||||
"library.categories.references",
|
"library.categories.references",
|
||||||
|
|
@ -491,9 +488,11 @@ export function validateSiteContent(site) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
assert.equal(typeof docs.importFormat, "object", `library.toolDocs.${toolType}.importFormat must be an object`);
|
if (docs.importFormat) {
|
||||||
|
assertNonEmptyString({ importFormat: docs.importFormat }, "importFormat.title");
|
||||||
assertNonEmptyString({ importFormat: docs.importFormat }, "importFormat.text");
|
assertNonEmptyString({ importFormat: docs.importFormat }, "importFormat.text");
|
||||||
assertNonEmptyString({ importFormat: docs.importFormat }, "importFormat.example");
|
assertNonEmptyString({ importFormat: docs.importFormat }, "importFormat.example");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const aboutLimits = valueAt(site, "about.limits");
|
const aboutLimits = valueAt(site, "about.limits");
|
||||||
|
|
|
||||||
|
|
@ -116,8 +116,6 @@ test("toolbox module registry and modules expose expected behavior", async () =>
|
||||||
const imageAnnotationModule = await readFile("website/src/features/toolboxes/modules/ImageAnnotationModule.jsx", "utf8");
|
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 linksModule = await readFile("website/src/features/toolboxes/modules/LinksModule.jsx", "utf8");
|
||||||
const textImportModal = await readFile("website/src/features/toolboxes/modules/TextImportModal.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 countersModule = await readFile("website/src/features/toolboxes/modules/CountersModule.jsx", "utf8");
|
||||||
const textImport = await readFile("website/src/features/toolboxes/modules/textImport.js", "utf8");
|
const textImport = await readFile("website/src/features/toolboxes/modules/textImport.js", "utf8");
|
||||||
const imageViewer = await readFile("website/src/components/ImageViewer.jsx", "utf8");
|
const imageViewer = await readFile("website/src/components/ImageViewer.jsx", "utf8");
|
||||||
|
|
@ -135,7 +133,7 @@ test("toolbox module registry and modules expose expected behavior", async () =>
|
||||||
assert.match(moduleRegistry, /CombosModule/);
|
assert.match(moduleRegistry, /CombosModule/);
|
||||||
assert.match(moduleRegistry, /calculator:/);
|
assert.match(moduleRegistry, /calculator:/);
|
||||||
assert.match(moduleRegistry, /table:/);
|
assert.match(moduleRegistry, /table:/);
|
||||||
assert.match(moduleRegistry, /table: \{ label: "Tableau", icon: "table", Component: TableModule, editable: true, scrollable: true \}/);
|
assert.match(moduleRegistry, /table: \{ label: "Tableau", icon: "table", Component: TableModule, editable: false, scrollable: true \}/);
|
||||||
assert.match(moduleRegistry, /timer:/);
|
assert.match(moduleRegistry, /timer:/);
|
||||||
assert.match(moduleRegistry, /taskPlanner:/);
|
assert.match(moduleRegistry, /taskPlanner:/);
|
||||||
assert.match(moduleRegistry, /equipmentPlanner:/);
|
assert.match(moduleRegistry, /equipmentPlanner:/);
|
||||||
|
|
@ -226,7 +224,6 @@ test("toolbox module registry and modules expose expected behavior", async () =>
|
||||||
assert.match(tableModule, /ArrowRight/);
|
assert.match(tableModule, /ArrowRight/);
|
||||||
assert.match(tableModule, /function HeaderLabelInput/);
|
assert.match(tableModule, /function HeaderLabelInput/);
|
||||||
assert.match(tableModule, /useInlineEdit/);
|
assert.match(tableModule, /useInlineEdit/);
|
||||||
assert.match(tableModule, /TextExchangeActions/);
|
|
||||||
assert.match(tableModule, /event\.target\.select/);
|
assert.match(tableModule, /event\.target\.select/);
|
||||||
assert.match(tableFormulaEngine, /export function evaluateTableCell/);
|
assert.match(tableFormulaEngine, /export function evaluateTableCell/);
|
||||||
assert.doesNotMatch(tableFormulaEngine, /Function\(/);
|
assert.doesNotMatch(tableFormulaEngine, /Function\(/);
|
||||||
|
|
@ -238,7 +235,6 @@ test("toolbox module registry and modules expose expected behavior", async () =>
|
||||||
assert.match(timerModule, /useInlineEdit/);
|
assert.match(timerModule, /useInlineEdit/);
|
||||||
assert.match(timerModule, /useDraftForm/);
|
assert.match(timerModule, /useDraftForm/);
|
||||||
assert.match(timerModule, /normalizeTimerData/);
|
assert.match(timerModule, /normalizeTimerData/);
|
||||||
assert.match(timerModule, /TextExchangeActions/);
|
|
||||||
assert.match(taskPlannerModule, /export function TaskPlannerModule/);
|
assert.match(taskPlannerModule, /export function TaskPlannerModule/);
|
||||||
assert.match(taskPlannerModule, /normalizeTaskPlannerData/);
|
assert.match(taskPlannerModule, /normalizeTaskPlannerData/);
|
||||||
assert.match(taskPlannerModule, /applyDueResets/);
|
assert.match(taskPlannerModule, /applyDueResets/);
|
||||||
|
|
@ -251,7 +247,6 @@ test("toolbox module registry and modules expose expected behavior", async () =>
|
||||||
assert.match(taskPlannerModule, /useInlineEdit/);
|
assert.match(taskPlannerModule, /useInlineEdit/);
|
||||||
assert.match(taskPlannerModule, /useDraftForm/);
|
assert.match(taskPlannerModule, /useDraftForm/);
|
||||||
assert.match(taskPlannerModule, /commitOnEnter: false/);
|
assert.match(taskPlannerModule, /commitOnEnter: false/);
|
||||||
assert.match(taskPlannerModule, /TextExchangeActions/);
|
|
||||||
assert.match(taskPlannerModule, /reorderFeatures/);
|
assert.match(taskPlannerModule, /reorderFeatures/);
|
||||||
assert.doesNotMatch(taskPlannerModule, /canMoveItem:/);
|
assert.doesNotMatch(taskPlannerModule, /canMoveItem:/);
|
||||||
assert.doesNotMatch(taskPlannerModule, /canMoveGroup:/);
|
assert.doesNotMatch(taskPlannerModule, /canMoveGroup:/);
|
||||||
|
|
@ -262,14 +257,18 @@ test("toolbox module registry and modules expose expected behavior", async () =>
|
||||||
assert.match(equipmentPlannerModule, /socketLinks/);
|
assert.match(equipmentPlannerModule, /socketLinks/);
|
||||||
assert.match(equipmentPlannerModule, /useGroupedReorder/);
|
assert.match(equipmentPlannerModule, /useGroupedReorder/);
|
||||||
assert.match(equipmentPlannerModule, /reorderFeatures/);
|
assert.match(equipmentPlannerModule, /reorderFeatures/);
|
||||||
assert.match(equipmentPlannerModule, /TextExchangeActions/);
|
|
||||||
assert.match(checklistModule, /export function ChecklistModule/);
|
assert.match(checklistModule, /export function ChecklistModule/);
|
||||||
assert.match(checklistModule, /TextExchangeActions/);
|
assert.match(checklistModule, /TextImportModal/);
|
||||||
|
assert.match(checklistModule, /Icon name="import"/);
|
||||||
assert.match(checklistModule, /editing &&/);
|
assert.match(checklistModule, /editing &&/);
|
||||||
assert.match(checklistModule, /ChecklistItem/);
|
assert.match(checklistModule, /ChecklistItem/);
|
||||||
assert.match(checklistModule, /ChecklistSection/);
|
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-down/);
|
||||||
assert.match(checklistModule, /chevron-up/);
|
assert.match(checklistModule, /chevron-up/);
|
||||||
|
assert.match(checklistModule, /Number\.parseInt/);
|
||||||
assert.match(checklistModule, /checklist-qty-current/);
|
assert.match(checklistModule, /checklist-qty-current/);
|
||||||
assert.match(checklistModule, /checklist-delete-button danger/);
|
assert.match(checklistModule, /checklist-delete-button danger/);
|
||||||
assert.match(checklistModule, /useGroupedReorder/);
|
assert.match(checklistModule, /useGroupedReorder/);
|
||||||
|
|
@ -283,21 +282,12 @@ test("toolbox module registry and modules expose expected behavior", async () =>
|
||||||
assert.match(checklistModule, /moveChecklistItemToCategory/);
|
assert.match(checklistModule, /moveChecklistItemToCategory/);
|
||||||
assert.match(textImportModal, /createPortal/);
|
assert.match(textImportModal, /createPortal/);
|
||||||
assert.match(textImportModal, /lockBodyScroll/);
|
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, /export function ImagesModule/);
|
||||||
assert.match(imagesModule, /useGroupedReorder/);
|
assert.match(imagesModule, /useGroupedReorder/);
|
||||||
assert.match(imagesModule, /orientation: "horizontal"/);
|
assert.match(imagesModule, /orientation: "horizontal"/);
|
||||||
assert.match(imagesModule, /editing &&/);
|
assert.match(imagesModule, /editing &&/);
|
||||||
assert.match(imagesModule, /clipboardData/);
|
assert.match(imagesModule, /clipboardData/);
|
||||||
assert.match(imagesModule, /createImageAnnotationModule/);
|
assert.match(imagesModule, /createImageAnnotationModule/);
|
||||||
assert.match(imagesModule, /TextExchangeActions/);
|
|
||||||
assert.match(imagesModule, /image-annotate-button/);
|
assert.match(imagesModule, /image-annotate-button/);
|
||||||
assert.match(imagesModule, /dataTransfer\.files/);
|
assert.match(imagesModule, /dataTransfer\.files/);
|
||||||
assert.match(imageAnnotationModule, /export function ImageAnnotationModule/);
|
assert.match(imageAnnotationModule, /export function ImageAnnotationModule/);
|
||||||
|
|
@ -307,12 +297,14 @@ test("toolbox module registry and modules expose expected behavior", async () =>
|
||||||
assert.match(imageAnnotationModule, /context\.setImage/);
|
assert.match(imageAnnotationModule, /context\.setImage/);
|
||||||
assert.match(imageAnnotationModule, /createMarkerId/);
|
assert.match(imageAnnotationModule, /createMarkerId/);
|
||||||
assert.match(imageAnnotationModule, /<Icon name="zoom"/);
|
assert.match(imageAnnotationModule, /<Icon name="zoom"/);
|
||||||
assert.match(imageAnnotationModule, /TextExchangeActions/);
|
|
||||||
assert.match(linksModule, /export function LinksModule/);
|
assert.match(linksModule, /export function LinksModule/);
|
||||||
assert.match(linksModule, /TextExchangeActions/);
|
assert.match(linksModule, /TextImportModal/);
|
||||||
|
assert.match(linksModule, /Icon name="import"/);
|
||||||
assert.match(linksModule, /editing &&/);
|
assert.match(linksModule, /editing &&/);
|
||||||
assert.match(linksModule, /normalizeUrl/);
|
assert.match(linksModule, /normalizeUrl/);
|
||||||
assert.match(linksModule, /copyText/);
|
assert.match(linksModule, /copyText/);
|
||||||
|
assert.match(linksModule, /parseColonImportLines/);
|
||||||
|
assert.match(linksModule, /context\.normalizeUrl/);
|
||||||
assert.match(linksModule, /useGroupedReorder/);
|
assert.match(linksModule, /useGroupedReorder/);
|
||||||
assert.match(linksModule, /useDraftForm/);
|
assert.match(linksModule, /useDraftForm/);
|
||||||
assert.match(linksModule, /linkDraft\.getFieldProps/);
|
assert.match(linksModule, /linkDraft\.getFieldProps/);
|
||||||
|
|
@ -325,15 +317,11 @@ test("toolbox module registry and modules expose expected behavior", async () =>
|
||||||
assert.match(countersModule, /counterDraft\.getFieldProps/);
|
assert.match(countersModule, /counterDraft\.getFieldProps/);
|
||||||
assert.match(countersModule, /orientation: "horizontal"/);
|
assert.match(countersModule, /orientation: "horizontal"/);
|
||||||
assert.match(countersModule, /counter-drag-handle/);
|
assert.match(countersModule, /counter-drag-handle/);
|
||||||
assert.match(countersModule, /TextExchangeActions/);
|
|
||||||
assert.match(timerModule, /useGroupedReorder/);
|
assert.match(timerModule, /useGroupedReorder/);
|
||||||
assert.match(timerModule, /reorderEnabled=\{!data\.sortResults\}/);
|
assert.match(timerModule, /reorderEnabled=\{!data\.sortResults\}/);
|
||||||
assert.match(timerModule, /parentId=\{moduleId\}/);
|
assert.match(timerModule, /parentId=\{moduleId\}/);
|
||||||
assert.match(timerModule, /timer-drag-handle/);
|
assert.match(timerModule, /timer-drag-handle/);
|
||||||
assert.match(textImport, /export function parseColonImportLines/);
|
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(textImport, /line\.indexOf\(":\"\)/);
|
||||||
assert.match(imageViewer, /image-viewer-media/);
|
assert.match(imageViewer, /image-viewer-media/);
|
||||||
assert.match(imageViewer, /image-viewer-image-frame/);
|
assert.match(imageViewer, /image-viewer-image-frame/);
|
||||||
|
|
|
||||||
|
|
@ -2,29 +2,11 @@
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { evaluateTableCell } from "../website/src/features/toolboxes/modules/tableFormulaEngine.js";
|
import { evaluateTableCell } from "../website/src/features/toolboxes/modules/tableFormulaEngine.js";
|
||||||
import { exportModuleText, importModuleText, parseColonImportLines } from "../website/src/features/toolboxes/modules/textImport.js";
|
import { parseColonImportLines } from "../website/src/features/toolboxes/modules/textImport.js";
|
||||||
import { getTimePatternRecurrenceMs, getTimePatternTargetMs } from "../website/src/features/toolboxes/modules/timerUtils.js";
|
import { getTimePatternRecurrenceMs, getTimePatternTargetMs } from "../website/src/features/toolboxes/modules/timerUtils.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 { compactModuleDataForStorage, createToolboxExportPayload, normalizeChecklistData, normalizeCombosData, normalizeEquipmentPlannerData, normalizeNotepadData, normalizeTableData, normalizeTaskPlannerData, 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";
|
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", () => {
|
test("colon text import keeps urls intact after the first separator", () => {
|
||||||
assert.deepEqual(parseColonImportLines("Build:https://example.com/build?class=rogue\nPotion:10"), [
|
assert.deepEqual(parseColonImportLines("Build:https://example.com/build?class=rogue\nPotion:10"), [
|
||||||
{ label: "Build", value: "https://example.com/build?class=rogue" },
|
{ label: "Build", value: "https://example.com/build?class=rogue" },
|
||||||
|
|
@ -38,140 +20,6 @@ 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", () => {
|
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 now = new Date(2026, 0, 1, 13, 23, 45, 0).getTime();
|
||||||
const target = getTimePatternTargetMs("X:24:X", now);
|
const target = getTimePatternTargetMs("X:24:X", now);
|
||||||
|
|
|
||||||
|
|
@ -41,9 +41,6 @@
|
||||||
"toolsLabel": "Exemples d'outils disponibles",
|
"toolsLabel": "Exemples d'outils disponibles",
|
||||||
"categoryLabel": "Usage",
|
"categoryLabel": "Usage",
|
||||||
"featureHeading": "Fonctionnalités détaillées",
|
"featureHeading": "Fonctionnalités détaillées",
|
||||||
"advancedHeading": "Import par texte",
|
|
||||||
"copyExampleLabel": "Copier l'exemple",
|
|
||||||
"copyExampleSuccess": "Exemple copié.",
|
|
||||||
"controlLegendHeading": "Repères",
|
"controlLegendHeading": "Repères",
|
||||||
"categories": {
|
"categories": {
|
||||||
"notesTracking": "Notes & suivi",
|
"notesTracking": "Notes & suivi",
|
||||||
|
|
@ -189,11 +186,7 @@
|
||||||
"Les données restent dans la toolbox locale et ne nécessitent pas de compte."
|
"Les données restent dans la toolbox locale et ne nécessitent pas de compte."
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"importFormat": {
|
|
||||||
"text": "Le bloc notes conserve du texte riche et des dessins. Il n'utilise donc pas le format d'import/export texte des outils structurés.",
|
|
||||||
"example": "Le partage du bloc notes passe par l'export JSON de toolbox."
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"checklist": {
|
"checklist": {
|
||||||
"controls": [
|
"controls": [
|
||||||
|
|
@ -299,6 +292,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"importFormat": {
|
"importFormat": {
|
||||||
|
"title": "Import texte",
|
||||||
"text": "Une ligne commençant par # crée une catégorie. Une ligne item peut contenir une quantité avec le format Nom:quantité.",
|
"text": "Une ligne commençant par # crée une catégorie. Une ligne item peut contenir une quantité avec le format Nom:quantité.",
|
||||||
"example": "# Potions\nPotion:10\nMéga potion:5\n\n# Armures\nCasque Rey Dau:1"
|
"example": "# Potions\nPotion:10\nMéga potion:5\n\n# Armures\nCasque Rey Dau:1"
|
||||||
}
|
}
|
||||||
|
|
@ -360,11 +354,7 @@
|
||||||
"Réinitialiser remet la valeur à zéro sans supprimer le compteur; supprimer retire le compteur entier."
|
"Réinitialiser remet la valeur à zéro sans supprimer le compteur; supprimer retire le compteur entier."
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"importFormat": {
|
|
||||||
"text": "Un compteur par ligne. Le libellé est séparé de la valeur avec deux-points.",
|
|
||||||
"example": "Victoires: 12\nDéfaites: -2\nEssais boss: 34"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"images": {
|
"images": {
|
||||||
"controls": [
|
"controls": [
|
||||||
|
|
@ -425,11 +415,7 @@
|
||||||
"La galerie garde l'image originale pour conserver la référence source."
|
"La galerie garde l'image originale pour conserver la référence source."
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"importFormat": {
|
|
||||||
"text": "Chaque image est exportée comme un bloc avec un libellé optionnel puis une data URL base64 complète.",
|
|
||||||
"example": "# Map zone nord\ndata:image/webp;base64,UklGRiQAAABXRUJQVlA4...\n\n# Capture boss\ndata:image/png;base64,iVBORw0KGgoAAAANS..."
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"imageAnnotation": {
|
"imageAnnotation": {
|
||||||
"controls": [
|
"controls": [
|
||||||
|
|
@ -545,11 +531,7 @@
|
||||||
"La gomme, l'annulation et l'effacement permettent de corriger la carte pendant l'édition."
|
"La gomme, l'annulation et l'effacement permettent de corriger la carte pendant l'édition."
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"importFormat": {
|
|
||||||
"text": "Le format contient l'image en base64, puis les marqueurs et les dessins permanents.",
|
|
||||||
"example": "data:image/webp;base64,UklGRiQAAABXRUJQVlA4...\n@marker: 42.5,68,Entrée\n@drawings: {\"strokes\":[]}"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"links": {
|
"links": {
|
||||||
"controls": [
|
"controls": [
|
||||||
|
|
@ -594,6 +576,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"importFormat": {
|
"importFormat": {
|
||||||
|
"title": "Import texte",
|
||||||
"text": "Un lien par ligne. Séparez le titre et l'URL avec deux-points.",
|
"text": "Un lien par ligne. Séparez le titre et l'URL avec deux-points.",
|
||||||
"example": "Build:https://example.com/build?class=rogue\nMap:https://example.com/map"
|
"example": "Build:https://example.com/build?class=rogue\nMap:https://example.com/map"
|
||||||
}
|
}
|
||||||
|
|
@ -667,11 +650,7 @@
|
||||||
"Le calculateur sert donc à préparer des objectifs chiffrés avant de les suivre comme tâches."
|
"Le calculateur sert donc à préparer des objectifs chiffrés avant de les suivre comme tâches."
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"importFormat": {
|
|
||||||
"text": "Une ligne par résultat. L'indentation de deux espaces conserve les liens parent/enfant.",
|
|
||||||
"example": "@scrollResults: true\nLingots: 100\n Minerai: 500\nCristaux: 12"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"controls": [
|
"controls": [
|
||||||
|
|
@ -747,11 +726,7 @@
|
||||||
"Cette copie sert d'export rapide, sans modifier le stockage local de la toolbox."
|
"Cette copie sert d'export rapide, sans modifier le stockage local de la toolbox."
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"importFormat": {
|
|
||||||
"text": "Un TSV pur peut être importé. L'export TSV du panneau d'ajout ajoute des lignes @ pour préserver dimensions, intitulés et formules.",
|
|
||||||
"example": "@size: 2x3\n@columns: Objet\tValeur\tTotal\n@tsv\nOeuf d'or\t20000\t=B1*3\nOeuf d'argent\t5000\t=B2*2"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"timer": {
|
"timer": {
|
||||||
"controls": [
|
"controls": [
|
||||||
|
|
@ -878,11 +853,7 @@
|
||||||
"Alerte globale site peut prévenir depuis une autre page de Sokko G, avec garde-fou sur les répétitions trop fréquentes."
|
"Alerte globale site peut prévenir depuis une autre page de Sokko G, avec garde-fou sur les répétitions trop fréquentes."
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"importFormat": {
|
|
||||||
"text": "Les étapes et comptes à rebours sont exportés en lignes lisibles. Les échéances sont recalculées à l'import.",
|
|
||||||
"example": "@activeTab: countdown\nlap | label=Phase 1 | elapsedMs=90000\ncountdown | label=Boss | type=duration | durationMs=300000\ncountdown | label=Event | type=time_pattern | pattern=X:45:00"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"taskPlanner": {
|
"taskPlanner": {
|
||||||
"controls": [
|
"controls": [
|
||||||
|
|
@ -961,11 +932,7 @@
|
||||||
"Masquer les tâches terminées réduit la vue aux objectifs restants."
|
"Masquer les tâches terminées réduit la vue aux objectifs restants."
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"importFormat": {
|
|
||||||
"text": "Les catégories commencent par #. Les tâches utilisent leur type et l'indentation de deux espaces crée les sous-tâches. Les états cochés ne sont pas repris.",
|
|
||||||
"example": "@resetTime: 06:00\n# Raid\n- daily Préparer la route\n @description: Prendre consommables\n - unique Acheter potions\n @prerequisite: true\n- weekly Coffre hebdo"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"combos": {
|
"combos": {
|
||||||
"controls": [
|
"controls": [
|
||||||
|
|
@ -1078,11 +1045,7 @@
|
||||||
"Le champ touche personnalisée accepte une commande courte pour documenter une touche absente de la palette."
|
"Le champ touche personnalisée accepte une commande courte pour documenter une touche absente de la palette."
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"importFormat": {
|
|
||||||
"text": "Les catégories commencent par #. Un combo décrit son périphérique et ses étapes avec >, tandis que + groupe des inputs simultanés.",
|
|
||||||
"example": "@device: playstation\n# Neutral\nHadoken | device=playstation | direction:down > direction:right > button:cross\nCharge | button:square[2s] > button:triangle+button:circle"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"equipmentPlanner": {
|
"equipmentPlanner": {
|
||||||
"controls": [
|
"controls": [
|
||||||
|
|
@ -1143,11 +1106,7 @@
|
||||||
"Les totaux de sockets aident à vérifier rapidement ce qui est déjà prévu dans le build."
|
"Les totaux de sockets aident à vérifier rapidement ce qui est déjà prévu dans le build."
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"importFormat": {
|
|
||||||
"text": "Le format est découpé par type, équipement et sous-sections Stats, Sockets et Craft.",
|
|
||||||
"example": "# Armes | icon=sword\n## Épée runique | icon=sword | active=true\nStats\nDégâts | sword | Attaque | 12\nSockets\nJoyau attaque | shape=jewel | color=yellow\n bonus | Compétence | sword | Attaque | 1\nCraft\nMinerai rare: 4"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"summaryItems": [
|
"summaryItems": [
|
||||||
|
|
@ -1403,14 +1362,8 @@
|
||||||
"addButton": "Ajouter",
|
"addButton": "Ajouter",
|
||||||
"importPlaceholder": "Importer plusieurs items...\n# Potions\nPotion:10\nMéga potion:5\n\n# Armures\nCasque Rey Dau:1",
|
"importPlaceholder": "Importer plusieurs items...\n# Potions\nPotion:10\nMéga potion:5\n\n# Armures\nCasque Rey Dau:1",
|
||||||
"importOpenButton": "Importer du texte",
|
"importOpenButton": "Importer du texte",
|
||||||
"exportOpenButton": "Exporter en format texte",
|
|
||||||
"importModalTitle": "Importer une checklist",
|
"importModalTitle": "Importer une checklist",
|
||||||
"exportModalTitle": "Exporter une checklist",
|
|
||||||
"importButton": "Importer le texte",
|
"importButton": "Importer le texte",
|
||||||
"exportButton": "Copier le texte",
|
|
||||||
"importSuccess": "Import checklist terminé.",
|
|
||||||
"exportSuccess": "Export checklist copié.",
|
|
||||||
"importError": "Format de checklist invalide.",
|
|
||||||
"hideCompletedSectionsTitle": "Réduire les catégories terminées",
|
"hideCompletedSectionsTitle": "Réduire les catégories terminées",
|
||||||
"hideCompletedSectionsFullyTitle": "Cacher les catégories terminées",
|
"hideCompletedSectionsFullyTitle": "Cacher les catégories terminées",
|
||||||
"completedSectionsModeTitle": "Affichage des catégories terminées",
|
"completedSectionsModeTitle": "Affichage des catégories terminées",
|
||||||
|
|
@ -1431,17 +1384,6 @@
|
||||||
"deleteTitle": "Supprimer"
|
"deleteTitle": "Supprimer"
|
||||||
},
|
},
|
||||||
"images": {
|
"images": {
|
||||||
"importPlaceholder": "# Map zone nord\ndata:image/webp;base64,...",
|
|
||||||
"importOpenButton": "Importer du texte",
|
|
||||||
"exportOpenButton": "Exporter en format texte",
|
|
||||||
"importModalTitle": "Importer des images",
|
|
||||||
"exportModalTitle": "Exporter des images",
|
|
||||||
"importButton": "Importer le texte",
|
|
||||||
"exportButton": "Copier le texte",
|
|
||||||
"importSuccess": "Import d'images terminé.",
|
|
||||||
"exportSuccess": "Export d'images copié.",
|
|
||||||
"importError": "Format d'images invalide.",
|
|
||||||
"exportInfo": "Les images sont exportées en base64 complet. Le texte peut donc être très long.",
|
|
||||||
"addImages": "Ajouter des images",
|
"addImages": "Ajouter des images",
|
||||||
"pastePlaceholder": "Coller une image ici",
|
"pastePlaceholder": "Coller une image ici",
|
||||||
"pasteAriaLabel": "Coller une image depuis le presse-papiers",
|
"pasteAriaLabel": "Coller une image depuis le presse-papiers",
|
||||||
|
|
@ -1462,14 +1404,8 @@
|
||||||
"addButton": "Ajouter",
|
"addButton": "Ajouter",
|
||||||
"importPlaceholder": "Importer plusieurs liens...\nBuild:https://example.com/build?class=rogue\nMap:https://example.com/map",
|
"importPlaceholder": "Importer plusieurs liens...\nBuild:https://example.com/build?class=rogue\nMap:https://example.com/map",
|
||||||
"importOpenButton": "Importer du texte",
|
"importOpenButton": "Importer du texte",
|
||||||
"exportOpenButton": "Exporter en format texte",
|
|
||||||
"importModalTitle": "Importer des liens",
|
"importModalTitle": "Importer des liens",
|
||||||
"exportModalTitle": "Exporter des liens",
|
|
||||||
"importButton": "Importer le texte",
|
"importButton": "Importer le texte",
|
||||||
"exportButton": "Copier le texte",
|
|
||||||
"importSuccess": "Import de liens terminé.",
|
|
||||||
"exportSuccess": "Export de liens copié.",
|
|
||||||
"importError": "Format de liens invalide.",
|
|
||||||
"copyTitle": "Copier",
|
"copyTitle": "Copier",
|
||||||
"copiedTitle": "Copié",
|
"copiedTitle": "Copié",
|
||||||
"deleteTitle": "Supprimer"
|
"deleteTitle": "Supprimer"
|
||||||
|
|
@ -1477,32 +1413,12 @@
|
||||||
"counters": {
|
"counters": {
|
||||||
"labelPlaceholder": "Nom du compteur",
|
"labelPlaceholder": "Nom du compteur",
|
||||||
"addButton": "Ajouter",
|
"addButton": "Ajouter",
|
||||||
"importPlaceholder": "Victoire: 12\nDéfaite: -2",
|
|
||||||
"importOpenButton": "Importer du texte",
|
|
||||||
"exportOpenButton": "Exporter en format texte",
|
|
||||||
"importModalTitle": "Importer des compteurs",
|
|
||||||
"exportModalTitle": "Exporter des compteurs",
|
|
||||||
"importButton": "Importer le texte",
|
|
||||||
"exportButton": "Copier le texte",
|
|
||||||
"importSuccess": "Import de compteurs terminé.",
|
|
||||||
"exportSuccess": "Export de compteurs copié.",
|
|
||||||
"importError": "Format de compteurs invalide.",
|
|
||||||
"decrementLabel": "Décrémenter",
|
"decrementLabel": "Décrémenter",
|
||||||
"incrementLabel": "Incrémenter",
|
"incrementLabel": "Incrémenter",
|
||||||
"resetTitle": "Réinitialiser",
|
"resetTitle": "Réinitialiser",
|
||||||
"deleteTitle": "Supprimer"
|
"deleteTitle": "Supprimer"
|
||||||
},
|
},
|
||||||
"combos": {
|
"combos": {
|
||||||
"importPlaceholder": "@device: playstation\n# Neutral\nHadoken | device=playstation | direction:down > direction:right > button:cross",
|
|
||||||
"importOpenButton": "Importer du texte",
|
|
||||||
"exportOpenButton": "Exporter en format texte",
|
|
||||||
"importModalTitle": "Importer des combos",
|
|
||||||
"exportModalTitle": "Exporter des combos",
|
|
||||||
"importButton": "Importer le texte",
|
|
||||||
"exportButton": "Copier le texte",
|
|
||||||
"importSuccess": "Import de combos terminé.",
|
|
||||||
"exportSuccess": "Export de combos copié.",
|
|
||||||
"importError": "Format de combos invalide.",
|
|
||||||
"deviceLabel": "Périphérique",
|
"deviceLabel": "Périphérique",
|
||||||
"keyboardLayoutLabel": "Disposition clavier",
|
"keyboardLayoutLabel": "Disposition clavier",
|
||||||
"categoryPlaceholder": "Catégorie",
|
"categoryPlaceholder": "Catégorie",
|
||||||
|
|
@ -1536,16 +1452,6 @@
|
||||||
"deleteComboTitle": "Supprimer le combo"
|
"deleteComboTitle": "Supprimer le combo"
|
||||||
},
|
},
|
||||||
"calculator": {
|
"calculator": {
|
||||||
"importPlaceholder": "@scrollResults: true\nLingots de fer: 100\n Minerais de fer: 500",
|
|
||||||
"importOpenButton": "Importer du texte",
|
|
||||||
"exportOpenButton": "Exporter en format texte",
|
|
||||||
"importModalTitle": "Importer des résultats",
|
|
||||||
"exportModalTitle": "Exporter des résultats",
|
|
||||||
"importButton": "Importer le texte",
|
|
||||||
"exportButton": "Copier le texte",
|
|
||||||
"importSuccess": "Import calculateur terminé.",
|
|
||||||
"exportSuccess": "Export calculateur copié.",
|
|
||||||
"importError": "Format de calculateur invalide.",
|
|
||||||
"expressionLabel": "Calcul",
|
"expressionLabel": "Calcul",
|
||||||
"expressionPlaceholder": "10*10",
|
"expressionPlaceholder": "10*10",
|
||||||
"resultLabel": "Résultat",
|
"resultLabel": "Résultat",
|
||||||
|
|
@ -1564,16 +1470,6 @@
|
||||||
"deleteTitle": "Supprimer"
|
"deleteTitle": "Supprimer"
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"importPlaceholder": "Collez un TSV pur ou un TSV exporté par Sokko G.",
|
|
||||||
"importOpenButton": "Importer TSV",
|
|
||||||
"exportOpenButton": "Exporter TSV",
|
|
||||||
"importModalTitle": "Importer un tableau TSV",
|
|
||||||
"exportModalTitle": "Exporter un tableau TSV",
|
|
||||||
"importButton": "Importer le TSV",
|
|
||||||
"exportButton": "Copier le TSV",
|
|
||||||
"importSuccess": "Import TSV terminé.",
|
|
||||||
"exportSuccess": "Export TSV copié.",
|
|
||||||
"importError": "Format TSV invalide.",
|
|
||||||
"toolbarLabel": "Actions du tableau",
|
"toolbarLabel": "Actions du tableau",
|
||||||
"gridLabel": "Tableau",
|
"gridLabel": "Tableau",
|
||||||
"cellLabel": "Cellule",
|
"cellLabel": "Cellule",
|
||||||
|
|
@ -1584,7 +1480,7 @@
|
||||||
"removeRowTitle": "Supprimer la dernière ligne",
|
"removeRowTitle": "Supprimer la dernière ligne",
|
||||||
"addColumnTitle": "Ajouter une colonne",
|
"addColumnTitle": "Ajouter une colonne",
|
||||||
"removeColumnTitle": "Supprimer la dernière colonne",
|
"removeColumnTitle": "Supprimer la dernière colonne",
|
||||||
"copyTitle": "Copier les valeurs",
|
"copyTitle": "Copier en TSV",
|
||||||
"copiedTitle": "Tableau copié",
|
"copiedTitle": "Tableau copié",
|
||||||
"errorTitle": "Formule invalide",
|
"errorTitle": "Formule invalide",
|
||||||
"columnLabel": "Intitulé de colonne",
|
"columnLabel": "Intitulé de colonne",
|
||||||
|
|
@ -1593,16 +1489,6 @@
|
||||||
"rowFormulaTitle": "Référence conservée dans les formules"
|
"rowFormulaTitle": "Référence conservée dans les formules"
|
||||||
},
|
},
|
||||||
"timer": {
|
"timer": {
|
||||||
"importPlaceholder": "@activeTab: countdown\ncountdown | label=Boss | type=duration | durationMs=300000",
|
|
||||||
"importOpenButton": "Importer du texte",
|
|
||||||
"exportOpenButton": "Exporter en format texte",
|
|
||||||
"importModalTitle": "Importer un timer",
|
|
||||||
"exportModalTitle": "Exporter un timer",
|
|
||||||
"importButton": "Importer le texte",
|
|
||||||
"exportButton": "Copier le texte",
|
|
||||||
"importSuccess": "Import timer terminé.",
|
|
||||||
"exportSuccess": "Export timer copié.",
|
|
||||||
"importError": "Format de timer invalide.",
|
|
||||||
"stopwatchTab": "Chronomètre",
|
"stopwatchTab": "Chronomètre",
|
||||||
"countdownTab": "Compte à rebours",
|
"countdownTab": "Compte à rebours",
|
||||||
"stopwatchTitle": "Chronomètre",
|
"stopwatchTitle": "Chronomètre",
|
||||||
|
|
@ -1650,16 +1536,6 @@
|
||||||
"deleteTitle": "Supprimer"
|
"deleteTitle": "Supprimer"
|
||||||
},
|
},
|
||||||
"taskPlanner": {
|
"taskPlanner": {
|
||||||
"importPlaceholder": "@resetTime: 06:00\n# Raid\n- daily Préparer la route\n @description: Notes rapides\n - unique Sous-tâche",
|
|
||||||
"importOpenButton": "Importer du texte",
|
|
||||||
"exportOpenButton": "Exporter en format texte",
|
|
||||||
"importModalTitle": "Importer des tâches",
|
|
||||||
"exportModalTitle": "Exporter des tâches",
|
|
||||||
"importButton": "Importer le texte",
|
|
||||||
"exportButton": "Copier le texte",
|
|
||||||
"importSuccess": "Import de tâches terminé.",
|
|
||||||
"exportSuccess": "Export de tâches copié.",
|
|
||||||
"importError": "Format de tâches invalide.",
|
|
||||||
"settingsTitle": "Réglages du planificateur",
|
"settingsTitle": "Réglages du planificateur",
|
||||||
"settingsButtonLabel": "Paramètres des réinitialisations",
|
"settingsButtonLabel": "Paramètres des réinitialisations",
|
||||||
"hideCompletedTitle": "Masquer les tâches effectuées",
|
"hideCompletedTitle": "Masquer les tâches effectuées",
|
||||||
|
|
@ -1706,16 +1582,6 @@
|
||||||
"emptyTasks": "Aucune tâche planifiée."
|
"emptyTasks": "Aucune tâche planifiée."
|
||||||
},
|
},
|
||||||
"equipmentPlanner": {
|
"equipmentPlanner": {
|
||||||
"importPlaceholder": "# Équipements | icon=shield\n## Épée runique | icon=sword | active=true\nStats\nDégâts | sword | Attaque | 12\nCraft\nMinerai rare: 4",
|
|
||||||
"importOpenButton": "Importer du texte",
|
|
||||||
"exportOpenButton": "Exporter en format texte",
|
|
||||||
"importModalTitle": "Importer des équipements",
|
|
||||||
"exportModalTitle": "Exporter des équipements",
|
|
||||||
"importButton": "Importer le texte",
|
|
||||||
"exportButton": "Copier le texte",
|
|
||||||
"importSuccess": "Import d'équipements terminé.",
|
|
||||||
"exportSuccess": "Export d'équipements copié.",
|
|
||||||
"importError": "Format d'équipements invalide.",
|
|
||||||
"equipmentIconLabel": "Type",
|
"equipmentIconLabel": "Type",
|
||||||
"typePlaceholder": "Type d'équipement",
|
"typePlaceholder": "Type d'équipement",
|
||||||
"equipmentPlaceholder": "Nouvel équipement",
|
"equipmentPlaceholder": "Nouvel équipement",
|
||||||
|
|
@ -1785,17 +1651,6 @@
|
||||||
"deleteSocketLinkTitle": "Retirer le lien"
|
"deleteSocketLinkTitle": "Retirer le lien"
|
||||||
},
|
},
|
||||||
"imageAnnotation": {
|
"imageAnnotation": {
|
||||||
"importPlaceholder": "data:image/webp;base64,...\n@marker: 42.5,68,Entrée\n@drawings: {\"strokes\":[]}",
|
|
||||||
"importOpenButton": "Importer du texte",
|
|
||||||
"exportOpenButton": "Exporter en format texte",
|
|
||||||
"importModalTitle": "Importer une annotation",
|
|
||||||
"exportModalTitle": "Exporter une annotation",
|
|
||||||
"importButton": "Importer le texte",
|
|
||||||
"exportButton": "Copier le texte",
|
|
||||||
"importSuccess": "Import d'annotation terminé.",
|
|
||||||
"exportSuccess": "Export d'annotation copié.",
|
|
||||||
"importError": "Format d'annotation invalide.",
|
|
||||||
"exportInfo": "L'image annotée est exportée en base64 complet. Le texte peut donc être très long.",
|
|
||||||
"addImage": "Ajouter une image",
|
"addImage": "Ajouter une image",
|
||||||
"replaceImage": "Remplacer l'image",
|
"replaceImage": "Remplacer l'image",
|
||||||
"pastePlaceholder": "Coller une image ici",
|
"pastePlaceholder": "Coller une image ici",
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
|
||||||
<path fill="currentColor" d="M11 10h2v8h-2v-8Zm0-4h2v2h-2V6Zm1-4a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm0 18a8 8 0 1 1 0-16 8 8 0 0 1 0 16Z"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 209 B |
|
|
@ -1,9 +1,8 @@
|
||||||
// Rôle : fournit l'outil calculateur avec résultats enregistrés en arborescence et échange texte.
|
// Rôle : fournit l'outil calculateur avec résultats enregistrés en arborescence.
|
||||||
import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Icon } from "../../../components/Icon.jsx";
|
import { Icon } from "../../../components/Icon.jsx";
|
||||||
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
|
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
|
||||||
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
|
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
|
||||||
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
|
||||||
|
|
||||||
function calculateExpression(expression) {
|
function calculateExpression(expression) {
|
||||||
const normalized = String(expression || "").replaceAll(",", ".").trim();
|
const normalized = String(expression || "").replaceAll(",", ".").trim();
|
||||||
|
|
@ -35,7 +34,7 @@ function getEntriesInTreeOrder(entries, parentId = "") {
|
||||||
return getChildren(entries, parentId).flatMap((entry) => [entry, ...getEntriesInTreeOrder(entries, entry.id)]);
|
return getChildren(entries, parentId).flatMap((entry) => [entry, ...getEntriesInTreeOrder(entries, entry.id)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CalculatorModule({ toolboxId, moduleId, context, editing }) {
|
export function CalculatorModule({ toolboxId, moduleId, context }) {
|
||||||
const data = context.normalizeCalculatorData(context.getModuleData(toolboxId, moduleId, { entries: [] }));
|
const data = context.normalizeCalculatorData(context.getModuleData(toolboxId, moduleId, { entries: [] }));
|
||||||
const textContent = context.moduleText?.calculator || {};
|
const textContent = context.moduleText?.calculator || {};
|
||||||
const [expression, setExpression] = useState("");
|
const [expression, setExpression] = useState("");
|
||||||
|
|
@ -140,11 +139,6 @@ export function CalculatorModule({ toolboxId, moduleId, context, editing }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="calculator-module">
|
<div className="calculator-module">
|
||||||
{editing && (
|
|
||||||
<div className="module-add-panel calculator-exchange-panel">
|
|
||||||
<TextExchangeActions type="calculator" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "calculator")} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<form className="calculator-form" onSubmit={saveResult}>
|
<form className="calculator-form" onSubmit={saveResult}>
|
||||||
<div className="calculator-card" ref={calculatorCardRef}>
|
<div className="calculator-card" ref={calculatorCardRef}>
|
||||||
<label>
|
<label>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
// Rôle : fournit l'outil checklist avec quantités, catégories et échange texte.
|
// Rôle : fournit l'outil checklist avec quantités, catégories et imports texte.
|
||||||
import { useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { Icon } from "../../../components/Icon.jsx";
|
import { Icon } from "../../../components/Icon.jsx";
|
||||||
import { useGroupedReorder, moveItem } from "../../../hooks/useGroupedReorder.js";
|
import { useGroupedReorder, moveItem } from "../../../hooks/useGroupedReorder.js";
|
||||||
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
|
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
|
||||||
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
import { TextImportModal } from "./TextImportModal.jsx";
|
||||||
|
import { parseColonImportLines } from "./textImport.js";
|
||||||
|
|
||||||
export function ChecklistModule({ toolboxId, moduleId, context, editing }) {
|
export function ChecklistModule({ toolboxId, moduleId, context, editing }) {
|
||||||
const data = context.normalizeChecklistData(context.getModuleData(toolboxId, moduleId, { items: [] }));
|
const data = context.normalizeChecklistData(context.getModuleData(toolboxId, moduleId, { items: [] }));
|
||||||
|
|
@ -11,6 +12,9 @@ export function ChecklistModule({ toolboxId, moduleId, context, editing }) {
|
||||||
const [label, setLabel] = useState("");
|
const [label, setLabel] = useState("");
|
||||||
const [sectionTitle, setSectionTitle] = useState("");
|
const [sectionTitle, setSectionTitle] = useState("");
|
||||||
const [qty, setQty] = useState(1);
|
const [qty, setQty] = useState(1);
|
||||||
|
const [textImport, setTextImport] = useState("");
|
||||||
|
const [importOpen, setImportOpen] = useState(false);
|
||||||
|
const closeImportModal = useCallback(() => setImportOpen(false), []);
|
||||||
const namedSections = data.sections.filter((section) => section.title);
|
const namedSections = data.sections.filter((section) => section.title);
|
||||||
const completedSectionsMode = data.hideCompletedSectionsFully ? "hidden" : data.hideCompletedSections ? "reduced" : "visible";
|
const completedSectionsMode = data.hideCompletedSectionsFully ? "hidden" : data.hideCompletedSections ? "reduced" : "visible";
|
||||||
const reorderItems = getChecklistReorderItems(data.sections);
|
const reorderItems = getChecklistReorderItems(data.sections);
|
||||||
|
|
@ -80,6 +84,17 @@ export function ChecklistModule({ toolboxId, moduleId, context, editing }) {
|
||||||
setQty(1);
|
setQty(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function importItems(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const importedSections = parseChecklistImport(textImport, context);
|
||||||
|
if (!importedSections.length) return;
|
||||||
|
const nextSections = importedSections.reduce((sections, section) => appendItems(sections, section.title, section.items), data.sections);
|
||||||
|
save(nextSections);
|
||||||
|
setTextImport("");
|
||||||
|
setImportOpen(false);
|
||||||
|
context.notify?.("Import checklist terminé.");
|
||||||
|
}
|
||||||
|
|
||||||
function setCompletedSectionsMode(mode) {
|
function setCompletedSectionsMode(mode) {
|
||||||
save(data.sections.map((section) => ({ ...section, hideWhenComplete: undefined })), {
|
save(data.sections.map((section) => ({ ...section, hideWhenComplete: undefined })), {
|
||||||
hideCompletedSections: mode === "reduced",
|
hideCompletedSections: mode === "reduced",
|
||||||
|
|
@ -117,8 +132,24 @@ export function ChecklistModule({ toolboxId, moduleId, context, editing }) {
|
||||||
<input className="checklist-qty-input" name="qty" type="number" min="1" value={qty} onChange={(event) => setQty(event.target.value)} aria-label={textContent.quantityLabel || "Quantité cible"} />
|
<input className="checklist-qty-input" name="qty" type="number" min="1" value={qty} onChange={(event) => setQty(event.target.value)} aria-label={textContent.quantityLabel || "Quantité cible"} />
|
||||||
<button className="primary">{textContent.addButton || "Ajouter"}</button>
|
<button className="primary">{textContent.addButton || "Ajouter"}</button>
|
||||||
</form>
|
</form>
|
||||||
<TextExchangeActions type="checklist" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "checklist")} />
|
<div className="text-import-actions">
|
||||||
|
<button type="button" onClick={() => setImportOpen(true)}>
|
||||||
|
<Icon name="import" />
|
||||||
|
<span>{textContent.importOpenButton || "Importer du texte"}</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{importOpen && (
|
||||||
|
<TextImportModal
|
||||||
|
title={textContent.importModalTitle || "Importer une checklist"}
|
||||||
|
value={textImport}
|
||||||
|
onChange={setTextImport}
|
||||||
|
placeholder={textContent.importPlaceholder || "Importer plusieurs items...\nPotion:10\nMéga potion:5"}
|
||||||
|
submitLabel={textContent.importButton || "Importer le texte"}
|
||||||
|
onSubmit={importItems}
|
||||||
|
onClose={closeImportModal}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{namedSections.length > 0 && (
|
{namedSections.length > 0 && (
|
||||||
<div className="checklist-toolbar" role="radiogroup" aria-label={textContent.completedSectionsModeTitle || "Affichage des catégories terminées"}>
|
<div className="checklist-toolbar" role="radiogroup" aria-label={textContent.completedSectionsModeTitle || "Affichage des catégories terminées"}>
|
||||||
|
|
@ -180,6 +211,32 @@ export function ChecklistModule({ toolboxId, moduleId, context, editing }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseChecklistImport(text, context) {
|
||||||
|
const sections = [];
|
||||||
|
let currentSection = { id: context.uid("section"), title: "", items: [] };
|
||||||
|
|
||||||
|
String(text || "").split(/\r?\n/).forEach((line) => {
|
||||||
|
const cleanLine = line.trim();
|
||||||
|
if (!cleanLine) return;
|
||||||
|
if (cleanLine.startsWith("#")) {
|
||||||
|
if (currentSection.items.length || currentSection.title) sections.push(currentSection);
|
||||||
|
currentSection = { id: context.uid("section"), title: cleanLine.replace(/^#+/, "").trim(), items: [] };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
parseColonImportLines(cleanLine).forEach((entry) => {
|
||||||
|
currentSection.items.push({
|
||||||
|
id: context.uid("item"),
|
||||||
|
label: entry.label,
|
||||||
|
qtyTarget: Math.max(1, Number.parseInt(entry.value, 10) || 1),
|
||||||
|
qtyCurrent: 0
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (currentSection.items.length || currentSection.title) sections.push(currentSection);
|
||||||
|
return sections.filter((section) => section.items.length);
|
||||||
|
}
|
||||||
|
|
||||||
function isSectionComplete(section, context) {
|
function isSectionComplete(section, context) {
|
||||||
return section.items.length > 0 && section.items.every((item) => context.clampQty(item.qtyCurrent, item.qtyTarget) >= item.qtyTarget);
|
return section.items.length > 0 && section.items.every((item) => context.clampQty(item.qtyCurrent, item.qtyTarget) >= item.qtyTarget);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// Rôle : fournit l'outil Combos avec palettes d'inputs, rendu visuel et échange texte.
|
// Rôle : fournit l'outil Combos avec palettes d'inputs et rendu visuel par périphérique.
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Icon } from "../../../components/Icon.jsx";
|
import { Icon } from "../../../components/Icon.jsx";
|
||||||
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
|
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
|
||||||
|
|
@ -7,7 +7,6 @@ import {
|
||||||
getGroupedEntries,
|
getGroupedEntries,
|
||||||
useGroupedReorder
|
useGroupedReorder
|
||||||
} from "../../../hooks/useGroupedReorder.js";
|
} from "../../../hooks/useGroupedReorder.js";
|
||||||
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
|
||||||
|
|
||||||
const DEVICE_OPTIONS = [
|
const DEVICE_OPTIONS = [
|
||||||
{ value: "playstation", label: "PlayStation" },
|
{ value: "playstation", label: "PlayStation" },
|
||||||
|
|
@ -750,9 +749,6 @@ export function CombosModule({ toolboxId, moduleId, context, editing }) {
|
||||||
<div className="combos-module">
|
<div className="combos-module">
|
||||||
{(editing || editingComboId) && (
|
{(editing || editingComboId) && (
|
||||||
<div className="module-add-panel combos-editor">
|
<div className="module-add-panel combos-editor">
|
||||||
{editing && !editingComboId && (
|
|
||||||
<TextExchangeActions type="combos" data={data} context={context} textContent={textContent} onImport={(nextData) => save(nextData)} />
|
|
||||||
)}
|
|
||||||
<div className="combos-device-row">
|
<div className="combos-device-row">
|
||||||
<label>
|
<label>
|
||||||
<span>{textContent.deviceLabel || "Périphérique"}</span>
|
<span>{textContent.deviceLabel || "Périphérique"}</span>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
// Rôle : fournit l'outil compteurs personnalisables avec échange texte.
|
// Rôle : fournit l'outil compteurs personnalisables.
|
||||||
import { Icon } from "../../../components/Icon.jsx";
|
import { Icon } from "../../../components/Icon.jsx";
|
||||||
import { useDraftForm } from "../../../hooks/useDraftForm.js";
|
import { useDraftForm } from "../../../hooks/useDraftForm.js";
|
||||||
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
|
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
|
||||||
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
|
||||||
|
|
||||||
export function CountersModule({ toolboxId, moduleId, context, editing }) {
|
export function CountersModule({ toolboxId, moduleId, context, editing }) {
|
||||||
const data = context.normalizeCountersData(context.getModuleData(toolboxId, moduleId, { counters: [] }));
|
const data = context.normalizeCountersData(context.getModuleData(toolboxId, moduleId, { counters: [] }));
|
||||||
|
|
@ -45,13 +44,10 @@ export function CountersModule({ toolboxId, moduleId, context, editing }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{editing && (
|
{editing && (
|
||||||
<div className="module-add-panel">
|
<form className="inline-form counters-add-form module-add-panel" onSubmit={addCounter}>
|
||||||
<form className="inline-form counters-add-form" onSubmit={addCounter}>
|
|
||||||
<input {...counterDraft.getFieldProps("label", { placeholder: textContent.labelPlaceholder || "Nom du compteur" })} />
|
<input {...counterDraft.getFieldProps("label", { placeholder: textContent.labelPlaceholder || "Nom du compteur" })} />
|
||||||
<button className="primary">{textContent.addButton || "Ajouter"}</button>
|
<button className="primary">{textContent.addButton || "Ajouter"}</button>
|
||||||
</form>
|
</form>
|
||||||
<TextExchangeActions type="counters" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "counters")} />
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
<div className="counters-grid">
|
<div className="counters-grid">
|
||||||
{data.counters.map((counter) => {
|
{data.counters.map((counter) => {
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
// Rôle : fournit l'outil de planification d'équipements, sertissages, résumé et échange texte.
|
// Rôle : fournit l'outil de planification d'équipements, sertissages et résumé de bonus.
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { Icon } from "../../../components/Icon.jsx";
|
import { Icon } from "../../../components/Icon.jsx";
|
||||||
import { Tooltip } from "../../../components/Tooltip.jsx";
|
import { Tooltip } from "../../../components/Tooltip.jsx";
|
||||||
import { applyGroupedReorderOperation, moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
|
import { applyGroupedReorderOperation, moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
|
||||||
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
|
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
|
||||||
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
|
||||||
|
|
||||||
const SOCKET_SHAPES = ["ball", "jewel"];
|
const SOCKET_SHAPES = ["ball", "jewel"];
|
||||||
const SOCKET_COLORS = ["red", "orange", "amber", "yellow", "lime", "green", "teal", "cyan", "blue", "indigo", "violet", "purple", "pink", "rose", "white", "gray", "black"];
|
const SOCKET_COLORS = ["red", "orange", "amber", "yellow", "lime", "green", "teal", "cyan", "blue", "indigo", "violet", "purple", "pink", "rose", "white", "gray", "black"];
|
||||||
|
|
@ -193,7 +192,6 @@ export function EquipmentPlannerModule({ toolboxId, moduleId, context, editing }
|
||||||
<input value={equipmentName} onChange={(event) => setEquipmentName(event.target.value)} placeholder={textContent.equipmentPlaceholder || "Nouvel équipement"} />
|
<input value={equipmentName} onChange={(event) => setEquipmentName(event.target.value)} placeholder={textContent.equipmentPlaceholder || "Nouvel équipement"} />
|
||||||
<button className="primary">{textContent.addEquipmentButton || "Ajouter"}</button>
|
<button className="primary">{textContent.addEquipmentButton || "Ajouter"}</button>
|
||||||
</form>
|
</form>
|
||||||
<TextExchangeActions type="equipmentPlanner" data={data} context={context} textContent={textContent} onImport={(nextData) => save(nextData)} />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
// Rôle : fournit l'outil annotation d'images avec marqueurs éditables et échange texte.
|
// Rôle : fournit l'outil annotation d'images avec marqueurs éditables.
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { Icon } from "../../../components/Icon.jsx";
|
import { Icon } from "../../../components/Icon.jsx";
|
||||||
import { Tooltip } from "../../../components/Tooltip.jsx";
|
import { Tooltip } from "../../../components/Tooltip.jsx";
|
||||||
import { DrawingOverlay } from "./DrawingOverlay.jsx";
|
import { DrawingOverlay } from "./DrawingOverlay.jsx";
|
||||||
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
|
||||||
|
|
||||||
function markerLabel(index, marker, textContent) {
|
function markerLabel(index, marker, textContent) {
|
||||||
return marker.label || `${textContent.markerPrefix || "Marqueur"} ${index + 1}`;
|
return marker.label || `${textContent.markerPrefix || "Marqueur"} ${index + 1}`;
|
||||||
|
|
@ -105,10 +104,6 @@ export function ImageAnnotationModule({ toolboxId, moduleId, context, editing })
|
||||||
>
|
>
|
||||||
{pastePlaceholder}
|
{pastePlaceholder}
|
||||||
</div>
|
</div>
|
||||||
<TextExchangeActions type="imageAnnotation" data={data} context={context} textContent={textContent} onImport={(nextData) => {
|
|
||||||
save(nextData);
|
|
||||||
setTemporaryDrawings({ strokes: [] });
|
|
||||||
}} />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
// Rôle : fournit l'outil images avec import fichier/texte, collage, libellés et annotation rapide.
|
// Rôle : fournit l'outil images avec import, collage, libellés et annotation rapide.
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Icon } from "../../../components/Icon.jsx";
|
import { Icon } from "../../../components/Icon.jsx";
|
||||||
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
|
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
|
||||||
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
|
||||||
|
|
||||||
export function ImagesModule({ toolboxId, moduleId, context, editing }) {
|
export function ImagesModule({ toolboxId, moduleId, context, editing }) {
|
||||||
const data = context.getModuleData(toolboxId, moduleId, { images: [] });
|
const data = context.getModuleData(toolboxId, moduleId, { images: [] });
|
||||||
|
|
@ -86,7 +85,6 @@ export function ImagesModule({ toolboxId, moduleId, context, editing }) {
|
||||||
>
|
>
|
||||||
{pastePlaceholder}
|
{pastePlaceholder}
|
||||||
</div>
|
</div>
|
||||||
<TextExchangeActions type="images" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "images")} />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="images">
|
<div className="images">
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,19 @@
|
||||||
// Rôle : fournit l'outil liens avec ajout manuel et échange texte.
|
// Rôle : fournit l'outil liens avec ajout manuel et import texte.
|
||||||
import { useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { Icon } from "../../../components/Icon.jsx";
|
import { Icon } from "../../../components/Icon.jsx";
|
||||||
import { useDraftForm } from "../../../hooks/useDraftForm.js";
|
import { useDraftForm } from "../../../hooks/useDraftForm.js";
|
||||||
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
|
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
|
||||||
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
import { TextImportModal } from "./TextImportModal.jsx";
|
||||||
|
import { parseColonImportLines } from "./textImport.js";
|
||||||
|
|
||||||
export function LinksModule({ toolboxId, moduleId, context, editing }) {
|
export function LinksModule({ toolboxId, moduleId, context, editing }) {
|
||||||
const data = context.normalizeLinksData(context.getModuleData(toolboxId, moduleId, { links: [] }));
|
const data = context.normalizeLinksData(context.getModuleData(toolboxId, moduleId, { links: [] }));
|
||||||
const textContent = context.moduleText?.links || {};
|
const textContent = context.moduleText?.links || {};
|
||||||
const linkDraft = useDraftForm({ title: "", url: "" });
|
const linkDraft = useDraftForm({ title: "", url: "" });
|
||||||
|
const [textImport, setTextImport] = useState("");
|
||||||
|
const [importOpen, setImportOpen] = useState(false);
|
||||||
const [copiedId, setCopiedId] = useState("");
|
const [copiedId, setCopiedId] = useState("");
|
||||||
|
const closeImportModal = useCallback(() => setImportOpen(false), []);
|
||||||
const {
|
const {
|
||||||
itemReorder,
|
itemReorder,
|
||||||
getItemProps,
|
getItemProps,
|
||||||
|
|
@ -46,6 +50,22 @@ export function LinksModule({ toolboxId, moduleId, context, editing }) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function importLinks(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const imported = parseColonImportLines(textImport)
|
||||||
|
.map((entry) => ({
|
||||||
|
id: context.uid("link"),
|
||||||
|
title: entry.label,
|
||||||
|
url: context.normalizeUrl(entry.value)
|
||||||
|
}))
|
||||||
|
.filter((link) => link.url);
|
||||||
|
if (!imported.length) return;
|
||||||
|
save([...data.links, ...imported]);
|
||||||
|
setTextImport("");
|
||||||
|
setImportOpen(false);
|
||||||
|
context.notify?.("Import de liens terminé.");
|
||||||
|
}
|
||||||
|
|
||||||
async function copyUrl(link) {
|
async function copyUrl(link) {
|
||||||
const copied = await context.copyText(link.url);
|
const copied = await context.copyText(link.url);
|
||||||
if (!copied) return;
|
if (!copied) return;
|
||||||
|
|
@ -63,8 +83,24 @@ export function LinksModule({ toolboxId, moduleId, context, editing }) {
|
||||||
<input {...linkDraft.getFieldProps("url", { placeholder: textContent.urlPlaceholder || "https://..." })} />
|
<input {...linkDraft.getFieldProps("url", { placeholder: textContent.urlPlaceholder || "https://..." })} />
|
||||||
<button className="primary">{textContent.addButton || "Ajouter"}</button>
|
<button className="primary">{textContent.addButton || "Ajouter"}</button>
|
||||||
</form>
|
</form>
|
||||||
<TextExchangeActions type="links" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "links")} />
|
<div className="text-import-actions">
|
||||||
|
<button type="button" onClick={() => setImportOpen(true)}>
|
||||||
|
<Icon name="import" />
|
||||||
|
<span>{textContent.importOpenButton || "Importer du texte"}</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{importOpen && (
|
||||||
|
<TextImportModal
|
||||||
|
title={textContent.importModalTitle || "Importer des liens"}
|
||||||
|
value={textImport}
|
||||||
|
onChange={setTextImport}
|
||||||
|
placeholder={textContent.importPlaceholder || "Importer plusieurs liens...\nBuild:https://example.com/build?class=rogue\nMap:https://example.com/map"}
|
||||||
|
submitLabel={textContent.importButton || "Importer le texte"}
|
||||||
|
onSubmit={importLinks}
|
||||||
|
onClose={closeImportModal}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<ul className="links-list">
|
<ul className="links-list">
|
||||||
{data.links.map((link) => {
|
{data.links.map((link) => {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
// Rôle : fournit l'outil tableau avec cellules libres, formules simples et échange TSV.
|
// Rôle : fournit l'outil tableau avec cellules libres et formules simples.
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Icon } from "../../../components/Icon.jsx";
|
import { Icon } from "../../../components/Icon.jsx";
|
||||||
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
|
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
|
||||||
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
|
||||||
import { cellAddress, columnIndexToName, evaluateTable, parseCellAddress } from "./tableFormulaEngine.js";
|
import { cellAddress, columnIndexToName, evaluateTable, parseCellAddress } from "./tableFormulaEngine.js";
|
||||||
|
|
||||||
const DEFAULT_ROWS = 10;
|
const DEFAULT_ROWS = 10;
|
||||||
|
|
@ -102,7 +101,7 @@ function getFormulaRangeText(selection) {
|
||||||
return addresses.length === 1 ? addresses[0] : `(${addresses.join("+")})`;
|
return addresses.length === 1 ? addresses[0] : `(${addresses.join("+")})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TableModule({ toolboxId, moduleId, context, editing }) {
|
export function TableModule({ toolboxId, moduleId, context }) {
|
||||||
const data = context.normalizeTableData(context.getModuleData(toolboxId, moduleId, { rows: DEFAULT_ROWS, columns: DEFAULT_COLUMNS, cells: {} }));
|
const data = context.normalizeTableData(context.getModuleData(toolboxId, moduleId, { rows: DEFAULT_ROWS, columns: DEFAULT_COLUMNS, cells: {} }));
|
||||||
const textContent = context.moduleText?.table || {};
|
const textContent = context.moduleText?.table || {};
|
||||||
const [editingCell, setEditingCell] = useState("");
|
const [editingCell, setEditingCell] = useState("");
|
||||||
|
|
@ -415,11 +414,6 @@ export function TableModule({ toolboxId, moduleId, context, editing }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="table-module">
|
<div className="table-module">
|
||||||
{editing && (
|
|
||||||
<div className="module-add-panel table-exchange-panel">
|
|
||||||
<TextExchangeActions type="table" data={data} context={context} textContent={textContent} exportLabel="Exporter TSV" onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "table")} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="notepad-toolbar table-toolbar" aria-label={textContent.toolbarLabel || "Actions du tableau"}>
|
<div className="notepad-toolbar table-toolbar" aria-label={textContent.toolbarLabel || "Actions du tableau"}>
|
||||||
<div className="notepad-format-controls table-controls">
|
<div className="notepad-format-controls table-controls">
|
||||||
<div className="notepad-toolbar-group" role="group" aria-label={textContent.rowActionsLabel || "Lignes"}>
|
<div className="notepad-toolbar-group" role="group" aria-label={textContent.rowActionsLabel || "Lignes"}>
|
||||||
|
|
@ -475,11 +469,10 @@ export function TableModule({ toolboxId, moduleId, context, editing }) {
|
||||||
className="notepad-toolbar-button table-copy-button"
|
className="notepad-toolbar-button table-copy-button"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={copyTsv}
|
onClick={copyTsv}
|
||||||
aria-label={textContent.copyTitle || "Copier les valeurs"}
|
aria-label={textContent.copyTitle || "Copier en TSV"}
|
||||||
title={textContent.copyTitle || "Copier les valeurs"}
|
title={textContent.copyTitle || "Copier en TSV"}
|
||||||
>
|
>
|
||||||
<Icon name="copy" />
|
<Icon name="copy" />
|
||||||
<span>{textContent.copyTitle || "Copier les valeurs"}</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// Rôle : fournit l'outil task planner avec tâches récurrentes, parents, pré requis et échange texte.
|
// Rôle : fournit l'outil task planner avec tâches récurrentes, parents et pré requis.
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Icon } from "../../../components/Icon.jsx";
|
import { Icon } from "../../../components/Icon.jsx";
|
||||||
import { InlineNotice } from "../../../components/AppOverlays.jsx";
|
import { InlineNotice } from "../../../components/AppOverlays.jsx";
|
||||||
|
|
@ -6,7 +6,6 @@ import { Tooltip } from "../../../components/Tooltip.jsx";
|
||||||
import { useDraftForm } from "../../../hooks/useDraftForm.js";
|
import { useDraftForm } from "../../../hooks/useDraftForm.js";
|
||||||
import { applyGroupedReorderOperation, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
|
import { applyGroupedReorderOperation, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
|
||||||
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
|
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
|
||||||
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
|
||||||
|
|
||||||
const TASK_TYPES = ["daily", "weekly", "unique"];
|
const TASK_TYPES = ["daily", "weekly", "unique"];
|
||||||
const WEEK_DAYS = [
|
const WEEK_DAYS = [
|
||||||
|
|
@ -510,7 +509,6 @@ export function TaskPlannerModule({ toolboxId, moduleId, context, editing }) {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{editing && (
|
{editing && (
|
||||||
<div className="task-planner-add-stack">
|
|
||||||
<form className="inline-form task-planner-add-form" onSubmit={addTask}>
|
<form className="inline-form task-planner-add-form" onSubmit={addTask}>
|
||||||
<input {...taskDraft.getFieldProps("title", { placeholder: textContent.titlePlaceholder || "Nouvelle tâche" })} />
|
<input {...taskDraft.getFieldProps("title", { placeholder: textContent.titlePlaceholder || "Nouvelle tâche" })} />
|
||||||
<select {...taskDraft.getFieldProps("type", { "aria-label": textContent.typeLabel || "Type" })}>
|
<select {...taskDraft.getFieldProps("type", { "aria-label": textContent.typeLabel || "Type" })}>
|
||||||
|
|
@ -518,8 +516,6 @@ export function TaskPlannerModule({ toolboxId, moduleId, context, editing }) {
|
||||||
</select>
|
</select>
|
||||||
<button className="primary">{textContent.addButton || "Ajouter"}</button>
|
<button className="primary">{textContent.addButton || "Ajouter"}</button>
|
||||||
</form>
|
</form>
|
||||||
<TextExchangeActions type="taskPlanner" data={data} context={context} textContent={textContent} onImport={(nextData) => save(nextData)} />
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
// Rôle : fournit les boutons et modales d'import/export texte des outils compatibles.
|
|
||||||
import { Icon } from "../../../components/Icon.jsx";
|
|
||||||
import { TextImportModal } from "./TextImportModal.jsx";
|
|
||||||
import { useTextExchange } from "./useTextExchange.js";
|
|
||||||
|
|
||||||
export function TextExchangeActions({ type, data, context, onImport, textContent = {}, exportLabel = "" }) {
|
|
||||||
const exchange = useTextExchange({ type, data, context, onImport, textContent });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="text-import-actions">
|
|
||||||
<button type="button" onClick={exchange.openImport}>
|
|
||||||
<Icon name="import" />
|
|
||||||
<span>{textContent.importOpenButton || "Importer du texte"}</span>
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={exchange.openExport} disabled={!exchange.canExport}>
|
|
||||||
<Icon name="export" />
|
|
||||||
<span>{textContent.exportOpenButton || exportLabel || "Exporter en format texte"}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{exchange.mode === "import" && (
|
|
||||||
<TextImportModal
|
|
||||||
title={textContent.importModalTitle || "Importer du texte"}
|
|
||||||
value={exchange.draft}
|
|
||||||
onChange={exchange.updateDraft}
|
|
||||||
error={exchange.error}
|
|
||||||
placeholder={textContent.importPlaceholder || "Collez le contenu à importer..."}
|
|
||||||
submitLabel={textContent.importButton || "Importer le texte"}
|
|
||||||
onSubmit={exchange.submitImport}
|
|
||||||
onClose={exchange.closeModal}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{exchange.mode === "export" && (
|
|
||||||
<TextImportModal
|
|
||||||
title={textContent.exportModalTitle || "Exporter en format texte"}
|
|
||||||
value={exchange.exportText}
|
|
||||||
readOnly
|
|
||||||
info={textContent.exportInfo || ""}
|
|
||||||
submitLabel={textContent.exportButton || "Copier le texte"}
|
|
||||||
onCopy={exchange.copyExport}
|
|
||||||
onClose={exchange.closeModal}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
// Rôle : affiche la modale d'import/export texte partagée par les outils compatibles.
|
// Rôle : affiche la modale d'import texte partagée par les outils compatibles.
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { Icon } from "../../../components/Icon.jsx";
|
import { Icon } from "../../../components/Icon.jsx";
|
||||||
import { lockBodyScroll } from "../../../utils/bodyScrollLock.js";
|
import { lockBodyScroll } from "../../../utils/bodyScrollLock.js";
|
||||||
|
|
||||||
export function TextImportModal({ title, value, placeholder, submitLabel, readOnly = false, error = "", info = "", onChange, onSubmit, onCopy, onClose }) {
|
export function TextImportModal({ title, value, placeholder, submitLabel, onChange, onSubmit, onClose }) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleKeyDown(event) {
|
function handleKeyDown(event) {
|
||||||
if (event.key === "Escape") onClose();
|
if (event.key === "Escape") onClose();
|
||||||
|
|
@ -31,34 +31,14 @@ export function TextImportModal({ title, value, placeholder, submitLabel, readOn
|
||||||
<form className="text-import-modal-form" onSubmit={onSubmit}>
|
<form className="text-import-modal-form" onSubmit={onSubmit}>
|
||||||
<textarea
|
<textarea
|
||||||
autoFocus
|
autoFocus
|
||||||
className={error ? "has-error" : ""}
|
|
||||||
readOnly={readOnly}
|
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(event) => onChange?.(event.target.value)}
|
onChange={(event) => onChange(event.target.value)}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
rows={9}
|
rows={9}
|
||||||
aria-invalid={Boolean(error)}
|
|
||||||
aria-describedby={error ? "text-import-error" : undefined}
|
|
||||||
/>
|
/>
|
||||||
{info && (
|
|
||||||
<p className="text-import-info">
|
|
||||||
<Icon name="info" />
|
|
||||||
<span>{info}</span>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{error && (
|
|
||||||
<p className="text-import-error" id="text-import-error" role="alert">
|
|
||||||
<Icon name="close" />
|
|
||||||
<span>{error}</span>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<footer>
|
<footer>
|
||||||
<button type="button" onClick={onClose}>Annuler</button>
|
<button type="button" onClick={onClose}>Annuler</button>
|
||||||
{readOnly ? (
|
|
||||||
<button className="primary" type="button" onClick={onCopy}>{submitLabel}</button>
|
|
||||||
) : (
|
|
||||||
<button className="primary" type="submit">{submitLabel}</button>
|
<button className="primary" type="submit">{submitLabel}</button>
|
||||||
)}
|
|
||||||
</footer>
|
</footer>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// Rôle : fournit l'outil timer avec chronomètre, comptes à rebours et échange texte.
|
// Rôle : fournit l'outil timer avec chronomètre à étapes et comptes à rebours multiples.
|
||||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Icon } from "../../../components/Icon.jsx";
|
import { Icon } from "../../../components/Icon.jsx";
|
||||||
import { Tabs } from "../../../components/Tabs.jsx";
|
import { Tabs } from "../../../components/Tabs.jsx";
|
||||||
|
|
@ -19,7 +19,6 @@ import {
|
||||||
timePartsToDurationMs,
|
timePartsToDurationMs,
|
||||||
timePartsToString
|
timePartsToString
|
||||||
} from "./timerUtils.js";
|
} from "./timerUtils.js";
|
||||||
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
|
||||||
|
|
||||||
const TIMER_TABS = [
|
const TIMER_TABS = [
|
||||||
{ id: "stopwatch", label: "Chronomètre", icon: "stopwatch" },
|
{ id: "stopwatch", label: "Chronomètre", icon: "stopwatch" },
|
||||||
|
|
@ -116,7 +115,7 @@ function getCountdownValidationError(type, draft, textContent) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TimerModule({ toolboxId, moduleId, context, editing }) {
|
export function TimerModule({ toolboxId, moduleId, context }) {
|
||||||
const data = context.normalizeTimerData(context.getModuleData(toolboxId, moduleId, { activeTab: "stopwatch" }));
|
const data = context.normalizeTimerData(context.getModuleData(toolboxId, moduleId, { activeTab: "stopwatch" }));
|
||||||
const textContent = context.moduleText?.timer || {};
|
const textContent = context.moduleText?.timer || {};
|
||||||
const activeTab = data.activeTab;
|
const activeTab = data.activeTab;
|
||||||
|
|
@ -334,11 +333,6 @@ export function TimerModule({ toolboxId, moduleId, context, editing }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="timer-module">
|
<div className="timer-module">
|
||||||
{editing && (
|
|
||||||
<div className="module-add-panel timer-exchange-panel">
|
|
||||||
<TextExchangeActions type="timer" data={data} context={context} textContent={textContent} onImport={(nextData) => save(nextData)} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<section className="timer-control-card">
|
<section className="timer-control-card">
|
||||||
<Tabs
|
<Tabs
|
||||||
className="timer-tabs"
|
className="timer-tabs"
|
||||||
|
|
|
||||||
|
|
@ -26,9 +26,9 @@ const MODULE_COMPONENTS = {
|
||||||
links: { label: "Liens", icon: "link", Component: LinksModule, editable: true, scrollable: true },
|
links: { label: "Liens", icon: "link", Component: LinksModule, editable: true, scrollable: true },
|
||||||
counters: { label: "Compteurs", icon: "abacus", Component: CountersModule, editable: true, scrollable: true },
|
counters: { label: "Compteurs", icon: "abacus", Component: CountersModule, editable: true, scrollable: true },
|
||||||
combos: { label: "Combos", icon: "controller", Component: CombosModule, editable: true, scrollable: true },
|
combos: { label: "Combos", icon: "controller", Component: CombosModule, editable: true, scrollable: true },
|
||||||
calculator: { label: "Calculateur", icon: "calculator", Component: CalculatorModule, editable: true },
|
calculator: { label: "Calculateur", icon: "calculator", Component: CalculatorModule, editable: false },
|
||||||
table: { label: "Tableau", icon: "table", Component: TableModule, editable: true, scrollable: true },
|
table: { label: "Tableau", icon: "table", Component: TableModule, editable: false, scrollable: true },
|
||||||
timer: { label: "Timer", icon: "clock", Component: TimerModule, editable: true },
|
timer: { label: "Timer", icon: "clock", Component: TimerModule, editable: false },
|
||||||
taskPlanner: { label: "Planificateur de tâches", icon: "tasklist", Component: TaskPlannerModule, editable: true, scrollable: true },
|
taskPlanner: { label: "Planificateur de tâches", icon: "tasklist", Component: TaskPlannerModule, editable: true, scrollable: true },
|
||||||
equipmentPlanner: { label: "Planificateur d'équipements", icon: "chest-armor", Component: EquipmentPlannerModule, editable: true, scrollable: true },
|
equipmentPlanner: { label: "Planificateur d'équipements", icon: "chest-armor", Component: EquipmentPlannerModule, editable: true, scrollable: true },
|
||||||
imageAnnotation: { label: "Annotation d'images", icon: "map", Component: ImageAnnotationModule, editable: true }
|
imageAnnotation: { label: "Annotation d'images", icon: "map", Component: ImageAnnotationModule, editable: true }
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,4 @@
|
||||||
// Rôle : parse et sérialise les formats texte/TSV d'échange des outils toolbox.
|
// Rôle : parse les imports texte en listes exploitables par les outils.
|
||||||
import { cellAddress, columnIndexToName } from "./tableFormulaEngine.js";
|
|
||||||
import { getCountdownTargetMs, getDailyTargetMs, getIntervalAnchorMs } from "./timerUtils.js";
|
|
||||||
|
|
||||||
export function parseColonImportLines(text) {
|
export function parseColonImportLines(text) {
|
||||||
return String(text || "")
|
return String(text || "")
|
||||||
.split(/\r?\n/)
|
.split(/\r?\n/)
|
||||||
|
|
@ -17,705 +14,3 @@ export function parseColonImportLines(text) {
|
||||||
})
|
})
|
||||||
.filter((entry) => entry.label);
|
.filter((entry) => entry.label);
|
||||||
}
|
}
|
||||||
|
|
||||||
const SUPPORTED_TEXT_EXCHANGE_TYPES = new Set([
|
|
||||||
"checklist",
|
|
||||||
"links",
|
|
||||||
"counters",
|
|
||||||
"combos",
|
|
||||||
"calculator",
|
|
||||||
"table",
|
|
||||||
"timer",
|
|
||||||
"taskPlanner",
|
|
||||||
"equipmentPlanner",
|
|
||||||
"images",
|
|
||||||
"imageAnnotation"
|
|
||||||
]);
|
|
||||||
const COMBO_DEVICES = new Set(["playstation", "xbox", "switch", "n64", "keyboardMouse"]);
|
|
||||||
const COMBO_INPUT_KINDS = new Set(["button", "direction", "key", "mouse"]);
|
|
||||||
const TIMER_TYPES = new Set(["duration", "daily_time", "time_pattern", "interval"]);
|
|
||||||
const ALERT_MODES = new Set(["off", "visible", "site"]);
|
|
||||||
const IMAGE_DATA_URL_PATTERN = /^data:image\/[a-z0-9.+-]+;base64,[a-z0-9+/=\s]+$/i;
|
|
||||||
|
|
||||||
export function supportsTextExchange(type) {
|
|
||||||
return SUPPORTED_TEXT_EXCHANGE_TYPES.has(type);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hasTextExchangeContent(type, data) {
|
|
||||||
if (type === "checklist") return Boolean(data?.sections?.some((section) => section.items?.length));
|
|
||||||
if (type === "links") return Boolean(data?.links?.length);
|
|
||||||
if (type === "counters") return Boolean(data?.counters?.length);
|
|
||||||
if (type === "combos") return Boolean(data?.combos?.length);
|
|
||||||
if (type === "calculator") return Boolean(data?.entries?.length || data?.scrollResults);
|
|
||||||
if (type === "table") return Boolean(Object.keys(data?.cells || {}).length || Object.keys(data?.rowLabels || {}).length || Object.keys(data?.columnLabels || {}).length);
|
|
||||||
if (type === "timer") return Boolean(data?.stopwatch?.laps?.length || data?.countdowns?.length || data?.stopwatch?.elapsedMs);
|
|
||||||
if (type === "taskPlanner") return Boolean(data?.tasks?.length);
|
|
||||||
if (type === "equipmentPlanner") return Boolean(data?.equipments?.length);
|
|
||||||
if (type === "images") return Boolean(data?.images?.length);
|
|
||||||
if (type === "imageAnnotation") return Boolean(data?.image);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function exportModuleText(type, data) {
|
|
||||||
if (type === "checklist") return exportChecklistText(data);
|
|
||||||
if (type === "links") return exportLinksText(data);
|
|
||||||
if (type === "counters") return exportCountersText(data);
|
|
||||||
if (type === "combos") return exportCombosText(data);
|
|
||||||
if (type === "calculator") return exportCalculatorText(data);
|
|
||||||
if (type === "table") return exportTableTsv(data);
|
|
||||||
if (type === "timer") return exportTimerText(data);
|
|
||||||
if (type === "taskPlanner") return exportTaskPlannerText(data);
|
|
||||||
if (type === "equipmentPlanner") return exportEquipmentPlannerText(data);
|
|
||||||
if (type === "images") return exportImagesText(data);
|
|
||||||
if (type === "imageAnnotation") return exportImageAnnotationText(data);
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function importModuleText(type, text, context) {
|
|
||||||
try {
|
|
||||||
const value = String(text || "");
|
|
||||||
if (!value.trim()) return fail("Le texte à importer est vide.");
|
|
||||||
let data = null;
|
|
||||||
if (type === "checklist") data = importChecklistText(value, context);
|
|
||||||
else if (type === "links") data = importLinksText(value, context);
|
|
||||||
else if (type === "counters") data = importCountersText(value, context);
|
|
||||||
else if (type === "combos") data = importCombosText(value, context);
|
|
||||||
else if (type === "calculator") data = importCalculatorText(value, context);
|
|
||||||
else if (type === "table") data = importTableTsv(value);
|
|
||||||
else if (type === "timer") data = importTimerText(value, context);
|
|
||||||
else if (type === "taskPlanner") data = importTaskPlannerText(value, context);
|
|
||||||
else if (type === "equipmentPlanner") data = importEquipmentPlannerText(value, context);
|
|
||||||
else if (type === "images") data = importImagesText(value, context);
|
|
||||||
else if (type === "imageAnnotation") data = importImageAnnotationText(value, context);
|
|
||||||
else return fail("Cet outil ne supporte pas l'import texte.");
|
|
||||||
if (!hasTextExchangeContent(type, data)) return fail("Aucun contenu valide détecté.");
|
|
||||||
return { ok: true, data };
|
|
||||||
} catch (error) {
|
|
||||||
return fail(error?.message || "Format invalide.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function fail(error) {
|
|
||||||
return { ok: false, error };
|
|
||||||
}
|
|
||||||
|
|
||||||
function splitLines(text) {
|
|
||||||
return String(text || "").split(/\r?\n/);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseMetaLine(line) {
|
|
||||||
const match = String(line || "").trim().match(/^@([a-zA-Z][\w-]*)\s*:\s*(.*)$/);
|
|
||||||
return match ? { key: match[1], value: match[2] } : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parsePipeParts(line) {
|
|
||||||
const [title = "", ...parts] = String(line || "").split("|").map((part) => part.trim());
|
|
||||||
const meta = {};
|
|
||||||
parts.forEach((part) => {
|
|
||||||
const separatorIndex = part.indexOf("=");
|
|
||||||
if (separatorIndex < 0) return;
|
|
||||||
meta[part.slice(0, separatorIndex).trim()] = part.slice(separatorIndex + 1).trim();
|
|
||||||
});
|
|
||||||
return { title, meta };
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseBoolean(value) {
|
|
||||||
return String(value || "").trim().toLowerCase() === "true";
|
|
||||||
}
|
|
||||||
|
|
||||||
function lineError(index, message) {
|
|
||||||
return new Error(`Ligne ${index + 1}: ${message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseNumber(value, fallback = 0) {
|
|
||||||
const parsed = Number(String(value || "").replace(",", "."));
|
|
||||||
return Number.isFinite(parsed) ? parsed : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseInteger(value, fallback = 0) {
|
|
||||||
const parsed = Number.parseInt(value, 10);
|
|
||||||
return Number.isFinite(parsed) ? parsed : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatMeta(meta) {
|
|
||||||
return Object.entries(meta)
|
|
||||||
.filter(([, value]) => value !== "" && value !== undefined && value !== false)
|
|
||||||
.map(([key, value]) => `${key}=${value}`)
|
|
||||||
.join(" | ");
|
|
||||||
}
|
|
||||||
|
|
||||||
function isValidImageDataUrl(value) {
|
|
||||||
return IMAGE_DATA_URL_PATTERN.test(String(value || "").trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
function cloneDrawingData(drawings) {
|
|
||||||
return {
|
|
||||||
strokes: (drawings?.strokes || []).map((stroke) => ({
|
|
||||||
...stroke,
|
|
||||||
points: (stroke.points || []).map((point) => ({ ...point }))
|
|
||||||
}))
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function exportChecklistText(data) {
|
|
||||||
return (data.sections || []).flatMap((section) => [
|
|
||||||
section.title ? `# ${section.title}` : "",
|
|
||||||
...(section.items || []).map((item) => `${item.label}: ${Math.max(1, parseInteger(item.qtyTarget, 1))}`)
|
|
||||||
]).filter((line, index, lines) => line || lines[index + 1]).join("\n").trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function importChecklistText(text, context) {
|
|
||||||
const sections = [];
|
|
||||||
let current = { id: context.uid("section"), title: "", items: [] };
|
|
||||||
splitLines(text).forEach((line) => {
|
|
||||||
const cleanLine = line.trim();
|
|
||||||
if (!cleanLine) return;
|
|
||||||
if (cleanLine.startsWith("#")) {
|
|
||||||
if (current.items.length) sections.push(current);
|
|
||||||
current = { id: context.uid("section"), title: cleanLine.replace(/^#+/, "").trim(), items: [] };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
parseColonImportLines(cleanLine).forEach((entry) => {
|
|
||||||
current.items.push({
|
|
||||||
id: context.uid("item"),
|
|
||||||
label: entry.label,
|
|
||||||
qtyTarget: Math.max(1, parseInteger(String(entry.value).split("/").pop(), 1)),
|
|
||||||
qtyCurrent: 0
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
if (current.items.length) sections.push(current);
|
|
||||||
return context.normalizeChecklistData({ sections });
|
|
||||||
}
|
|
||||||
|
|
||||||
function exportLinksText(data) {
|
|
||||||
return (data.links || []).map((link) => link.title ? `${link.title}: ${link.url}` : link.url).join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function importLinksText(text, context) {
|
|
||||||
const links = [];
|
|
||||||
splitLines(text).forEach((rawLine, index) => {
|
|
||||||
const line = rawLine.trim();
|
|
||||||
if (!line) return;
|
|
||||||
const separatorIndex = line.indexOf(":");
|
|
||||||
const maybeUrl = separatorIndex >= 0 ? line.slice(separatorIndex + 1).trim() : line;
|
|
||||||
const title = separatorIndex >= 0 && !/^[a-z][a-z0-9+.-]*:\/\//i.test(line) ? line.slice(0, separatorIndex).trim() : "";
|
|
||||||
const url = context.normalizeUrl(title ? maybeUrl : line);
|
|
||||||
if (!url) throw lineError(index, `URL invalide: ${line}`);
|
|
||||||
links.push({ id: context.uid("link"), title, url });
|
|
||||||
});
|
|
||||||
return context.normalizeLinksData({ links });
|
|
||||||
}
|
|
||||||
|
|
||||||
function exportCountersText(data) {
|
|
||||||
return (data.counters || []).map((counter) => `${counter.label}: ${counter.value}`).join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function importCountersText(text, context) {
|
|
||||||
const counters = [];
|
|
||||||
splitLines(text).forEach((line, index) => {
|
|
||||||
const cleanLine = line.trim();
|
|
||||||
if (!cleanLine) return;
|
|
||||||
const separatorIndex = cleanLine.indexOf(":");
|
|
||||||
const entry = separatorIndex === -1
|
|
||||||
? { label: cleanLine, value: "" }
|
|
||||||
: { label: cleanLine.slice(0, separatorIndex).trim(), value: cleanLine.slice(separatorIndex + 1).trim() };
|
|
||||||
if (!entry.label) return;
|
|
||||||
if (!entry.value && entry.value !== "0") throw lineError(index, `valeur manquante pour ${entry.label}.`);
|
|
||||||
const value = Number.parseInt(entry.value, 10);
|
|
||||||
if (!Number.isFinite(value)) throw lineError(index, `valeur invalide pour ${entry.label}.`);
|
|
||||||
counters.push({ id: context.uid("counter"), label: entry.label, value });
|
|
||||||
});
|
|
||||||
return context.normalizeCountersData({ counters });
|
|
||||||
}
|
|
||||||
|
|
||||||
function exportCalculatorText(data) {
|
|
||||||
const lines = [];
|
|
||||||
if (data.scrollResults) lines.push("@scrollResults: true");
|
|
||||||
function visit(parentId = "", depth = 0) {
|
|
||||||
(data.entries || []).filter((entry) => (entry.parentId || "") === parentId).forEach((entry) => {
|
|
||||||
lines.push(`${" ".repeat(depth)}${entry.label || ""}: ${entry.value}`);
|
|
||||||
visit(entry.id, depth + 1);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
visit();
|
|
||||||
return lines.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function importCalculatorText(text, context) {
|
|
||||||
const entries = [];
|
|
||||||
const parentStack = [];
|
|
||||||
let scrollResults = false;
|
|
||||||
splitLines(text).forEach((line, index) => {
|
|
||||||
if (!line.trim()) return;
|
|
||||||
const meta = parseMetaLine(line);
|
|
||||||
if (meta?.key === "scrollResults") {
|
|
||||||
scrollResults = parseBoolean(meta.value);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const depth = Math.floor((line.match(/^ */)?.[0].length || 0) / 2);
|
|
||||||
const separatorIndex = line.indexOf(":");
|
|
||||||
if (separatorIndex < 0) throw lineError(index, `ligne calculateur invalide: ${line.trim()}`);
|
|
||||||
const label = line.slice(0, separatorIndex).trim();
|
|
||||||
const value = parseNumber(line.slice(separatorIndex + 1), Number.NaN);
|
|
||||||
if (!Number.isFinite(value)) throw lineError(index, `valeur calculateur invalide: ${line.trim()}`);
|
|
||||||
const entry = { id: context.uid("calc"), parentId: parentStack[depth - 1] || "", label, value };
|
|
||||||
entries.push(entry);
|
|
||||||
parentStack[depth] = entry.id;
|
|
||||||
parentStack.length = depth + 1;
|
|
||||||
});
|
|
||||||
return context.normalizeCalculatorData({ entries, scrollResults });
|
|
||||||
}
|
|
||||||
|
|
||||||
function exportTableTsv(data) {
|
|
||||||
const lines = [];
|
|
||||||
if (data.rows !== 10 || data.columns !== 6) lines.push(`@size: ${data.rows}x${data.columns}`);
|
|
||||||
if (Object.keys(data.columnLabels || {}).length) lines.push(`@columns: ${Array.from({ length: data.columns }, (_, index) => data.columnLabels?.[index] || columnIndexToName(index)).join("\t")}`);
|
|
||||||
if (Object.keys(data.rowLabels || {}).length) lines.push(`@rows: ${Array.from({ length: data.rows }, (_, index) => data.rowLabels?.[index] || index + 1).join("\t")}`);
|
|
||||||
if (lines.length) lines.push("@tsv");
|
|
||||||
for (let rowIndex = 0; rowIndex < data.rows; rowIndex += 1) {
|
|
||||||
lines.push(Array.from({ length: data.columns }, (_, columnIndex) => data.cells?.[cellAddress(rowIndex, columnIndex)] || "").join("\t"));
|
|
||||||
}
|
|
||||||
return lines.join("\n").replace(/\n+$/, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
function importTableTsv(text) {
|
|
||||||
const lines = splitLines(text);
|
|
||||||
let rows = 0;
|
|
||||||
let columns = 0;
|
|
||||||
let rowLabels = {};
|
|
||||||
let columnLabels = {};
|
|
||||||
let dataLines = lines;
|
|
||||||
const metaLines = [];
|
|
||||||
const tsvIndex = lines.findIndex((line) => line.trim() === "@tsv");
|
|
||||||
if (tsvIndex >= 0) {
|
|
||||||
metaLines.push(...lines.slice(0, tsvIndex));
|
|
||||||
dataLines = lines.slice(tsvIndex + 1);
|
|
||||||
}
|
|
||||||
metaLines.forEach((line, index) => {
|
|
||||||
const meta = parseMetaLine(line);
|
|
||||||
if (!meta) throw lineError(index, `meta tableau invalide: ${line}`);
|
|
||||||
if (meta.key === "size") {
|
|
||||||
const match = meta.value.match(/^(\d+)x(\d+)$/);
|
|
||||||
if (!match) throw lineError(index, "taille de tableau invalide.");
|
|
||||||
rows = parseInteger(match[1], 0);
|
|
||||||
columns = parseInteger(match[2], 0);
|
|
||||||
}
|
|
||||||
if (meta.key === "columns") {
|
|
||||||
columnLabels = Object.fromEntries(meta.value.split("\t").map((value, index) => [index, value]).filter(([index, value]) => value && value !== columnIndexToName(index)));
|
|
||||||
}
|
|
||||||
if (meta.key === "rows") {
|
|
||||||
rowLabels = Object.fromEntries(meta.value.split("\t").map((value, index) => [index, value]).filter(([index, value]) => value && value !== String(index + 1)));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const tableRows = dataLines.filter((line) => line.trim() || line.includes("\t")).map((line) => line.split("\t"));
|
|
||||||
if (!tableRows.length) throw new Error("TSV vide.");
|
|
||||||
rows = rows || tableRows.length;
|
|
||||||
columns = columns || Math.max(...tableRows.map((row) => row.length));
|
|
||||||
const cells = {};
|
|
||||||
for (let rowIndex = 0; rowIndex < Math.min(rows, 50); rowIndex += 1) {
|
|
||||||
for (let columnIndex = 0; columnIndex < Math.min(columns, 20); columnIndex += 1) {
|
|
||||||
const value = tableRows[rowIndex]?.[columnIndex] || "";
|
|
||||||
if (value.trim()) cells[cellAddress(rowIndex, columnIndex)] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { rows, columns, cells, rowLabels, columnLabels };
|
|
||||||
}
|
|
||||||
|
|
||||||
function exportComboInput(input) {
|
|
||||||
let suffix = "";
|
|
||||||
if (input.holdMs) suffix = `[${Math.round(input.holdMs / 1000)}s]`;
|
|
||||||
else if (input.hold) suffix = "[hold]";
|
|
||||||
return `${input.kind}:${input.value}${suffix}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseComboInput(text) {
|
|
||||||
const match = String(text || "").trim().match(/^([a-zA-Z]+):([^\[]+?)(?:\[(hold|\d+s)\])?$/);
|
|
||||||
if (!match || !COMBO_INPUT_KINDS.has(match[1])) throw new Error(`Input combo invalide: ${text}`);
|
|
||||||
const input = { kind: match[1], value: match[2].trim() };
|
|
||||||
if (!input.value) throw new Error(`Input combo invalide: ${text}`);
|
|
||||||
if (match[3] === "hold") input.hold = true;
|
|
||||||
if (match[3]?.endsWith("s")) {
|
|
||||||
input.hold = true;
|
|
||||||
input.holdMs = Math.max(1000, Math.min(99000, parseInteger(match[3], 1) * 1000));
|
|
||||||
}
|
|
||||||
return input;
|
|
||||||
}
|
|
||||||
|
|
||||||
function exportCombosText(data) {
|
|
||||||
const lines = [];
|
|
||||||
if (data.device && data.device !== "playstation") lines.push(`@device: ${data.device}`);
|
|
||||||
if (data.collapsedCategories?.length) lines.push(`@collapsed: ${data.collapsedCategories.join(", ")}`);
|
|
||||||
let lastCategory = null;
|
|
||||||
(data.combos || []).forEach((combo) => {
|
|
||||||
const category = combo.category || "";
|
|
||||||
if (category && category !== lastCategory) {
|
|
||||||
lines.push(`# ${category}`);
|
|
||||||
lastCategory = category;
|
|
||||||
}
|
|
||||||
const meta = formatMeta({ device: combo.device || "" });
|
|
||||||
const sequence = (combo.inputs || []).map((step) => step.map(exportComboInput).join("+")).join(" > ");
|
|
||||||
lines.push([combo.name, meta, sequence].filter(Boolean).join(" | "));
|
|
||||||
});
|
|
||||||
return lines.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function importCombosText(text, context) {
|
|
||||||
const combos = [];
|
|
||||||
let device = "playstation";
|
|
||||||
let collapsedCategories = [];
|
|
||||||
let category = "";
|
|
||||||
splitLines(text).forEach((line, index) => {
|
|
||||||
const cleanLine = line.trim();
|
|
||||||
if (!cleanLine) return;
|
|
||||||
const meta = parseMetaLine(cleanLine);
|
|
||||||
if (meta?.key === "device") {
|
|
||||||
if (!COMBO_DEVICES.has(meta.value)) throw lineError(index, "périphérique combo invalide.");
|
|
||||||
device = meta.value;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (meta?.key === "collapsed") {
|
|
||||||
collapsedCategories = meta.value.split(",").map((item) => item.trim()).filter(Boolean);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (cleanLine.startsWith("#")) {
|
|
||||||
category = cleanLine.replace(/^#+/, "").trim();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { title, meta: lineMeta } = parsePipeParts(cleanLine);
|
|
||||||
const sequence = cleanLine.split("|").map((part) => part.trim()).filter((part, index) => index > 0 && !part.includes("=")).join(" | ");
|
|
||||||
const comboDevice = lineMeta.device || device;
|
|
||||||
if (!COMBO_DEVICES.has(comboDevice)) throw lineError(index, `périphérique combo invalide: ${comboDevice}`);
|
|
||||||
let inputs = [];
|
|
||||||
try {
|
|
||||||
inputs = sequence ? String(sequence).split(">").map((step) => step.split("+").map(parseComboInput)) : [];
|
|
||||||
} catch (error) {
|
|
||||||
throw lineError(index, error?.message || "input combo invalide.");
|
|
||||||
}
|
|
||||||
combos.push({ id: context.uid("combo"), name: title || "Combo", category, device: comboDevice, inputs });
|
|
||||||
});
|
|
||||||
return context.normalizeCombosData({ device, collapsedCategories, combos });
|
|
||||||
}
|
|
||||||
|
|
||||||
function exportTaskPlannerText(data) {
|
|
||||||
const lines = [];
|
|
||||||
if (data.weeklyResetDay !== 1) lines.push(`@weeklyResetDay: ${data.weeklyResetDay}`);
|
|
||||||
if (data.resetTime && data.resetTime !== "00:00") lines.push(`@resetTime: ${data.resetTime}`);
|
|
||||||
if (data.hideCompleted) lines.push("@hideCompleted: true");
|
|
||||||
if (data.collapsedCategories?.length) lines.push(`@collapsed: ${data.collapsedCategories.join(", ")}`);
|
|
||||||
const parentByChild = new Map((data.relations || []).map((relation) => [relation.fromTaskId, relation]));
|
|
||||||
const childrenByParent = new Map();
|
|
||||||
(data.tasks || []).forEach((task) => {
|
|
||||||
const parentId = parentByChild.get(task.id)?.toTaskId || "";
|
|
||||||
childrenByParent.set(parentId, [...(childrenByParent.get(parentId) || []), task]);
|
|
||||||
});
|
|
||||||
function writeTask(task, depth = 0) {
|
|
||||||
const prefix = " ".repeat(depth);
|
|
||||||
lines.push(`${prefix}- ${task.type || "unique"} ${task.title}`);
|
|
||||||
if (task.description) lines.push(`${prefix} @description: ${task.description.replace(/\n/g, "\\n")}`);
|
|
||||||
if (task.dailyResetTime) lines.push(`${prefix} @dailyResetTime: ${task.dailyResetTime}`);
|
|
||||||
if (task.weeklyResetDay !== undefined) lines.push(`${prefix} @weeklyResetDay: ${task.weeklyResetDay}`);
|
|
||||||
if (parentByChild.get(task.id)?.prerequisite) lines.push(`${prefix} @prerequisite: true`);
|
|
||||||
(childrenByParent.get(task.id) || []).forEach((child) => writeTask(child, depth + 1));
|
|
||||||
}
|
|
||||||
let lastCategory = null;
|
|
||||||
(childrenByParent.get("") || []).forEach((task) => {
|
|
||||||
const category = task.category || "";
|
|
||||||
if (category && category !== lastCategory) {
|
|
||||||
lines.push(`# ${category}`);
|
|
||||||
lastCategory = category;
|
|
||||||
}
|
|
||||||
writeTask(task);
|
|
||||||
});
|
|
||||||
return lines.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function importTaskPlannerText(text, context) {
|
|
||||||
const tasks = [];
|
|
||||||
const relations = [];
|
|
||||||
const stack = [];
|
|
||||||
let category = "";
|
|
||||||
const data = { weeklyResetDay: 1, resetTime: "00:00", hideCompleted: false, collapsedCategories: [], tasks, relations };
|
|
||||||
splitLines(text).forEach((line, index) => {
|
|
||||||
if (!line.trim()) return;
|
|
||||||
const cleanLine = line.trim();
|
|
||||||
const rootMeta = parseMetaLine(cleanLine);
|
|
||||||
if (rootMeta && !stack.length) {
|
|
||||||
if (rootMeta.key === "weeklyResetDay") data.weeklyResetDay = parseInteger(rootMeta.value, 1);
|
|
||||||
if (rootMeta.key === "resetTime") data.resetTime = rootMeta.value;
|
|
||||||
if (rootMeta.key === "hideCompleted") data.hideCompleted = parseBoolean(rootMeta.value);
|
|
||||||
if (rootMeta.key === "collapsed") data.collapsedCategories = rootMeta.value.split(",").map((item) => item.trim()).filter(Boolean);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (cleanLine.startsWith("#")) {
|
|
||||||
category = cleanLine.replace(/^#+/, "").trim();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const depth = Math.floor((line.match(/^ */)?.[0].length || 0) / 2);
|
|
||||||
const meta = parseMetaLine(cleanLine);
|
|
||||||
if (meta) {
|
|
||||||
const task = stack[Math.max(0, depth - 1)];
|
|
||||||
if (!task) throw lineError(index, `meta de tâche sans tâche: ${cleanLine}`);
|
|
||||||
if (meta.key === "description") task.description = meta.value.replace(/\\n/g, "\n");
|
|
||||||
if (meta.key === "dailyResetTime") task.dailyResetTime = meta.value;
|
|
||||||
if (meta.key === "weeklyResetDay") task.weeklyResetDay = parseInteger(meta.value, 1);
|
|
||||||
if (meta.key === "prerequisite") {
|
|
||||||
const relation = relations.find((item) => item.fromTaskId === task.id);
|
|
||||||
if (relation) relation.prerequisite = parseBoolean(meta.value);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const match = cleanLine.match(/^-\s+(unique|daily|weekly)\s+(.+)$/);
|
|
||||||
if (!match) throw lineError(index, `ligne tâche invalide: ${cleanLine}`);
|
|
||||||
const task = { id: context.uid("task"), title: match[2].trim(), description: "", type: match[1], checked: false, checkedAt: 0 };
|
|
||||||
const parent = stack[depth - 1];
|
|
||||||
if (parent) relations.push({ id: context.uid("relation"), fromTaskId: task.id, toTaskId: parent.id, prerequisite: false });
|
|
||||||
else if (category) task.category = category;
|
|
||||||
tasks.push(task);
|
|
||||||
stack[depth] = task;
|
|
||||||
stack.length = depth + 1;
|
|
||||||
});
|
|
||||||
return context.normalizeTaskPlannerData(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
function exportTimerText(data) {
|
|
||||||
const lines = [];
|
|
||||||
if (data.activeTab && data.activeTab !== "stopwatch") lines.push(`@activeTab: ${data.activeTab}`);
|
|
||||||
if (data.scrollResults) lines.push("@scrollResults: true");
|
|
||||||
if (data.sortResults) lines.push("@sortResults: true");
|
|
||||||
if (data.stopwatch?.elapsedMs) lines.push(`@stopwatchElapsedMs: ${data.stopwatch.elapsedMs}`);
|
|
||||||
(data.stopwatch?.laps || []).forEach((lap) => lines.push(`lap | label=${lap.label || ""} | elapsedMs=${lap.elapsedMs}`));
|
|
||||||
(data.countdowns || []).forEach((countdown) => {
|
|
||||||
const meta = { label: countdown.label, type: countdown.type, alertMode: countdown.alertMode || "", autoRefresh: countdown.autoRefresh === true };
|
|
||||||
if (countdown.type === "duration") meta.durationMs = countdown.durationMs;
|
|
||||||
if (countdown.type === "daily_time") meta.time = countdown.time;
|
|
||||||
if (countdown.type === "time_pattern") meta.pattern = countdown.pattern;
|
|
||||||
if (countdown.type === "interval") {
|
|
||||||
meta.intervalMs = countdown.intervalMs;
|
|
||||||
meta.startMode = countdown.startMode || "now";
|
|
||||||
meta.startTime = countdown.startTime || "";
|
|
||||||
}
|
|
||||||
lines.push(`countdown | ${formatMeta(meta)}`);
|
|
||||||
});
|
|
||||||
return lines.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function importTimerText(text, context) {
|
|
||||||
const now = Date.now();
|
|
||||||
const data = { activeTab: "stopwatch", scrollResults: false, sortResults: false, stopwatch: { elapsedMs: 0, startedAt: 0, laps: [] }, countdowns: [] };
|
|
||||||
splitLines(text).forEach((line, index) => {
|
|
||||||
const cleanLine = line.trim();
|
|
||||||
if (!cleanLine) return;
|
|
||||||
const meta = parseMetaLine(cleanLine);
|
|
||||||
if (meta) {
|
|
||||||
if (meta.key === "activeTab") data.activeTab = meta.value === "countdown" ? "countdown" : "stopwatch";
|
|
||||||
if (meta.key === "scrollResults") data.scrollResults = parseBoolean(meta.value);
|
|
||||||
if (meta.key === "sortResults") data.sortResults = parseBoolean(meta.value);
|
|
||||||
if (meta.key === "stopwatchElapsedMs") data.stopwatch.elapsedMs = Math.max(0, parseInteger(meta.value, 0));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { title, meta: lineMeta } = parsePipeParts(cleanLine);
|
|
||||||
if (title === "lap") {
|
|
||||||
data.stopwatch.laps.push({ id: context.uid("timer"), label: lineMeta.label || "", elapsedMs: Math.max(0, parseInteger(lineMeta.elapsedMs, 0)) });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (title !== "countdown") throw lineError(index, `ligne timer invalide: ${cleanLine}`);
|
|
||||||
const type = lineMeta.type;
|
|
||||||
if (!TIMER_TYPES.has(type)) throw lineError(index, `type timer invalide: ${type}`);
|
|
||||||
const countdown = { id: context.uid("timer"), label: lineMeta.label || "Timer", type, alertMode: ALERT_MODES.has(lineMeta.alertMode) ? lineMeta.alertMode : "off" };
|
|
||||||
if (type === "duration") {
|
|
||||||
countdown.durationMs = Math.max(1, parseInteger(lineMeta.durationMs, 0));
|
|
||||||
countdown.targetAt = now + countdown.durationMs;
|
|
||||||
}
|
|
||||||
if (type === "daily_time") {
|
|
||||||
countdown.time = lineMeta.time;
|
|
||||||
countdown.targetAt = getDailyTargetMs(countdown.time, now);
|
|
||||||
countdown.autoRefresh = parseBoolean(lineMeta.autoRefresh);
|
|
||||||
}
|
|
||||||
if (type === "time_pattern") {
|
|
||||||
countdown.pattern = lineMeta.pattern;
|
|
||||||
countdown.autoRefresh = parseBoolean(lineMeta.autoRefresh);
|
|
||||||
}
|
|
||||||
if (type === "interval") {
|
|
||||||
countdown.intervalMs = Math.max(1, parseInteger(lineMeta.intervalMs, 0));
|
|
||||||
countdown.startMode = lineMeta.startMode === "time" && lineMeta.startTime ? "time" : "now";
|
|
||||||
if (countdown.startMode === "time") countdown.startTime = lineMeta.startTime;
|
|
||||||
countdown.anchorAt = getIntervalAnchorMs(countdown.startMode, countdown.startTime, now);
|
|
||||||
countdown.targetAt = getCountdownTargetMs({ ...countdown, autoRefresh: true }, now);
|
|
||||||
countdown.autoRefresh = parseBoolean(lineMeta.autoRefresh);
|
|
||||||
}
|
|
||||||
data.countdowns.push(countdown);
|
|
||||||
});
|
|
||||||
return context.normalizeTimerData(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
function exportImagesText(data) {
|
|
||||||
return (data.images || []).flatMap((image) => [
|
|
||||||
image.label ? `# ${image.label}` : "#",
|
|
||||||
image.dataUrl
|
|
||||||
]).join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function importImagesText(text, context) {
|
|
||||||
const images = [];
|
|
||||||
let label = "";
|
|
||||||
splitLines(text).forEach((line, index) => {
|
|
||||||
const cleanLine = line.trim();
|
|
||||||
if (!cleanLine) return;
|
|
||||||
if (cleanLine.startsWith("#")) {
|
|
||||||
label = cleanLine.replace(/^#+/, "").trim();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!isValidImageDataUrl(cleanLine)) throw lineError(index, "image base64 invalide.");
|
|
||||||
images.push({ id: context.uid("image"), label, dataUrl: cleanLine });
|
|
||||||
label = "";
|
|
||||||
});
|
|
||||||
return { images };
|
|
||||||
}
|
|
||||||
|
|
||||||
function exportImageAnnotationText(data) {
|
|
||||||
const lines = [data.image];
|
|
||||||
(data.markers || []).forEach((marker) => lines.push(`@marker: ${marker.x},${marker.y},${marker.label || ""}`));
|
|
||||||
if (data.drawings?.strokes?.length) lines.push(`@drawings: ${JSON.stringify(cloneDrawingData(data.drawings))}`);
|
|
||||||
return lines.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function importImageAnnotationText(text, context) {
|
|
||||||
const markers = [];
|
|
||||||
let image = "";
|
|
||||||
let drawings = { strokes: [] };
|
|
||||||
splitLines(text).forEach((line, index) => {
|
|
||||||
const cleanLine = line.trim();
|
|
||||||
if (!cleanLine) return;
|
|
||||||
const meta = parseMetaLine(cleanLine);
|
|
||||||
if (meta?.key === "marker") {
|
|
||||||
const [x, y, ...labelParts] = meta.value.split(",");
|
|
||||||
const marker = { id: context.uid("marker"), x: parseNumber(x, Number.NaN), y: parseNumber(y, Number.NaN), label: labelParts.join(",").trim() };
|
|
||||||
if (!Number.isFinite(marker.x) || !Number.isFinite(marker.y) || marker.x < 0 || marker.x > 100 || marker.y < 0 || marker.y > 100) throw lineError(index, "marqueur invalide.");
|
|
||||||
markers.push(marker);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (meta?.key === "drawings") {
|
|
||||||
try {
|
|
||||||
drawings = JSON.parse(meta.value);
|
|
||||||
} catch {
|
|
||||||
throw lineError(index, "dessins invalides.");
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (image) throw lineError(index, "une seule image peut être importée.");
|
|
||||||
if (!isValidImageDataUrl(cleanLine)) throw lineError(index, "image base64 invalide.");
|
|
||||||
image = cleanLine;
|
|
||||||
});
|
|
||||||
return context.normalizeImageAnnotationData({ image, markers, drawings });
|
|
||||||
}
|
|
||||||
|
|
||||||
function exportEquipmentPlannerText(data) {
|
|
||||||
const lines = [];
|
|
||||||
const typesById = new Map((data.types || []).map((type) => [type.id, type]));
|
|
||||||
(data.types || [{ id: "", title: "Équipements", icon: "shield", collapsed: false }]).forEach((type) => {
|
|
||||||
const equipments = (data.equipments || []).filter((equipment) => type.id ? equipment.typeId === type.id : !typesById.has(equipment.typeId));
|
|
||||||
if (!equipments.length) return;
|
|
||||||
lines.push(`# ${type.title} | ${formatMeta({ icon: type.icon, collapsed: type.collapsed })}`);
|
|
||||||
equipments.forEach((equipment) => {
|
|
||||||
lines.push(`## ${equipment.name} | ${formatMeta({ icon: equipment.icon, active: equipment.active !== false })}`);
|
|
||||||
if (equipment.characteristics?.length) {
|
|
||||||
lines.push("Stats");
|
|
||||||
equipment.characteristics.forEach((trait) => lines.push(`${trait.category || ""} | ${trait.icon || "sword"} | ${trait.name} | ${trait.value ?? ""}`));
|
|
||||||
}
|
|
||||||
if (equipment.socketItems?.length) {
|
|
||||||
lines.push("Sockets");
|
|
||||||
equipment.socketItems.forEach((socketItem) => {
|
|
||||||
lines.push(`${socketItem.name} | shape=${socketItem.shape} | color=${socketItem.color}`);
|
|
||||||
(socketItem.bonuses || []).forEach((bonus) => lines.push(` bonus | ${bonus.category || ""} | ${bonus.icon || "sword"} | ${bonus.name} | ${bonus.value ?? ""}`));
|
|
||||||
});
|
|
||||||
if (equipment.socketLinks?.length) {
|
|
||||||
const socketItemsById = new Map(equipment.socketItems.map((socketItem) => [socketItem.id, socketItem.name]));
|
|
||||||
equipment.socketLinks.forEach((link) => {
|
|
||||||
const fromName = socketItemsById.get(link.fromSocketItemId);
|
|
||||||
const toName = socketItemsById.get(link.toSocketItemId);
|
|
||||||
if (fromName && toName) lines.push(` link | ${fromName} | ${toName}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (equipment.obtain || equipment.materials?.length) {
|
|
||||||
lines.push("Craft");
|
|
||||||
if (equipment.obtain) lines.push(`@obtain: ${equipment.obtain.replace(/\n/g, "\\n")}`);
|
|
||||||
(equipment.materials || []).forEach((material) => lines.push(`${material.name}: ${material.qty}`));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return lines.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function importEquipmentPlannerText(text, context) {
|
|
||||||
const types = [];
|
|
||||||
const equipments = [];
|
|
||||||
let currentType = null;
|
|
||||||
let currentEquipment = null;
|
|
||||||
let section = "";
|
|
||||||
let currentSocket = null;
|
|
||||||
const socketsByName = new Map();
|
|
||||||
splitLines(text).forEach((line, index) => {
|
|
||||||
const cleanLine = line.trim();
|
|
||||||
if (!cleanLine) return;
|
|
||||||
if (cleanLine.startsWith("##")) {
|
|
||||||
if (!currentType) throw lineError(index, "équipement sans type.");
|
|
||||||
const { title, meta } = parsePipeParts(cleanLine.replace(/^##+/, "").trim());
|
|
||||||
currentEquipment = { id: context.uid("equipment"), typeId: currentType.id, name: title, icon: meta.icon || "shield", active: meta.active !== "false", obtain: "", characteristics: [], socketItems: [], socketLinks: [], materials: [], categoryOrder: [], collapsedCategories: [] };
|
|
||||||
equipments.push(currentEquipment);
|
|
||||||
section = "";
|
|
||||||
currentSocket = null;
|
|
||||||
socketsByName.clear();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (cleanLine.startsWith("#")) {
|
|
||||||
const { title, meta } = parsePipeParts(cleanLine.replace(/^#+/, "").trim());
|
|
||||||
currentType = { id: context.uid("equipmentType"), title, icon: meta.icon || "shield", collapsed: parseBoolean(meta.collapsed) };
|
|
||||||
types.push(currentType);
|
|
||||||
currentEquipment = null;
|
|
||||||
section = "";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (["Stats", "Sockets", "Craft"].includes(cleanLine)) {
|
|
||||||
if (!currentEquipment) throw lineError(index, `${cleanLine} sans équipement.`);
|
|
||||||
section = cleanLine;
|
|
||||||
currentSocket = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!currentEquipment || !section) throw lineError(index, `ligne équipement invalide: ${cleanLine}`);
|
|
||||||
if (section === "Stats") {
|
|
||||||
const [category = "", icon = "sword", name = "", value = ""] = cleanLine.split("|").map((part) => part.trim());
|
|
||||||
currentEquipment.characteristics.push({ id: context.uid("trait"), category, icon, name, value });
|
|
||||||
}
|
|
||||||
if (section === "Sockets") {
|
|
||||||
if (cleanLine.startsWith("bonus")) {
|
|
||||||
if (!currentSocket) throw lineError(index, "bonus sans objet serti.");
|
|
||||||
const [, category = "", icon = "sword", name = "", value = ""] = cleanLine.split("|").map((part) => part.trim());
|
|
||||||
currentSocket.bonuses.push({ id: context.uid("socketBonus"), category, icon, name, value });
|
|
||||||
} else if (cleanLine.startsWith("link")) {
|
|
||||||
const [, fromName = "", toName = ""] = cleanLine.split("|").map((part) => part.trim());
|
|
||||||
const fromSocketItemId = socketsByName.get(fromName);
|
|
||||||
const toSocketItemId = socketsByName.get(toName);
|
|
||||||
if (!fromSocketItemId || !toSocketItemId) throw lineError(index, "lien de sertissage invalide.");
|
|
||||||
currentEquipment.socketLinks.push({ id: context.uid("socketLink"), fromSocketItemId, toSocketItemId });
|
|
||||||
} else {
|
|
||||||
const { title, meta } = parsePipeParts(cleanLine);
|
|
||||||
currentSocket = { id: context.uid("socketItem"), name: title, shape: meta.shape || "jewel", color: meta.color || "yellow", bonuses: [] };
|
|
||||||
currentEquipment.socketItems.push(currentSocket);
|
|
||||||
socketsByName.set(title, currentSocket.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (section === "Craft") {
|
|
||||||
const meta = parseMetaLine(cleanLine);
|
|
||||||
if (meta?.key === "obtain") {
|
|
||||||
currentEquipment.obtain = meta.value.replace(/\\n/g, "\n");
|
|
||||||
} else {
|
|
||||||
const [entry] = parseColonImportLines(cleanLine);
|
|
||||||
if (!entry) throw lineError(index, `matériau invalide: ${cleanLine}`);
|
|
||||||
currentEquipment.materials.push({ id: context.uid("material"), name: entry.label, qty: Math.max(1, parseInteger(entry.value, 1)) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return context.normalizeEquipmentPlannerData({ types, equipments, typeOrder: types.map((type) => type.id) });
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
// Rôle : centralise le flux import/export texte des outils compatibles.
|
|
||||||
import { useCallback, useMemo, useState } from "react";
|
|
||||||
import { exportModuleText, hasTextExchangeContent, importModuleText } from "./textImport.js";
|
|
||||||
|
|
||||||
export function useTextExchange({ type, data, context, onImport, textContent = {} }) {
|
|
||||||
const [mode, setMode] = useState("");
|
|
||||||
const [draft, setDraft] = useState("");
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
const exportText = useMemo(() => mode === "export" ? exportModuleText(type, data) : "", [data, mode, type]);
|
|
||||||
const canExport = hasTextExchangeContent(type, data);
|
|
||||||
|
|
||||||
const closeModal = useCallback(() => {
|
|
||||||
setMode("");
|
|
||||||
setDraft("");
|
|
||||||
setError("");
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
function openImport() {
|
|
||||||
setDraft("");
|
|
||||||
setError("");
|
|
||||||
setMode("import");
|
|
||||||
}
|
|
||||||
|
|
||||||
function openExport() {
|
|
||||||
setError("");
|
|
||||||
setMode("export");
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateDraft(value) {
|
|
||||||
setDraft(value);
|
|
||||||
if (error) setError("");
|
|
||||||
}
|
|
||||||
|
|
||||||
function submitImport(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
const result = importModuleText(type, draft, context);
|
|
||||||
if (!result.ok) {
|
|
||||||
setError(result.error || textContent.importError || "Format invalide.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onImport(result.data);
|
|
||||||
context.notify?.(textContent.importSuccess || "Import texte terminé.");
|
|
||||||
closeModal();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copyExport() {
|
|
||||||
if (!await context.copyText(exportText)) return;
|
|
||||||
context.notify?.(textContent.exportSuccess || "Export texte copié.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
canExport,
|
|
||||||
closeModal,
|
|
||||||
copyExport,
|
|
||||||
draft,
|
|
||||||
error,
|
|
||||||
exportText,
|
|
||||||
mode,
|
|
||||||
openExport,
|
|
||||||
openImport,
|
|
||||||
submitImport,
|
|
||||||
updateDraft
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// Rôle : affiche la librairie des outils avec des exemples locaux non persistés.
|
// Rôle : affiche la librairie des outils avec des exemples locaux non persistés.
|
||||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import { Icon } from "../components/Icon.jsx";
|
import { Icon } from "../components/Icon.jsx";
|
||||||
import { CalculatorModule } from "../features/toolboxes/modules/CalculatorModule.jsx";
|
import { CalculatorModule } from "../features/toolboxes/modules/CalculatorModule.jsx";
|
||||||
import { ChecklistModule } from "../features/toolboxes/modules/ChecklistModule.jsx";
|
import { ChecklistModule } from "../features/toolboxes/modules/ChecklistModule.jsx";
|
||||||
|
|
@ -266,9 +266,6 @@ export function LibraryPage({ siteContent, actions }) {
|
||||||
categoryTitle={activeCategory.title}
|
categoryTitle={activeCategory.title}
|
||||||
docs={content.toolDocs?.[module.type]}
|
docs={content.toolDocs?.[module.type]}
|
||||||
featureHeading={content.featureHeading}
|
featureHeading={content.featureHeading}
|
||||||
advancedHeading={content.advancedHeading}
|
|
||||||
advancedCopyLabel={content.copyExampleLabel}
|
|
||||||
advancedCopySuccess={content.copyExampleSuccess}
|
|
||||||
controlLegendHeading={content.controlLegendHeading}
|
controlLegendHeading={content.controlLegendHeading}
|
||||||
context={moduleContext}
|
context={moduleContext}
|
||||||
/>
|
/>
|
||||||
|
|
@ -327,7 +324,7 @@ function LibraryDocNav({ content, categories, activeCategoryKey, onSelectCategor
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function LibraryToolExample({ module, sourceData, description, categoryTitle, docs, featureHeading, advancedHeading, advancedCopyLabel, advancedCopySuccess, controlLegendHeading, context }) {
|
function LibraryToolExample({ module, sourceData, description, categoryTitle, docs, featureHeading, controlLegendHeading, context }) {
|
||||||
const definition = MODULE_COMPONENTS[module.type] || MODULE_COMPONENTS.notepad;
|
const definition = MODULE_COMPONENTS[module.type] || MODULE_COMPONENTS.notepad;
|
||||||
const Component = definition.Component;
|
const Component = definition.Component;
|
||||||
const [scrollable, setScrollable] = useState(false);
|
const [scrollable, setScrollable] = useState(false);
|
||||||
|
|
@ -393,7 +390,6 @@ function LibraryToolExample({ module, sourceData, description, categoryTitle, do
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
<ToolFeatureList docs={docs} heading={featureHeading || "Fonctionnalités"} />
|
<ToolFeatureList docs={docs} heading={featureHeading || "Fonctionnalités"} />
|
||||||
<ToolAdvancedFeatureList docs={docs} heading={advancedHeading || "Import par texte"} copyLabel={advancedCopyLabel} copySuccess={advancedCopySuccess} context={context} />
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -519,7 +515,7 @@ function ControlPreview({ item }) {
|
||||||
|
|
||||||
function ToolFeatureList({ docs, heading }) {
|
function ToolFeatureList({ docs, heading }) {
|
||||||
const features = Array.isArray(docs?.features) ? docs.features : [];
|
const features = Array.isArray(docs?.features) ? docs.features : [];
|
||||||
if (!features.length) return null;
|
if (!features.length && !docs?.importFormat) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="library-tool-docs">
|
<section className="library-tool-docs">
|
||||||
|
|
@ -544,44 +540,13 @@ function ToolFeatureList({ docs, heading }) {
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
{docs?.importFormat && (
|
||||||
</details>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ToolAdvancedFeatureList({ docs, heading, copyLabel, copySuccess, context }) {
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
const copiedTimeoutRef = useRef(0);
|
|
||||||
useEffect(() => () => window.clearTimeout(copiedTimeoutRef.current), []);
|
|
||||||
if (!docs?.importFormat) return null;
|
|
||||||
|
|
||||||
async function copyExample() {
|
|
||||||
if (!await context.copyText(docs.importFormat.example)) return;
|
|
||||||
setCopied(true);
|
|
||||||
context.notify?.(copySuccess || "Exemple copié.");
|
|
||||||
window.clearTimeout(copiedTimeoutRef.current);
|
|
||||||
copiedTimeoutRef.current = window.setTimeout(() => setCopied(false), 1400);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="library-tool-docs">
|
|
||||||
<details className="library-tool-feature-list library-tool-advanced-list">
|
|
||||||
<summary>
|
|
||||||
<h3>{heading}</h3>
|
|
||||||
<Icon name="chevron-down" />
|
|
||||||
</summary>
|
|
||||||
<div className="library-tool-feature-content">
|
|
||||||
<div className="library-tool-import-format">
|
<div className="library-tool-import-format">
|
||||||
|
<h3>{docs.importFormat.title}</h3>
|
||||||
<p>{docs.importFormat.text}</p>
|
<p>{docs.importFormat.text}</p>
|
||||||
<div className="library-tool-import-example">
|
|
||||||
<pre><code>{docs.importFormat.example}</code></pre>
|
<pre><code>{docs.importFormat.example}</code></pre>
|
||||||
<button type="button" onClick={copyExample} title={copyLabel || "Copier l'exemple"}>
|
|
||||||
<Icon name="copy" />
|
|
||||||
<span>{copied ? copySuccess || "Exemple copié." : copyLabel || "Copier l'exemple"}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -641,10 +641,6 @@ span {
|
||||||
rgba(5, 7, 17, 0.3);
|
rgba(5, 7, 17, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-tool-advanced-list {
|
|
||||||
border-color: rgba(34, 211, 238, 0.16);
|
|
||||||
}
|
|
||||||
|
|
||||||
.library-tool-feature-list summary {
|
.library-tool-feature-list summary {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -723,8 +719,11 @@ span {
|
||||||
.library-tool-import-format {
|
.library-tool-import-format {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
|
padding-top: var(--space-3);
|
||||||
|
border-top: 1px solid rgba(246, 196, 83, 0.16);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.library-tool-import-format h3,
|
||||||
.library-tool-import-format p {
|
.library-tool-import-format p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
@ -742,26 +741,6 @@ span {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-tool-import-example {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.library-tool-import-example button {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-self: end;
|
|
||||||
gap: 8px;
|
|
||||||
width: fit-content;
|
|
||||||
min-height: 38px;
|
|
||||||
padding-inline: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.library-tool-import-example .ui-icon {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.library-tool-badge {
|
.library-tool-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
|
|
|
||||||
|
|
@ -37,11 +37,6 @@
|
||||||
-webkit-mask-image: url("/static/icons/save.svg");
|
-webkit-mask-image: url("/static/icons/save.svg");
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-icon-info {
|
|
||||||
mask-image: url("/static/icons/info.svg");
|
|
||||||
-webkit-mask-image: url("/static/icons/info.svg");
|
|
||||||
}
|
|
||||||
|
|
||||||
.ui-icon-export {
|
.ui-icon-export {
|
||||||
mask-image: url("/static/icons/export.svg");
|
mask-image: url("/static/icons/export.svg");
|
||||||
-webkit-mask-image: url("/static/icons/export.svg");
|
-webkit-mask-image: url("/static/icons/export.svg");
|
||||||
|
|
|
||||||
|
|
@ -1366,9 +1366,7 @@
|
||||||
|
|
||||||
.text-import-actions {
|
.text-import-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: var(--space-2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-import-actions button {
|
.text-import-actions button {
|
||||||
|
|
@ -1405,38 +1403,7 @@
|
||||||
|
|
||||||
.text-import-modal-form {
|
.text-import-modal-form {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-3);
|
gap: var(--space-4);
|
||||||
}
|
|
||||||
|
|
||||||
.text-import-modal-form textarea.has-error {
|
|
||||||
border-color: rgba(248, 113, 113, 0.75);
|
|
||||||
box-shadow: 0 0 0 1px rgba(248, 113, 113, 0.24), var(--shadow-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-import-error {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
margin: 0;
|
|
||||||
color: #fecaca;
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-import-info {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
margin: 0;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-import-info .ui-icon,
|
|
||||||
.text-import-error .ui-icon {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-import-modal-form textarea {
|
.text-import-modal-form textarea {
|
||||||
|
|
@ -1911,15 +1878,6 @@ textarea:focus {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-planner-add-stack {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-planner-add-stack .text-import-actions {
|
|
||||||
padding-top: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-planner-global-controls {
|
.task-planner-global-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|
@ -4624,13 +4582,6 @@ button.combo-input-token.combo-value-grab .ui-icon {
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.calculator-exchange-panel,
|
|
||||||
.timer-exchange-panel,
|
|
||||||
.table-exchange-panel {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calculator-form {
|
.calculator-form {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|
@ -5267,19 +5218,6 @@ button.combo-input-token.combo-value-grab .ui-icon {
|
||||||
border-right: 0;
|
border-right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-copy-button.notepad-toolbar-button {
|
|
||||||
display: inline-flex;
|
|
||||||
width: auto;
|
|
||||||
min-width: 0;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding-inline: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-copy-button span {
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-grid-wrap {
|
.table-grid-wrap {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
@ -6017,9 +5955,8 @@ button.combo-input-token.combo-value-grab .ui-icon {
|
||||||
.annotation-media {
|
.annotation-media {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: block;
|
display: block;
|
||||||
width: fit-content;
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
justify-self: center;
|
|
||||||
line-height: 0;
|
line-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -6056,9 +5993,7 @@ button.combo-input-token.combo-value-grab .ui-icon {
|
||||||
.annotation-stage img,
|
.annotation-stage img,
|
||||||
.annotation-media img {
|
.annotation-media img {
|
||||||
display: block;
|
display: block;
|
||||||
width: auto;
|
width: 100%;
|
||||||
height: auto;
|
|
||||||
max-width: 100%;
|
|
||||||
max-height: min(62vh, 620px);
|
max-height: min(62vh, 620px);
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue