Add calendar toolbox with weekly and monthly scheduling
All checks were successful
Deploy Sokko G / deploy (push) Successful in 14s
All checks were successful
Deploy Sokko G / deploy (push) Successful in 14s
This commit is contained in:
parent
4f73e0068b
commit
992665f4ea
39 changed files with 4331 additions and 36 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
1215
website/src/features/toolboxes/modules/CalendarModule.jsx
Normal file
1215
website/src/features/toolboxes/modules/CalendarModule.jsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
266
website/src/features/toolboxes/modules/calendarUtils.js
Normal file
266
website/src/features/toolboxes/modules/calendarUtils.js
Normal 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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}` : "#",
|
||||
|
|
|
|||
123
website/src/features/toolboxes/storage/modules/calendar.js
Normal file
123
website/src/features/toolboxes/storage/modules/calendar.js
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -15,13 +15,23 @@ const NOTEPAD_RGB_COLOR_MAP = {
|
|||
};
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || "")
|
||||
return decodeBasicEntitiesDeep(value)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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(/ /gi, " ")
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export const ID_PREFIXES = {
|
|||
counter: "c",
|
||||
combo: "o",
|
||||
calc: "r",
|
||||
calendar: "h",
|
||||
marker: "k",
|
||||
timer: "z",
|
||||
task: "a",
|
||||
|
|
|
|||
|
|
@ -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")));
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
];
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue