414 lines
12 KiB
TypeScript
414 lines
12 KiB
TypeScript
import {
|
|
config,
|
|
editor,
|
|
events,
|
|
markdown,
|
|
space,
|
|
sync,
|
|
} from "@silverbulletmd/silverbullet/syscalls";
|
|
|
|
import {
|
|
addParentPointers,
|
|
collectNodesMatching,
|
|
findNodeMatching,
|
|
findNodeOfType,
|
|
findParentMatching,
|
|
nodeAtPos,
|
|
type ParseTree,
|
|
renderToText,
|
|
} from "@silverbulletmd/silverbullet/lib/tree";
|
|
import {
|
|
getNameFromPath,
|
|
getOffsetFromLineColumn,
|
|
isMarkdownPath,
|
|
parseToRef,
|
|
} from "@silverbulletmd/silverbullet/lib/ref";
|
|
import type { ClickEvent } from "@silverbulletmd/silverbullet/type/client";
|
|
|
|
export const completeStates = ["x", "X"];
|
|
|
|
export const incompleteStates = [" "];
|
|
|
|
export function taskToggle(event: ClickEvent) {
|
|
if (event.altKey) {
|
|
return;
|
|
}
|
|
return taskCycleAtPos(event.pos);
|
|
}
|
|
|
|
function sortedTaskStateNames(allStates: Record<string, any>): string[] {
|
|
return Object.entries(allStates)
|
|
.sort(([, a]: any, [, b]: any) => (a.order ?? 0) - (b.order ?? 0))
|
|
.map(([name]) => name);
|
|
}
|
|
|
|
async function convertListItemToTask(node: ParseTree) {
|
|
const listMark = node.children![0];
|
|
const originalMark = renderToText(listMark);
|
|
|
|
// Determine the task marker based on the original list type
|
|
let taskMarker: string;
|
|
if (originalMark.match(/^\d+\./)) {
|
|
// Numbered list: preserve the number
|
|
taskMarker = `${originalMark} [ ]`;
|
|
} else {
|
|
// Bullet list: use standard bullet
|
|
taskMarker = "* [ ]";
|
|
}
|
|
|
|
await editor.dispatch({
|
|
changes: {
|
|
from: listMark.from,
|
|
to: listMark.to,
|
|
insert: taskMarker,
|
|
},
|
|
});
|
|
}
|
|
|
|
async function removeTaskCheckbox(listItemNode: ParseTree) {
|
|
const taskNode = findNodeOfType(listItemNode, "Task");
|
|
if (!taskNode) {
|
|
console.error("No task node found in list item");
|
|
return;
|
|
}
|
|
|
|
// Task node contains: TaskMark, TaskState, and text content. Keep just list marker and content after the checkbox
|
|
const listMark = listItemNode.children![0];
|
|
const contentAfterCheckbox = taskNode.children!.slice(1); // Skip TaskMark which contains [ ]
|
|
|
|
const textContent = contentAfterCheckbox.map(renderToText).join("");
|
|
|
|
// Replace entire list item content
|
|
await editor.dispatch({
|
|
changes: {
|
|
from: listItemNode.from!,
|
|
to: listItemNode.to!,
|
|
insert: renderToText(listMark) + textContent,
|
|
},
|
|
});
|
|
}
|
|
|
|
async function cycleTaskState(
|
|
node: ParseTree,
|
|
removeCheckbox: boolean = false,
|
|
) {
|
|
const stateText = node.children![1].text!;
|
|
|
|
// If removeCheckbox is true and task is complete, remove checkbox entirely
|
|
if (removeCheckbox && completeStates.includes(stateText)) {
|
|
// Convert back to regular list item
|
|
const taskNode = node.parent!;
|
|
const listItemNode = taskNode.parent!;
|
|
await removeTaskCheckbox(listItemNode);
|
|
return;
|
|
}
|
|
|
|
let changeTo: string | undefined;
|
|
if (completeStates.includes(stateText)) {
|
|
changeTo = " ";
|
|
} else if (incompleteStates.includes(stateText)) {
|
|
changeTo = "x";
|
|
} else {
|
|
// Not a checkbox, but a custom state
|
|
const allStates = await config.get("taskStates", {});
|
|
const states = sortedTaskStateNames(allStates);
|
|
console.log("All states", states);
|
|
let currentStateIndex = states.indexOf(stateText);
|
|
if (currentStateIndex === -1) {
|
|
console.error("Unknown state", stateText);
|
|
currentStateIndex = 0;
|
|
}
|
|
const nextStateIndex = (currentStateIndex + 1) % states.length;
|
|
changeTo = states[nextStateIndex];
|
|
}
|
|
await editor.dispatch({
|
|
changes: {
|
|
from: node.children![1].from,
|
|
to: node.children![1].to,
|
|
insert: changeTo,
|
|
},
|
|
});
|
|
|
|
const parentWikiLinks = collectNodesMatching(
|
|
node.parent!,
|
|
(n) => n.type === "WikiLinkPage",
|
|
);
|
|
for (const wikiLink of parentWikiLinks) {
|
|
const ref = wikiLink.children![0].text!;
|
|
if (ref.includes("@")) {
|
|
await updateTaskState(ref, stateText, changeTo);
|
|
}
|
|
}
|
|
|
|
await events.dispatchEvent("task:stateChange", {
|
|
from: node.parent!.from,
|
|
to: node.parent!.to,
|
|
newState: changeTo,
|
|
text: renderToText(node.parent),
|
|
});
|
|
}
|
|
|
|
export async function updateTaskState(
|
|
path: string,
|
|
oldState: string,
|
|
newState: string,
|
|
) {
|
|
const currentPath = await editor.getCurrentPath();
|
|
const ref = parseToRef(path);
|
|
|
|
if (
|
|
!ref ||
|
|
!ref.details ||
|
|
!isMarkdownPath(ref.path) ||
|
|
(ref.details.type !== "linecolumn" && ref.details.type !== "position")
|
|
) {
|
|
console.log("No position found in page ref, skipping", ref);
|
|
return;
|
|
}
|
|
|
|
if (ref.path === currentPath) {
|
|
// In current page, just update the task marker with dispatch
|
|
const editorText = await editor.getText();
|
|
|
|
const targetPos =
|
|
ref.details.type === "position"
|
|
? ref.details.pos
|
|
: getOffsetFromLineColumn(
|
|
editorText,
|
|
ref.details.line,
|
|
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;
|
|
}
|
|
await editor.dispatch({
|
|
changes: {
|
|
from: targetPos + 3,
|
|
to: targetPos + 3 + oldState.length,
|
|
insert: newState,
|
|
},
|
|
});
|
|
} else {
|
|
const pageName = getNameFromPath(ref.path);
|
|
let text = await space.readPage(pageName);
|
|
|
|
const referenceMdTree = await markdown.parseMarkdown(text);
|
|
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);
|
|
});
|
|
}
|
|
}
|
|
|
|
// Prevent concurrent task cycling
|
|
let taskCycleLock = false;
|
|
|
|
export async function taskCycleAtPos(pos: number) {
|
|
if (taskCycleLock) return;
|
|
taskCycleLock = true;
|
|
try {
|
|
const text = await editor.getText();
|
|
const mdTree = await markdown.parseMarkdown(text);
|
|
addParentPointers(mdTree);
|
|
|
|
let node = nodeAtPos(mdTree, pos);
|
|
if (node) {
|
|
if (node.type === "TaskMark") {
|
|
node = node.parent!;
|
|
}
|
|
if (node.type === "TaskState") {
|
|
await cycleTaskState(node, false);
|
|
}
|
|
}
|
|
} finally {
|
|
taskCycleLock = false;
|
|
}
|
|
}
|
|
|
|
export async function taskCycleCommand() {
|
|
if (taskCycleLock) return;
|
|
taskCycleLock = true;
|
|
try {
|
|
const text = await editor.getText();
|
|
const pos = await editor.getCursor();
|
|
const tree = await markdown.parseMarkdown(text);
|
|
addParentPointers(tree);
|
|
|
|
let node = nodeAtPos(tree, pos);
|
|
if (!node) {
|
|
await editor.flashNotification("No task at cursor");
|
|
return;
|
|
}
|
|
if (["BulletList", "Document"].includes(node.type!)) {
|
|
node = nodeAtPos(tree, pos - 1);
|
|
}
|
|
if (!node) {
|
|
await editor.flashNotification("No task at cursor");
|
|
return;
|
|
}
|
|
const taskNode =
|
|
node.type === "Task"
|
|
? node
|
|
: findParentMatching(node!, (n) => n.type === "Task");
|
|
|
|
if (taskNode) {
|
|
const taskState = findNodeOfType(taskNode!, "TaskState");
|
|
if (taskState) {
|
|
await cycleTaskState(taskState, true);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const listItem = findParentMatching(node!, (n) => n.type === "ListItem");
|
|
if (!listItem) {
|
|
await editor.flashNotification("No task at cursor");
|
|
return;
|
|
}
|
|
|
|
const existingTask = findNodeOfType(listItem, "Task");
|
|
if (existingTask) {
|
|
const taskState = findNodeOfType(existingTask, "TaskState");
|
|
if (taskState) {
|
|
await cycleTaskState(taskState, true);
|
|
}
|
|
return;
|
|
}
|
|
|
|
await convertListItemToTask(listItem);
|
|
} finally {
|
|
taskCycleLock = false;
|
|
}
|
|
}
|
|
|
|
// Core logic extracted for testability. Mutates tree in place.
|
|
export function removeCompletedTasksFromTree(
|
|
tree: ParseTree,
|
|
allCompletedStates: string[],
|
|
) {
|
|
// Taking this ugly approach because the tree is modified in place
|
|
// Just finding and removing one task at a time and then repeating until nothing changes
|
|
while (true) {
|
|
const completedTaskNode = findNodeMatching(tree, (node) => {
|
|
return (
|
|
node.type === "Task" &&
|
|
allCompletedStates.includes(node.children![0].children![1].text!)
|
|
);
|
|
});
|
|
if (completedTaskNode) {
|
|
// Ok got one, let's remove it
|
|
const listItemNode = completedTaskNode.parent!;
|
|
const bulletListNode = listItemNode.parent!;
|
|
// Remove the list item
|
|
const listItemIdx = bulletListNode.children!.indexOf(listItemNode);
|
|
// Also remove the adjacent whitespace/newline separator text node.
|
|
// Prefer the following separator; if none, remove the preceding one.
|
|
const nextChild = bulletListNode.children![listItemIdx + 1];
|
|
const prevChild =
|
|
listItemIdx > 0 ? bulletListNode.children![listItemIdx - 1] : undefined;
|
|
if (nextChild && !nextChild.type && nextChild.text?.startsWith("\n")) {
|
|
// Remove item and following separator
|
|
bulletListNode.children!.splice(listItemIdx, 2);
|
|
} else if (
|
|
prevChild &&
|
|
!prevChild.type &&
|
|
prevChild.text?.startsWith("\n")
|
|
) {
|
|
// Remove preceding separator and item
|
|
bulletListNode.children!.splice(listItemIdx - 1, 2);
|
|
} else {
|
|
// No separator to remove, just remove the item
|
|
bulletListNode.children!.splice(listItemIdx, 1);
|
|
}
|
|
// If the BulletList now has no ListItem children, remove it and any
|
|
// adjacent whitespace/newline text node from its parent. This prevents
|
|
// blank lines left behind when all items in a nested list are completed.
|
|
if (
|
|
bulletListNode.parent &&
|
|
!bulletListNode.children!.some((c) => c.type === "ListItem")
|
|
) {
|
|
const parentChildren = bulletListNode.parent.children!;
|
|
const blIdx = parentChildren.indexOf(bulletListNode);
|
|
const blNext = parentChildren[blIdx + 1];
|
|
const blPrev = blIdx > 0 ? parentChildren[blIdx - 1] : undefined;
|
|
if (blNext && !blNext.type && blNext.text?.startsWith("\n")) {
|
|
// Remove BulletList and following separator
|
|
parentChildren.splice(blIdx, 2);
|
|
} else if (blPrev && !blPrev.type && blPrev.text?.startsWith("\n")) {
|
|
// Remove preceding separator and BulletList
|
|
parentChildren.splice(blIdx - 1, 2);
|
|
} else {
|
|
parentChildren.splice(blIdx, 1);
|
|
}
|
|
}
|
|
} else {
|
|
// No completed tasks left, we're done
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function removeCompletedTasksCommand() {
|
|
const tree = await markdown.parseMarkdown(await editor.getText());
|
|
addParentPointers(tree);
|
|
|
|
const allCompletedStates = completeStates.concat(
|
|
Object.values(await config.get("taskStates", {}))
|
|
.filter((ts: any) => ts.done)
|
|
.map((ts: any) => ts.name),
|
|
);
|
|
|
|
removeCompletedTasksFromTree(tree, allCompletedStates);
|
|
|
|
await editor.setText(renderToText(tree));
|
|
}
|
|
|
|
export async function cycleTaskStateByRef(
|
|
path: string,
|
|
oldState: string,
|
|
): Promise<string> {
|
|
let newState: string;
|
|
if (completeStates.includes(oldState)) {
|
|
newState = " ";
|
|
} else if (incompleteStates.includes(oldState)) {
|
|
newState = "x";
|
|
} else {
|
|
const allStates = await config.get("taskStates", {});
|
|
const states = sortedTaskStateNames(allStates);
|
|
let idx = states.indexOf(oldState);
|
|
if (idx === -1) {
|
|
idx = 0;
|
|
}
|
|
newState = states[(idx + 1) % states.length];
|
|
}
|
|
await updateTaskState(path, oldState, newState);
|
|
await events.dispatchEvent("task:stateChange", {
|
|
newState,
|
|
});
|
|
return newState;
|
|
}
|