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
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:
@@ -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"]);
|
||||
});
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = `
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user