Redoing item and task indexing
parent
0c4838e45c
commit
8bc7dee75e
|
@ -1,11 +1,10 @@
|
||||||
import { parse } from "./parse_tree.ts";
|
|
||||||
import {
|
import {
|
||||||
collectNodesOfType,
|
collectNodesOfType,
|
||||||
findNodeOfType,
|
findNodeOfType,
|
||||||
renderToText,
|
renderToText,
|
||||||
} from "@silverbulletmd/silverbullet/lib/tree";
|
} from "@silverbulletmd/silverbullet/lib/tree";
|
||||||
import { assertEquals, assertNotEquals } from "@std/assert";
|
import { assert, assertEquals, assertNotEquals } from "@std/assert";
|
||||||
import { extendedMarkdownLanguage } from "./parser.ts";
|
import { parseMarkdown } from "./parser.ts";
|
||||||
|
|
||||||
const sample1 = `---
|
const sample1 = `---
|
||||||
type: page
|
type: page
|
||||||
|
@ -26,7 +25,7 @@ name: Zef
|
||||||
Supper`;
|
Supper`;
|
||||||
|
|
||||||
Deno.test("Test parser", () => {
|
Deno.test("Test parser", () => {
|
||||||
let tree = parse(extendedMarkdownLanguage, sample1);
|
let tree = parseMarkdown(sample1);
|
||||||
// console.log("tree", JSON.stringify(tree, null, 2));
|
// console.log("tree", JSON.stringify(tree, null, 2));
|
||||||
// Check if rendering back to text works
|
// Check if rendering back to text works
|
||||||
assertEquals(renderToText(tree), sample1);
|
assertEquals(renderToText(tree), sample1);
|
||||||
|
@ -44,7 +43,7 @@ Deno.test("Test parser", () => {
|
||||||
// Find frontmatter
|
// Find frontmatter
|
||||||
let node = findNodeOfType(tree, "FrontMatter");
|
let node = findNodeOfType(tree, "FrontMatter");
|
||||||
assertNotEquals(node, undefined);
|
assertNotEquals(node, undefined);
|
||||||
tree = parse(extendedMarkdownLanguage, sampleInvalid1);
|
tree = parseMarkdown(sampleInvalid1);
|
||||||
node = findNodeOfType(tree, "FrontMatter");
|
node = findNodeOfType(tree, "FrontMatter");
|
||||||
// console.log("Invalid node", node);
|
// console.log("Invalid node", node);
|
||||||
assertEquals(node, undefined);
|
assertEquals(node, undefined);
|
||||||
|
@ -61,7 +60,7 @@ And one with nested brackets: [array: [1, 2, 3]]
|
||||||
`;
|
`;
|
||||||
|
|
||||||
Deno.test("Test inline attribute syntax", () => {
|
Deno.test("Test inline attribute syntax", () => {
|
||||||
const tree = parse(extendedMarkdownLanguage, inlineAttributeSample);
|
const tree = parseMarkdown(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");
|
||||||
|
@ -81,11 +80,8 @@ Deno.test("Test inline attribute syntax", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("Test template directive parsing", () => {
|
Deno.test("Test template directive parsing", () => {
|
||||||
const tree = parse(
|
const tree = parseMarkdown("Simple {{name}} and {{count({ page })}}");
|
||||||
extendedMarkdownLanguage,
|
assert(findNodeOfType(tree, "TemplateDirective"));
|
||||||
"Simple {{name}} and {{count({ page })}}",
|
|
||||||
);
|
|
||||||
console.log("Template directive", JSON.stringify(tree, null, 2));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const multiStatusTaskExample = `
|
const multiStatusTaskExample = `
|
||||||
|
@ -95,7 +91,7 @@ const multiStatusTaskExample = `
|
||||||
`;
|
`;
|
||||||
|
|
||||||
Deno.test("Test multi-status tasks", () => {
|
Deno.test("Test multi-status tasks", () => {
|
||||||
const tree = parse(extendedMarkdownLanguage, multiStatusTaskExample);
|
const tree = parseMarkdown(multiStatusTaskExample);
|
||||||
// console.log("Tasks parsed", JSON.stringify(tree, null, 2));
|
// console.log("Tasks parsed", JSON.stringify(tree, null, 2));
|
||||||
const tasks = collectNodesOfType(tree, "Task");
|
const tasks = collectNodesOfType(tree, "Task");
|
||||||
assertEquals(tasks.length, 3);
|
assertEquals(tasks.length, 3);
|
||||||
|
@ -112,7 +108,7 @@ const commandLinkSample = `
|
||||||
`;
|
`;
|
||||||
|
|
||||||
Deno.test("Test command links", () => {
|
Deno.test("Test command links", () => {
|
||||||
const tree = parse(extendedMarkdownLanguage, commandLinkSample);
|
const tree = parseMarkdown(commandLinkSample);
|
||||||
const commands = collectNodesOfType(tree, "CommandLink");
|
const commands = collectNodesOfType(tree, "CommandLink");
|
||||||
// console.log("Command links parsed", JSON.stringify(commands, null, 2));
|
// console.log("Command links parsed", JSON.stringify(commands, null, 2));
|
||||||
assertEquals(commands.length, 3);
|
assertEquals(commands.length, 3);
|
||||||
|
@ -129,7 +125,7 @@ const commandLinkArgsSample = `
|
||||||
`;
|
`;
|
||||||
|
|
||||||
Deno.test("Test command link arguments", () => {
|
Deno.test("Test command link arguments", () => {
|
||||||
const tree = parse(extendedMarkdownLanguage, commandLinkArgsSample);
|
const tree = parseMarkdown(commandLinkArgsSample);
|
||||||
const commands = collectNodesOfType(tree, "CommandLink");
|
const commands = collectNodesOfType(tree, "CommandLink");
|
||||||
assertEquals(commands.length, 2);
|
assertEquals(commands.length, 2);
|
||||||
|
|
||||||
|
@ -142,22 +138,22 @@ Deno.test("Test command link arguments", () => {
|
||||||
|
|
||||||
Deno.test("Test directive parser", () => {
|
Deno.test("Test directive parser", () => {
|
||||||
const simpleExample = `Simple {{.}}`;
|
const simpleExample = `Simple {{.}}`;
|
||||||
let tree = parse(extendedMarkdownLanguage, simpleExample);
|
let tree = parseMarkdown(simpleExample);
|
||||||
assertEquals(renderToText(tree), simpleExample);
|
assertEquals(renderToText(tree), simpleExample);
|
||||||
|
|
||||||
const eachExample = `{{#each .}}Sup{{/each}}`;
|
const eachExample = `{{#each .}}Sup{{/each}}`;
|
||||||
tree = parse(extendedMarkdownLanguage, eachExample);
|
tree = parseMarkdown(eachExample);
|
||||||
|
|
||||||
const ifExample = `{{#if true}}Sup{{/if}}`;
|
const ifExample = `{{#if true}}Sup{{/if}}`;
|
||||||
tree = parse(extendedMarkdownLanguage, ifExample);
|
tree = parseMarkdown(ifExample);
|
||||||
assertEquals(renderToText(tree), ifExample);
|
assertEquals(renderToText(tree), ifExample);
|
||||||
|
|
||||||
const ifElseExample = `{{#if true}}Sup{{else}}Sup2{{/if}}`;
|
const ifElseExample = `{{#if true}}Sup{{else}}Sup2{{/if}}`;
|
||||||
tree = parse(extendedMarkdownLanguage, ifElseExample);
|
tree = parseMarkdown(ifElseExample);
|
||||||
assertEquals(renderToText(tree), 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}}`;
|
const letExample = `{{#let @p = true}}{{/let}}`;
|
||||||
tree = parse(extendedMarkdownLanguage, letExample);
|
tree = parseMarkdown(letExample);
|
||||||
assertEquals(renderToText(tree), letExample);
|
assertEquals(renderToText(tree), letExample);
|
||||||
});
|
});
|
||||||
|
|
|
@ -541,6 +541,8 @@ const NamedAnchor = regexParser({
|
||||||
import { Table } from "./table_parser.ts";
|
import { Table } from "./table_parser.ts";
|
||||||
import { foldNodeProp } from "@codemirror/language";
|
import { foldNodeProp } from "@codemirror/language";
|
||||||
import { pWikiLinkRegex, tagRegex } from "$common/markdown_parser/constants.ts";
|
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
|
// FrontMatter parser
|
||||||
|
|
||||||
|
@ -661,3 +663,7 @@ export const extendedMarkdownLanguage = markdown({
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}).language;
|
}).language;
|
||||||
|
|
||||||
|
export function parseMarkdown(text: string): ParseTree {
|
||||||
|
return parse(extendedMarkdownLanguage, text);
|
||||||
|
}
|
||||||
|
|
|
@ -1,6 +1,9 @@
|
||||||
import "./syscall_mock.ts";
|
import "./syscall_mock.ts";
|
||||||
import { parse } from "$common/markdown_parser/parse_tree.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 { assertEquals } from "@std/assert";
|
||||||
import { renderToText } from "./tree.ts";
|
import { renderToText } from "./tree.ts";
|
||||||
import { extendedMarkdownLanguage } from "$common/markdown_parser/parser.ts";
|
import { extendedMarkdownLanguage } from "$common/markdown_parser/parser.ts";
|
||||||
|
@ -19,22 +22,22 @@ const cleanedInlineAttributeSample = `
|
||||||
# My document
|
# My document
|
||||||
Top level attributes:
|
Top level attributes:
|
||||||
|
|
||||||
* [ ] Attribute in a task [tag:: foo]
|
* [ ] Attribute in a task
|
||||||
* Regular item [tag:: bar]
|
* Regular item
|
||||||
|
|
||||||
1. Itemized list [tag:: baz]
|
1. Itemized list
|
||||||
`;
|
`;
|
||||||
|
|
||||||
Deno.test("Test attribute extraction", async () => {
|
Deno.test("Test attribute extraction", async () => {
|
||||||
const tree = parse(extendedMarkdownLanguage, inlineAttributeSample);
|
const tree = parse(extendedMarkdownLanguage, inlineAttributeSample);
|
||||||
const toplevelAttributes = await extractAttributes(["test"], tree, false);
|
const toplevelAttributes = await extractAttributes(["test"], tree);
|
||||||
// console.log("All attributes", toplevelAttributes);
|
// console.log("All attributes", toplevelAttributes);
|
||||||
assertEquals(toplevelAttributes.name, "sup");
|
assertEquals(toplevelAttributes.name, "sup");
|
||||||
assertEquals(toplevelAttributes.age, 42);
|
assertEquals(toplevelAttributes.age, 42);
|
||||||
assertEquals(toplevelAttributes.children, ["pete", "john", "mary"]);
|
assertEquals(toplevelAttributes.children, ["pete", "john", "mary"]);
|
||||||
// Check if the attributes are still there
|
// Check if the attributes are still there
|
||||||
assertEquals(renderToText(tree), inlineAttributeSample);
|
assertEquals(renderToText(tree), inlineAttributeSample);
|
||||||
// Now once again with cleaning
|
// And now clean
|
||||||
await extractAttributes(["test"], tree, true);
|
cleanAttributes(tree);
|
||||||
assertEquals(renderToText(tree), cleanedInlineAttributeSample);
|
assertEquals(renderToText(tree), cleanedInlineAttributeSample);
|
||||||
});
|
});
|
||||||
|
|
|
@ -2,7 +2,8 @@ import {
|
||||||
findNodeOfType,
|
findNodeOfType,
|
||||||
type ParseTree,
|
type ParseTree,
|
||||||
renderToText,
|
renderToText,
|
||||||
replaceNodesMatchingAsync,
|
replaceNodesMatching,
|
||||||
|
traverseTreeAsync,
|
||||||
} from "./tree.ts";
|
} from "./tree.ts";
|
||||||
|
|
||||||
import { cleanupJSON } from "@silverbulletmd/silverbullet/lib/json";
|
import { cleanupJSON } from "@silverbulletmd/silverbullet/lib/json";
|
||||||
|
@ -10,21 +11,19 @@ import { cleanupJSON } from "@silverbulletmd/silverbullet/lib/json";
|
||||||
import { system, YAML } from "../syscalls.ts";
|
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 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
|
* @returns mapping from attribute name to attribute value
|
||||||
*/
|
*/
|
||||||
export async function extractAttributes(
|
export async function extractAttributes(
|
||||||
tags: string[],
|
tags: string[],
|
||||||
tree: ParseTree,
|
tree: ParseTree,
|
||||||
clean: boolean,
|
|
||||||
): Promise<Record<string, any>> {
|
): Promise<Record<string, any>> {
|
||||||
let attributes: Record<string, any> = {};
|
let attributes: Record<string, any> = {};
|
||||||
await replaceNodesMatchingAsync(tree, async (n) => {
|
await traverseTreeAsync(tree, async (n) => {
|
||||||
if (n.type === "ListItem") {
|
if (tree !== n && n.type === "ListItem") {
|
||||||
// Find top-level only, no nested lists
|
// Find top-level only, no nested lists
|
||||||
return n;
|
return true;
|
||||||
}
|
}
|
||||||
if (n.type === "Attribute") {
|
if (n.type === "Attribute") {
|
||||||
const nameNode = findNodeOfType(n, "AttributeName");
|
const nameNode = findNodeOfType(n, "AttributeName");
|
||||||
|
@ -38,15 +37,10 @@ export async function extractAttributes(
|
||||||
console.error("Error parsing attribute value as YAML", val, e);
|
console.error("Error parsing attribute value as YAML", val, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Remove from tree
|
return true;
|
||||||
if (clean) {
|
|
||||||
return null;
|
|
||||||
} else {
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Go on...
|
// Go on...
|
||||||
return undefined;
|
return false;
|
||||||
});
|
});
|
||||||
const text = renderToText(tree);
|
const text = renderToText(tree);
|
||||||
const spaceScriptAttributes = await system.applyAttributeExtractors(
|
const spaceScriptAttributes = await system.applyAttributeExtractors(
|
||||||
|
@ -60,3 +54,16 @@ export async function extractAttributes(
|
||||||
};
|
};
|
||||||
return attributes;
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
@ -9,7 +9,10 @@ import {
|
||||||
* Feed parsing functionality (WIP)
|
* Feed parsing functionality (WIP)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { extractAttributes } from "@silverbulletmd/silverbullet/lib/attribute";
|
import {
|
||||||
|
cleanAttributes,
|
||||||
|
extractAttributes,
|
||||||
|
} from "@silverbulletmd/silverbullet/lib/attribute";
|
||||||
|
|
||||||
export type FeedItem = {
|
export type FeedItem = {
|
||||||
id: string;
|
id: string;
|
||||||
|
@ -29,7 +32,6 @@ export async function extractFeedItems(tree: ParseTree): Promise<FeedItem[]> {
|
||||||
for (const node of tree.children!) {
|
for (const node of tree.children!) {
|
||||||
if (node.type === "FrontMatter") {
|
if (node.type === "FrontMatter") {
|
||||||
// Not interested
|
// Not interested
|
||||||
console.log("Ignoring", node);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (node.type === "HorizontalRule") {
|
if (node.type === "HorizontalRule") {
|
||||||
|
@ -51,7 +53,8 @@ async function nodesToFeedItem(nodes: ParseTree[]): Promise<FeedItem> {
|
||||||
const wrapperNode: ParseTree = {
|
const wrapperNode: ParseTree = {
|
||||||
children: nodes,
|
children: nodes,
|
||||||
};
|
};
|
||||||
const attributes = await extractAttributes(["feed"], wrapperNode, true);
|
const attributes = await extractAttributes(["feed"], wrapperNode);
|
||||||
|
cleanAttributes(wrapperNode);
|
||||||
let id = attributes.id;
|
let id = attributes.id;
|
||||||
delete attributes.id;
|
delete attributes.id;
|
||||||
if (!id) {
|
if (!id) {
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { assertEquals } from "@std/assert";
|
import { assertEquals } from "@std/assert";
|
||||||
import { cleanupJSON, deepEqual, deepObjectMerge } from "./json.ts";
|
import { cleanupJSON, deepClone, deepEqual, deepObjectMerge } from "./json.ts";
|
||||||
|
|
||||||
Deno.test("JSON utils", () => {
|
Deno.test("JSON utils", () => {
|
||||||
assertEquals(deepEqual({ a: 1 }, { a: 1 }), true);
|
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");
|
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
|
||||||
|
});
|
||||||
|
|
|
@ -139,3 +139,39 @@ export function deepObjectMerge(a: any, b: any, reverseArrays = false): any {
|
||||||
}
|
}
|
||||||
return b;
|
return b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function deepClone<T>(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.");
|
||||||
|
}
|
||||||
|
|
|
@ -1,5 +1,9 @@
|
||||||
import type { FrontMatter } from "./frontmatter.ts";
|
import type { FrontMatter } from "./frontmatter.ts";
|
||||||
import type { ObjectValue } from "../types.ts";
|
import type { ObjectValue } from "../types.ts";
|
||||||
|
import {
|
||||||
|
type ParseTree,
|
||||||
|
traverseTree,
|
||||||
|
} from "@silverbulletmd/silverbullet/lib/tree";
|
||||||
|
|
||||||
export function updateITags<T>(obj: ObjectValue<T>, frontmatter: FrontMatter) {
|
export function updateITags<T>(obj: ObjectValue<T>, frontmatter: FrontMatter) {
|
||||||
const itags = [obj.tag, ...frontmatter.tags || []];
|
const itags = [obj.tag, ...frontmatter.tags || []];
|
||||||
|
@ -12,3 +16,37 @@ export function updateITags<T>(obj: ObjectValue<T>, frontmatter: FrontMatter) {
|
||||||
}
|
}
|
||||||
obj.itags = itags;
|
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<string>();
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
@ -43,7 +43,6 @@ export async function indexHeaders({ name: pageName, tree }: IndexTreeEvent) {
|
||||||
const extractedAttributes = await extractAttributes(
|
const extractedAttributes = await extractAttributes(
|
||||||
["header", ...tags],
|
["header", ...tags],
|
||||||
n,
|
n,
|
||||||
true,
|
|
||||||
);
|
);
|
||||||
const name = n.children!.slice(1).map(renderToText).join("").trim();
|
const name = n.children!.slice(1).map(renderToText).join("").trim();
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
|
@ -1,16 +1,27 @@
|
||||||
import type { IndexTreeEvent } from "../../plug-api/types.ts";
|
import type { IndexTreeEvent, ObjectValue } from "../../plug-api/types.ts";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
collectNodesOfType,
|
findParentMatching,
|
||||||
type ParseTree,
|
type ParseTree,
|
||||||
renderToText,
|
renderToText,
|
||||||
} from "../../plug-api/lib/tree.ts";
|
traverseTreeAsync,
|
||||||
import { extractAttributes } from "@silverbulletmd/silverbullet/lib/attribute";
|
} from "@silverbulletmd/silverbullet/lib/tree";
|
||||||
|
import {
|
||||||
|
cleanAttributes,
|
||||||
|
extractAttributes,
|
||||||
|
} from "@silverbulletmd/silverbullet/lib/attribute";
|
||||||
import { rewritePageRefs } from "@silverbulletmd/silverbullet/lib/resolve";
|
import { rewritePageRefs } from "@silverbulletmd/silverbullet/lib/resolve";
|
||||||
import type { ObjectValue } from "../../plug-api/types.ts";
|
|
||||||
import { indexObjects } from "./api.ts";
|
import { indexObjects } from "./api.ts";
|
||||||
import { updateITags } from "@silverbulletmd/silverbullet/lib/tags";
|
import {
|
||||||
import { extractFrontmatter } from "@silverbulletmd/silverbullet/lib/frontmatter";
|
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<
|
export type ItemObject = ObjectValue<
|
||||||
{
|
{
|
||||||
|
@ -22,73 +33,127 @@ export type ItemObject = ObjectValue<
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export async function indexItems({ name, tree }: IndexTreeEvent) {
|
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<ItemObject>[] = [];
|
const items: ObjectValue<ItemObject>[] = [];
|
||||||
|
|
||||||
const frontmatter = await extractFrontmatter(tree);
|
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) {
|
if (!n.children) {
|
||||||
continue;
|
// Weird, let's jump out
|
||||||
}
|
return true;
|
||||||
if (collectNodesOfType(n, "Task").length > 0) {
|
|
||||||
// This is a task item, skip it
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const tags = new Set<string>();
|
const item: ItemObject = await extractItemFromNode(
|
||||||
const item: ItemObject = {
|
name,
|
||||||
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],
|
|
||||||
n,
|
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);
|
items.push(item);
|
||||||
}
|
|
||||||
// console.log("Found", items, "item(s)");
|
return false;
|
||||||
await indexObjects(name, items);
|
});
|
||||||
|
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<any>,
|
||||||
|
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",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,7 +27,6 @@ export async function indexPage({ name, tree }: IndexTreeEvent) {
|
||||||
const toplevelAttributes = await extractAttributes(
|
const toplevelAttributes = await extractAttributes(
|
||||||
["page", ...frontmatter.tags || []],
|
["page", ...frontmatter.tags || []],
|
||||||
tree,
|
tree,
|
||||||
false,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Push them all into the page object
|
// Push them all into the page object
|
||||||
|
|
|
@ -46,7 +46,7 @@ export async function indexParagraphs({ name: page, tree }: IndexTreeEvent) {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Extract attributes and remove from tree
|
// 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);
|
const text = renderToText(p);
|
||||||
|
|
||||||
if (!text.trim()) {
|
if (!text.trim()) {
|
||||||
|
|
|
@ -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"]),
|
||||||
|
);
|
||||||
|
});
|
|
@ -21,16 +21,25 @@ import {
|
||||||
traverseTreeAsync,
|
traverseTreeAsync,
|
||||||
} from "../../plug-api/lib/tree.ts";
|
} from "../../plug-api/lib/tree.ts";
|
||||||
import { niceDate } from "$lib/dates.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 { rewritePageRefs } from "@silverbulletmd/silverbullet/lib/resolve";
|
||||||
import type { ObjectValue } from "../../plug-api/types.ts";
|
import type { ObjectValue } from "../../plug-api/types.ts";
|
||||||
import { indexObjects, queryObjects } from "../index/plug_api.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 { extractFrontmatter } from "@silverbulletmd/silverbullet/lib/frontmatter";
|
||||||
import {
|
import {
|
||||||
parsePageRef,
|
parsePageRef,
|
||||||
positionOfLine,
|
positionOfLine,
|
||||||
} from "@silverbulletmd/silverbullet/lib/page_ref";
|
} from "@silverbulletmd/silverbullet/lib/page_ref";
|
||||||
|
import { enrichItemFromParents } from "../index/item.ts";
|
||||||
|
import { deepClone } from "@silverbulletmd/silverbullet/lib/json";
|
||||||
|
|
||||||
export type TaskObject = ObjectValue<
|
export type TaskObject = ObjectValue<
|
||||||
{
|
{
|
||||||
|
@ -57,7 +66,10 @@ function getDeadline(deadlineNode: ParseTree): string {
|
||||||
const completeStates = ["x", "X"];
|
const completeStates = ["x", "X"];
|
||||||
const incompleteStates = [" "];
|
const incompleteStates = [" "];
|
||||||
|
|
||||||
export async function indexTasks({ name, tree }: IndexTreeEvent) {
|
export async function extractTasks(
|
||||||
|
name: string,
|
||||||
|
tree: ParseTree,
|
||||||
|
): Promise<TaskObject[]> {
|
||||||
const tasks: ObjectValue<TaskObject>[] = [];
|
const tasks: ObjectValue<TaskObject>[] = [];
|
||||||
const taskStates = new Map<string, { count: number; firstPos: number }>();
|
const taskStates = new Map<string, { count: number; firstPos: number }>();
|
||||||
const frontmatter = await extractFrontmatter(tree);
|
const frontmatter = await extractFrontmatter(tree);
|
||||||
|
@ -66,6 +78,7 @@ export async function indexTasks({ name, tree }: IndexTreeEvent) {
|
||||||
if (n.type !== "Task") {
|
if (n.type !== "Task") {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
const listItemNode = n.parent!;
|
||||||
const state = n.children![0].children![1].text!;
|
const state = n.children![0].children![1].text!;
|
||||||
if (!incompleteStates.includes(state) && !completeStates.includes(state)) {
|
if (!incompleteStates.includes(state) && !completeStates.includes(state)) {
|
||||||
let currentState = taskStates.get(state);
|
let currentState = taskStates.get(state);
|
||||||
|
@ -76,6 +89,7 @@ export async function indexTasks({ name, tree }: IndexTreeEvent) {
|
||||||
currentState.count++;
|
currentState.count++;
|
||||||
}
|
}
|
||||||
const complete = completeStates.includes(state);
|
const complete = completeStates.includes(state);
|
||||||
|
|
||||||
const task: TaskObject = {
|
const task: TaskObject = {
|
||||||
ref: `${name}@${n.from}`,
|
ref: `${name}@${n.from}`,
|
||||||
tag: "task",
|
tag: "task",
|
||||||
|
@ -99,30 +113,27 @@ export async function indexTasks({ name, tree }: IndexTreeEvent) {
|
||||||
// Remove this node from the tree
|
// Remove this node from the tree
|
||||||
return null;
|
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(
|
const extractedAttributes = await extractAttributes(
|
||||||
["task", ...task.tags || []],
|
["task", ...task.tags || []],
|
||||||
n,
|
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)) {
|
for (const [key, value] of Object.entries(extractedAttributes)) {
|
||||||
task[key] = value;
|
task[key] = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateITags(task, frontmatter);
|
updateITags(task, frontmatter);
|
||||||
|
await enrichItemFromParents(listItemNode, task, name, frontmatter);
|
||||||
|
|
||||||
tasks.push(task);
|
tasks.push(task);
|
||||||
return true;
|
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
|
// Index tasks themselves
|
||||||
if (tasks.length > 0) {
|
if (extractTasks.length > 0) {
|
||||||
await indexObjects(name, tasks);
|
await indexObjects(name, extractedTasks);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue