From 1afac0274eacbece90a6c6bc62996ebfe4224a6d Mon Sep 17 00:00:00 2001 From: Zef Hemel Date: Mon, 6 Nov 2023 09:14:16 +0100 Subject: [PATCH] Custom template slash commands --- common/syscalls/handlebar_helpers.ts | 3 + plug-api/app_event.ts | 7 +++ plug-api/lib/frontmatter.ts | 22 +++++-- plug-api/types.ts | 16 ++--- plugs/directive/util.ts | 1 - plugs/editor/complete.ts | 19 ++++-- plugs/index/page.ts | 27 ++------ plugs/index/plug_api.ts | 2 +- plugs/index/tags.ts | 12 ++-- plugs/query/query.ts | 10 +-- plugs/query/template.ts | 5 +- plugs/template/template.plug.yaml | 9 +++ plugs/template/template.ts | 94 +++++++++++++++++++++------- web/client.ts | 11 +++- web/hooks/slash_command.ts | 48 +++++++++++++- web/space.ts | 7 ++- 16 files changed, 214 insertions(+), 79 deletions(-) diff --git a/common/syscalls/handlebar_helpers.ts b/common/syscalls/handlebar_helpers.ts index 004a09d..0defd4a 100644 --- a/common/syscalls/handlebar_helpers.ts +++ b/common/syscalls/handlebar_helpers.ts @@ -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); }, diff --git a/plug-api/app_event.ts b/plug-api/app_event.ts index e326f63..f34e181 100644 --- a/plug-api/app_event.ts +++ b/plug-api/app_event.ts @@ -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; + export type WidgetContent = { html?: string; script?: string; diff --git a/plug-api/lib/frontmatter.ts b/plug-api/lib/frontmatter.ts index ad1d302..5fd040c 100644 --- a/plug-api/lib/frontmatter.ts +++ b/plug-api/lib/frontmatter.ts @@ -9,18 +9,23 @@ import { traverseTreeAsync, } from "$sb/lib/tree.ts"; +export type FrontMatter = { tags: string[] } & Record; + // 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 { - let data: any = {}; +): Promise { + 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; diff --git a/plug-api/types.ts b/plug-api/types.ts index 50904c3..2772a78 100644 --- a/plug-api/types.ts +++ b/plug-api/types.ts @@ -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 +>; export type AttachmentMeta = { name: string; diff --git a/plugs/directive/util.ts b/plugs/directive/util.ts index 9256a70..d1275b6 100644 --- a/plugs/directive/util.ts +++ b/plugs/directive/util.ts @@ -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) { diff --git a/plugs/editor/complete.ts b/plugs/editor/complete.ts index 0f91387..c1c2d83 100644 --- a/plugs/editor/complete.ts +++ b/plugs/editor/complete.ts @@ -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; +} diff --git a/plugs/index/page.ts b/plugs/index/page.ts index 2eab2dc..d28e21b 100644 --- a/plugs/index/page.ts +++ b/plugs/index/page.ts @@ -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, "created"> & { - created: string; // indexing it as a string - lastModified: string; // indexing it as a string - } & Record ->; - 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 = 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(name, [pageObj]); + await indexObjects(name, [pageMeta]); } diff --git a/plugs/index/plug_api.ts b/plugs/index/plug_api.ts index 27f6d2c..c7adcfd 100644 --- a/plugs/index/plug_api.ts +++ b/plugs/index/plug_api.ts @@ -29,6 +29,6 @@ export function getObjectByRef( page: string, tag: string, ref: string, -): Promise[]> { +): Promise { return invokeFunction("index.getObjectByRef", page, tag, ref); } diff --git a/plugs/index/tags.ts b/plugs/index/tags.ts index 1b66545..2943fc0 100644 --- a/plugs/index/tags.ts +++ b/plugs/index/tags.ts @@ -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(); // 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("tag", { filter: ["=", ["attr", "parent"], ["string", parent]], }); + return { from: completeEvent.pos - tagPrefix.length, options: allTags.map((tag) => ({ diff --git a/plugs/query/query.ts b/plugs/query/query.ts index d3c4dae..7140685 100644 --- a/plugs/query/query.ts +++ b/plugs/query/query.ts @@ -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 { - 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!, diff --git a/plugs/query/template.ts b/plugs/query/template.ts index be63f3e..75537fa 100644 --- a/plugs/query/template.ts +++ b/plugs/query/template.ts @@ -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 { - const pageMeta = await space.getPageMeta(pageName); + const pageMeta: PageMeta = await loadPageObject(pageName); try { const config: TemplateConfig = await YAML.parse(bodyText); diff --git a/plugs/template/template.plug.yaml b/plugs/template/template.plug.yaml index f862c7a..ce72a20 100644 --- a/plugs/template/template.plug.yaml +++ b/plugs/template/template.plug.yaml @@ -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" diff --git a/plugs/template/template.ts b/plugs/template/template.ts index 6f5b79e..7103746 100644 --- a/plugs/template/template.ts +++ b/plugs/template/template.ts @@ -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; +}>; + +export async function templateSlashComplete( + completeEvent: CompleteEvent, +): Promise { + const allTemplates = await queryObjects("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 { + return (await getObjectByRef( + 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); diff --git a/web/client.ts b/web/client.ts index 95f0187..4a63c69 100644 --- a/web/client.ts +++ b/web/client.ts @@ -624,7 +624,7 @@ export class Client { } // Code completion support - private async completeWithEvent( + async completeWithEvent( context: CompletionContext, eventName: AppEvent, ): Promise { @@ -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( diff --git a/web/hooks/slash_command.ts b/web/hooks/slash_command.ts index b074287..ca54dda 100644 --- a/web/hooks/slash_command.ts +++ b/web/hooks/slash_command.ts @@ -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 { } // Completer for CodeMirror - public slashCommandCompleter( + public async slashCommandCompleter( ctx: CompletionContext, - ): CompletionResult | null { + ): Promise { const prefix = ctx.matchBefore(slashCommandRegexp); if (!prefix) { return null; @@ -68,6 +69,7 @@ export class SlashCommandHook implements Hook { 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 { }, }); } + + 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, diff --git a/web/space.ts b/web/space.ts index c203a4f..5fe998d 100644 --- a/web/space.ts +++ b/web/space.ts @@ -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; }