Massive restructure of plugin API

This commit is contained in:
Zef Hemel
2022-10-14 15:11:33 +02:00
parent 982623fc38
commit 7d28b53b75
70 changed files with 826 additions and 969 deletions
+11
View File
@@ -0,0 +1,11 @@
export function niceDate(d: Date): string {
function pad(n: number) {
let s = String(n);
if (s.length === 1) {
s = "0" + s;
}
return s;
}
return d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate());
}
+168
View File
@@ -0,0 +1,168 @@
import {
addParentPointers,
collectNodesMatching,
ParseTree,
renderToText,
} from "./tree.ts";
export const queryRegex =
/(<!--\s*#query\s+(.+?)-->)(.+?)(<!--\s*\/query\s*-->)/gs;
export const directiveStartRegex = /<!--\s*#([\w\-]+)\s+(.+?)-->/s;
export const directiveEndRegex = /<!--\s*\/([\w\-]+)\s*-->/s;
export type QueryFilter = {
op: string;
prop: string;
value: any;
};
export type ParsedQuery = {
table: string;
orderBy?: string;
orderDesc?: boolean;
limit?: number;
filter: QueryFilter[];
select?: string[];
render?: string;
};
export function applyQuery<T>(parsedQuery: ParsedQuery, records: T[]): T[] {
let resultRecords: any[] = [];
if (parsedQuery.filter.length === 0) {
resultRecords = records.slice();
} else {
recordLoop:
for (const record of records) {
const recordAny: any = record;
for (let { op, prop, value } of parsedQuery.filter) {
switch (op) {
case "=": {
const recordPropVal = recordAny[prop];
if (Array.isArray(recordPropVal) && !Array.isArray(value)) {
// Record property is an array, and value is a scalar: find the value in the array
if (!recordPropVal.includes(value)) {
continue recordLoop;
}
} else if (Array.isArray(recordPropVal) && Array.isArray(value)) {
// Record property is an array, and value is an array: find the value in the array
if (!recordPropVal.some((v) => value.includes(v))) {
continue recordLoop;
}
} else if (!(recordPropVal == value)) {
// Both are scalars: exact value
continue recordLoop;
}
break;
}
case "!=":
if (!(recordAny[prop] != value)) {
continue recordLoop;
}
break;
case "<":
if (!(recordAny[prop] < value)) {
continue recordLoop;
}
break;
case "<=":
if (!(recordAny[prop] <= value)) {
continue recordLoop;
}
break;
case ">":
if (!(recordAny[prop] > value)) {
continue recordLoop;
}
break;
case ">=":
if (!(recordAny[prop] >= value)) {
continue recordLoop;
}
break;
case "=~":
// TODO: Cache regexps somehow
if (!new RegExp(value).exec(recordAny[prop])) {
continue recordLoop;
}
break;
case "!=~":
if (new RegExp(value).exec(recordAny[prop])) {
continue recordLoop;
}
break;
case "in":
if (!value.includes(recordAny[prop])) {
continue recordLoop;
}
break;
}
}
resultRecords.push(recordAny);
}
}
// Now the sorting
if (parsedQuery.orderBy) {
resultRecords = resultRecords.sort((a: any, b: any) => {
const orderBy = parsedQuery.orderBy!;
const orderDesc = parsedQuery.orderDesc!;
if (a[orderBy] === b[orderBy]) {
return 0;
}
if (a[orderBy] < b[orderBy]) {
return orderDesc ? 1 : -1;
} else {
return orderDesc ? -1 : 1;
}
});
}
if (parsedQuery.limit) {
resultRecords = resultRecords.slice(0, parsedQuery.limit);
}
if (parsedQuery.select) {
resultRecords = resultRecords.map((rec) => {
let newRec: any = {};
for (let k of parsedQuery.select!) {
newRec[k] = rec[k];
}
return newRec;
});
}
return resultRecords;
}
export function removeQueries(pt: ParseTree) {
addParentPointers(pt);
collectNodesMatching(pt, (t) => {
if (t.type !== "CommentBlock") {
return false;
}
let text = t.children![0].text!;
let match = directiveStartRegex.exec(text);
if (!match) {
return false;
}
let directiveType = match[1];
let parentChildren = t.parent!.children!;
let index = parentChildren.indexOf(t);
let nodesToReplace: ParseTree[] = [];
for (let i = index + 1; i < parentChildren.length; i++) {
let n = parentChildren[i];
if (n.type === "CommentBlock") {
let text = n.children![0].text!;
let match = directiveEndRegex.exec(text);
if (match && match[1] === directiveType) {
break;
}
}
nodesToReplace.push(n);
}
let renderedText = nodesToReplace.map(renderToText).join("");
parentChildren.splice(index + 1, nodesToReplace.length, {
text: new Array(renderedText.length + 1).join(" "),
});
return true;
});
}
+25
View File
@@ -0,0 +1,25 @@
import { readYamlPage } from "./yaml_page.ts";
// Read SECRETS page and retrieve specific set of secret keys
// Note: in this implementation there's no encryption employed at all so it's just a matter
// of not decising this SECRETS page to other places
export async function readSecrets(keys: string[]): Promise<any[]> {
try {
let allSecrets = await readYamlPage("SECRETS", ["yaml", "secrets"]);
let collectedSecrets: any[] = [];
for (let key of keys) {
let secret = allSecrets[key];
if (secret) {
collectedSecrets.push(secret);
} else {
throw new Error(`No such secret: ${key}`);
}
}
return collectedSecrets;
} catch (e: any) {
if (e.message === "Page not found") {
throw new Error(`No such secret: ${keys[0]}`);
}
throw e;
}
}
+63
View File
@@ -0,0 +1,63 @@
import { readYamlPage } from "./yaml_page.ts";
import { notifyUser } from "./util.ts";
import * as YAML from "yaml";
import { space } from "$sb/silverbullet-syscall/mod.ts";
/**
* Convenience function to read a specific set of settings from the `SETTINGS` page as well as default values
* in case they are not specified.
* Example: `await readSettings({showPreview: false})` will return an object like `{showPreview: false}` (or `true`)
* in case this setting is specifically set in the `SETTINGS` page.
*
* @param settings object with settings to fetch and their default values
* @returns an object with the same shape as `settings` but with non-default values override based on `SETTINGS`
*/
const SETTINGS_PAGE = "SETTINGS";
export async function readSettings<T extends object>(settings: T): Promise<T> {
try {
const allSettings = (await readYamlPage(SETTINGS_PAGE, ["yaml"])) || {};
// TODO: I'm sure there's a better way to type this than "any"
const collectedSettings: any = {};
for (let [key, defaultVal] of Object.entries(settings)) {
if (key in allSettings) {
collectedSettings[key] = allSettings[key];
} else {
collectedSettings[key] = defaultVal;
}
}
return collectedSettings as T;
} catch (e: any) {
if (e.message === "Page not found") {
// No settings yet, return default values for all
return settings;
}
throw e;
}
}
/**
* Convenience function to write a specific set of settings from the `SETTINGS` page.
* If the SETTiNGS page doesn't exist it will create it.
* @param settings
*/
export async function writeSettings<T extends object>(settings: T) {
let readSettings = {};
try {
readSettings = (await readYamlPage(SETTINGS_PAGE, ["yaml"])) || {};
} catch {
await notifyUser("Creating a new SETTINGS page...", "info");
}
const writeSettings: any = { ...readSettings, ...settings };
// const doc = new YAML.Document();
// doc.contents = writeSettings;
const contents =
`This page contains settings for configuring SilverBullet and its Plugs.\nAny changes outside of the yaml block will be overwritten.\n\`\`\`yaml\n${
YAML.stringify(
writeSettings,
)
}\n\`\`\``; // might need \r\n for windows?
await space.writePage(SETTINGS_PAGE, contents);
}
+79
View File
@@ -0,0 +1,79 @@
// import { parse } from "./parse_tree.ts";
import {
addParentPointers,
collectNodesMatching,
findParentMatching,
nodeAtPos,
removeParentPointers,
renderToText,
replaceNodesMatching,
} from "./tree.ts";
import wikiMarkdownLang from "../../common/parser.ts";
import { assertEquals, assertNotEquals } from "../../test_deps.ts";
import { parse } from "../../common/parse_tree.ts";
const mdTest1 = `
# Heading
## Sub _heading_ cool
Hello, this is some **bold** text and *italic*. And [a link](http://zef.me).
%% My comment here
%% And second line
And an @mention
http://zef.plus
- This is a list [[PageLink]]
- With another item
- TODOs:
- [ ] A task that's not yet done
- [x] Hello
- And a _third_ one [[Wiki Page]] yo
`;
const mdTest2 = `
Hello
* Item 1
*
Sup`;
const mdTest3 = `
\`\`\`yaml
name: something
\`\`\`
`;
Deno.test("Run a Node sandbox", () => {
const lang = wikiMarkdownLang([]);
let mdTree = parse(lang, mdTest1);
addParentPointers(mdTree);
// console.log(JSON.stringify(mdTree, null, 2));
let wikiLink = nodeAtPos(mdTree, mdTest1.indexOf("Wiki Page"))!;
assertEquals(wikiLink.type, "WikiLink");
assertNotEquals(
findParentMatching(wikiLink, (n) => n.type === "BulletList"),
null,
);
let allTodos = collectNodesMatching(mdTree, (n) => n.type === "Task");
assertEquals(allTodos.length, 2);
// Render back into markdown should be equivalent
assertEquals(renderToText(mdTree), mdTest1);
removeParentPointers(mdTree);
replaceNodesMatching(mdTree, (n) => {
if (n.type === "Task") {
return {
type: "Tosk",
};
}
});
console.log(JSON.stringify(mdTree, null, 2));
let mdTree3 = parse(lang, mdTest3);
console.log(JSON.stringify(mdTree3, null, 2));
});
+150
View File
@@ -0,0 +1,150 @@
export type ParseTree = {
type?: string; // undefined === text node
from?: number;
to?: number;
text?: string;
children?: ParseTree[];
// Only present after running addParentPointers
parent?: ParseTree;
};
export function addParentPointers(tree: ParseTree) {
if (!tree.children) {
return;
}
for (let child of tree.children) {
if (child.parent) {
// Already added parent pointers before
return;
}
child.parent = tree;
addParentPointers(child);
}
}
export function removeParentPointers(tree: ParseTree) {
delete tree.parent;
if (!tree.children) {
return;
}
for (let child of tree.children) {
removeParentPointers(child);
}
}
export function findParentMatching(
tree: ParseTree,
matchFn: (tree: ParseTree) => boolean,
): ParseTree | null {
let node = tree.parent;
while (node) {
if (matchFn(node)) {
return node;
}
node = node.parent;
}
return null;
}
export function collectNodesOfType(
tree: ParseTree,
nodeType: string,
): ParseTree[] {
return collectNodesMatching(tree, (n) => n.type === nodeType);
}
export function collectNodesMatching(
tree: ParseTree,
matchFn: (tree: ParseTree) => boolean,
): ParseTree[] {
if (matchFn(tree)) {
return [tree];
}
let results: ParseTree[] = [];
if (tree.children) {
for (let child of tree.children) {
results = [...results, ...collectNodesMatching(child, matchFn)];
}
}
return results;
}
// return value: returning undefined = not matched, continue, null = delete, new node = replace
export function replaceNodesMatching(
tree: ParseTree,
substituteFn: (tree: ParseTree) => ParseTree | null | undefined,
) {
if (tree.children) {
let children = tree.children.slice();
for (let child of children) {
let subst = substituteFn(child);
if (subst !== undefined) {
let pos = tree.children.indexOf(child);
if (subst) {
tree.children.splice(pos, 1, subst);
} else {
// null = delete
tree.children.splice(pos, 1);
}
} else {
replaceNodesMatching(child, substituteFn);
}
}
}
}
export function findNodeMatching(
tree: ParseTree,
matchFn: (tree: ParseTree) => boolean,
): ParseTree | null {
return collectNodesMatching(tree, matchFn)[0];
}
export function findNodeOfType(
tree: ParseTree,
nodeType: string,
): ParseTree | null {
return collectNodesMatching(tree, (n) => n.type === nodeType)[0];
}
export function traverseTree(
tree: ParseTree,
// Return value = should stop traversal?
matchFn: (tree: ParseTree) => boolean,
): void {
// Do a collect, but ignore the result
collectNodesMatching(tree, matchFn);
}
// Finds non-text node at position
export function nodeAtPos(tree: ParseTree, pos: number): ParseTree | null {
if (pos < tree.from! || pos > tree.to!) {
return null;
}
if (!tree.children) {
return tree;
}
for (let child of tree.children) {
let n = nodeAtPos(child, pos);
if (n && n.text !== undefined) {
// Got a text node, let's return its parent
return tree;
} else if (n) {
// Got it
return n;
}
}
return null;
}
// Turn ParseTree back into text
export function renderToText(tree: ParseTree): string {
let pieces: string[] = [];
if (tree.text !== undefined) {
return tree.text;
}
for (let child of tree.children!) {
pieces.push(renderToText(child));
}
return pieces.join("");
}
+36
View File
@@ -0,0 +1,36 @@
import { editor } from "$sb/silverbullet-syscall/mod.ts";
export async function replaceAsync(
str: string,
regex: RegExp,
asyncFn: (match: string, ...args: any[]) => Promise<string>,
) {
const promises: Promise<string>[] = [];
str.replace(regex, (match: string, ...args: any[]): string => {
const promise = asyncFn(match, ...args);
promises.push(promise);
return "";
});
const data = await Promise.all(promises);
return str.replace(regex, () => data.shift()!);
}
export function isServer() {
return (
typeof window === "undefined" || typeof window.document === "undefined"
); // if something defines window the same way as the browser, this will fail.
}
// this helps keep if's condition as positive
export function isBrowser() {
return !isServer();
}
export function notifyUser(message: string, type?: "info" | "error") {
if (isBrowser()) {
return editor.flashNotification(message, type);
}
const log = type === "error" ? console.error : console.log;
log(message); // we should end up sending the message to the user, users dont read logs.
return;
}
+49
View File
@@ -0,0 +1,49 @@
import { findNodeOfType, traverseTree } from "$sb/lib/tree.ts";
import { markdown, space } from "$sb/silverbullet-syscall/mod.ts";
import * as YAML from "yaml";
export async function readYamlPage(
pageName: string,
allowedLanguages = ["yaml"],
): Promise<any> {
const text = await space.readPage(pageName);
const tree = await markdown.parseMarkdown(text);
let data: any = {};
traverseTree(tree, (t): boolean => {
// Find a fenced code block
if (t.type !== "FencedCode") {
return false;
}
const codeInfoNode = findNodeOfType(t, "CodeInfo");
if (!codeInfoNode) {
return false;
}
if (!allowedLanguages.includes(codeInfoNode.children![0].text!)) {
return false;
}
const codeTextNode = findNodeOfType(t, "CodeText");
if (!codeTextNode) {
// Honestly, this shouldn't happen
return false;
}
const codeText = codeTextNode.children![0].text!;
try {
data = YAML.parse(codeText);
} catch (e: any) {
console.error("YAML Page parser error", e);
throw new Error(`YAML Error: ${e.message}`);
}
return true;
});
return data;
}
export async function writeYamlPage(
pageName: string,
data: any,
): Promise<void> {
const text = YAML.stringify(data);
await space.writePage(pageName, "```yaml\n" + text + "\n```");
}