Unify category autocomplete fields
All checks were successful
Deploy Sokko G / deploy (push) Successful in 17s

This commit is contained in:
Shinuwa 2026-09-04 14:09:08 +02:00
parent e3d823d0b1
commit 055fb78d60
21 changed files with 739 additions and 168 deletions

View file

@ -686,10 +686,13 @@ Type : `links`
```json
{
"categoryOrder": ["Builds", "Guides"],
"collapsedCategories": ["Guides"],
"links": [
{
"id": "link1",
"title": "Build rogue",
"category": "Builds",
"url": "https://example.com/build"
}
]
@ -700,7 +703,11 @@ Stockage compact :
- `title` est facultatif.
- `title` est omis si vide.
- `category` est facultatif et sert à l'autocomplete de création.
- `category` est omis si vide.
- `url` est obligatoire.
- `categoryOrder` stocke l'ordre d'affichage des catégories, y compris les catégories vides.
- `collapsedCategories` stocke les noms des catégories repliées.
## Outil Compteurs
@ -811,7 +818,11 @@ Type : `rngSimulator`
{
"id": "rate1",
"label": "Drop rare",
"rate": "2.500"
"rate": "2.500",
"multiplier": 50,
"perHour": 30,
"perDay": 120,
"perMonth": 3600
},
{
"id": "rate2",
@ -820,11 +831,7 @@ Type : `rngSimulator`
}
],
"settings": {
"multiplier": 50,
"simulationDraws": 1000,
"perHour": 30,
"perDay": 120,
"perMonth": 3600
"simulationDraws": 1000
}
}
```
@ -835,10 +842,11 @@ Stockage compact :
- `rate` est une chaîne en pourcentage afin de conserver la précision saisie, par exemple `"2.500"` ou `"0.000004"`.
- un taux doit être strictement supérieur à `0` et inférieur ou égal à `100`; les valeurs supérieures sont bornées à `"100"`.
- `activeRateId` référence un taux existant; sinon le premier taux est sélectionné.
- `settings.multiplier` est un bonus ou malus en pourcentage appliqué au taux actif pour les calculs et simulations, vaut 0 par défaut et le taux effectif est borné entre 0% et 100%.
- `rates[].multiplier` est un bonus ou malus en pourcentage associé au taux, vaut 0 par défaut et le taux effectif est borné entre 0% et 100%.
- `rates[].perHour`, `rates[].perDay` et `rates[].perMonth` sont numériques, positifs ou nuls, associés au taux, et valent 0 par défaut.
- `settings.simulationDraws` est borné de 1 à 1 000 000 et vaut 1000 par défaut.
- `settings.perHour`, `settings.perDay` et `settings.perMonth` sont numériques, positifs ou nuls, et valent 0 par défaut.
- `settings` est omis si tous ses champs ont leurs valeurs par défaut.
- `settings` est omis si `simulationDraws` vaut sa valeur par défaut.
- `rates[].multiplier`, `rates[].perHour`, `rates[].perDay` et `rates[].perMonth` sont omis si leur valeur vaut 0.
- l'historique des simulations n'est pas persisté en IndexedDB; il est limité aux 20 dernières entrées en `sessionStorage`.
## Outil Combos
@ -1026,7 +1034,7 @@ Formats :
- `notepad` : format structuré `@notepad`, blocs `@page: Titre`, `@html` / `@endhtml` pour le HTML riche nettoyé, meta `@drawingMode: permanent` et `@drawings: {...}` pour les dessins permanents. Un collage texte simple reste accepté et devient une page sans mise en forme.
- `memos` : une ligne par mémo, avec préfixe `-` optionnel et suffixe couleur `| color=#f6c453` ; les anciens préfixes `*` sont acceptés puis normalisés.
- `checklist` : sections `# Catégorie`, lignes `Item: quantité cible`.
- `links` : lignes `Titre: https://...` ou URL seule.
- `links` : sections `# Catégorie`, lignes `Titre: https://...` ou URL seule; le suffixe optionnel `| category=Catégorie` reste accepté à l'import.
- `counters` : lignes `Libellé: valeur`, valeurs négatives acceptées.
- `calculator` : lignes `Libellé: valeur | formula=base*5`, indentation de deux espaces pour les enfants, meta `@scrollResults: true`. Le suffixe `formula=...` est optionnel et les anciens exports `Libellé: valeur` restent acceptés.
- `table` : TSV pur accepté ; l'export peut ajouter `@size`, `@columns`, `@rows` puis `@tsv` pour préserver dimensions et intitulés.

View file

@ -120,10 +120,15 @@ test("text exchange round trips simple toolbox modules and resets checklist prog
["Mega potion", 5, 0]
]);
const links = normalizeLinksData({ links: [{ id: "l1", title: "Build", url: "https://example.com/build" }] });
const links = normalizeLinksData({
categoryOrder: ["Builds", "Guides"],
links: [{ id: "l1", title: "Build", url: "https://example.com/build", category: "Builds" }]
});
const linksImport = importModuleText("links", exportModuleText("links", links), context);
assert.equal(linksImport.ok, true);
assert.equal(linksImport.data.links[0].url, "https://example.com/build");
assert.equal(linksImport.data.links[0].category, "Builds");
assert.deepEqual(linksImport.data.categoryOrder, ["Builds", "Guides"]);
const counters = normalizeCountersData({ counters: [{ id: "c1", label: "Win", value: 12 }, { id: "c2", label: "Loss", value: -2 }] });
const countersImport = importModuleText("counters", exportModuleText("counters", counters), context);
@ -295,11 +300,11 @@ test("rng simulator preserves rate precision and compacts storage", () => {
const normalized = normalizeRngSimulatorData({
activeRateId: "rate2",
rates: [
{ id: "rate1", label: "Drop rare", rate: "2.500" },
{ id: "rate2", label: "Ultra rare", rate: "0.000004" },
{ id: "rate1", label: "Drop rare", rate: "2.500", multiplier: 25, perHour: 10 },
{ id: "rate2", label: "Ultra rare", rate: "0.000004", multiplier: 50, perHour: 30, perDay: 120, perMonth: 3600 },
{ id: "bad", label: "", rate: "4" }
],
settings: { multiplier: 50, simulationDraws: 2000, perHour: 30, perDay: 120, perMonth: 3600 },
settings: { simulationDraws: 2000 },
history: [
{ id: "h1", at: "2026-08-30T12:00:00.000Z", label: "Drop rare", rate: "2.500", multiplier: 50, draws: 100, successes: 4, fails: 400, longestFailStreak: 40 }
]
@ -307,10 +312,12 @@ test("rng simulator preserves rate precision and compacts storage", () => {
assert.equal(normalized.activeRateId, "rate2");
assert.deepEqual(normalized.rates.map((rate) => [rate.label, rate.rate]), [["Drop rare", "2.500"], ["Ultra rare", "0.000004"]]);
assert.deepEqual(normalized.rates.map((rate) => [rate.multiplier, rate.perHour, rate.perDay, rate.perMonth]), [[25, 10, 0, 0], [50, 30, 120, 3600]]);
assert.equal(normalized.history, undefined);
assert.equal(compactModuleDataForStorage("rngSimulator", normalizeRngSimulatorData({})), null);
assert.equal(compactModuleDataForStorage("rngSimulator", normalized).settings.multiplier, 50);
assert.equal(compactModuleDataForStorage("rngSimulator", normalized).settings.perMonth, 3600);
assert.equal(compactModuleDataForStorage("rngSimulator", normalized).rates[1].multiplier, 50);
assert.equal(compactModuleDataForStorage("rngSimulator", normalized).rates[1].perMonth, 3600);
assert.equal(compactModuleDataForStorage("rngSimulator", normalized).settings.simulationDraws, 2000);
assert.equal(compactModuleDataForStorage("rngSimulator", normalized).history, undefined);
});
@ -357,10 +364,10 @@ test("rng simulator text exchange round trips rates and settings", () => {
const rngSimulator = normalizeRngSimulatorData({
activeRateId: "rate2",
rates: [
{ id: "rate1", label: "Drop rare", rate: "2.500" },
{ id: "rate2", label: "Ultra rare", rate: "0.000004" }
{ id: "rate1", label: "Drop rare", rate: "2.500", multiplier: 25, perHour: 20 },
{ id: "rate2", label: "Ultra rare", rate: "0.000004", multiplier: 50, perHour: 40, perDay: 120, perMonth: 3600 }
],
settings: { multiplier: 50, simulationDraws: 5000, perHour: 40, perDay: 120, perMonth: 3600 }
settings: { simulationDraws: 5000 }
});
const exported = exportModuleText("rngSimulator", rngSimulator);
assert.doesNotMatch(exported, /\[history\]/);
@ -368,9 +375,11 @@ test("rng simulator text exchange round trips rates and settings", () => {
assert.equal(imported.ok, true);
assert.deepEqual(imported.data.rates.map((rate) => [rate.label, rate.rate]), [["Drop rare", "2.500"], ["Ultra rare", "0.000004"]]);
assert.equal(imported.data.rates.find((rate) => rate.id === imported.data.activeRateId)?.label, "Ultra rare");
assert.equal(imported.data.settings.multiplier, 50);
assert.equal(imported.data.rates.find((rate) => rate.label === "Drop rare")?.multiplier, 25);
assert.equal(imported.data.rates.find((rate) => rate.label === "Ultra rare")?.multiplier, 50);
assert.equal(imported.data.rates.find((rate) => rate.label === "Ultra rare")?.perHour, 40);
assert.equal(imported.data.rates.find((rate) => rate.label === "Ultra rare")?.perMonth, 3600);
assert.equal(imported.data.settings.simulationDraws, 5000);
assert.equal(imported.data.settings.perHour, 40);
assert.equal(imported.data.history, undefined);
});

View file

@ -543,7 +543,7 @@
],
"importFormat": {
"text": "Le format texte contient les sections [rates] et [settings]. Les taux sont exprimés en pourcentage.",
"example": "@activeRateId: fnn88zy\n[rates]\n- Elten Piece | rate=0.000004 | active=true\n- Pen Eklecta | rate=20.02\n\n[settings]\nmultiplier: 320\nsimulationDraws: 100000\nperHour: 60\nperDay: 0\nperMonth: 0"
"example": "@activeRateId: fnn88zy\n[rates]\n- Elten Piece | rate=0.000004 | active=true | multiplier=320 | perHour=60 | perDay=0 | perMonth=0\n- Pen Eklecta | rate=20.02 | multiplier=0 | perHour=0 | perDay=0 | perMonth=0\n\n[settings]\nsimulationDraws: 1000"
}
},
"images": {
@ -1996,7 +1996,12 @@
"links": {
"titlePlaceholder": "Nom du lien",
"urlPlaceholder": "https://...",
"categoryPlaceholder": "Catégorie",
"addButton": "Ajouter",
"reorderCategoryTitle": "Déplacer la catégorie",
"showCategoryTitle": "Afficher la catégorie",
"hideCategoryTitle": "Réduire la catégorie",
"deleteCategoryTitle": "Supprimer la catégorie",
"importPlaceholder": "Importer plusieurs liens...\nBuild:https://example.com/build?class=rogue\nMap:https://example.com/map",
"importOpenButton": "Importer du texte",
"exportOpenButton": "Exporter en format texte",
@ -2137,7 +2142,7 @@
"observedRateLabel": "Taux de réussite",
"multiplierShortLabel": "Bonus",
"longestFailStreakLabel": "Pire série",
"importPlaceholder": "@activeRateId: fnn88zy\n[rates]\n- Elten Piece | rate=0.000004 | active=true\n- Pen Eklecta | rate=20.02\n\n[settings]\nmultiplier: 320\nsimulationDraws: 100000\nperHour: 60\nperDay: 0\nperMonth: 0",
"importPlaceholder": "@activeRateId: fnn88zy\n[rates]\n- Elten Piece | rate=0.000004 | active=true | multiplier=320 | perHour=60 | perDay=0 | perMonth=0\n- Pen Eklecta | rate=20.02 | multiplier=0 | perHour=0 | perDay=0 | perMonth=0\n\n[settings]\nsimulationDraws: 1000",
"importOpenButton": "Importer du texte",
"exportOpenButton": "Exporter en format texte",
"importModalTitle": "Importer un simulateur de chance",

View file

@ -0,0 +1,146 @@
// Rôle : fournit un champ NDS d'autocomplete libre pour les catégories.
import { forwardRef, useId, useMemo, useRef, useState } from "react";
function normalizeOptions(options) {
const seen = new Set();
return (options || [])
.map((option) => String(option || "").trim())
.filter((option) => {
if (!option || seen.has(option)) return false;
seen.add(option);
return true;
});
}
export const CategoryAutocompleteInput = forwardRef(function CategoryAutocompleteInput({
className = "nds-input",
id,
name,
options,
placeholder,
value,
onChange,
onFocus,
onBlur,
onKeyDown,
autoComplete = "off",
maxOptions = 8,
...props
}, forwardedRef) {
const generatedId = useId();
const inputId = id || `category-autocomplete-${generatedId}`;
const rootRef = useRef(null);
const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const cleanOptions = useMemo(() => normalizeOptions(options), [options]);
const cleanValue = String(value || "");
const filteredOptions = useMemo(() => {
const query = cleanValue.trim().toLowerCase();
const matches = query
? cleanOptions.filter((option) => option.toLowerCase().includes(query))
: cleanOptions;
return matches.slice(0, maxOptions);
}, [cleanOptions, cleanValue, maxOptions]);
const menuId = `${inputId}-menu`;
const showMenu = open && filteredOptions.length > 0;
function setInputRef(element) {
if (typeof forwardedRef === "function") forwardedRef(element);
else if (forwardedRef) forwardedRef.current = element;
}
function emitChange(nextValue) {
onChange?.({ target: { name, value: nextValue } });
}
function selectOption(option) {
emitChange(option);
setOpen(false);
setActiveIndex(-1);
}
function handleChange(event) {
onChange?.(event);
setOpen(true);
setActiveIndex(-1);
}
function handleFocus(event) {
setOpen(true);
onFocus?.(event);
}
function handleBlur(event) {
if (rootRef.current?.contains(event.relatedTarget)) return;
setOpen(false);
setActiveIndex(-1);
onBlur?.(event);
}
function handleKeyDown(event) {
if (!filteredOptions.length) {
onKeyDown?.(event);
return;
}
if (event.key === "ArrowDown") {
event.preventDefault();
setOpen(true);
setActiveIndex((index) => Math.min(index + 1, filteredOptions.length - 1));
} else if (event.key === "ArrowUp") {
event.preventDefault();
setOpen(true);
setActiveIndex((index) => Math.max(index - 1, 0));
} else if (event.key === "Enter" && open && activeIndex >= 0) {
event.preventDefault();
selectOption(filteredOptions[activeIndex]);
} else if (event.key === "Escape" && open) {
setOpen(false);
setActiveIndex(-1);
} else {
onKeyDown?.(event);
}
}
return (
<div className="nds-autocomplete-field" ref={rootRef}>
<input
{...props}
ref={setInputRef}
id={inputId}
className={`${className} nds-category-autocomplete-input`.trim()}
name={name}
type="text"
value={cleanValue}
placeholder={placeholder}
autoComplete={autoComplete}
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
role="combobox"
aria-autocomplete="list"
aria-expanded={showMenu}
aria-controls={menuId}
aria-activedescendant={activeIndex >= 0 ? `${menuId}-${activeIndex}` : undefined}
/>
{showMenu && (
<div className="nds-autocomplete-menu" id={menuId} role="listbox">
{filteredOptions.map((option, index) => (
<button
className={`nds-autocomplete-option ${index === activeIndex ? "is-active" : ""}`}
id={`${menuId}-${index}`}
key={option}
type="button"
role="option"
aria-selected={index === activeIndex}
onMouseDown={(event) => event.preventDefault()}
onClick={() => selectOption(option)}
>
{option}
</button>
))}
</div>
)}
</div>
);
});

View file

@ -1,5 +1,6 @@
// Rôle : fournit l'outil checklist avec quantités, catégories et échange texte.
import { useState } from "react";
import { CategoryAutocompleteInput } from "../../../components/CategoryAutocompleteInput.jsx";
import { Icon } from "../../../components/Icon.jsx";
import { useGroupedReorder, moveItem } from "../../../hooks/useGroupedReorder.js";
import { ChecklistSection } from "./ChecklistParts.jsx";
@ -22,6 +23,7 @@ export function ChecklistModule({ toolboxId, moduleId, context, editing, textExc
const [sectionTitle, setSectionTitle] = useState("");
const [qty, setQty] = useState(1);
const namedSections = data.sections.filter((section) => section.title);
const categoryOptions = namedSections.map((section) => section.title);
const completedSectionsMode = data.hideCompletedSectionsFully ? "hidden" : data.hideCompletedSections ? "reduced" : "visible";
const reorderItems = getChecklistReorderItems(data.sections);
const {
@ -113,7 +115,7 @@ export function ChecklistModule({ toolboxId, moduleId, context, editing, textExc
{editing && (
<div className="module-add-panel">
<form className="inline-form checklist-add-form" onSubmit={addItem}>
<input className="nds-input" data-size="sm" name="section" type="text" placeholder={textContent.sectionPlaceholder || "Catégorie"} value={sectionTitle} onChange={(event) => setSectionTitle(event.target.value)} />
<CategoryAutocompleteInput className="nds-input" data-size="sm" name="section" placeholder={textContent.sectionPlaceholder || "Catégorie"} value={sectionTitle} onChange={(event) => setSectionTitle(event.target.value)} options={categoryOptions} />
<input className="nds-input" data-size="sm" name="label" type="text" placeholder={textContent.itemPlaceholder || "Nouvel item"} value={label} onChange={(event) => setLabel(event.target.value)} />
<input className="checklist-qty-input nds-input" data-size="sm" name="qty" type="number" min="1" value={qty} onChange={(event) => setQty(event.target.value)} aria-label={textContent.quantityLabel || "Quantité cible"} />
<button className="nds-button" data-size="sm" data-variant="primary">{textContent.addButton || "Ajouter"}</button>
@ -172,6 +174,7 @@ export function ChecklistModule({ toolboxId, moduleId, context, editing, textExc
onUpdateItem={updateItem}
onMoveItemToCategory={moveItemToCategory}
onDeleteItem={deleteItem}
categoryOptions={categoryOptions}
/>,
showBoundaryDropZones && isLastSection ? (
<li

View file

@ -1,5 +1,6 @@
// Rôle : rend les sections et items de checklist avec édition inline et quantités.
import { useEffect, useRef, useState } from "react";
import { CategoryAutocompleteInput } from "../../../components/CategoryAutocompleteInput.jsx";
import { Icon } from "../../../components/Icon.jsx";
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import {
@ -8,7 +9,7 @@ import {
keepChecklistSection
} from "./checklistUtils.js";
export function ChecklistSection({ section, context, data, reorder, onRenameSection, onUpdateSection, onSaveSections, onUpdateItem, onMoveItemToCategory, onDeleteItem }) {
export function ChecklistSection({ section, context, data, reorder, onRenameSection, onUpdateSection, onSaveSections, onUpdateItem, onMoveItemToCategory, onDeleteItem, categoryOptions = [] }) {
const showTitle = Boolean(section.title);
const sectionComplete = showTitle && isSectionComplete(section, context);
const hideWhenComplete = getSectionHideWhenComplete(section, data.hideCompletedSections);
@ -117,6 +118,7 @@ export function ChecklistSection({ section, context, data, reorder, onRenameSect
onUpdateItem={onUpdateItem}
onMoveItemToCategory={onMoveItemToCategory}
onDeleteItem={onDeleteItem}
categoryOptions={categoryOptions}
editing={editingItemId === item.id}
onToggleEditing={() => setEditingItemId(editingItemId === item.id ? "" : item.id)}
onCloseEditing={() => setEditingItemId("")}
@ -128,7 +130,7 @@ export function ChecklistSection({ section, context, data, reorder, onRenameSect
);
}
function ChecklistItem({ item, section, context, reorder, items, save, onUpdateItem, onMoveItemToCategory, onDeleteItem, editing, onToggleEditing, onCloseEditing }) {
function ChecklistItem({ item, section, context, reorder, items, save, onUpdateItem, onMoveItemToCategory, onDeleteItem, categoryOptions, editing, onToggleEditing, onCloseEditing }) {
const itemRef = useRef(null);
const done = context.clampQty(item.qtyCurrent, item.qtyTarget) >= item.qtyTarget;
const textContent = context.moduleText?.checklist || {};
@ -221,13 +223,13 @@ function ChecklistItem({ item, section, context, reorder, items, save, onUpdateI
"aria-label": textContent.itemEditLabel || "Libellé"
})}
/>
<input
<CategoryAutocompleteInput
{...categoryEdit.getInputProps({
className: "checklist-category-input nds-inline-input",
type: "text",
placeholder: textContent.sectionPlaceholder || "Catégorie",
"aria-label": textContent.categoryEditLabel || "Catégorie"
})}
options={categoryOptions}
/>
</>
) : (

View file

@ -1,4 +1,5 @@
// Rôle : rend le formulaire de création et édition des combos.
import { CategoryAutocompleteInput } from "../../../components/CategoryAutocompleteInput.jsx";
import { Icon } from "../../../components/Icon.jsx";
import { ComboInputToken, ComboSequence } from "./CombosParts.jsx";
import {
@ -10,6 +11,7 @@ import {
export function CombosEditor({
data,
textContent,
categoryOptions,
comboCategory,
comboName,
customKey,
@ -78,7 +80,7 @@ export function CombosEditor({
<div className="combos-form-grid">
<label>
<span>{textContent.targetCategoryLabel || "Catégorie"}</span>
<input className="nds-input" data-size="sm" type="text" value={comboCategory} onChange={(event) => onComboCategoryChange(event.target.value)} placeholder={textContent.noCategoryLabel || "Sans catégorie"} />
<CategoryAutocompleteInput className="nds-input" data-size="sm" value={comboCategory} onChange={(event) => onComboCategoryChange(event.target.value)} placeholder={textContent.noCategoryLabel || "Sans catégorie"} options={categoryOptions} />
</label>
<label>
<span>{textContent.comboNameLabel || "Nom"}</span>

View file

@ -12,6 +12,10 @@ import { useCombosReorder } from "./useCombosReorder.js";
export function CombosModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
const data = context.normalizeCombosData(context.getModuleData(toolboxId, moduleId, { combos: [] }));
const textContent = context.moduleText?.combos || {};
const categoryOptions = [
...data.categoryOrder,
...data.combos.map(getComboCategory).filter(Boolean).filter((category) => !data.categoryOrder.includes(category))
];
const entries = getGroupedEntries(data.combos, {
groupOrder: data.categoryOrder,
getItemGroup: getComboCategory,
@ -48,6 +52,7 @@ export function CombosModule({ toolboxId, moduleId, context, editing, textExchan
<CombosEditor
data={data}
textContent={textContent}
categoryOptions={categoryOptions}
comboCategory={editorState.comboCategory}
comboName={editorState.comboName}
customKey={editorState.customKey}

View file

@ -1,51 +1,154 @@
// Rôle : fournit l'outil liens avec ajout manuel et échange texte.
import { useState } from "react";
import { CategoryAutocompleteInput } from "../../../components/CategoryAutocompleteInput.jsx";
import { Icon } from "../../../components/Icon.jsx";
import { useDraftForm } from "../../../hooks/useDraftForm.js";
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { applyGroupedReorderOperation, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
function getLinkCategory(link) {
return String(link?.category || "").trim();
}
function setLinkCategory(link, category) {
const cleanCategory = String(category || "").trim();
const nextLink = { ...link };
if (cleanCategory) nextLink.category = cleanCategory;
else delete nextLink.category;
return nextLink;
}
function getLinkEntries(data) {
const ungrouped = data.links.filter((link) => !getLinkCategory(link)).map((link) => ({ type: "item", item: link }));
const categories = data.categoryOrder.map((category) => ({
type: "group",
group: {
category,
links: data.links.filter((link) => getLinkCategory(link) === category)
}
}));
return [...ungrouped, ...categories];
}
export function LinksModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
const data = context.normalizeLinksData(context.getModuleData(toolboxId, moduleId, { links: [] }));
const textContent = context.moduleText?.links || {};
const linkDraft = useDraftForm({ title: "", url: "" });
const linkDraft = useDraftForm({ title: "", url: "", category: "" });
const categoryOptions = data.categoryOrder;
const [copiedId, setCopiedId] = useState("");
const {
itemReorder,
groupReorder,
getItemProps,
getGroupProps,
getGroupBoundaryProps,
isItemDragging,
isItemDropTarget,
isGroupDragging,
isGroupDropTarget,
isBoundaryDropTarget,
getDropPlacement
} = useGroupedReorder({
namespace: "links",
namespace: `links-${moduleId}`,
items: data.links,
getItemId: (link) => link.id,
getParentId: () => moduleId,
onItemMove: (operation) => save(moveItem(data.links, operation.sourceId, operation.targetId, operation.placement)),
hierarchy: { enabled: true, stickyParents: true }
getItemGroup: getLinkCategory,
reorderFeatures: {
item: {
groupChange: true,
boundaryDrop: true,
ungroupedDrop: true
},
group: {
reorder: true,
boundaryDrop: true,
ungroupedDrop: true,
itemDrop: true
}
},
onItemMove: applyLinkReorder,
onGroupMove: applyLinkReorder,
hierarchy: { enabled: false, stickyParents: false }
});
function save(links) {
context.setModuleData(toolboxId, moduleId, { links });
function save(nextData) {
context.setModuleData(toolboxId, moduleId, nextData, "links");
}
function applyLinkReorder(operation) {
if (operation.type === "group" && data.categoryOrder.includes(operation.sourceId)) {
const categoryOrder = data.categoryOrder.filter((category) => category !== operation.sourceId);
const targetIndex = categoryOrder.indexOf(operation.targetId);
if (targetIndex >= 0) {
categoryOrder.splice(operation.placement === "after" ? targetIndex + 1 : targetIndex, 0, operation.sourceId);
save({ ...data, categoryOrder });
return;
}
}
const nextData = applyGroupedReorderOperation(data, {
operation,
itemsKey: "links",
groupOrderKey: "categoryOrder",
collapsedGroupsKey: "collapsedCategories",
getItemGroup: getLinkCategory,
setItemGroup: setLinkCategory
});
save({
...nextData,
categoryOrder: [
...data.categoryOrder,
...nextData.categoryOrder.filter((category) => !data.categoryOrder.includes(category))
]
});
}
function addLink(event) {
linkDraft.handleSubmit(event, (draft, { reset }) => {
const cleanUrl = context.normalizeUrl(draft.url);
if (!cleanUrl) return;
const category = draft.category.trim();
save([
...data.links,
{
id: context.uid("link"),
title: draft.title.trim(),
url: cleanUrl
if (!cleanUrl) {
if (category && !data.categoryOrder.includes(category)) {
save({ ...data, categoryOrder: [...data.categoryOrder, category] });
reset();
}
]);
return;
}
const categoryOrder = category && !data.categoryOrder.includes(category) ? [...data.categoryOrder, category] : data.categoryOrder;
save({
...data,
categoryOrder,
links: [
...data.links,
{
id: context.uid("link"),
title: draft.title.trim(),
url: cleanUrl,
...(category ? { category } : {})
}
]
});
reset();
});
}
function deleteCategory(category) {
save({
...data,
categoryOrder: data.categoryOrder.filter((item) => item !== category),
collapsedCategories: data.collapsedCategories.filter((item) => item !== category),
links: data.links.map((link) => getLinkCategory(link) === category ? setLinkCategory(link, "") : link)
});
}
function toggleCategoryCollapsed(category) {
const collapsedCategories = data.collapsedCategories.includes(category)
? data.collapsedCategories.filter((item) => item !== category)
: [...data.collapsedCategories, category];
save({ ...data, collapsedCategories });
}
async function copyUrl(link) {
const copied = await context.copyText(link.url);
if (!copied) return;
@ -61,6 +164,7 @@ export function LinksModule({ toolboxId, moduleId, context, editing, textExchang
<form className="inline-form links-add-form" onSubmit={addLink}>
<input {...linkDraft.getFieldProps("title", { className: "nds-input", "data-size": "sm", type: "text", placeholder: textContent.titlePlaceholder || "Nom du lien" })} />
<input {...linkDraft.getFieldProps("url", { className: "nds-input", "data-size": "sm", type: "url", placeholder: textContent.urlPlaceholder || "https://..." })} />
<CategoryAutocompleteInput {...linkDraft.getFieldProps("category", { className: "nds-input", "data-size": "sm", placeholder: textContent.categoryPlaceholder || "Catégorie" })} options={categoryOptions} />
<button className="nds-button" data-size="sm" data-variant="primary">{textContent.addButton || "Ajouter"}</button>
</form>
</div>
@ -70,44 +174,125 @@ export function LinksModule({ toolboxId, moduleId, context, editing, textExchang
<TextExchangeActions type="links" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "links")} />
</div>
)}
<ul className="links-list">
{data.links.map((link) => {
const className = [
"link-item",
isItemDragging(link.id) ? "is-dragging" : "",
isItemDropTarget(link.id) ? "is-drop-target" : "",
getDropPlacement("item", link.id) === "after" ? "drop-after" : ""
].filter(Boolean).join(" ");
<div className="links-category-list">
{getLinkEntries(data).map((entry) => {
if (entry.type === "group") {
const category = entry.group.category;
const isCollapsed = data.collapsedCategories.includes(category);
const showBoundaryDropZones = Boolean(itemReorder.draggingId || groupReorder.draggingId);
const categoryClassName = [
"links-category-section checklist-section is-grouped",
isCollapsed ? "is-collapsed" : "",
isGroupDragging(category) ? "is-dragging" : "",
isGroupDropTarget(category) ? "is-drop-target" : "",
getDropPlacement("group", category) === "after" ? "drop-after" : ""
].filter(Boolean).join(" ");
return (
<li className={className} key={link.id} {...getItemProps({ itemId: link.id, parentId: moduleId })}>
<button
className="link-drag-handle nds-icon-button"
data-size="sm"
data-variant="secondary"
type="button"
onPointerDown={(event) => itemReorder.startDrag(event, link.id)}
aria-label={`${textContent.reorderTitle || "Déplacer"} ${link.title || link.url}`}
title={textContent.reorderTitle || "Déplacer"}
>
<Icon name="drag" />
</button>
<a href={link.url} target="_blank" rel="noreferrer" title={link.url}>
<strong>{link.title || context.hostnameFromUrl(link.url)}</strong>
<span>{link.url}</span>
</a>
<div>
<button type="button" className="nds-icon-button" data-size="sm" onClick={() => copyUrl(link)} aria-label={`${textContent.copyTitle || "Copier"} ${link.title || link.url}`} title={copiedId === link.id ? textContent.copiedTitle || "Copié" : textContent.copyTitle || "Copier"}>
<Icon name="copy" />
</button>
<button type="button" className="nds-icon-button" data-size="sm" data-variant="danger" onClick={() => save(data.links.filter((item) => item.id !== link.id))} aria-label={`${textContent.deleteTitle || "Supprimer"} ${link.title || link.url}`} title={textContent.deleteTitle || "Supprimer"}>
<Icon name="trash" />
</button>
</div>
</li>
);
return (
<div className="links-category-block" key={`category:${category}`}>
{showBoundaryDropZones && (
<div
className={`links-category-boundary-drop-zone ${isBoundaryDropTarget({ groupId: category, placement: "before" }) ? "is-drop-target" : ""}`}
{...getGroupBoundaryProps({ groupId: category, placement: "before" })}
/>
)}
<section className={categoryClassName} {...getGroupProps({ groupId: category })}>
<div className="checklist-section-header links-category-header">
<div className="links-category-title">
<button
className="task-planner-category-drag-handle nds-icon-button"
data-size="xs"
data-variant="secondary"
type="button"
onPointerDown={(event) => groupReorder.startDrag(event, { groupId: category })}
aria-label={`${textContent.reorderCategoryTitle || "Déplacer la catégorie"} ${category}`}
title={textContent.reorderCategoryTitle || "Déplacer la catégorie"}
>
<Icon name="drag" />
</button>
<h3>{category}</h3>
</div>
<div className="checklist-section-actions links-category-actions">
<span>{entry.group.links.length}</span>
<button
className="checklist-section-collapse-button nds-icon-button"
data-size="xs"
type="button"
onClick={() => toggleCategoryCollapsed(category)}
aria-expanded={!isCollapsed}
aria-label={isCollapsed ? textContent.showCategoryTitle || "Afficher la catégorie" : textContent.hideCategoryTitle || "Réduire la catégorie"}
title={isCollapsed ? textContent.showCategoryTitle || "Afficher la catégorie" : textContent.hideCategoryTitle || "Réduire la catégorie"}
>
<Icon name={isCollapsed ? "chevron-down" : "chevron-up"} />
</button>
<button
className="nds-icon-button"
data-size="xs"
data-variant="danger"
type="button"
onClick={() => deleteCategory(category)}
aria-label={`${textContent.deleteCategoryTitle || "Supprimer la catégorie"} ${category}`}
title={textContent.deleteCategoryTitle || "Supprimer la catégorie"}
>
<Icon name="trash" />
</button>
</div>
</div>
{!isCollapsed && (
<div className="links-list">
{entry.group.links.map(renderLink)}
</div>
)}
</section>
{showBoundaryDropZones && (
<div
className={`links-category-boundary-drop-zone ${isBoundaryDropTarget({ groupId: category, placement: "after" }) ? "is-drop-target" : ""}`}
{...getGroupBoundaryProps({ groupId: category, placement: "after" })}
/>
)}
</div>
);
}
return renderLink(entry.item);
})}
</ul>
</div>
</>
);
function renderLink(link) {
const className = [
"link-item",
isItemDragging(link.id) ? "is-dragging" : "",
isItemDropTarget(link.id) ? "is-drop-target" : "",
getDropPlacement("item", link.id) === "after" ? "drop-after" : ""
].filter(Boolean).join(" ");
return (
<article className={className} key={link.id} {...getItemProps({ itemId: link.id, groupId: getLinkCategory(link) })}>
<button
className="link-drag-handle nds-icon-button"
data-size="sm"
data-variant="secondary"
type="button"
onPointerDown={(event) => itemReorder.startDrag(event, link.id)}
aria-label={`${textContent.reorderTitle || "Déplacer"} ${link.title || link.url}`}
title={textContent.reorderTitle || "Déplacer"}
>
<Icon name="drag" />
</button>
<a href={link.url} target="_blank" rel="noreferrer" title={link.url}>
<strong>{link.title || context.hostnameFromUrl(link.url)}</strong>
<span>{link.url}</span>
</a>
<div>
<button type="button" className="nds-icon-button" data-size="sm" onClick={() => copyUrl(link)} aria-label={`${textContent.copyTitle || "Copier"} ${link.title || link.url}`} title={copiedId === link.id ? textContent.copiedTitle || "Copié" : textContent.copyTitle || "Copier"}>
<Icon name="copy" />
</button>
<button type="button" className="nds-icon-button" data-size="sm" data-variant="danger" onClick={() => save({ ...data, links: data.links.filter((item) => item.id !== link.id) })} aria-label={`${textContent.deleteTitle || "Supprimer"} ${link.title || link.url}`} title={textContent.deleteTitle || "Supprimer"}>
<Icon name="trash" />
</button>
</div>
</article>
);
}
}

View file

@ -104,12 +104,24 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, textExchangeO
const [occurrenceDrafts, setOccurrenceDrafts] = useState({ perHour: null, perDay: null, perMonth: null });
const rateDraft = useDraftForm({ label: "", rate: RNG_SIMULATOR_DEFAULT_RATE });
const activeRate = data.rates.find((rate) => rate.id === data.activeRateId) || data.rates[0] || null;
const summary = useMemo(() => activeRate ? calculateRngSummary({ rate: activeRate.rate, ...data.settings }) : null, [activeRate, data.settings]);
const summary = useMemo(() => activeRate ? calculateRngSummary({
rate: activeRate.rate,
multiplier: activeRate.multiplier,
simulationDraws: data.settings.simulationDraws,
perHour: activeRate.perHour,
perDay: activeRate.perDay,
perMonth: activeRate.perMonth
}) : null, [activeRate, data.settings.simulationDraws]);
useEffect(() => {
setHistory(readSimulationHistory(historyStorageKey));
}, [historyStorageKey]);
useEffect(() => {
setMultiplierDraft(null);
setOccurrenceDrafts({ perHour: null, perDay: null, perMonth: null });
}, [activeRate?.id]);
function save(nextData) {
context.setModuleData(toolboxId, moduleId, nextData, "rngSimulator");
}
@ -118,21 +130,28 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, textExchangeO
const settings = { ...data.settings };
if (field === "simulationDraws") {
settings[field] = clampInteger(value, RNG_SIMULATOR_LIMITS.simulationDrawsMin, RNG_SIMULATOR_LIMITS.simulationDrawsMax, data.settings.simulationDraws);
} else if (field === "multiplier") {
settings[field] = clampDecimal(value, RNG_SIMULATOR_LIMITS.multiplierMin, RNG_SIMULATOR_LIMITS.multiplierMax, 0);
} else {
settings[field] = clampDecimal(value, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, data.settings[field]);
}
save({ ...data, settings });
}
function updateActiveRateSettings(field, value) {
if (!activeRate) return;
const nextValue = field === "multiplier"
? clampDecimal(value, RNG_SIMULATOR_LIMITS.multiplierMin, RNG_SIMULATOR_LIMITS.multiplierMax, 0)
: clampDecimal(value, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, activeRate[field]);
save({
...data,
rates: data.rates.map((rate) => rate.id === activeRate.id ? { ...rate, [field]: nextValue } : rate)
});
}
function updateMultiplier(value) {
setMultiplierDraft(value);
if (String(value).trim() === "") {
updateSettings("multiplier", 0);
updateActiveRateSettings("multiplier", 0);
return;
}
updateSettings("multiplier", value);
updateActiveRateSettings("multiplier", value);
}
function blurMultiplier() {
@ -141,7 +160,7 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, textExchangeO
const parsed = Number(raw.replace(",", "."));
if (!raw || !Number.isFinite(parsed)) {
setMultiplierDraft(null);
updateSettings("multiplier", 0);
updateActiveRateSettings("multiplier", 0);
return;
}
setMultiplierDraft(null);
@ -149,16 +168,16 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, textExchangeO
function clearMultiplier() {
setMultiplierDraft(null);
updateSettings("multiplier", 0);
updateActiveRateSettings("multiplier", 0);
}
function updateOccurrence(field, value) {
setOccurrenceDrafts((drafts) => ({ ...drafts, [field]: value }));
if (String(value).trim() === "") {
updateSettings(field, 0);
updateActiveRateSettings(field, 0);
return;
}
updateSettings(field, value);
updateActiveRateSettings(field, value);
}
function blurOccurrence(field) {
@ -166,7 +185,7 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, textExchangeO
if (value === null) return;
const raw = value.trim();
const parsed = Number(raw.replace(",", "."));
if (!raw || !Number.isFinite(parsed)) updateSettings(field, 0);
if (!raw || !Number.isFinite(parsed)) updateActiveRateSettings(field, 0);
setOccurrenceDrafts((drafts) => ({ ...drafts, [field]: null }));
}
@ -207,12 +226,12 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, textExchangeO
function runSimulation() {
if (!activeRate) return;
const result = simulateRngDraws(activeRate.rate, data.settings.simulationDraws, Math.random, data.settings.multiplier);
const result = simulateRngDraws(activeRate.rate, data.settings.simulationDraws, Math.random, activeRate.multiplier);
const entry = {
id: context.uid("rng"),
label: activeRate.label,
rate: activeRate.rate,
multiplier: data.settings.multiplier,
multiplier: activeRate.multiplier,
...result
};
const nextHistory = addRngSimulationHistory(history, entry);
@ -302,7 +321,7 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, textExchangeO
<div className="rng-simulator-field rng-simulator-multiplier-field">
<span>{textContent.multiplierLabel || "Multiplicateur"}</span>
<div className="rng-simulator-percent-input">
<input className="nds-input" data-size="sm" type="text" inputMode="decimal" value={multiplierDraft ?? String(data.settings.multiplier ?? 0)} onChange={(event) => updateMultiplier(event.target.value)} onBlur={blurMultiplier} aria-label={textContent.multiplierLabel || "Multiplicateur"} />
<input className="nds-input" data-size="sm" type="text" inputMode="decimal" value={multiplierDraft ?? String(activeRate.multiplier ?? 0)} onChange={(event) => updateMultiplier(event.target.value)} onBlur={blurMultiplier} aria-label={textContent.multiplierLabel || "Multiplicateur"} />
<span>%</span>
<button className="nds-icon-button" data-size="xs" type="button" onClick={clearMultiplier} aria-label={textContent.clearMultiplierButton || "Réinitialiser le multiplicateur"} title={textContent.clearMultiplierButton || "Réinitialiser le multiplicateur"}>
<Icon name="rubber" />
@ -320,15 +339,15 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, textExchangeO
<div className="rng-simulator-settings rng-simulator-three-columns">
<label className="rng-simulator-field">
<span>{textContent.perHourLabel || "Par heure"}</span>
<input className="nds-input" data-size="sm" type="text" inputMode="decimal" value={occurrenceDrafts.perHour ?? String(data.settings.perHour ?? 0)} onChange={(event) => updateOccurrence("perHour", event.target.value)} onBlur={() => blurOccurrence("perHour")} />
<input className="nds-input" data-size="sm" type="text" inputMode="decimal" value={occurrenceDrafts.perHour ?? String(activeRate.perHour ?? 0)} onChange={(event) => updateOccurrence("perHour", event.target.value)} onBlur={() => blurOccurrence("perHour")} />
</label>
<label className="rng-simulator-field">
<span>{textContent.perDayLabel || "Par jour"}</span>
<input className="nds-input" data-size="sm" type="text" inputMode="decimal" value={occurrenceDrafts.perDay ?? String(data.settings.perDay ?? 0)} onChange={(event) => updateOccurrence("perDay", event.target.value)} onBlur={() => blurOccurrence("perDay")} />
<input className="nds-input" data-size="sm" type="text" inputMode="decimal" value={occurrenceDrafts.perDay ?? String(activeRate.perDay ?? 0)} onChange={(event) => updateOccurrence("perDay", event.target.value)} onBlur={() => blurOccurrence("perDay")} />
</label>
<label className="rng-simulator-field">
<span>{textContent.perMonthLabel || "Par mois"}</span>
<input className="nds-input" data-size="sm" type="text" inputMode="decimal" value={occurrenceDrafts.perMonth ?? String(data.settings.perMonth ?? 0)} onChange={(event) => updateOccurrence("perMonth", event.target.value)} onBlur={() => blurOccurrence("perMonth")} />
<input className="nds-input" data-size="sm" type="text" inputMode="decimal" value={occurrenceDrafts.perMonth ?? String(activeRate.perMonth ?? 0)} onChange={(event) => updateOccurrence("perMonth", event.target.value)} onBlur={() => blurOccurrence("perMonth")} />
</label>
</div>
<div className="rng-simulator-time-grid">

View file

@ -13,7 +13,11 @@ import { useTaskPlannerReorder } from "./useTaskPlannerReorder.js";
export function TaskPlannerModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
const data = context.normalizeTaskPlannerData(context.getModuleData(toolboxId, moduleId, { tasks: [] }));
const textContent = context.moduleText?.taskPlanner || {};
const taskDraft = useDraftForm({ title: "", type: "daily" });
const taskDraft = useDraftForm({ title: "", type: "daily", category: "" });
const categoryOptions = useMemo(() => [
...data.categoryOrder,
...data.tasks.map((task) => task.category).filter(Boolean).filter((category) => !data.categoryOrder.includes(category))
], [data.categoryOrder, data.tasks]);
const [nowMs, setNowMs] = useState(() => Date.now());
const [settingsOpen, setSettingsOpen] = useState(false);
const [openDescriptions, setOpenDescriptions] = useState(() => new Set());
@ -45,19 +49,20 @@ export function TaskPlannerModule({ toolboxId, moduleId, context, editing, textE
taskDraft.handleSubmit(event, (draft, { reset }) => {
const cleanTitle = draft.title.trim();
if (!cleanTitle) return;
const category = draft.category.trim();
const task = {
id: context.uid("task"),
title: cleanTitle,
description: "",
type: draft.type,
checked: false,
checkedAt: 0
};
if (category) task.category = category;
save({
...data,
tasks: [
...data.tasks,
{
id: context.uid("task"),
title: cleanTitle,
description: "",
type: draft.type,
checked: false,
checkedAt: 0
}
]
tasks: [...data.tasks, task],
categoryOrder: category && !data.categoryOrder.includes(category) ? [...data.categoryOrder, category] : data.categoryOrder
});
reset();
});
@ -171,6 +176,7 @@ export function TaskPlannerModule({ toolboxId, moduleId, context, editing, textE
context={context}
editing={editing}
settingsOpen={settingsOpen}
categoryOptions={categoryOptions}
taskDraft={taskDraft}
textContent={textContent}
textExchangeOpen={textExchangeOpen}

View file

@ -1,4 +1,5 @@
// Rôle : rend la barre haute du task planner, l'ajout rapide et l'échange texte.
import { CategoryAutocompleteInput } from "../../../components/CategoryAutocompleteInput.jsx";
import { Icon } from "../../../components/Icon.jsx";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
import {
@ -11,6 +12,7 @@ export function TaskPlannerTopbar({
context,
editing,
settingsOpen,
categoryOptions,
taskDraft,
textContent,
textExchangeOpen,
@ -51,6 +53,7 @@ export function TaskPlannerTopbar({
<div className="task-planner-add-stack">
<form className="inline-form task-planner-add-form" onSubmit={onAddTask}>
<input {...taskDraft.getFieldProps("title", { className: "nds-input", "data-size": "sm", type: "text", placeholder: textContent.titlePlaceholder || "Nouvelle tâche" })} />
<CategoryAutocompleteInput {...taskDraft.getFieldProps("category", { className: "nds-input", "data-size": "sm", placeholder: textContent.categoryPlaceholder || "Nom de catégorie" })} options={categoryOptions} />
<select {...taskDraft.getFieldProps("type", { className: "nds-select", "data-size": "sm", "aria-label": textContent.typeLabel || "Type" })}>
{TASK_TYPES.map((taskType) => <option key={taskType} value={taskType}>{getTaskTypeLabel(taskType, textContent)}</option>)}
</select>

View file

@ -1,7 +1,7 @@
// Rôle : gère l'import/export texte des outils simples et tabulaires.
import { cellAddress, columnIndexToName } from "../tableFormulaEngine.js";
import { evaluateCalculatorEntries } from "../calculatorUtils.js";
import { cloneDrawingData, lineError, parseBoolean, parseColonImportLines, parseInteger, parseMetaLine, parseNumber, splitLines, textToNotepadExchangeHtml } from "./shared.js";
import { cloneDrawingData, lineError, parseBoolean, parseColonImportLines, parseInteger, parseMetaLine, parseNumber, parsePipeParts, splitLines, textToNotepadExchangeHtml } from "./shared.js";
export function exportNotepadText(data) {
const pages = Array.isArray(data?.pages) && data.pages.length ? data.pages : [data];
@ -171,23 +171,45 @@ export function importChecklistText(text, context) {
export function exportLinksText(data) {
return (data.links || []).map((link) => link.title ? `${link.title}: ${link.url}` : link.url).join("\n");
const lines = [];
(data.links || []).filter((link) => !link.category).forEach((link) => {
const line = link.title ? `${link.title}: ${link.url}` : link.url;
lines.push(line);
});
(data.categoryOrder || []).forEach((category) => {
lines.push(`# ${category}`);
(data.links || []).filter((link) => link.category === category).forEach((link) => {
lines.push(link.title ? `${link.title}: ${link.url}` : link.url);
});
});
return lines.join("\n");
}
export function importLinksText(text, context) {
const links = [];
const categoryOrder = [];
let category = "";
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 (line.startsWith("#")) {
category = line.replace(/^#+/, "").trim();
if (category && !categoryOrder.includes(category)) categoryOrder.push(category);
return;
}
const parsed = parsePipeParts(line);
const entryLine = parsed.title;
const separatorIndex = entryLine.indexOf(":");
const maybeUrl = separatorIndex >= 0 ? entryLine.slice(separatorIndex + 1).trim() : entryLine;
const title = separatorIndex >= 0 && !/^[a-z][a-z0-9+.-]*:\/\//i.test(entryLine) ? entryLine.slice(0, separatorIndex).trim() : "";
const url = context.normalizeUrl(title ? maybeUrl : entryLine);
if (!url) throw lineError(index, `URL invalide: ${line}`);
links.push({ id: context.uid("link"), title, url });
const linkCategory = parsed.meta.category || category;
if (linkCategory && !categoryOrder.includes(linkCategory)) categoryOrder.push(linkCategory);
links.push({ id: context.uid("link"), title, url, ...(linkCategory ? { category: linkCategory } : {}) });
});
return context.normalizeLinksData({ links });
return context.normalizeLinksData({ links, categoryOrder });
}

View file

@ -6,16 +6,12 @@ export function exportRngSimulatorText(data) {
lines.push("@activeRateId: " + (data.activeRateId || ""));
lines.push("[rates]");
(data.rates || []).forEach((rate) => {
lines.push(`- ${rate.label} | rate=${rate.rate}${rate.id === data.activeRateId ? " | active=true" : ""}`);
lines.push(`- ${rate.label} | rate=${rate.rate}${rate.id === data.activeRateId ? " | active=true" : ""} | multiplier=${rate.multiplier ?? 0} | perHour=${rate.perHour || 0} | perDay=${rate.perDay || 0} | perMonth=${rate.perMonth || 0}`);
});
lines.push(
"",
"[settings]",
`multiplier: ${data.settings?.multiplier ?? 0}`,
`simulationDraws: ${data.settings?.simulationDraws || 1000}`,
`perHour: ${data.settings?.perHour || 0}`,
`perDay: ${data.settings?.perDay || 0}`,
`perMonth: ${data.settings?.perMonth || 0}`
`simulationDraws: ${data.settings?.simulationDraws || 1000}`
);
return lines.join("\n").trim();
}
@ -24,7 +20,7 @@ export function importRngSimulatorText(text, context) {
const data = {
activeRateId: "",
rates: [],
settings: { multiplier: 0, simulationDraws: 1000, perHour: 0, perDay: 0, perMonth: 0 }
settings: { simulationDraws: 1000 }
};
let section = "";
@ -44,18 +40,22 @@ export function importRngSimulatorText(text, context) {
if (separatorIndex > 0 && !cleanLine.startsWith("-")) {
const key = cleanLine.slice(0, separatorIndex).trim();
const value = cleanLine.slice(separatorIndex + 1).trim();
if (section === "settings" && key === "multiplier") data.settings.multiplier = value;
if (section === "settings" && key === "simulationDraws") data.settings.simulationDraws = parseInteger(value, 1000);
if (section === "settings" && key === "perHour") data.settings.perHour = value;
if (section === "settings" && key === "perDay") data.settings.perDay = value;
if (section === "settings" && key === "perMonth") data.settings.perMonth = value;
return;
}
if (section === "rates" && cleanLine.startsWith("-")) {
const { title, meta: pipeMeta } = parsePipeParts(cleanLine.replace(/^[-*]\s*/, ""));
if (!title) throw lineError(index, "libellé de taux manquant.");
const id = context.uid("rng");
data.rates.push({ id, label: title, rate: pipeMeta.rate });
data.rates.push({
id,
label: title,
rate: pipeMeta.rate,
multiplier: pipeMeta.multiplier,
perHour: pipeMeta.perHour,
perDay: pipeMeta.perDay,
perMonth: pipeMeta.perMonth
});
if (pipeMeta.active === "true") data.activeRateId = id;
return;
}

View file

@ -23,18 +23,33 @@ export function hostnameFromUrl(value) {
}
export function normalizeLinksData(data) {
const links = (data?.links || [])
.map((link) => {
const url = normalizeUrl(link?.url);
if (!url) return null;
const category = String(link?.category || "").trim();
return {
id: link?.id || uid("link"),
title: String(link?.title || "").trim(),
url,
...(category ? { category } : {})
};
})
.filter(Boolean);
const linkCategories = [...new Set(links.map((link) => link.category).filter(Boolean))];
const categoryOrder = [
...(Array.isArray(data?.categoryOrder) ? data.categoryOrder : [])
.map((category) => String(category || "").trim())
.filter((category, index, categories) => category && categories.indexOf(category) === index),
...linkCategories
].filter((category, index, categories) => categories.indexOf(category) === index);
return {
links: (data?.links || [])
.map((link) => {
const url = normalizeUrl(link?.url);
if (!url) return null;
return {
id: link?.id || uid("link"),
title: String(link?.title || "").trim(),
url
};
})
.filter(Boolean)
categoryOrder,
collapsedCategories: (Array.isArray(data?.collapsedCategories) ? data.collapsedCategories : [])
.map((category) => String(category || "").trim())
.filter((category, index, categories) => category && categoryOrder.includes(category) && categories.indexOf(category) === index),
links
};
}
@ -42,7 +57,13 @@ export function compactLinksDataForStorage(value) {
const links = normalizeLinksData(value).links.map((link) => {
const compact = { id: link.id, url: link.url };
if (link.title) compact.title = link.title;
if (link.category) compact.category = link.category;
return compact;
});
return links.length ? { links } : null;
const normalized = normalizeLinksData(value);
const compact = {};
if (normalized.categoryOrder.length) compact.categoryOrder = normalized.categoryOrder;
if (normalized.collapsedCategories.length) compact.collapsedCategories = normalized.collapsedCategories;
if (links.length) compact.links = links;
return Object.keys(compact).length ? compact : null;
}

View file

@ -13,17 +13,17 @@ function normalizeRateEntry(entry) {
return {
id: entry?.id || uid("rng"),
label,
rate: normalizeRateString(entry?.rate)
rate: normalizeRateString(entry?.rate),
multiplier: clampDecimal(entry?.multiplier, RNG_SIMULATOR_LIMITS.multiplierMin, RNG_SIMULATOR_LIMITS.multiplierMax, 0),
perHour: clampDecimal(entry?.perHour, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, 0),
perDay: clampDecimal(entry?.perDay, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, 0),
perMonth: clampDecimal(entry?.perMonth, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, 0)
};
}
function normalizeSettings(settings) {
return {
multiplier: clampDecimal(settings?.multiplier, RNG_SIMULATOR_LIMITS.multiplierMin, RNG_SIMULATOR_LIMITS.multiplierMax, 0),
simulationDraws: clampInteger(settings?.simulationDraws, RNG_SIMULATOR_LIMITS.simulationDrawsMin, RNG_SIMULATOR_LIMITS.simulationDrawsMax, 1000),
perHour: clampDecimal(settings?.perHour, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, 0),
perDay: clampDecimal(settings?.perDay, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, 0),
perMonth: clampDecimal(settings?.perMonth, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, 0)
simulationDraws: clampInteger(settings?.simulationDraws, RNG_SIMULATOR_LIMITS.simulationDrawsMin, RNG_SIMULATOR_LIMITS.simulationDrawsMax, 1000)
};
}
@ -43,13 +43,16 @@ export function compactRngSimulatorDataForStorage(value) {
const normalized = normalizeRngSimulatorData(value);
const compact = {};
if (normalized.activeRateId) compact.activeRateId = normalized.activeRateId;
if (normalized.rates.length) compact.rates = normalized.rates;
if (
normalized.settings.simulationDraws !== 1000 ||
normalized.settings.multiplier !== 0 ||
normalized.settings.perHour !== 0 ||
normalized.settings.perDay !== 0 ||
normalized.settings.perMonth !== 0
) compact.settings = normalized.settings;
if (normalized.rates.length) {
compact.rates = normalized.rates.map((rate) => {
const compactRate = { id: rate.id, label: rate.label, rate: rate.rate };
if (rate.multiplier !== 0) compactRate.multiplier = rate.multiplier;
if (rate.perHour !== 0) compactRate.perHour = rate.perHour;
if (rate.perDay !== 0) compactRate.perDay = rate.perDay;
if (rate.perMonth !== 0) compactRate.perMonth = rate.perMonth;
return compactRate;
});
}
if (normalized.settings.simulationDraws !== 1000) compact.settings = normalized.settings;
return Object.keys(compact).length ? compact : null;
}

View file

@ -79,6 +79,7 @@ export function useToolboxAlerts({ toolboxes, moduleData, links, route, drawerGa
const currentOccurrence = getCurrentCalendarOccurrence(event, nowMs);
if (!currentOccurrence) return;
if (!getCalendarAlertOffsets(event).includes(0)) return;
const resumeKey = `${key}:${currentOccurrence.startMs}`;
if (resumedCalendarEventsRef.current.has(resumeKey)) return;
resumedCalendarEventsRef.current.add(resumeKey);

View file

@ -105,3 +105,57 @@
.nds-inline-input:focus {
@include controls.inline-field-hover;
}
.nds-autocomplete-field {
position: relative;
min-width: 0;
}
.nds-autocomplete-field,
.nds-autocomplete-field > input {
width: 100%;
}
.nds-category-autocomplete-input::-webkit-calendar-picker-indicator {
display: none !important;
}
.nds-autocomplete-menu {
position: absolute;
z-index: 30;
top: calc(100% + 4px);
right: 0;
left: 0;
display: grid;
gap: 4px;
max-height: 220px;
padding: 6px;
overflow: auto;
border: 1px solid rgba(165, 180, 252, 0.18);
border-radius: var(--radius-md);
background: rgba(10, 13, 28, 0.98);
box-shadow: var(--shadow-lg);
}
.nds-autocomplete-option {
min-width: 0;
padding: 8px 10px;
overflow: hidden;
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text-secondary);
font: inherit;
font-size: var(--font-size-sm);
font-weight: 800;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.nds-autocomplete-option:hover,
.nds-autocomplete-option.is-active {
border-color: rgba(246, 196, 83, 0.36);
background: rgba(246, 196, 83, 0.1);
color: var(--color-text-primary);
}

View file

@ -301,18 +301,10 @@
}
.links-add-form {
grid-template-columns: minmax(120px, 0.7fr) minmax(180px, 1fr) auto;
grid-template-columns: minmax(120px, 0.6fr) minmax(180px, 1fr) minmax(120px, 0.46fr) auto;
}
.links-list {
display: grid;
gap: 10px;
margin: var(--space-4);
padding: 0;
list-style: none;
}
.module-add-panel + .links-list,
.module-add-panel + .links-category-list,
.module-add-panel + .checklist,
.module-add-panel + .task-planner-list,
.module-add-panel + .counters-grid {

View file

@ -1,4 +1,77 @@
// Rôle : gère l’outil liens et ses actions de liste.
.links-category-list,
.links-category-block,
.links-list {
display: grid;
gap: 10px;
}
.links-category-list {
margin: var(--space-4);
}
.links-category-section {
display: grid;
gap: 8px;
padding: 10px;
border: 1px solid rgba(246, 196, 83, 0.16);
border-radius: var(--radius-md);
background:
linear-gradient(135deg, rgba(246, 196, 83, 0.04), transparent 42%),
rgba(7, 10, 24, 0.18);
}
.links-category-header {
padding: 0 2px;
}
.links-category-title {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 8px;
}
.links-category-title h3 {
min-width: 0;
margin: 0;
overflow: hidden;
color: var(--color-accent-gold);
font-size: var(--font-size-sm);
text-overflow: ellipsis;
white-space: nowrap;
}
.links-category-actions {
display: inline-flex;
align-items: center;
gap: 8px;
}
.links-category-actions > span {
flex: 0 0 auto;
padding: 3px 9px;
border: 1px solid rgba(246, 196, 83, 0.22);
border-radius: var(--radius-pill);
background: rgba(246, 196, 83, 0.08);
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
font-weight: 800;
}
.links-category-boundary-drop-zone {
min-height: 10px;
border: 1px dashed rgba(165, 180, 252, 0.16);
border-radius: var(--radius-sm);
background: rgba(15, 23, 42, 0.12);
}
.links-category-boundary-drop-zone.is-drop-target {
border-color: rgba(246, 196, 83, 0.72);
background: rgba(246, 196, 83, 0.08);
box-shadow: inset 0 0 0 1px rgba(246, 196, 83, 0.24);
}
.link-item {
display: grid;
grid-template-columns: 34px minmax(0, 1fr) auto;
@ -10,6 +83,18 @@
background: rgba(7, 10, 24, 0.42);
}
.link-item.is-dragging,
.links-category-section.is-dragging {
cursor: grabbing;
opacity: 0.58;
transform: scale(0.99);
}
.link-item.is-drop-target,
.links-category-section.is-drop-target {
border-color: rgba(246, 196, 83, 0.72);
}
.link-drag-handle {
cursor: grab;
touch-action: none;

View file

@ -119,5 +119,5 @@
}
.task-planner-add-form {
grid-template-columns: minmax(0, 1fr) minmax(120px, 0.36fr) auto;
grid-template-columns: minmax(0, 1fr) minmax(120px, 0.36fr) minmax(120px, 0.32fr) auto;
}