Files
plainleaf/plugs/index/item.ts
T
Matouš Jan FialkaandGitHub 6d52350640 Fix extended task rendering in widgets (#1882)
* Fix extended task rendering in widgets

This patch addresses several issues.

1. Extended tasks like `[PLANNED]` or `[TODO]` were not rendering
   correctly when generated via widgets. The HTML renderer was producing
   plain text without brackets or proper styling, while simple `[x]`
   tasks worked fine.  The fix produces the same DOM structure as
   CodeMirror producing coherent styling everywhere.

2. Widget checkboxes are not interactive now. Since widget tasks have no
   backing state handler clicking them only caused a misleading visual
   toggle with no effect. The `disabled` attribute prevents this
   confusion and the dimmed appearanceit signals to the user that the
   checkbox is read-only in this context which is the truth.

To see it in action try the following Markdown snippet in SilverBullet:

```
- [x] #foo [att: i]
- [PLANNED] bar #bar [att: i]

${'- [x] #foo [att: i]\n- [PLANNED] bar #bar [att: i]'}
```

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>

* Revert and just prevent widget checkboxes from toggling visually

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>

* Support custom task states in widgets and indexer

Custom task states like `[PLANNED]` or `[IN PROGRESS]` now render
correctly in *widgets*, matching the same DOM structure as CodeMirror
uses in the editor. Widget-rendered tasks that reference a real task
(via `page@pos`) are fully interactive — clicking an extended state
cycles it through the configured states and updates the source task.
Tasks without a reference are disabled to prevent misleading visual
feedback.

The task indexer now respects the `done` flag on custom states defined
via `taskState.define`, so queries like `where not t.done` correctly
exclude tasks in states marked as done. Previously only the built-in
simple tasks (`- [x]` or `- [X]`) states were treated as completed. The
`Task: Remove Completed` command already handled custom done states and
continues to work unchanged.

State cycling now preserves the definition order from `taskState.define`
calls instead of sorting alphabetically. A new `order` field allows
explicit control over the cycle sequence. Without it, states cycle in
definition order. With it, states are sorted by the `order` field value.
This gives users full control over the progression, for example:

    PLANNED --> IN PROGRESS --> FINISHED

The `taskState.define` API now validates its input against a schema:
`name` (string, required), `done` (boolean, optional), and `order`
(number, optional).

The `expandMarkdown` no longer injects "fake" `page@pos` references into
tasks rendered via `widget.markdownBlock` or `widget.new` which was
causing broken links and unwanted interactivity on static task content.

The `task:stateChange` event is dispatched when toggling tasks from
widgets, ensuring Lua listeners (such as some custom completed timestamp
management) fire correctly regardless of where the toggle originates.

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>

---------

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>
2026-03-13 13:32:26 +01:00

204 lines
4.9 KiB
TypeScript

import {
cloneTree,
findParentMatching,
type ParseTree,
renderToText,
traverseTree,
} from "@silverbulletmd/silverbullet/lib/tree";
import { cleanTags, collectTags, updateITags } from "./tags.ts";
import type { FrontMatter } from "./frontmatter.ts";
import type {
ObjectValue,
PageMeta,
} from "@silverbulletmd/silverbullet/type/index";
import { system } from "@silverbulletmd/silverbullet/syscalls";
import { cleanAttributes, collectAttributes } from "./attribute.ts";
import { collectPageLinks } from "./link.ts";
export type ItemObject = ObjectValue<
{
page: string;
name: string;
text: string;
// Deprecated, use range instead
pos: number;
// Deprecated, use range instead
toPos: number;
parent?: string;
links?: string[];
ilinks?: string[];
} & Record<string, any>
>;
export type TaskObject = ObjectValue<
// "Inherit" everyting from item
ItemObject & {
// And add a few more attributes
done: boolean;
state: string;
} & Record<string, any>
>;
const completeStates = ["x", "X"];
export async function indexItems(
pageMeta: PageMeta,
frontmatter: FrontMatter,
tree: ParseTree,
) {
const shouldIndexAllItems = await system.getConfig("index.item.all", true);
const shouldIndexAllTasks = await system.getConfig("index.task.all", true);
// Build complete list of "done" states
const taskStates = await system.getConfig("taskStates", {});
const allCompleteStates = completeStates.concat(
Object.values(taskStates)
.filter((ts: any) => ts.done)
.map((ts: any) => ts.name),
);
let items: ObjectValue<ItemObject | TaskObject>[] = [];
traverseTree(tree, (n) => {
if (n.type !== "ListItem") {
return false;
}
if (!n.children) {
// Weird, let's jump out
return true;
}
items.push(
extractItemFromNode(
pageMeta.name,
n,
frontmatter,
true,
allCompleteStates,
),
);
// Traversal continue into child items (potentially)
return false;
});
if (!shouldIndexAllItems) {
items = items.filter((item) => item.tag !== "item" || item.tags?.length);
}
if (!shouldIndexAllTasks) {
items = items.filter((item) => item.tag !== "task" || item.tags?.length);
}
return items;
}
export function extractItemFromNode(
name: string,
itemNode: ParseTree,
frontmatter: FrontMatter,
withParents = true,
allCompleteStates: string[] = completeStates,
): ItemObject | TaskObject {
const item: ItemObject | TaskObject = {
ref: `${name}@${itemNode.from}`,
tag: "item",
pos: itemNode.from!,
toPos: itemNode.to!,
range: [itemNode.from!, itemNode.to!],
name: "", // to be replaced
text: "", // to be replaced
page: name,
};
// This will only be valid for items, not task
let nameNode = itemNode.children!.find((n) => n.type === "Paragraph");
// Is this a task?
const taskNode = itemNode.children!.find((n) => n.type === "Task");
if (taskNode) {
item.tag = "task";
item.state = taskNode.children![0].children![1].text!;
item.done = allCompleteStates.includes(item.state);
// Fake a paragraph node for text rendering later
nameNode = { type: "Paragraph", children: taskNode.children!.slice(1) };
}
// Now let's extract tags and attributes
const tags = collectTags(itemNode);
const attributes = collectAttributes(itemNode);
const links = collectPageLinks(itemNode);
item.text = renderToText(nameNode).trim();
const nameNodeClone = cloneTree(nameNode!);
cleanTags(nameNodeClone);
cleanAttributes(nameNodeClone);
item.name = renderToText(nameNodeClone).trim();
if (tags.length > 0) {
item.tags = tags;
}
if (links.length > 0) {
item.links = links;
item.ilinks = links;
}
for (const [key, value] of Object.entries(attributes)) {
item[key] = value;
}
updateITags(item, frontmatter);
if (withParents) {
enrichItemFromParents(itemNode, item, name, frontmatter, allCompleteStates);
}
return item;
}
export function enrichItemFromParents(
n: ParseTree,
item: ItemObject,
pageName: string,
frontmatter: FrontMatter,
allCompleteStates: string[] = completeStates,
) {
let directParent = true;
let parentItemNode = findParentMatching(n, (n) => n.type === "ListItem");
while (parentItemNode) {
const parentItem = extractItemFromNode(
pageName,
parentItemNode,
frontmatter,
false,
allCompleteStates,
);
if (directParent) {
item.parent = parentItem.ref;
directParent = false;
}
// Merge tags
item.itags = [
...new Set([
...(item.itags || []),
...parentItem.itags!.filter((t) => !["item", "task"].includes(t)),
]),
];
// And links
const ilinks = [
...new Set([...(item.ilinks || []), ...(parentItem.ilinks || [])]),
];
if (ilinks.length > 0) {
item.ilinks = ilinks;
}
parentItemNode = findParentMatching(
parentItemNode,
(n) => n.type === "ListItem",
);
}
}