diff --git a/common/markdown_parser/parser.test.ts b/common/markdown_parser/parser.test.ts index 5379b3e4..f0e361f6 100644 --- a/common/markdown_parser/parser.test.ts +++ b/common/markdown_parser/parser.test.ts @@ -1,11 +1,10 @@ -import { parse } from "./parse_tree.ts"; import { collectNodesOfType, findNodeOfType, renderToText, } from "@silverbulletmd/silverbullet/lib/tree"; -import { assertEquals, assertNotEquals } from "@std/assert"; -import { extendedMarkdownLanguage } from "./parser.ts"; +import { assert, assertEquals, assertNotEquals } from "@std/assert"; +import { parseMarkdown } from "./parser.ts"; const sample1 = `--- type: page @@ -26,7 +25,7 @@ name: Zef Supper`; Deno.test("Test parser", () => { - let tree = parse(extendedMarkdownLanguage, sample1); + let tree = parseMarkdown(sample1); // console.log("tree", JSON.stringify(tree, null, 2)); // Check if rendering back to text works assertEquals(renderToText(tree), sample1); @@ -44,7 +43,7 @@ Deno.test("Test parser", () => { // Find frontmatter let node = findNodeOfType(tree, "FrontMatter"); assertNotEquals(node, undefined); - tree = parse(extendedMarkdownLanguage, sampleInvalid1); + tree = parseMarkdown(sampleInvalid1); node = findNodeOfType(tree, "FrontMatter"); // console.log("Invalid node", node); assertEquals(node, undefined); @@ -61,7 +60,7 @@ And one with nested brackets: [array: [1, 2, 3]] `; Deno.test("Test inline attribute syntax", () => { - const tree = parse(extendedMarkdownLanguage, inlineAttributeSample); + const tree = parseMarkdown(inlineAttributeSample); // console.log("Attribute parsed", JSON.stringify(tree, null, 2)); const attributes = collectNodesOfType(tree, "Attribute"); let nameNode = findNodeOfType(attributes[0], "AttributeName"); @@ -81,11 +80,8 @@ Deno.test("Test inline attribute syntax", () => { }); Deno.test("Test template directive parsing", () => { - const tree = parse( - extendedMarkdownLanguage, - "Simple {{name}} and {{count({ page })}}", - ); - console.log("Template directive", JSON.stringify(tree, null, 2)); + const tree = parseMarkdown("Simple {{name}} and {{count({ page })}}"); + assert(findNodeOfType(tree, "TemplateDirective")); }); const multiStatusTaskExample = ` @@ -95,7 +91,7 @@ const multiStatusTaskExample = ` `; Deno.test("Test multi-status tasks", () => { - const tree = parse(extendedMarkdownLanguage, multiStatusTaskExample); + const tree = parseMarkdown(multiStatusTaskExample); // console.log("Tasks parsed", JSON.stringify(tree, null, 2)); const tasks = collectNodesOfType(tree, "Task"); assertEquals(tasks.length, 3); @@ -112,7 +108,7 @@ const commandLinkSample = ` `; Deno.test("Test command links", () => { - const tree = parse(extendedMarkdownLanguage, commandLinkSample); + const tree = parseMarkdown(commandLinkSample); const commands = collectNodesOfType(tree, "CommandLink"); // console.log("Command links parsed", JSON.stringify(commands, null, 2)); assertEquals(commands.length, 3); @@ -129,7 +125,7 @@ const commandLinkArgsSample = ` `; Deno.test("Test command link arguments", () => { - const tree = parse(extendedMarkdownLanguage, commandLinkArgsSample); + const tree = parseMarkdown(commandLinkArgsSample); const commands = collectNodesOfType(tree, "CommandLink"); assertEquals(commands.length, 2); @@ -142,22 +138,22 @@ Deno.test("Test command link arguments", () => { Deno.test("Test directive parser", () => { const simpleExample = `Simple {{.}}`; - let tree = parse(extendedMarkdownLanguage, simpleExample); + let tree = parseMarkdown(simpleExample); assertEquals(renderToText(tree), simpleExample); const eachExample = `{{#each .}}Sup{{/each}}`; - tree = parse(extendedMarkdownLanguage, eachExample); + tree = parseMarkdown(eachExample); const ifExample = `{{#if true}}Sup{{/if}}`; - tree = parse(extendedMarkdownLanguage, ifExample); + tree = parseMarkdown(ifExample); assertEquals(renderToText(tree), ifExample); const ifElseExample = `{{#if true}}Sup{{else}}Sup2{{/if}}`; - tree = parse(extendedMarkdownLanguage, ifElseExample); + tree = parseMarkdown(ifElseExample); assertEquals(renderToText(tree), ifElseExample); - console.log("Final tree", JSON.stringify(tree, null, 2)); + // console.log("Final tree", JSON.stringify(tree, null, 2)); const letExample = `{{#let @p = true}}{{/let}}`; - tree = parse(extendedMarkdownLanguage, letExample); + tree = parseMarkdown(letExample); assertEquals(renderToText(tree), letExample); }); diff --git a/common/markdown_parser/parser.ts b/common/markdown_parser/parser.ts index 131496fa..1416fd73 100644 --- a/common/markdown_parser/parser.ts +++ b/common/markdown_parser/parser.ts @@ -541,6 +541,8 @@ const NamedAnchor = regexParser({ import { Table } from "./table_parser.ts"; import { foldNodeProp } from "@codemirror/language"; import { pWikiLinkRegex, tagRegex } from "$common/markdown_parser/constants.ts"; +import { parse } from "$common/markdown_parser/parse_tree.ts"; +import type { ParseTree } from "@silverbulletmd/silverbullet/lib/tree"; // FrontMatter parser @@ -661,3 +663,7 @@ export const extendedMarkdownLanguage = markdown({ }, ], }).language; + +export function parseMarkdown(text: string): ParseTree { + return parse(extendedMarkdownLanguage, text); +} diff --git a/plug-api/lib/attribute.test.ts b/plug-api/lib/attribute.test.ts index 95a7a25b..93235e6a 100644 --- a/plug-api/lib/attribute.test.ts +++ b/plug-api/lib/attribute.test.ts @@ -1,6 +1,9 @@ import "./syscall_mock.ts"; import { parse } from "$common/markdown_parser/parse_tree.ts"; -import { extractAttributes } from "@silverbulletmd/silverbullet/lib/attribute"; +import { + cleanAttributes, + extractAttributes, +} from "@silverbulletmd/silverbullet/lib/attribute"; import { assertEquals } from "@std/assert"; import { renderToText } from "./tree.ts"; import { extendedMarkdownLanguage } from "$common/markdown_parser/parser.ts"; @@ -19,22 +22,22 @@ const cleanedInlineAttributeSample = ` # My document Top level attributes: -* [ ] Attribute in a task [tag:: foo] -* Regular item [tag:: bar] +* [ ] Attribute in a task +* Regular item -1. Itemized list [tag:: baz] +1. Itemized list `; Deno.test("Test attribute extraction", async () => { const tree = parse(extendedMarkdownLanguage, inlineAttributeSample); - const toplevelAttributes = await extractAttributes(["test"], tree, false); + const toplevelAttributes = await extractAttributes(["test"], tree); // console.log("All attributes", toplevelAttributes); assertEquals(toplevelAttributes.name, "sup"); assertEquals(toplevelAttributes.age, 42); assertEquals(toplevelAttributes.children, ["pete", "john", "mary"]); // Check if the attributes are still there assertEquals(renderToText(tree), inlineAttributeSample); - // Now once again with cleaning - await extractAttributes(["test"], tree, true); + // And now clean + cleanAttributes(tree); assertEquals(renderToText(tree), cleanedInlineAttributeSample); }); diff --git a/plug-api/lib/attribute.ts b/plug-api/lib/attribute.ts index b9d0184c..5a26d112 100644 --- a/plug-api/lib/attribute.ts +++ b/plug-api/lib/attribute.ts @@ -2,7 +2,8 @@ import { findNodeOfType, type ParseTree, renderToText, - replaceNodesMatchingAsync, + replaceNodesMatching, + traverseTreeAsync, } from "./tree.ts"; import { cleanupJSON } from "@silverbulletmd/silverbullet/lib/json"; @@ -10,21 +11,19 @@ import { cleanupJSON } from "@silverbulletmd/silverbullet/lib/json"; import { system, YAML } from "../syscalls.ts"; /** - * Extracts attributes from a tree, optionally cleaning them out of the tree. + * Extracts attributes from a tree * @param tree tree to extract attributes from - * @param clean whether or not to clean out the attributes from the tree * @returns mapping from attribute name to attribute value */ export async function extractAttributes( tags: string[], tree: ParseTree, - clean: boolean, ): Promise> { let attributes: Record = {}; - await replaceNodesMatchingAsync(tree, async (n) => { - if (n.type === "ListItem") { + await traverseTreeAsync(tree, async (n) => { + if (tree !== n && n.type === "ListItem") { // Find top-level only, no nested lists - return n; + return true; } if (n.type === "Attribute") { const nameNode = findNodeOfType(n, "AttributeName"); @@ -38,15 +37,10 @@ export async function extractAttributes( console.error("Error parsing attribute value as YAML", val, e); } } - // Remove from tree - if (clean) { - return null; - } else { - return n; - } + return true; } // Go on... - return undefined; + return false; }); const text = renderToText(tree); const spaceScriptAttributes = await system.applyAttributeExtractors( @@ -60,3 +54,16 @@ export async function extractAttributes( }; return attributes; } + +/** + * Cleans attributes from a tree (as a side effect) + * @param tree to clean attributes from + */ +export function cleanAttributes(tree: ParseTree) { + replaceNodesMatching(tree, (n) => { + if (n.type === "Attribute") { + return null; + } + return; + }); +} diff --git a/plug-api/lib/feed.ts b/plug-api/lib/feed.ts index 04ac28cd..22da19a2 100644 --- a/plug-api/lib/feed.ts +++ b/plug-api/lib/feed.ts @@ -9,7 +9,10 @@ import { * Feed parsing functionality (WIP) */ -import { extractAttributes } from "@silverbulletmd/silverbullet/lib/attribute"; +import { + cleanAttributes, + extractAttributes, +} from "@silverbulletmd/silverbullet/lib/attribute"; export type FeedItem = { id: string; @@ -29,7 +32,6 @@ export async function extractFeedItems(tree: ParseTree): Promise { for (const node of tree.children!) { if (node.type === "FrontMatter") { // Not interested - console.log("Ignoring", node); continue; } if (node.type === "HorizontalRule") { @@ -51,7 +53,8 @@ async function nodesToFeedItem(nodes: ParseTree[]): Promise { const wrapperNode: ParseTree = { children: nodes, }; - const attributes = await extractAttributes(["feed"], wrapperNode, true); + const attributes = await extractAttributes(["feed"], wrapperNode); + cleanAttributes(wrapperNode); let id = attributes.id; delete attributes.id; if (!id) { diff --git a/plug-api/lib/json.test.ts b/plug-api/lib/json.test.ts index d0969ab7..882f43c7 100644 --- a/plug-api/lib/json.test.ts +++ b/plug-api/lib/json.test.ts @@ -1,5 +1,5 @@ import { assertEquals } from "@std/assert"; -import { cleanupJSON, deepEqual, deepObjectMerge } from "./json.ts"; +import { cleanupJSON, deepClone, deepEqual, deepObjectMerge } from "./json.ts"; Deno.test("JSON utils", () => { assertEquals(deepEqual({ a: 1 }, { a: 1 }), true); @@ -25,3 +25,30 @@ Deno.test("JSON utils", () => { assertEquals(cleanupJSON(new Date("2023-05-03T00:00:00Z")), "2023-05-03"); }); + +Deno.test("JSON utils - deepObjectMerge", () => { + // Tests for deepClone + const obj1 = { a: 1, b: { c: 2, d: [3, 4] }, e: new Date("2023-08-21") }; + const clone1 = deepClone(obj1); + assertEquals(clone1, obj1); + assertEquals(clone1 === obj1, false); // Ensuring deep clone, not shallow + assertEquals(clone1.b === obj1.b, false); // Nested object should be different reference + assertEquals(clone1.e === obj1.e, false); // Date object should be different reference + + const arrayTest = [1, 2, { a: 3, b: [4, 5] }]; + const cloneArray = deepClone(arrayTest); + assertEquals(cloneArray, arrayTest); + assertEquals(cloneArray === arrayTest, false); // Array itself should be different reference + assertEquals(cloneArray[2] === arrayTest[2], false); // Nested object in array should be different reference + + const nullTest = { a: null, b: undefined, c: { d: null } }; + const cloneNullTest = deepClone(nullTest); + assertEquals(cloneNullTest, nullTest); + assertEquals(cloneNullTest === nullTest, false); // Ensure it's a deep clone + assertEquals(cloneNullTest.c === nullTest.c, false); // Nested object should be different reference + + const dateTest = new Date(); + const cloneDateTest = deepClone(dateTest); + assertEquals(cloneDateTest.getTime(), dateTest.getTime()); + assertEquals(cloneDateTest === dateTest, false); // Date instance should be different reference +}); diff --git a/plug-api/lib/json.ts b/plug-api/lib/json.ts index 5e379258..027c4c0d 100644 --- a/plug-api/lib/json.ts +++ b/plug-api/lib/json.ts @@ -139,3 +139,39 @@ export function deepObjectMerge(a: any, b: any, reverseArrays = false): any { } return b; } + +export function deepClone(obj: T, ignoreKeys: string[] = []): T { + // Handle null, undefined, or primitive types (string, number, boolean, symbol, bigint) + if (obj === null || typeof obj !== "object") { + return obj; + } + + // Handle Date + if (obj instanceof Date) { + return new Date(obj.getTime()) as any; + } + + // Handle Array + if (Array.isArray(obj)) { + const arrClone: any[] = []; + for (let i = 0; i < obj.length; i++) { + arrClone[i] = deepClone(obj[i], ignoreKeys); + } + return arrClone as any; + } + + // Handle Object + if (obj instanceof Object) { + const objClone: { [key: string]: any } = {}; + for (const key in obj) { + if (ignoreKeys.includes(key)) { + objClone[key] = obj[key]; + } else if (Object.prototype.hasOwnProperty.call(obj, key)) { + objClone[key] = deepClone(obj[key], ignoreKeys); + } + } + return objClone as T; + } + + throw new Error("Unsupported data type."); +} diff --git a/plug-api/lib/tags.ts b/plug-api/lib/tags.ts index 42ccd979..1af39bf3 100644 --- a/plug-api/lib/tags.ts +++ b/plug-api/lib/tags.ts @@ -1,5 +1,9 @@ import type { FrontMatter } from "./frontmatter.ts"; import type { ObjectValue } from "../types.ts"; +import { + type ParseTree, + traverseTree, +} from "@silverbulletmd/silverbullet/lib/tree"; export function updateITags(obj: ObjectValue, frontmatter: FrontMatter) { const itags = [obj.tag, ...frontmatter.tags || []]; @@ -12,3 +16,37 @@ export function updateITags(obj: ObjectValue, frontmatter: FrontMatter) { } obj.itags = itags; } + +/** + * Extracts a set of hashtags from a tree + * @param n the tree to extract from + * @returns + */ +export function extractHashTags(n: ParseTree): string[] { + const tags = new Set(); + traverseTree(n, (n) => { + if (n.type === "Hashtag") { + tags.add(n.children![0].text!.substring(1)); + return true; + } else if (n.type === "OrderedList" || n.type === "BulletList") { + // Don't traverse into sub-lists + return true; + } + return false; + }); + return [...tags]; +} + +/** + * Cleans hashtags from a tree as a side effect + * @param n + */ +export function cleanHashTags(n: ParseTree) { + traverseTree(n, (n) => { + if (n.type === "Hashtag") { + n.children = []; + return true; + } + return false; + }); +} diff --git a/plugs/index/header.ts b/plugs/index/header.ts index 922289d6..5fb62a5c 100644 --- a/plugs/index/header.ts +++ b/plugs/index/header.ts @@ -43,7 +43,6 @@ export async function indexHeaders({ name: pageName, tree }: IndexTreeEvent) { const extractedAttributes = await extractAttributes( ["header", ...tags], n, - true, ); const name = n.children!.slice(1).map(renderToText).join("").trim(); diff --git a/plugs/index/item.test.ts b/plugs/index/item.test.ts new file mode 100644 index 00000000..08b5a3eb --- /dev/null +++ b/plugs/index/item.test.ts @@ -0,0 +1,33 @@ +import "../../plug-api/lib/syscall_mock.ts"; +import { parseMarkdown } from "$common/markdown_parser/parser.ts"; +import { extractItems } from "./item.ts"; +import { assertEquals } from "@std/assert"; + +const itemsMd = ` +* Item 1 #tag1 #tag2 [age: 100] + * Item 1.1 #tag3 #tag1 + * Item 1.1.1 +`; + +Deno.test("Test item extraction", async () => { + const t = parseMarkdown(itemsMd); + const items = await extractItems("test", t); + + assertEquals(items[0].name, "Item 1"); + assertEquals(items[0].age, 100); + assertEquals(items[0].page, "test"); + assertEquals(items[0].parent, undefined); + assertEquals(items[0].text, "Item 1 #tag1 #tag2 [age: 100]"); + assertEquals(new Set(items[0].tags), new Set(["tag1", "tag2"])); + assertEquals(new Set(items[0].itags), new Set(["item", "tag1", "tag2"])); + + assertEquals(items[1].name, "Item 1.1"); + assertEquals(new Set(items[1].tags), new Set(["tag3", "tag1"])); + assertEquals( + new Set(items[1].itags), + new Set(["tag3", "tag2", "tag1", "item"]), + ); + assertEquals(items[1].parent, items[0].ref); + + assertEquals(items[2].parent, items[1].ref); +}); diff --git a/plugs/index/item.ts b/plugs/index/item.ts index 3c07c3a6..e34f93ba 100644 --- a/plugs/index/item.ts +++ b/plugs/index/item.ts @@ -1,16 +1,27 @@ -import type { IndexTreeEvent } from "../../plug-api/types.ts"; +import type { IndexTreeEvent, ObjectValue } from "../../plug-api/types.ts"; import { - collectNodesOfType, + findParentMatching, type ParseTree, renderToText, -} from "../../plug-api/lib/tree.ts"; -import { extractAttributes } from "@silverbulletmd/silverbullet/lib/attribute"; + traverseTreeAsync, +} from "@silverbulletmd/silverbullet/lib/tree"; +import { + cleanAttributes, + extractAttributes, +} from "@silverbulletmd/silverbullet/lib/attribute"; import { rewritePageRefs } from "@silverbulletmd/silverbullet/lib/resolve"; -import type { ObjectValue } from "../../plug-api/types.ts"; import { indexObjects } from "./api.ts"; -import { updateITags } from "@silverbulletmd/silverbullet/lib/tags"; -import { extractFrontmatter } from "@silverbulletmd/silverbullet/lib/frontmatter"; +import { + cleanHashTags, + extractHashTags, + updateITags, +} from "@silverbulletmd/silverbullet/lib/tags"; +import { + extractFrontmatter, + type FrontMatter, +} from "@silverbulletmd/silverbullet/lib/frontmatter"; +import { deepClone } from "@silverbulletmd/silverbullet/lib/json"; export type ItemObject = ObjectValue< { @@ -22,73 +33,127 @@ export type ItemObject = ObjectValue< >; export async function indexItems({ name, tree }: IndexTreeEvent) { + const items = await extractItems(name, tree); + console.log("Found", items, "item(s)"); + await indexObjects(name, items); +} + +export async function extractItems(name: string, tree: ParseTree) { const items: ObjectValue[] = []; const frontmatter = await extractFrontmatter(tree); - const coll = collectNodesOfType(tree, "ListItem"); + await traverseTreeAsync(tree, async (n) => { + if (n.type !== "ListItem") { + return false; + } - for (const n of coll) { if (!n.children) { - continue; - } - if (collectNodesOfType(n, "Task").length > 0) { - // This is a task item, skip it - continue; + // Weird, let's jump out + return true; } - const tags = new Set(); - const item: ItemObject = { - ref: `${name}@${n.from}`, - tag: "item", - name: "", - text: "", - page: name, - pos: n.from!, - }; - - const textNodes: ParseTree[] = []; - - const fullText = renderToText(n); - - collectNodesOfType(n, "Hashtag").forEach((h) => { - // Push tag to the list, removing the initial # - tags.add(h.children![0].text!.substring(1)); - h.children = []; - }); - - // Extract attributes and remove from tree - const extractedAttributes = await extractAttributes( - ["item", ...tags], + const item: ItemObject = await extractItemFromNode( + name, n, - true, + frontmatter, ); - for (const child of n.children!.slice(1)) { - rewritePageRefs(child, name); - if (child.type === "OrderedList" || child.type === "BulletList") { - break; - } - textNodes.push(child); - } - - item.name = textNodes.map(renderToText).join("").trim(); - item.text = fullText; - - if (tags.size > 0) { - item.tags = [...tags]; - } - - for ( - const [key, value] of Object.entries(extractedAttributes) - ) { - item[key] = value; - } - - updateITags(item, frontmatter); - items.push(item); - } - // console.log("Found", items, "item(s)"); - await indexObjects(name, items); + + return false; + }); + return items; +} + +export async function extractItemFromNode( + name: string, + itemNode: ParseTree, + frontmatter: FrontMatter, +) { + const item: ItemObject = { + ref: `${name}@${itemNode.from}`, + tag: "item", + name: "", + text: "", + page: name, + pos: itemNode.from!, + }; + + // Now let's extract tags and attributes + const tags = extractHashTags(itemNode); + const extractedAttributes = await extractAttributes( + ["item", ...tags], + itemNode, + ); + + const clonedTextNodes: ParseTree[] = []; + + for (const child of itemNode.children!.slice(1)) { + rewritePageRefs(child, name); + + if (child.type === "OrderedList" || child.type === "BulletList") { + break; + } + clonedTextNodes.push(deepClone(child, ["parent"])); + } + + // Original text + item.text = clonedTextNodes.map(renderToText).join("").trim(); + + // Clean out attribtus and tags and render a clean item name + for (const clonedTextNode of clonedTextNodes) { + cleanHashTags(clonedTextNode); + cleanAttributes(clonedTextNode); + } + + item.name = clonedTextNodes.map(renderToText).join("").trim(); + + if (tags.length > 0) { + item.tags = tags; + } + + for (const [key, value] of Object.entries(extractedAttributes)) { + item[key] = value; + } + + updateITags(item, frontmatter); + + await enrichItemFromParents(itemNode, item, name, frontmatter); + + return item; +} + +export async function enrichItemFromParents( + n: ParseTree, + item: ObjectValue, + pageName: string, + frontmatter: FrontMatter, +) { + let directParent = true; + let parentItemNode = findParentMatching(n, (n) => n.type === "ListItem"); + while (parentItemNode) { + // console.log("Got parent", parentItemNode); + const parentItem = await extractItemFromNode( + pageName, + parentItemNode, + frontmatter, + ); + if (directParent) { + item.parent = parentItem.ref; + directParent = false; + } + // Merge tags + item.itags = [ + ...new Set([ + ...item.itags || [], + ...(parentItem.itags!.filter((t) => !["item", "task"].includes(t))), + ]), + ]; + + parentItemNode = findParentMatching( + parentItemNode, + (n) => n.type === "ListItem", + ); + } } diff --git a/plugs/index/page.ts b/plugs/index/page.ts index e8504ba0..be1e1b81 100644 --- a/plugs/index/page.ts +++ b/plugs/index/page.ts @@ -27,7 +27,6 @@ export async function indexPage({ name, tree }: IndexTreeEvent) { const toplevelAttributes = await extractAttributes( ["page", ...frontmatter.tags || []], tree, - false, ); // Push them all into the page object diff --git a/plugs/index/paragraph.ts b/plugs/index/paragraph.ts index 8de95c78..b1945aed 100644 --- a/plugs/index/paragraph.ts +++ b/plugs/index/paragraph.ts @@ -46,7 +46,7 @@ export async function indexParagraphs({ name: page, tree }: IndexTreeEvent) { }); // Extract attributes and remove from tree - const attrs = await extractAttributes(["paragraph", ...tags], p, true); + const attrs = await extractAttributes(["paragraph", ...tags], p); const text = renderToText(p); if (!text.trim()) { diff --git a/plugs/tasks/task.test.ts b/plugs/tasks/task.test.ts new file mode 100644 index 00000000..6ea416fd --- /dev/null +++ b/plugs/tasks/task.test.ts @@ -0,0 +1,51 @@ +import "../../plug-api/lib/syscall_mock.ts"; +import { parseMarkdown } from "$common/markdown_parser/parser.ts"; +import { extractTasks } from "./task.ts"; +import { extractItems } from "../index/item.ts"; +import { assertEquals } from "@std/assert"; + +const itemsMd = ` +* Item 1 #tag1 #tag2 [age: 100] + * [ ] Task 1 [age: 200] + * [ ] Task 2 #tag3 #tag1 + * [x] Task 2.1 +`; + +Deno.test("Test task extraction", async () => { + const t = parseMarkdown(itemsMd); + const tasks = await extractTasks("test", t); + const items = await extractItems("test", t); + + // Tasks are also indexed as items, because they are + assertEquals(items.length, 4); + + assertEquals(tasks.length, 3); + assertEquals(tasks[0].name, "Task 1"); + assertEquals(tasks[0].age, 200); + assertEquals(tasks[0].page, "test"); + assertEquals(tasks[0].text, "Task 1 [age: 200]"); + assertEquals(new Set(tasks[0].itags), new Set(["tag1", "tag2", "task"])); + assertEquals(tasks[0].parent, "test@1"); + assertEquals(tasks[1].name, "Task 2"); + // Don't inherit attributes + assertEquals(tasks[1].age, undefined); + // But inherit tags through itags, not tags + assertEquals( + new Set(tasks[1].tags), + new Set(["tag1", "tag3"]), + ); + assertEquals( + new Set(tasks[1].itags), + new Set(["tag1", "tag3", "task", "tag2"]), + ); + assertEquals(tasks[1].parent, "test@1"); + // Deeply + assertEquals(tasks[2].name, "Task 2.1"); + assertEquals(tasks[2].tags, []); + // Parent is * [ ] Task 2 #tag3 #tag1 list item + assertEquals(tasks[2].parent, items[2].ref); + assertEquals( + new Set(tasks[2].itags), + new Set(["tag1", "tag3", "task", "tag2"]), + ); +}); diff --git a/plugs/tasks/task.ts b/plugs/tasks/task.ts index 3421a359..e1cccf4d 100644 --- a/plugs/tasks/task.ts +++ b/plugs/tasks/task.ts @@ -21,16 +21,25 @@ import { traverseTreeAsync, } from "../../plug-api/lib/tree.ts"; import { niceDate } from "$lib/dates.ts"; -import { extractAttributes } from "@silverbulletmd/silverbullet/lib/attribute"; +import { + cleanAttributes, + extractAttributes, +} from "@silverbulletmd/silverbullet/lib/attribute"; import { rewritePageRefs } from "@silverbulletmd/silverbullet/lib/resolve"; import type { ObjectValue } from "../../plug-api/types.ts"; import { indexObjects, queryObjects } from "../index/plug_api.ts"; -import { updateITags } from "@silverbulletmd/silverbullet/lib/tags"; +import { + cleanHashTags, + extractHashTags, + updateITags, +} from "@silverbulletmd/silverbullet/lib/tags"; import { extractFrontmatter } from "@silverbulletmd/silverbullet/lib/frontmatter"; import { parsePageRef, positionOfLine, } from "@silverbulletmd/silverbullet/lib/page_ref"; +import { enrichItemFromParents } from "../index/item.ts"; +import { deepClone } from "@silverbulletmd/silverbullet/lib/json"; export type TaskObject = ObjectValue< { @@ -57,7 +66,10 @@ function getDeadline(deadlineNode: ParseTree): string { const completeStates = ["x", "X"]; const incompleteStates = [" "]; -export async function indexTasks({ name, tree }: IndexTreeEvent) { +export async function extractTasks( + name: string, + tree: ParseTree, +): Promise { const tasks: ObjectValue[] = []; const taskStates = new Map(); const frontmatter = await extractFrontmatter(tree); @@ -66,6 +78,7 @@ export async function indexTasks({ name, tree }: IndexTreeEvent) { if (n.type !== "Task") { return false; } + const listItemNode = n.parent!; const state = n.children![0].children![1].text!; if (!incompleteStates.includes(state) && !completeStates.includes(state)) { let currentState = taskStates.get(state); @@ -76,6 +89,7 @@ export async function indexTasks({ name, tree }: IndexTreeEvent) { currentState.count++; } const complete = completeStates.includes(state); + const task: TaskObject = { ref: `${name}@${n.from}`, tag: "task", @@ -99,30 +113,27 @@ export async function indexTasks({ name, tree }: IndexTreeEvent) { // Remove this node from the tree return null; } - if (tree.type === "Hashtag") { - // Push the tag to the list, removing the initial # - const tagName = tree.children![0].text!.substring(1); - if (!task.tags) { - task.tags = []; - } - task.tags.push(tagName); - tree.children = []; - } }); - // Extract attributes and remove from tree + // Extract tags and attributes + task.tags = extractHashTags(n); const extractedAttributes = await extractAttributes( ["task", ...task.tags || []], n, - true, ); - task.name = n.children!.slice(1).map(renderToText).join("").trim(); + + // Then clean them out + const clonedNode = deepClone(n, ["parent"]); + cleanHashTags(clonedNode); + cleanAttributes(clonedNode); + task.name = clonedNode.children!.slice(1).map(renderToText).join("").trim(); for (const [key, value] of Object.entries(extractedAttributes)) { task[key] = value; } updateITags(task, frontmatter); + await enrichItemFromParents(listItemNode, task, name, frontmatter); tasks.push(task); return true; @@ -141,10 +152,15 @@ export async function indexTasks({ name, tree }: IndexTreeEvent) { })), ); } + return tasks; +} + +export async function indexTasks({ name, tree }: IndexTreeEvent) { + const extractedTasks = await extractTasks(name, tree); // Index tasks themselves - if (tasks.length > 0) { - await indexObjects(name, tasks); + if (extractTasks.length > 0) { + await indexObjects(name, extractedTasks); } }