diff --git a/common/common_system.ts b/common/common_system.ts index 66d04b65..e4d3714e 100644 --- a/common/common_system.ts +++ b/common/common_system.ts @@ -32,7 +32,7 @@ export abstract class CommonSystem { constructor( protected mq: DataStoreMQ, - protected ds: DataStore, + public ds: DataStore, public eventHook: EventHook, public readOnlyMode: boolean, protected enableSpaceScript: boolean, diff --git a/common/expression_parser.test.ts b/common/expression_parser.test.ts new file mode 100644 index 00000000..9363957d --- /dev/null +++ b/common/expression_parser.test.ts @@ -0,0 +1,7 @@ +import { assertEquals } from "$std/testing/asserts.ts"; +import { parseExpression } from "$common/expression_parser.ts"; + +Deno.test("Test expression parser", () => { + // Just a sanity check here + assertEquals(parseExpression("1 + 2"), ["+", ["number", 1], ["number", 2]]); +}); diff --git a/common/expression_parser.ts b/common/expression_parser.ts new file mode 100644 index 00000000..c978bdf2 --- /dev/null +++ b/common/expression_parser.ts @@ -0,0 +1,12 @@ +import { QueryExpression } from "$sb/types.ts"; +import { parseTreeToAST } from "$sb/lib/tree.ts"; +import { expressionLanguage } from "$common/template/template_parser.ts"; +import { expressionToKvQueryExpression } from "$sb/lib/parse-query.ts"; +import { lezerToParseTree } from "$common/markdown_parser/parse_tree.ts"; + +export function parseExpression(s: string): QueryExpression { + const ast = parseTreeToAST( + lezerToParseTree(s, expressionLanguage.parser.parse(s).topNode), + ); + return expressionToKvQueryExpression(ast[1]); +} diff --git a/common/settings.ts b/common/settings.ts index 7ec4cab4..ba3aec9f 100644 --- a/common/settings.ts +++ b/common/settings.ts @@ -2,7 +2,10 @@ import YAML from "js-yaml"; import { INDEX_TEMPLATE, SETTINGS_TEMPLATE } from "./PAGE_TEMPLATES.ts"; import { SpacePrimitives } from "./spaces/space_primitives.ts"; import { cleanupJSON } from "../plug-api/lib/json.ts"; -import type { BuiltinSettings } from "../type/web.ts"; +import { BuiltinSettings } from "$type/settings.ts"; +import { DataStore, ObjectEnricher } from "$lib/data/datastore.ts"; +import { parseExpression } from "$common/expression_parser.ts"; +import { QueryExpression } from "$sb/types.ts"; const yamlSettingsRegex = /^(```+|~~~+)ya?ml\r?\n([\S\s]+?)\1/m; @@ -100,3 +103,37 @@ export async function ensureAndLoadSettingsAndIndex( cleanupJSON(settings); return { ...defaultSettings, ...settings }; } + +export function updateObjectDecorators( + settings: BuiltinSettings, + ds: DataStore, +) { + if (settings.objectDecorators) { + // Reload object decorators + const newDecorators: ObjectEnricher[] = []; + for ( + const decorator of settings.objectDecorators + ) { + try { + const parsedWhere = parseExpression(decorator.where); + const parsedDynamicAttributes: Record = {}; + for (const [key, value] of Object.entries(decorator.attributes)) { + parsedDynamicAttributes[key] = parseExpression(value); + } + newDecorators.push({ + where: parsedWhere, + attributes: parsedDynamicAttributes, + }); + } catch (e: any) { + console.error( + "Error parsing object decorator", + decorator, + "got error", + e.message, + ); + } + } + console.info(`Loaded ${newDecorators.length} object decorators`); + ds.objectEnrichers = newDecorators; + } +} diff --git a/lib/data/datastore.bench.ts b/lib/data/datastore.bench.ts new file mode 100644 index 00000000..c3bcc1a7 --- /dev/null +++ b/lib/data/datastore.bench.ts @@ -0,0 +1,60 @@ +import { DataStore } from "$lib/data/datastore.ts"; +import { MemoryKvPrimitives } from "$lib/data/memory_kv_primitives.ts"; + +Deno.bench("DataStore enrichment benchmark with match", (b) => { + // Dummy datastore with a single object enricher + const datastore = new DataStore(new MemoryKvPrimitives(), {}); + + datastore.objectEnrichers = [ + { + where: ["=", ["attr", "tags"], ["string", "person"]], + attributes: { + fullName: ["+", ["+", ["attr", "firstName"], ["string", " "]], [ + "attr", + "lastName", + ]], + }, + }, + ]; + + b.start(); + // Let's try with half a million entries + for (let i = 0; i < 500000; i++) { + const obj = { + firstName: "Pete", + lastName: "Smith", + tags: ["person"], + }; + datastore.enrichObject(obj); + } + b.end(); +}); + +Deno.bench("DataStore enrichment benchmark without match", (b) => { + // Dummy datastore with a single object enricher + const datastore = new DataStore(new MemoryKvPrimitives(), {}); + + datastore.objectEnrichers = [ + { + where: ["=", ["attr", "tags"], ["string", "person"]], + attributes: { + fullName: ["+", ["+", ["attr", "firstName"], ["string", " "]], [ + "attr", + "lastName", + ]], + }, + }, + ]; + + b.start(); + // Let's try with half a million entries + for (let i = 0; i < 500000; i++) { + const obj = { + firstName: "Pete", + lastName: "Smith", + tags: ["peson"], + }; + datastore.enrichObject(obj); + } + b.end(); +}); diff --git a/lib/data/datastore.test.ts b/lib/data/datastore.test.ts index 8e13bde3..75e3e372 100644 --- a/lib/data/datastore.test.ts +++ b/lib/data/datastore.test.ts @@ -1,6 +1,6 @@ import "fake-indexeddb/auto"; import { IndexedDBKvPrimitives } from "../data/indexeddb_kv_primitives.ts"; -import { DataStore } from "../data/datastore.ts"; +import { cleanupEmptyObjects, DataStore } from "../data/datastore.ts"; import { DenoKvPrimitives } from "../data/deno_kv_primitives.ts"; import { KvPrimitives } from "../data/kv_primitives.ts"; import { assertEquals } from "$std/testing/asserts.ts"; @@ -103,6 +103,65 @@ async function test(db: KvPrimitives) { }), [{ key: ["kv", "complicated"], value: { random: [] } }], ); + + // Test object enrichment + datastore.objectEnrichers = [ + { // fullName + where: ["=", ["attr", "tags"], ["string", "person"]], + attributes: { + fullName: ["+", ["+", ["attr", "firstName"], ["string", " "]], [ + "attr", + "lastName", + ]], + }, + }, + { + where: ["=", ["attr", "tags"], ["string", "person"]], + attributes: { + "pageDecoration.prefix.bla.doh": ["+", ["string", "🧑 "], [ + "attr", + "fullName", + ]], + }, + }, + { + where: ["=", ["attr", "tags"], ["string", "person"]], + attributes: { + "existingObjAttribute.another": ["string", "value"], + }, + }, + ]; + + const obj: Record = { + firstName: "Pete", + lastName: "Smith", + existingObjAttribute: { + something: true, + }, + tags: ["person"], + }; + const pristineCopy = JSON.parse(JSON.stringify(obj)); + + datastore.enrichObject(obj); + assertEquals(obj.fullName, "Pete Smith"); + assertEquals(obj.pageDecoration, { + prefix: { bla: { doh: "🧑 Pete Smith" } }, + }); + assertEquals(obj.existingObjAttribute.something, true); + assertEquals(obj.existingObjAttribute.another, "value"); + + // And now let's clean it again + datastore.cleanEnrichedObject(obj); + + assertEquals(obj, pristineCopy); + + // Validate no async functions are called in the object enrichment + datastore.objectEnrichers = [ + { + where: ["call", "$query", []], + attributes: {}, + }, + ]; } Deno.test("Test Deno KV DataStore", async () => { @@ -122,3 +181,22 @@ Deno.test("Test IndexDB DataStore", { await test(db); db.close(); }); + +Deno.test("Test cleanupEmptyObjects", () => { + const testObject: any = { + attribute1: 10, + another: { nested: 20, removeMe: {} }, + list: [], + removeMe: { + deeply: {}, + }, + another2: [1, 2, 3], + }; + cleanupEmptyObjects(testObject); + assertEquals(testObject, { + attribute1: 10, + another: { nested: 20 }, + list: [], + another2: [1, 2, 3], + }); +}); diff --git a/lib/data/datastore.ts b/lib/data/datastore.ts index 71fcff8e..0f28525c 100644 --- a/lib/data/datastore.ts +++ b/lib/data/datastore.ts @@ -1,5 +1,11 @@ import { applyQueryNoFilterKV } from "../../plug-api/lib/query.ts"; -import { FunctionMap, KV, KvKey, KvQuery } from "../../plug-api/types.ts"; +import { + FunctionMap, + KV, + KvKey, + KvQuery, + QueryExpression, +} from "../../plug-api/types.ts"; import { builtinFunctions } from "../builtin_query_functions.ts"; import { KvPrimitives } from "./kv_primitives.ts"; import { evalQueryExpression } from "../../plug-api/lib/query_expression.ts"; @@ -11,6 +17,7 @@ export class DataStore { constructor( readonly kv: KvPrimitives, public functionMap: FunctionMap = builtinFunctions, + public objectEnrichers: ObjectEnricher[] = [], ) { } @@ -18,11 +25,17 @@ export class DataStore { return (await this.batchGet([key]))[0]; } - batchGet(keys: KvKey[]): Promise<(T | null)[]> { + async batchGet(keys: KvKey[]): Promise<(T | null)[]> { if (keys.length === 0) { - return Promise.resolve([]); + return []; } - return this.kv.batchGet(keys); + const results = await this.kv.batchGet(keys); + + // Enrich the objects based on object enrichers + for (const entry of results) { + this.enrichObject(entry); + } + return results; } set(key: KvKey, value: any): Promise { @@ -41,6 +54,7 @@ export class DataStore { console.warn(`Duplicate key ${keyString} in batchSet, skipping`); } else { allKeyStrings.add(keyString); + this.cleanEnrichedObject(value); uniqueEntries.push({ key, value }); } } @@ -77,6 +91,8 @@ export class DataStore { for await ( const entry of this.kv.query(query) ) { + // Enrich + this.enrichObject(entry.value); // Filter if ( query.filter && @@ -118,4 +134,139 @@ export class DataStore { } return this.batchDelete(keys); } + + /** + * Enriches the object with the attributes defined in the object enrichers on the fly + * @param object + * @returns + */ + enrichObject(object: any) { + // Check if this object looks like an object value + if (!object || typeof object !== "object") { + // Skip + return; + } + + for (const enricher of this.objectEnrichers) { + const whereEvalResult = evalQueryExpression( + enricher.where, + object, + {}, // We will not support variables in enrichers for now + this.functionMap, + ); + if (whereEvalResult instanceof Promise) { + // For performance reasons we can only allow synchronous where clauses + throw new Error( + `Enricher where clause cannot be an async function: ${enricher.where}`, + ); + } + if ( + whereEvalResult + ) { + // The `where` matches so we should enrich this object + for ( + const [attributeSelector, expression] of Object.entries( + enricher.attributes, + ) + ) { + // Recursively travel to the attribute based on the selector, which may contain .'s to go deeper + let objectValue = object; + const selectorParts = attributeSelector.split("."); + for (const part of selectorParts.slice(0, -1)) { + if (typeof objectValue[part] !== "object") { + // Pre-create the object if it doesn't exist + objectValue[part] = {}; + } + objectValue = objectValue[part]; + } + + const value = evalQueryExpression( + expression, + object, + {}, + this.functionMap, + ); + if (value instanceof Promise) { + // For performance reasons we can only allow synchronous expressions + throw new Error( + `Enricher dynamic attribute expression cannot be an async function: ${expression}`, + ); + } + objectValue[selectorParts[selectorParts.length - 1]] = value; + } + } + } + } + + /** + * Reverses the enriching of the object with the attributes defined in objectEnrichers + * @param object + * @returns + */ + cleanEnrichedObject(object: any) { + // Check if this object looks like an object value + if (!object || typeof object !== "object") { + // Skip + return; + } + + for (const enricher of this.objectEnrichers) { + if ( + evalQueryExpression( + enricher.where, + object, + {}, // We will not support variables in enrichers for now + this.functionMap, + ) + ) { + // The `where` matches so we should clean this object from the dynamic attributes + for ( + const [attributeSelector, _expression] of Object.entries( + enricher.attributes, + ) + ) { + // Recursively travel to the attribute based on the selector, which may contain .'s to go deeper + let objectValue = object; + const selectorParts = attributeSelector.split("."); + for (const part of selectorParts.slice(0, -1)) { + if (typeof objectValue[part] !== "object") { + // This shouldn't happen, but let's back out + break; + } + objectValue = objectValue[part]; + } + + delete objectValue[selectorParts[selectorParts.length - 1]]; + } + } + } + // Clean up empty objects, this is somewhat questionable, because it also means that if the user intentionally kept empty objects in there, these will be wiped + cleanupEmptyObjects(object); + } +} + +export type ObjectEnricher = { + // If this expression evaluates to true for the given object + where: QueryExpression; + // Dynamically add these attributes to the object, can use "." syntax for deeper attribute definition + attributes: Record; +}; + +/** + * Recursively removes empty objects from the object + * @param object + */ +export function cleanupEmptyObjects(object: any) { + for (const key in object) { + // Skip arrays + if (Array.isArray(object[key])) { + continue; + } + if (typeof object[key] === "object") { + cleanupEmptyObjects(object[key]); + if (Object.keys(object[key]).length === 0) { + delete object[key]; + } + } + } } diff --git a/plug-api/syscalls.ts b/plug-api/syscalls.ts index 814ca4f0..9081f127 100644 --- a/plug-api/syscalls.ts +++ b/plug-api/syscalls.ts @@ -16,4 +16,3 @@ export * as YAML from "./syscalls/yaml.ts"; export * as mq from "./syscalls/mq.ts"; export * from "./syscall.ts"; export * as datastore from "./syscalls/datastore.ts"; -export * as decoration from "./syscalls/decoration.ts"; diff --git a/plug-api/syscalls/decoration.ts b/plug-api/syscalls/decoration.ts deleted file mode 100644 index cd622e12..00000000 --- a/plug-api/syscalls/decoration.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { PageMeta } from "$sb/types.ts"; - -export function applyDecorationsToPages( - pages: PageMeta[], -): Promise { - return syscall("decoration.applyDecorationsToPages", pages); -} diff --git a/plug-api/types.ts b/plug-api/types.ts index 6df978f2..02c474f6 100644 --- a/plug-api/types.ts +++ b/plug-api/types.ts @@ -26,8 +26,6 @@ export type PageMeta = ObjectValue< * Decorates a page when it matches certain criteria */ export type PageDecoration = { - where?: string; - whereParsed?: QueryExpression; prefix: string; hide?: boolean; }; diff --git a/plugs/editor/complete.ts b/plugs/editor/complete.ts index 7dc099c8..a0f41d49 100644 --- a/plugs/editor/complete.ts +++ b/plugs/editor/complete.ts @@ -8,7 +8,6 @@ import { import { listFilesCached } from "../federation/federation.ts"; import { queryObjects } from "../index/plug_api.ts"; import { folderName } from "$sb/lib/resolve.ts"; -import { decoration } from "$sb/syscalls.ts"; import type { LinkObject } from "../index/page_links.ts"; // A meta page is a page tagged with either #template or #meta @@ -121,9 +120,6 @@ export async function pageComplete(completeEvent: CompleteEvent) { const folder = folderName(completeEvent.pageName); - // Decorate the pages - allPages = await decoration.applyDecorationsToPages(allPages as PageMeta[]); - return { from: completeEvent.pos - prefix.length, options: allPages.map((pageMeta) => { diff --git a/plugs/index/widget.ts b/plugs/index/widget.ts index 5353c665..6ae508b4 100644 --- a/plugs/index/widget.ts +++ b/plugs/index/widget.ts @@ -53,6 +53,7 @@ export async function renderTemplateWidgets(side: "top" | "bottom"): Promise< await language.parseLanguage("expression", blockDef.where!), ); const parsedExpression = expressionToKvQueryExpression(exprAST[1]); + if (await evalQueryExpression(parsedExpression, pageMeta, {}, {})) { // Match! We're happy const templateText = await space.readPage(template.ref); diff --git a/server/instance.ts b/server/instance.ts index c6469ee9..98392e02 100644 --- a/server/instance.ts +++ b/server/instance.ts @@ -1,5 +1,8 @@ import { SilverBulletHooks } from "../lib/manifest.ts"; -import { ensureAndLoadSettingsAndIndex } from "$common/settings.ts"; +import { + ensureAndLoadSettingsAndIndex, + updateObjectDecorators, +} from "$common/settings.ts"; import { AssetBundlePlugSpacePrimitives } from "$common/spaces/asset_bundle_space_primitives.ts"; import { FilteredSpacePrimitives } from "$common/spaces/filtered_space_primitives.ts"; import { ReadOnlySpacePrimitives } from "$common/spaces/ro_space_primitives.ts"; @@ -10,13 +13,13 @@ import { DataStore } from "$lib/data/datastore.ts"; import { KvPrimitives } from "$lib/data/kv_primitives.ts"; import { DataStoreMQ } from "$lib/data/mq.datastore.ts"; import { System } from "$lib/plugos/system.ts"; -import { BuiltinSettings } from "../type/web.ts"; import { JWTIssuer } from "./crypto.ts"; import { compile as gitIgnoreCompiler } from "gitignore-parser"; import { ServerSystem } from "./server_system.ts"; import { determineShellBackend, NotSupportedShell } from "./shell_backend.ts"; import { ShellBackend } from "./shell_backend.ts"; import { determineStorageBackend } from "./storage_backend.ts"; +import { BuiltinSettings } from "$type/settings.ts"; export type SpaceServerConfig = { hostname: string; @@ -136,5 +139,9 @@ export class SpaceServer { async reloadSettings() { this.settings = await ensureAndLoadSettingsAndIndex(this.spacePrimitives); + + if (this.serverSystem) { + updateObjectDecorators(this.settings, this.serverSystem.ds); + } } } diff --git a/type/settings.ts b/type/settings.ts new file mode 100644 index 00000000..6b9df0de --- /dev/null +++ b/type/settings.ts @@ -0,0 +1,33 @@ +import { ActionButton, EmojiConfig, Shortcut } from "$lib/web.ts"; +import { Manifest } from "$lib/manifest.ts"; + +export type ObjectDecorator = { + // The expression to match against the object + where: string; + // The dynamic attributes to add to the object + attributes: Record; // attributePath -> expression +}; + +export type BuiltinSettings = { + indexPage: string; + shortcuts?: Shortcut[]; + useSmartQuotes?: boolean; + maximumAttachmentSize?: number; + // Open the last page that was open when the app was closed + pwaOpenLastPage?: boolean; + // UI visuals + hideEditButton?: boolean; + hideSyncButton?: boolean; + actionButtons: ActionButton[]; + objectDecorators?: ObjectDecorator[]; + // Format: compatible with docker ignore + spaceIgnore?: string; + emoji?: EmojiConfig; + // DEPRECATED: Use space styles instead + customStyles?: string | string[]; + // DEPRECATED: Use shortcuts instead + plugOverrides?: Record>; + + // NOTE: Bit niche, maybe delete at some point? + defaultLinkStyle?: string; +}; diff --git a/type/web.ts b/type/web.ts index 38b94b4b..4e72bc37 100644 --- a/type/web.ts +++ b/type/web.ts @@ -1,38 +1,8 @@ -import { Manifest } from "../lib/manifest.ts"; -import { PageDecoration, PageMeta } from "../plug-api/types.ts"; import { AppCommand } from "../lib/command.ts"; import { defaultSettings } from "$common/settings.ts"; -import { - ActionButton, - EmojiConfig, - FilterOption, - Notification, - PanelMode, - Shortcut, -} from "$lib/web.ts"; - -export type BuiltinSettings = { - indexPage: string; - shortcuts?: Shortcut[]; - useSmartQuotes?: boolean; - maximumAttachmentSize?: number; - // Open the last page that was open when the app was closed - pwaOpenLastPage?: boolean; - // UI visuals - hideEditButton?: boolean; - hideSyncButton?: boolean; - actionButtons: ActionButton[]; - pageDecorations?: PageDecoration[]; - // Format: compatible with docker ignore - spaceIgnore?: string; - emoji?: EmojiConfig; - // DEPRECATED: Use space styles instead - customStyles?: string | string[]; - // DEPRECATED: Use shortcuts instead - plugOverrides?: Record>; - // NOTE: Bit niche, maybe delete at some point? - defaultLinkStyle?: string; -}; +import { FilterOption, Notification, PanelMode } from "$lib/web.ts"; +import { BuiltinSettings } from "$type/settings.ts"; +import { PageMeta } from "$sb/types.ts"; export type PanelConfig = { mode?: PanelMode; diff --git a/web/client.ts b/web/client.ts index 679797cc..3b4b0b3f 100644 --- a/web/client.ts +++ b/web/client.ts @@ -14,7 +14,7 @@ import { PathPageNavigator, } from "./navigator.ts"; -import { AppViewState, BuiltinSettings } from "../type/web.ts"; +import { AppViewState } from "../type/web.ts"; import type { AppEvent, @@ -54,12 +54,16 @@ import { PageRef } from "../plug-api/lib/page_ref.ts"; import { ReadOnlySpacePrimitives } from "$common/spaces/ro_space_primitives.ts"; import { KvPrimitives } from "$lib/data/kv_primitives.ts"; import { builtinFunctions } from "$lib/builtin_query_functions.ts"; -import { ensureAndLoadSettingsAndIndex } from "$common/settings.ts"; +import { + ensureAndLoadSettingsAndIndex, + updateObjectDecorators, +} from "$common/settings.ts"; import { LimitedMap } from "$lib/limited_map.ts"; import { plugPrefix } from "$common/spaces/constants.ts"; import { lezerToParseTree } from "$common/markdown_parser/parse_tree.ts"; import { findNodeMatching } from "$sb/lib/tree.ts"; import type { LinkObject } from "../plugs/index/page_links.ts"; +import { BuiltinSettings } from "$type/settings.ts"; const frontMatterRegex = /^---\n(([^\n]|\n)*?)---\n/; @@ -248,6 +252,7 @@ export class Client { this.settings = await ensureAndLoadSettingsAndIndex( this.space.spacePrimitives, ); + updateObjectDecorators(this.settings, this.stateDataStore); this.ui.viewDispatch({ type: "settings-loaded", settings: this.settings, diff --git a/web/client_system.ts b/web/client_system.ts index 5036ed58..4ef6c068 100644 --- a/web/client_system.ts +++ b/web/client_system.ts @@ -43,7 +43,6 @@ import { createKeyBindings } from "./editor_state.ts"; import { CommonSystem } from "$common/common_system.ts"; import { DataStoreMQ } from "$lib/data/mq.datastore.ts"; import { plugPrefix } from "$common/spaces/constants.ts"; -import { decorationSyscalls } from "./syscalls/decoration.ts"; const plugNameExtractRegex = /\/(.+)\.plug\.js$/; @@ -169,7 +168,6 @@ export class ClientSystem extends CommonSystem { spaceReadSyscalls(this.client), systemSyscalls(this.system, false, this, this.client), markdownSyscalls(), - decorationSyscalls(this.client), assetSyscalls(this.system), yamlSyscalls(), templateSyscalls(this.ds), diff --git a/web/components/command_palette.tsx b/web/components/command_palette.tsx index fd01283a..1e40a256 100644 --- a/web/components/command_palette.tsx +++ b/web/components/command_palette.tsx @@ -3,8 +3,8 @@ import { CompletionContext, CompletionResult } from "@codemirror/autocomplete"; import { Terminal } from "preact-feather"; import { AppCommand } from "../../lib/command.ts"; import { FilterOption } from "$lib/web.ts"; -import { BuiltinSettings } from "../../type/web.ts"; import { parseCommand } from "$common/command.ts"; +import { BuiltinSettings } from "$type/settings.ts"; export function CommandPalette({ commands, diff --git a/web/reducer.ts b/web/reducer.ts index 67311ae6..089874e5 100644 --- a/web/reducer.ts +++ b/web/reducer.ts @@ -1,6 +1,5 @@ import { PageMeta } from "../plug-api/types.ts"; import { Action, AppViewState } from "../type/web.ts"; -import { decoratePageMeta } from "./syscalls/decoration.ts"; export default function reducer( state: AppViewState, @@ -47,16 +46,10 @@ export default function reducer( }; } case "update-current-page-meta": { - if (state.settings.pageDecorations) { - decoratePageMeta( - action.meta, - state.settings.pageDecorations, - ); - // Update in the allPages list as well - state.allPages = state.allPages.map((pageMeta) => - pageMeta.name === action.meta.name ? action.meta : pageMeta - ); - } + // Update in the allPages list as well + state.allPages = state.allPages.map((pageMeta) => + pageMeta.name === action.meta.name ? action.meta : pageMeta + ); return { ...state, currentPageMeta: action.meta, @@ -83,12 +76,6 @@ export default function reducer( if (oldPageMetaItem && oldPageMetaItem.lastOpened) { pageMeta.lastOpened = oldPageMetaItem.lastOpened; } - if (state.settings.pageDecorations) { - decoratePageMeta( - pageMeta, - state.settings.pageDecorations, - ); - } if (pageMeta.name === state.currentPage) { currPageMeta = pageMeta; } diff --git a/web/syscalls/decoration.ts b/web/syscalls/decoration.ts deleted file mode 100644 index c7bdd6f2..00000000 --- a/web/syscalls/decoration.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { PageDecoration, PageMeta } from "$sb/types.ts"; -import { SysCallMapping } from "$lib/plugos/system.ts"; -import { Client } from "../client.ts"; -import { parseTreeToAST } from "$sb/lib/tree.ts"; -import { lezerToParseTree } from "$common/markdown_parser/parse_tree.ts"; -import { expressionLanguage } from "$common/template/template_parser.ts"; -import { expressionToKvQueryExpression } from "$sb/lib/parse-query.ts"; -import { evalQueryExpression } from "$sb/lib/query_expression.ts"; -import { builtinFunctions } from "$lib/builtin_query_functions.ts"; - -export function decorationSyscalls( - client: Client, -): SysCallMapping { - return { - "decoration.applyDecorationsToPages": ( - _ctx, - pages: PageMeta[], - ): PageMeta[] => { - if (client.settings.pageDecorations) { - for (const pageMeta of pages) { - decoratePageMeta(pageMeta, client.settings.pageDecorations); - } - } - return pages; - }, - }; -} - -/** - * Decorates (= attaches a pageDecoration field) to the pageMeta object when a matching decorator is found - */ -export function decoratePageMeta( - pageMeta: PageMeta, - decorations: PageDecoration[], -) { - if (!pageMeta) { - return; - } - for (const decoration of decorations) { - if (!decoration.where) { - continue; - } - // whereParsed is effectively a cached version of the parsed where expression - // Let's check if it's populated - if (!decoration.whereParsed) { - // If not, populate it - try { - const ast = parseTreeToAST(lezerToParseTree( - decoration.where, - expressionLanguage.parser.parse(decoration.where).topNode, - )); - decoration.whereParsed = expressionToKvQueryExpression( - ast[1], - ); - } catch (e: any) { - console.error( - "Failed to parse 'where' expression in decoration:", - e, - ); - continue; - } - } - if ( - evalQueryExpression( - decoration.whereParsed, - pageMeta, - {}, - builtinFunctions, - ) - ) { - pageMeta.pageDecoration = decoration; - } - } -} diff --git a/website/CHANGELOG.md b/website/CHANGELOG.md index 1c9dfe38..7f6a6ebd 100644 --- a/website/CHANGELOG.md +++ b/website/CHANGELOG.md @@ -5,7 +5,8 @@ An attempt at documenting the changes/new features introduced in each release. ## Edge _These features are not yet properly released, you need to use [the edge builds](https://community.silverbullet.md/t/living-on-the-edge-builds/27) to try them._ -* Nothing yet since 0.8.4. Stay tuned! +* New power-user feature: [[Object Decorators]]. Its primary use case is to apply [[Page Decorations]], but you may find other uses as well. Speaking of which... +* **Breaking change:**The way [[Page Decorations]] are specified has changed. It has now been replaced with [[Object Decorators]](as just mentioned), which are a more generic mechanism. Your existing `pageDecorations` will stop working. You’ll have to rewrite based on the new format. See [[Page Decorations]] for examples. ## 0.8.4 * [[Page Picker#Keyboard shortcuts]]: allow folder completion using Shift-Space (by [Marek S. Łukasiewicz](https://github.com/silverbulletmd/silverbullet/pull/961)) diff --git a/website/Library/Core/Widget/Table of Contents.md b/website/Library/Core/Widget/Table of Contents.md index 06052d2f..22ff719f 100644 --- a/website/Library/Core/Widget/Table of Contents.md +++ b/website/Library/Core/Widget/Table of Contents.md @@ -2,7 +2,7 @@ description: Adds a Table of Contents to pages tags: template hooks.top: - where: 'true' + where: 'not pageDecoration.disableTOC' # Show all the way at the top order: 0 --- diff --git a/website/Object Decorators.md b/website/Object Decorators.md new file mode 100644 index 00000000..5ff2c679 --- /dev/null +++ b/website/Object Decorators.md @@ -0,0 +1,69 @@ +Object decorators are an **advanced technique** that can be used to add attributes to [[Objects]] dynamically whose values are _calculated dynamically_ (on-the-fly) based on an [[Expression Language|expression]]. + +> **warning** Warning +> This feature is still experimental and may change in the (near) future. + +The primary use case is [[Page Decorations]], but it is a powerful mechanism that probably has wider applications. As always, with great power comes great responsibility. + +# Syntax +Object decorations are specified in [[^SETTINGS]] using the following syntax: + +```yaml +objectDecorators: +- where: '<>' + attributes: + <>: '<>' +``` + +**Note:** To make changes take effect you may have to reload your client (just refresh the page). + +A few things of note: + +* `<>` is a [[YAML]] string-encoded expression using SilverBullet’s [[Expression Language]]. Some examples: + * `where: 'tags = "book"'` to make this apply to all objects that has `book` as one of its tags. +* `<>` can either be a simple attribute name, or a nested one using the `attribute.subAttribute` syntax. Some examples: + * `fullName` + * `pageDecoration.prefix` +* `<>` like `<>` must be a YAML string-encoded expression using the [[Expression Language]], some examples together with the attribute path: + * `alwaysTen: '10'` (attaches an attribute named `alwaysTen` with the numeric value `10` to all objects matching the `where` clause) + * `fullName: 'firstName + " " + lastName'` (attaches a `fullName` attribute that concatenates the `firstName` and `lastName` attributes with a space in between) + * `nameLength: 'count(name)'` (attaches an attribute `nameLength` with the string length of `name` — not particularly useful, but to demonstrate you can call [[Functions]] here too) +* For performance reasons, all expressions (both filter and value expressions) need to be _synchronously evaluatable_. + * Generally, this means they need to be “simple expressions” that require no expensive calls. + * Simple expressions include simple things like literals, arithmetic, calling some of the cheap [[Functions]] such as `today()` or string manipulation functions. + * Expensive calls include any additional database queries, or any function call (custom or otherwise) that are _asynchronous_. These are _not supported_. + * This requirement **will be checked at runtime**. Watch your server logs and browser’s JavaScript JavaScript console to see these errors. + +# Example +Let’s say that you use the `human` tag to track various humans in your space, as follows: + +```#human +firstName: Steve +lastName: Bee +--- +firstName: Stephanie +lastName: Bee +``` + +This would get you the follow data set: + +```query +human select firstName, lastName +``` + +However, you would like to dynamically compute an additional attribute for all humans, namely `fullName`. This can be done as follows in your [[^SETTINGS]]: + +```yaml +objectDecorators: +- where: 'tag = "human"' + attributes: + fullName: 'firstName + " " + lastName' +``` + +Which will give you the following: + +```query +human select fullName, firstName, lastName +``` + +Tadaa! diff --git a/website/Objects.md b/website/Objects.md index 3e25a34f..cd28b32b 100644 --- a/website/Objects.md +++ b/website/Objects.md @@ -13,6 +13,8 @@ In addition, many objects will also contain: * `tags`: an optional set of additional, explicitly assigned tags. * `itags`: a set of _implicit_ or _inherited_ tags: including the object’s `tag`, `tags` as well as any tags _assigned to its containing page_. This is useful to answer queries like, “give me all tasks on pages where that page is tagged with `person`“, which would be expressed as `task where itags = "person"` (although technically that would also match any tags that have the `#person` explicitly assigned). +In addition, an object’s attribute set can be dynamically extended using [[Object Decorators]]. + Beside these, any number of additional tag-specific and custom [[Attributes]] can be defined (see below). # Tags diff --git a/website/Page Decorations.md b/website/Page Decorations.md index f0c1336f..57d82c73 100644 --- a/website/Page Decorations.md +++ b/website/Page Decorations.md @@ -1,36 +1,49 @@ --- pageDecoration.prefix: "🎄 " +pageDecoration.disableTOC: true --- Page decorations allow you to “decorate” pages in various ways. -For now “various ways” means just one way (adding a visual prefix), but in the future, more such decorations will likely be added. - -There are two ways to decorate a page. - -# Frontmatter -The first is demonstrated in the [[Frontmatter]] of this page, by using the special `pageDecoration` attribute. - -# Settings -The more useful way is to apply decorations to pages _dynamically_, you can use the `pageDecorations` attribute in [[SETTINGS]]. - -Every page decoration has two parts: -* `where`: the [[Expression Language]] expression that has to evaluate to `true` for a given page for that decoration to be applied. -* A set of decorations to apply, see [[#Supported decorations]] - -For example: -```yaml -pageDecorations: -- where: "tags = 'person'" - prefix: "🧑 " -``` - -This will prefix all pages tagged with `#person` with a 🧑 emoji. - -Here on silverbullet.md, we have a decoration like this for pages tagged with #plug: [[Plugs/Emoji]] and [[Plugs/Git]] for instance. +> **warning** Warning +> This feature is still experimental and may change in the (near) future. # Supported decorations - * `prefix`: A (visual) string prefix (often an emoji) to add to all page names. This prefix will appear in the top bar as well as in (live preview) links to this page. For example, the name of this page is actually “Page Decorations”, but when you link to it, you’ll see it’s prefixed with a 🎄: [[Page Decorations]] * `hide` When this is set to `true`, the page will not be shown in [[Page Picker]], [[Meta Picker]], or suggested for completion of [[Links]]. It will otherwise behave as normal - will be [[Plugs/Index|indexed]] and found in [[Live Queries]]. The page can be opened through [[All Pages Picker]], or linked normally when the full name is typed out without completion. +* `disableTOC` (not technically built-in, but a feature of the [[^Library/Core/Widget/Table of Contents]] widget): disable the [[Table of Contents]] for this particular page. + +There are two ways to apply decorations to pages: + +# With [[Frontmatter]] directly +This is demonstrated in the [[Frontmatter]] at the top of this page, by using the special `pageDecoration` attribute. This is how we get the fancy tree in front of the page name. Sweet. + +# With [[Object Decorators]] +The more useful way is to apply decorations to pages _dynamically_, for this we will leverage the more powerful [[Object Decorators]] feature. Read the [[Object Decorators]] page for a more in-depth explanation of how this feature works if you’re interested (as you should be, because it’s pretty cool on its own). + +For the purposes of [[Page Decorations]], let us limit simply to some useful examples. + +## Example: page prefix +Let’s say we want to put a 🧑 prefix on every page tagged with `#person`. We can achieve this as follows in our [[^SETTINGS]]: +```yaml +objectDecorations: +- where: "tags = 'person'" + pageDecoration.prefix: '"🧑 "' +``` + +Note the (perhaps) strange double quoting there, both the `where` and the value for the attributes are [[Expression Language|expressions]] encoded inside of YAML. It’s a bit weird, but it works. + +## Example: disabling [[Table of Contents]] +Let’s say that adding this `pageDecoration.disableTOC` to the front matter is too much effort to disable the TOC on some pages. Therefore, you would like to simplify this by simply adding a `#notoc` tag to your pages. + +You can do this as follows: + +```yaml +objectDecorations: +- where: 'tags = "notoc"' + attributes: + pageDecoration.disableTOC: "true" +``` + +## Example: Plug prefix +Here on silverbullet.md, we have a decoration like this for pages tagged with `#plug`: [[Plugs/Emoji]] and [[Plugs/Git]] for instance. -Later, more such decorations may be added. \ No newline at end of file diff --git a/website/SETTINGS.md b/website/SETTINGS.md index 40c38f23..b932c0db 100644 --- a/website/SETTINGS.md +++ b/website/SETTINGS.md @@ -58,10 +58,17 @@ shortcuts: - command: "{[Upload: File]}" priority: 1 -# Page decorations -pageDecorations: +# Object decorators, see the "Page Decorators" page for more info +objectDecorators: - where: 'tags = "plug"' - prefix: "🔌 " + attributes: + pageDecoration.prefix: "'🔌 '" +- where: 'tag = "human"' + attributes: + fullName: 'firstName + " " + lastName' +- where: 'tags = "notoc"' + attributes: + pageDecoration.disableTOC: "true" # Toggles between “smart” ‘quotes’ (left and right) and "simple" 'quotes' (good ol' ASCII) useSmartQuotes: true