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 => v && typeof v === "object" && !Array.isArray(v) ? (v as Record) : {}; 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()): 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 { 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([[id, task]]), visiting = new Set(), done = new Set(); 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; }