export type PlainleafTask = { ref: string; page: string; name: string; state: string; done: boolean; scheduled?: string; deadline?: string; completed?: string; repeat?: PlainleafRepeatRule; reminder?: string; tags?: string[]; links?: string[]; parent?: string; pageLastModified?: string; }; export type PlainleafRepeatRule = "daily" | "weekdays" | "weekly" | "monthly" | "yearly"; export type PlainleafTaskPatch = { name?: string; state?: string; scheduled?: string | null; deadline?: string | null; completed?: string | null; repeat?: PlainleafRepeatRule | null; reminder?: string | null; project?: string | null; tags?: string[]; }; export type PlainleafNewTask = { name: string; scheduled?: string | null; deadline?: string | null; repeat?: PlainleafRepeatRule | null; reminder?: string | null; project?: string | null; tags?: string[]; }; export type PlainleafDeletedTaskBlock = { page: string; position: number; text: string; }; export type PlainleafTaskList = "inbox" | "today" | "planned" | "anytime" | "projects" | "logbook"; export const plainleafTaskInbox = "任务/收件箱"; export const repeatRuleLabels: Record = { daily: "每天", weekdays: "每个工作日", weekly: "每周", monthly: "每月", yearly: "每年", }; export function localDateKey(date = new Date()): string { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0"); return `${year}-${month}-${day}`; } export function taskDate(value?: string): string | undefined { const match = value?.match(/^\d{4}-\d{2}-\d{2}/); if (!match) return undefined; const [year, month, day] = match[0].split("-").map(Number); const parsed = new Date(year, month - 1, day, 12); return parsed.getFullYear() === year && parsed.getMonth() === month - 1 && parsed.getDate() === day ? match[0] : undefined; } function dateFromKey(value: string): Date { return new Date(`${value}T12:00:00`); } export function addLocalDays(value: string, days: number): string { const date = dateFromKey(value); date.setDate(date.getDate() + days); return localDateKey(date); } const chineseWeekdays: Record = { 日: 0, 天: 0, 一: 1, 二: 2, 三: 3, 四: 4, 五: 5, 六: 6, }; /** Parses the small, predictable set of Chinese dates used by quick capture. */ export function parseNaturalTaskDate( input: string, base = new Date(), ): string | undefined { const value = input.trim().replace(/\s+/g, ""); if (!value) return undefined; const baseKey = localDateKey(base); if (value === "今天") return baseKey; if (value === "明天") return addLocalDays(baseKey, 1); if (value === "后天") return addLocalDays(baseKey, 2); const stable = taskDate(value); if (stable && !Number.isNaN(dateFromKey(stable).getTime())) return stable; const monthDay = /^(?:(\d{4})年)?(\d{1,2})月(\d{1,2})日?$/.exec(value); if (monthDay) { const year = Number(monthDay[1] ?? base.getFullYear()); const month = Number(monthDay[2]); const day = Number(monthDay[3]); const parsed = new Date(year, month - 1, day, 12); if ( parsed.getFullYear() === year && parsed.getMonth() === month - 1 && parsed.getDate() === day ) { if (!monthDay[1] && localDateKey(parsed) < baseKey) { parsed.setFullYear(year + 1); } return localDateKey(parsed); } } const weekday = /^(下周|本周|周|星期)([一二三四五六日天])$/.exec(value); if (weekday) { const target = chineseWeekdays[weekday[2]]; const current = base.getDay(); let delta: number; if (weekday[1] === "下周") { const daysToNextMonday = (8 - current) % 7 || 7; delta = daysToNextMonday + (target === 0 ? 6 : target - 1); } else if (weekday[1] === "本周") { const currentFromMonday = current === 0 ? 6 : current - 1; const targetFromMonday = target === 0 ? 6 : target - 1; delta = targetFromMonday - currentFromMonday; } else { delta = (target - current + 7) % 7; if (delta === 0 && weekday[1] !== "本周") delta = 7; } return addLocalDays(baseKey, delta); } return undefined; } export function nextRepeatDate( rule: PlainleafRepeatRule, from: string, ): string { const date = dateFromKey(from); if (rule === "daily") return addLocalDays(from, 1); if (rule === "weekdays") { do date.setDate(date.getDate() + 1); while (date.getDay() === 0 || date.getDay() === 6); return localDateKey(date); } if (rule === "weekly") return addLocalDays(from, 7); if (rule === "monthly") { const targetDay = date.getDate(); date.setDate(1); date.setMonth(date.getMonth() + 1); const lastDay = new Date( date.getFullYear(), date.getMonth() + 1, 0, ).getDate(); date.setDate(Math.min(targetDay, lastDay)); return localDateKey(date); } const month = date.getMonth(); const day = date.getDate(); date.setFullYear(date.getFullYear() + 1, month, 1); const lastDay = new Date(date.getFullYear(), month + 1, 0).getDate(); date.setDate(Math.min(day, lastDay)); return localDateKey(date); } export function nextRepeatedTask( task: PlainleafTask, today = localDateKey(), ): PlainleafNewTask | undefined { if (!task.repeat) return undefined; const scheduledDate = taskDate(task.scheduled); const deadlineDate = taskDate(task.deadline); const sourceDate = scheduledDate ?? deadlineDate ?? today; const nextDate = nextRepeatDate(task.repeat, sourceDate); const scheduled = scheduledDate || !deadlineDate ? nextDate : undefined; const deadlineDelta = deadlineDate && scheduledDate ? Math.round( (dateFromKey(deadlineDate).getTime() - dateFromKey(scheduledDate).getTime()) / 86_400_000, ) : undefined; return { name: task.name.replace(/\s*\[\[[^\]]+\]\]/g, "").trim() || task.name, scheduled, deadline: deadlineDelta === undefined ? deadlineDate ? nextRepeatDate(task.repeat, deadlineDate) : undefined : addLocalDays(scheduled!, deadlineDelta), repeat: task.repeat, reminder: task.reminder ? `${scheduled ?? nextDate}T${task.reminder.split("T")[1] ?? "09:00"}` : undefined, project: task.links?.[0], tags: task.tags, }; } export function isOverdueTask( task: PlainleafTask, today = localDateKey(), ): boolean { const deadline = taskDate(task.deadline); return !task.done && !!deadline && deadline < today; } export function taskBelongsToToday( task: PlainleafTask, today = localDateKey(), ): boolean { if (task.done) return false; const scheduled = taskDate(task.scheduled); const deadline = taskDate(task.deadline); return scheduled === today || (!!deadline && deadline <= today); } export function tasksForInbox(tasks: PlainleafTask[]): PlainleafTask[] { return tasks.filter( (task) => !task.done && task.page.replace(/\.md$/i, "") === plainleafTaskInbox, ); } export function tasksForAnytime(tasks: PlainleafTask[]): PlainleafTask[] { return tasks.filter( (task) => !task.done && !taskDate(task.scheduled) && !taskDate(task.deadline) && task.page.replace(/\.md$/i, "") !== plainleafTaskInbox, ); } function futureTaskDate( task: PlainleafTask, today: string, ): string | undefined { const scheduled = taskDate(task.scheduled); const deadline = taskDate(task.deadline); if (scheduled && scheduled > today) return scheduled; if (deadline && deadline > today) return deadline; return undefined; } export function groupScheduledTasks( tasks: PlainleafTask[], today = localDateKey(), ): Array<{ date: string; tasks: PlainleafTask[] }> { const groups = new Map(); for (const task of tasks) { if (task.done) continue; const date = futureTaskDate(task, today); if (!date) continue; const group = groups.get(date) ?? []; group.push(task); groups.set(date, group); } return [...groups.entries()] .sort(([a], [b]) => a.localeCompare(b)) .map(([date, groupedTasks]) => ({ date, tasks: groupedTasks })); } export function projectForTask(task: PlainleafTask): string | undefined { const explicit = task.links?.find((link) => link.trim()); if (explicit) return explicit.trim(); const page = task.page.replace(/\.md$/i, ""); return page === plainleafTaskInbox ? undefined : page; } export function groupTasksByProject( tasks: PlainleafTask[], ): Array<{ project: string; tasks: PlainleafTask[] }> { const groups = new Map(); for (const task of tasks) { if (task.done) continue; const project = projectForTask(task); if (!project) continue; const group = groups.get(project) ?? []; group.push(task); groups.set(project, group); } return [...groups.entries()] .sort(([a], [b]) => a.localeCompare(b, "zh-CN")) .map(([project, groupedTasks]) => ({ project, tasks: groupedTasks })); } export function projectSummaries(tasks: PlainleafTask[]): Array<{ project: string; open: number; completed: number; total: number; }> { const summaries = new Map< string, { project: string; open: number; completed: number; total: number } >(); for (const task of tasks) { const project = projectForTask(task); if (!project) continue; const summary = summaries.get(project) ?? { project, open: 0, completed: 0, total: 0, }; summary.total += 1; if (task.done) summary.completed += 1; else summary.open += 1; summaries.set(project, summary); } return [...summaries.values()].sort((a, b) => a.project.localeCompare(b.project, "zh-CN"), ); } export function taskMatchesFilters( task: PlainleafTask, filters: { query?: string; tag?: string; project?: string }, ): boolean { const query = filters.query?.trim().toLocaleLowerCase("zh-CN"); if ( query && ![task.name, task.page, ...(task.tags ?? []), ...(task.links ?? [])] .join(" ") .toLocaleLowerCase("zh-CN") .includes(query) ) { return false; } if (filters.tag && !task.tags?.includes(filters.tag)) return false; if (filters.project && projectForTask(task) !== filters.project) return false; return true; } export function taskDepth(task: PlainleafTask, tasks: PlainleafTask[]): number { const byRef = new Map(tasks.map((candidate) => [candidate.ref, candidate])); let depth = 0; let parent = task.parent; const visited = new Set(); while (parent && depth < 4 && !visited.has(parent)) { visited.add(parent); depth += 1; parent = byRef.get(parent)?.parent; } return depth; } export function subtaskProgress( task: PlainleafTask, tasks: PlainleafTask[], ): { completed: number; total: number } | undefined { const children = new Map(); for (const candidate of tasks) { if (!candidate.parent) continue; const siblings = children.get(candidate.parent) ?? []; siblings.push(candidate); children.set(candidate.parent, siblings); } const descendants: PlainleafTask[] = []; const pending = [...(children.get(task.ref) ?? [])]; const visited = new Set([task.ref]); while (pending.length) { const candidate = pending.shift()!; if (visited.has(candidate.ref)) continue; visited.add(candidate.ref); descendants.push(candidate); pending.push(...(children.get(candidate.ref) ?? [])); } if (!descendants.length) return undefined; return { completed: descendants.filter((candidate) => candidate.done).length, total: descendants.length, }; } export function calendarMonth( year: number, month: number, ): Array<{ date: string; inMonth: boolean }> { const first = new Date(year, month, 1, 12); const start = new Date(first); start.setDate(first.getDate() - first.getDay()); return Array.from({ length: 42 }, (_, index) => { const date = new Date(start); date.setDate(start.getDate() + index); return { date: localDateKey(date), inMonth: date.getMonth() === month }; }); } export function tasksForCalendarDate( tasks: PlainleafTask[], date: string, ): PlainleafTask[] { return tasks.filter( (task) => !task.done && (taskDate(task.scheduled) === date || taskDate(task.deadline) === date), ); } export function dueReminderTasks( tasks: PlainleafTask[], now = new Date(), notified = new Set(), ): PlainleafTask[] { const timestamp = now.getTime(); return tasks.filter((task) => { if ( task.done || !task.reminder || notified.has(`${task.ref}:${task.reminder}`) ) { return false; } const reminder = new Date(task.reminder).getTime(); return !Number.isNaN(reminder) && reminder <= timestamp; }); } export function staleProjects( tasks: PlainleafTask[], today = localDateKey(), afterDays = 14, ): Set { const cutoff = addLocalDays(today, -afterDays); const stale = new Set(); for (const group of groupTasksByProject(tasks)) { const latest = group.tasks .map((task) => taskDate(task.pageLastModified)) .filter(Boolean) .sort() .at(-1); if (latest && latest < cutoff) stale.add(group.project); } return stale; } export type TaskHealthIssue = { ref: string; type: | "invalid-date" | "deadline-before-scheduled" | "missing-parent" | "duplicate-ref"; message: string; }; export function taskHealthIssues(tasks: PlainleafTask[]): TaskHealthIssue[] { const issues: TaskHealthIssue[] = []; const refs = new Map(); for (const task of tasks) refs.set(task.ref, (refs.get(task.ref) ?? 0) + 1); const knownRefs = new Set(refs.keys()); for (const task of tasks) { for (const [field, value] of [ ["计划日期", task.scheduled], ["截止日期", task.deadline], ] as const) { if (value && !taskDate(value)) { issues.push({ ref: task.ref, type: "invalid-date", message: `${field}格式无效`, }); } } const scheduled = taskDate(task.scheduled); const deadline = taskDate(task.deadline); if (scheduled && deadline && deadline < scheduled) { issues.push({ ref: task.ref, type: "deadline-before-scheduled", message: "截止日期早于计划日期", }); } if (task.parent && !knownRefs.has(task.parent)) { issues.push({ ref: task.ref, type: "missing-parent", message: "找不到父任务", }); } if ((refs.get(task.ref) ?? 0) > 1) { issues.push({ ref: task.ref, type: "duplicate-ref", message: "任务引用重复", }); } } return issues; } export function logbookTasks(tasks: PlainleafTask[]): PlainleafTask[] { return tasks .filter((task) => task.done) .sort((a, b) => (taskDate(b.completed) ?? b.pageLastModified ?? "").localeCompare( taskDate(a.completed) ?? a.pageLastModified ?? "", ), ); } export function groupLogbookTasks( tasks: PlainleafTask[], period: "week" | "month", ): Array<{ period: string; tasks: PlainleafTask[] }> { const groups = new Map(); for (const task of logbookTasks(tasks)) { const key = taskDate(task.completed) ?? taskDate(task.pageLastModified); if (!key) continue; const date = dateFromKey(key); let label: string; if (period === "month") { label = `${date.getFullYear()}年${date.getMonth() + 1}月`; } else { const monday = new Date(date); const day = monday.getDay() || 7; monday.setDate(monday.getDate() - day + 1); label = `${localDateKey(monday)} 这一周`; } const group = groups.get(label) ?? []; group.push(task); groups.set(label, group); } return [...groups.entries()].map(([label, groupedTasks]) => ({ period: label, tasks: groupedTasks, })); } export function sortTodayTasks( tasks: PlainleafTask[], today = localDateKey(), ): PlainleafTask[] { return tasks .filter((task) => taskBelongsToToday(task, today)) .sort((a, b) => { const rank = (task: PlainleafTask) => { if (isOverdueTask(task, today)) return 0; if (taskDate(task.deadline) === today) return 1; return 2; }; return rank(a) - rank(b) || a.name.localeCompare(b.name, "zh-CN"); }); }