From bab4b36fae68df4b6c1ece6df5d8c5ee5e027587 Mon Sep 17 00:00:00 2001 From: wusumac <736139669@qq.com> Date: Wed, 12 Aug 2026 19:26:10 +0800 Subject: [PATCH] feat: build Markdown-native task center Co-Authored-By: Codex --- client/components/plainleaf_tasks.test.ts | 113 +++ client/components/plainleaf_tasks.ts | 162 ++++ client/components/plainleaf_workspace.test.ts | 14 + client/components/plainleaf_workspace.tsx | 891 ++++++++++++++---- client/editor_ui.tsx | 23 +- client/styles/plainleaf_workspace.scss | 753 ++++++++++++++- client/styles/plainleaf_workspace.test.ts | 14 +- plugs/index/index.plug.yaml | 4 + plugs/index/item.test.ts | 15 + plugs/index/task.test.ts | 70 +- plugs/index/task.ts | 294 +++++- 11 files changed, 2129 insertions(+), 224 deletions(-) create mode 100644 client/components/plainleaf_tasks.test.ts create mode 100644 client/components/plainleaf_tasks.ts diff --git a/client/components/plainleaf_tasks.test.ts b/client/components/plainleaf_tasks.test.ts new file mode 100644 index 00000000..2bf60460 --- /dev/null +++ b/client/components/plainleaf_tasks.test.ts @@ -0,0 +1,113 @@ +import { expect, test } from "vitest"; +import { + groupScheduledTasks, + groupTasksByProject, + isOverdueTask, + localDateKey, + logbookTasks, + projectForTask, + sortTodayTasks, + taskBelongsToToday, + tasksForAnytime, + tasksForInbox, + type PlainleafTask, +} from "./plainleaf_tasks.ts"; + +function task(extra: Partial = {}): PlainleafTask { + return { + ref: "测试@0", + page: "测试", + name: "测试任务", + state: " ", + done: false, + ...extra, + }; +} + +test("localDateKey uses local calendar fields", () => { + expect(localDateKey(new Date(2026, 7, 12, 23, 30))).toBe("2026-08-12"); +}); + +test("today contains scheduled today and overdue or due deadlines", () => { + const today = "2026-08-12"; + expect(taskBelongsToToday(task({ scheduled: today }), today)).toBe(true); + expect(taskBelongsToToday(task({ deadline: "2026-08-10" }), today)).toBe( + true, + ); + expect(taskBelongsToToday(task({ deadline: today }), today)).toBe(true); + expect(taskBelongsToToday(task({ scheduled: "2026-08-13" }), today)).toBe( + false, + ); + expect( + taskBelongsToToday(task({ scheduled: today, done: true }), today), + ).toBe(false); + expect(isOverdueTask(task({ deadline: "2026-08-11" }), today)).toBe(true); +}); + +test("sortTodayTasks puts overdue and due tasks before scheduled tasks", () => { + const today = "2026-08-12"; + expect( + sortTodayTasks( + [ + task({ ref: "scheduled", name: "计划", scheduled: today }), + task({ ref: "due", name: "截止", deadline: today }), + task({ ref: "overdue", name: "逾期", deadline: "2026-08-11" }), + ], + today, + ).map((item) => item.ref), + ).toEqual(["overdue", "due", "scheduled"]); +}); + +test("inbox and anytime remain distinct", () => { + const inbox = task({ ref: "inbox", page: "任务/收件箱" }); + const anytime = task({ ref: "anytime", page: "生活/采购" }); + const planned = task({ + ref: "planned", + page: "生活/采购", + scheduled: "2026-08-13", + }); + expect(tasksForInbox([inbox, anytime, planned])).toEqual([inbox]); + expect(tasksForAnytime([inbox, anytime, planned])).toEqual([anytime]); +}); + +test("planned tasks are grouped by the next meaningful date", () => { + const groups = groupScheduledTasks( + [ + task({ ref: "a", scheduled: "2026-08-14" }), + task({ ref: "b", deadline: "2026-08-13" }), + task({ ref: "c", scheduled: "2026-08-13", deadline: "2026-08-20" }), + task({ ref: "old", deadline: "2026-08-11" }), + ], + "2026-08-12", + ); + expect( + groups.map((group) => [group.date, group.tasks.map((item) => item.ref)]), + ).toEqual([ + ["2026-08-13", ["b", "c"]], + ["2026-08-14", ["a"]], + ]); +}); + +test("explicit wiki links override source pages as projects", () => { + expect( + projectForTask(task({ page: "工作/周会", links: ["项目/Plainleaf"] })), + ).toBe("项目/Plainleaf"); + expect(projectForTask(task({ page: "工作/周会" }))).toBe("工作/周会"); + expect(projectForTask(task({ page: "任务/收件箱" }))).toBeUndefined(); + expect( + groupTasksByProject([ + task({ ref: "a", page: "工作", links: ["项目 A"] }), + task({ ref: "b", page: "项目 B" }), + ]).map((group) => group.project), + ).toEqual(["项目 A", "项目 B"]); +}); + +test("logbook sorts completed metadata before source modification time", () => { + expect( + logbookTasks([ + task({ ref: "old", done: true, completed: "2026-08-10" }), + task({ ref: "new", done: true, completed: "2026-08-12" }), + task({ ref: "open" }), + ]).map((item) => item.ref), + ).toEqual(["new", "old"]); +}); diff --git a/client/components/plainleaf_tasks.ts b/client/components/plainleaf_tasks.ts new file mode 100644 index 00000000..1fb6d0b1 --- /dev/null +++ b/client/components/plainleaf_tasks.ts @@ -0,0 +1,162 @@ +export type PlainleafTask = { + ref: string; + page: string; + name: string; + state: string; + done: boolean; + scheduled?: string; + deadline?: string; + completed?: string; + tags?: string[]; + links?: string[]; + pageLastModified?: string; +}; + +export type PlainleafTaskPatch = { + name?: string; + state?: string; + scheduled?: string | null; + deadline?: string | null; + completed?: string | null; + project?: string | null; + tags?: string[]; +}; + +export type PlainleafNewTask = { + name: string; + scheduled?: string | null; + deadline?: string | null; + project?: string | null; + tags?: string[]; +}; + +export type PlainleafTaskList = + "inbox" | "today" | "planned" | "anytime" | "projects" | "logbook"; + +export const plainleafTaskInbox = "任务/收件箱"; + +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}/); + return match?.[0]; +} + +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 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 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"); + }); +} diff --git a/client/components/plainleaf_workspace.test.ts b/client/components/plainleaf_workspace.test.ts index 70631e0f..50ee3c53 100644 --- a/client/components/plainleaf_workspace.test.ts +++ b/client/components/plainleaf_workspace.test.ts @@ -5,6 +5,7 @@ import { collectPlainleafTags, plainleafPageLabel, plainleafTaskReference, + plainleafTaskTitle, visiblePlainleafPages, } from "./plainleaf_workspace.tsx"; @@ -147,3 +148,16 @@ test("qualifies anchor-backed task refs with their source page", () => { }), ).toBe("项目/计划@42"); }); + +test("separates explicit project links from the editable task title", () => { + expect( + plainleafTaskTitle({ + ref: "项目/计划@42", + page: "项目/计划", + name: "跟进设计稿 [[项目/Plainleaf]]", + state: " ", + done: false, + links: ["项目/Plainleaf"], + }), + ).toBe("跟进设计稿"); +}); diff --git a/client/components/plainleaf_workspace.tsx b/client/components/plainleaf_workspace.tsx index ffc9707b..a79e1d91 100644 --- a/client/components/plainleaf_workspace.tsx +++ b/client/components/plainleaf_workspace.tsx @@ -10,6 +10,7 @@ import { ChevronDown, ChevronRight, Circle, + Clipboard, Clock, Cloud, Command, @@ -32,6 +33,29 @@ import { Zap, } from "preact-feather"; import { parsePageMetaLastModified } from "../lib/page_meta.ts"; +import { + groupScheduledTasks, + groupTasksByProject, + isOverdueTask, + localDateKey, + logbookTasks, + plainleafTaskInbox, + projectForTask, + sortTodayTasks, + taskDate, + tasksForAnytime, + tasksForInbox, + type PlainleafNewTask, + type PlainleafTask, + type PlainleafTaskList, + type PlainleafTaskPatch, +} from "./plainleaf_tasks.ts"; + +export type { + PlainleafNewTask, + PlainleafTask, + PlainleafTaskPatch, +} from "./plainleaf_tasks.ts"; export type PageTreeFile = { kind: "page"; @@ -59,15 +83,6 @@ export type PlainleafSearchResult = { updated?: string; }; -export type PlainleafTask = { - ref: string; - page: string; - name: string; - state: string; - done: boolean; - deadline?: string; -}; - export function plainleafTaskReference(task: PlainleafTask): string { const ref = task.ref.trim(); if (ref.includes("@") || ref.includes("#") || ref.includes("$")) return ref; @@ -75,19 +90,10 @@ export function plainleafTaskReference(task: PlainleafTask): string { } export type PlainleafAdvancedTool = - | "commands" - | "graph" - | "libraries" - | "configuration" - | "system-pages"; + "commands" | "graph" | "libraries" | "configuration" | "system-pages"; type PlainleafView = - | "notes" - | "search" - | "tasks" - | "tags" - | "settings" - | "more"; + "notes" | "search" | "tasks" | "tags" | "settings" | "more"; type MutableFolder = { name: string; @@ -198,14 +204,12 @@ function parentFolderPaths(page?: string): string[] { function finalizeFolder(folder: MutableFolder): PageTreeNode[] { return [ - ...Array.from(folder.folders.values()).map( - (child): PageTreeFolder => ({ - kind: "folder", - name: child.name, - path: child.path, - children: finalizeFolder(child), - }), - ), + ...Array.from(folder.folders.values()).map((child): PageTreeFolder => ({ + kind: "folder", + name: child.name, + path: child.path, + children: finalizeFolder(child), + })), ...folder.pages, ].sort(compareTreeNodes); } @@ -464,6 +468,315 @@ function AdvancedTools({ ); } +const taskListLabels: Record = { + inbox: "收件箱", + today: "今天", + planned: "计划", + anytime: "随时", + projects: "项目", + logbook: "日志簿", +}; + +function taskListIcon(list: PlainleafTaskList) { + const props = { size: 16 }; + switch (list) { + case "inbox": + return ; + case "today": + return ; + case "planned": + return ; + case "anytime": + return ; + case "projects": + return ; + case "logbook": + return ; + } +} + +function displayTaskDate(value?: string): string { + const date = taskDate(value); + if (!date) return ""; + const today = localDateKey(); + if (date === today) return "今天"; + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + if (date === localDateKey(tomorrow)) return "明天"; + return new Date(`${date}T00:00:00`).toLocaleDateString("zh-CN", { + month: "numeric", + day: "numeric", + weekday: "short", + }); +} + +export function plainleafTaskTitle(task: PlainleafTask): string { + return task.name.replace(/\s*\[\[[^\]]+\]\]/g, "").trim() || task.name; +} + +function TaskRow({ + task, + selected, + readOnly, + onSelect, + onComplete, +}: { + task: PlainleafTask; + selected: boolean; + readOnly: boolean; + onSelect: () => void; + onComplete: () => void; +}) { + const date = taskDate(task.deadline) ?? taskDate(task.scheduled); + return ( +
+ + +
+ ); +} + +function TaskEditor({ + task, + readOnly, + onClose, + onSave, + onOpenSource, +}: { + task: PlainleafTask; + readOnly: boolean; + onClose: () => void; + onSave: (patch: PlainleafTaskPatch) => Promise; + onOpenSource: () => void; +}) { + const [name, setName] = useState(plainleafTaskTitle(task)); + const [scheduled, setScheduled] = useState(taskDate(task.scheduled) ?? ""); + const [deadline, setDeadline] = useState(taskDate(task.deadline) ?? ""); + const [project, setProject] = useState(task.links?.[0] ?? ""); + const [tags, setTags] = useState((task.tags ?? []).join(" ")); + const [saving, setSaving] = useState(false); + + useEffect(() => { + setName(plainleafTaskTitle(task)); + setScheduled(taskDate(task.scheduled) ?? ""); + setDeadline(taskDate(task.deadline) ?? ""); + setProject(task.links?.[0] ?? ""); + setTags((task.tags ?? []).join(" ")); + }, [task.ref, task.name, task.scheduled, task.deadline, task.links]); + + const save = async () => { + if (!name.trim()) return; + setSaving(true); + try { + await onSave({ + name: name.trim(), + scheduled: scheduled || null, + deadline: deadline || null, + project: project || null, + tags: tags.split(/[\s,,]+/).filter(Boolean), + }); + } finally { + setSaving(false); + } + }; + + return ( +