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.
269 lines
8.3 KiB
TypeScript
269 lines
8.3 KiB
TypeScript
import type { PlaitElement } from "@plait/core";
|
|
import { getTopicEntries } from "./topic-details";
|
|
export const TASK_PROVIDER = "org.xmind.ui.task";
|
|
import {
|
|
DAY,
|
|
dateIndex,
|
|
indexDate,
|
|
addDays,
|
|
dateTimestamp,
|
|
timestampDate,
|
|
todayDate,
|
|
} from "./calendar-dates";
|
|
export {
|
|
DAY,
|
|
dateIndex,
|
|
indexDate,
|
|
addDays,
|
|
dateTimestamp,
|
|
timestampDate,
|
|
todayDate,
|
|
} from "./calendar-dates";
|
|
import {
|
|
activeWorkingCalendar,
|
|
getProjectCalendar,
|
|
taskDueDate,
|
|
taskStartDate,
|
|
preserveNonWorkingDue,
|
|
workingDaysBetween,
|
|
type ProjectCalendarConfig,
|
|
} from "./project-calendar";
|
|
export type TaskDependency = {
|
|
id: string;
|
|
type: "FS" | "SS" | "FF" | "SF";
|
|
lag: number;
|
|
};
|
|
export type TopicTask = {
|
|
start: string;
|
|
due: string;
|
|
duration: number;
|
|
progress: number;
|
|
priority: number | null;
|
|
assignees: string[];
|
|
dependencies: TaskDependency[];
|
|
creator?: string;
|
|
};
|
|
const record = (v: unknown): Record<string, unknown> =>
|
|
v && typeof v === "object" && !Array.isArray(v)
|
|
? (v as Record<string, unknown>)
|
|
: {};
|
|
export function getTaskContent(element: PlaitElement) {
|
|
const extensions = record(element.xmind).extensions;
|
|
if (!Array.isArray(extensions)) return null;
|
|
const ext = extensions.map(record).find((e) => e.provider === TASK_PROVIDER);
|
|
const content = record(ext?.content);
|
|
return typeof content.progress === "number" ? content : null;
|
|
}
|
|
export function getTopicTask(element: PlaitElement): TopicTask | null {
|
|
const c = getTaskContent(element);
|
|
if (!c) return null;
|
|
return {
|
|
start: timestampDate(c.start),
|
|
due: timestampDate(c.due),
|
|
duration: Math.max(1, Math.round(Number(c.duration || DAY) / DAY)),
|
|
progress: Math.max(0, Math.min(100, Math.round(Number(c.progress) * 100))),
|
|
priority: typeof c.priority === "number" ? c.priority : null,
|
|
assignees: Array.isArray(c.assignees)
|
|
? c.assignees.filter((s): s is string => typeof s === "string")
|
|
: [],
|
|
dependencies: Array.isArray(c.dependencies)
|
|
? c.dependencies.flatMap((d) => {
|
|
const item = record(d);
|
|
return typeof item.id === "string" &&
|
|
["FS", "SS", "FF", "SF"].includes(String(item.type))
|
|
? [
|
|
{
|
|
id: item.id,
|
|
type: item.type as TaskDependency["type"],
|
|
lag: Number(item.lag) || 0,
|
|
},
|
|
]
|
|
: [];
|
|
})
|
|
: [],
|
|
...(typeof c.creator === "string" ? { creator: c.creator } : {}),
|
|
};
|
|
}
|
|
export function newTopicTask(): TopicTask {
|
|
return {
|
|
start: todayDate(),
|
|
due: todayDate(),
|
|
duration: 1,
|
|
progress: 0,
|
|
priority: null,
|
|
assignees: [],
|
|
dependencies: [],
|
|
};
|
|
}
|
|
export function patchTopicTask(element: PlaitElement, task: TopicTask | null) {
|
|
const metadata = { ...record(element.xmind) };
|
|
const extensions = Array.isArray(metadata.extensions)
|
|
? metadata.extensions.map(record)
|
|
: [];
|
|
const existing = extensions.find((e) => e.provider === TASK_PROVIDER);
|
|
const others = extensions.filter((e) => e.provider !== TASK_PROVIDER);
|
|
if (task)
|
|
others.push({
|
|
...existing,
|
|
provider: TASK_PROVIDER,
|
|
content: {
|
|
...record(existing?.content),
|
|
...task,
|
|
start: dateTimestamp(task.start),
|
|
due: dateTimestamp(task.due, true),
|
|
duration: task.duration * DAY,
|
|
progress: task.progress / 100,
|
|
},
|
|
});
|
|
metadata.extensions = others;
|
|
return metadata;
|
|
}
|
|
export function validateTopicTask(
|
|
id: string,
|
|
task: TopicTask,
|
|
elements: PlaitElement[]
|
|
): string | null {
|
|
if (
|
|
(task.start && !Number.isFinite(dateIndex(task.start))) ||
|
|
(task.due && !Number.isFinite(dateIndex(task.due)))
|
|
)
|
|
return "请输入有效日期。";
|
|
if (task.start && task.due && task.due < task.start)
|
|
return "截止日期不能早于开始日期。";
|
|
if (
|
|
!Number.isInteger(task.duration) ||
|
|
task.duration < 1 ||
|
|
task.duration > 36500
|
|
)
|
|
return "工期必须是 1 至 36500 天。";
|
|
if (
|
|
!Number.isFinite(task.progress) ||
|
|
task.progress < 0 ||
|
|
task.progress > 100
|
|
)
|
|
return "进度必须在 0% 至 100% 之间。";
|
|
const entries = getTopicEntries(elements),
|
|
byId = new Map(entries.map((e) => [e.element.id, e]));
|
|
const me = byId.get(id);
|
|
for (const dep of task.dependencies) {
|
|
const target = byId.get(dep.id);
|
|
if (!target || !getTopicTask(target.element)) return "前置任务已不存在。";
|
|
if (!Number.isFinite(dep.lag)) return "间隔天数无效。";
|
|
if (
|
|
dep.id === id ||
|
|
(me &&
|
|
(target.path.every((n, i) => me.path[i] === n) ||
|
|
me.path.every((n, i) => target.path[i] === n)))
|
|
)
|
|
return "任务不能依赖自身、父任务或子任务。";
|
|
const visit = (current: string, seen = new Set<string>()): boolean => {
|
|
if (current === id) return true;
|
|
if (seen.has(current)) return false;
|
|
seen.add(current);
|
|
const item = byId.get(current);
|
|
return item
|
|
? (getTopicTask(item.element)?.dependencies || []).some((d) =>
|
|
visit(d.id, seen)
|
|
)
|
|
: false;
|
|
};
|
|
if (visit(dep.id)) return "前置任务形成循环,请调整依赖关系。";
|
|
}
|
|
return null;
|
|
}
|
|
export function getTaskRows(elements: PlaitElement[]) {
|
|
const calendar = activeWorkingCalendar(getProjectCalendar(elements));
|
|
return getTopicEntries(elements).flatMap((entry) => {
|
|
const task = getTopicTask(entry.element);
|
|
if (!task) return [];
|
|
const descendants = getTopicEntries(entry.element.children || [])
|
|
.map((e) => getTopicTask(e.element))
|
|
.filter((t): t is TopicTask => Boolean(t?.start && t.due));
|
|
if (!descendants.length) return [{ ...entry, task, summary: false }];
|
|
const start = descendants.map((t) => t.start).sort()[0],
|
|
due = descendants
|
|
.map((t) => t.due)
|
|
.sort()
|
|
.at(-1)!;
|
|
const total = descendants.reduce((s, t) => s + t.duration, 0);
|
|
return [
|
|
{
|
|
...entry,
|
|
summary: true,
|
|
task: {
|
|
...task,
|
|
start,
|
|
due,
|
|
duration: Math.max(1, workingDaysBetween(start, due, calendar)),
|
|
progress: Math.round(
|
|
descendants.reduce((s, t) => s + t.progress * t.duration, 0) / total
|
|
),
|
|
},
|
|
},
|
|
];
|
|
});
|
|
}
|
|
/** Propagate finish/start constraints in topological order; the caller commits one undo batch. */
|
|
export function scheduleTaskChanges(
|
|
elements: PlaitElement[],
|
|
id: string,
|
|
task: TopicTask,
|
|
config: ProjectCalendarConfig = getProjectCalendar(elements)
|
|
): Map<string, TopicTask> {
|
|
const error = validateTopicTask(id, task, elements);
|
|
if (error) throw new Error(error);
|
|
const calendar = activeWorkingCalendar(config);
|
|
const tasks = new Map(
|
|
getTopicEntries(elements).flatMap((e) => {
|
|
const t = getTopicTask(e.element);
|
|
return t ? [[e.element.id, t] as const] : [];
|
|
})
|
|
);
|
|
tasks.set(id, task);
|
|
const changed = new Map<string, TopicTask>([[id, task]]),
|
|
visiting = new Set<string>(),
|
|
done = new Set<string>();
|
|
const visit = (key: string) => {
|
|
if (done.has(key)) return;
|
|
if (visiting.has(key)) return;
|
|
visiting.add(key);
|
|
const t = tasks.get(key)!;
|
|
for (const d of t.dependencies) if (tasks.has(d.id)) visit(d.id);
|
|
if (t.start && t.due) {
|
|
let earliest = dateIndex(t.start);
|
|
for (const d of t.dependencies) {
|
|
const before = tasks.get(d.id);
|
|
if (!before?.start || !before.due) continue;
|
|
const lag = d.lag / DAY;
|
|
const anchor = d.type[0] === "F" ? before.due : before.start;
|
|
const endpoint = addDays(
|
|
anchor,
|
|
d.type[0] === "F" ? Math.ceil(lag) : Math.floor(lag)
|
|
);
|
|
const constraint =
|
|
d.type === "FS"
|
|
? addDays(endpoint, 1)
|
|
: d.type === "SS"
|
|
? endpoint
|
|
: taskStartDate(endpoint, t.duration, calendar);
|
|
earliest = Math.max(earliest, dateIndex(constraint));
|
|
}
|
|
const start = indexDate(earliest);
|
|
const due = preserveNonWorkingDue(
|
|
t.due,
|
|
taskDueDate(start, t.duration, calendar),
|
|
calendar
|
|
);
|
|
if (start !== t.start || (calendar && due !== t.due)) {
|
|
const next = { ...t, start, due };
|
|
tasks.set(key, next);
|
|
changed.set(key, next);
|
|
}
|
|
}
|
|
visiting.delete(key);
|
|
done.add(key);
|
|
};
|
|
tasks.forEach((_, key) => visit(key));
|
|
return changed;
|
|
}
|