1
0

Custom template slash commands

This commit is contained in:
Zef Hemel 2023-11-06 09:14:16 +01:00
parent 0e2a802bbd
commit 1afac0274e
16 changed files with 214 additions and 79 deletions

View File

@ -7,6 +7,9 @@ export function handlebarHelpers() {
escapeRegexp: (ts: any) => {
return ts.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&");
},
escape: (handlebarsExpr: string) => {
return `{{${handlebarsExpr}}}`;
},
replaceRegexp: (s: string, regexp: string, replacement: string) => {
return s.replace(new RegExp(regexp, "g"), replacement);
},

View File

@ -6,6 +6,7 @@ export type AppEvent =
| "page:click"
| "editor:complete"
| "minieditor:complete"
| "slash:complete"
| "page:load"
| "editor:init"
| "editor:pageLoaded" // args: pageName, previousPage, isSynced
@ -51,6 +52,12 @@ export type CompleteEvent = {
parentNodes: string[];
};
export type SlashCompletion = {
label: string;
detail?: string;
invoke: string;
} & Record<string, any>;
export type WidgetContent = {
html?: string;
script?: string;

View File

@ -9,18 +9,23 @@ import {
traverseTreeAsync,
} from "$sb/lib/tree.ts";
export type FrontMatter = { tags: string[] } & Record<string, any>;
// Extracts front matter (or legacy "meta" code blocks) from a markdown document
// optionally removes certain keys from the front matter
export async function extractFrontmatter(
tree: ParseTree,
removeKeys: string[] = [],
removeFrontmatterSection = false,
): Promise<any> {
let data: any = {};
): Promise<FrontMatter> {
let data: FrontMatter = {
tags: [],
};
addParentPointers(tree);
let paragraphCounter = 0;
await replaceNodesMatchingAsync(tree, async (t) => {
// Find tags in the first paragraph to attach to the page
if (t.type === "Paragraph") {
paragraphCounter++;
// Only attach hashtags in the first paragraph to the page
@ -28,11 +33,8 @@ export async function extractFrontmatter(
return;
}
collectNodesOfType(t, "Hashtag").forEach((h) => {
if (!data.tags) {
data.tags = [];
}
const tagname = h.children![0].text!.substring(1);
if (Array.isArray(data.tags) && !data.tags.includes(tagname)) {
if (!data.tags.includes(tagname)) {
data.tags.push(tagname);
}
});
@ -45,6 +47,14 @@ export async function extractFrontmatter(
const parsedData: any = await YAML.parse(yamlText);
const newData = { ...parsedData };
data = { ...data, ...parsedData };
// Make sure we have a tags array
if (!data.tags) {
data.tags = [];
}
// Normalize tags to an array and support a "tag1, tag2" notation
if (typeof data.tags === "string") {
data.tags = (data.tags as string).split(/,\s*/);
}
if (removeKeys.length > 0) {
let removedOne = false;

View File

@ -8,13 +8,15 @@ export type FileMeta = {
noSync?: boolean;
};
export type PageMeta = {
name: string;
created: number;
lastModified: number;
lastOpened?: number;
perm: "ro" | "rw";
};
export type PageMeta = ObjectValue<
{
name: string;
created: string; // indexing it as a string
lastModified: string; // indexing it as a string
lastOpened?: number;
perm: "ro" | "rw";
} & Record<string, any>
>;
export type AttachmentMeta = {
name: string;

View File

@ -1,7 +1,6 @@
import { handlebars, space } from "$sb/syscalls.ts";
import { handlebarHelpers } from "../../common/syscalls/handlebar_helpers.ts";
import { PageMeta } from "$sb/types.ts";
import { render } from "preact";
export function defaultJsonTransformer(_k: string, v: any) {
if (v === undefined) {

View File

@ -1,6 +1,6 @@
import { CompleteEvent } from "$sb/app_event.ts";
import { space } from "$sb/syscalls.ts";
import { PageMeta } from "$sb/types.ts";
import { FileMeta, PageMeta } from "$sb/types.ts";
import { cacheFileListing } from "../federation/federation.ts";
// Completion
@ -24,10 +24,7 @@ export async function pageComplete(completeEvent: CompleteEvent) {
// Cached listing
const federationPages = (await cacheFileListing(domain)).filter((fm) =>
fm.name.endsWith(".md")
).map((fm) => ({
...fm,
name: fm.name.slice(0, -3),
}));
).map(fileMetaToPageMeta);
if (federationPages.length > 0) {
allPages = allPages.concat(federationPages);
}
@ -45,3 +42,15 @@ export async function pageComplete(completeEvent: CompleteEvent) {
}),
};
}
function fileMetaToPageMeta(fileMeta: FileMeta): PageMeta {
const name = fileMeta.name.substring(0, fileMeta.name.length - 3);
return {
...fileMeta,
ref: fileMeta.name,
tags: ["page"],
name,
created: new Date(fileMeta.created).toISOString(),
lastModified: new Date(fileMeta.lastModified).toISOString(),
} as PageMeta;
}

View File

@ -1,43 +1,28 @@
import type { IndexTreeEvent } from "$sb/app_event.ts";
import { space } from "$sb/syscalls.ts";
import type { ObjectValue, PageMeta } from "$sb/types.ts";
import type { PageMeta } from "$sb/types.ts";
import { extractFrontmatter } from "$sb/lib/frontmatter.ts";
import { extractAttributes } from "$sb/lib/attribute.ts";
import { indexObjects } from "./api.ts";
export type PageObject = ObjectValue<
// The base is PageMeta, but we override lastModified to be a string
Omit<Omit<PageMeta, "lastModified">, "created"> & {
created: string; // indexing it as a string
lastModified: string; // indexing it as a string
} & Record<string, any>
>;
export async function indexPage({ name, tree }: IndexTreeEvent) {
if (name.startsWith("_")) {
// Don't index pages starting with _
return;
}
const pageMeta = await space.getPageMeta(name);
let pageObj: PageObject = {
ref: name,
tags: [], // will be overridden in a bit
...pageMeta,
created: new Date(pageMeta.created).toISOString(),
lastModified: new Date(pageMeta.lastModified).toISOString(),
};
let pageMeta = await space.getPageMeta(name);
const frontmatter: Record<string, any> = await extractFrontmatter(tree);
const frontmatter = await extractFrontmatter(tree);
const toplevelAttributes = await extractAttributes(tree, false);
// Push them all into the page object
pageObj = { ...pageObj, ...frontmatter, ...toplevelAttributes };
pageMeta = { ...pageMeta, ...frontmatter, ...toplevelAttributes };
pageObj.tags = ["page", ...pageObj.tags || []];
pageMeta.tags = [...new Set(["page", ...pageMeta.tags || []])];
// console.log("Page object", pageObj);
// console.log("Extracted page meta data", pageMeta);
await indexObjects<PageObject>(name, [pageObj]);
await indexObjects<PageMeta>(name, [pageMeta]);
}

View File

@ -29,6 +29,6 @@ export function getObjectByRef<T>(
page: string,
tag: string,
ref: string,
): Promise<ObjectValue<T>[]> {
): Promise<T | undefined> {
return invokeFunction("index.getObjectByRef", page, tag, ref);
}

View File

@ -7,20 +7,19 @@ import {
collectNodesOfType,
findParentMatching,
} from "$sb/lib/tree.ts";
import type { ObjectValue } from "$sb/types.ts";
export type TagObject = {
ref: string;
tags: string[];
export type TagObject = ObjectValue<{
name: string;
page: string;
parent: string;
};
}>;
export async function indexTags({ name, tree }: IndexTreeEvent) {
removeQueries(tree);
const tags = new Set<string>(); // name:parent
addParentPointers(tree);
const pageTags: string[] = (await extractFrontmatter(tree)).tags || [];
const pageTags: string[] = (await extractFrontmatter(tree)).tags;
for (const pageTag of pageTags) {
tags.add(`${pageTag}:page`);
}
@ -68,9 +67,12 @@ export async function tagComplete(completeEvent: CompleteEvent) {
} else if (itemPrefixRegex.test(completeEvent.linePrefix)) {
parent = "item";
}
// Query all tags
const allTags = await queryObjects<TagObject>("tag", {
filter: ["=", ["attr", "parent"], ["string", parent]],
});
return {
from: completeEvent.pos - tagPrefix.length,
options: allTags.map((tag) => ({

View File

@ -3,19 +3,19 @@ import { events, language, space, system } 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 { replaceTemplateVars } from "../template/template.ts";
import { loadPageObject, replaceTemplateVars } from "../template/template.ts";
export async function widget(
bodyText: string,
pageName: string,
): Promise<WidgetContent> {
const pageMeta = await space.getPageMeta(pageName);
const pageObject = await loadPageObject(pageName);
try {
const queryAST = parseTreeToAST(
await language.parseLanguage(
"query",
await replaceTemplateVars(bodyText, pageMeta),
await replaceTemplateVars(bodyText, pageObject),
),
);
const parsedQuery = astToKvQuery(queryAST[1]);
@ -28,7 +28,7 @@ export async function widget(
// Let's dispatch an event and see what happens
const results = await events.dispatchEvent(
eventName,
{ query: parsedQuery, pageName: pageMeta.name },
{ query: parsedQuery, pageName: pageObject.name },
30 * 1000,
);
if (results.length === 0) {
@ -45,7 +45,7 @@ export async function widget(
if (parsedQuery.render) {
// Configured a custom rendering template, let's use it!
const rendered = await renderTemplate(
pageMeta,
pageObject,
parsedQuery.render,
allResults,
parsedQuery.renderAll!,

View File

@ -1,8 +1,9 @@
import { WidgetContent } from "$sb/app_event.ts";
import { handlebars, markdown, space, system, YAML } from "$sb/syscalls.ts";
import { rewritePageRefs } from "$sb/lib/resolve.ts";
import { replaceTemplateVars } from "../template/template.ts";
import { loadPageObject, replaceTemplateVars } from "../template/template.ts";
import { renderToText } from "$sb/lib/tree.ts";
import { PageMeta } from "$sb/types.ts";
type TemplateConfig = {
// Pull the template from a page
@ -19,7 +20,7 @@ export async function widget(
bodyText: string,
pageName: string,
): Promise<WidgetContent> {
const pageMeta = await space.getPageMeta(pageName);
const pageMeta: PageMeta = await loadPageObject(pageName);
try {
const config: TemplateConfig = await YAML.parse(bodyText);

View File

@ -1,5 +1,14 @@
name: template
functions:
templateSlashCommand:
path: ./template.ts:templateSlashComplete
events:
- slash:complete
insertSlashTemplate:
path: ./template.ts:insertSlashTemplate
# Template commands
insertTemplateText:
path: "./template.ts:insertTemplateText"

View File

@ -1,10 +1,53 @@
import { editor, handlebars, markdown, space } from "$sb/syscalls.ts";
import { editor, handlebars, markdown, space, YAML } from "$sb/syscalls.ts";
import { extractFrontmatter } from "$sb/lib/frontmatter.ts";
import { renderToText } from "$sb/lib/tree.ts";
import { niceDate, niceTime } from "$sb/lib/dates.ts";
import { readSettings } from "$sb/lib/settings_page.ts";
import { cleanPageRef } from "$sb/lib/resolve.ts";
import { PageMeta } from "$sb/types.ts";
import { ObjectValue, PageMeta } from "$sb/types.ts";
import { CompleteEvent, SlashCompletion } from "$sb/app_event.ts";
import { getObjectByRef, queryObjects } from "../index/plug_api.ts";
export type TemplateObject = ObjectValue<{
trigger?: string; // has to start with # for now
scope?: string;
frontmatter?: Record<string, any> | string;
}>;
export async function templateSlashComplete(
completeEvent: CompleteEvent,
): Promise<SlashCompletion[]> {
const allTemplates = await queryObjects<TemplateObject>("template", {});
return allTemplates.map((template) => ({
label: template.trigger!,
detail: "template",
templatePage: template.ref,
pageName: completeEvent.pageName,
invoke: "template.insertSlashTemplate",
}));
}
export async function insertSlashTemplate(slashCompletion: SlashCompletion) {
const pageObject = await loadPageObject(slashCompletion.pageName);
let templateText = await space.readPage(slashCompletion.templatePage);
templateText = await replaceTemplateVars(templateText, pageObject);
const parseTree = await markdown.parseMarkdown(templateText);
const frontmatter = await extractFrontmatter(parseTree, [], true);
templateText = renderToText(parseTree).trim();
if (frontmatter.frontmatter) {
templateText = "---\n" + (await YAML.stringify(frontmatter.frontmatter)) +
"---\n" + templateText;
}
const cursorPos = await editor.getCursor();
const carretPos = templateText.indexOf("|^|");
templateText = templateText.replace("|^|", "");
await editor.insertAtCursor(templateText);
if (carretPos !== -1) {
await editor.moveCursor(cursorPos + carretPos);
}
}
export async function instantiateTemplateCommand() {
const allPages = await space.listPages();
@ -39,9 +82,11 @@ export async function instantiateTemplateCommand() {
]);
const tempPageMeta: PageMeta = {
tags: ["page"],
ref: "",
name: "",
created: 0,
lastModified: 0,
created: "",
lastModified: "",
perm: "rw",
};
@ -159,6 +204,20 @@ export async function applyPageTemplateCommand() {
}
}
export async function loadPageObject(pageName: string): Promise<PageMeta> {
return (await getObjectByRef<PageMeta>(
pageName,
"page",
pageName,
)) || {
ref: pageName,
name: pageName,
tags: ["page"],
lastModified: "",
created: "",
} as PageMeta;
}
export function replaceTemplateVars(
s: string,
pageMeta: PageMeta,
@ -206,9 +265,11 @@ export async function dailyNoteCommand() {
await space.writePage(
pageName,
await replaceTemplateVars(dailyNoteTemplateText, {
tags: ["page"],
ref: pageName,
name: pageName,
created: 0,
lastModified: 0,
created: "",
lastModified: "",
perm: "rw",
}),
);
@ -252,8 +313,10 @@ export async function weeklyNoteCommand() {
pageName,
await replaceTemplateVars(weeklyNoteTemplateText, {
name: pageName,
created: 0,
lastModified: 0,
ref: pageName,
tags: ["page"],
created: "",
lastModified: "",
perm: "rw",
}),
);
@ -267,22 +330,11 @@ export async function weeklyNoteCommand() {
export async function insertTemplateText(cmdDef: any) {
const cursorPos = await editor.getCursor();
const page = await editor.getCurrentPage();
let pageMeta: PageMeta | undefined;
try {
pageMeta = await space.getPageMeta(page);
} catch {
// Likely page not yet created
pageMeta = {
name: page,
created: 0,
lastModified: 0,
perm: "rw",
};
}
const pageMeta = await loadPageObject(page);
let templateText: string = cmdDef.value;
const carretPos = templateText.indexOf("|^|");
templateText = templateText.replace("|^|", "");
templateText = await replaceTemplateVars(templateText, pageMeta!);
templateText = await replaceTemplateVars(templateText, pageMeta);
await editor.insertAtCursor(templateText);
if (carretPos !== -1) {
await editor.moveCursor(cursorPos + carretPos);

View File

@ -624,7 +624,7 @@ export class Client {
}
// Code completion support
private async completeWithEvent(
async completeWithEvent(
context: CompletionContext,
eventName: AppEvent,
): Promise<CompletionResult | null> {
@ -761,7 +761,14 @@ export class Client {
console.log("Page doesn't exist, creating new page:", pageName);
doc = {
text: "",
meta: { name: pageName, lastModified: 0, perm: "rw" } as PageMeta,
meta: {
ref: pageName,
tags: ["page"],
name: pageName,
lastModified: "",
created: "",
perm: "rw",
} as PageMeta,
};
} else {
this.flashNotification(

View File

@ -4,6 +4,7 @@ import { Completion, CompletionContext, CompletionResult } from "../deps.ts";
import { safeRun } from "../../common/util.ts";
import { Client } from "../client.ts";
import { syntaxTree } from "../deps.ts";
import { SlashCompletion } from "$sb/app_event.ts";
export type SlashCommandDef = {
name: string;
@ -53,9 +54,9 @@ export class SlashCommandHook implements Hook<SlashCommandHookT> {
}
// Completer for CodeMirror
public slashCommandCompleter(
public async slashCommandCompleter(
ctx: CompletionContext,
): CompletionResult | null {
): Promise<CompletionResult | null> {
const prefix = ctx.matchBefore(slashCommandRegexp);
if (!prefix) {
return null;
@ -68,6 +69,7 @@ export class SlashCommandHook implements Hook<SlashCommandHookT> {
if (currentNode.type.name === "CommentBlock") {
return null;
}
for (const def of this.slashCommands.values()) {
options.push({
label: def.slashCommand.name,
@ -90,6 +92,48 @@ export class SlashCommandHook implements Hook<SlashCommandHookT> {
},
});
}
const slashCompletions: SlashCompletion[] | null = await this.editor
.completeWithEvent(
ctx,
"slash:complete",
) as any;
if (slashCompletions) {
for (const slashCompletion of slashCompletions) {
options.push({
label: slashCompletion.label,
detail: slashCompletion.detail,
apply: () => {
// Delete slash command part
this.editor.editorView.dispatch({
changes: {
from: prefix!.from + prefixText.indexOf("/"),
to: ctx.pos,
insert: "",
},
});
// Replace with whatever the completion is
safeRun(async () => {
const [plugName, functionName] = slashCompletion.invoke.split(
".",
);
const plug = this.editor.system.system.loadedPlugs.get(plugName);
if (!plug) {
this.editor.flashNotification(
`Plug ${plugName} not found`,
"error",
);
return;
}
await plug.invoke(functionName, [slashCompletion]);
this.editor.focus();
});
},
});
}
}
return {
// + 1 because of the '/'
from: prefix.from + prefixText.indexOf("/") + 1,

View File

@ -221,8 +221,13 @@ export class Space {
}
export function fileMetaToPageMeta(fileMeta: FileMeta): PageMeta {
const name = fileMeta.name.substring(0, fileMeta.name.length - 3);
return {
...fileMeta,
name: fileMeta.name.substring(0, fileMeta.name.length - 3),
ref: name,
tags: ["page"],
name,
created: new Date(fileMeta.created).toISOString(),
lastModified: new Date(fileMeta.lastModified).toISOString(),
} as PageMeta;
}