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();
console.log(options);
if (!folder) {
folder = Deno.env.get("SB_FOLDER");
if (!folder) {

View File

@ -5,8 +5,6 @@ export const CommandLinkNameTag = Tag.define();
export const WikiLinkTag = Tag.define();
export const WikiLinkPageTag = Tag.define();
export const CodeInfoTag = Tag.define();
export const TaskTag = Tag.define();
export const TaskMarkerTag = Tag.define();
export const CommentTag = Tag.define();
export const CommentMarkerTag = Tag.define();
export const BulletList = Tag.define();
@ -22,3 +20,7 @@ export const DirectiveProgramTag = Tag.define();
export const AttributeTag = Tag.define();
export const AttributeNameTag = 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", () => {
const lang = buildMarkdown([]);
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");
let nameNode = findNodeOfType(attributes[0], "AttributeName");
assertEquals(nameNode?.children![0].text, "age");
@ -120,3 +120,21 @@ Deno.test("Test inline attribute syntax", () => {
valueNode = findNodeOfType(attributes[2], "AttributeValue");
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,
styleTags,
tags as t,
TaskList,
yamlLanguage,
} from "../deps.ts";
import * as ct from "./customtags.ts";
import { TaskList } from "./extended_task.ts";
import {
MDExt,
mdExtensionStyleTags,

View File

@ -115,9 +115,6 @@ export class EventedSpacePrimitives implements SpacePrimitives {
console.error("Error dispatching page:saved event", e);
});
}
// if (name.startsWith("_plug/") && name.endsWith(".plug.js")) {
// await this.dispatchEvent("plug:changed", name);
// }
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
pageComplete:
path: "./page.ts:pageComplete"
path: "./complete.ts:pageComplete"
events:
- editor:complete
commandComplete:

View File

@ -60,43 +60,3 @@ export async function newPageCommand() {
}
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",
done: "boolean",
page: "string",
state: "string",
deadline: "string",
pos: "number",
tags: "array",
@ -126,6 +127,10 @@ export function builtinAttributeCompleter(
}
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(
completeEvent.linePrefix,
);

View File

@ -260,7 +260,11 @@ function render(
name: "span",
body: cleanTags(mapRender(t.children!)),
};
case "TaskMarker":
case "TaskState": {
// child[0] = marker, child[1] = state, child[2] = marker
const stateText = t.children![1].text!;
if ([" ", "x", "X"].includes(stateText)) {
return {
name: "input",
attrs: {
@ -270,6 +274,16 @@ function render(
},
body: "",
};
} else {
return {
name: "span",
attrs: {
class: "task-state",
},
body: stateText,
};
}
}
case "NamedAnchor":
return {
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,
collectNodesMatching,
findNodeOfType,
findParentMatching,
nodeAtPos,
ParseTree,
renderToText,
@ -25,6 +26,7 @@ import { indexAttributes } from "../index/attributes.ts";
export type Task = {
name: string;
done: boolean;
state: string;
deadline?: string;
tags?: string[];
nested?: string;
@ -37,8 +39,12 @@ function getDeadline(deadlineNode: ParseTree): string {
return deadlineNode.children![0].text!.replace(/📅\s*/, "");
}
const completeStates = ["x", "X"];
const incompleteStates = [" "];
export async function indexTasks({ name, tree }: IndexTreeEvent) {
const tasks: { key: string; value: Task }[] = [];
const taskStates = new Map<string, number>();
removeQueries(tree);
addParentPointers(tree);
const allAttributes: Record<string, any> = {};
@ -46,10 +52,19 @@ export async function indexTasks({ name, tree }: IndexTreeEvent) {
if (n.type !== "Task") {
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 = {
name: "",
done: complete,
state,
};
rewritePageRefs(n, name);
@ -95,31 +110,59 @@ export async function indexTasks({ name, tree }: IndexTreeEvent) {
// console.log("Found", tasks, "task(s)");
await index.batchSet(name, tasks);
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) {
return taskToggleAtPos(event.page, event.pos);
if (event.altKey) {
return;
}
return taskCycleAtPos(event.page, event.pos);
}
export async function previewTaskToggle(eventString: string) {
const [eventName, pos] = JSON.parse(eventString);
if (eventName === "task") {
return taskToggleAtPos(await editor.getCurrentPage(), +pos);
return taskCycleAtPos(await editor.getCurrentPage(), +pos);
}
}
async function toggleTaskMarker(
async function cycleTaskState(
pageName: string,
node: ParseTree,
) {
let changeTo = "[x]";
if (node.children![0].text === "[x]" || node.children![0].text === "[X]") {
changeTo = "[ ]";
const stateText = node.children![1].text!;
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 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({
changes: {
from: node.from,
to: node.to,
from: node.children![1].from,
to: node.children![1].to,
insert: changeTo,
},
});
@ -135,10 +178,23 @@ async function toggleTaskMarker(
const pos = +posS;
if (page === pageName) {
// 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({
changes: {
from: pos,
to: pos + changeTo.length,
from: pos + 1,
to: pos + 1 + stateText.length,
insert: changeTo,
},
});
@ -146,17 +202,16 @@ async function toggleTaskMarker(
let text = await space.readPage(page);
const referenceMdTree = await markdown.parseMarkdown(text);
// Adding +1 to immediately hit the task marker
const taskMarkerNode = nodeAtPos(referenceMdTree, pos + 1);
if (!taskMarkerNode || taskMarkerNode.type !== "TaskMarker") {
// Adding +1 to immediately hit the task state node
const taskStateNode = nodeAtPos(referenceMdTree, pos + 1);
if (!taskStateNode || taskStateNode.type !== "TaskState") {
console.error(
"Reference not a task marker, out of date?",
taskMarkerNode,
taskStateNode,
);
return;
}
taskMarkerNode.children![0].text = changeTo;
taskStateNode.children![1].text = changeTo;
text = renderToText(referenceMdTree);
await space.writePage(page, text);
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 mdTree = await markdown.parseMarkdown(text);
addParentPointers(mdTree);
const node = nodeAtPos(mdTree, pos);
if (node && node.type === "TaskMarker") {
await toggleTaskMarker(pageName, node);
let node = nodeAtPos(mdTree, pos);
if (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 pos = await editor.getCursor();
const tree = await markdown.parseMarkdown(text);
addParentPointers(tree);
const node = nodeAtPos(tree, pos);
// We kwow node.type === Task (due to the task context)
const taskMarker = findNodeOfType(node!, "TaskMarker");
await toggleTaskMarker(await editor.getCurrentPage(), taskMarker!);
let node = nodeAtPos(tree, pos);
if (!node) {
await editor.flashNotification("No task at cursor");
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() {
@ -210,7 +291,7 @@ export async function postponeCommand() {
return;
}
// 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.
// `monthIndex` parameter is zero-based, so subtract 1 from parsed month.
const d = new Date(yyyy, mm - 1, dd);
@ -236,7 +317,6 @@ export async function postponeCommand() {
anchor: pos,
},
});
// await toggleTaskMarker(taskMarker!, pos);
}
export async function queryProvider({

View File

@ -40,12 +40,10 @@ functions:
events:
- query:task
taskToggleCommand:
path: ./task.ts:taskToggleCommand
path: ./task.ts:taskCycleCommand
command:
name: "Task: Toggle"
name: "Task: Cycle State"
key: Alt-t
contexts:
- Task
taskPostponeCommand:
path: ./task.ts:postponeCommand
command:
@ -57,3 +55,8 @@ functions:
path: ./task.ts:previewTaskToggle
events:
- preview:click
taskComplete:
path: ./complete.ts:completeTaskState
events:
- editor:complete

View File

@ -96,6 +96,9 @@ export class Client {
parent: Element,
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
this.dbPrefix = "" + simpleHash(window.silverBulletConfig.spaceFolderPath);

View File

@ -37,12 +37,13 @@ export function taskListPlugin(
syntaxTree(state).iterate({
enter({ type, from, to, node }) {
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
node.toTree().iterate({
enter: (ref) => iterateInner(ref.type, ref.from, ref.to),
});
if (checked) {
if (checkboxStatus === true) {
widgets.push(
Decoration.mark({
tagName: "span",
@ -52,14 +53,22 @@ export function taskListPlugin(
}
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;
const checkbox = state.sliceDoc(from + nfrom, from + nto);
// 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({
widget: new CheckboxWidget(
checked,
checkboxStatus,
from + nfrom + 1,
onCheckboxClick,
),

View File

@ -22,6 +22,7 @@ export default function highlightStyles(mdExtension: MDExt[]) {
{ tag: ct.AttributeNameTag, class: "sb-atom" },
{ tag: ct.TaskTag, class: "sb-task" },
{ tag: ct.TaskMarkerTag, class: "sb-task-marker" },
{ tag: ct.TaskStateTag, class: "sb-task-state" },
{ tag: ct.CodeInfoTag, class: "sb-code-info" },
{ tag: ct.CommentTag, class: "sb-comment" },
{ tag: ct.CommentMarkerTag, class: "sb-comment-marker" },

View File

@ -381,6 +381,10 @@
color: var(--editor-task-marker-color);
}
.sb-task-state {
color: var(--editor-task-state-color);
}
.sb-line-comment {
background-color: var(--editor-code-comment-color); // rgba(255, 255, 0, 0.5);
}

View File

@ -312,6 +312,10 @@
font-size: 91%;
}
.sb-task-state {
font-size: 91%;
}
.sb-directive-start {
border-top-left-radius: 10px;
border-top-right-radius: 10px;

View File

@ -105,6 +105,8 @@ html {
--editor-directive-color: #5b5b5b;
--editor-directive-info-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,
"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:
* 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]]
---

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`.
You can also use docker-compose if you prefer. From a silverbullet check-out run:
```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.
To upgrade, simply pull the latest docker image and start the new container.
```shell
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
---
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]]:
* [ ] 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:
@ -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.
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}}" -->
|name |done |page |pos|tags |deadline |
|-----------------------------|-----|--------|---|------|----------|
|This is a task |false|🔌 Tasks|213| | |
|This is a tagged task #my-tag|false|🔌 Tasks|279|my-tag| |
|This is due |false|🔌 Tasks|565| |2022-11-26|
|name |done |state |page |pos |tags |taskAttribute|deadline |
|--|--|--|--|--|--|--|--|
|Remote toggle me |false| |🔌 Tasks|3056| | | |
|This is a task (toggle me!) |false| |🔌 Tasks|321 | | | |
|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 -->
## 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