Complete redo of content indexing and querying (#517)
Complete redo of data store Introduces live queries and live templates
This commit is contained in:
@@ -7,6 +7,7 @@ export const builtinPlugNames = [
|
||||
"plug-manager",
|
||||
"directive",
|
||||
"emoji",
|
||||
"query",
|
||||
"markdown",
|
||||
"share",
|
||||
"tasks",
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { editor, markdown, mq, space, sync } from "$sb/syscalls.ts";
|
||||
import {
|
||||
addParentPointers,
|
||||
findParentMatching,
|
||||
nodeAtPos,
|
||||
ParseTree,
|
||||
removeParentPointers,
|
||||
renderToText,
|
||||
@@ -7,9 +10,8 @@ import {
|
||||
} from "$sb/lib/tree.ts";
|
||||
import { renderDirectives } from "./directives.ts";
|
||||
import { extractFrontmatter } from "$sb/lib/frontmatter.ts";
|
||||
import type { PageMeta } from "../../web/types.ts";
|
||||
import { isFederationPath } from "$sb/lib/resolve.ts";
|
||||
import { MQMessage } from "$sb/types.ts";
|
||||
import { MQMessage, PageMeta } from "$sb/types.ts";
|
||||
import { sleep } from "$sb/lib/async.ts";
|
||||
|
||||
const directiveUpdateQueueName = "directiveUpdateQueue";
|
||||
@@ -200,3 +202,30 @@ export async function updateDirectives(
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
export async function convertToLiveQuery() {
|
||||
const text = await editor.getText();
|
||||
const pos = await editor.getCursor();
|
||||
const tree = await markdown.parseMarkdown(text);
|
||||
addParentPointers(tree);
|
||||
const currentNode = nodeAtPos(tree, pos);
|
||||
const directive = findParentMatching(
|
||||
currentNode!,
|
||||
(node) => node.type === "Directive",
|
||||
);
|
||||
if (!directive) {
|
||||
await editor.flashNotification(
|
||||
"No directive found at cursor position",
|
||||
"error",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const queryText = renderToText(directive!.children![0].children![1]);
|
||||
await editor.dispatch({
|
||||
changes: {
|
||||
from: directive.from,
|
||||
to: directive.to,
|
||||
insert: "```query\n" + queryText + "\n```",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
+23
-10
@@ -1,11 +1,11 @@
|
||||
import { events } from "$sb/syscalls.ts";
|
||||
import { CompleteEvent } from "$sb/app_event.ts";
|
||||
import { buildHandebarOptions } from "./util.ts";
|
||||
import type { PageMeta } from "../../web/types.ts";
|
||||
import type {
|
||||
AttributeCompleteEvent,
|
||||
AttributeCompletion,
|
||||
} from "../index/attributes.ts";
|
||||
import { PageMeta } from "$sb/types.ts";
|
||||
|
||||
export async function queryComplete(completeEvent: CompleteEvent) {
|
||||
const querySourceMatch = /#query\s+([\w\-_]*)$/.exec(
|
||||
@@ -14,18 +14,31 @@ export async function queryComplete(completeEvent: CompleteEvent) {
|
||||
if (querySourceMatch) {
|
||||
const allEvents = await events.listEvents();
|
||||
|
||||
const completionOptions = allEvents
|
||||
.filter((eventName) =>
|
||||
eventName.startsWith("query:") && !eventName.includes("*")
|
||||
)
|
||||
.map((source) => ({
|
||||
label: source.substring("query:".length),
|
||||
}));
|
||||
|
||||
const allObjectTypes: string[] = (await events.dispatchEvent("query_", {}))
|
||||
.flat();
|
||||
|
||||
for (const type of allObjectTypes) {
|
||||
completionOptions.push({
|
||||
label: type,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
from: completeEvent.pos - querySourceMatch[1].length,
|
||||
options: allEvents
|
||||
.filter((eventName) => eventName.startsWith("query:"))
|
||||
.map((source) => ({
|
||||
label: source.substring("query:".length),
|
||||
})),
|
||||
options: completionOptions,
|
||||
};
|
||||
}
|
||||
|
||||
if (completeEvent.parentNodes.includes("DirectiveStart")) {
|
||||
const querySourceMatch = /#query\s+([\w\-_]+)/.exec(
|
||||
const querySourceMatch = /#query\s+([\w\-_\/]+)/.exec(
|
||||
completeEvent.linePrefix,
|
||||
);
|
||||
const whereMatch =
|
||||
@@ -69,9 +82,9 @@ export async function templateVariableComplete(completeEvent: CompleteEvent) {
|
||||
);
|
||||
|
||||
const completions = (await events.dispatchEvent(
|
||||
`attribute:complete:*`,
|
||||
`attribute:complete:_`,
|
||||
{
|
||||
source: "*",
|
||||
source: "",
|
||||
prefix: match[1],
|
||||
} as AttributeCompleteEvent,
|
||||
)).flat() as AttributeCompletion[];
|
||||
@@ -92,7 +105,7 @@ export function attributeCompletionsToCMCompletion(
|
||||
return completions.map(
|
||||
(completion) => ({
|
||||
label: completion.name,
|
||||
detail: `${completion.type} (${completion.source})`,
|
||||
detail: `${completion.attributeType} (${completion.source})`,
|
||||
type: "attribute",
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -20,14 +20,6 @@ functions:
|
||||
mqSubscriptions:
|
||||
- queue: directiveUpdateQueue
|
||||
batchSize: 3
|
||||
indexData:
|
||||
path: ./data.ts:indexData
|
||||
events:
|
||||
- page:index
|
||||
dataQueryProvider:
|
||||
path: ./data.ts:queryProvider
|
||||
events:
|
||||
- query:data
|
||||
queryComplete:
|
||||
path: ./complete.ts:queryComplete
|
||||
events:
|
||||
@@ -37,43 +29,13 @@ functions:
|
||||
events:
|
||||
- editor:complete
|
||||
|
||||
# Conversion
|
||||
convertToLiveQuery:
|
||||
path: command.ts:convertToLiveQuery
|
||||
command:
|
||||
name: "Directive: Convert Query to Live Query"
|
||||
|
||||
# Templates
|
||||
insertQuery:
|
||||
redirect: template.insertTemplateText
|
||||
slashCommand:
|
||||
name: query
|
||||
description: Insert a query
|
||||
value: |
|
||||
<!-- #query |^| -->
|
||||
|
||||
<!-- /query -->
|
||||
insertInclude:
|
||||
redirect: template.insertTemplateText
|
||||
slashCommand:
|
||||
name: include
|
||||
description: Include another page
|
||||
value: |
|
||||
<!-- #include [[|^|]] -->
|
||||
|
||||
<!-- /include -->
|
||||
insertUseTemplate:
|
||||
redirect: template.insertTemplateText
|
||||
slashCommand:
|
||||
name: use
|
||||
description: Use a template
|
||||
value: |
|
||||
<!-- #use [[|^|]] {} -->
|
||||
|
||||
<!-- /use -->
|
||||
insertUseVerboseTemplate:
|
||||
redirect: template.insertTemplateText
|
||||
slashCommand:
|
||||
name: use-verbose
|
||||
description: Use a template (verbose mode)
|
||||
value: |
|
||||
<!-- #use-verbose [[|^|]] {} -->
|
||||
|
||||
<!-- /use-verbose -->
|
||||
insertEvalTemplate:
|
||||
redirect: template.insertTemplateText
|
||||
slashCommand:
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { ParseTree, renderToText } from "$sb/lib/tree.ts";
|
||||
import { PageMeta } from "../../web/types.ts";
|
||||
import {
|
||||
addParentPointers,
|
||||
findParentMatching,
|
||||
ParseTree,
|
||||
renderToText,
|
||||
} from "$sb/lib/tree.ts";
|
||||
import { PageMeta } from "$sb/types.ts";
|
||||
import { editor, markdown } from "$sb/syscalls.ts";
|
||||
|
||||
import { evalDirectiveRenderer } from "./eval_directive.ts";
|
||||
import { queryDirectiveRenderer } from "./query_directive.ts";
|
||||
@@ -53,8 +59,29 @@ export async function directiveDispatcher(
|
||||
const directiveStartText = renderToText(directiveStart).trim();
|
||||
const directiveEndText = renderToText(directiveEnd).trim();
|
||||
|
||||
if (directiveStart.children!.length === 1) {
|
||||
// Everything not #query
|
||||
const firstPart = directiveStart.children![0].text!;
|
||||
if (firstPart?.includes("#query")) {
|
||||
// #query
|
||||
const newBody = await directiveRenderers["query"](
|
||||
"query",
|
||||
pageMeta,
|
||||
directiveStart.children![1].children![0], // The query ParseTree
|
||||
);
|
||||
const result =
|
||||
`${directiveStartText}\n${newBody.trim()}\n${directiveEndText}`;
|
||||
return result;
|
||||
} else if (firstPart?.includes("#eval")) {
|
||||
console.log("Eval stuff", directiveStart.children![1].children![0]);
|
||||
const newBody = await directiveRenderers["eval"](
|
||||
"eval",
|
||||
pageMeta,
|
||||
directiveStart.children![1].children![0],
|
||||
);
|
||||
const result =
|
||||
`${directiveStartText}\n${newBody.trim()}\n${directiveEndText}`;
|
||||
return result;
|
||||
} else {
|
||||
// Everything not #query and #eval
|
||||
const match = directiveStartRegex.exec(directiveStart.children![0].text!);
|
||||
if (!match) {
|
||||
throw Error("No match");
|
||||
@@ -70,16 +97,6 @@ export async function directiveDispatcher(
|
||||
} catch (e: any) {
|
||||
return `${directiveStartText}\n**ERROR:** ${e.message}\n${directiveEndText}`;
|
||||
}
|
||||
} else {
|
||||
// #query
|
||||
const newBody = await directiveRenderers["query"](
|
||||
"query",
|
||||
pageMeta,
|
||||
directiveStart.children![1], // The query ParseTree
|
||||
);
|
||||
const result =
|
||||
`${directiveStartText}\n${newBody.trim()}\n${directiveEndText}`;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,23 +1,9 @@
|
||||
// This is some shocking stuff. My profession would kill me for this.
|
||||
|
||||
import { YAML } from "$sb/syscalls.ts";
|
||||
import { ParseTree } from "$sb/lib/tree.ts";
|
||||
import { jsonToMDTable, renderTemplate } from "./util.ts";
|
||||
import type { PageMeta } from "../../web/types.ts";
|
||||
import { ParseTree, parseTreeToAST } from "$sb/lib/tree.ts";
|
||||
import { replaceTemplateVars } from "../template/template.ts";
|
||||
|
||||
// Enables plugName.functionName(arg1, arg2) syntax in JS expressions
|
||||
function translateJs(js: string): string {
|
||||
return js.replaceAll(
|
||||
/(\w+\.\w+)\s*\(/g,
|
||||
'await invokeFunction("$1", ',
|
||||
);
|
||||
}
|
||||
|
||||
// Syntaxes to support:
|
||||
// - random JS expression
|
||||
// - random JS expression render [[some/template]]
|
||||
const expressionRegex = /(.+?)(\s+render\s+\[\[([^\]]+)\]\])?$/;
|
||||
import { PageMeta } from "$sb/types.ts";
|
||||
import { expressionToKvQueryExpression } from "$sb/lib/parse-query.ts";
|
||||
import { evalQueryExpression } from "$sb/lib/query.ts";
|
||||
import { builtinFunctions } from "$sb/lib/builtin_query_functions.ts";
|
||||
|
||||
// This is rather scary and fragile stuff, but it works.
|
||||
export async function evalDirectiveRenderer(
|
||||
@@ -25,42 +11,19 @@ export async function evalDirectiveRenderer(
|
||||
pageMeta: PageMeta,
|
||||
expression: string | ParseTree,
|
||||
): Promise<string> {
|
||||
if (typeof expression !== "string") {
|
||||
throw new Error("Expected a string");
|
||||
}
|
||||
// console.log("Got JS expression", expression);
|
||||
const match = expressionRegex.exec(expression);
|
||||
if (!match) {
|
||||
throw new Error(`Invalid eval directive: ${expression}`);
|
||||
}
|
||||
let template = "";
|
||||
if (match[3]) {
|
||||
// This is the template reference
|
||||
expression = match[1];
|
||||
template = match[3];
|
||||
}
|
||||
try {
|
||||
// Why the weird "eval" call? https://esbuild.github.io/content-types/#direct-eval
|
||||
const result = await (0, eval)(
|
||||
`(async () => {
|
||||
function invokeFunction(name, ...args) {
|
||||
return syscall("system.invokeFunction", name, ...args);
|
||||
}
|
||||
return ${replaceTemplateVars(translateJs(expression), pageMeta)};
|
||||
})()`,
|
||||
const result = evalQueryExpression(
|
||||
expressionToKvQueryExpression(parseTreeToAST(
|
||||
JSON.parse(
|
||||
await replaceTemplateVars(JSON.stringify(expression), pageMeta),
|
||||
),
|
||||
)),
|
||||
{},
|
||||
builtinFunctions,
|
||||
);
|
||||
if (template) {
|
||||
return await renderTemplate(pageMeta, template, result);
|
||||
}
|
||||
if (typeof result === "string") {
|
||||
return result;
|
||||
} else if (typeof result === "number") {
|
||||
return "" + result;
|
||||
} else if (Array.isArray(result)) {
|
||||
return jsonToMDTable(result);
|
||||
}
|
||||
return await YAML.stringify(result);
|
||||
|
||||
return Promise.resolve("" + result);
|
||||
} catch (e: any) {
|
||||
return `**ERROR:** ${e.message}`;
|
||||
return Promise.resolve(`**ERROR:** ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { niceDate } from "$sb/lib/dates.ts";
|
||||
|
||||
export function handlebarHelpers(_pageName: string) {
|
||||
return {
|
||||
json: (v: any) => JSON.stringify(v),
|
||||
niceDate: (ts: any) => niceDate(new Date(ts)),
|
||||
escapeRegexp: (ts: any) => {
|
||||
return ts.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&");
|
||||
},
|
||||
replaceRegexp: (s: string, regexp: string, replacement: string) => {
|
||||
return s.replace(new RegExp(regexp, "g"), replacement);
|
||||
},
|
||||
prefixLines: (v: string, prefix: string) =>
|
||||
v.split("\n").map((l) => prefix + l).join("\n"),
|
||||
substring: (s: string, from: number, to: number, elipsis = "") =>
|
||||
s.length > to - from ? s.substring(from, to) + elipsis : s,
|
||||
|
||||
today: () => niceDate(new Date()),
|
||||
tomorrow: () => {
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
return niceDate(tomorrow);
|
||||
},
|
||||
yesterday: () => {
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
return niceDate(yesterday);
|
||||
},
|
||||
lastWeek: () => {
|
||||
const lastWeek = new Date();
|
||||
lastWeek.setDate(lastWeek.getDate() - 7);
|
||||
return niceDate(lastWeek);
|
||||
},
|
||||
nextWeek: () => {
|
||||
const nextWeek = new Date();
|
||||
nextWeek.setDate(nextWeek.getDate() + 7);
|
||||
return niceDate(nextWeek);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
import {
|
||||
collectNodesOfType,
|
||||
findNodeOfType,
|
||||
ParseTree,
|
||||
replaceNodesMatching,
|
||||
} from "$sb/lib/tree.ts";
|
||||
|
||||
// @ts-ignore auto generated
|
||||
import { ParsedQuery, QueryFilter } from "$sb/lib/query.ts";
|
||||
|
||||
export function parseQuery(queryTree: ParseTree): ParsedQuery {
|
||||
// const n = lezerToParseTree(query, parser.parse(query).topNode);
|
||||
// Clean the tree a bit
|
||||
replaceNodesMatching(queryTree, (n) => {
|
||||
if (!n.type) {
|
||||
const trimmed = n.text!.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
n.text = trimmed;
|
||||
}
|
||||
});
|
||||
|
||||
// console.log("Parsed", JSON.stringify(n, null, 2));
|
||||
const queryNode = queryTree.children![0];
|
||||
const parsedQuery: ParsedQuery = {
|
||||
table: queryNode.children![0].children![0].text!,
|
||||
filter: [],
|
||||
ordering: [],
|
||||
};
|
||||
|
||||
const orderByNodes = collectNodesOfType(queryNode, "OrderClause");
|
||||
for (const orderByNode of orderByNodes) {
|
||||
const nameNode = findNodeOfType(orderByNode, "Name");
|
||||
const orderBy = nameNode!.children![0].text!;
|
||||
const orderNode = findNodeOfType(orderByNode, "OrderDirection");
|
||||
const orderDesc = orderNode
|
||||
? orderNode.children![0].text! === "desc"
|
||||
: false;
|
||||
parsedQuery.ordering.push({ orderBy, orderDesc });
|
||||
}
|
||||
/**
|
||||
* @deprecated due to PR #387
|
||||
* We'll take the first ordering and send that as the deprecated
|
||||
* fields orderBy and orderDesc. This way it will be backward
|
||||
* Plugs using the old ParsedQuery.
|
||||
* Remove this block completely when ParsedQuery no longer have
|
||||
* those two fields
|
||||
*/
|
||||
if (parsedQuery.ordering.length > 0) {
|
||||
parsedQuery.orderBy = parsedQuery.ordering[0].orderBy;
|
||||
parsedQuery.orderDesc = parsedQuery.ordering[0].orderDesc;
|
||||
}
|
||||
/** @end-deprecation due to PR #387 */
|
||||
|
||||
const limitNode = findNodeOfType(queryNode, "LimitClause");
|
||||
if (limitNode) {
|
||||
const nameNode = findNodeOfType(limitNode, "Number");
|
||||
parsedQuery.limit = valueNodeToVal(nameNode!);
|
||||
}
|
||||
|
||||
const filterNodes = collectNodesOfType(queryNode, "FilterExpr");
|
||||
for (const filterNode of filterNodes) {
|
||||
let val: any = undefined;
|
||||
const valNode = filterNode.children![2].children![0];
|
||||
val = valueNodeToVal(valNode);
|
||||
const f: QueryFilter = {
|
||||
prop: filterNode.children![0].children![0].text!,
|
||||
op: filterNode.children![1].text!,
|
||||
value: val,
|
||||
};
|
||||
parsedQuery.filter.push(f);
|
||||
}
|
||||
const selectNode = findNodeOfType(queryNode, "SelectClause");
|
||||
if (selectNode) {
|
||||
parsedQuery.select = [];
|
||||
collectNodesOfType(selectNode, "Name").forEach((t) => {
|
||||
parsedQuery.select!.push(t.children![0].text!);
|
||||
});
|
||||
}
|
||||
|
||||
const renderNode = findNodeOfType(queryNode, "RenderClause");
|
||||
if (renderNode) {
|
||||
let renderNameNode = findNodeOfType(renderNode, "PageRef");
|
||||
if (!renderNameNode) {
|
||||
renderNameNode = findNodeOfType(renderNode, "String");
|
||||
}
|
||||
parsedQuery.render = valueNodeToVal(renderNameNode!);
|
||||
}
|
||||
|
||||
return parsedQuery;
|
||||
}
|
||||
|
||||
export function valueNodeToVal(valNode: ParseTree): any {
|
||||
switch (valNode.type) {
|
||||
case "Number":
|
||||
return +valNode.children![0].text!;
|
||||
case "Bool":
|
||||
return valNode.children![0].text! === "true";
|
||||
case "Null":
|
||||
return null;
|
||||
case "Name":
|
||||
return valNode.children![0].text!;
|
||||
case "Regex": {
|
||||
const val = valNode.children![0].text!;
|
||||
return val.substring(1, val.length - 1);
|
||||
}
|
||||
case "String": {
|
||||
const stringVal = valNode.children![0].text!;
|
||||
return stringVal.substring(1, stringVal.length - 1);
|
||||
}
|
||||
case "PageRef": {
|
||||
const pageRefVal = valNode.children![0].text!;
|
||||
return pageRefVal.substring(2, pageRefVal.length - 2);
|
||||
}
|
||||
case "List": {
|
||||
return collectNodesOfType(valNode, "Value").map((t) =>
|
||||
valueNodeToVal(t.children![0])
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
import { assertEquals } from "../../test_deps.ts";
|
||||
import { applyQuery } from "$sb/lib/query.ts";
|
||||
|
||||
import wikiMarkdownLang from "../../common/markdown_parser/parser.ts";
|
||||
import { parse } from "../../common/markdown_parser/parse_tree.ts";
|
||||
import { parseQuery as parseQueryQuery } from "./parser.ts";
|
||||
import { findNodeOfType, renderToText } from "../../plug-api/lib/tree.ts";
|
||||
|
||||
function parseQuery(query: string) {
|
||||
const lang = wikiMarkdownLang([]);
|
||||
const mdTree = parse(
|
||||
lang,
|
||||
`<!-- #query ${query} -->
|
||||
|
||||
<!-- /query -->`,
|
||||
);
|
||||
const programNode = findNodeOfType(mdTree, "Program")!;
|
||||
return parseQueryQuery(programNode);
|
||||
}
|
||||
|
||||
Deno.test("Test parser", () => {
|
||||
const parsedBasicQuery = parseQuery(`page`);
|
||||
assertEquals(parsedBasicQuery.table, "page");
|
||||
|
||||
const parsedQuery1 = parseQuery(
|
||||
`task where completed = false and dueDate <= "{{today}}" order by dueDate desc limit 5`,
|
||||
);
|
||||
assertEquals(parsedQuery1.table, "task");
|
||||
assertEquals(parsedQuery1.ordering.length, 1);
|
||||
assertEquals(parsedQuery1.ordering[0].orderBy, "dueDate");
|
||||
assertEquals(parsedQuery1.ordering[0].orderDesc, true);
|
||||
assertEquals(parsedQuery1.limit, 5);
|
||||
assertEquals(parsedQuery1.filter.length, 2);
|
||||
assertEquals(parsedQuery1.filter[0], {
|
||||
op: "=",
|
||||
prop: "completed",
|
||||
value: false,
|
||||
});
|
||||
assertEquals(parsedQuery1.filter[1], {
|
||||
op: "<=",
|
||||
prop: "dueDate",
|
||||
value: "{{today}}",
|
||||
});
|
||||
|
||||
const parsedQuery2 = parseQuery(`page where name =~ /interview\\/.*/"`);
|
||||
assertEquals(parsedQuery2.table, "page");
|
||||
assertEquals(parsedQuery2.filter.length, 1);
|
||||
assertEquals(parsedQuery2.filter[0], {
|
||||
op: "=~",
|
||||
prop: "name",
|
||||
value: "interview\\/.*",
|
||||
});
|
||||
|
||||
const parsedQuery3 = parseQuery(`page where something != null`);
|
||||
assertEquals(parsedQuery3.table, "page");
|
||||
assertEquals(parsedQuery3.filter.length, 1);
|
||||
assertEquals(parsedQuery3.filter[0], {
|
||||
op: "!=",
|
||||
prop: "something",
|
||||
value: null,
|
||||
});
|
||||
|
||||
assertEquals(parseQuery(`page select name`).select, ["name"]);
|
||||
assertEquals(parseQuery(`page select name, age`).select, [
|
||||
"name",
|
||||
"age",
|
||||
]);
|
||||
|
||||
assertEquals(
|
||||
parseQuery(`gh-events where type in ["PushEvent", "somethingElse"]`),
|
||||
{
|
||||
table: "gh-events",
|
||||
ordering: [],
|
||||
filter: [
|
||||
{
|
||||
op: "in",
|
||||
prop: "type",
|
||||
value: ["PushEvent", "somethingElse"],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
assertEquals(parseQuery(`something render [[template/table]]`), {
|
||||
table: "something",
|
||||
ordering: [],
|
||||
filter: [],
|
||||
render: "template/table",
|
||||
});
|
||||
|
||||
assertEquals(parseQuery(`something render "template/table"`), {
|
||||
table: "something",
|
||||
ordering: [],
|
||||
filter: [],
|
||||
render: "template/table",
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test("Test applyQuery", () => {
|
||||
const data: any[] = [
|
||||
{ name: "interview/My Interview", lastModified: 1 },
|
||||
{ name: "interview/My Interview 2", lastModified: 2 },
|
||||
{ name: "Pete", age: 38 },
|
||||
{ name: "Angie", age: 28 },
|
||||
];
|
||||
|
||||
assertEquals(
|
||||
applyQuery(parseQuery(`page where name =~ /interview\\/.*/`), data),
|
||||
[
|
||||
{ name: "interview/My Interview", lastModified: 1 },
|
||||
{ name: "interview/My Interview 2", lastModified: 2 },
|
||||
],
|
||||
);
|
||||
assertEquals(
|
||||
applyQuery(
|
||||
parseQuery(`page where name =~ /interview\\/.*/ order by lastModified`),
|
||||
data,
|
||||
),
|
||||
[
|
||||
{ name: "interview/My Interview", lastModified: 1 },
|
||||
{ name: "interview/My Interview 2", lastModified: 2 },
|
||||
],
|
||||
);
|
||||
assertEquals(
|
||||
applyQuery(
|
||||
parseQuery(
|
||||
`page where name =~ /interview\\/.*/ order by lastModified desc`,
|
||||
),
|
||||
data,
|
||||
),
|
||||
[
|
||||
{ name: "interview/My Interview 2", lastModified: 2 },
|
||||
{ name: "interview/My Interview", lastModified: 1 },
|
||||
],
|
||||
);
|
||||
assertEquals(applyQuery(parseQuery(`page where age > 30`), data), [
|
||||
{ name: "Pete", age: 38 },
|
||||
]);
|
||||
assertEquals(
|
||||
applyQuery(parseQuery(`page where age > 28 and age < 38`), data),
|
||||
[],
|
||||
);
|
||||
assertEquals(
|
||||
applyQuery(parseQuery(`page where age > 30 select name`), data),
|
||||
[{ name: "Pete" }],
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
applyQuery(parseQuery(`page where name in ["Pete"] select name`), data),
|
||||
[{ name: "Pete" }],
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Test applyQuery with multi value", () => {
|
||||
const data: any[] = [
|
||||
{ name: "Pete", children: ["John", "Angie"] },
|
||||
{ name: "Angie", children: ["Angie"] },
|
||||
{ name: "Steve" },
|
||||
];
|
||||
|
||||
assertEquals(
|
||||
applyQuery(parseQuery(`page where children = "Angie"`), data),
|
||||
[
|
||||
{ name: "Pete", children: ["John", "Angie"] },
|
||||
{ name: "Angie", children: ["Angie"] },
|
||||
],
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
applyQuery(parseQuery(`page where children = ["Angie", "John"]`), data),
|
||||
[
|
||||
{ name: "Pete", children: ["John", "Angie"] },
|
||||
{ name: "Angie", children: ["Angie"] },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
const testQuery = `<!-- #query source where a = 1 and b = "2" and c = "3" -->
|
||||
|
||||
<!-- /query -->`;
|
||||
|
||||
Deno.test("Query parsing and serialization", () => {
|
||||
const lang = wikiMarkdownLang([]);
|
||||
const mdTree = parse(lang, testQuery);
|
||||
// console.log(JSON.stringify(mdTree, null, 2));
|
||||
assertEquals(renderToText(mdTree), testQuery);
|
||||
});
|
||||
@@ -2,10 +2,10 @@ import { events } from "$sb/syscalls.ts";
|
||||
|
||||
import { replaceTemplateVars } from "../template/template.ts";
|
||||
import { renderTemplate } from "./util.ts";
|
||||
import { parseQuery } from "./parser.ts";
|
||||
import { jsonToMDTable } from "./util.ts";
|
||||
import { ParseTree } from "$sb/lib/tree.ts";
|
||||
import type { PageMeta } from "../../web/types.ts";
|
||||
import { ParseTree, parseTreeToAST } from "$sb/lib/tree.ts";
|
||||
import { astToKvQuery } from "$sb/lib/parse-query.ts";
|
||||
import { PageMeta, Query } from "$sb/types.ts";
|
||||
|
||||
export async function queryDirectiveRenderer(
|
||||
_directive: string,
|
||||
@@ -15,11 +15,14 @@ export async function queryDirectiveRenderer(
|
||||
if (typeof query === "string") {
|
||||
throw new Error("Argument must be a ParseTree");
|
||||
}
|
||||
const parsedQuery = parseQuery(
|
||||
JSON.parse(replaceTemplateVars(JSON.stringify(query), pageMeta)),
|
||||
const parsedQuery: Query = astToKvQuery(
|
||||
parseTreeToAST(
|
||||
JSON.parse(await replaceTemplateVars(JSON.stringify(query), pageMeta)),
|
||||
),
|
||||
);
|
||||
// console.log("QUERY", parsedQuery);
|
||||
|
||||
const eventName = `query:${parsedQuery.table}`;
|
||||
const eventName = `query:${parsedQuery.querySource}`;
|
||||
|
||||
// console.log("Parsed query", parsedQuery);
|
||||
// Let's dispatch an event and see what happens
|
||||
@@ -30,24 +33,23 @@ export async function queryDirectiveRenderer(
|
||||
);
|
||||
if (results.length === 0) {
|
||||
// This means there was no handler for the event which means it's unsupported
|
||||
return `**Error:** Unsupported query source '${parsedQuery.table}'`;
|
||||
} else if (results.length === 1) {
|
||||
return `**Error:** Unsupported query source '${parsedQuery.querySource}'`;
|
||||
} else {
|
||||
// console.log("Parsed query", parsedQuery);
|
||||
const allResults = results.flat();
|
||||
if (parsedQuery.render) {
|
||||
const rendered = await renderTemplate(
|
||||
pageMeta,
|
||||
parsedQuery.render,
|
||||
results[0],
|
||||
allResults,
|
||||
);
|
||||
return rendered.trim();
|
||||
} else {
|
||||
if (results[0].length === 0) {
|
||||
if (allResults.length === 0) {
|
||||
return "No results";
|
||||
} else {
|
||||
return jsonToMDTable(results[0]);
|
||||
return jsonToMDTable(allResults);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Too many query results: ${results.length}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { queryRegex } from "$sb/lib/query.ts";
|
||||
import { ParseTree, renderToText } from "$sb/lib/tree.ts";
|
||||
import { markdown, space } from "$sb/syscalls.ts";
|
||||
import Handlebars from "handlebars";
|
||||
import { handlebars, markdown, space } from "$sb/syscalls.ts";
|
||||
|
||||
import { replaceTemplateVars } from "../template/template.ts";
|
||||
import { extractFrontmatter } from "$sb/lib/frontmatter.ts";
|
||||
import { directiveRegex } from "./directives.ts";
|
||||
import { updateDirectives } from "./command.ts";
|
||||
import { buildHandebarOptions } from "./util.ts";
|
||||
import { PageMeta } from "../../web/types.ts";
|
||||
import { resolvePath, rewritePageRefs } from "$sb/lib/resolve.ts";
|
||||
import { PageMeta } from "$sb/types.ts";
|
||||
|
||||
const templateRegex = /\[\[([^\]]+)\]\]\s*(.*)\s*/;
|
||||
|
||||
@@ -30,7 +28,7 @@ export async function templateDirectiveRenderer(
|
||||
let parsedArgs = {};
|
||||
if (args) {
|
||||
try {
|
||||
parsedArgs = JSON.parse(replaceTemplateVars(args, pageMeta));
|
||||
parsedArgs = JSON.parse(await replaceTemplateVars(args, pageMeta));
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Failed to parse template instantiation arg: ${
|
||||
@@ -65,11 +63,9 @@ export async function templateDirectiveRenderer(
|
||||
|
||||
// if it's a template injection (not a literal "include")
|
||||
if (directive === "use") {
|
||||
const templateFn = Handlebars.compile(
|
||||
newBody,
|
||||
{ noEscape: true },
|
||||
);
|
||||
newBody = templateFn(parsedArgs, buildHandebarOptions(pageMeta));
|
||||
newBody = await handlebars.renderTemplate(newBody, parsedArgs, {
|
||||
page: pageMeta,
|
||||
});
|
||||
|
||||
// Recursively render directives
|
||||
const tree = await markdown.parseMarkdown(newBody);
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import Handlebars from "handlebars";
|
||||
|
||||
import { space } from "$sb/syscalls.ts";
|
||||
import type { PageMeta } from "../../web/types.ts";
|
||||
import { handlebarHelpers } from "./handlebar_helpers.ts";
|
||||
import { handlebars, space } from "$sb/syscalls.ts";
|
||||
import { handlebarHelpers } from "../../common/syscalls/handlebar_helpers.ts";
|
||||
import { PageMeta } from "$sb/types.ts";
|
||||
|
||||
const maxWidth = 70;
|
||||
|
||||
@@ -10,6 +8,9 @@ export function defaultJsonTransformer(_k: string, v: any) {
|
||||
if (v === undefined) {
|
||||
return "";
|
||||
}
|
||||
if (typeof v === "string") {
|
||||
return v.replaceAll("\n", " ").replaceAll("|", "\\|");
|
||||
}
|
||||
return "" + v;
|
||||
}
|
||||
|
||||
@@ -86,13 +87,12 @@ export async function renderTemplate(
|
||||
): Promise<string> {
|
||||
let templateText = await space.readPage(renderTemplate);
|
||||
templateText = `{{#each .}}\n${templateText}\n{{/each}}`;
|
||||
const template = Handlebars.compile(templateText, { noEscape: true });
|
||||
return template(data, buildHandebarOptions(pageMeta));
|
||||
return handlebars.renderTemplate(templateText, data, { page: pageMeta });
|
||||
}
|
||||
|
||||
export function buildHandebarOptions(pageMeta: PageMeta) {
|
||||
return {
|
||||
helpers: handlebarHelpers(pageMeta.name),
|
||||
helpers: handlebarHelpers(),
|
||||
data: { page: pageMeta },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { CompleteEvent } from "$sb/app_event.ts";
|
||||
import { space } from "$sb/syscalls.ts";
|
||||
import { PageMeta } from "../../web/types.ts";
|
||||
import { PageMeta } from "$sb/types.ts";
|
||||
import { cacheFileListing } from "../federation/federation.ts";
|
||||
|
||||
// Completion
|
||||
|
||||
@@ -57,7 +57,7 @@ export async function titleUnfurl(url: string): Promise<string> {
|
||||
const response = await fetch(url);
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
console.error("Unfurl failed", await response.text());
|
||||
throw new Error(`Failed to fetch: ${await response.statusText}`);
|
||||
throw new Error(`Failed to fetch: ${response.statusText}`);
|
||||
}
|
||||
const body = await response.text();
|
||||
const match = titleRegex.exec(body);
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import type { CompleteEvent } from "$sb/app_event.ts";
|
||||
import { editor, space } from "$sb/syscalls.ts";
|
||||
|
||||
import { cacheFileListing } from "../federation/federation.ts";
|
||||
import type { PageMeta } from "../../web/types.ts";
|
||||
|
||||
export async function deletePage() {
|
||||
const pageName = await editor.getCurrentPage();
|
||||
if (
|
||||
|
||||
+4
-4
@@ -1,11 +1,11 @@
|
||||
import { readCodeBlockPage } from "$sb/lib/yaml_page.ts";
|
||||
import { editor, store } from "$sb/syscalls.ts";
|
||||
import { clientStore, editor } from "$sb/syscalls.ts";
|
||||
|
||||
export async function toggleVimMode() {
|
||||
let vimMode = await store.get("vimMode");
|
||||
let vimMode = await clientStore.get("vimMode");
|
||||
vimMode = !vimMode;
|
||||
await editor.setUiOption("vimMode", vimMode);
|
||||
await store.set("vimMode", vimMode);
|
||||
await clientStore.set("vimMode", vimMode);
|
||||
}
|
||||
|
||||
export async function loadVimRc() {
|
||||
@@ -28,7 +28,7 @@ export async function loadVimRc() {
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
} catch {
|
||||
// No VIMRC page found
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import "$sb/lib/fetch.ts";
|
||||
import { federatedPathToUrl } from "$sb/lib/resolve.ts";
|
||||
import { readFederationConfigs } from "./config.ts";
|
||||
import { store } from "$sb/syscalls.ts";
|
||||
import { datastore } from "$sb/syscalls.ts";
|
||||
import type { FileMeta } from "$sb/types.ts";
|
||||
|
||||
async function responseToFileMeta(
|
||||
@@ -29,7 +29,7 @@ async function responseToFileMeta(
|
||||
};
|
||||
}
|
||||
|
||||
const fileListingPrefixCacheKey = `federationListCache:`;
|
||||
const fileListingPrefixCacheKey = `federationListCache`;
|
||||
const listingCacheTimeout = 1000 * 30;
|
||||
const listingFetchTimeout = 2000;
|
||||
|
||||
@@ -56,8 +56,8 @@ export async function listFiles(): Promise<FileMeta[]> {
|
||||
}
|
||||
|
||||
export async function cacheFileListing(uri: string): Promise<FileMeta[]> {
|
||||
const cachedListing = await store.get(
|
||||
`${fileListingPrefixCacheKey}${uri}`,
|
||||
const cachedListing = await datastore.get(
|
||||
[fileListingPrefixCacheKey, uri],
|
||||
) as FileListingCacheEntry;
|
||||
if (
|
||||
cachedListing &&
|
||||
@@ -99,7 +99,7 @@ export async function cacheFileListing(uri: string): Promise<FileMeta[]> {
|
||||
perm: "ro",
|
||||
name: `${rootUri}/${meta.name}`,
|
||||
}));
|
||||
await store.set(`${fileListingPrefixCacheKey}${uri}`, {
|
||||
await datastore.set([fileListingPrefixCacheKey, uri], {
|
||||
items,
|
||||
lastUpdated: Date.now(),
|
||||
} as FileListingCacheEntry);
|
||||
|
||||
+18
-11
@@ -1,24 +1,31 @@
|
||||
import { collectNodesOfType } from "$sb/lib/tree.ts";
|
||||
import { index } from "$sb/syscalls.ts";
|
||||
import type { CompleteEvent, IndexTreeEvent } from "$sb/app_event.ts";
|
||||
import { removeQueries } from "$sb/lib/query.ts";
|
||||
import { ObjectValue } from "$sb/types.ts";
|
||||
import { indexObjects, queryObjects } from "./api.ts";
|
||||
|
||||
// Key space
|
||||
// a:pageName:anchorName => pos
|
||||
type AnchorObject = ObjectValue<{
|
||||
name: string;
|
||||
page: string;
|
||||
pos: number;
|
||||
}>;
|
||||
|
||||
export async function indexAnchors({ name: pageName, tree }: IndexTreeEvent) {
|
||||
removeQueries(tree);
|
||||
const anchors: { key: string; value: string }[] = [];
|
||||
const anchors: ObjectValue<AnchorObject>[] = [];
|
||||
|
||||
collectNodesOfType(tree, "NamedAnchor").forEach((n) => {
|
||||
const aName = n.children![0].text!.substring(1);
|
||||
anchors.push({
|
||||
key: `a:${pageName}:${aName}`,
|
||||
value: "" + n.from,
|
||||
ref: `${pageName}@${aName}`,
|
||||
tags: ["anchor"],
|
||||
name: aName,
|
||||
page: pageName,
|
||||
pos: n.from!,
|
||||
});
|
||||
});
|
||||
// console.log("Found", anchors.length, "anchors(s)");
|
||||
await index.batchSet(pageName, anchors);
|
||||
await indexObjects(pageName, anchors);
|
||||
}
|
||||
|
||||
export async function anchorComplete(completeEvent: CompleteEvent) {
|
||||
@@ -31,13 +38,13 @@ export async function anchorComplete(completeEvent: CompleteEvent) {
|
||||
if (!pageRef) {
|
||||
pageRef = completeEvent.pageName;
|
||||
}
|
||||
const allAnchors = await index.queryPrefix(
|
||||
`a:${pageRef}:${anchorRef}`,
|
||||
);
|
||||
const allAnchors = await queryObjects<AnchorObject>("anchor", {
|
||||
filter: ["=", ["attr", "page"], ["string", pageRef]],
|
||||
});
|
||||
return {
|
||||
from: completeEvent.pos - anchorRef.length,
|
||||
options: allAnchors.map((a) => ({
|
||||
label: a.key.split(":")[2],
|
||||
label: a.name,
|
||||
type: "anchor",
|
||||
})),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { datastore } from "$sb/syscalls.ts";
|
||||
import { KV, KvKey, ObjectQuery, ObjectValue } from "$sb/types.ts";
|
||||
import { QueryProviderEvent } from "$sb/app_event.ts";
|
||||
import { builtins } from "./builtins.ts";
|
||||
import { AttributeObject, determineType } from "./attributes.ts";
|
||||
|
||||
const indexKey = "idx";
|
||||
const pageKey = "ridx";
|
||||
|
||||
/*
|
||||
* Key namespace:
|
||||
* [indexKey, type, ...key, page] -> value
|
||||
* [pageKey, page, ...key] -> true // for fast page clearing
|
||||
* ["type", type] -> true // for fast type listing
|
||||
*/
|
||||
|
||||
export function batchSet(page: string, kvs: KV[]): Promise<void> {
|
||||
const finalBatch: KV[] = [];
|
||||
for (const { key, value } of kvs) {
|
||||
finalBatch.push({
|
||||
key: [indexKey, ...key, page],
|
||||
value,
|
||||
}, {
|
||||
key: [pageKey, page, ...key],
|
||||
value: true,
|
||||
});
|
||||
}
|
||||
return datastore.batchSet(finalBatch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all keys for a given page
|
||||
* @param page
|
||||
*/
|
||||
export async function clearPageIndex(page: string): Promise<void> {
|
||||
const allKeys: KvKey[] = [];
|
||||
for (
|
||||
const { key } of await datastore.query({
|
||||
prefix: [pageKey, page],
|
||||
})
|
||||
) {
|
||||
allKeys.push(key);
|
||||
allKeys.push([indexKey, ...key.slice(2), page]);
|
||||
}
|
||||
await datastore.batchDel(allKeys);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the entire datastore for this indexKey plug
|
||||
*/
|
||||
export async function clearIndex(): Promise<void> {
|
||||
const allKeys: KvKey[] = [];
|
||||
for (
|
||||
const { key } of await datastore.query({ prefix: [] })
|
||||
) {
|
||||
allKeys.push(key);
|
||||
}
|
||||
await datastore.batchDel(allKeys);
|
||||
console.log("Deleted", allKeys.length, "keys from the index");
|
||||
}
|
||||
|
||||
// ENTITIES API
|
||||
|
||||
/**
|
||||
* Indexes entities in the data store
|
||||
*/
|
||||
export async function indexObjects<T>(
|
||||
page: string,
|
||||
objects: ObjectValue<T>[],
|
||||
): Promise<void> {
|
||||
const kvs: KV<T>[] = [];
|
||||
const allAttributes = new Map<string, string>(); // tag:name -> attributeType
|
||||
for (const obj of objects) {
|
||||
for (const tag of obj.tags) {
|
||||
kvs.push({
|
||||
key: [tag, cleanKey(obj.ref, page)],
|
||||
value: obj,
|
||||
});
|
||||
// Index attributes
|
||||
if (!builtins[tag]) {
|
||||
// But only for non-builtin tags
|
||||
for (
|
||||
const [attrName, attrValue] of Object.entries(
|
||||
obj as Record<string, any>,
|
||||
)
|
||||
) {
|
||||
if (attrName.startsWith("$")) {
|
||||
continue;
|
||||
}
|
||||
allAttributes.set(`${tag}:${attrName}`, determineType(attrValue));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (allAttributes.size > 0) {
|
||||
await indexObjects<AttributeObject>(
|
||||
page,
|
||||
[...allAttributes].map(([key, value]) => {
|
||||
const [tag, name] = key.split(":");
|
||||
return {
|
||||
ref: key,
|
||||
tags: ["attribute"],
|
||||
tag,
|
||||
name,
|
||||
attributeType: value,
|
||||
page,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
return batchSet(page, kvs);
|
||||
}
|
||||
|
||||
function cleanKey(ref: string, page: string) {
|
||||
if (ref.startsWith(`${page}@`)) {
|
||||
return ref.substring(page.length + 1);
|
||||
} else {
|
||||
return ref;
|
||||
}
|
||||
}
|
||||
|
||||
export async function queryObjects<T>(
|
||||
tag: string,
|
||||
query: ObjectQuery,
|
||||
): Promise<ObjectValue<T>[]> {
|
||||
return (await datastore.query({
|
||||
...query,
|
||||
prefix: [indexKey, tag],
|
||||
})).map(({ value }) => value);
|
||||
}
|
||||
|
||||
export async function getObjectByRef<T>(
|
||||
page: string,
|
||||
tag: string,
|
||||
ref: string,
|
||||
): Promise<ObjectValue<T> | undefined> {
|
||||
console.log("Fetching!!!!!", [indexKey, tag, cleanKey(ref, page), page]);
|
||||
return (await datastore.get([indexKey, tag, cleanKey(ref, page), page]));
|
||||
}
|
||||
|
||||
export async function objectSourceProvider({
|
||||
query,
|
||||
}: QueryProviderEvent): Promise<any[]> {
|
||||
const tag = query.querySource!;
|
||||
const results = await datastore.query({
|
||||
...query,
|
||||
prefix: [indexKey, tag],
|
||||
});
|
||||
return results.map((r) => r.value);
|
||||
}
|
||||
|
||||
export async function discoverSources() {
|
||||
return (await datastore.query({ prefix: [indexKey, "tag"] })).map((
|
||||
{ key },
|
||||
) => key[2]);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
function processClick(e) {
|
||||
const dataEl = e.target.closest("[data-ref]");
|
||||
syscall(
|
||||
"system.invokeFunction",
|
||||
"index.navigateToMention",
|
||||
dataEl.getAttribute("data-ref"),
|
||||
).catch(console.error);
|
||||
}
|
||||
|
||||
document.getElementById("link-ul").addEventListener("click", processClick);
|
||||
document.getElementById("hide-button").addEventListener("click", function () {
|
||||
console.log("HERE")
|
||||
syscall(
|
||||
"system.invokeFunction",
|
||||
"index.toggleMentions",
|
||||
).catch(console.error);
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
body {
|
||||
font-family: var(--ui-font);
|
||||
background-color: var(--root-background-color);
|
||||
color: var(--root-color);
|
||||
overflow: scroll;
|
||||
}
|
||||
|
||||
.sb-line-h2 {
|
||||
border-top-right-radius: 5px;
|
||||
border-top-left-radius: 5px;
|
||||
margin: 0;
|
||||
padding: 10px !important;
|
||||
background-color: rgba(233, 233, 233, 0.5);
|
||||
}
|
||||
|
||||
#hide-button {
|
||||
position: absolute;
|
||||
right: 15px;
|
||||
top: 15px;
|
||||
}
|
||||
|
||||
li code {
|
||||
font-size: 80%;
|
||||
color: #a5a4a4;
|
||||
}
|
||||
+25
-98
@@ -1,12 +1,15 @@
|
||||
import { index } from "$sb/silverbullet-syscall/mod.ts";
|
||||
import type { CompleteEvent } from "$sb/app_event.ts";
|
||||
import { events } from "$sb/syscalls.ts";
|
||||
import { queryObjects } from "./api.ts";
|
||||
import { ObjectValue, QueryExpression } from "$sb/types.ts";
|
||||
import { builtinPseudoPage } from "./builtins.ts";
|
||||
|
||||
export type AttributeContext = "page" | "item" | "task";
|
||||
|
||||
type AttributeEntry = {
|
||||
type: string;
|
||||
};
|
||||
export type AttributeObject = ObjectValue<{
|
||||
name: string;
|
||||
attributeType: string;
|
||||
tag: string;
|
||||
page: string;
|
||||
}>;
|
||||
|
||||
export type AttributeCompleteEvent = {
|
||||
source: string;
|
||||
@@ -16,41 +19,11 @@ export type AttributeCompleteEvent = {
|
||||
export type AttributeCompletion = {
|
||||
name: string;
|
||||
source: string;
|
||||
type: string;
|
||||
attributeType: string;
|
||||
builtin?: boolean;
|
||||
};
|
||||
|
||||
const builtinAttributes: Record<string, Record<string, string>> = {
|
||||
page: {
|
||||
name: "string",
|
||||
lastModified: "number",
|
||||
perm: "rw|ro",
|
||||
contentType: "string",
|
||||
size: "number",
|
||||
tags: "array",
|
||||
},
|
||||
task: {
|
||||
name: "string",
|
||||
done: "boolean",
|
||||
page: "string",
|
||||
state: "string",
|
||||
deadline: "string",
|
||||
pos: "number",
|
||||
tags: "array",
|
||||
},
|
||||
item: {
|
||||
name: "string",
|
||||
page: "string",
|
||||
pos: "number",
|
||||
tags: "array",
|
||||
},
|
||||
tag: {
|
||||
name: "string",
|
||||
freq: "number",
|
||||
},
|
||||
};
|
||||
|
||||
function determineType(v: any): string {
|
||||
export function determineType(v: any): string {
|
||||
const t = typeof v;
|
||||
if (t === "object") {
|
||||
if (Array.isArray(v)) {
|
||||
@@ -60,69 +33,23 @@ function determineType(v: any): string {
|
||||
return t;
|
||||
}
|
||||
|
||||
const attributeKeyPrefix = "attr:";
|
||||
|
||||
export async function indexAttributes(
|
||||
pageName: string,
|
||||
attributes: Record<string, any>,
|
||||
context: AttributeContext,
|
||||
) {
|
||||
await index.batchSet(
|
||||
pageName,
|
||||
Object.entries(attributes).map(([k, v]) => {
|
||||
return {
|
||||
key: `${attributeKeyPrefix}${context}:${k}`,
|
||||
value: {
|
||||
type: determineType(v),
|
||||
} as AttributeEntry,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function customAttributeCompleter(
|
||||
export async function objectAttributeCompleter(
|
||||
attributeCompleteEvent: AttributeCompleteEvent,
|
||||
): Promise<AttributeCompletion[]> {
|
||||
const sourcePrefix = attributeCompleteEvent.source === "*"
|
||||
? ""
|
||||
: `${attributeCompleteEvent.source}:`;
|
||||
const allAttributes = await index.queryPrefix(
|
||||
`${attributeKeyPrefix}${sourcePrefix}`,
|
||||
);
|
||||
return allAttributes.map((attr) => {
|
||||
const [_prefix, context, name] = attr.key.split(":");
|
||||
return {
|
||||
name,
|
||||
source: context,
|
||||
type: attr.value.type,
|
||||
};
|
||||
const attributeFilter: QueryExpression | undefined =
|
||||
attributeCompleteEvent.source === ""
|
||||
? undefined
|
||||
: ["=", ["attr", "tag"], ["string", attributeCompleteEvent.source]];
|
||||
const allAttributes = await queryObjects<AttributeObject>("attribute", {
|
||||
filter: attributeFilter,
|
||||
});
|
||||
}
|
||||
|
||||
export function builtinAttributeCompleter(
|
||||
attributeCompleteEvent: AttributeCompleteEvent,
|
||||
): AttributeCompletion[] {
|
||||
let allAttributes = builtinAttributes[attributeCompleteEvent.source];
|
||||
if (attributeCompleteEvent.source === "*") {
|
||||
allAttributes = {};
|
||||
for (const [source, attributes] of Object.entries(builtinAttributes)) {
|
||||
for (const [name, type] of Object.entries(attributes)) {
|
||||
allAttributes[name] = `${type}|${source}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!allAttributes) {
|
||||
return [];
|
||||
}
|
||||
return Object.entries(allAttributes).map(([name, type]) => {
|
||||
return allAttributes.map((value) => {
|
||||
return {
|
||||
name,
|
||||
source: attributeCompleteEvent.source === "*"
|
||||
? type.split("|")[1]
|
||||
: attributeCompleteEvent.source,
|
||||
type: attributeCompleteEvent.source === "*" ? type.split("|")[0] : type,
|
||||
builtin: true,
|
||||
};
|
||||
name: value.name,
|
||||
source: value.tag,
|
||||
attributeType: value.attributeType,
|
||||
builtin: value.page === builtinPseudoPage,
|
||||
} as AttributeCompletion;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -184,7 +111,7 @@ export function attributeCompletionsToCMCompletion(
|
||||
(completion) => ({
|
||||
label: completion.name,
|
||||
apply: `${completion.name}: `,
|
||||
detail: `${completion.type} (${completion.source})`,
|
||||
detail: `${completion.attributeType} (${completion.source})`,
|
||||
type: "attribute",
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { ObjectValue } from "$sb/types.ts";
|
||||
import { indexObjects } from "./api.ts";
|
||||
import { AttributeObject } from "./attributes.ts";
|
||||
import { TagObject } from "./tags.ts";
|
||||
|
||||
export const builtinPseudoPage = ":builtin:";
|
||||
|
||||
export const builtins: Record<string, Record<string, string>> = {
|
||||
page: {
|
||||
name: "string",
|
||||
lastModified: "date",
|
||||
perm: "rw|ro",
|
||||
contentType: "string",
|
||||
size: "number",
|
||||
tags: "array",
|
||||
},
|
||||
task: {
|
||||
name: "string",
|
||||
done: "boolean",
|
||||
page: "string",
|
||||
state: "string",
|
||||
deadline: "string",
|
||||
pos: "number",
|
||||
tags: "array",
|
||||
},
|
||||
tag: {
|
||||
name: "string",
|
||||
page: "string",
|
||||
context: "string",
|
||||
},
|
||||
attribute: {
|
||||
name: "string",
|
||||
attributeType: "string",
|
||||
type: "string",
|
||||
page: "string",
|
||||
},
|
||||
anchor: {
|
||||
name: "string",
|
||||
page: "string",
|
||||
pos: "number",
|
||||
},
|
||||
link: {
|
||||
name: "string",
|
||||
page: "string",
|
||||
pos: "number",
|
||||
alias: "string",
|
||||
inDirective: "boolean",
|
||||
asTemplate: "boolean",
|
||||
},
|
||||
};
|
||||
|
||||
export async function loadBuiltinsIntoIndex() {
|
||||
console.log("Loading builtins attributes into index");
|
||||
const allTags: ObjectValue<TagObject>[] = [];
|
||||
for (const [tag, attributes] of Object.entries(builtins)) {
|
||||
allTags.push({
|
||||
ref: tag,
|
||||
tags: ["tag"],
|
||||
name: tag,
|
||||
page: builtinPseudoPage,
|
||||
parent: "builtin",
|
||||
});
|
||||
await indexObjects<AttributeObject>(
|
||||
builtinPseudoPage,
|
||||
Object.entries(attributes).map(([name, attributeType]) => {
|
||||
return {
|
||||
ref: `${tag}:${name}`,
|
||||
tags: ["attribute"],
|
||||
tag,
|
||||
name,
|
||||
attributeType,
|
||||
builtinPseudoPage,
|
||||
page: builtinPseudoPage,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
await indexObjects(builtinPseudoPage, allTags);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { editor, events, markdown, mq, space, system } from "$sb/syscalls.ts";
|
||||
import { sleep } from "$sb/lib/async.ts";
|
||||
import { IndexEvent } from "$sb/app_event.ts";
|
||||
import { MQMessage } from "$sb/types.ts";
|
||||
|
||||
export async function reindexCommand() {
|
||||
await editor.flashNotification("Performing full page reindex...");
|
||||
await system.invokeFunction("reindexSpace");
|
||||
await editor.flashNotification("Done with page index!");
|
||||
}
|
||||
|
||||
export async function reindexSpace() {
|
||||
console.log("Clearing page index...");
|
||||
// Executed this way to not have to embed the search plug code here
|
||||
await system.invokeFunction("search.clearIndex");
|
||||
await system.invokeFunction("index.clearIndex");
|
||||
const pages = await space.listPages();
|
||||
|
||||
// Queue all page names to be indexed
|
||||
await mq.batchSend("indexQueue", pages.map((page) => page.name));
|
||||
|
||||
// Now let's wait for the processing to finish
|
||||
let queueStats = await mq.getQueueStats("indexQueue");
|
||||
while (queueStats.queued > 0 || queueStats.processing > 0) {
|
||||
sleep(1000);
|
||||
queueStats = await mq.getQueueStats("indexQueue");
|
||||
}
|
||||
// And notify the user
|
||||
console.log("Indexing completed!");
|
||||
}
|
||||
|
||||
export async function processIndexQueue(messages: MQMessage[]) {
|
||||
for (const message of messages) {
|
||||
const name: string = message.body;
|
||||
console.log(`Indexing page ${name}`);
|
||||
const text = await space.readPage(name);
|
||||
const parsed = await markdown.parseMarkdown(text);
|
||||
await events.dispatchEvent("page:index", {
|
||||
name,
|
||||
tree: parsed,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function parseIndexTextRepublish({ name, text }: IndexEvent) {
|
||||
// console.log("Reindexing", name);
|
||||
await events.dispatchEvent("page:index", {
|
||||
name,
|
||||
tree: await markdown.parseMarkdown(text),
|
||||
});
|
||||
}
|
||||
@@ -1,13 +1,20 @@
|
||||
// Index key space:
|
||||
// data:page@pos
|
||||
|
||||
import type { IndexTreeEvent, QueryProviderEvent } from "$sb/app_event.ts";
|
||||
import { index, YAML } from "$sb/syscalls.ts";
|
||||
import type { IndexTreeEvent } from "$sb/app_event.ts";
|
||||
import { YAML } from "$sb/syscalls.ts";
|
||||
import { collectNodesOfType, findNodeOfType } from "$sb/lib/tree.ts";
|
||||
import { applyQuery, removeQueries } from "$sb/lib/query.ts";
|
||||
import { removeQueries } from "$sb/lib/query.ts";
|
||||
import { ObjectValue } from "$sb/types.ts";
|
||||
import { indexObjects } from "./api.ts";
|
||||
import { TagObject } from "./tags.ts";
|
||||
|
||||
type DataObject = ObjectValue<
|
||||
{
|
||||
pos: number;
|
||||
page: string;
|
||||
} & Record<string, any>
|
||||
>;
|
||||
|
||||
export async function indexData({ name, tree }: IndexTreeEvent) {
|
||||
const dataObjects: { key: string; value: any }[] = [];
|
||||
const dataObjects: ObjectValue<DataObject>[] = [];
|
||||
|
||||
removeQueries(tree);
|
||||
|
||||
@@ -17,7 +24,8 @@ export async function indexData({ name, tree }: IndexTreeEvent) {
|
||||
if (!codeInfoNode) {
|
||||
return;
|
||||
}
|
||||
if (codeInfoNode.children![0].text !== "data") {
|
||||
const fenceType = codeInfoNode.children![0].text!;
|
||||
if (fenceType !== "data" && !fenceType.startsWith("#")) {
|
||||
return;
|
||||
}
|
||||
const codeTextNode = findNodeOfType(t, "CodeText");
|
||||
@@ -26,6 +34,7 @@ export async function indexData({ name, tree }: IndexTreeEvent) {
|
||||
return;
|
||||
}
|
||||
const codeText = codeTextNode.children![0].text!;
|
||||
const dataType = fenceType === "data" ? "data" : fenceType.substring(1);
|
||||
try {
|
||||
const docs = codeText.split("---");
|
||||
// We support multiple YAML documents in one block
|
||||
@@ -34,12 +43,25 @@ export async function indexData({ name, tree }: IndexTreeEvent) {
|
||||
if (!doc) {
|
||||
continue;
|
||||
}
|
||||
const pos = t.from! + i;
|
||||
dataObjects.push({
|
||||
key: `data:${name}@${t.from! + i}`,
|
||||
value: doc,
|
||||
ref: `${name}@${pos}`,
|
||||
tags: [dataType],
|
||||
...doc,
|
||||
pos,
|
||||
page: name,
|
||||
});
|
||||
}
|
||||
// console.log("Parsed data", parsedData);
|
||||
await indexObjects<TagObject>(name, [
|
||||
{
|
||||
ref: dataType,
|
||||
tags: ["tag"],
|
||||
name: dataType,
|
||||
page: name,
|
||||
parent: "data",
|
||||
},
|
||||
]);
|
||||
} catch (e) {
|
||||
console.error("Could not parse data", codeText, "error:", e);
|
||||
return;
|
||||
@@ -47,20 +69,5 @@ export async function indexData({ name, tree }: IndexTreeEvent) {
|
||||
}),
|
||||
);
|
||||
// console.log("Found", dataObjects.length, "data objects");
|
||||
await index.batchSet(name, dataObjects);
|
||||
}
|
||||
|
||||
export async function queryProvider({
|
||||
query,
|
||||
}: QueryProviderEvent): Promise<any[]> {
|
||||
const allData: any[] = [];
|
||||
for (const { key, page, value } of await index.queryPrefix("data:")) {
|
||||
const [, pos] = key.split("@");
|
||||
allData.push({
|
||||
...value,
|
||||
page: page,
|
||||
pos: +pos,
|
||||
});
|
||||
}
|
||||
return applyQuery(query, allData);
|
||||
await indexObjects(name, dataObjects);
|
||||
}
|
||||
+76
-36
@@ -10,34 +10,67 @@ syntax:
|
||||
- "$"
|
||||
regex: "\\$[a-zA-Z\\.\\-\\/]+[\\w\\.\\-\\/]*"
|
||||
className: sb-named-anchor
|
||||
assets:
|
||||
- asset/*
|
||||
functions:
|
||||
clearPageIndex:
|
||||
path: "./page.ts:clearPageIndex"
|
||||
loadBuiltinsIntoIndex:
|
||||
path: builtins.ts:loadBuiltinsIntoIndex
|
||||
env: server
|
||||
events:
|
||||
- system:ready
|
||||
|
||||
# Public API
|
||||
batchSet:
|
||||
path: api.ts:batchSet
|
||||
env: server
|
||||
indexObjects:
|
||||
path: api.ts:indexObjects
|
||||
env: server
|
||||
queryObjects:
|
||||
path: api.ts:queryObjects
|
||||
env: server
|
||||
getObjectByRef:
|
||||
path: api.ts:getObjectByRef
|
||||
env: server
|
||||
|
||||
objectSourceProvider:
|
||||
path: api.ts:objectSourceProvider
|
||||
events:
|
||||
- query:*
|
||||
discoverSources:
|
||||
path: api.ts:discoverSources
|
||||
events:
|
||||
- query_
|
||||
clearIndex:
|
||||
path: api.ts:clearIndex
|
||||
env: server
|
||||
|
||||
clearDSIndex:
|
||||
path: api.ts:clearPageIndex
|
||||
env: server
|
||||
events:
|
||||
- page:saved
|
||||
- page:deleted
|
||||
pageQueryProvider:
|
||||
path: ./page.ts:pageQueryProvider
|
||||
events:
|
||||
- query:page
|
||||
|
||||
parseIndexTextRepublish:
|
||||
path: "./page.ts:parseIndexTextRepublish"
|
||||
path: "./command.ts:parseIndexTextRepublish"
|
||||
env: server
|
||||
events:
|
||||
- page:index_text
|
||||
|
||||
reindexSpaceCommand:
|
||||
path: "./page.ts:reindexCommand"
|
||||
path: "./command.ts:reindexCommand"
|
||||
command:
|
||||
name: "Space: Reindex"
|
||||
processIndexQueue:
|
||||
path: ./page.ts:processIndexQueue
|
||||
path: ./command.ts:processIndexQueue
|
||||
mqSubscriptions:
|
||||
- queue: indexQueue
|
||||
batchSize: 10
|
||||
autoAck: true
|
||||
reindexSpace:
|
||||
path: "./page.ts:reindexSpace"
|
||||
path: "./command.ts:reindexSpace"
|
||||
env: server
|
||||
|
||||
# Attachments
|
||||
attachmentQueryProvider:
|
||||
@@ -45,46 +78,37 @@ functions:
|
||||
events:
|
||||
- query:attachment
|
||||
|
||||
indexPage:
|
||||
path: page.ts:indexPage
|
||||
events:
|
||||
- page:index
|
||||
|
||||
# Backlinks
|
||||
indexLinks:
|
||||
path: "./page_links.ts:indexLinks"
|
||||
events:
|
||||
- page:index
|
||||
linkQueryProvider:
|
||||
path: ./page_links.ts:linkQueryProvider
|
||||
events:
|
||||
- query:link
|
||||
|
||||
attributeComplete:
|
||||
path: "./attributes.ts:attributeComplete"
|
||||
events:
|
||||
- editor:complete
|
||||
|
||||
customAttributeCompleter:
|
||||
path: ./attributes.ts:customAttributeCompleter
|
||||
objectAttributeCompleter:
|
||||
path: ./attributes.ts:objectAttributeCompleter
|
||||
events:
|
||||
- attribute:complete:page
|
||||
- attribute:complete:task
|
||||
- attribute:complete:item
|
||||
- attribute:complete:*
|
||||
|
||||
builtinAttributeCompleter:
|
||||
path: ./attributes.ts:builtinAttributeCompleter
|
||||
events:
|
||||
- attribute:complete:page
|
||||
- attribute:complete:task
|
||||
- attribute:complete:item
|
||||
- attribute:complete:*
|
||||
# builtinAttributeCompleter:
|
||||
# path: ./attributes.ts:builtinAttributeCompleter
|
||||
# events:
|
||||
# - attribute:complete:*
|
||||
|
||||
# Item indexing
|
||||
indexItem:
|
||||
path: "./item.ts:indexItems"
|
||||
events:
|
||||
- page:index
|
||||
itemQueryProvider:
|
||||
path: "./item.ts:queryProvider"
|
||||
events:
|
||||
- query:item
|
||||
|
||||
# Anchors
|
||||
indexAnchors:
|
||||
@@ -96,19 +120,21 @@ functions:
|
||||
events:
|
||||
- editor:complete
|
||||
|
||||
# Data
|
||||
indexData:
|
||||
path: data.ts:indexData
|
||||
events:
|
||||
- page:index
|
||||
|
||||
# Hashtags
|
||||
indexTags:
|
||||
path: "./tags.ts:indexTags"
|
||||
path: tags.ts:indexTags
|
||||
events:
|
||||
- page:index
|
||||
tagComplete:
|
||||
path: "./tags.ts:tagComplete"
|
||||
path: tags.ts:tagComplete
|
||||
events:
|
||||
- editor:complete
|
||||
tagProvider:
|
||||
path: "./tags.ts:tagProvider"
|
||||
events:
|
||||
- query:tag
|
||||
|
||||
renamePageCommand:
|
||||
path: "./refactor.ts:renamePageCommand"
|
||||
@@ -128,4 +154,18 @@ functions:
|
||||
command:
|
||||
name: "Page: Extract"
|
||||
|
||||
# Mentions panel (postscript)
|
||||
toggleMentions:
|
||||
path: "./mentions_ps.ts:toggleMentions"
|
||||
command:
|
||||
name: "Mentions: Toggle"
|
||||
key: ctrl-alt-m
|
||||
updateMentions:
|
||||
path: "./mentions_ps.ts:updateMentions"
|
||||
env: client
|
||||
events:
|
||||
- plug:load
|
||||
- editor:pageLoaded
|
||||
navigateToMention:
|
||||
path: "./mentions_ps.ts:navigate"
|
||||
|
||||
|
||||
+28
-50
@@ -1,31 +1,28 @@
|
||||
import type { IndexTreeEvent, QueryProviderEvent } from "$sb/app_event.ts";
|
||||
import type { IndexTreeEvent } from "$sb/app_event.ts";
|
||||
|
||||
import { index } from "$sb/syscalls.ts";
|
||||
import { collectNodesOfType, ParseTree, renderToText } from "$sb/lib/tree.ts";
|
||||
import { applyQuery, removeQueries } from "$sb/lib/query.ts";
|
||||
import { removeQueries } from "$sb/lib/query.ts";
|
||||
import { extractAttributes } from "$sb/lib/attribute.ts";
|
||||
import { rewritePageRefs } from "$sb/lib/resolve.ts";
|
||||
import { indexAttributes } from "./attributes.ts";
|
||||
import { ObjectValue } from "$sb/types.ts";
|
||||
import { indexObjects } from "./api.ts";
|
||||
|
||||
export type Item = {
|
||||
name: string;
|
||||
nested?: string;
|
||||
tags?: string[];
|
||||
// Not stored in DB
|
||||
page?: string;
|
||||
pos?: number;
|
||||
} & Record<string, any>;
|
||||
export type ItemObject = ObjectValue<
|
||||
{
|
||||
name: string;
|
||||
page: string;
|
||||
pos: number;
|
||||
} & Record<string, any>
|
||||
>;
|
||||
|
||||
export async function indexItems({ name, tree }: IndexTreeEvent) {
|
||||
const items: { key: string; value: Item }[] = [];
|
||||
const items: ObjectValue<ItemObject>[] = [];
|
||||
removeQueries(tree);
|
||||
|
||||
// console.log("Indexing items", name);
|
||||
|
||||
const coll = collectNodesOfType(tree, "ListItem");
|
||||
|
||||
const allAttributes: Record<string, any> = {};
|
||||
|
||||
for (const n of coll) {
|
||||
if (!n.children) {
|
||||
continue;
|
||||
@@ -35,16 +32,24 @@ export async function indexItems({ name, tree }: IndexTreeEvent) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const item: Item = {
|
||||
const item: ItemObject = {
|
||||
ref: `${name}@${n.from}`,
|
||||
tags: [],
|
||||
name: "", // to be replaced
|
||||
page: name,
|
||||
pos: n.from!,
|
||||
};
|
||||
|
||||
const textNodes: ParseTree[] = [];
|
||||
let nested: string | undefined;
|
||||
|
||||
collectNodesOfType(n, "Hashtag").forEach((h) => {
|
||||
// Push tag to the list, removing the initial #
|
||||
item.tags.push(h.children![0].text!.substring(1));
|
||||
});
|
||||
|
||||
for (const child of n.children!.slice(1)) {
|
||||
rewritePageRefs(child, name);
|
||||
if (child.type === "OrderedList" || child.type === "BulletList") {
|
||||
nested = renderToText(child);
|
||||
break;
|
||||
}
|
||||
// Extract attributes and remove from tree
|
||||
@@ -52,44 +57,17 @@ export async function indexItems({ name, tree }: IndexTreeEvent) {
|
||||
|
||||
for (const [key, value] of Object.entries(extractedAttributes)) {
|
||||
item[key] = value;
|
||||
allAttributes[key] = value;
|
||||
}
|
||||
textNodes.push(child);
|
||||
}
|
||||
|
||||
item.name = textNodes.map(renderToText).join("").trim();
|
||||
if (nested) {
|
||||
item.nested = nested;
|
||||
}
|
||||
collectNodesOfType(n, "Hashtag").forEach((h) => {
|
||||
if (!item.tags) {
|
||||
item.tags = [];
|
||||
}
|
||||
// Push tag to the list, removinn the initial #
|
||||
item.tags.push(h.children![0].text!.substring(1));
|
||||
});
|
||||
|
||||
items.push({
|
||||
key: `it:${n.from}`,
|
||||
value: item,
|
||||
});
|
||||
if (item.tags.length > 0) {
|
||||
// Only index items with tags
|
||||
items.push(item);
|
||||
}
|
||||
}
|
||||
// console.log("Found", items, "item(s)");
|
||||
await index.batchSet(name, items);
|
||||
await indexAttributes(name, allAttributes, "item");
|
||||
}
|
||||
|
||||
export async function queryProvider({
|
||||
query,
|
||||
}: QueryProviderEvent): Promise<any[]> {
|
||||
const allItems: Item[] = [];
|
||||
for (const { key, page, value } of await index.queryPrefix("it:")) {
|
||||
const [, pos] = key.split(":");
|
||||
allItems.push({
|
||||
...value,
|
||||
page: page,
|
||||
pos: +pos,
|
||||
});
|
||||
}
|
||||
return applyQuery(query, allItems);
|
||||
await indexObjects(name, items);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { asset } from "$sb/plugos-syscall/mod.ts";
|
||||
import { clientStore, editor } from "$sb/silverbullet-syscall/mod.ts";
|
||||
import { queryObjects } from "./api.ts";
|
||||
import { LinkObject } from "./page_links.ts";
|
||||
|
||||
const hideMentionsKey = "hideMentions";
|
||||
|
||||
export async function toggleMentions() {
|
||||
let hideMentions = await clientStore.get(hideMentionsKey);
|
||||
hideMentions = !hideMentions;
|
||||
await clientStore.set(hideMentionsKey, hideMentions);
|
||||
if (!hideMentions) {
|
||||
const name = await editor.getCurrentPage();
|
||||
await renderMentions(name);
|
||||
} else {
|
||||
await editor.hidePanel("ps");
|
||||
}
|
||||
}
|
||||
|
||||
// Triggered when switching pages or upon first load
|
||||
export async function updateMentions() {
|
||||
if (await clientStore.get(hideMentionsKey)) {
|
||||
return;
|
||||
}
|
||||
const name = await editor.getCurrentPage();
|
||||
await renderMentions(name);
|
||||
}
|
||||
|
||||
// use internal navigation via syscall to prevent reloading the full page.
|
||||
export async function navigate(ref: string) {
|
||||
const [page, pos] = ref.split("@");
|
||||
await editor.navigate(page, +pos);
|
||||
}
|
||||
|
||||
function escapeHtml(unsafe: string) {
|
||||
return unsafe.replace(/&/g, "&").replace(/</g, "<").replace(
|
||||
/>/g,
|
||||
">",
|
||||
);
|
||||
}
|
||||
|
||||
async function renderMentions(page: string) {
|
||||
const linksResult = await queryObjects<LinkObject>("link", {
|
||||
// Query all links that point to this page, excluding those that are inside directives and self pointers.
|
||||
filter: ["and", ["!=", ["attr", "page"], ["string", page]], ["and", ["=", [
|
||||
"attr",
|
||||
"toPage",
|
||||
], ["string", page]], ["=", ["attr", "inDirective"], ["boolean", false]]]],
|
||||
});
|
||||
if (linksResult.length === 0) {
|
||||
// Don't show the panel if there are no links here.
|
||||
await editor.hidePanel("ps");
|
||||
} else {
|
||||
const css = await asset.readAsset("asset/style.css");
|
||||
const js = await asset.readAsset("asset/linked_mentions.js");
|
||||
|
||||
await editor.showPanel(
|
||||
"ps",
|
||||
1,
|
||||
` <style>${css}</style>
|
||||
<link rel="stylesheet" href="/.client/main.css" />
|
||||
<div id="sb-main"><div id="sb-editor"><div class="cm-editor">
|
||||
<button id="hide-button">Hide</button>
|
||||
<div class="cm-line sb-line-h2">Linked Mentions</div>
|
||||
<ul id="link-ul">
|
||||
${
|
||||
linksResult.map((link) =>
|
||||
`<li data-ref="${link.ref}"><span class="sb-wiki-link-page">${link.ref}</span>: <code>...${
|
||||
escapeHtml(link.snippet)
|
||||
}...</code></li>`
|
||||
).join("")
|
||||
}
|
||||
</ul>
|
||||
</div></div></div>
|
||||
`,
|
||||
js,
|
||||
);
|
||||
}
|
||||
}
|
||||
+28
-66
@@ -1,75 +1,37 @@
|
||||
import type { IndexEvent, QueryProviderEvent } from "$sb/app_event.ts";
|
||||
import {
|
||||
editor,
|
||||
events,
|
||||
index,
|
||||
markdown,
|
||||
mq,
|
||||
space,
|
||||
system,
|
||||
} from "$sb/syscalls.ts";
|
||||
import type { IndexTreeEvent } from "$sb/app_event.ts";
|
||||
import { space } from "$sb/syscalls.ts";
|
||||
|
||||
import { applyQuery } from "$sb/lib/query.ts";
|
||||
import type { MQMessage } from "$sb/types.ts";
|
||||
import { sleep } from "$sb/lib/async.ts";
|
||||
import type { ObjectValue, PageMeta } from "$sb/types.ts";
|
||||
import { extractFrontmatter } from "$sb/lib/frontmatter.ts";
|
||||
import { extractAttributes } from "$sb/lib/attribute.ts";
|
||||
import { indexObjects } from "./api.ts";
|
||||
|
||||
// Key space:
|
||||
// meta: => metaJson
|
||||
type PageObject = ObjectValue<
|
||||
// The base is PageMeta, but we override lastModified to be a string
|
||||
Omit<PageMeta, "lastModified"> & {
|
||||
lastModified: string; // indexing it as a string
|
||||
} & Record<string, any>
|
||||
>;
|
||||
|
||||
export async function pageQueryProvider({
|
||||
query,
|
||||
}: QueryProviderEvent): Promise<any[]> {
|
||||
return applyQuery(query, await space.listPages());
|
||||
}
|
||||
export async function indexPage({ name, tree }: IndexTreeEvent) {
|
||||
const pageMeta = await space.getPageMeta(name);
|
||||
let pageObj: PageObject = {
|
||||
ref: name,
|
||||
tags: [], // will be overridden in a bit
|
||||
...pageMeta,
|
||||
lastModified: new Date(pageMeta.lastModified).toISOString(),
|
||||
};
|
||||
|
||||
export async function reindexCommand() {
|
||||
await editor.flashNotification("Performing full page reindex...");
|
||||
await reindexSpace();
|
||||
await editor.flashNotification("Done with page index!");
|
||||
}
|
||||
const frontmatter: Record<string, any> = await extractFrontmatter(tree);
|
||||
const toplevelAttributes = await extractAttributes(tree, false);
|
||||
|
||||
export async function reindexSpace() {
|
||||
console.log("Clearing page index...");
|
||||
await index.clearPageIndex();
|
||||
// Executed this way to not have to embed the search plug code here
|
||||
await system.invokeFunction("search.clearIndex");
|
||||
const pages = await space.listPages();
|
||||
// Push them all into the page object
|
||||
pageObj = { ...pageObj, ...frontmatter, ...toplevelAttributes };
|
||||
|
||||
// Queue all page names to be indexed
|
||||
await mq.batchSend("indexQueue", pages.map((page) => page.name));
|
||||
pageObj.tags = ["page", ...pageObj.tags || []];
|
||||
|
||||
// Now let's wait for the processing to finish
|
||||
let queueStats = await mq.getQueueStats("indexQueue");
|
||||
while (queueStats.queued > 0 || queueStats.processing > 0) {
|
||||
sleep(1000);
|
||||
queueStats = await mq.getQueueStats("indexQueue");
|
||||
}
|
||||
// And notify the user
|
||||
console.log("Indexing completed!");
|
||||
}
|
||||
// console.log("Page object", pageObj);
|
||||
|
||||
export async function processIndexQueue(messages: MQMessage[]) {
|
||||
for (const message of messages) {
|
||||
const name: string = message.body;
|
||||
console.log(`Indexing page ${name}`);
|
||||
const text = await space.readPage(name);
|
||||
const parsed = await markdown.parseMarkdown(text);
|
||||
await events.dispatchEvent("page:index", {
|
||||
name,
|
||||
tree: parsed,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearPageIndex(page: string) {
|
||||
// console.log("Clearing page index for page", page);
|
||||
await index.clearPageIndexForPage(page);
|
||||
}
|
||||
|
||||
export async function parseIndexTextRepublish({ name, text }: IndexEvent) {
|
||||
// console.log("Reindexing", name);
|
||||
await events.dispatchEvent("page:index", {
|
||||
name,
|
||||
tree: await markdown.parseMarkdown(text),
|
||||
});
|
||||
// console.log("Extracted page meta data", pageMeta);
|
||||
await indexObjects<PageObject>(name, [pageObj]);
|
||||
}
|
||||
|
||||
+83
-93
@@ -1,48 +1,53 @@
|
||||
import { index } from "$sb/syscalls.ts";
|
||||
import { findNodeOfType, traverseTree } from "$sb/lib/tree.ts";
|
||||
import { extractFrontmatter } from "$sb/lib/frontmatter.ts";
|
||||
import { extractAttributes } from "$sb/lib/attribute.ts";
|
||||
import { IndexTreeEvent, QueryProviderEvent } from "$sb/app_event.ts";
|
||||
import { applyQuery } from "$sb/lib/query.ts";
|
||||
import { findNodeOfType, renderToText, traverseTree } from "$sb/lib/tree.ts";
|
||||
import { IndexTreeEvent } from "$sb/app_event.ts";
|
||||
import { resolvePath } from "$sb/lib/resolve.ts";
|
||||
import { indexAttributes } from "./attributes.ts";
|
||||
import { indexObjects, queryObjects } from "./api.ts";
|
||||
import { ObjectValue } from "$sb/types.ts";
|
||||
|
||||
// Key space:
|
||||
// l:toPage:pos => {name: pageName, inDirective: true, asTemplate: true}
|
||||
|
||||
export const backlinkPrefix = `l:`;
|
||||
|
||||
export type BacklinkEntry = {
|
||||
name: string;
|
||||
export type LinkObject = {
|
||||
ref: string;
|
||||
tags: string[];
|
||||
// The page the link points to
|
||||
toPage: string;
|
||||
// The page the link occurs in
|
||||
page: string;
|
||||
pos: number;
|
||||
snippet: string;
|
||||
alias?: string;
|
||||
inDirective?: boolean;
|
||||
asTemplate?: boolean;
|
||||
inDirective: boolean;
|
||||
asTemplate: boolean;
|
||||
};
|
||||
|
||||
export function extractSnippet(text: string, pos: number): string {
|
||||
let prefix = "";
|
||||
for (let i = pos - 1; i > 0; i--) {
|
||||
if (text[i] === "\n") {
|
||||
break;
|
||||
}
|
||||
prefix = text[i] + prefix;
|
||||
if (prefix.length > 25) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let suffix = "";
|
||||
for (let i = pos; i < text.length; i++) {
|
||||
if (text[i] === "\n") {
|
||||
break;
|
||||
}
|
||||
suffix += text[i];
|
||||
if (suffix.length > 25) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return prefix + suffix;
|
||||
}
|
||||
|
||||
export async function indexLinks({ name, tree }: IndexTreeEvent) {
|
||||
const backLinks: { key: string; value: BacklinkEntry }[] = [];
|
||||
const links: ObjectValue<LinkObject>[] = [];
|
||||
// [[Style Links]]
|
||||
// console.log("Now indexing links for", name);
|
||||
const pageMeta = await extractFrontmatter(tree);
|
||||
const toplevelAttributes = await extractAttributes(tree, false);
|
||||
if (
|
||||
Object.keys(pageMeta).length > 0 ||
|
||||
Object.keys(toplevelAttributes).length > 0
|
||||
) {
|
||||
for (const [k, v] of Object.entries(toplevelAttributes)) {
|
||||
pageMeta[k] = v;
|
||||
}
|
||||
// Don't index meta data starting with $
|
||||
for (const key in pageMeta) {
|
||||
if (key.startsWith("$")) {
|
||||
delete pageMeta[key];
|
||||
}
|
||||
}
|
||||
// console.log("Extracted page meta data", pageMeta);
|
||||
await index.set(name, "meta:", pageMeta);
|
||||
}
|
||||
|
||||
await indexAttributes(name, pageMeta, "page");
|
||||
const pageText = renderToText(tree);
|
||||
|
||||
let directiveDepth = 0;
|
||||
traverseTree(tree, (n): boolean => {
|
||||
@@ -54,9 +59,16 @@ export async function indexLinks({ name, tree }: IndexTreeEvent) {
|
||||
name,
|
||||
pageRef.children![0].text!.slice(2, -2),
|
||||
);
|
||||
backLinks.push({
|
||||
key: `${backlinkPrefix}${pageRefName}:${pageRef.from! + 2}`,
|
||||
value: { name, asTemplate: true },
|
||||
const pos = pageRef.from! + 2;
|
||||
links.push({
|
||||
ref: `${name}@${pos}`,
|
||||
tags: ["link"],
|
||||
toPage: pageRefName,
|
||||
pos: pos,
|
||||
snippet: extractSnippet(pageText, pos),
|
||||
page: name,
|
||||
asTemplate: true,
|
||||
inDirective: false,
|
||||
});
|
||||
}
|
||||
const directiveText = n.children![0].text;
|
||||
@@ -65,18 +77,23 @@ export async function indexLinks({ name, tree }: IndexTreeEvent) {
|
||||
const match = /\[\[(.+)\]\]/.exec(directiveText);
|
||||
if (match) {
|
||||
const pageRefName = resolvePath(name, match[1]);
|
||||
backLinks.push({
|
||||
key: `${backlinkPrefix}${pageRefName}:${
|
||||
n.from! + match.index! + 2
|
||||
}`,
|
||||
value: { name, asTemplate: true },
|
||||
const pos = n.from! + match.index! + 2;
|
||||
links.push({
|
||||
ref: `${name}@${pos}`,
|
||||
tags: ["link"],
|
||||
toPage: pageRefName,
|
||||
page: name,
|
||||
snippet: extractSnippet(pageText, pos),
|
||||
pos: pos,
|
||||
asTemplate: true,
|
||||
inDirective: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
if (n.type === "DirectiveStop") {
|
||||
if (n.type === "DirectiveEnd") {
|
||||
directiveDepth--;
|
||||
return true;
|
||||
}
|
||||
@@ -85,66 +102,39 @@ export async function indexLinks({ name, tree }: IndexTreeEvent) {
|
||||
const wikiLinkPage = findNodeOfType(n, "WikiLinkPage")!;
|
||||
const wikiLinkAlias = findNodeOfType(n, "WikiLinkAlias");
|
||||
let toPage = resolvePath(name, wikiLinkPage.children![0].text!);
|
||||
const pos = wikiLinkPage.from!;
|
||||
if (toPage.includes("@")) {
|
||||
toPage = toPage.split("@")[0];
|
||||
}
|
||||
const blEntry: BacklinkEntry = { name };
|
||||
const link: LinkObject = {
|
||||
ref: `${name}@${pos}`,
|
||||
tags: ["link"],
|
||||
toPage: toPage,
|
||||
snippet: extractSnippet(pageText, pos),
|
||||
pos,
|
||||
page: name,
|
||||
inDirective: false,
|
||||
asTemplate: false,
|
||||
};
|
||||
if (directiveDepth > 0) {
|
||||
blEntry.inDirective = true;
|
||||
link.inDirective = true;
|
||||
}
|
||||
if (wikiLinkAlias) {
|
||||
blEntry.alias = wikiLinkAlias.children![0].text!;
|
||||
link.alias = wikiLinkAlias.children![0].text!;
|
||||
}
|
||||
backLinks.push({
|
||||
key: `${backlinkPrefix}${toPage}:${wikiLinkPage.from}`,
|
||||
value: blEntry,
|
||||
});
|
||||
links.push(link);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
// console.log("Found", backLinks, "page link(s)");
|
||||
await index.batchSet(name, backLinks);
|
||||
await indexObjects(name, links);
|
||||
}
|
||||
|
||||
export async function linkQueryProvider({
|
||||
query,
|
||||
pageName,
|
||||
}: QueryProviderEvent): Promise<any[]> {
|
||||
const links: any[] = [];
|
||||
for (
|
||||
const { value: blEntry, key } of await index.queryPrefix(
|
||||
`${backlinkPrefix}${pageName}:`,
|
||||
)
|
||||
) {
|
||||
const [, , pos] = key.split(":"); // Key: l:page:pos
|
||||
if (!blEntry.inDirective) {
|
||||
blEntry.inDirective = false;
|
||||
}
|
||||
if (!blEntry.asTemplate) {
|
||||
blEntry.asTemplate = false;
|
||||
}
|
||||
links.push({ ...blEntry, pos });
|
||||
}
|
||||
return applyQuery(query, links);
|
||||
}
|
||||
|
||||
type BackLinkPage = {
|
||||
page: string;
|
||||
pos: number;
|
||||
};
|
||||
|
||||
export async function getBackLinks(pageName: string): Promise<BackLinkPage[]> {
|
||||
const allBackLinks = await index.queryPrefix(
|
||||
`${backlinkPrefix}${pageName}:`,
|
||||
);
|
||||
const pagesToUpdate: BackLinkPage[] = [];
|
||||
for (const { key, value: { name } } of allBackLinks) {
|
||||
const keyParts = key.split(":");
|
||||
pagesToUpdate.push({
|
||||
page: name,
|
||||
pos: +keyParts[keyParts.length - 1],
|
||||
});
|
||||
}
|
||||
return pagesToUpdate;
|
||||
export async function getBackLinks(
|
||||
pageName: string,
|
||||
): Promise<LinkObject[]> {
|
||||
return (await queryObjects<LinkObject>("link", {
|
||||
filter: ["=", ["attr", "toPage"], ["string", pageName]],
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { ObjectQuery, ObjectValue } from "$sb/types.ts";
|
||||
import { invokeFunction } from "$sb/silverbullet-syscall/system.ts";
|
||||
|
||||
export function indexObjects<T>(
|
||||
page: string,
|
||||
objects: ObjectValue<T>[],
|
||||
): Promise<void> {
|
||||
return invokeFunction("index.indexObjects", page, objects);
|
||||
}
|
||||
|
||||
export function queryObjects<T>(
|
||||
tag: string,
|
||||
query: ObjectQuery,
|
||||
): Promise<ObjectValue<T>[]> {
|
||||
return invokeFunction("index.queryObjects", tag, query);
|
||||
}
|
||||
|
||||
export function getObjectByRef<T>(
|
||||
page: string,
|
||||
tag: string,
|
||||
ref: string,
|
||||
): Promise<ObjectValue<T>[]> {
|
||||
return invokeFunction("index.getObjectByRef", page, tag, ref);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { assertEquals } from "../../test_deps.ts";
|
||||
import { extractSnippet } from "./page_links.ts";
|
||||
|
||||
Deno.test("Snippet extraction", () => {
|
||||
const sample1 = `This is a test
|
||||
and a [[new]] line that runs super duper duper duper duper duper long
|
||||
[[SETTINGS]]
|
||||
super`;
|
||||
assertEquals(
|
||||
extractSnippet(sample1, sample1.indexOf("[[new]]")),
|
||||
"and a [[new]] line that runs sup",
|
||||
);
|
||||
assertEquals(
|
||||
extractSnippet(sample1, sample1.indexOf("[[SETTINGS]]")),
|
||||
"[[SETTINGS]]",
|
||||
);
|
||||
});
|
||||
+54
-43
@@ -1,67 +1,78 @@
|
||||
import { collectNodesOfType } from "$sb/lib/tree.ts";
|
||||
import { index } from "$sb/syscalls.ts";
|
||||
import type {
|
||||
CompleteEvent,
|
||||
IndexTreeEvent,
|
||||
QueryProviderEvent,
|
||||
} from "$sb/app_event.ts";
|
||||
import { applyQuery, removeQueries } from "$sb/lib/query.ts";
|
||||
import type { CompleteEvent, IndexTreeEvent } from "$sb/app_event.ts";
|
||||
import { removeQueries } from "$sb/lib/query.ts";
|
||||
import { extractFrontmatter } from "$sb/lib/frontmatter.ts";
|
||||
import { indexObjects, queryObjects } from "./api.ts";
|
||||
import {
|
||||
addParentPointers,
|
||||
collectNodesOfType,
|
||||
findParentMatching,
|
||||
} from "$sb/lib/tree.ts";
|
||||
|
||||
// Key space
|
||||
// tag:TAG => true (for completion)
|
||||
export type TagObject = {
|
||||
ref: string;
|
||||
tags: string[];
|
||||
name: string;
|
||||
page: string;
|
||||
parent: string;
|
||||
};
|
||||
|
||||
export async function indexTags({ name, tree }: IndexTreeEvent) {
|
||||
removeQueries(tree);
|
||||
const allTags = new Set<string>();
|
||||
const { tags } = await extractFrontmatter(tree);
|
||||
if (Array.isArray(tags)) {
|
||||
tags.forEach((t) => allTags.add(t));
|
||||
const tags = new Set<string>(); // name:parent
|
||||
addParentPointers(tree);
|
||||
const pageTags: string[] = (await extractFrontmatter(tree)).tags || [];
|
||||
for (const pageTag of pageTags) {
|
||||
tags.add(`${pageTag}:page`);
|
||||
}
|
||||
collectNodesOfType(tree, "Hashtag").forEach((n) => {
|
||||
allTags.add(n.children![0].text!.substring(1));
|
||||
collectNodesOfType(tree, "Hashtag").forEach((h) => {
|
||||
const tagName = h.children![0].text!.substring(1);
|
||||
// Check if this occurs in the context of a task
|
||||
if (findParentMatching(h, (n) => n.type === "Task")) {
|
||||
tags.add(`${tagName}:task`);
|
||||
} else if (findParentMatching(h, (n) => n.type === "ListItem")) {
|
||||
// Or an item
|
||||
tags.add(`${tagName}:item`);
|
||||
}
|
||||
});
|
||||
await index.batchSet(
|
||||
// console.log("Indexing these tags", tags);
|
||||
await indexObjects<TagObject>(
|
||||
name,
|
||||
[...allTags].map((t) => ({ key: `tag:${t}`, value: t })),
|
||||
[...tags].map((tag) => {
|
||||
const [tagName, parent] = tag.split(":");
|
||||
return {
|
||||
ref: tagName,
|
||||
tags: ["tag"],
|
||||
name: tagName,
|
||||
page: name,
|
||||
parent,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const taskPrefixRegex = /^\s*[\-\*]\s+\[([^\]]+)\]/;
|
||||
const itemPrefixRegex = /^\s*[\-\*]\s+/;
|
||||
|
||||
export async function tagComplete(completeEvent: CompleteEvent) {
|
||||
const match = /#[^#\s]+$/.exec(completeEvent.linePrefix);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const tagPrefix = match[0].substring(1);
|
||||
const allTags = await index.queryPrefix(`tag:${tagPrefix}`);
|
||||
let parent = "page";
|
||||
if (taskPrefixRegex.test(completeEvent.linePrefix)) {
|
||||
parent = "task";
|
||||
} else if (itemPrefixRegex.test(completeEvent.linePrefix)) {
|
||||
parent = "item";
|
||||
}
|
||||
const allTags = await queryObjects<TagObject>("tag", {
|
||||
filter: ["=", ["attr", "parent"], ["string", parent]],
|
||||
});
|
||||
return {
|
||||
from: completeEvent.pos - tagPrefix.length,
|
||||
options: allTags.map((tag) => ({
|
||||
label: tag.value,
|
||||
label: tag.name,
|
||||
type: "tag",
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
type Tag = {
|
||||
name: string;
|
||||
freq: number;
|
||||
};
|
||||
|
||||
export async function tagProvider({ query }: QueryProviderEvent) {
|
||||
const allTags = new Map<string, number>();
|
||||
for (const { value } of await index.queryPrefix("tag:")) {
|
||||
let currentFreq = allTags.get(value);
|
||||
if (!currentFreq) {
|
||||
currentFreq = 0;
|
||||
}
|
||||
allTags.set(value, currentFreq + 1);
|
||||
}
|
||||
return applyQuery(
|
||||
query,
|
||||
[...allTags.entries()].map(([name, freq]) => ({
|
||||
name,
|
||||
freq,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ Deno.test("Markdown render", async () => {
|
||||
new URL("test/example.md", import.meta.url).pathname,
|
||||
);
|
||||
const tree = parse(lang, testFile);
|
||||
await renderMarkdownToHtml(tree, {
|
||||
renderMarkdownToHtml(tree, {
|
||||
failOnUnknown: true,
|
||||
});
|
||||
// console.log("HTML", html);
|
||||
@@ -34,7 +34,7 @@ Deno.test("Smart hard break test", async () => {
|
||||
*world!*`;
|
||||
const lang = buildMarkdown([]);
|
||||
const tree = parse(lang, example);
|
||||
const html = await renderMarkdownToHtml(tree, {
|
||||
const html = renderMarkdownToHtml(tree, {
|
||||
failOnUnknown: true,
|
||||
smartHardBreak: true,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
collectNodesOfType,
|
||||
findNodeOfType,
|
||||
ParseTree,
|
||||
renderToText,
|
||||
@@ -115,9 +116,13 @@ function render(
|
||||
case "FencedCode":
|
||||
case "CodeBlock": {
|
||||
// Clear out top-level indent blocks
|
||||
const lang = findNodeOfType(t, "CodeInfo");
|
||||
t.children = t.children!.filter((c) => c.type);
|
||||
return {
|
||||
name: "pre",
|
||||
attrs: {
|
||||
"data-lang": lang ? lang.children![0].text : undefined,
|
||||
},
|
||||
body: cleanTags(mapRender(t.children!)),
|
||||
};
|
||||
}
|
||||
@@ -235,6 +240,7 @@ function render(
|
||||
name: "a",
|
||||
attrs: {
|
||||
href: `/${ref.replace("@", "#")}`,
|
||||
"data-ref": ref,
|
||||
},
|
||||
body: linkText,
|
||||
};
|
||||
@@ -255,12 +261,25 @@ function render(
|
||||
body: t.children![0].text!,
|
||||
};
|
||||
|
||||
case "Task":
|
||||
case "Task": {
|
||||
let externalTaskRef = "";
|
||||
collectNodesOfType(t, "WikiLinkPage").forEach((wikilink) => {
|
||||
const ref = wikilink.children![0].text!;
|
||||
if (!externalTaskRef && ref.includes("@")) {
|
||||
externalTaskRef = ref;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
name: "span",
|
||||
attrs: externalTaskRef
|
||||
? {
|
||||
"data-external-task-ref": externalTaskRef,
|
||||
}
|
||||
: {},
|
||||
body: cleanTags(mapRender(t.children!)),
|
||||
};
|
||||
|
||||
}
|
||||
case "TaskState": {
|
||||
// child[0] = marker, child[1] = state, child[2] = marker
|
||||
const stateText = t.children![1].text!;
|
||||
@@ -269,8 +288,8 @@ function render(
|
||||
name: "input",
|
||||
attrs: {
|
||||
type: "checkbox",
|
||||
checked: t.children![0].text !== "[ ]" ? "checked" : undefined,
|
||||
"data-onclick": JSON.stringify(["task", t.to]),
|
||||
checked: stateText !== " " ? "checked" : undefined,
|
||||
"data-state": stateText,
|
||||
},
|
||||
body: "",
|
||||
};
|
||||
|
||||
@@ -12,7 +12,7 @@ export async function markdownWidget(
|
||||
});
|
||||
return Promise.resolve({
|
||||
html: html,
|
||||
script: `updateHeight();
|
||||
script: `
|
||||
document.addEventListener("click", () => {
|
||||
api({type: "blur"});
|
||||
});`,
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
async function init() {
|
||||
// Make edit button send the "blur" API call so that the MD code is visible
|
||||
document.getElementById("edit-button").addEventListener("click", () => {
|
||||
api({ type: "blur" });
|
||||
});
|
||||
document.getElementById("reload-button").addEventListener("click", () => {
|
||||
api({ type: "reload" });
|
||||
});
|
||||
|
||||
document.querySelectorAll("a[data-ref]").forEach((el) => {
|
||||
el.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
syscall("editor.navigate", el.dataset.ref);
|
||||
});
|
||||
});
|
||||
|
||||
// Find all fenced code blocks and replace them with iframes (if a code widget is defined for them)
|
||||
const allWidgets = document.querySelectorAll("pre[data-lang]");
|
||||
for (const widget of allWidgets) {
|
||||
const lang = widget.getAttribute("data-lang");
|
||||
const body = widget.innerText;
|
||||
|
||||
try {
|
||||
const result = await syscall("widget.render", lang, body);
|
||||
const iframe = document.createElement("iframe");
|
||||
iframe.srcdoc = panelHtml; // set as a global
|
||||
iframe.onload = () => {
|
||||
iframe.contentWindow.postMessage({
|
||||
type: "html",
|
||||
theme: document.getElementsByTagName("html")[0].getAttribute(
|
||||
"data-theme",
|
||||
),
|
||||
...result,
|
||||
}, "*");
|
||||
};
|
||||
widget.parentNode.replaceChild(iframe, widget);
|
||||
|
||||
globalThis.addEventListener("message", (e) => {
|
||||
if (e.source !== iframe.contentWindow) {
|
||||
return;
|
||||
}
|
||||
const messageData = e.data;
|
||||
switch (messageData.type) {
|
||||
case "setHeight":
|
||||
iframe.style.height = messageData.height + "px";
|
||||
// Propagate height setting to parent
|
||||
updateHeight();
|
||||
break;
|
||||
case "syscall": {
|
||||
// Intercept syscall messages and send them to the parent
|
||||
const { id, name, args } = messageData;
|
||||
syscall(name, ...args).then((result) => {
|
||||
iframe.contentWindow.postMessage(
|
||||
{ id, type: "syscall-response", result },
|
||||
"*",
|
||||
);
|
||||
}).catch((error) => {
|
||||
iframe.contentWindow.postMessage({
|
||||
id,
|
||||
type: "syscall-response",
|
||||
error,
|
||||
}, "*");
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
// Bubble up any other messages to parent iframe
|
||||
window.parent.postMessage(messageData, "*");
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
if (e.message.includes("not found")) {
|
||||
// Not a code widget, ignore
|
||||
} else {
|
||||
console.error("Error rendering widget", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find all task toggles and propagate their state
|
||||
document.querySelectorAll("span[data-external-task-ref]").forEach((el) => {
|
||||
const taskRef = el.dataset.externalTaskRef;
|
||||
el.querySelector("input[type=checkbox]").addEventListener("change", (e) => {
|
||||
const oldState = e.target.dataset.state;
|
||||
const newState = oldState === " " ? "x" : " ";
|
||||
// Update state in DOM as well for future toggles
|
||||
e.target.dataset.state = newState;
|
||||
console.log("Toggling task", taskRef);
|
||||
syscall(
|
||||
"system.invokeFunction",
|
||||
"tasks.updateTaskState",
|
||||
taskRef,
|
||||
oldState,
|
||||
newState,
|
||||
).catch(
|
||||
console.error,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
init().catch(console.error);
|
||||
@@ -0,0 +1,45 @@
|
||||
body {
|
||||
font-family: var(--editor-font);
|
||||
background-color: var(--root-background-color);
|
||||
color: var(--root-color);
|
||||
overflow: scroll;
|
||||
}
|
||||
|
||||
ul li p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body:hover #button-bar,
|
||||
body:active #button-bar {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#button-bar {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 3px;
|
||||
display: none;
|
||||
background: rgb(255 255 255 / 0.9);
|
||||
padding: 0 3px;
|
||||
}
|
||||
|
||||
#button-bar button {
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: var(--root-color);
|
||||
}
|
||||
|
||||
#edit-button {
|
||||
margin-left: -10px;
|
||||
}
|
||||
|
||||
li code {
|
||||
font-size: 80%;
|
||||
color: #a5a4a4;
|
||||
}
|
||||
|
||||
iframe {
|
||||
border: none;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { CompleteEvent } from "$sb/app_event.ts";
|
||||
import { events } from "$sb/syscalls.ts";
|
||||
import {
|
||||
AttributeCompleteEvent,
|
||||
AttributeCompletion,
|
||||
} from "../index/attributes.ts";
|
||||
|
||||
export async function queryComplete(completeEvent: CompleteEvent) {
|
||||
const fencedParent = completeEvent.parentNodes.find((node) =>
|
||||
node === "FencedCode:query"
|
||||
);
|
||||
if (!fencedParent) {
|
||||
return null;
|
||||
}
|
||||
let querySourceMatch = /^\s*([\w\-_]*)$/.exec(
|
||||
completeEvent.linePrefix,
|
||||
);
|
||||
if (querySourceMatch) {
|
||||
const allEvents = await events.listEvents();
|
||||
|
||||
const completionOptions = allEvents
|
||||
.filter((eventName) =>
|
||||
eventName.startsWith("query:") && !eventName.includes("*")
|
||||
)
|
||||
.map((source) => ({
|
||||
label: source.substring("query:".length),
|
||||
}));
|
||||
|
||||
const allObjectTypes: string[] = (await events.dispatchEvent("query_", {}))
|
||||
.flat();
|
||||
|
||||
for (const type of allObjectTypes) {
|
||||
completionOptions.push({
|
||||
label: type,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
from: completeEvent.pos - querySourceMatch[1].length,
|
||||
options: completionOptions,
|
||||
};
|
||||
}
|
||||
|
||||
querySourceMatch = /^\s*([\w\-_]*)/.exec(
|
||||
completeEvent.linePrefix,
|
||||
);
|
||||
const whereMatch =
|
||||
/(where|order\s+by|and|or|select(\s+[\w\s,]+)?)\s+([\w\-_]*)$/
|
||||
.exec(
|
||||
completeEvent.linePrefix,
|
||||
);
|
||||
if (querySourceMatch && whereMatch) {
|
||||
const type = querySourceMatch[1];
|
||||
const attributePrefix = whereMatch[3];
|
||||
const completions = (await events.dispatchEvent(
|
||||
`attribute:complete:${type}`,
|
||||
{
|
||||
source: type,
|
||||
prefix: attributePrefix,
|
||||
} as AttributeCompleteEvent,
|
||||
)).flat() as AttributeCompletion[];
|
||||
return {
|
||||
from: completeEvent.pos - attributePrefix.length,
|
||||
options: attributeCompletionsToCMCompletion(completions),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function attributeCompletionsToCMCompletion(
|
||||
completions: AttributeCompletion[],
|
||||
) {
|
||||
return completions.map(
|
||||
(completion) => ({
|
||||
label: completion.name,
|
||||
detail: `${completion.attributeType} (${completion.source})`,
|
||||
type: "attribute",
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
name: query
|
||||
assets:
|
||||
- "assets/*"
|
||||
functions:
|
||||
queryWidget:
|
||||
path: query.ts:widget
|
||||
codeWidget: query
|
||||
|
||||
templateWidget:
|
||||
path: template.ts:widget
|
||||
codeWidget: template
|
||||
|
||||
queryComplete:
|
||||
path: complete.ts:queryComplete
|
||||
events:
|
||||
- editor:complete
|
||||
|
||||
# Slash commands
|
||||
insertQuery:
|
||||
redirect: template.insertTemplateText
|
||||
slashCommand:
|
||||
name: query
|
||||
description: Insert a query
|
||||
value: |
|
||||
```query
|
||||
|^|
|
||||
```
|
||||
insertInclude:
|
||||
redirect: template.insertTemplateText
|
||||
slashCommand:
|
||||
name: include
|
||||
description: Include another page
|
||||
value: |
|
||||
<!-- #include [[|^|]] -->
|
||||
|
||||
<!-- /include -->
|
||||
insertUseTemplate:
|
||||
redirect: template.insertTemplateText
|
||||
slashCommand:
|
||||
name: template
|
||||
description: Use a template
|
||||
value: |
|
||||
```template
|
||||
page: "[[|^|]]"
|
||||
```
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { WidgetContent } from "$sb/app_event.ts";
|
||||
import { editor, events, language, markdown, space } from "$sb/syscalls.ts";
|
||||
import { parseTreeToAST } from "$sb/lib/tree.ts";
|
||||
import { astToKvQuery } from "$sb/lib/parse-query.ts";
|
||||
import { jsonToMDTable, renderTemplate } from "../directive/util.ts";
|
||||
import { renderMarkdownToHtml } from "../markdown/markdown_render.ts";
|
||||
import { replaceTemplateVars } from "../template/template.ts";
|
||||
import { prepareJS, wrapHTML } from "./util.ts";
|
||||
|
||||
export async function widget(bodyText: string): Promise<WidgetContent> {
|
||||
const pageMeta = await space.getPageMeta(await editor.getCurrentPage());
|
||||
|
||||
try {
|
||||
const queryAST = parseTreeToAST(
|
||||
await language.parseLanguage("query", bodyText),
|
||||
);
|
||||
const parsedQuery = astToKvQuery(
|
||||
JSON.parse(
|
||||
await replaceTemplateVars(JSON.stringify(queryAST[1]), pageMeta),
|
||||
),
|
||||
);
|
||||
|
||||
// console.log("actual query", parsedQuery);
|
||||
const eventName = `query:${parsedQuery.querySource}`;
|
||||
|
||||
let resultMarkdown = "";
|
||||
|
||||
// console.log("Parsed query", parsedQuery);
|
||||
// Let's dispatch an event and see what happens
|
||||
const results = await events.dispatchEvent(
|
||||
eventName,
|
||||
{ query: parsedQuery, pageName: pageMeta.name },
|
||||
30 * 1000,
|
||||
);
|
||||
if (results.length === 0) {
|
||||
// This means there was no handler for the event which means it's unsupported
|
||||
return {
|
||||
html:
|
||||
`**Error:** Unsupported query source '${parsedQuery.querySource}'`,
|
||||
};
|
||||
} else {
|
||||
const allResults = results.flat();
|
||||
if (allResults.length === 0) {
|
||||
resultMarkdown = "No results";
|
||||
} else {
|
||||
if (parsedQuery.render) {
|
||||
// Configured a custom rendering template, let's use it!
|
||||
const rendered = await renderTemplate(
|
||||
pageMeta,
|
||||
parsedQuery.render,
|
||||
allResults,
|
||||
);
|
||||
resultMarkdown = rendered.trim();
|
||||
} else {
|
||||
// TODO: At this point it's a bit pointless to first render a markdown table, and then convert that to HTML
|
||||
// We should just render the HTML table directly
|
||||
resultMarkdown = jsonToMDTable(allResults);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse markdown to a ParseTree
|
||||
const mdTree = await markdown.parseMarkdown(resultMarkdown);
|
||||
// And then render it to HTML
|
||||
const html = renderMarkdownToHtml(mdTree, { smartHardBreak: true });
|
||||
return {
|
||||
html: await wrapHTML(`
|
||||
${parsedQuery.render ? "" : `<div class="sb-table-widget">`}
|
||||
${html}
|
||||
${parsedQuery.render ? "" : `</div>`}
|
||||
`),
|
||||
script: await prepareJS(),
|
||||
};
|
||||
} catch (e: any) {
|
||||
return {
|
||||
html: await wrapHTML(`<b>Error:</b> ${e.message}`),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { WidgetContent } from "$sb/app_event.ts";
|
||||
import { editor, handlebars, markdown, space, YAML } from "$sb/syscalls.ts";
|
||||
import { renderMarkdownToHtml } from "../markdown/markdown_render.ts";
|
||||
import { prepareJS, wrapHTML } from "./util.ts";
|
||||
|
||||
type TemplateConfig = {
|
||||
// Pull the template from a page
|
||||
page?: string;
|
||||
// Or use a string directly
|
||||
template?: string;
|
||||
// Optional argument to pass
|
||||
value?: any;
|
||||
// If true, don't render the template, just use it as-is
|
||||
raw?: boolean;
|
||||
};
|
||||
|
||||
export async function widget(bodyText: string): Promise<WidgetContent> {
|
||||
const pageMeta = await space.getPageMeta(await editor.getCurrentPage());
|
||||
|
||||
try {
|
||||
const config: TemplateConfig = await YAML.parse(bodyText);
|
||||
let templateText = config.template || "";
|
||||
if (config.page) {
|
||||
let page = config.page;
|
||||
if (!page) {
|
||||
throw new Error("Missing `page`");
|
||||
}
|
||||
|
||||
if (page.startsWith("[[")) {
|
||||
page = page.slice(2, -2);
|
||||
}
|
||||
templateText = await space.readPage(page);
|
||||
}
|
||||
|
||||
const rendered = config.raw
|
||||
? templateText
|
||||
: await handlebars.renderTemplate(
|
||||
templateText,
|
||||
config.value,
|
||||
{
|
||||
page: pageMeta,
|
||||
},
|
||||
);
|
||||
const parsedMarkdown = await markdown.parseMarkdown(rendered);
|
||||
const html = renderMarkdownToHtml(parsedMarkdown, {
|
||||
smartHardBreak: true,
|
||||
});
|
||||
|
||||
return {
|
||||
html: await wrapHTML(html),
|
||||
script: await prepareJS(),
|
||||
};
|
||||
} catch (e: any) {
|
||||
return {
|
||||
html: `<b>Error:</b> ${e.message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { asset } from "$sb/syscalls.ts";
|
||||
import { panelHtml } from "../../web/components/panel_html.ts";
|
||||
|
||||
export async function prepareJS() {
|
||||
const iframeJS = await asset.readAsset("assets/common.js");
|
||||
|
||||
return `
|
||||
const panelHtml = \`${panelHtml}\`;
|
||||
${iframeJS}
|
||||
`;
|
||||
}
|
||||
|
||||
export async function wrapHTML(html: string): Promise<string> {
|
||||
const css = await asset.readAsset("assets/style.css");
|
||||
|
||||
return `
|
||||
<!-- Load SB's own CSS here too -->
|
||||
<link rel="stylesheet" href="/.client/main.css" />
|
||||
<!-- In addition to some custom CSS -->
|
||||
<style>${css}</style>
|
||||
<!-- Wrap the whole thing in something SB-like to get access to styles -->
|
||||
<div id="sb-main"><div id="sb-editor"><div class="cm-editor">
|
||||
<!-- And add an edit button -->
|
||||
<div id="button-bar">
|
||||
<button id="reload-button" title="Reload"><svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"></polyline><polyline points="1 20 1 14 7 14"></polyline><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path></svg></button>
|
||||
<button id="edit-button" title="Edit"><svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-edit"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg></button>
|
||||
</div>
|
||||
${html}
|
||||
</div></div></div>
|
||||
`;
|
||||
}
|
||||
+16
-18
@@ -1,44 +1,42 @@
|
||||
import { KV, KvKey } from "$sb/types.ts";
|
||||
import { assertEquals } from "../../test_deps.ts";
|
||||
import { BatchKVStore, SimpleSearchEngine } from "./engine.ts";
|
||||
|
||||
class InMemoryBatchKVStore implements BatchKVStore {
|
||||
private store = new Map<string, any>();
|
||||
|
||||
get(keys: string[]): Promise<(any | undefined)[]> {
|
||||
const results: (any | undefined)[] = keys.map((key) => this.store.get(key));
|
||||
return Promise.resolve(results);
|
||||
}
|
||||
|
||||
queryPrefix(prefix: string): Promise<[string, any][]> {
|
||||
const results: [string, any][] = [];
|
||||
query({ prefix }: { prefix: KvKey }): Promise<KV[]> {
|
||||
const results: KV[] = [];
|
||||
entries:
|
||||
for (const [key, value] of this.store.entries()) {
|
||||
if (key.startsWith(prefix)) {
|
||||
results.push([key, value]);
|
||||
const parsedKey: string[] = JSON.parse(key);
|
||||
for (let i = 0; i < prefix.length; i++) {
|
||||
if (prefix[i] !== parsedKey[i]) {
|
||||
continue entries;
|
||||
}
|
||||
}
|
||||
results.push({ key: parsedKey, value });
|
||||
}
|
||||
return Promise.resolve(results);
|
||||
}
|
||||
|
||||
set(entries: Map<string, any>): Promise<void> {
|
||||
for (const [key, value] of entries) {
|
||||
this.store.set(key, value);
|
||||
batchSet(kvs: KV[]): Promise<void> {
|
||||
for (const { key, value } of kvs) {
|
||||
this.store.set(JSON.stringify(key), value);
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
delete(keys: string[]): Promise<void> {
|
||||
batchDel(keys: KvKey[]): Promise<void> {
|
||||
for (const key of keys) {
|
||||
this.store.delete(key);
|
||||
this.store.delete(JSON.stringify(key));
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
Deno.test("Test full text search", async () => {
|
||||
const engine = new SimpleSearchEngine(
|
||||
new InMemoryBatchKVStore(),
|
||||
new InMemoryBatchKVStore(),
|
||||
);
|
||||
const engine = new SimpleSearchEngine(new InMemoryBatchKVStore());
|
||||
|
||||
await engine.indexDocument({ id: "1", text: "The quick brown fox" });
|
||||
await engine.indexDocument({ id: "2", text: "jumps over the lazy dogs" });
|
||||
|
||||
+27
-21
@@ -1,4 +1,5 @@
|
||||
import { stemmer } from "https://esm.sh/porter-stemmer@0.9.1";
|
||||
import { KV, KvKey } from "$sb/types.ts";
|
||||
|
||||
export type Document = {
|
||||
id: string;
|
||||
@@ -6,10 +7,9 @@ export type Document = {
|
||||
};
|
||||
|
||||
export interface BatchKVStore {
|
||||
get(keys: string[]): Promise<(any | undefined)[]>;
|
||||
set(entries: Map<string, any>): Promise<void>;
|
||||
delete(keys: string[]): Promise<void>;
|
||||
queryPrefix(prefix: string): Promise<[string, any][]>;
|
||||
batchSet(kvs: KV[]): Promise<void>;
|
||||
batchDel(keys: KvKey[]): Promise<void>;
|
||||
query(options: { prefix: KvKey }): Promise<KV[]>;
|
||||
}
|
||||
|
||||
type ResultObject = {
|
||||
@@ -22,7 +22,7 @@ export class SimpleSearchEngine {
|
||||
|
||||
constructor(
|
||||
public index: BatchKVStore,
|
||||
public reverseIndex: BatchKVStore,
|
||||
// public reverseIndex: BatchKVStore,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -68,8 +68,16 @@ export class SimpleSearchEngine {
|
||||
|
||||
// console.log("updateIndexMap", updateIndexMap);
|
||||
|
||||
await this.index.set(updateIndexMap);
|
||||
await this.reverseIndex.set(updateReverseIndexMap);
|
||||
await this.index.batchSet(
|
||||
[...updateIndexMap.entries()].map((
|
||||
[key, value],
|
||||
) => ({ key: ["fts", ...key.split("!")], value: value })),
|
||||
);
|
||||
await this.index.batchSet(
|
||||
[...updateReverseIndexMap.entries()].map((
|
||||
[key, value],
|
||||
) => ({ key: ["fts_rev", ...key.split("!")], value: value })),
|
||||
);
|
||||
}
|
||||
|
||||
// Search for a phrase and return document ids sorted by match count
|
||||
@@ -82,9 +90,9 @@ export class SimpleSearchEngine {
|
||||
const matchCounts: Map<string, number> = new Map(); // pageName -> count
|
||||
|
||||
for (const stemmedWord of stemmedWords) {
|
||||
const entries = await this.index.queryPrefix(`${stemmedWord}!`);
|
||||
for (const [key, value] of entries) {
|
||||
const id = key.split("!").slice(1).join("!");
|
||||
const entries = await this.index.query({ prefix: ["fts", stemmedWord] });
|
||||
for (const { key, value } of entries) {
|
||||
const id = key[2];
|
||||
if (matchCounts.has(id)) {
|
||||
matchCounts.set(id, matchCounts.get(id)! + value);
|
||||
} else {
|
||||
@@ -102,17 +110,15 @@ export class SimpleSearchEngine {
|
||||
|
||||
// Delete a document from the index
|
||||
public async deleteDocument(documentId: string): Promise<void> {
|
||||
const words: [string, boolean][] = await this.reverseIndex.queryPrefix(
|
||||
`${documentId}!`,
|
||||
);
|
||||
const keysToDelete: string[] = [];
|
||||
const revKeysToDelete: string[] = [];
|
||||
for (const [wordKey] of words) {
|
||||
const word = wordKey.split("!").slice(1).join("!");
|
||||
keysToDelete.push(`${word}!${documentId}`);
|
||||
revKeysToDelete.push(wordKey);
|
||||
const words = await this.index.query({
|
||||
prefix: ["fts_rev", documentId],
|
||||
});
|
||||
const keysToDelete: KvKey[] = [];
|
||||
for (const { key } of words) {
|
||||
const word = key[2];
|
||||
keysToDelete.push(["fts", word, documentId]);
|
||||
keysToDelete.push(key);
|
||||
}
|
||||
await this.index.delete(keysToDelete);
|
||||
await this.reverseIndex.delete(revKeysToDelete);
|
||||
await this.index.batchDel(keysToDelete);
|
||||
}
|
||||
}
|
||||
|
||||
+21
-51
@@ -1,41 +1,18 @@
|
||||
import { IndexTreeEvent, QueryProviderEvent } from "$sb/app_event.ts";
|
||||
import { renderToText } from "$sb/lib/tree.ts";
|
||||
import { applyQuery } from "$sb/lib/query.ts";
|
||||
import { editor, index, store } from "$sb/syscalls.ts";
|
||||
import { BatchKVStore, SimpleSearchEngine } from "./engine.ts";
|
||||
import { FileMeta } from "$sb/types.ts";
|
||||
import {
|
||||
applyQuery,
|
||||
evalQueryExpression,
|
||||
liftAttributeFilter,
|
||||
} from "$sb/lib/query.ts";
|
||||
import { datastore, editor } from "$sb/syscalls.ts";
|
||||
import { SimpleSearchEngine } from "./engine.ts";
|
||||
import { FileMeta, KvKey } from "$sb/types.ts";
|
||||
import { PromiseQueue } from "$sb/lib/async.ts";
|
||||
|
||||
const searchPrefix = "🔍 ";
|
||||
|
||||
class StoreKVStore implements BatchKVStore {
|
||||
constructor(private prefix: string) {
|
||||
}
|
||||
async queryPrefix(prefix: string): Promise<[string, any][]> {
|
||||
const results = await store.queryPrefix(this.prefix + prefix);
|
||||
return results.map((
|
||||
{ key, value },
|
||||
) => [key.substring(this.prefix.length), value]);
|
||||
}
|
||||
get(keys: string[]): Promise<(string[] | undefined)[]> {
|
||||
return store.batchGet(keys.map((key) => this.prefix + key));
|
||||
}
|
||||
set(entries: Map<string, string[]>): Promise<void> {
|
||||
return store.batchSet(
|
||||
Array.from(entries.entries()).map((
|
||||
[key, value],
|
||||
) => ({ key: this.prefix + key, value })),
|
||||
);
|
||||
}
|
||||
delete(keys: string[]): Promise<void> {
|
||||
return store.batchDel(keys.map((key) => this.prefix + key));
|
||||
}
|
||||
}
|
||||
|
||||
const ftsKvStore = new StoreKVStore("fts:");
|
||||
const ftsRevKvStore = new StoreKVStore("fts_rev:");
|
||||
|
||||
const engine = new SimpleSearchEngine(ftsKvStore, ftsRevKvStore);
|
||||
const engine = new SimpleSearchEngine(datastore);
|
||||
|
||||
// Search indexing is prone to concurrency issues, so we queue all write operations
|
||||
const promiseQueue = new PromiseQueue();
|
||||
@@ -50,8 +27,14 @@ export function indexPage({ name, tree }: IndexTreeEvent) {
|
||||
}
|
||||
|
||||
export async function clearIndex() {
|
||||
await store.deletePrefix("fts:");
|
||||
await store.deletePrefix("fts_rev:");
|
||||
const keysToDelete: KvKey[] = [];
|
||||
for (const { key } of await datastore.query({ prefix: ["fts"] })) {
|
||||
keysToDelete.push(key);
|
||||
}
|
||||
for (const { key } of await datastore.query({ prefix: ["fts_rev"] })) {
|
||||
keysToDelete.push(key);
|
||||
}
|
||||
await datastore.batchDel(keysToDelete);
|
||||
}
|
||||
|
||||
export function pageUnindex(pageName: string) {
|
||||
@@ -63,11 +46,13 @@ export function pageUnindex(pageName: string) {
|
||||
export async function queryProvider({
|
||||
query,
|
||||
}: QueryProviderEvent): Promise<any[]> {
|
||||
const phraseFilter = query.filter.find((f) => f.prop === "phrase");
|
||||
const phraseFilter = liftAttributeFilter(query.filter, "phrase");
|
||||
if (!phraseFilter) {
|
||||
throw Error("No 'phrase' filter specified, this is mandatory");
|
||||
}
|
||||
let results: any[] = await engine.search(phraseFilter.value);
|
||||
const phrase = evalQueryExpression(phraseFilter, {});
|
||||
// console.log("Phrase", phrase);
|
||||
let results: any[] = await engine.search(phrase);
|
||||
|
||||
// Patch the object to a format that users expect (translate id to name)
|
||||
for (const r of results) {
|
||||
@@ -75,21 +60,6 @@ export async function queryProvider({
|
||||
delete r.id;
|
||||
}
|
||||
|
||||
const allPageMap: Map<string, any> = new Map(
|
||||
results.map((r: any) => [r.name, r]),
|
||||
);
|
||||
for (const { page, value } of await index.queryPrefix("meta:")) {
|
||||
const p = allPageMap.get(page);
|
||||
if (p) {
|
||||
for (const [k, v] of Object.entries(value)) {
|
||||
p[k] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the "phrase" filter
|
||||
query.filter.splice(query.filter.indexOf(phraseFilter), 1);
|
||||
|
||||
results = applyQuery(query, results);
|
||||
return results;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CompleteEvent } from "$sb/app_event.ts";
|
||||
import { index } from "$sb/syscalls.ts";
|
||||
import { queryObjects } from "../index/plug_api.ts";
|
||||
import { TaskStateObject } from "./task.ts";
|
||||
|
||||
export async function completeTaskState(completeEvent: CompleteEvent) {
|
||||
const taskMatch = /([\-\*]\s+\[)([^\[\]]+)$/.exec(
|
||||
@@ -8,8 +9,8 @@ export async function completeTaskState(completeEvent: CompleteEvent) {
|
||||
if (!taskMatch) {
|
||||
return null;
|
||||
}
|
||||
const allStates = await index.queryPrefix("taskState:");
|
||||
const states = [...new Set(allStates.map((s) => s.key.split(":")[1]))];
|
||||
const allStates = await queryObjects<TaskStateObject>("taskstate", {});
|
||||
const states = [...new Set(allStates.map((s) => s.state))];
|
||||
|
||||
return {
|
||||
from: completeEvent.pos - taskMatch[2].length,
|
||||
|
||||
+113
-116
@@ -1,10 +1,6 @@
|
||||
import type {
|
||||
ClickEvent,
|
||||
IndexTreeEvent,
|
||||
QueryProviderEvent,
|
||||
} from "$sb/app_event.ts";
|
||||
import type { ClickEvent, IndexTreeEvent } from "$sb/app_event.ts";
|
||||
|
||||
import { editor, index, markdown, space, sync } from "$sb/syscalls.ts";
|
||||
import { editor, markdown, space, sync } from "$sb/syscalls.ts";
|
||||
|
||||
import {
|
||||
addParentPointers,
|
||||
@@ -17,24 +13,32 @@ import {
|
||||
replaceNodesMatching,
|
||||
traverseTreeAsync,
|
||||
} from "$sb/lib/tree.ts";
|
||||
import { applyQuery, removeQueries } from "$sb/lib/query.ts";
|
||||
import { removeQueries } from "$sb/lib/query.ts";
|
||||
import { niceDate } from "$sb/lib/dates.ts";
|
||||
import { extractAttributes } from "$sb/lib/attribute.ts";
|
||||
import { rewritePageRefs } from "$sb/lib/resolve.ts";
|
||||
import { indexAttributes } from "../index/attributes.ts";
|
||||
import { ObjectValue } from "$sb/types.ts";
|
||||
import { indexObjects, queryObjects } from "../index/plug_api.ts";
|
||||
|
||||
export type Task = {
|
||||
export type TaskObject = {
|
||||
ref: string;
|
||||
tags: string[];
|
||||
page: string;
|
||||
pos: number;
|
||||
name: string;
|
||||
done: boolean;
|
||||
state: string;
|
||||
deadline?: string;
|
||||
tags?: string[];
|
||||
nested?: string;
|
||||
// Not saved in DB, just added when pulled out (from key)
|
||||
pos?: number;
|
||||
page?: string;
|
||||
} & Record<string, any>;
|
||||
|
||||
export type TaskStateObject = {
|
||||
ref: string;
|
||||
tags: string[];
|
||||
state: string;
|
||||
count: number;
|
||||
page: string;
|
||||
};
|
||||
|
||||
function getDeadline(deadlineNode: ParseTree): string {
|
||||
return deadlineNode.children![0].text!.replace(/📅\s*/, "");
|
||||
}
|
||||
@@ -43,27 +47,32 @@ 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>();
|
||||
const tasks: ObjectValue<TaskObject>[] = [];
|
||||
const taskStates = new Map<string, { count: number; firstPos: number }>();
|
||||
removeQueries(tree);
|
||||
addParentPointers(tree);
|
||||
const allAttributes: Record<string, any> = {};
|
||||
// const allAttributes: AttributeObject[] = [];
|
||||
// const allTags = new Set<string>();
|
||||
await traverseTreeAsync(tree, async (n) => {
|
||||
if (n.type !== "Task") {
|
||||
return false;
|
||||
}
|
||||
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);
|
||||
let currentState = taskStates.get(state);
|
||||
if (!currentState) {
|
||||
currentState = { count: 0, firstPos: n.from! };
|
||||
}
|
||||
currentState.count++;
|
||||
}
|
||||
const complete = completeStates.includes(state);
|
||||
const task: Task = {
|
||||
const task: TaskObject = {
|
||||
ref: `${name}@${n.from}`,
|
||||
tags: [],
|
||||
name: "",
|
||||
done: complete,
|
||||
page: name,
|
||||
pos: n.from!,
|
||||
state,
|
||||
};
|
||||
|
||||
@@ -76,65 +85,60 @@ export async function indexTasks({ name, tree }: IndexTreeEvent) {
|
||||
return null;
|
||||
}
|
||||
if (tree.type === "Hashtag") {
|
||||
if (!task.tags) {
|
||||
task.tags = [];
|
||||
}
|
||||
// Push the tag to the list, removing the initial #
|
||||
task.tags.push(tree.children![0].text!.substring(1));
|
||||
// Remove this node from the tree
|
||||
// return null;
|
||||
const tagName = tree.children![0].text!.substring(1);
|
||||
task.tags.push(tagName);
|
||||
}
|
||||
});
|
||||
task.tags = ["task", ...task.tags];
|
||||
|
||||
// Extract attributes and remove from tree
|
||||
const extractedAttributes = await extractAttributes(n, true);
|
||||
for (const [key, value] of Object.entries(extractedAttributes)) {
|
||||
task[key] = value;
|
||||
allAttributes[key] = value;
|
||||
}
|
||||
|
||||
task.name = n.children!.slice(1).map(renderToText).join("").trim();
|
||||
|
||||
const taskIndex = n.parent!.children!.indexOf(n);
|
||||
const nestedItems = n.parent!.children!.slice(taskIndex + 1);
|
||||
if (nestedItems.length > 0) {
|
||||
task.nested = nestedItems.map(renderToText).join("").trim();
|
||||
}
|
||||
tasks.push({
|
||||
key: `task:${n.from}`,
|
||||
value: task,
|
||||
});
|
||||
tasks.push(task);
|
||||
return true;
|
||||
});
|
||||
|
||||
// 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,
|
||||
})),
|
||||
);
|
||||
// Index task states
|
||||
if (taskStates.size > 0) {
|
||||
await indexObjects<TaskStateObject>(
|
||||
name,
|
||||
Array.from(taskStates.entries()).map(([state, { firstPos, count }]) => ({
|
||||
ref: `${name}@${firstPos}`,
|
||||
tags: ["taskstate"],
|
||||
state,
|
||||
count,
|
||||
page: name,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
// Index tasks themselves
|
||||
if (tasks.length > 0) {
|
||||
await indexObjects(name, tasks);
|
||||
}
|
||||
}
|
||||
|
||||
export function taskToggle(event: ClickEvent) {
|
||||
if (event.altKey) {
|
||||
return;
|
||||
}
|
||||
return taskCycleAtPos(event.page, event.pos);
|
||||
return taskCycleAtPos(event.pos);
|
||||
}
|
||||
|
||||
export async function previewTaskToggle(eventString: string) {
|
||||
export function previewTaskToggle(eventString: string) {
|
||||
const [eventName, pos] = JSON.parse(eventString);
|
||||
if (eventName === "task") {
|
||||
return taskCycleAtPos(await editor.getCurrentPage(), +pos);
|
||||
return taskCycleAtPos(+pos);
|
||||
}
|
||||
}
|
||||
|
||||
async function cycleTaskState(
|
||||
pageName: string,
|
||||
node: ParseTree,
|
||||
) {
|
||||
const stateText = node.children![1].text!;
|
||||
@@ -145,8 +149,8 @@ async function cycleTaskState(
|
||||
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]))];
|
||||
const allStates = await queryObjects<TaskStateObject>("taskstate", {});
|
||||
const states = [...new Set(allStates.map((s) => s.state))];
|
||||
states.sort();
|
||||
// Select a next state
|
||||
const currentStateIndex = states.indexOf(stateText);
|
||||
@@ -174,64 +178,73 @@ async function cycleTaskState(
|
||||
for (const wikiLink of parentWikiLinks) {
|
||||
const ref = wikiLink.children![0].text!;
|
||||
if (ref.includes("@")) {
|
||||
const [page, posS] = ref.split("@");
|
||||
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 + 1,
|
||||
to: pos + 1 + stateText.length,
|
||||
insert: changeTo,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
let text = await space.readPage(page);
|
||||
|
||||
const referenceMdTree = await markdown.parseMarkdown(text);
|
||||
// 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?",
|
||||
taskStateNode,
|
||||
);
|
||||
return;
|
||||
}
|
||||
taskStateNode.children![1].text = changeTo;
|
||||
text = renderToText(referenceMdTree);
|
||||
await space.writePage(page, text);
|
||||
sync.scheduleFileSync(`${page}.md`);
|
||||
}
|
||||
await updateTaskState(ref, stateText, changeTo);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function taskCycleAtPos(pageName: string, pos: number) {
|
||||
export async function updateTaskState(
|
||||
ref: string,
|
||||
oldState: string,
|
||||
newState: string,
|
||||
) {
|
||||
const currentPage = await editor.getCurrentPage();
|
||||
const [page, posS] = ref.split("@");
|
||||
const pos = +posS;
|
||||
if (page === currentPage) {
|
||||
// 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 + oldState.length,
|
||||
);
|
||||
if (targetText !== oldState) {
|
||||
console.error(
|
||||
"Reference not a task marker, out of date?",
|
||||
targetText,
|
||||
);
|
||||
return;
|
||||
}
|
||||
await editor.dispatch({
|
||||
changes: {
|
||||
from: pos + 1,
|
||||
to: pos + 1 + oldState.length,
|
||||
insert: newState,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
let text = await space.readPage(page);
|
||||
|
||||
const referenceMdTree = await markdown.parseMarkdown(text);
|
||||
// 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?",
|
||||
taskStateNode,
|
||||
);
|
||||
return;
|
||||
}
|
||||
taskStateNode.children![1].text = newState;
|
||||
text = renderToText(referenceMdTree);
|
||||
await space.writePage(page, text);
|
||||
sync.scheduleFileSync(`${page}.md`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function taskCycleAtPos(pos: number) {
|
||||
const text = await editor.getText();
|
||||
const mdTree = await markdown.parseMarkdown(text);
|
||||
addParentPointers(mdTree);
|
||||
|
||||
let node = nodeAtPos(mdTree, pos);
|
||||
if (node) {
|
||||
if (node.type === "TaskMarker") {
|
||||
if (node.type === "TaskMark") {
|
||||
node = node.parent!;
|
||||
}
|
||||
if (node.type === "TaskState") {
|
||||
await cycleTaskState(pageName, node);
|
||||
await cycleTaskState(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -265,7 +278,7 @@ export async function taskCycleCommand() {
|
||||
}
|
||||
const taskState = findNodeOfType(taskNode!, "TaskState");
|
||||
if (taskState) {
|
||||
await cycleTaskState(await editor.getCurrentPage(), taskState);
|
||||
await cycleTaskState(taskState);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,19 +331,3 @@ export async function postponeCommand() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function queryProvider({
|
||||
query,
|
||||
}: QueryProviderEvent): Promise<Task[]> {
|
||||
const allTasks: Task[] = [];
|
||||
|
||||
for (const { key, page, value } of await index.queryPrefix("task:")) {
|
||||
const pos = key.split(":")[1];
|
||||
allTasks.push({
|
||||
...value,
|
||||
page: page,
|
||||
pos: +pos,
|
||||
});
|
||||
}
|
||||
return applyQuery(query, allTasks);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,11 @@ syntax:
|
||||
styles:
|
||||
backgroundColor: "rgba(22,22,22,0.07)"
|
||||
functions:
|
||||
# API
|
||||
updateTaskState:
|
||||
path: task.ts:updateTaskState
|
||||
|
||||
|
||||
turnIntoTask:
|
||||
redirect: template.applyLineReplace
|
||||
slashCommand:
|
||||
@@ -35,10 +40,10 @@ functions:
|
||||
path: "./task.ts:taskToggle"
|
||||
events:
|
||||
- page:click
|
||||
itemQueryProvider:
|
||||
path: ./task.ts:queryProvider
|
||||
events:
|
||||
- query:task
|
||||
# itemQueryProvider:
|
||||
# path: ./task.ts:queryProvider
|
||||
# events:
|
||||
# - query:task
|
||||
taskToggleCommand:
|
||||
path: ./task.ts:taskCycleCommand
|
||||
command:
|
||||
|
||||
+19
-18
@@ -1,13 +1,10 @@
|
||||
import { editor, markdown, space } from "$sb/syscalls.ts";
|
||||
import { editor, handlebars, markdown, space } from "$sb/syscalls.ts";
|
||||
import { extractFrontmatter } from "$sb/lib/frontmatter.ts";
|
||||
import { renderToText } from "$sb/lib/tree.ts";
|
||||
import { niceDate } from "$sb/lib/dates.ts";
|
||||
import { readSettings } from "$sb/lib/settings_page.ts";
|
||||
import { PageMeta } from "../../web/types.ts";
|
||||
import { buildHandebarOptions } from "../directive/util.ts";
|
||||
|
||||
import Handlebars from "handlebars";
|
||||
import { cleanPageRef } from "$sb/lib/resolve.ts";
|
||||
import { PageMeta } from "$sb/types.ts";
|
||||
|
||||
export async function instantiateTemplateCommand() {
|
||||
const allPages = await space.listPages();
|
||||
@@ -48,7 +45,7 @@ export async function instantiateTemplateCommand() {
|
||||
};
|
||||
|
||||
if (additionalPageMeta.$name) {
|
||||
additionalPageMeta.$name = replaceTemplateVars(
|
||||
additionalPageMeta.$name = await replaceTemplateVars(
|
||||
additionalPageMeta.$name,
|
||||
tempPageMeta,
|
||||
);
|
||||
@@ -79,7 +76,10 @@ export async function instantiateTemplateCommand() {
|
||||
// The preferred scenario, let's keep going
|
||||
}
|
||||
|
||||
const pageText = replaceTemplateVars(renderToText(parseTree), tempPageMeta);
|
||||
const pageText = await replaceTemplateVars(
|
||||
renderToText(parseTree),
|
||||
tempPageMeta,
|
||||
);
|
||||
await space.writePage(pageName, pageText);
|
||||
await editor.navigate(pageName);
|
||||
}
|
||||
@@ -110,10 +110,10 @@ export async function insertSnippet() {
|
||||
}
|
||||
|
||||
const text = await space.readPage(`${snippetPrefix}${selectedSnippet.name}`);
|
||||
let templateText = replaceTemplateVars(text, pageMeta);
|
||||
let templateText = await replaceTemplateVars(text, pageMeta);
|
||||
const carretPos = templateText.indexOf("|^|");
|
||||
templateText = templateText.replace("|^|", "");
|
||||
templateText = replaceTemplateVars(templateText, pageMeta);
|
||||
templateText = await replaceTemplateVars(templateText, pageMeta);
|
||||
await editor.insertAtCursor(templateText);
|
||||
if (carretPos !== -1) {
|
||||
await editor.moveCursor(cursorPos + carretPos);
|
||||
@@ -148,20 +148,21 @@ export async function applyPageTemplateCommand() {
|
||||
const text = await space.readPage(
|
||||
`${pageTemplatePrefix}${selectedPage.name}`,
|
||||
);
|
||||
let templateText = replaceTemplateVars(text, pageMeta);
|
||||
let templateText = await replaceTemplateVars(text, pageMeta);
|
||||
const carretPos = templateText.indexOf("|^|");
|
||||
templateText = templateText.replace("|^|", "");
|
||||
templateText = replaceTemplateVars(templateText, pageMeta);
|
||||
templateText = await replaceTemplateVars(templateText, pageMeta);
|
||||
await editor.insertAtCursor(templateText);
|
||||
if (carretPos !== -1) {
|
||||
await editor.moveCursor(cursorPos + carretPos);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: This should probably be replaced with handlebards somehow?
|
||||
export function replaceTemplateVars(s: string, pageMeta: PageMeta): string {
|
||||
const template = Handlebars.compile(s, { noEscape: true });
|
||||
return template({}, buildHandebarOptions(pageMeta));
|
||||
export function replaceTemplateVars(
|
||||
s: string,
|
||||
pageMeta: PageMeta,
|
||||
): Promise<string> {
|
||||
return handlebars.renderTemplate(s, {}, { page: pageMeta });
|
||||
}
|
||||
|
||||
export async function quickNoteCommand() {
|
||||
@@ -204,7 +205,7 @@ export async function dailyNoteCommand() {
|
||||
|
||||
await space.writePage(
|
||||
pageName,
|
||||
replaceTemplateVars(dailyNoteTemplateText, {
|
||||
await replaceTemplateVars(dailyNoteTemplateText, {
|
||||
name: pageName,
|
||||
lastModified: 0,
|
||||
perm: "rw",
|
||||
@@ -248,7 +249,7 @@ export async function weeklyNoteCommand() {
|
||||
// Doesn't exist, let's create
|
||||
await space.writePage(
|
||||
pageName,
|
||||
replaceTemplateVars(weeklyNoteTemplateText, {
|
||||
await replaceTemplateVars(weeklyNoteTemplateText, {
|
||||
name: pageName,
|
||||
lastModified: 0,
|
||||
perm: "rw",
|
||||
@@ -278,7 +279,7 @@ export async function insertTemplateText(cmdDef: any) {
|
||||
let templateText: string = cmdDef.value;
|
||||
const carretPos = templateText.indexOf("|^|");
|
||||
templateText = templateText.replace("|^|", "");
|
||||
templateText = replaceTemplateVars(templateText, pageMeta);
|
||||
templateText = await replaceTemplateVars(templateText, pageMeta!);
|
||||
await editor.insertAtCursor(templateText);
|
||||
if (carretPos !== -1) {
|
||||
await editor.moveCursor(cursorPos + carretPos);
|
||||
|
||||
Reference in New Issue
Block a user