Files
plainleaf/client/components/plainleaf_tasks.ts
T
wushenghua 9e43ae824a
CI / config (push) Canceled after 0s
CI / test-frontend (push) Canceled after 0s
CI / test-rust (push) Canceled after 0s
CI / test-e2e (push) Canceled after 0s
CI / test-e2e-release (push) Canceled after 0s
CI / build (push) Canceled after 0s
CI / release (push) Canceled after 0s
CI / docker (push) Canceled after 0s
CI / docker-website (push) Canceled after 0s
feat: complete task planning and account management
Add Markdown-native planning, repeat rules, subtasks, reminders, transactional backup restore, Chinese account management, and the account-managed NAS migration configuration.\n\nCo-Authored-By: Codex <noreply@anthropic.com>
2026-08-12 20:22:33 +08:00

570 lines
16 KiB
TypeScript

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<PlainleafRepeatRule, string> = {
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<string, number> = {
日: 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<string, PlainleafTask[]>();
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<string, PlainleafTask[]>();
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<string>();
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<string, PlainleafTask[]>();
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<string>([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<string>(),
): 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<string> {
const cutoff = addLocalDays(today, -afterDays);
const stale = new Set<string>();
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<string, number>();
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<string, PlainleafTask[]>();
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");
});
}