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.
289 lines
8.9 KiB
TypeScript
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;
|
|
}
|