feat: build Markdown-native task center
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

Co-Authored-By: Codex <noreply@anthropic.com>
This commit is contained in:
2026-08-12 19:26:10 +08:00
co-authored by Codex
parent 5440f36e06
commit bab4b36fae
11 changed files with 2129 additions and 224 deletions
+113
View File
@@ -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> = {}): 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"]);
});
+162
View File
@@ -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<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 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");
});
}
@@ -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("跟进设计稿");
});
File diff suppressed because it is too large Load Diff
+20 -3
View File
@@ -9,9 +9,11 @@ import {
plainleafTaskReference,
visiblePlainleafPages,
type PlainleafAdvancedTool,
type PlainleafNewTask,
type PlainleafSearchResult,
type PlainleafSearchScope,
type PlainleafTask,
type PlainleafTaskPatch,
} from "./components/plainleaf_workspace.tsx";
import reducer from "./reducer.ts";
import {
@@ -632,10 +634,25 @@ export class MainUI {
return [...results.values()].slice(0, 60);
}}
onLoadTasks={() => client.queryLuaObjects<PlainleafTask>("task", {})}
onToggleTask={async (task) => {
onToggleTask={async (task, patch) => {
await client.clientSystem.system.invokeFunction(
"index.updateTaskState",
[plainleafTaskReference(task), task.state, "x"],
"index.updateTaskByRef",
[plainleafTaskReference(task), patch],
);
}}
onUpdateTask={async (
task: PlainleafTask,
patch: PlainleafTaskPatch,
) => {
await client.clientSystem.system.invokeFunction(
"index.updateTaskByRef",
[plainleafTaskReference(task), patch],
);
}}
onCreateTask={async (input: PlainleafNewTask) => {
return await client.clientSystem.system.invokeFunction(
"index.appendTask",
["任务/收件箱", input],
);
}}
onOpenTask={(task) => {
+745 -8
View File
@@ -554,9 +554,15 @@ html[data-theme="dark"] #pl-workspace-shell {
background: var(--pl-green);
}
.pl-tag-dot.color-1 { background: var(--pl-coral); }
.pl-tag-dot.color-2 { background: var(--pl-blue); }
.pl-tag-dot.color-3 { background: var(--pl-yellow); }
.pl-tag-dot.color-1 {
background: var(--pl-coral);
}
.pl-tag-dot.color-2 {
background: var(--pl-blue);
}
.pl-tag-dot.color-3 {
background: var(--pl-yellow);
}
.pl-tag-results {
margin-top: 17px;
@@ -829,7 +835,8 @@ button.pl-setting-row:hover {
}
.pl-home-surface,
.pl-settings-surface {
.pl-settings-surface,
.pl-task-surface {
z-index: 5;
grid-area: 1 / 1;
min-width: 0;
@@ -842,6 +849,588 @@ button.pl-setting-row:hover {
-webkit-overflow-scrolling: touch;
}
.pl-task-surface {
display: grid;
grid-template-columns: minmax(360px, 1fr) auto;
overflow: hidden;
}
.pl-task-workspace-nav {
box-sizing: border-box;
min-width: 0;
min-height: 0;
padding: 25px 14px;
border-right: 1px solid var(--pl-border);
background: color-mix(in srgb, var(--pl-sidebar) 72%, var(--pl-surface));
overflow-y: auto;
}
.pl-task-workspace-nav > header {
display: flex;
align-items: center;
justify-content: space-between;
height: 36px;
margin: 0 6px 14px;
color: var(--pl-muted);
font-size: 11px;
font-weight: 700;
}
.pl-task-workspace-nav > header button,
.pl-task-inspector > header button,
.pl-task-dialog > header button {
display: grid;
place-items: center;
width: 32px;
height: 32px;
padding: 0;
border: 0;
border-radius: 6px;
color: var(--pl-muted);
background: transparent;
cursor: pointer;
}
.pl-task-workspace-nav > header button:hover,
.pl-task-inspector > header button:hover,
.pl-task-dialog > header button:hover {
color: var(--pl-text);
background: var(--pl-green-soft);
}
.pl-task-workspace-nav > button,
.pl-task-sidebar-lists > button {
display: grid;
grid-template-columns: 30px minmax(0, 1fr) auto;
align-items: center;
gap: 7px;
width: 100%;
min-width: 0;
height: 40px;
padding: 0 9px;
border: 0;
border-radius: 6px;
color: var(--pl-muted);
background: transparent;
cursor: pointer;
font-size: 13px;
text-align: left;
}
.pl-task-workspace-nav > button:hover,
.pl-task-workspace-nav > button.is-active,
.pl-task-sidebar-lists > button:hover,
.pl-task-sidebar-lists > button.is-active {
color: var(--pl-text);
background: var(--pl-green-soft);
}
.pl-task-workspace-nav > button > small,
.pl-task-sidebar-lists > button > small {
color: var(--pl-faint);
font-size: 10px;
}
.pl-task-nav-icon,
.pl-task-title-icon {
display: grid;
place-items: center;
width: 27px;
height: 27px;
border-radius: 6px;
color: var(--pl-green-dark);
background: var(--pl-green-soft);
}
.pl-task-nav-icon.is-today,
.pl-task-title-icon.is-today {
color: #a06624;
background: color-mix(in srgb, var(--pl-yellow) 30%, var(--pl-surface));
}
.pl-task-nav-icon.is-planned,
.pl-task-title-icon.is-planned {
color: var(--pl-coral);
background: var(--pl-coral-soft);
}
.pl-task-nav-icon.is-projects,
.pl-task-title-icon.is-projects {
color: var(--pl-blue);
background: var(--pl-blue-soft);
}
.pl-task-content {
display: flex;
position: relative;
min-width: 0;
min-height: 0;
flex-direction: column;
background: var(--pl-surface);
overflow: hidden;
}
.pl-task-content-header {
display: grid;
grid-template-columns: 36px minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
box-sizing: border-box;
min-height: 92px;
padding: 22px clamp(24px, 4vw, 54px) 18px;
border-bottom: 1px solid var(--pl-border);
}
.pl-task-content-header > div {
min-width: 0;
}
.pl-task-content-header h1 {
margin: 0;
color: var(--pl-text);
font-size: 23px;
font-weight: 700;
line-height: 30px;
}
.pl-task-content-header p {
margin: 1px 0 0;
color: var(--pl-faint);
font-size: 11px;
}
.pl-task-add-button {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 36px;
padding: 0 13px;
border: 1px solid var(--pl-green);
border-radius: 6px;
color: white;
background: #3f7658;
cursor: pointer;
font-size: 12px;
font-weight: 650;
}
.pl-task-add-button:disabled,
.pl-task-mobile-add:disabled {
opacity: 0.5;
}
.pl-task-mobile-lists,
.pl-task-mobile-add {
display: none;
}
.pl-task-scroll {
box-sizing: border-box;
width: min(100%, 780px);
min-height: 0;
margin: 0 auto;
padding: 26px clamp(24px, 5vw, 64px) 90px;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
}
.pl-task-group + .pl-task-group {
margin-top: 28px;
}
.pl-task-group > header {
display: flex;
align-items: center;
justify-content: space-between;
height: 30px;
padding: 0 5px;
border-bottom: 1px solid var(--pl-border);
}
.pl-task-group > header h2 {
margin: 0;
color: var(--pl-muted);
font-size: 11px;
font-weight: 700;
}
.pl-task-group > header span {
color: var(--pl-faint);
font-size: 10px;
}
.pl-task-center-row {
display: grid;
grid-template-columns: 38px minmax(0, 1fr);
align-items: start;
min-height: 58px;
border-bottom: 1px solid color-mix(in srgb, var(--pl-border) 72%, transparent);
transition: background-color 120ms ease;
}
.pl-task-center-row:hover,
.pl-task-center-row.is-selected {
background: color-mix(in srgb, var(--pl-green-soft) 45%, transparent);
}
.pl-task-center-check {
display: grid;
place-items: center;
width: 38px;
height: 52px;
padding: 0;
border: 0;
color: var(--pl-faint);
background: transparent;
cursor: pointer;
}
.pl-task-center-check:hover {
color: var(--pl-green);
}
.pl-task-center-copy {
display: block;
min-width: 0;
min-height: 58px;
padding: 10px 7px 8px 0;
border: 0;
color: var(--pl-text);
background: transparent;
cursor: pointer;
text-align: left;
}
.pl-task-center-copy > strong {
display: block;
overflow-wrap: anywhere;
font-size: 13px;
font-weight: 520;
line-height: 20px;
}
.pl-task-center-copy > span {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
margin-top: 3px;
color: var(--pl-faint);
font-size: 10px;
}
.pl-task-center-copy small {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pl-task-center-copy time {
flex: 0 0 auto;
}
.pl-task-center-copy time.is-overdue {
color: var(--pl-coral);
font-weight: 650;
}
.pl-task-center-row.is-complete .pl-task-center-copy > strong {
color: var(--pl-faint);
text-decoration: line-through;
}
.pl-task-empty,
.pl-task-loading {
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
min-height: 280px;
flex-direction: column;
color: var(--pl-faint);
text-align: center;
}
.pl-task-empty > span {
display: grid;
place-items: center;
width: 42px;
height: 42px;
margin-bottom: 12px;
border-radius: 6px;
color: var(--pl-green);
background: var(--pl-green-soft);
}
.pl-task-empty strong {
color: var(--pl-muted);
font-size: 13px;
}
.pl-task-empty p {
max-width: 300px;
margin: 7px 0 0;
font-size: 11px;
line-height: 18px;
}
.pl-task-inspector {
box-sizing: border-box;
width: 310px;
min-height: 0;
padding: 20px;
border-left: 1px solid var(--pl-border);
background: var(--pl-canvas);
overflow-x: hidden;
overflow-y: auto;
}
.pl-task-inspector > header,
.pl-task-dialog > header {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 34px;
margin-bottom: 19px;
}
.pl-task-inspector > header strong {
font-size: 13px;
}
.pl-task-inspector > label,
.pl-task-dialog-project {
display: block;
margin-top: 15px;
color: var(--pl-muted);
font-size: 10px;
font-weight: 650;
}
.pl-task-inspector input,
.pl-task-inspector textarea,
.pl-task-dialog input,
.pl-task-dialog textarea {
display: block;
box-sizing: border-box;
width: 100%;
margin-top: 6px;
padding: 9px 10px;
border: 1px solid var(--pl-border-strong);
border-radius: 6px;
color: var(--pl-text);
background: var(--pl-surface);
font-size: 12px;
line-height: 18px;
}
.pl-task-inspector input {
height: 38px;
padding-block: 0;
}
.pl-task-inspector textarea,
.pl-task-dialog textarea {
resize: vertical;
}
.pl-task-date-fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 9px;
margin-top: 15px;
}
.pl-task-date-fields label {
color: var(--pl-muted);
font-size: 10px;
font-weight: 650;
}
.pl-task-source {
margin-top: 19px;
padding-top: 14px;
border-top: 1px solid var(--pl-border);
}
.pl-task-source span,
.pl-task-source strong {
display: block;
}
.pl-task-source span {
color: var(--pl-faint);
font-size: 10px;
}
.pl-task-source strong {
margin-top: 5px;
overflow-wrap: anywhere;
font-size: 11px;
font-weight: 550;
}
.pl-task-inspector > footer,
.pl-task-dialog > footer {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 24px;
}
.pl-task-inspector > footer button,
.pl-task-dialog > footer button {
min-height: 34px;
padding: 0 11px;
border: 1px solid var(--pl-border);
border-radius: 6px;
color: var(--pl-text);
background: var(--pl-surface);
cursor: pointer;
font-size: 11px;
font-weight: 650;
}
.pl-task-inspector > footer button.is-primary,
.pl-task-dialog > footer button.is-primary {
border-color: #3f7658;
color: white;
background: #3f7658;
}
.pl-task-inspector > footer button:disabled,
.pl-task-dialog > footer button:disabled {
opacity: 0.5;
}
.pl-task-sidebar-lists {
display: grid;
gap: 2px;
}
.pl-task-dialog-backdrop {
display: grid;
place-items: center;
position: fixed;
z-index: 170;
inset: 0;
padding: 16px;
background: rgb(28 34 29 / 38%);
}
.pl-task-dialog {
box-sizing: border-box;
width: min(460px, calc(100vw - 32px));
max-width: 100%;
padding: 20px;
border: 1px solid var(--pl-border);
border-radius: 8px;
color: var(--pl-text);
background: var(--pl-surface);
box-shadow: 0 18px 60px rgb(38 48 40 / 22%);
}
.pl-task-dialog h2 {
margin: 0;
font-size: 17px;
}
.pl-task-dialog > textarea {
margin-top: 0;
font-size: 14px;
}
.pl-task-quick-dates {
display: flex;
gap: 8px;
margin-top: 12px;
}
.pl-task-quick-dates > button,
.pl-task-quick-dates > label {
display: flex;
align-items: center;
gap: 6px;
position: relative;
height: 34px;
padding: 0 10px;
border: 1px solid var(--pl-border);
border-radius: 6px;
color: var(--pl-muted);
background: var(--pl-surface);
cursor: pointer;
font-size: 11px;
}
.pl-task-quick-dates > button.is-active {
border-color: var(--pl-green);
color: var(--pl-green-dark);
background: var(--pl-green-soft);
}
.pl-task-quick-dates input[type="date"] {
position: absolute;
inset: 0;
height: 100%;
margin: 0;
opacity: 0;
cursor: pointer;
}
.pl-task-dialog > p {
margin: 14px 0 0;
color: var(--pl-faint);
font-size: 10px;
}
.pl-task-undo,
.pl-task-error {
display: flex;
align-items: center;
gap: 14px;
position: fixed;
z-index: 190;
right: 22px;
bottom: 22px;
max-width: min(420px, calc(100vw - 32px));
min-height: 42px;
padding: 0 12px 0 15px;
border: 1px solid var(--pl-border-strong);
border-radius: 6px;
color: var(--pl-surface);
background: var(--pl-text);
box-shadow: 0 10px 28px rgb(25 32 27 / 22%);
font-size: 11px;
}
.pl-task-undo span,
.pl-task-error span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pl-task-undo button,
.pl-task-error button {
flex: 0 0 auto;
padding: 5px;
border: 0;
color: var(--pl-green-soft);
background: transparent;
cursor: pointer;
font-weight: 700;
}
.pl-task-error {
bottom: 74px;
color: white;
background: var(--pl-danger);
}
.pl-home-inner {
box-sizing: border-box;
width: min(100%, 920px);
@@ -914,9 +1503,18 @@ button.pl-setting-row:hover {
min-width: 0;
}
.pl-home-actions .is-green { color: var(--pl-green); background: var(--pl-green-soft); }
.pl-home-actions .is-coral { color: var(--pl-coral); background: var(--pl-coral-soft); }
.pl-home-actions .is-blue { color: var(--pl-blue); background: var(--pl-blue-soft); }
.pl-home-actions .is-green {
color: var(--pl-green);
background: var(--pl-green-soft);
}
.pl-home-actions .is-coral {
color: var(--pl-coral);
background: var(--pl-coral-soft);
}
.pl-home-actions .is-blue {
color: var(--pl-blue);
background: var(--pl-blue-soft);
}
.pl-home-actions strong,
.pl-home-actions small,
@@ -1307,6 +1905,20 @@ button.pl-setting-row:hover {
.pl-home-grid {
gap: 24px;
}
.pl-task-surface {
grid-template-columns: minmax(320px, 1fr);
}
.pl-task-inspector {
position: absolute;
z-index: 20;
top: 0;
right: 0;
bottom: 0;
width: 310px;
box-shadow: -12px 0 34px rgb(29 37 31 / 14%);
}
}
@media only screen and (max-width: 700px) {
@@ -1332,7 +1944,10 @@ button.pl-setting-row:hover {
opacity: 0;
box-shadow: 12px 0 40px rgb(29 37 31 / 18%);
transform: translateX(-16px);
transition: opacity 180ms ease, transform 180ms ease, visibility 180ms ease;
transition:
opacity 180ms ease,
transform 180ms ease,
visibility 180ms ease;
}
.pl-sidebar.is-mobile-open {
@@ -1421,6 +2036,128 @@ button.pl-setting-row:hover {
border-right: 0;
}
.pl-task-surface {
display: block;
width: 100%;
max-width: 100vw;
height: calc(100vh - 62px - env(safe-area-inset-bottom));
height: calc(100dvh - 62px - env(safe-area-inset-bottom));
overflow: hidden;
}
.pl-task-workspace-nav {
display: none;
}
.pl-task-content {
width: 100%;
max-width: 100vw;
height: 100%;
}
.pl-task-content-header {
grid-template-columns: 34px 34px minmax(0, 1fr);
gap: 8px;
min-height: 70px;
padding: 9px 15px;
}
.pl-task-content-header h1 {
font-size: 19px;
line-height: 24px;
}
.pl-task-mobile-lists {
display: grid;
place-items: center;
width: 34px;
height: 34px;
padding: 0;
border: 0;
border-radius: 6px;
color: var(--pl-muted);
background: transparent;
}
.pl-task-add-button {
display: none;
}
.pl-task-scroll {
width: 100%;
max-width: 100vw;
padding: 14px 15px 86px;
}
.pl-task-mobile-add {
display: grid;
place-items: center;
position: absolute;
z-index: 15;
right: 20px;
bottom: 20px;
width: 48px;
height: 48px;
padding: 0;
border: 0;
border-radius: 50%;
color: white;
background: #3f7658;
box-shadow: 0 8px 20px rgb(43 79 59 / 26%);
}
.pl-task-inspector {
position: absolute;
z-index: 160;
inset: 0;
width: 100%;
max-width: 100vw;
height: 100%;
padding: 18px;
border-left: 0;
background: var(--pl-surface);
}
.pl-task-date-fields {
grid-template-columns: 1fr;
}
.pl-task-inspector > footer {
flex-direction: column-reverse;
}
.pl-task-inspector > footer button {
width: 100%;
min-height: 42px;
}
.pl-task-dialog-backdrop {
align-items: end;
padding: 0;
}
.pl-task-dialog {
width: 100%;
max-width: 100vw;
padding: 18px 18px calc(18px + env(safe-area-inset-bottom));
border-right: 0;
border-bottom: 0;
border-left: 0;
border-radius: 8px 8px 0 0;
}
.pl-task-undo,
.pl-task-error {
right: 16px;
bottom: calc(72px + env(safe-area-inset-bottom));
left: 16px;
max-width: none;
}
.pl-task-error {
bottom: calc(122px + env(safe-area-inset-bottom));
}
.pl-mobile-navigation {
display: grid;
grid-template-columns: repeat(5, 1fr);
+13 -1
View File
@@ -20,7 +20,7 @@ test("keeps mobile workspace within the dynamic viewport", () => {
test("uses internal vertical scrolling without widening the page", () => {
expect(workspaceStyles).toMatch(
/\.pl-home-surface,\s*\.pl-settings-surface\s*\{[^}]*overflow-x: hidden;[^}]*overflow-y: auto;/s,
/\.pl-home-surface,\s*\.pl-settings-surface,\s*\.pl-task-surface\s*\{[^}]*overflow-x: hidden;[^}]*overflow-y: auto;/s,
);
expect(workspaceStyles).toMatch(
/\.pl-home-inner\s*\{[^}]*box-sizing: border-box;[^}]*max-width: 100%;/s,
@@ -29,3 +29,15 @@ test("uses internal vertical scrolling without widening the page", () => {
/\.pl-settings-inner\s*\{[^}]*box-sizing: border-box;[^}]*max-width: 100%;/s,
);
});
test("gives the task center a full workspace and mobile viewport", () => {
expect(workspaceStyles).toMatch(
/\.pl-task-surface\s*\{[^}]*grid-template-columns: minmax\(360px, 1fr\) auto;/s,
);
expect(workspaceStyles).toMatch(
/@media only screen and \(max-width: 700px\)[\s\S]*?\.pl-task-surface\s*\{[^}]*height: calc\(100dvh - 62px - env\(safe-area-inset-bottom\)\);[^}]*overflow: hidden;/,
);
expect(workspaceStyles).toMatch(
/\.pl-task-content\s*\{[^}]*min-width: 0;[^}]*min-height: 0;[^}]*overflow: hidden;/s,
);
});
+4
View File
@@ -201,6 +201,10 @@ functions:
# Tasks
updateTaskState:
path: task.ts:updateTaskState
updateTaskByRef:
path: task.ts:updateTaskByRef
appendTask:
path: task.ts:appendTask
taskToggle:
path: "./task.ts:taskToggle"
+15
View File
@@ -120,6 +120,21 @@ test("task without anchor keeps Page@pos ref", async () => {
expect(task.ref).toMatch(/^MyPage@\d+$/);
});
test("task index exposes task-center metadata from Markdown", async () => {
const items = await indexItemsForTest(
'- [x] 购买打印纸 [scheduled: "2026-08-13"] [deadline: "2026-08-15"] [completed: "2026-08-14"] #采购 [[生活/采购]]\n',
);
const task = items.find((item) => item.tag === "task")!;
expect(task).toMatchObject({
done: true,
scheduled: "2026-08-13",
deadline: "2026-08-15",
completed: "2026-08-14",
tags: ["采购"],
links: ["生活/采购"],
});
});
// Regression test for https://github.com/silverbulletmd/silverbullet/issues/1932
// and https://github.com/silverbulletmd/silverbullet/issues/1903
const nestedListMd = `
+69 -1
View File
@@ -2,7 +2,12 @@ import { parseMarkdown } from "../../client/markdown_parser/parser.ts";
import { createMockSystem } from "../../plug-api/system_mock.ts";
import { expect, test } from "vitest";
import { addParentPointers, renderToText } from "../../plug-api/lib/tree.ts";
import { completeStates, removeCompletedTasksFromTree } from "./task.ts";
import {
buildTaskMarkdown,
completeStates,
removeCompletedTasksFromTree,
rewriteTaskAtPosition,
} from "./task.ts";
function removeCompleted(md: string): string {
createMockSystem();
@@ -89,3 +94,66 @@ test("removeCompletedTasks: no completed tasks, no change", () => {
const result = removeCompleted(input);
expect(result).toEqual(input);
});
function rewriteTask(
md: string,
position: number,
patch: Parameters<typeof rewriteTaskAtPosition>[3],
) {
createMockSystem();
return rewriteTaskAtPosition(md, parseMarkdown(md), position, patch);
}
test("buildTaskMarkdown writes portable task metadata", () => {
expect(
buildTaskMarkdown({
name: "购买打印纸",
scheduled: "2026-08-13",
deadline: "2026-08-15",
project: "采购",
tags: ["外出", "高 优"],
}),
).toBe(
'- [ ] 购买打印纸 [scheduled: "2026-08-13"] [deadline: "2026-08-15"] #外出 #高-优 [[采购]]',
);
expect(() => buildTaskMarkdown({ name: " " })).toThrow("待办标题不能为空");
});
test("rewriteTaskAtPosition updates task fields and preserves nested markdown", () => {
const input = `前文
- [ ] 原任务 [scheduled: "2026-08-12"] [priority: 2] #旧 [[旧项目]] $keep
- [ ] 子任务
后文
`;
expect(
rewriteTask(input, input.indexOf("- [ ]"), {
name: "新任务",
state: "x",
scheduled: null,
deadline: "2026-08-20",
completed: "2026-08-12",
project: "新项目",
tags: ["新标签"],
}),
).toBe(`前文
- [x] 新任务 [priority: 2] [deadline: "2026-08-20"] [completed: "2026-08-12"] #新标签 [[新项目]] $keep
- [ ] 子任务
后文
`);
});
test("rewriteTaskAtPosition accepts positions inside an anchor", () => {
const input = "- [ ] 带锚点的任务 $task-one\n";
expect(
rewriteTask(input, input.indexOf("task-one"), { deadline: "2026-08-15" }),
).toBe('- [ ] 带锚点的任务 [deadline: "2026-08-15"] $task-one\n');
});
test("rewriteTaskAtPosition removes managed fields without touching neighbors", () => {
const input = `- [ ] 第一项 [deadline: "2026-08-15"] [[项目]]
- [ ] 第二项
`;
expect(rewriteTask(input, 0, { deadline: null, project: null })).toBe(
"- [ ] 第一项\n- [ ] 第二项\n",
);
});
+250 -44
View File
@@ -23,9 +23,187 @@ import {
getOffsetFromLineColumn,
isMarkdownPath,
parseToRef,
type Ref,
} from "@silverbulletmd/silverbullet/lib/ref";
import type { ClickEvent } from "@silverbulletmd/silverbullet/type/client";
export type TaskPatch = {
name?: string;
state?: string;
scheduled?: string | null;
deadline?: string | null;
completed?: string | null;
project?: string | null;
tags?: string[];
};
export type NewTaskInput = Omit<TaskPatch, "state" | "completed"> & {
name: string;
};
const editableTaskAttributes = ["scheduled", "deadline", "completed"] as const;
function cleanTaskText(value: string): string {
return value.replace(/[\r\n]+/g, " ").trim();
}
function taskAttribute(name: string, value: string): string {
return `[${name}: ${JSON.stringify(cleanTaskText(value))}]`;
}
function cleanProjectName(value: string): string {
return cleanTaskText(value)
.replace(/^\[\[|\]\]$/g, "")
.trim();
}
function cleanTag(value: string): string {
return cleanTaskText(value).replace(/^#+/, "").replace(/\s+/g, "-");
}
function localDateKey(date = new Date()): string {
return [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, "0"),
String(date.getDate()).padStart(2, "0"),
].join("-");
}
export function buildTaskMarkdown(input: NewTaskInput): string {
const name = cleanTaskText(input.name);
if (!name) throw new Error("待办标题不能为空");
const parts = [`- [ ] ${name}`];
if (input.scheduled) parts.push(taskAttribute("scheduled", input.scheduled));
if (input.deadline) parts.push(taskAttribute("deadline", input.deadline));
for (const tag of input.tags ?? []) {
const cleaned = cleanTag(tag);
if (cleaned) parts.push(`#${cleaned}`);
}
if (input.project) {
const project = cleanProjectName(input.project);
if (project) parts.push(`[[${project}]]`);
}
return parts.join(" ");
}
function taskNodeAtPosition(tree: ParseTree, position: number): ParseTree {
addParentPointers(tree);
const node = nodeAtPos(tree, Math.max(0, position));
let taskNode =
node?.type === "Task"
? node
: node &&
findParentMatching(node, (candidate) => candidate.type === "Task");
if (!taskNode && node) {
const listItem =
node.type === "ListItem"
? node
: findParentMatching(
node,
(candidate) => candidate.type === "ListItem",
);
taskNode = listItem && findNodeOfType(listItem, "Task");
}
if (!taskNode) {
throw new Error("任务引用已失效,请刷新后重试");
}
return taskNode;
}
function attributeName(node: ParseTree): string | undefined {
return findNodeOfType(node, "AttributeName")?.children?.[0]?.text;
}
/** Rewrites one parsed task node while leaving its list marker and children intact. */
export function taskChangeAtPosition(
tree: ParseTree,
position: number,
patch: TaskPatch,
): { from: number; to: number; insert: string } {
const taskNode = taskNodeAtPosition(tree, position);
const stateNode = findNodeOfType(taskNode, "TaskState");
if (!stateNode) throw new Error("引用位置不是待办事项");
const structured = new Set([
"Attribute",
"Hashtag",
"WikiLink",
"NamedAnchor",
]);
const attributes = new Map<string, string>();
const tags: string[] = [];
const links: string[] = [];
const anchors: string[] = [];
const titleParts: string[] = [];
for (const child of taskNode.children?.slice(1) ?? []) {
const rendered = renderToText(child);
if (!child.type) {
titleParts.push(rendered);
} else if (child.type === "Attribute") {
const name = attributeName(child);
if (name) attributes.set(name, rendered);
} else if (child.type === "Hashtag") {
tags.push(rendered);
} else if (child.type === "WikiLink") {
links.push(rendered);
} else if (child.type === "NamedAnchor") {
anchors.push(rendered);
} else if (!structured.has(child.type)) {
titleParts.push(rendered);
}
}
for (const key of editableTaskAttributes) {
const value = patch[key];
if (value === undefined) continue;
if (value === null || value === "") attributes.delete(key);
else attributes.set(key, taskAttribute(key, value));
}
let nextLinks = links;
if (patch.project !== undefined) {
const project = patch.project ? cleanProjectName(patch.project) : "";
nextLinks = [...links];
if (project) {
if (nextLinks.length) nextLinks[0] = `[[${project}]]`;
else nextLinks.push(`[[${project}]]`);
} else {
nextLinks.shift();
}
}
const nextTags =
patch.tags === undefined
? tags
: patch.tags
.map(cleanTag)
.filter(Boolean)
.map((tag) => `#${tag}`);
const title = cleanTaskText(patch.name ?? titleParts.join(""));
if (!title) throw new Error("待办标题不能为空");
const suffix = [
...attributes.values(),
...nextTags,
...nextLinks,
...anchors,
];
const state = patch.state ?? stateNode.children?.[1]?.text ?? " ";
const replacement = `[${state}] ${title}${suffix.length ? ` ${suffix.join(" ")}` : ""}`;
return { from: taskNode.from!, to: taskNode.to!, insert: replacement };
}
export function rewriteTaskAtPosition(
text: string,
tree: ParseTree,
position: number,
patch: TaskPatch,
): string {
const change = taskChangeAtPosition(tree, position, patch);
return `${text.slice(0, change.from)}${change.insert}${text.slice(change.to)}`;
}
export const completeStates = ["x", "X"];
export const incompleteStates = [" "];
@@ -151,15 +329,27 @@ async function cycleTaskState(
export async function updateTaskState(
path: string,
oldState: string,
_oldState: string,
newState: string,
) {
const currentPath = await editor.getCurrentPath();
await updateTaskByRef(path, {
state: newState,
completed: completeStates.includes(newState) ? localDateKey() : null,
});
}
type ResolvedTaskRef = Ref & {
details:
| { type: "position"; pos: number }
| { type: "linecolumn"; line: number; column: number };
};
async function resolveTaskReference(path: string): Promise<ResolvedTaskRef> {
const ref = parseToRef(path);
if (!ref) {
console.log("Could not parse task ref, skipping", path);
return;
throw new Error("无法解析待办引用");
}
// Anchor refs need resolving to a concrete page+position before we can
@@ -173,7 +363,7 @@ export async function updateTaskState(
const result = await index.resolveAnchor(ref.details.name, pageFilter);
if (!result.ok) {
console.log("Could not resolve task anchor, skipping", path, result);
return;
throw new Error("无法定位待办锚点");
}
ref.path = `${result.page}.md`;
ref.details = { type: "position", pos: result.range[0] };
@@ -185,13 +375,27 @@ export async function updateTaskState(
(ref.details.type !== "linecolumn" && ref.details.type !== "position")
) {
console.log("No position found in page ref, skipping", ref);
return;
throw new Error("待办引用缺少有效位置");
}
if (ref.path === currentPath) {
// In current page, just update the task marker with dispatch
const editorText = await editor.getText();
return ref as ResolvedTaskRef;
}
async function syncTaskPage(pageName: string) {
try {
await sync.performFileSync(`${pageName}.md`);
} catch (error: any) {
console.warn("File sync after task update failed:", error.message);
}
}
export async function updateTaskByRef(path: string, patch: TaskPatch) {
const currentPath = await editor.getCurrentPath();
const ref = await resolveTaskReference(path);
const pageName = getNameFromPath(ref.path);
if (ref.path === currentPath) {
const editorText = await editor.getText();
const targetPos =
ref.details.type === "position"
? ref.details.pos
@@ -201,52 +405,54 @@ export async function updateTaskState(
ref.details.column,
);
// Check if the task state marker is still there
const targetText = editorText.substring(
targetPos + 3, // 3 because: "* ["
targetPos + 3 + oldState.length,
);
if (targetText !== oldState) {
console.error("Reference not a task marker, out of date?", targetText);
return;
}
const tree = await markdown.parseMarkdown(editorText);
await editor.dispatch({
changes: {
from: targetPos + 3,
to: targetPos + 3 + oldState.length,
insert: newState,
},
changes: taskChangeAtPosition(tree, targetPos, patch),
});
} else {
const pageName = getNameFromPath(ref.path);
let text = await space.readPage(pageName);
const referenceMdTree = await markdown.parseMarkdown(text);
const text = await space.readPage(pageName);
const targetPos =
ref.details.type === "position"
? ref.details.pos
: getOffsetFromLineColumn(text, ref.details.line, ref.details.column);
const itemNode = nodeAtPos(referenceMdTree, targetPos + 1);
if (!itemNode) {
console.error("Reference not a valid item, out of date?", itemNode);
return;
}
const taskStateNode = findNodeOfType(itemNode, "TaskState");
if (!taskStateNode) {
console.error("Cannot find a task state", taskStateNode);
return;
}
taskStateNode.children![1].text = newState;
text = renderToText(referenceMdTree);
await space.writePage(pageName, text);
// Best-effort sync; will catch up on next cycle if SW is unavailable
sync.performFileSync(`${pageName}.md`).catch((e) => {
console.warn("File sync after task update failed:", e.message);
});
const tree = await markdown.parseMarkdown(text);
await space.writePage(
pageName,
rewriteTaskAtPosition(text, tree, targetPos, patch),
);
await syncTaskPage(pageName);
}
}
export async function appendTask(
page = "任务/收件箱",
input: NewTaskInput,
): Promise<string> {
const pageName = page.replace(/\.md$/i, "") || "任务/收件箱";
const currentPath = await editor.getCurrentPath();
const taskLine = buildTaskMarkdown(input);
if (currentPath === `${pageName}.md`) {
const text = await editor.getText();
const prefix = text.length && !text.endsWith("\n") ? "\n" : "";
const from = text.length;
await editor.dispatch({
changes: { from, to: from, insert: `${prefix}${taskLine}\n` },
});
return `${pageName}@${from + prefix.length}`;
}
let text = "";
if (await space.pageExists(pageName)) {
text = await space.readPage(pageName);
}
const prefix = text.length && !text.endsWith("\n") ? "\n" : "";
const position = text.length + prefix.length;
await space.writePage(pageName, `${text}${prefix}${taskLine}\n`);
await syncTaskPage(pageName);
return `${pageName}@${position}`;
}
// Prevent concurrent task cycling
let taskCycleLock = false;