Object decorators (#995)

Implemented objectDecorators to replace pageDecorations in SETTINGS
pull/1003/head
Zef Hemel 2024-07-29 21:21:16 +02:00 committed by GitHub
parent 69578ae09f
commit 75471fa86b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 533 additions and 183 deletions

View File

@ -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,

View File

@ -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]]);
});

View File

@ -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]);
}

View File

@ -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<string, QueryExpression> = {};
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;
}
}

View File

@ -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();
});

View File

@ -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<string, any> = {
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],
});
});

View File

@ -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<T = any>(keys: KvKey[]): Promise<(T | null)[]> {
async batchGet<T = any>(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<void> {
@ -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<string, QueryExpression>;
};
/**
* 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];
}
}
}
}

View File

@ -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";

View File

@ -1,7 +0,0 @@
import { PageMeta } from "$sb/types.ts";
export function applyDecorationsToPages(
pages: PageMeta[],
): Promise<PageMeta[]> {
return syscall("decoration.applyDecorationsToPages", pages);
}

View File

@ -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;
};

View File

@ -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) => {

View File

@ -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);

View File

@ -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);
}
}
}

33
type/settings.ts Normal file
View File

@ -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<string, string>; // 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<string, Partial<Manifest>>;
// NOTE: Bit niche, maybe delete at some point?
defaultLinkStyle?: string;
};

View File

@ -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<string, Partial<Manifest>>;
// 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;

View File

@ -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,

View File

@ -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),

View File

@ -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,

View File

@ -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;
}

View File

@ -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;
}
}
}

View File

@ -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. Youll 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))

View File

@ -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
---

View File

@ -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: '<<filter expression>>'
attributes:
<<attributePath>>: '<<value expression>>'
```
**Note:** To make changes take effect you may have to reload your client (just refresh the page).
A few things of note:
* `<<filter expression>>` is a [[YAML]] string-encoded expression using SilverBullets [[Expression Language]]. Some examples:
* `where: 'tags = "book"'` to make this apply to all objects that has `book` as one of its tags.
* `<<attributePath>>` can either be a simple attribute name, or a nested one using the `attribute.subAttribute` syntax. Some examples:
* `fullName`
* `pageDecoration.prefix`
* `<<value expression>>` like `<<expression>>` 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 browsers JavaScript JavaScript console to see these errors.
# Example
Lets 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!

View File

@ -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 objects `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 objects 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

View File

@ -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, youll see its 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 youre interested (as you should be, because its pretty cool on its own).
For the purposes of [[Page Decorations]], let us limit simply to some useful examples.
## Example: page prefix
Lets 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. Its a bit weird, but it works.
## Example: disabling [[Table of Contents]]
Lets 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.

View File

@ -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