Custom task statuses plus various fixes
This commit is contained in:
parent
9b5fa44fb6
commit
2ee20af5c8
@ -42,8 +42,6 @@ export async function serveCommand(
|
|||||||
|
|
||||||
const app = new Application();
|
const app = new Application();
|
||||||
|
|
||||||
console.log(options);
|
|
||||||
|
|
||||||
if (!folder) {
|
if (!folder) {
|
||||||
folder = Deno.env.get("SB_FOLDER");
|
folder = Deno.env.get("SB_FOLDER");
|
||||||
if (!folder) {
|
if (!folder) {
|
||||||
|
@ -5,8 +5,6 @@ export const CommandLinkNameTag = Tag.define();
|
|||||||
export const WikiLinkTag = Tag.define();
|
export const WikiLinkTag = Tag.define();
|
||||||
export const WikiLinkPageTag = Tag.define();
|
export const WikiLinkPageTag = Tag.define();
|
||||||
export const CodeInfoTag = Tag.define();
|
export const CodeInfoTag = Tag.define();
|
||||||
export const TaskTag = Tag.define();
|
|
||||||
export const TaskMarkerTag = Tag.define();
|
|
||||||
export const CommentTag = Tag.define();
|
export const CommentTag = Tag.define();
|
||||||
export const CommentMarkerTag = Tag.define();
|
export const CommentMarkerTag = Tag.define();
|
||||||
export const BulletList = Tag.define();
|
export const BulletList = Tag.define();
|
||||||
@ -22,3 +20,7 @@ export const DirectiveProgramTag = Tag.define();
|
|||||||
export const AttributeTag = Tag.define();
|
export const AttributeTag = Tag.define();
|
||||||
export const AttributeNameTag = Tag.define();
|
export const AttributeNameTag = Tag.define();
|
||||||
export const AttributeValueTag = Tag.define();
|
export const AttributeValueTag = Tag.define();
|
||||||
|
|
||||||
|
export const TaskTag = Tag.define();
|
||||||
|
export const TaskMarkerTag = Tag.define();
|
||||||
|
export const TaskStateTag = Tag.define();
|
||||||
|
60
common/markdown_parser/extended_task.ts
Normal file
60
common/markdown_parser/extended_task.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import {
|
||||||
|
BlockContext,
|
||||||
|
LeafBlock,
|
||||||
|
LeafBlockParser,
|
||||||
|
MarkdownConfig,
|
||||||
|
} from "../deps.ts";
|
||||||
|
|
||||||
|
import { tags as t } from "@lezer/highlight";
|
||||||
|
import { TaskStateTag } from "./customtags.ts";
|
||||||
|
|
||||||
|
// Taken from https://github.com/lezer-parser/markdown/blob/main/src/extension.ts and adapted
|
||||||
|
|
||||||
|
class MultiStatusTaskParser implements LeafBlockParser {
|
||||||
|
constructor(private status: string) {
|
||||||
|
}
|
||||||
|
|
||||||
|
nextLine() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
finish(cx: BlockContext, leaf: LeafBlock) {
|
||||||
|
cx.addLeafElement(
|
||||||
|
leaf,
|
||||||
|
cx.elt("Task", leaf.start, leaf.start + leaf.content.length, [
|
||||||
|
cx.elt("TaskState", leaf.start, leaf.start + 2 + this.status.length, [
|
||||||
|
cx.elt("TaskMarker", leaf.start, leaf.start + 1),
|
||||||
|
cx.elt(
|
||||||
|
"TaskMarker",
|
||||||
|
leaf.start + 1 + this.status.length,
|
||||||
|
leaf.start + 2 + this.status.length,
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
...cx.parser.parseInline(
|
||||||
|
leaf.content.slice(this.status.length + 2),
|
||||||
|
leaf.start + this.status.length + 2,
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TaskList: MarkdownConfig = {
|
||||||
|
defineNodes: [
|
||||||
|
{ name: "Task", block: true, style: t.list },
|
||||||
|
{ name: "TaskMarker", style: t.atom },
|
||||||
|
{ name: "TaskState", style: TaskStateTag },
|
||||||
|
],
|
||||||
|
parseBlock: [{
|
||||||
|
name: "TaskList",
|
||||||
|
leaf(cx, leaf) {
|
||||||
|
const match = /^\[([^\]]+)\][ \t]/.exec(leaf.content);
|
||||||
|
return match &&
|
||||||
|
cx.parentType().name == "ListItem"
|
||||||
|
? new MultiStatusTaskParser(match[1])
|
||||||
|
: null;
|
||||||
|
},
|
||||||
|
after: "SetextHeading",
|
||||||
|
}],
|
||||||
|
};
|
@ -103,7 +103,7 @@ And one with nested brackets: [array: [1, 2, 3]]
|
|||||||
Deno.test("Test inline attribute syntax", () => {
|
Deno.test("Test inline attribute syntax", () => {
|
||||||
const lang = buildMarkdown([]);
|
const lang = buildMarkdown([]);
|
||||||
const tree = parse(lang, inlineAttributeSample);
|
const tree = parse(lang, inlineAttributeSample);
|
||||||
console.log("Attribute parsed", JSON.stringify(tree, null, 2));
|
// console.log("Attribute parsed", JSON.stringify(tree, null, 2));
|
||||||
const attributes = collectNodesOfType(tree, "Attribute");
|
const attributes = collectNodesOfType(tree, "Attribute");
|
||||||
let nameNode = findNodeOfType(attributes[0], "AttributeName");
|
let nameNode = findNodeOfType(attributes[0], "AttributeName");
|
||||||
assertEquals(nameNode?.children![0].text, "age");
|
assertEquals(nameNode?.children![0].text, "age");
|
||||||
@ -120,3 +120,21 @@ Deno.test("Test inline attribute syntax", () => {
|
|||||||
valueNode = findNodeOfType(attributes[2], "AttributeValue");
|
valueNode = findNodeOfType(attributes[2], "AttributeValue");
|
||||||
assertEquals(valueNode?.children![0].text, "[1, 2, 3]");
|
assertEquals(valueNode?.children![0].text, "[1, 2, 3]");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const multiStatusTaskExample = `
|
||||||
|
* [ ] Task 1
|
||||||
|
- [x] Task 2
|
||||||
|
* [TODO] Task 3
|
||||||
|
`;
|
||||||
|
|
||||||
|
Deno.test("Test multi-status tasks", () => {
|
||||||
|
const lang = buildMarkdown([]);
|
||||||
|
const tree = parse(lang, multiStatusTaskExample);
|
||||||
|
// console.log("Tasks parsed", JSON.stringify(tree, null, 2));
|
||||||
|
const tasks = collectNodesOfType(tree, "Task");
|
||||||
|
assertEquals(tasks.length, 3);
|
||||||
|
// Check " " checkbox state parsing
|
||||||
|
assertEquals(tasks[0].children![0].children![1].text, " ");
|
||||||
|
assertEquals(tasks[1].children![0].children![1].text, "x");
|
||||||
|
assertEquals(tasks[2].children![0].children![1].text, "TODO");
|
||||||
|
});
|
||||||
|
@ -10,10 +10,10 @@ import {
|
|||||||
Strikethrough,
|
Strikethrough,
|
||||||
styleTags,
|
styleTags,
|
||||||
tags as t,
|
tags as t,
|
||||||
TaskList,
|
|
||||||
yamlLanguage,
|
yamlLanguage,
|
||||||
} from "../deps.ts";
|
} from "../deps.ts";
|
||||||
import * as ct from "./customtags.ts";
|
import * as ct from "./customtags.ts";
|
||||||
|
import { TaskList } from "./extended_task.ts";
|
||||||
import {
|
import {
|
||||||
MDExt,
|
MDExt,
|
||||||
mdExtensionStyleTags,
|
mdExtensionStyleTags,
|
||||||
|
@ -115,9 +115,6 @@ export class EventedSpacePrimitives implements SpacePrimitives {
|
|||||||
console.error("Error dispatching page:saved event", e);
|
console.error("Error dispatching page:saved event", e);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// if (name.startsWith("_plug/") && name.endsWith(".plug.js")) {
|
|
||||||
// await this.dispatchEvent("plug:changed", name);
|
|
||||||
// }
|
|
||||||
return newMeta;
|
return newMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
47
plugs/editor/complete.ts
Normal file
47
plugs/editor/complete.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { CompleteEvent } from "$sb/app_event.ts";
|
||||||
|
import { space } from "$sb/syscalls.ts";
|
||||||
|
import { PageMeta } from "../../web/types.ts";
|
||||||
|
import { cacheFileListing } from "../federation/federation.ts";
|
||||||
|
|
||||||
|
// Completion
|
||||||
|
export async function pageComplete(completeEvent: CompleteEvent) {
|
||||||
|
const match = /\[\[([^\]@:\{}]*)$/.exec(completeEvent.linePrefix);
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let allPages: PageMeta[] = await space.listPages();
|
||||||
|
const prefix = match[1];
|
||||||
|
if (prefix.startsWith("!")) {
|
||||||
|
// Federation prefix, let's first see if we're matching anything from federation that is locally synced
|
||||||
|
const prefixMatches = allPages.filter((pageMeta) =>
|
||||||
|
pageMeta.name.startsWith(prefix)
|
||||||
|
);
|
||||||
|
if (prefixMatches.length === 0) {
|
||||||
|
// Ok, nothing synced in via federation, let's see if this URI is complete enough to try to fetch index.json
|
||||||
|
if (prefix.includes("/")) {
|
||||||
|
// Yep
|
||||||
|
const domain = prefix.split("/")[0];
|
||||||
|
// Cached listing
|
||||||
|
const federationPages = (await cacheFileListing(domain)).filter((fm) =>
|
||||||
|
fm.name.endsWith(".md")
|
||||||
|
).map((fm) => ({
|
||||||
|
...fm,
|
||||||
|
name: fm.name.slice(0, -3),
|
||||||
|
}));
|
||||||
|
if (federationPages.length > 0) {
|
||||||
|
allPages = allPages.concat(federationPages);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
from: completeEvent.pos - match[1].length,
|
||||||
|
options: allPages.map((pageMeta) => {
|
||||||
|
return {
|
||||||
|
label: pageMeta.name,
|
||||||
|
boost: pageMeta.lastModified,
|
||||||
|
type: "page",
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
@ -32,7 +32,7 @@ functions:
|
|||||||
|
|
||||||
# Completion
|
# Completion
|
||||||
pageComplete:
|
pageComplete:
|
||||||
path: "./page.ts:pageComplete"
|
path: "./complete.ts:pageComplete"
|
||||||
events:
|
events:
|
||||||
- editor:complete
|
- editor:complete
|
||||||
commandComplete:
|
commandComplete:
|
||||||
|
@ -60,43 +60,3 @@ export async function newPageCommand() {
|
|||||||
}
|
}
|
||||||
await editor.navigate(pageName);
|
await editor.navigate(pageName);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Completion
|
|
||||||
export async function pageComplete(completeEvent: CompleteEvent) {
|
|
||||||
const match = /\[\[([^\]@:\{}]*)$/.exec(completeEvent.linePrefix);
|
|
||||||
if (!match) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
let allPages: PageMeta[] = await space.listPages();
|
|
||||||
const prefix = match[1];
|
|
||||||
if (prefix.startsWith("!")) {
|
|
||||||
// Federation prefix, let's first see if we're matching anything from federation that is locally synced
|
|
||||||
const prefixMatches = allPages.filter((pageMeta) =>
|
|
||||||
pageMeta.name.startsWith(prefix)
|
|
||||||
);
|
|
||||||
if (prefixMatches.length === 0) {
|
|
||||||
// Ok, nothing synced in via federation, let's see if this URI is complete enough to try to fetch index.json
|
|
||||||
if (prefix.includes("/")) {
|
|
||||||
// Yep
|
|
||||||
const domain = prefix.split("/")[0];
|
|
||||||
// Cached listing
|
|
||||||
allPages = (await cacheFileListing(domain)).filter((fm) =>
|
|
||||||
fm.name.endsWith(".md")
|
|
||||||
).map((fm) => ({
|
|
||||||
...fm,
|
|
||||||
name: fm.name.slice(0, -3),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
from: completeEvent.pos - match[1].length,
|
|
||||||
options: allPages.map((pageMeta) => {
|
|
||||||
return {
|
|
||||||
label: pageMeta.name,
|
|
||||||
boost: pageMeta.lastModified,
|
|
||||||
type: "page",
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
@ -33,6 +33,7 @@ const builtinAttributes: Record<string, Record<string, string>> = {
|
|||||||
name: "string",
|
name: "string",
|
||||||
done: "boolean",
|
done: "boolean",
|
||||||
page: "string",
|
page: "string",
|
||||||
|
state: "string",
|
||||||
deadline: "string",
|
deadline: "string",
|
||||||
pos: "number",
|
pos: "number",
|
||||||
tags: "array",
|
tags: "array",
|
||||||
@ -126,6 +127,10 @@ export function builtinAttributeCompleter(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function attributeComplete(completeEvent: CompleteEvent) {
|
export async function attributeComplete(completeEvent: CompleteEvent) {
|
||||||
|
if (/([\-\*]\s+\[)([^\]]+)$/.test(completeEvent.linePrefix)) {
|
||||||
|
// Don't match task states, which look similar
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const inlineAttributeMatch = /([^\[\{}]|^)\[(\w+)$/.exec(
|
const inlineAttributeMatch = /([^\[\{}]|^)\[(\w+)$/.exec(
|
||||||
completeEvent.linePrefix,
|
completeEvent.linePrefix,
|
||||||
);
|
);
|
||||||
|
@ -260,16 +260,30 @@ function render(
|
|||||||
name: "span",
|
name: "span",
|
||||||
body: cleanTags(mapRender(t.children!)),
|
body: cleanTags(mapRender(t.children!)),
|
||||||
};
|
};
|
||||||
case "TaskMarker":
|
|
||||||
return {
|
case "TaskState": {
|
||||||
name: "input",
|
// child[0] = marker, child[1] = state, child[2] = marker
|
||||||
attrs: {
|
const stateText = t.children![1].text!;
|
||||||
type: "checkbox",
|
if ([" ", "x", "X"].includes(stateText)) {
|
||||||
checked: t.children![0].text !== "[ ]" ? "checked" : undefined,
|
return {
|
||||||
"data-onclick": JSON.stringify(["task", t.to]),
|
name: "input",
|
||||||
},
|
attrs: {
|
||||||
body: "",
|
type: "checkbox",
|
||||||
};
|
checked: t.children![0].text !== "[ ]" ? "checked" : undefined,
|
||||||
|
"data-onclick": JSON.stringify(["task", t.to]),
|
||||||
|
},
|
||||||
|
body: "",
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
name: "span",
|
||||||
|
attrs: {
|
||||||
|
class: "task-state",
|
||||||
|
},
|
||||||
|
body: stateText,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
case "NamedAnchor":
|
case "NamedAnchor":
|
||||||
return {
|
return {
|
||||||
name: "a",
|
name: "a",
|
||||||
|
20
plugs/tasks/complete.ts
Normal file
20
plugs/tasks/complete.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { CompleteEvent } from "$sb/app_event.ts";
|
||||||
|
import { index } from "$sb/syscalls.ts";
|
||||||
|
|
||||||
|
export async function completeTaskState(completeEvent: CompleteEvent) {
|
||||||
|
const taskMatch = /([\-\*]\s+\[)([^\[\]]+)$/.exec(
|
||||||
|
completeEvent.linePrefix,
|
||||||
|
);
|
||||||
|
if (!taskMatch) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const allStates = await index.queryPrefix("taskState:");
|
||||||
|
const states = [...new Set(allStates.map((s) => s.key.split(":")[1]))];
|
||||||
|
|
||||||
|
return {
|
||||||
|
from: completeEvent.pos - taskMatch[2].length,
|
||||||
|
options: states.map((state) => ({
|
||||||
|
label: state,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
@ -10,6 +10,7 @@ import {
|
|||||||
addParentPointers,
|
addParentPointers,
|
||||||
collectNodesMatching,
|
collectNodesMatching,
|
||||||
findNodeOfType,
|
findNodeOfType,
|
||||||
|
findParentMatching,
|
||||||
nodeAtPos,
|
nodeAtPos,
|
||||||
ParseTree,
|
ParseTree,
|
||||||
renderToText,
|
renderToText,
|
||||||
@ -25,6 +26,7 @@ import { indexAttributes } from "../index/attributes.ts";
|
|||||||
export type Task = {
|
export type Task = {
|
||||||
name: string;
|
name: string;
|
||||||
done: boolean;
|
done: boolean;
|
||||||
|
state: string;
|
||||||
deadline?: string;
|
deadline?: string;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
nested?: string;
|
nested?: string;
|
||||||
@ -37,8 +39,12 @@ function getDeadline(deadlineNode: ParseTree): string {
|
|||||||
return deadlineNode.children![0].text!.replace(/📅\s*/, "");
|
return deadlineNode.children![0].text!.replace(/📅\s*/, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const completeStates = ["x", "X"];
|
||||||
|
const incompleteStates = [" "];
|
||||||
|
|
||||||
export async function indexTasks({ name, tree }: IndexTreeEvent) {
|
export async function indexTasks({ name, tree }: IndexTreeEvent) {
|
||||||
const tasks: { key: string; value: Task }[] = [];
|
const tasks: { key: string; value: Task }[] = [];
|
||||||
|
const taskStates = new Map<string, number>();
|
||||||
removeQueries(tree);
|
removeQueries(tree);
|
||||||
addParentPointers(tree);
|
addParentPointers(tree);
|
||||||
const allAttributes: Record<string, any> = {};
|
const allAttributes: Record<string, any> = {};
|
||||||
@ -46,10 +52,19 @@ export async function indexTasks({ name, tree }: IndexTreeEvent) {
|
|||||||
if (n.type !== "Task") {
|
if (n.type !== "Task") {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const complete = n.children![0].children![0].text! !== "[ ]";
|
const state = n.children![0].children![1].text!;
|
||||||
|
if (!incompleteStates.includes(state) && !completeStates.includes(state)) {
|
||||||
|
if (!taskStates.has(state)) {
|
||||||
|
taskStates.set(state, 1);
|
||||||
|
} else {
|
||||||
|
taskStates.set(state, taskStates.get(state)! + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const complete = completeStates.includes(state);
|
||||||
const task: Task = {
|
const task: Task = {
|
||||||
name: "",
|
name: "",
|
||||||
done: complete,
|
done: complete,
|
||||||
|
state,
|
||||||
};
|
};
|
||||||
|
|
||||||
rewritePageRefs(n, name);
|
rewritePageRefs(n, name);
|
||||||
@ -95,31 +110,59 @@ export async function indexTasks({ name, tree }: IndexTreeEvent) {
|
|||||||
// console.log("Found", tasks, "task(s)");
|
// console.log("Found", tasks, "task(s)");
|
||||||
await index.batchSet(name, tasks);
|
await index.batchSet(name, tasks);
|
||||||
await indexAttributes(name, allAttributes, "task");
|
await indexAttributes(name, allAttributes, "task");
|
||||||
|
await index.batchSet(
|
||||||
|
name,
|
||||||
|
Array.from(taskStates.entries()).map(([state, count]) => ({
|
||||||
|
key: `taskState:${state}`,
|
||||||
|
value: count,
|
||||||
|
})),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function taskToggle(event: ClickEvent) {
|
export function taskToggle(event: ClickEvent) {
|
||||||
return taskToggleAtPos(event.page, event.pos);
|
if (event.altKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return taskCycleAtPos(event.page, event.pos);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function previewTaskToggle(eventString: string) {
|
export async function previewTaskToggle(eventString: string) {
|
||||||
const [eventName, pos] = JSON.parse(eventString);
|
const [eventName, pos] = JSON.parse(eventString);
|
||||||
if (eventName === "task") {
|
if (eventName === "task") {
|
||||||
return taskToggleAtPos(await editor.getCurrentPage(), +pos);
|
return taskCycleAtPos(await editor.getCurrentPage(), +pos);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleTaskMarker(
|
async function cycleTaskState(
|
||||||
pageName: string,
|
pageName: string,
|
||||||
node: ParseTree,
|
node: ParseTree,
|
||||||
) {
|
) {
|
||||||
let changeTo = "[x]";
|
const stateText = node.children![1].text!;
|
||||||
if (node.children![0].text === "[x]" || node.children![0].text === "[X]") {
|
let changeTo: string | undefined;
|
||||||
changeTo = "[ ]";
|
if (completeStates.includes(stateText)) {
|
||||||
|
changeTo = " ";
|
||||||
|
} else if (incompleteStates.includes(stateText)) {
|
||||||
|
changeTo = "x";
|
||||||
|
} else {
|
||||||
|
// Not a checkbox, but a custom state
|
||||||
|
const allStates = await index.queryPrefix("taskState:");
|
||||||
|
const states = [...new Set(allStates.map((s) => s.key.split(":")[1]))];
|
||||||
|
states.sort();
|
||||||
|
// Select a next state
|
||||||
|
const currentStateIndex = states.indexOf(stateText);
|
||||||
|
if (currentStateIndex === -1) {
|
||||||
|
console.error("Unknown state", stateText);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextStateIndex = (currentStateIndex + 1) % states.length;
|
||||||
|
changeTo = states[nextStateIndex];
|
||||||
|
// console.log("All possible states", states);
|
||||||
|
// return;
|
||||||
}
|
}
|
||||||
await editor.dispatch({
|
await editor.dispatch({
|
||||||
changes: {
|
changes: {
|
||||||
from: node.from,
|
from: node.children![1].from,
|
||||||
to: node.to,
|
to: node.children![1].to,
|
||||||
insert: changeTo,
|
insert: changeTo,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -135,10 +178,23 @@ async function toggleTaskMarker(
|
|||||||
const pos = +posS;
|
const pos = +posS;
|
||||||
if (page === pageName) {
|
if (page === pageName) {
|
||||||
// In current page, just update the task marker with dispatch
|
// In current page, just update the task marker with dispatch
|
||||||
|
const editorText = await editor.getText();
|
||||||
|
// Check if the task state marker is still there
|
||||||
|
const targetText = editorText.substring(
|
||||||
|
pos + 1,
|
||||||
|
pos + 1 + stateText.length,
|
||||||
|
);
|
||||||
|
if (targetText !== stateText) {
|
||||||
|
console.error(
|
||||||
|
"Reference not a task marker, out of date?",
|
||||||
|
targetText,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
await editor.dispatch({
|
await editor.dispatch({
|
||||||
changes: {
|
changes: {
|
||||||
from: pos,
|
from: pos + 1,
|
||||||
to: pos + changeTo.length,
|
to: pos + 1 + stateText.length,
|
||||||
insert: changeTo,
|
insert: changeTo,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -146,17 +202,16 @@ async function toggleTaskMarker(
|
|||||||
let text = await space.readPage(page);
|
let text = await space.readPage(page);
|
||||||
|
|
||||||
const referenceMdTree = await markdown.parseMarkdown(text);
|
const referenceMdTree = await markdown.parseMarkdown(text);
|
||||||
// Adding +1 to immediately hit the task marker
|
// Adding +1 to immediately hit the task state node
|
||||||
const taskMarkerNode = nodeAtPos(referenceMdTree, pos + 1);
|
const taskStateNode = nodeAtPos(referenceMdTree, pos + 1);
|
||||||
|
if (!taskStateNode || taskStateNode.type !== "TaskState") {
|
||||||
if (!taskMarkerNode || taskMarkerNode.type !== "TaskMarker") {
|
|
||||||
console.error(
|
console.error(
|
||||||
"Reference not a task marker, out of date?",
|
"Reference not a task marker, out of date?",
|
||||||
taskMarkerNode,
|
taskStateNode,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
taskMarkerNode.children![0].text = changeTo;
|
taskStateNode.children![1].text = changeTo;
|
||||||
text = renderToText(referenceMdTree);
|
text = renderToText(referenceMdTree);
|
||||||
await space.writePage(page, text);
|
await space.writePage(page, text);
|
||||||
sync.scheduleFileSync(`${page}.md`);
|
sync.scheduleFileSync(`${page}.md`);
|
||||||
@ -165,27 +220,53 @@ async function toggleTaskMarker(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function taskToggleAtPos(pageName: string, pos: number) {
|
export async function taskCycleAtPos(pageName: string, pos: number) {
|
||||||
const text = await editor.getText();
|
const text = await editor.getText();
|
||||||
const mdTree = await markdown.parseMarkdown(text);
|
const mdTree = await markdown.parseMarkdown(text);
|
||||||
addParentPointers(mdTree);
|
addParentPointers(mdTree);
|
||||||
|
|
||||||
const node = nodeAtPos(mdTree, pos);
|
let node = nodeAtPos(mdTree, pos);
|
||||||
if (node && node.type === "TaskMarker") {
|
if (node) {
|
||||||
await toggleTaskMarker(pageName, node);
|
if (node.type === "TaskMarker") {
|
||||||
|
node = node.parent!;
|
||||||
|
}
|
||||||
|
if (node.type === "TaskState") {
|
||||||
|
await cycleTaskState(pageName, node);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function taskToggleCommand() {
|
export async function taskCycleCommand() {
|
||||||
const text = await editor.getText();
|
const text = await editor.getText();
|
||||||
const pos = await editor.getCursor();
|
const pos = await editor.getCursor();
|
||||||
const tree = await markdown.parseMarkdown(text);
|
const tree = await markdown.parseMarkdown(text);
|
||||||
addParentPointers(tree);
|
addParentPointers(tree);
|
||||||
|
|
||||||
const node = nodeAtPos(tree, pos);
|
let node = nodeAtPos(tree, pos);
|
||||||
// We kwow node.type === Task (due to the task context)
|
if (!node) {
|
||||||
const taskMarker = findNodeOfType(node!, "TaskMarker");
|
await editor.flashNotification("No task at cursor");
|
||||||
await toggleTaskMarker(await editor.getCurrentPage(), taskMarker!);
|
return;
|
||||||
|
}
|
||||||
|
if (["BulletList", "Document"].includes(node.type!)) {
|
||||||
|
// Likely at the end of the line, let's back up a position
|
||||||
|
node = nodeAtPos(tree, pos - 1);
|
||||||
|
}
|
||||||
|
if (!node) {
|
||||||
|
await editor.flashNotification("No task at cursor");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log("Node", node);
|
||||||
|
const taskNode = node.type === "Task"
|
||||||
|
? node
|
||||||
|
: findParentMatching(node!, (n) => n.type === "Task");
|
||||||
|
if (!taskNode) {
|
||||||
|
await editor.flashNotification("No task at cursor");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const taskState = findNodeOfType(taskNode!, "TaskState");
|
||||||
|
if (taskState) {
|
||||||
|
await cycleTaskState(await editor.getCurrentPage(), taskState);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function postponeCommand() {
|
export async function postponeCommand() {
|
||||||
@ -210,7 +291,7 @@ export async function postponeCommand() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Parse "naive" due date
|
// Parse "naive" due date
|
||||||
let [yyyy, mm, dd] = date.split("-").map(Number);
|
const [yyyy, mm, dd] = date.split("-").map(Number);
|
||||||
// Create new naive Date object.
|
// Create new naive Date object.
|
||||||
// `monthIndex` parameter is zero-based, so subtract 1 from parsed month.
|
// `monthIndex` parameter is zero-based, so subtract 1 from parsed month.
|
||||||
const d = new Date(yyyy, mm - 1, dd);
|
const d = new Date(yyyy, mm - 1, dd);
|
||||||
@ -236,7 +317,6 @@ export async function postponeCommand() {
|
|||||||
anchor: pos,
|
anchor: pos,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
// await toggleTaskMarker(taskMarker!, pos);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function queryProvider({
|
export async function queryProvider({
|
||||||
|
@ -40,12 +40,10 @@ functions:
|
|||||||
events:
|
events:
|
||||||
- query:task
|
- query:task
|
||||||
taskToggleCommand:
|
taskToggleCommand:
|
||||||
path: ./task.ts:taskToggleCommand
|
path: ./task.ts:taskCycleCommand
|
||||||
command:
|
command:
|
||||||
name: "Task: Toggle"
|
name: "Task: Cycle State"
|
||||||
key: Alt-t
|
key: Alt-t
|
||||||
contexts:
|
|
||||||
- Task
|
|
||||||
taskPostponeCommand:
|
taskPostponeCommand:
|
||||||
path: ./task.ts:postponeCommand
|
path: ./task.ts:postponeCommand
|
||||||
command:
|
command:
|
||||||
@ -56,4 +54,9 @@ functions:
|
|||||||
previewTaskToggle:
|
previewTaskToggle:
|
||||||
path: ./task.ts:previewTaskToggle
|
path: ./task.ts:previewTaskToggle
|
||||||
events:
|
events:
|
||||||
- preview:click
|
- preview:click
|
||||||
|
|
||||||
|
taskComplete:
|
||||||
|
path: ./complete.ts:completeTaskState
|
||||||
|
events:
|
||||||
|
- editor:complete
|
@ -96,6 +96,9 @@ export class Client {
|
|||||||
parent: Element,
|
parent: Element,
|
||||||
public syncMode = false,
|
public syncMode = false,
|
||||||
) {
|
) {
|
||||||
|
if (!syncMode) {
|
||||||
|
this.fullSyncCompleted = true;
|
||||||
|
}
|
||||||
// Generate a semi-unique prefix for the database so not to reuse databases for different space paths
|
// Generate a semi-unique prefix for the database so not to reuse databases for different space paths
|
||||||
this.dbPrefix = "" + simpleHash(window.silverBulletConfig.spaceFolderPath);
|
this.dbPrefix = "" + simpleHash(window.silverBulletConfig.spaceFolderPath);
|
||||||
|
|
||||||
|
@ -37,12 +37,13 @@ export function taskListPlugin(
|
|||||||
syntaxTree(state).iterate({
|
syntaxTree(state).iterate({
|
||||||
enter({ type, from, to, node }) {
|
enter({ type, from, to, node }) {
|
||||||
if (type.name !== "Task") return;
|
if (type.name !== "Task") return;
|
||||||
let checked = false;
|
// true/false if this is a checkbox, undefined when it's a custom-status task
|
||||||
|
let checkboxStatus: boolean | undefined;
|
||||||
// Iterate inside the task node to find the checkbox
|
// Iterate inside the task node to find the checkbox
|
||||||
node.toTree().iterate({
|
node.toTree().iterate({
|
||||||
enter: (ref) => iterateInner(ref.type, ref.from, ref.to),
|
enter: (ref) => iterateInner(ref.type, ref.from, ref.to),
|
||||||
});
|
});
|
||||||
if (checked) {
|
if (checkboxStatus === true) {
|
||||||
widgets.push(
|
widgets.push(
|
||||||
Decoration.mark({
|
Decoration.mark({
|
||||||
tagName: "span",
|
tagName: "span",
|
||||||
@ -52,14 +53,22 @@ export function taskListPlugin(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function iterateInner(type: NodeType, nfrom: number, nto: number) {
|
function iterateInner(type: NodeType, nfrom: number, nto: number) {
|
||||||
if (type.name !== "TaskMarker") return;
|
if (type.name !== "TaskState") return;
|
||||||
if (isCursorInRange(state, [from + nfrom, from + nto])) return;
|
if (isCursorInRange(state, [from + nfrom, from + nto])) return;
|
||||||
const checkbox = state.sliceDoc(from + nfrom, from + nto);
|
const checkbox = state.sliceDoc(from + nfrom, from + nto);
|
||||||
// Checkbox is checked if it has a 'x' in between the []
|
// Checkbox is checked if it has a 'x' in between the []
|
||||||
if ("xX".includes(checkbox[1])) checked = true;
|
if (checkbox === "[x]" || checkbox === "[X]") {
|
||||||
|
checkboxStatus = true;
|
||||||
|
} else if (checkbox === "[ ]") {
|
||||||
|
checkboxStatus = false;
|
||||||
|
}
|
||||||
|
if (checkboxStatus === undefined) {
|
||||||
|
// Not replacing it with a widget
|
||||||
|
return;
|
||||||
|
}
|
||||||
const dec = Decoration.replace({
|
const dec = Decoration.replace({
|
||||||
widget: new CheckboxWidget(
|
widget: new CheckboxWidget(
|
||||||
checked,
|
checkboxStatus,
|
||||||
from + nfrom + 1,
|
from + nfrom + 1,
|
||||||
onCheckboxClick,
|
onCheckboxClick,
|
||||||
),
|
),
|
||||||
|
@ -22,6 +22,7 @@ export default function highlightStyles(mdExtension: MDExt[]) {
|
|||||||
{ tag: ct.AttributeNameTag, class: "sb-atom" },
|
{ tag: ct.AttributeNameTag, class: "sb-atom" },
|
||||||
{ tag: ct.TaskTag, class: "sb-task" },
|
{ tag: ct.TaskTag, class: "sb-task" },
|
||||||
{ tag: ct.TaskMarkerTag, class: "sb-task-marker" },
|
{ tag: ct.TaskMarkerTag, class: "sb-task-marker" },
|
||||||
|
{ tag: ct.TaskStateTag, class: "sb-task-state" },
|
||||||
{ tag: ct.CodeInfoTag, class: "sb-code-info" },
|
{ tag: ct.CodeInfoTag, class: "sb-code-info" },
|
||||||
{ tag: ct.CommentTag, class: "sb-comment" },
|
{ tag: ct.CommentTag, class: "sb-comment" },
|
||||||
{ tag: ct.CommentMarkerTag, class: "sb-comment-marker" },
|
{ tag: ct.CommentMarkerTag, class: "sb-comment-marker" },
|
||||||
|
@ -381,6 +381,10 @@
|
|||||||
color: var(--editor-task-marker-color);
|
color: var(--editor-task-marker-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sb-task-state {
|
||||||
|
color: var(--editor-task-state-color);
|
||||||
|
}
|
||||||
|
|
||||||
.sb-line-comment {
|
.sb-line-comment {
|
||||||
background-color: var(--editor-code-comment-color); // rgba(255, 255, 0, 0.5);
|
background-color: var(--editor-code-comment-color); // rgba(255, 255, 0, 0.5);
|
||||||
}
|
}
|
||||||
|
@ -312,6 +312,10 @@
|
|||||||
font-size: 91%;
|
font-size: 91%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sb-task-state {
|
||||||
|
font-size: 91%;
|
||||||
|
}
|
||||||
|
|
||||||
.sb-directive-start {
|
.sb-directive-start {
|
||||||
border-top-left-radius: 10px;
|
border-top-left-radius: 10px;
|
||||||
border-top-right-radius: 10px;
|
border-top-right-radius: 10px;
|
||||||
|
@ -105,6 +105,8 @@ html {
|
|||||||
--editor-directive-color: #5b5b5b;
|
--editor-directive-color: #5b5b5b;
|
||||||
--editor-directive-info-color: var(--subtle-color);
|
--editor-directive-info-color: var(--subtle-color);
|
||||||
--editor-task-marker-color: var(--subtle-color);
|
--editor-task-marker-color: var(--subtle-color);
|
||||||
|
--editor-task-state-color: var(--subtle-color);
|
||||||
|
|
||||||
|
|
||||||
--ui-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
--ui-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||||
"Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji",
|
"Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji",
|
||||||
|
@ -8,6 +8,8 @@ This release brings a new default [[Client Modes|client mode]] to SilverBullet:
|
|||||||
|
|
||||||
Other notable changes:
|
Other notable changes:
|
||||||
* Massive reshuffling of built-in [[🔌 Plugs]], splitting the old “core” plug into [[🔌 Editor]], [[🔌 Template]] and [[🔌 Index]].
|
* Massive reshuffling of built-in [[🔌 Plugs]], splitting the old “core” plug into [[🔌 Editor]], [[🔌 Template]] and [[🔌 Index]].
|
||||||
|
* [[🔌 Tasks]] now support custom states (not just `[x]` and `[ ]`), for example:
|
||||||
|
* [IN PROGRESS] An in progress task
|
||||||
* Removed [[Cloud Links]] support in favor of [[Federation]]
|
* Removed [[Cloud Links]] support in favor of [[Federation]]
|
||||||
|
|
||||||
---
|
---
|
||||||
|
@ -83,15 +83,7 @@ docker run -p 3000:3000 -v myspace:/space -d -e SB_USER=me:letmein zefhemel/silv
|
|||||||
|
|
||||||
To build your own version of the docker image, run `./scripts/build_docker.sh`.
|
To build your own version of the docker image, run `./scripts/build_docker.sh`.
|
||||||
|
|
||||||
You can also use docker-compose if you prefer. From a silverbullet check-out run:
|
To upgrade, simply pull the latest docker image and start the new container.
|
||||||
|
|
||||||
```shell
|
|
||||||
PORT=3000 docker-compose up
|
|
||||||
```
|
|
||||||
|
|
||||||
or similar.
|
|
||||||
|
|
||||||
To upgrade, simply pull the latest docker image (rebuilt and pushed after every commit to "main") and start the new container.
|
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
docker pull zefhemel/silverbullet
|
docker pull zefhemel/silverbullet
|
||||||
|
@ -1 +1 @@
|
|||||||
* [{{#if done}}x{{else}} {{/if}}] [[{{page}}@{{pos}}]] {{name}}
|
* [{{state}}] [[{{page}}@{{pos}}]] {{name}}
|
@ -3,17 +3,33 @@ type: plug
|
|||||||
repo: https://github.com/silverbulletmd/silverbullet
|
repo: https://github.com/silverbulletmd/silverbullet
|
||||||
---
|
---
|
||||||
|
|
||||||
The Tasks plug implements a lot of the task support in SilverBullet.
|
The Tasks plug implements task support in SilverBullet.
|
||||||
|
|
||||||
Tasks in SilverBullet are written using semi-standard task syntax:
|
## Task states
|
||||||
|
The tasks plug support the standard “done” and “not done” states via `[x]` and `[ ]` notation in the context of a list (this is fairly widely accepted [[Markdown]] syntax):
|
||||||
|
|
||||||
* [ ] This is a task
|
* [ ] This is a task (toggle me!)
|
||||||
|
|
||||||
|
However, custom states can also be used for extra flexibility:
|
||||||
|
|
||||||
|
* [TODO] This task is still to do
|
||||||
|
* [IN PROGRESS] In progress task
|
||||||
|
* [RESOLVED] A resolved task
|
||||||
|
* [-] Whatever this state means
|
||||||
|
* [/] Or this one
|
||||||
|
|
||||||
|
You can cycle through the states by clicking on the status or by running the {[Task: Cycle State]} command while on a task. There is also auto complete for all known custom task states in a space.
|
||||||
|
|
||||||
|
## Annotations
|
||||||
Tasks can also be annotated with [[Tags]]:
|
Tasks can also be annotated with [[Tags]]:
|
||||||
|
|
||||||
* [ ] This is a tagged task #my-tag
|
* [ ] This is a tagged task #my-tag
|
||||||
|
|
||||||
You can _toggle_ a task state either by putting in an `x` or `X` inside the box or by simply clicking/tapping on the box. Alternatively, you can use the {[Task: Toggle]} command to toggle the task you’re currently in.
|
As well as [[Attributes]]:
|
||||||
|
|
||||||
|
* [ ] This is a task with attributes [taskAttribute: true]
|
||||||
|
|
||||||
|
## Deadlines
|
||||||
|
|
||||||
Tasks can specify deadlines:
|
Tasks can specify deadlines:
|
||||||
|
|
||||||
@ -21,12 +37,31 @@ Tasks can specify deadlines:
|
|||||||
|
|
||||||
When the cursor is positioned inside of a due date, the {[Task: Postpone]} command can be used to postpone the task for a certain period.
|
When the cursor is positioned inside of a due date, the {[Task: Postpone]} command can be used to postpone the task for a certain period.
|
||||||
|
|
||||||
This metadata is extracted and available via the `task` query source to [[🔌 Directive/Query]]:
|
## Querying
|
||||||
|
All meta data (`done` status, `state`, `tags`, `deadline` and custom attributes) is extracted and available via the `task` query source to [[🔌 Directive/Query]]:
|
||||||
|
|
||||||
<!-- #query task where page = "{{@page.name}}" -->
|
<!-- #query task where page = "{{@page.name}}" -->
|
||||||
|name |done |page |pos|tags |deadline |
|
|name |done |state |page |pos |tags |taskAttribute|deadline |
|
||||||
|-----------------------------|-----|--------|---|------|----------|
|
|--|--|--|--|--|--|--|--|
|
||||||
|This is a task |false|🔌 Tasks|213| | |
|
|Remote toggle me |false| |🔌 Tasks|3056| | | |
|
||||||
|This is a tagged task #my-tag|false|🔌 Tasks|279|my-tag| |
|
|This is a task (toggle me!) |false| |🔌 Tasks|321 | | | |
|
||||||
|This is due |false|🔌 Tasks|565| |2022-11-26|
|
|This task is still to do |false|TODO |🔌 Tasks|420 | | | |
|
||||||
|
|In progress task |false|IN PROGRESS|🔌 Tasks|454 | | | |
|
||||||
|
|A resolved task |false|RESOLVED |🔌 Tasks|487 | | | |
|
||||||
|
|Whatever this state means |false|- |🔌 Tasks|516 | | | |
|
||||||
|
|Or this one |false|/ |🔌 Tasks|548 | | | |
|
||||||
|
|This is a tagged task #my-tag |false| |🔌 Tasks|824 |my-tag| | |
|
||||||
|
|This is a task with attributes|false| |🔌 Tasks|889 | |true| |
|
||||||
|
|This is due |false| |🔌 Tasks|993 | | |2022-11-26|
|
||||||
<!-- /query -->
|
<!-- /query -->
|
||||||
|
|
||||||
|
## Rendering
|
||||||
|
There is a [[!silverbullet.md/template/task]] template you can use to render tasks nicely rather than using the default table (as demonstrated above). When you use this template, you can even cycle through the states of the task by click on its state _inside_ the rendered query, and it will update the state of the _original_ task automatically (although not yet in reverse) — this works across pages.
|
||||||
|
|
||||||
|
Try it (by clicking on the checkbox inside of the directive):
|
||||||
|
|
||||||
|
<!-- #query task where page = "{{@page.name}}" and name = "Remote toggle me" render [[template/task]] -->
|
||||||
|
* [ ] [[🔌 Tasks@3056]] Remote toggle me
|
||||||
|
<!-- /query -->
|
||||||
|
|
||||||
|
* [ ] Remote toggle me
|
||||||
|
Loading…
Reference in New Issue
Block a user