ai-chatbot/lib/flow-board/project-calendar.ts
134 23ced0b760 feat(flow-board): add rich notes, Gantt tasks and PNG export
Add native task metadata, working-day calendar settings, dependency scheduling and rich note editing. Support complete-sheet PNG export with resolution and transparency controls, preserve XMind extensions and update browser regressions.
2026-10-06 17:38:14 +08:00

289 lines
8.9 KiB
TypeScript

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<string, unknown> =>
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
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;
}