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
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>
570 lines
16 KiB
TypeScript
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");
|
|
});
|
|
}
|