Massive restructure of plugin API
This commit is contained in:
@@ -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());
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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("");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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```");
|
||||
}
|
||||
Reference in New Issue
Block a user