import type { PlaitElement } from "@plait/core"; import { getTopicEntries } from "./topic-details"; import { addDays, dateIndex, DAY, indexDate } from "./calendar-dates"; export const CALENDAR_PROVIDER = "org.xmind.ui.working-day-settings"; export const CALENDAR_FREQUENCIES = [ "NONE", "DAILY", "WEEKLY", "MONTHLY_BY_DATE", "MONTHLY_BY_WEEKDAY", "YEARLY", ] as const; export type CalendarRule = { id: string; name: string; selectedDates: string[]; type: "WORKING_DAY" | "NON_WORKING_DAY"; frequency: (typeof CALENDAR_FREQUENCIES)[number]; interval?: number; effectiveRange?: { start?: string; end?: string }; }; export type ProjectWorkingCalendar = { id: string; name: string; defaultWorkingDays: number[]; rules: CalendarRule[]; effectiveRange?: { start?: string; end?: string }; }; export type ProjectCalendarConfig = { settings: ProjectWorkingCalendar | null; skip: boolean; }; const record = (value: unknown): Record => value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; export function defaultWorkingCalendar(): ProjectWorkingCalendar { return { id: crypto.randomUUID(), name: "基本日历", defaultWorkingDays: [1, 2, 3, 4, 5], rules: [], }; } export function calendarRoot(elements: PlaitElement[]) { return ( getTopicEntries(elements).find((entry) => entry.depth === 0)?.element || null ); } export function readWorkingCalendar( value: unknown ): ProjectWorkingCalendar | null { const source = record(value); if (!Array.isArray(source.defaultWorkingDays) || !Array.isArray(source.rules)) return null; return { ...source, id: String(source.id || "project-calendar"), name: String(source.name || "项目日历"), defaultWorkingDays: source.defaultWorkingDays.filter( (day): day is number => Number.isInteger(day) && day >= 1 && day <= 7 ), rules: source.rules.map((rule, index) => { const item = record(rule); return { ...item, id: String(item.id || `calendar-rule-${index}`), name: String(item.name || "例外"), selectedDates: Array.isArray(item.selectedDates) ? item.selectedDates.filter( (date): date is string => typeof date === "string" && Number.isFinite(dateIndex(date)) ) : [], type: item.type === "WORKING_DAY" ? "WORKING_DAY" : "NON_WORKING_DAY", frequency: CALENDAR_FREQUENCIES.includes( item.frequency as CalendarRule["frequency"] ) ? (item.frequency as CalendarRule["frequency"]) : "NONE", } as CalendarRule; }), } as ProjectWorkingCalendar; } export function getProjectCalendar( elements: PlaitElement[] ): ProjectCalendarConfig { const sheet = record(record(calendarRoot(elements)?.xmind).sheet); const extension = Array.isArray(sheet.extensions) ? sheet.extensions .map(record) .find((item) => item.provider === CALENDAR_PROVIDER) : null; const settings = readWorkingCalendar(extension?.content); return { settings, skip: Boolean(settings && sheet.nonWorkingDaysSkipping === "skip"), }; } export function patchProjectCalendar( root: PlaitElement, config: ProjectCalendarConfig ) { const metadata = { ...record(root.xmind) }, sheet = { ...record(metadata.sheet) }; const extensions = Array.isArray(sheet.extensions) ? sheet.extensions.map(record) : []; const existing = extensions.find( (item) => item.provider === CALENDAR_PROVIDER ); sheet.extensions = [ ...extensions.filter((item) => item.provider !== CALENDAR_PROVIDER), ...(config.settings ? [{ ...existing, provider: CALENDAR_PROVIDER, content: config.settings }] : []), ]; sheet.nonWorkingDaysSkipping = config.skip && config.settings ? "skip" : "include"; metadata.sheet = sheet; return metadata; } export const activeWorkingCalendar = (config: ProjectCalendarConfig) => config.skip ? config.settings : null; function inRange(date: string, range?: CalendarRule["effectiveRange"]) { return ( (!range?.start || date >= range.start) && (!range?.end || date <= range.end) ); } function ruleMatches(date: string, rule: CalendarRule) { if (!inRange(date, rule.effectiveRange)) return false; const index = dateIndex(date), target = new Date(index * DAY), interval = rule.interval || 1; return rule.selectedDates.some((selected) => { const seedIndex = dateIndex(selected), seed = new Date(seedIndex * DAY); const difference = Math.abs(index - seedIndex), months = Math.abs( (target.getUTCFullYear() - seed.getUTCFullYear()) * 12 + target.getUTCMonth() - seed.getUTCMonth() ); switch (rule.frequency) { case "NONE": return selected === date; case "DAILY": return difference % interval === 0; case "WEEKLY": return ( target.getUTCDay() === seed.getUTCDay() && Math.floor(difference / 7) % interval === 0 ); case "MONTHLY_BY_DATE": return ( target.getUTCDate() === seed.getUTCDate() && months % interval === 0 ); case "MONTHLY_BY_WEEKDAY": return ( target.getUTCDay() === seed.getUTCDay() && Math.ceil(target.getUTCDate() / 7) === Math.ceil(seed.getUTCDate() / 7) && months % interval === 0 ); case "YEARLY": return ( target.getUTCMonth() === seed.getUTCMonth() && target.getUTCDate() === seed.getUTCDate() && Math.abs(target.getUTCFullYear() - seed.getUTCFullYear()) % interval === 0 ); } }); } export function isWorkingDate( date: string, settings: ProjectWorkingCalendar | null ) { if (!settings || !inRange(date, settings.effectiveRange)) return true; const weekday = new Date(dateIndex(date) * DAY).getUTCDay() || 7; let result = settings.defaultWorkingDays.includes(weekday); for (const rule of settings.rules) if (ruleMatches(date, rule)) result = rule.type === "WORKING_DAY"; return result; } export function nextWorkingDate( date: string, settings: ProjectWorkingCalendar | null, direction: 1 | -1 = 1 ) { for ( let day = dateIndex(date), attempt = 0; attempt <= 36500; day += direction, attempt++ ) { const candidate = indexDate(day); if (isWorkingDate(candidate, settings)) return candidate; } throw new Error("日历中连续 100 年没有可用工作日,请调整例外规则。"); } export function addWorkingDays( date: string, days: number, settings: ProjectWorkingCalendar | null ) { if (!settings) return addDays(date, days); const direction = days < 0 ? -1 : 1; let result = date; for (let left = Math.abs(days); left > 0; left--) result = nextWorkingDate(addDays(result, direction), settings, direction); return result; } export function taskDueDate( start: string, duration: number, settings: ProjectWorkingCalendar | null ) { return addWorkingDays( nextWorkingDate(start, settings), duration - 1, settings ); } export function taskStartDate( due: string, duration: number, settings: ProjectWorkingCalendar | null ) { return addWorkingDays( nextWorkingDate(due, settings, -1), 1 - duration, settings ); } export function workingDaysBetween( start: string, due: string, settings: ProjectWorkingCalendar | null ) { if (!settings) return dateIndex(due) - dateIndex(start) + 1; let duration = 0; for (let day = dateIndex(start), last = dateIndex(due); day <= last; day++) if (isWorkingDate(indexDate(day), settings)) duration++; return duration; } export function preserveNonWorkingDue( due: string, calculated: string, settings: ProjectWorkingCalendar | null ) { return settings && due && !isWorkingDate(due, settings) && nextWorkingDate(due, settings, -1) === calculated ? due : calculated; } export function validateWorkingCalendar( calendar: ProjectWorkingCalendar ): string | null { if (!calendar.defaultWorkingDays.length) return "请至少保留一个每周工作日。"; const validateRange = (range?: CalendarRule["effectiveRange"]) => !range || ((!range.start || Number.isFinite(dateIndex(range.start))) && (!range.end || Number.isFinite(dateIndex(range.end))) && (!range.start || !range.end || range.start <= range.end)); if (!validateRange(calendar.effectiveRange)) return "日历生效范围无效。"; for (const rule of calendar.rules) { if ( !rule.selectedDates.length || rule.selectedDates.some((date) => !Number.isFinite(dateIndex(date))) ) return "请选择有效的例外日期。"; if (!validateRange(rule.effectiveRange)) return "例外结束日期不能早于开始日期。"; if ( rule.interval !== undefined && (!Number.isInteger(rule.interval) || rule.interval < 1 || rule.interval > 100) ) return "重复间隔必须是 1 至 100。"; } return null; }