Add calendar toolbox with weekly and monthly scheduling
All checks were successful
Deploy Sokko G / deploy (push) Successful in 14s

This commit is contained in:
Shinuwa 2026-08-20 15:48:24 +02:00
parent 4f73e0068b
commit 992665f4ea
39 changed files with 4331 additions and 36 deletions

View file

@ -15,6 +15,7 @@ import {
clampQty,
hostnameFromUrl,
normalizeCalculatorData,
normalizeCalendarData,
normalizeChecklistData,
normalizeCombosData,
normalizeCountersData,
@ -191,6 +192,7 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
normalizeCountersData,
normalizeEquipmentPlannerData,
normalizeCalculatorData,
normalizeCalendarData,
normalizeImageAnnotationData,
normalizeNotepadData,
normalizeTableData,
@ -220,7 +222,7 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
setShowTutorial(shouldShowTutorial(toolbox, pendingTutorialToolboxId));
}, [pendingTutorialToolboxId, toolbox, toolboxId, toolboxTutorial]);
function addModule(type) {
function addModule(type, initialData = null) {
if (!type) return;
const module = { id: uid("mod"), type };
const modules = [...toolbox.modules, module];
@ -229,6 +231,7 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
modules,
moduleOrder: addModuleToOrder(toolbox.moduleOrder, modules, module.id)
});
if (initialData) updateModuleData(toolbox.id, module.id, initialData, type);
}
function createImageAnnotationModule(dataUrl) {

File diff suppressed because it is too large Load diff

View file

@ -442,9 +442,6 @@ export function CombosModule({ toolboxId, moduleId, context, editing }) {
<div className="combos-module">
{(editing || editingComboId) && (
<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">
<label>
<span>{textContent.deviceLabel || "Périphérique"}</span>
@ -592,6 +589,9 @@ export function CombosModule({ toolboxId, moduleId, context, editing }) {
</div>
</div>
</form>
{editing && !editingComboId && (
<TextExchangeActions type="combos" data={data} context={context} textContent={textContent} onImport={(nextData) => save(nextData)} />
)}
</div>
)}

View file

@ -0,0 +1,266 @@
// Rôle : regroupe les calculs hebdomadaires/mensuels, occurrences et alertes du calendrier.
export const CALENDAR_VIEW_MODES = ["week", "month"];
export const CALENDAR_MONTHLY_REPEAT_BY = ["monthDay", "weekday"];
export const CALENDAR_REPEAT_MODES = ["none", "daily", "weekly", "monthly"];
export const CALENDAR_MONTH_REPEAT_MODES = ["none", "weekly", "monthly"];
export const CALENDAR_ALERT_MODES = ["off", "visible", "site"];
export const CALENDAR_ALERT_OFFSETS = [10, 5, 0];
export const CALENDAR_COLORS = ["violet", "gold", "cyan", "pink", "green", "gray"];
export const WEEK_SLOT_MINUTES = 30;
export const WEEK_TIME_INPUT_STEP_MINUTES = 5;
export const WEEK_SLOT_COUNT = 48;
export const DAY_MS = 24 * 60 * 60 * 1000;
export const MINUTE_MS = 60 * 1000;
export const WEEK_DAY_COUNT = 7;
export function pad2(value) {
return String(value).padStart(2, "0");
}
export function formatLocalDateKey(dateLike) {
const date = new Date(dateLike);
if (Number.isNaN(date.getTime())) return "";
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
}
export function parseLocalDateKey(value) {
const match = String(value || "").match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) return null;
const year = Number(match[1]);
const month = Number(match[2]) - 1;
const day = Number(match[3]);
const date = new Date(year, month, day);
if (date.getFullYear() !== year || date.getMonth() !== month || date.getDate() !== day) return null;
return date;
}
export function minutesToTimeValue(minutes) {
const normalized = Math.max(0, Math.min(23 * 60 + 59, Number(minutes) || 0));
return `${pad2(Math.floor(normalized / 60))}:${pad2(normalized % 60)}`;
}
export function timeValueToMinutes(value) {
const match = String(value || "").match(/^(\d{1,2}):(\d{2})$/);
if (!match) return -1;
const hours = Number(match[1]);
const minutes = Number(match[2]);
if (!Number.isInteger(hours) || !Number.isInteger(minutes) || hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return -1;
return hours * 60 + minutes;
}
export function startOfLocalDay(dateLike) {
const date = new Date(dateLike);
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
}
export function addDays(dateLike, days) {
const date = new Date(dateLike);
date.setDate(date.getDate() + days);
return date;
}
export function addMonths(dateLike, months) {
const date = new Date(dateLike);
date.setMonth(date.getMonth() + months);
return date;
}
export function getLocalDayIndex(dateLike) {
return (new Date(dateLike).getDay() + 6) % WEEK_DAY_COUNT;
}
export function getWeekStart(dateLike, weekStartsOn = 1) {
const day = startOfLocalDay(dateLike);
const current = day.getDay();
const offset = (current - weekStartsOn + 7) % 7;
return addDays(day, -offset);
}
export function getWeekDays(weekStart) {
return Array.from({ length: 7 }, (_, index) => addDays(weekStart, index));
}
export function getMonthStart(dateLike) {
const date = new Date(dateLike);
return new Date(date.getFullYear(), date.getMonth(), 1);
}
export function getMonthEnd(dateLike) {
const date = getMonthStart(dateLike);
date.setMonth(date.getMonth() + 1);
return date;
}
export function getDaysInMonth(dateLike) {
return new Date(new Date(dateLike).getFullYear(), new Date(dateLike).getMonth() + 1, 0).getDate();
}
export function getMonthGridDays(dateLike) {
const monthStart = getMonthStart(dateLike);
const monthEnd = getMonthEnd(dateLike);
const gridStart = getWeekStart(monthStart, 1);
const gridEnd = addDays(getWeekStart(addDays(monthEnd, -1), 1), 7);
const days = [];
for (let cursor = new Date(gridStart); cursor < gridEnd; cursor = addDays(cursor, 1)) {
const date = new Date(cursor);
days.push({
date,
dayNumber: date.getDate(),
dayIndex: getLocalDayIndex(date),
inMonth: date.getMonth() === monthStart.getMonth() && date.getFullYear() === monthStart.getFullYear()
});
}
return days;
}
export function getDateForWeekDay(weekStart, dayIndex, minutes = 0) {
const date = addDays(weekStart, Math.max(0, Math.min(WEEK_DAY_COUNT - 1, Number(dayIndex) || 0)));
date.setHours(0, Math.max(0, Number(minutes) || 0), 0, 0);
return date;
}
export function getDateForMonthDay(monthDate, monthDay, minutes = 0) {
const day = Number(monthDay);
const monthStart = getMonthStart(monthDate);
if (!Number.isInteger(day) || day < 1 || day > getDaysInMonth(monthStart)) return null;
const date = new Date(monthStart.getFullYear(), monthStart.getMonth(), day);
date.setHours(0, Math.max(0, Number(minutes) || 0), 0, 0);
return date;
}
function getWeekdayOrdinalInMonth(dateLike) {
const date = new Date(dateLike);
return Math.floor((date.getDate() - 1) / 7) + 1;
}
function getMonthlyWeekdayDate(monthDate, sourceDate, minutes = 0) {
const source = new Date(sourceDate);
const ordinal = getWeekdayOrdinalInMonth(source);
const dayIndex = getLocalDayIndex(source);
const monthStart = getMonthStart(monthDate);
const firstDayOffset = (dayIndex - getLocalDayIndex(monthStart) + WEEK_DAY_COUNT) % WEEK_DAY_COUNT;
const date = addDays(monthStart, firstDayOffset + (ordinal - 1) * WEEK_DAY_COUNT);
if (date.getMonth() !== monthStart.getMonth()) return null;
date.setHours(0, Math.max(0, Number(minutes) || 0), 0, 0);
return date;
}
function getMonthlyDateRange(event, monthDate) {
const sourceStart = parseLocalDateKey(event.startDate);
const sourceEnd = parseLocalDateKey(event.endDate || event.startDate);
if (!sourceStart || !sourceEnd || sourceEnd < sourceStart) return null;
const spanDays = Math.max(0, Math.round((startOfLocalDay(sourceEnd).getTime() - startOfLocalDay(sourceStart).getTime()) / DAY_MS));
const start = event.monthlyRepeatBy === "weekday"
? getMonthlyWeekdayDate(monthDate, sourceStart, event.startMinutes)
: getDateForMonthDay(monthDate, sourceStart.getDate(), event.startMinutes);
if (!start) return null;
const end = addDays(start, spanDays + 1);
return { start, end };
}
function getDatedEventRange(event) {
const start = parseLocalDateKey(event.startDate);
const endSource = parseLocalDateKey(event.endDate || event.startDate);
if (!start || !endSource || endSource < start) return null;
start.setHours(0, Math.max(0, Number(event.startMinutes) || 0), 0, 0);
const end = addDays(endSource, 1);
end.setHours(0, 0, 0, 0);
return { start, end };
}
export function getEventDurationMs(event) {
const startMinutes = Number(event.startMinutes);
const endMinutes = Number(event.endMinutes);
return Number.isFinite(startMinutes) && Number.isFinite(endMinutes) && endMinutes > startMinutes ? (endMinutes - startMinutes) * MINUTE_MS : 0;
}
export function getEventEndMs(event, startMs) {
const durationMs = getEventDurationMs(event);
return durationMs > 0 ? startMs + durationMs : startMs;
}
export function getCalendarAlertOffsets(event) {
const rawOffsets = Array.isArray(event?.alertOffsetMinutes) ? event.alertOffsetMinutes : [event?.alertOffsetMinutes];
const offsets = [...new Set(rawOffsets.map(Number).filter((offset) => CALENDAR_ALERT_OFFSETS.includes(offset)))];
return offsets.length ? offsets.sort((a, b) => b - a) : [10];
}
function getWeeklyOccurrenceStart(event, weekStart, offset) {
if (event.repeat === "none") return getDateForWeekDay(weekStart, event.dayIndex, event.startMinutes);
return getDateForWeekDay(addDays(weekStart, offset * 7), event.dayIndex, event.startMinutes);
}
export function getCalendarOccurrences(events, rangeStart, rangeEnd) {
const baseWeekStart = getWeekStart(rangeStart);
const startMs = rangeStart.getTime();
const endMs = rangeEnd.getTime();
return events.flatMap((event) => {
const durationMs = getEventDurationMs(event);
const candidates = [];
if (event.startDate) {
const baseRange = getDatedEventRange(event);
if (baseRange) {
const spanDays = Math.max(0, Math.round((baseRange.end.getTime() - baseRange.start.getTime()) / DAY_MS) - 1);
if (event.repeat === "monthly") {
for (let cursor = getMonthStart(rangeStart); cursor < rangeEnd; cursor = addMonths(cursor, 1)) {
const range = getMonthlyDateRange(event, cursor);
if (range) candidates.push(range);
}
} else if (event.repeat === "weekly") {
const firstOffset = Math.floor((getWeekStart(rangeStart).getTime() - getWeekStart(baseRange.start).getTime()) / (7 * DAY_MS)) - 1;
const weekCount = Math.ceil((rangeEnd.getTime() - rangeStart.getTime()) / (7 * DAY_MS)) + 3;
for (let index = Math.max(-1, firstOffset); index < firstOffset + weekCount; index += 1) {
const start = addDays(baseRange.start, index * 7);
const end = addDays(start, spanDays + 1);
candidates.push({ start, end });
}
} else {
candidates.push(baseRange);
}
}
} else if (event.repeat === "none") {
candidates.push(getWeeklyOccurrenceStart(event, baseWeekStart, 0));
} else {
candidates.push(getWeeklyOccurrenceStart(event, baseWeekStart, -1), getWeeklyOccurrenceStart(event, baseWeekStart, 0), getWeeklyOccurrenceStart(event, baseWeekStart, 1));
}
return candidates
.map((candidate) => candidate instanceof Date ? { start: candidate, end: null } : candidate)
.filter((candidate) => candidate?.start && !Number.isNaN(candidate.start.getTime()))
.filter((candidate) => !event.excludedOccurrences?.includes?.(formatLocalDateKey(candidate.start)))
.map((candidate) => {
const occurrenceStartMs = candidate.start.getTime();
const occurrenceEndMs = candidate.end && !Number.isNaN(candidate.end.getTime()) ? candidate.end.getTime() : 0;
return {
id: `${event.id}:${occurrenceStartMs}`,
event,
startMs: occurrenceStartMs,
endMs: occurrenceEndMs > occurrenceStartMs ? occurrenceEndMs : durationMs > 0 ? occurrenceStartMs + durationMs : occurrenceStartMs
};
})
.filter((occurrence) => {
const visibleEndMs = occurrence.endMs || occurrence.startMs;
return visibleEndMs >= startMs && occurrence.startMs < endMs;
});
}).sort((a, b) => a.startMs - b.startMs);
}
export function getNextCalendarAlertTime(event, nowMs) {
if (!event || event.alertMode === "off") return 0;
const rangeStart = addDays(new Date(nowMs), -1);
const rangeEnd = addDays(new Date(nowMs), 370);
const alert = getCalendarOccurrences([event], rangeStart, rangeEnd)
.flatMap((occurrence) => getCalendarAlertOffsets(event).map((offset) => occurrence.startMs - offset * MINUTE_MS))
.filter((alertMs) => alertMs >= nowMs)
.sort((a, b) => a - b)[0];
return alert || 0;
}
export function getCurrentCalendarOccurrence(event, nowMs) {
if (!event?.endMinutes && !event?.startDate) return null;
const rangeStart = addDays(new Date(nowMs), -1);
const rangeEnd = addDays(new Date(nowMs), 1);
return getCalendarOccurrences([event], rangeStart, rangeEnd)
.find((occurrence) => occurrence.startMs <= nowMs && occurrence.endMs > nowMs) || null;
}

View file

@ -8,6 +8,7 @@ import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import { lockBodyScroll } from "../../../utils/bodyScrollLock.js";
import { TOOLBOX_MODULE_DEFINITIONS, TOOLBOX_MODULES } from "../toolboxModuleRegistry.js";
import { CalculatorModule } from "./CalculatorModule.jsx";
import { CalendarModule } from "./CalendarModule.jsx";
import { ChecklistModule } from "./ChecklistModule.jsx";
import { CombosModule } from "./CombosModule.jsx";
import { CountersModule } from "./CountersModule.jsx";
@ -28,6 +29,7 @@ const MODULE_COMPONENTS = {
counters: { ...TOOLBOX_MODULE_DEFINITIONS.counters, Component: CountersModule },
combos: { ...TOOLBOX_MODULE_DEFINITIONS.combos, Component: CombosModule },
calculator: { ...TOOLBOX_MODULE_DEFINITIONS.calculator, Component: CalculatorModule },
calendar: { ...TOOLBOX_MODULE_DEFINITIONS.calendar, Component: CalendarModule },
table: { ...TOOLBOX_MODULE_DEFINITIONS.table, Component: TableModule },
timer: { ...TOOLBOX_MODULE_DEFINITIONS.timer, Component: TimerModule },
taskPlanner: { ...TOOLBOX_MODULE_DEFINITIONS.taskPlanner, Component: TaskPlannerModule },
@ -352,7 +354,7 @@ function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", onRena
</div>
</header>
<div className={`module-content ${scrollable ? "is-scrollable legacy-scrollbar" : ""}`}>
<Component toolboxId={toolbox.id} moduleId={module.id} context={context} editing={editing} />
<Component toolboxId={toolbox.id} moduleId={module.id} context={context} editing={editing} setEditing={setEditing} />
</div>
</article>
);

View file

@ -1,4 +1,5 @@
// Rôle : parse et sérialise les formats texte/TSV d'échange des outils toolbox.
import { CALENDAR_ALERT_MODES, CALENDAR_ALERT_OFFSETS, CALENDAR_COLORS, CALENDAR_MONTHLY_REPEAT_BY, CALENDAR_MONTH_REPEAT_MODES, minutesToTimeValue, timeValueToMinutes } from "./calendarUtils.js";
import { cellAddress, columnIndexToName } from "./tableFormulaEngine.js";
import { getCountdownTargetMs, getDailyTargetMs, getIntervalAnchorMs } from "./timerUtils.js";
@ -27,6 +28,7 @@ const SUPPORTED_TEXT_EXCHANGE_TYPES = new Set([
"calculator",
"table",
"timer",
"calendar",
"taskPlanner",
"equipmentPlanner",
"images",
@ -36,6 +38,12 @@ const COMBO_DEVICES = new Set(["playstation", "xbox", "switch", "n64", "keyboard
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 CALENDAR_ALERT_MODE_SET = new Set(CALENDAR_ALERT_MODES);
const CALENDAR_ALERT_OFFSET_SET = new Set(CALENDAR_ALERT_OFFSETS);
const CALENDAR_COLOR_SET = new Set(CALENDAR_COLORS);
const CALENDAR_MONTHLY_REPEAT_BY_SET = new Set(CALENDAR_MONTHLY_REPEAT_BY);
const CALENDAR_MONTH_REPEAT_MODE_SET = new Set(CALENDAR_MONTH_REPEAT_MODES);
const CALENDAR_WEEKDAYS = ["lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche"];
const IMAGE_DATA_URL_PATTERN = /^data:image\/[a-z0-9.+-]+;base64,[a-z0-9+/=\s]+$/i;
export function supportsTextExchange(type) {
@ -51,6 +59,7 @@ export function hasTextExchangeContent(type, data) {
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 === "calendar") return Boolean(data?.events?.length);
if (type === "taskPlanner") return Boolean(data?.tasks?.length);
if (type === "equipmentPlanner") return Boolean(data?.equipments?.length);
if (type === "images") return Boolean(data?.images?.length);
@ -67,6 +76,7 @@ export function exportModuleText(type, data) {
if (type === "calculator") return exportCalculatorText(data);
if (type === "table") return exportTableTsv(data);
if (type === "timer") return exportTimerText(data);
if (type === "calendar") return exportCalendarText(data);
if (type === "taskPlanner") return exportTaskPlannerText(data);
if (type === "equipmentPlanner") return exportEquipmentPlannerText(data);
if (type === "images") return exportImagesText(data);
@ -87,6 +97,7 @@ export function importModuleText(type, text, 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 === "calendar") data = importCalendarText(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);
@ -644,6 +655,112 @@ function importTimerText(text, context) {
return context.normalizeTimerData(data);
}
function parseCalendarDayIndex(value) {
const normalized = String(value || "").trim().toLowerCase();
const weekdayIndex = CALENDAR_WEEKDAYS.indexOf(normalized);
if (weekdayIndex >= 0) return weekdayIndex;
const parsed = parseInteger(normalized, -1);
return parsed >= 0 && parsed <= 6 ? parsed : -1;
}
function parseCalendarAlertOffsets(value) {
return String(value || "")
.split(",")
.map((item) => parseInteger(item.trim(), Number.NaN))
.filter((offset, index, offsets) => CALENDAR_ALERT_OFFSET_SET.has(offset) && offsets.indexOf(offset) === index)
.sort((a, b) => b - a);
}
function formatCalendarEventMeta(event, viewMode) {
const alertMode = CALENDAR_ALERT_MODE_SET.has(event.alertMode) ? event.alertMode : "off";
const meta = {
title: event.title,
color: event.color && event.color !== "violet" ? event.color : "",
repeatable: event.repeatable === false ? "false" : "",
alertMode: alertMode !== "off" ? alertMode : "",
alerts: alertMode !== "off" ? (event.alertOffsetMinutes || [10]).join(",") : ""
};
if (viewMode === "month") {
const monthlyRepeatBy = CALENDAR_MONTHLY_REPEAT_BY_SET.has(event.monthlyRepeatBy) ? event.monthlyRepeatBy : "monthDay";
if (monthlyRepeatBy === "weekday") {
return {
...meta,
repeatBy: "weekday",
repeat: event.repeat || "none",
startDate: event.startDate,
endDate: event.endDate && event.endDate !== event.startDate ? event.endDate : ""
};
}
return {
...meta,
repeatBy: "monthDay",
repeat: event.repeat || "none",
startDate: event.startDate,
endDate: event.endDate && event.endDate !== event.startDate ? event.endDate : ""
};
}
return {
...meta,
day: CALENDAR_WEEKDAYS[event.dayIndex] || "",
start: minutesToTimeValue(event.startMinutes),
end: event.endMinutes ? minutesToTimeValue(event.endMinutes) : ""
};
}
function exportCalendarText(data) {
const viewMode = data?.viewMode === "month" ? "month" : "week";
const lines = [`@viewMode: ${viewMode}`];
if (data?.weekStartsOn === 0) lines.push("@weekStartsOn: 0");
(data?.events || []).forEach((event) => {
lines.push(`event | ${formatMeta(formatCalendarEventMeta(event, viewMode))}`);
});
return lines.join("\n");
}
function importCalendarText(text, context) {
const trimmed = String(text || "").trim();
if (trimmed.startsWith("{")) {
return context.normalizeCalendarData(JSON.parse(trimmed));
}
const data = { initialized: true, viewMode: "week", weekStartsOn: 1, events: [] };
splitLines(text).forEach((line, index) => {
const cleanLine = line.trim();
if (!cleanLine) return;
const meta = parseMetaLine(cleanLine);
if (meta) {
if (meta.key === "viewMode") data.viewMode = meta.value === "month" ? "month" : "week";
if (meta.key === "weekStartsOn") data.weekStartsOn = parseInteger(meta.value, 1) === 0 ? 0 : 1;
return;
}
const { title, meta: lineMeta } = parsePipeParts(cleanLine);
if (title !== "event") throw lineError(index, `ligne calendrier invalide: ${cleanLine}`);
const event = {
id: context.uid("calendar"),
title: lineMeta.title || "",
repeatable: lineMeta.repeatable !== "false",
alertMode: CALENDAR_ALERT_MODE_SET.has(lineMeta.alertMode) ? lineMeta.alertMode : "off",
alertOffsetMinutes: parseCalendarAlertOffsets(lineMeta.alerts),
color: CALENDAR_COLOR_SET.has(lineMeta.color) ? lineMeta.color : "violet"
};
if (data.viewMode === "month") {
const monthlyRepeatBy = CALENDAR_MONTHLY_REPEAT_BY_SET.has(lineMeta.repeatBy) ? lineMeta.repeatBy : "monthDay";
event.startMinutes = 0;
event.monthlyRepeatBy = monthlyRepeatBy;
event.repeat = CALENDAR_MONTH_REPEAT_MODE_SET.has(lineMeta.repeat) ? lineMeta.repeat : "none";
event.startDate = lineMeta.startDate || "";
event.endDate = lineMeta.endDate || event.startDate;
} else {
event.dayIndex = parseCalendarDayIndex(lineMeta.day);
event.startMinutes = timeValueToMinutes(lineMeta.start);
if (lineMeta.end) event.endMinutes = timeValueToMinutes(lineMeta.end);
}
data.events.push(event);
});
return context.normalizeCalendarData(data);
}
function exportImagesText(data) {
return (data.images || []).flatMap((image) => [
image.label ? `# ${image.label}` : "#",

View file

@ -0,0 +1,123 @@
// Rôle : normalise et compacte les événements du calendrier hebdomadaire et mensuel.
import { CALENDAR_ALERT_MODES, CALENDAR_ALERT_OFFSETS, CALENDAR_COLORS, CALENDAR_MONTHLY_REPEAT_BY, CALENDAR_MONTH_REPEAT_MODES, CALENDAR_VIEW_MODES, formatLocalDateKey, getLocalDayIndex, parseLocalDateKey, WEEK_DAY_COUNT } from "../../modules/calendarUtils.js";
import { uid } from "./shared.js";
const VIEW_MODES = new Set(CALENDAR_VIEW_MODES);
const ALERT_MODES = new Set(CALENDAR_ALERT_MODES);
const ALERT_OFFSETS = new Set(CALENDAR_ALERT_OFFSETS);
const COLORS = new Set(CALENDAR_COLORS);
const MONTHLY_REPEAT_BY = new Set(CALENDAR_MONTHLY_REPEAT_BY);
const MONTH_REPEAT_MODES = new Set(CALENDAR_MONTH_REPEAT_MODES);
function normalizeDayIndex(value) {
const parsed = Number(value);
return Number.isInteger(parsed) && parsed >= 0 && parsed < WEEK_DAY_COUNT ? parsed : -1;
}
function normalizeMinuteOfDay(value) {
const parsed = Number(value);
return Number.isInteger(parsed) && parsed >= 0 && parsed < 24 * 60 ? parsed : -1;
}
function normalizeDateKey(value) {
const date = parseLocalDateKey(value);
return date ? formatLocalDateKey(date) : "";
}
function normalizeExcludedOccurrences(value) {
if (!Array.isArray(value)) return [];
return [...new Set(value.map(normalizeDateKey).filter(Boolean))].sort();
}
function normalizeAlertOffsets(value) {
const rawOffsets = Array.isArray(value) ? value : [value];
const offsets = [...new Set(rawOffsets.map(Number).filter((offset) => ALERT_OFFSETS.has(offset)))];
return offsets.length ? offsets.sort((a, b) => b - a) : [10];
}
export function normalizeCalendarData(data) {
const viewMode = VIEW_MODES.has(data?.viewMode) ? data.viewMode : "week";
const events = (Array.isArray(data?.events) ? data.events : [])
.map((event) => {
const title = String(event?.title || "").trim();
const dayIndex = normalizeDayIndex(event?.dayIndex);
const startDate = normalizeDateKey(event?.startDate);
const endDate = normalizeDateKey(event?.endDate || event?.startDate);
const parsedStartMinutes = normalizeMinuteOfDay(event?.startMinutes);
const startMinutes = viewMode === "month" && parsedStartMinutes < 0 ? 0 : parsedStartMinutes;
const monthlyRepeatBy = MONTHLY_REPEAT_BY.has(event?.monthlyRepeatBy) ? event.monthlyRepeatBy : "monthDay";
const monthRepeat = MONTH_REPEAT_MODES.has(event?.repeat) ? event.repeat : event?.repeatable === false ? "none" : "monthly";
const hasValidDateTarget = viewMode === "month"
? startDate && endDate && endDate >= startDate
: dayIndex >= 0;
if (!title || !hasValidDateTarget || startMinutes < 0) return null;
const endMinutes = normalizeMinuteOfDay(event?.endMinutes);
const alertMode = ALERT_MODES.has(event?.alertMode) ? event.alertMode : "off";
const alertOffsetMinutes = normalizeAlertOffsets(event?.alertOffsetMinutes);
const color = COLORS.has(event?.color) ? event.color : "violet";
const repeatable = viewMode === "month" ? monthRepeat !== "none" : event?.repeatable !== false;
const normalized = {
id: event?.id || uid("calendar"),
title,
startMinutes,
repeat: viewMode === "month" ? monthlyRepeatBy === "monthDay" && monthRepeat === "weekly" ? "none" : monthRepeat : repeatable ? "weekly" : "none",
repeatable,
alertMode,
alertOffsetMinutes,
color
};
if (viewMode === "month") {
normalized.monthlyRepeatBy = monthlyRepeatBy;
normalized.startDate = startDate;
normalized.endDate = endDate;
normalized.dayIndex = dayIndex >= 0 ? dayIndex : getLocalDayIndex(parseLocalDateKey(startDate));
normalized.excludedOccurrences = normalizeExcludedOccurrences(event?.excludedOccurrences);
} else {
normalized.dayIndex = dayIndex;
}
if (endMinutes > startMinutes) normalized.endMinutes = endMinutes;
return normalized;
})
.filter(Boolean);
return {
initialized: data?.initialized === true || events.length > 0,
viewMode,
weekStartsOn: Number(data?.weekStartsOn) === 0 ? 0 : 1,
events
};
}
export function compactCalendarDataForStorage(value) {
const normalized = normalizeCalendarData(value);
const compact = {};
if (normalized.initialized) compact.initialized = true;
if (normalized.viewMode !== "week") compact.viewMode = normalized.viewMode;
if (normalized.weekStartsOn !== 1) compact.weekStartsOn = normalized.weekStartsOn;
if (normalized.events.length) {
compact.events = normalized.events.map((event) => {
const compactEvent = {
id: event.id,
title: event.title
};
if (normalized.viewMode !== "month" || event.startMinutes !== 0) compactEvent.startMinutes = event.startMinutes;
if (normalized.viewMode === "month") {
compactEvent.startDate = event.startDate;
if (event.endDate !== event.startDate) compactEvent.endDate = event.endDate;
if (event.repeat !== "monthly") compactEvent.repeat = event.repeat;
if (event.monthlyRepeatBy !== "monthDay") compactEvent.monthlyRepeatBy = event.monthlyRepeatBy;
if (event.excludedOccurrences.length) compactEvent.excludedOccurrences = event.excludedOccurrences;
} else {
compactEvent.dayIndex = event.dayIndex;
}
if (normalized.viewMode !== "month" && event.endMinutes) compactEvent.endMinutes = event.endMinutes;
if (normalized.viewMode !== "month" && event.repeatable === false) compactEvent.repeatable = false;
if (event.alertMode !== "off") compactEvent.alertMode = event.alertMode;
if (event.alertMode !== "off" && (event.alertOffsetMinutes.length !== 1 || event.alertOffsetMinutes[0] !== 10)) compactEvent.alertOffsetMinutes = event.alertOffsetMinutes;
if (event.color !== "violet") compactEvent.color = event.color;
return compactEvent;
});
}
return Object.keys(compact).length ? compact : null;
}

View file

@ -1,5 +1,6 @@
// Rôle : expose le registre strict des compacteurs par type d'outil toolbox.
import { compactCalculatorDataForStorage } from "./calculator.js";
import { compactCalendarDataForStorage } from "./calendar.js";
import { compactChecklistDataForStorage } from "./checklist.js";
import { compactCombosDataForStorage } from "./combos.js";
import { compactCountersDataForStorage } from "./counters.js";
@ -20,6 +21,7 @@ export const MODULE_DATA_COMPACTORS = {
counters: compactCountersDataForStorage,
combos: compactCombosDataForStorage,
calculator: compactCalculatorDataForStorage,
calendar: compactCalendarDataForStorage,
table: compactTableDataForStorage,
timer: compactTimerDataForStorage,
taskPlanner: compactTaskPlannerDataForStorage,

View file

@ -15,13 +15,23 @@ const NOTEPAD_RGB_COLOR_MAP = {
};
function escapeHtml(value) {
return String(value || "")
return decodeBasicEntitiesDeep(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function decodeBasicEntitiesDeep(value) {
let current = String(value || "");
for (let index = 0; index < 5; index += 1) {
const next = decodeBasicEntities(current);
if (next === current) return next;
current = next;
}
return current;
}
function decodeBasicEntities(value) {
return String(value || "")
.replace(/&nbsp;/gi, " ")

View file

@ -9,6 +9,7 @@ export const ID_PREFIXES = {
counter: "c",
combo: "o",
calc: "r",
calendar: "h",
marker: "k",
timer: "z",
task: "a",

View file

@ -4,6 +4,7 @@ import { MODULE_DATA_COMPACTORS } from "./modules/index.js";
import { ID_PREFIXES, uid } from "./modules/shared.js";
export { normalizeCalculatorData } from "./modules/calculator.js";
export { normalizeCalendarData } from "./modules/calendar.js";
export { clampQty, normalizeChecklistData } from "./modules/checklist.js";
export { normalizeCombosData } from "./modules/combos.js";
export { normalizeCountersData } from "./modules/counters.js";
@ -250,6 +251,12 @@ function remapModuleDataForExport(type, data, nextId) {
return remapped;
}
if (type === "calendar") {
const remapped = { ...compact };
if (compact.events) remapped.events = compact.events.map((event) => ({ ...event, id: nextId("calendar") }));
return remapped;
}
if (type === "taskPlanner") {
const taskIdMap = new Map();
compact.tasks?.forEach((task) => taskIdMap.set(task.id, nextId("task")));

View file

@ -78,6 +78,15 @@ export const TOOLBOX_MODULE_DEFINITIONS = {
editable: true,
libraryCategory: "timeRoutines"
},
calendar: {
label: "Calendrier",
defaultTitle: "Calendrier",
mode: "Agenda hebdomadaire",
icon: "calendar",
editable: true,
scrollable: true,
libraryCategory: "timeRoutines"
},
taskPlanner: {
label: "Planificateur de tâches",
defaultTitle: "Planificateur de tâches",
@ -115,7 +124,7 @@ export const LIBRARY_CATEGORY_DEFINITIONS = [
{ key: "notesTracking", types: ["notepad", "checklist", "counters"] },
{ key: "references", types: ["images", "imageAnnotation", "links"] },
{ key: "calculationData", types: ["calculator", "table"] },
{ key: "timeRoutines", types: ["timer", "taskPlanner"] },
{ key: "timeRoutines", types: ["timer", "calendar", "taskPlanner"] },
{ key: "buildsCommands", types: ["combos", "equipmentPlanner"] }
];