1
0

Custom task statuses plus various fixes

This commit is contained in:
Zef Hemel 2023-09-01 16:57:29 +02:00
parent 9b5fa44fb6
commit 2ee20af5c8
24 changed files with 374 additions and 118 deletions

View File

@ -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) {

View File

@ -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();

View 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",
}],
};

View File

@ -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");
});

View File

@ -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,

View File

@ -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
View 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",
};
}),
};
}

View File

@ -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:

View File

@ -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",
};
}),
};
}

View File

@ -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,
); );

View File

@ -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
View 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,
})),
};
}

View File

@ -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({

View File

@ -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

View File

@ -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);

View File

@ -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,
), ),

View File

@ -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" },

View File

@ -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);
} }

View File

@ -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;

View File

@ -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",

View File

@ -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]]
--- ---

View File

@ -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

View File

@ -1 +1 @@
* [{{#if done}}x{{else}} {{/if}}] [[{{page}}@{{pos}}]] {{name}} * [{{state}}] [[{{page}}@{{pos}}]] {{name}}

View File

@ -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 youre 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