New space script APIs (#761)

New space script APIs: registerEventListener and registerAttributeExtractor
pull/774/head
Zef Hemel 2024-02-27 20:05:12 +01:00 committed by GitHub
parent a98299e8b3
commit de2d1089d4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 303 additions and 79 deletions

View File

@ -13,7 +13,7 @@ import { LocalShell } from "../server/shell_backend.ts";
import { Hono } from "../server/deps.ts";
import { DataStore } from "$lib/data/datastore.ts";
import { DataStoreMQ } from "$lib/data/mq.datastore.ts";
import { EventHook } from "$lib/plugos/hooks/event.ts";
import { EventHook } from "../common/hooks/event.ts";
import { sleep } from "$lib/async.ts";
import { AssetBundle } from "$lib/asset_bundle/bundle.ts";

View File

@ -3,13 +3,14 @@ import { PlugNamespaceHook } from "$common/hooks/plug_namespace.ts";
import { SilverBulletHooks } from "./manifest.ts";
import { buildQueryFunctions } from "./query_functions.ts";
import { ScriptEnvironment } from "./space_script.ts";
import { EventHook } from "../lib/plugos/hooks/event.ts";
import { EventHook } from "./hooks/event.ts";
import { DataStore } from "$lib/data/datastore.ts";
import { System } from "$lib/plugos/system.ts";
import { CodeWidgetHook } from "../web/hooks/code_widget.ts";
import { PanelWidgetHook } from "../web/hooks/panel_widget.ts";
import { SlashCommandHook } from "../web/hooks/slash_command.ts";
import { DataStoreMQ } from "$lib/data/mq.datastore.ts";
import { ParseTree } from "$lib/tree.ts";
export abstract class CommonSystem {
system!: System<SilverBulletHooks>;
@ -23,6 +24,7 @@ export abstract class CommonSystem {
readonly allKnownPages = new Set<string>();
readonly spaceScriptCommands = new Map<string, AppCommand>();
scriptEnv: ScriptEnvironment = new ScriptEnvironment();
constructor(
protected mq: DataStoreMQ,
@ -42,31 +44,64 @@ export abstract class CommonSystem {
this.allKnownPages,
this.system,
);
const scriptEnv = new ScriptEnvironment();
if (this.enableSpaceScript) {
this.scriptEnv = new ScriptEnvironment();
try {
await scriptEnv.loadFromSystem(this.system);
await this.scriptEnv.loadFromSystem(this.system);
console.log(
"Loaded",
Object.keys(scriptEnv.functions).length,
Object.keys(this.scriptEnv.functions).length,
"functions and",
Object.keys(scriptEnv.commands).length,
Object.keys(this.scriptEnv.commands).length,
"commands from space-script",
);
} catch (e: any) {
console.error("Error loading space-script:", e.message);
}
functions = { ...functions, ...scriptEnv.functions };
functions = { ...functions, ...this.scriptEnv.functions };
// Reset the space script commands
this.spaceScriptCommands.clear();
for (const [name, command] of Object.entries(scriptEnv.commands)) {
for (const [name, command] of Object.entries(this.scriptEnv.commands)) {
this.spaceScriptCommands.set(name, command);
}
// Inject the registered events in the event hook
this.eventHook.scriptEnvironment = this.scriptEnv;
this.commandHook.throttledBuildAllCommands();
}
// Swap in the expanded function map
this.ds.functionMap = functions;
}
invokeSpaceFunction(name: string, args: any[]) {
return this.scriptEnv.functions[name](...args);
}
async applyAttributeExtractors(
tags: string[],
text: string,
tree: ParseTree,
): Promise<Record<string, any>> {
let resultingAttributes: Record<string, any> = {};
for (const tag of tags) {
const extractors = this.scriptEnv.attributeExtractors[tag];
if (!extractors) {
continue;
}
for (const fn of extractors) {
const extractorResult = await fn(text, tree);
if (extractorResult) {
// Merge the attributes in
resultingAttributes = {
...resultingAttributes,
...extractorResult,
};
}
}
}
return resultingAttributes;
}
}

View File

@ -1,5 +1,6 @@
import type { Hook, Manifest } from "../types.ts";
import { System } from "../system.ts";
import type { Hook, Manifest } from "../../lib/plugos/types.ts";
import { System } from "../../lib/plugos/system.ts";
import { ScriptEnvironment } from "$common/space_script.ts";
// System events:
// - plug:load (plugName: string)
@ -10,7 +11,8 @@ export type EventHookT = {
export class EventHook implements Hook<EventHookT> {
private system?: System<EventHookT>;
public localListeners: Map<string, ((...args: any[]) => any)[]> = new Map();
private localListeners: Map<string, ((...args: any[]) => any)[]> = new Map();
public scriptEnvironment?: ScriptEnvironment;
addLocalListener(eventName: string, callback: (...args: any[]) => any) {
if (!this.localListeners.has(eventName)) {
@ -80,6 +82,8 @@ export class EventHook implements Hook<EventHookT> {
}
}
}
// Local listeners
const localListeners = this.localListeners.get(eventName);
if (localListeners) {
for (const localListener of localListeners) {
@ -93,6 +97,32 @@ export class EventHook implements Hook<EventHookT> {
}
}
// Space script listeners
if (this.scriptEnvironment) {
for (
const [name, listeners] of Object.entries(
this.scriptEnvironment.eventHandlers,
)
) {
if (eventNameToRegex(name).test(eventName)) {
for (const listener of listeners) {
promises.push((async () => {
const result = await Promise.resolve(
listener({
name: eventName,
// Most events have a single argument, so let's optimize for that, otherwise pass all arguments as an array
data: args.length === 1 ? args[0] : args,
}),
);
if (result) {
responses.push(result);
}
})());
}
}
}
}
// Wait for all promises to resolve
await Promise.all(promises);

View File

@ -1,6 +1,6 @@
import * as plugos from "../lib/plugos/types.ts";
import { CronHookT } from "../lib/plugos/hooks/cron.ts";
import { EventHookT } from "../lib/plugos/hooks/event.ts";
import { EventHookT } from "./hooks/event.ts";
import { CommandHookT } from "./hooks/command.ts";
import { SlashCommandHookT } from "../web/hooks/slash_command.ts";
import { PlugNamespaceHookT } from "./hooks/plug_namespace.ts";

View File

@ -2,7 +2,7 @@ import { SpacePrimitives } from "$common/spaces/space_primitives.ts";
import { plugPrefix } from "$common/spaces/constants.ts";
import { AttachmentMeta, FileMeta, PageMeta } from "../type/types.ts";
import { EventHook } from "../lib/plugos/hooks/event.ts";
import { EventHook } from "./hooks/event.ts";
import { safeRun } from "../lib/async.ts";
const pageWatchInterval = 5000;

View File

@ -1,4 +1,5 @@
import { System } from "../lib/plugos/system.ts";
import { ParseTree } from "$lib/tree.ts";
import { ScriptObject } from "../plugs/index/script.ts";
import { AppCommand, CommandDef } from "./hooks/command.ts";
@ -6,9 +7,24 @@ type FunctionDef = {
name: string;
};
type AttributeExtractorDef = {
tags: string[];
};
type EventListenerDef = {
name: string;
};
type AttributeExtractorCallback = (
text: string,
tree: ParseTree,
) => Record<string, any> | null | Promise<Record<string, any> | null>;
export class ScriptEnvironment {
functions: Record<string, (...args: any[]) => any> = {};
commands: Record<string, AppCommand> = {};
attributeExtractors: Record<string, AttributeExtractorCallback[]> = {};
eventHandlers: Record<string, ((...args: any[]) => any)[]> = {};
// Public API
@ -43,23 +59,40 @@ export class ScriptEnvironment {
};
}
registerAttributeExtractor(
def: AttributeExtractorDef,
callback: AttributeExtractorCallback,
) {
for (const tag of def.tags) {
if (!this.attributeExtractors[tag]) {
this.attributeExtractors[tag] = [];
}
this.attributeExtractors[tag].push(callback);
}
}
registerEventListener(
def: EventListenerDef,
callback: (...args: any[]) => any,
) {
if (!this.eventHandlers[def.name]) {
this.eventHandlers[def.name] = [];
}
this.eventHandlers[def.name].push(callback);
}
// Internal API
evalScript(script: string, system: System<any>) {
try {
const fn = Function(
"silverbullet",
"syscall",
"Deno",
"window",
"globalThis",
"self",
script,
);
fn.call(
{},
this,
(name: string, ...args: any[]) => system.syscall({}, name, args),
// The rest is explicitly left to be undefined to prevent access to the global scope
);
} catch (e: any) {
throw new Error(

View File

@ -1,5 +1,5 @@
import { FileMeta } from "../../type/types.ts";
import { EventHook } from "../../lib/plugos/hooks/event.ts";
import { EventHook } from "../hooks/event.ts";
import type { SpacePrimitives } from "./space_primitives.ts";

View File

@ -5,6 +5,7 @@ import { CommandDef } from "../hooks/command.ts";
import { proxySyscall } from "../../web/syscalls/util.ts";
import type { CommonSystem } from "../common_system.ts";
import { version } from "../../version.ts";
import { ParseTree } from "$lib/tree.ts";
export function systemSyscalls(
system: System<any>,
@ -93,6 +94,17 @@ export function systemSyscalls(
}
}
},
"system.invokeSpaceFunction": (_ctx, name: string, ...args: any[]) => {
return commonSystem.invokeSpaceFunction(name, args);
},
"system.applyAttributeExtractors": (
_ctx,
tags: string[],
text: string,
tree: ParseTree,
): Promise<Record<string, any>> => {
return commonSystem.applyAttributeExtractors(tags, text, tree);
},
"system.getEnv": () => {
return system.env;
},

View File

@ -1,5 +1,5 @@
import { SysCallMapping } from "../system.ts";
import { EventHook } from "../hooks/event.ts";
import { EventHook } from "../../../common/hooks/event.ts";
export function eventSyscalls(eventHook: EventHook): SysCallMapping {
return {

View File

@ -27,7 +27,7 @@ Top level attributes:
Deno.test("Test attribute extraction", async () => {
const tree = parse(extendedMarkdownLanguage, inlineAttributeSample);
const toplevelAttributes = await extractAttributes(tree, false);
const toplevelAttributes = await extractAttributes(["test"], tree, false);
// console.log("All attributes", toplevelAttributes);
assertEquals(toplevelAttributes.name, "sup");
assertEquals(toplevelAttributes.age, 42);
@ -35,6 +35,6 @@ Deno.test("Test attribute extraction", async () => {
// Check if the attributes are still there
assertEquals(renderToText(tree), inlineAttributeSample);
// Now once again with cleaning
await extractAttributes(tree, true);
await extractAttributes(["test"], tree, true);
assertEquals(renderToText(tree), cleanedInlineAttributeSample);
});

View File

@ -1,10 +1,11 @@
import {
findNodeOfType,
ParseTree,
renderToText,
replaceNodesMatchingAsync,
} from "$lib/tree.ts";
import { YAML } from "$sb/syscalls.ts";
import { system, YAML } from "$sb/syscalls.ts";
/**
* Extracts attributes from a tree, optionally cleaning them out of the tree.
@ -13,10 +14,11 @@ import { YAML } from "$sb/syscalls.ts";
* @returns mapping from attribute name to attribute value
*/
export async function extractAttributes(
tags: string[],
tree: ParseTree,
clean: boolean,
): Promise<Record<string, any>> {
const attributes: Record<string, any> = {};
let attributes: Record<string, any> = {};
await replaceNodesMatchingAsync(tree, async (n) => {
if (n.type === "ListItem") {
// Find top-level only, no nested lists
@ -44,5 +46,15 @@ export async function extractAttributes(
// Go on...
return undefined;
});
const text = renderToText(tree);
const spaceScriptAttributes = await system.applyAttributeExtractors(
tags,
text,
tree,
);
attributes = {
...attributes,
...spaceScriptAttributes,
};
return attributes;
}

View File

@ -51,7 +51,7 @@ async function nodesToFeedItem(nodes: ParseTree[]): Promise<FeedItem> {
const wrapperNode: ParseTree = {
children: nodes,
};
const attributes = await extractAttributes(wrapperNode, true);
const attributes = await extractAttributes(["feed"], wrapperNode, true);
let id = attributes.id;
delete attributes.id;
if (!id) {

View File

@ -4,6 +4,8 @@ globalThis.syscall = (name: string, ...args: readonly any[]) => {
switch (name) {
case "yaml.parse":
return Promise.resolve(YAML.load(args[0]));
case "system.applyAttributeExtractors":
return Promise.resolve({});
default:
throw Error(`Not implemented in tests: ${name}`);
}

View File

@ -1,5 +1,6 @@
import type { CommandDef } from "$common/hooks/command.ts";
import { SyscallMeta } from "$type/types.ts";
import type { SyscallMeta } from "$type/types.ts";
import type { ParseTree } from "$lib/tree.ts";
import { syscall } from "../syscall.ts";
export function invokeFunction(
@ -23,8 +24,23 @@ export function listSyscalls(): Promise<SyscallMeta[]> {
return syscall("system.listSyscalls");
}
export function invokeSpaceFunction(
name: string,
...args: any[]
): Promise<any> {
return syscall("system.invokeSpaceFunction", name, ...args);
}
export function applyAttributeExtractors(
tags: string[],
text: string,
tree: ParseTree,
): Promise<Record<string, any>[]> {
return syscall("system.applyAttributeExtractors", tags, text, tree);
}
export function reloadPlugs() {
syscall("system.reloadPlugs");
return syscall("system.reloadPlugs");
}
// Returns what runtime environment this plug is run in, e.g. "server" or "client" can be undefined, which would mean a hybrid environment (such as mobile)

View File

@ -23,7 +23,7 @@ export async function indexItems({ name, tree }: IndexTreeEvent) {
const coll = collectNodesOfType(tree, "ListItem");
for (const n of coll) {
for (let n of coll) {
if (!n.children) {
continue;
}
@ -46,19 +46,21 @@ export async function indexItems({ name, tree }: IndexTreeEvent) {
collectNodesOfType(n, "Hashtag").forEach((h) => {
// Push tag to the list, removing the initial #
tags.add(h.children![0].text!.substring(1));
h.children = [];
});
// Extract attributes and remove from tree
const extractedAttributes = await extractAttributes(
["item", ...tags],
n,
true,
);
for (const child of n.children!.slice(1)) {
rewritePageRefs(child, name);
if (child.type === "OrderedList" || child.type === "BulletList") {
break;
}
// Extract attributes and remove from tree
const extractedAttributes = await extractAttributes(child, true);
for (const [key, value] of Object.entries(extractedAttributes)) {
item[key] = value;
}
textNodes.push(child);
}
@ -67,6 +69,12 @@ export async function indexItems({ name, tree }: IndexTreeEvent) {
item.tags = [...tags];
}
for (
const [key, value] of Object.entries(extractedAttributes)
) {
item[key] = value;
}
updateITags(item, frontmatter);
items.push(item);

View File

@ -15,7 +15,11 @@ export async function indexPage({ name, tree }: IndexTreeEvent) {
}
const pageMeta = await space.getPageMeta(name);
const frontmatter = await extractFrontmatter(tree);
const toplevelAttributes = await extractAttributes(tree, false);
const toplevelAttributes = await extractAttributes(
["page", ...frontmatter.tags || []],
tree,
false,
);
// Push them all into the page object
// Note the order here, making sure that the actual page meta data overrules

View File

@ -35,20 +35,19 @@ export async function indexParagraphs({ name: page, tree }: IndexTreeEvent) {
return false;
}
const attrs = await extractAttributes(p, true);
const tags = new Set<string>();
const text = renderToText(p);
// So we're looking at indexable a paragraph now
const tags = new Set<string>();
collectNodesOfType(p, "Hashtag").forEach((tagNode) => {
tags.add(tagNode.children![0].text!.substring(1));
// Hacky way to remove the hashtag
tagNode.children = [];
});
const textWithoutTags = renderToText(p);
// Extract attributes and remove from tree
const attrs = await extractAttributes(["paragraph", ...tags], p, true);
const text = renderToText(p);
if (!textWithoutTags.trim()) {
if (!text.trim()) {
// Empty paragraph, just tags and attributes maybe
return true;
}

View File

@ -91,17 +91,23 @@ export async function indexTasks({ name, tree }: IndexTreeEvent) {
task.tags = [];
}
task.tags.push(tagName);
tree.children = [];
}
});
// Extract attributes and remove from tree
const extractedAttributes = await extractAttributes(n, true);
task.name = n.children!.slice(1).map(renderToText).join("").trim();
const extractedAttributes = await extractAttributes(
["task", ...task.tags || []],
n,
true,
);
task.name = n.children!.slice(1).map(renderToText).join("").trim();
for (const [key, value] of Object.entries(extractedAttributes)) {
task[key] = value;
}
task.name = n.children!.slice(1).map(renderToText).join("").trim();
updateITags(task, frontmatter);
tasks.push(task);

View File

@ -5,7 +5,7 @@ import { FilteredSpacePrimitives } from "$common/spaces/filtered_space_primitive
import { ReadOnlySpacePrimitives } from "$common/spaces/ro_space_primitives.ts";
import { SpacePrimitives } from "$common/spaces/space_primitives.ts";
import { AssetBundle } from "../lib/asset_bundle/bundle.ts";
import { EventHook } from "../lib/plugos/hooks/event.ts";
import { EventHook } from "../common/hooks/event.ts";
import { DataStore } from "$lib/data/datastore.ts";
import { KvPrimitives } from "$lib/data/kv_primitives.ts";
import { DataStoreMQ } from "$lib/data/mq.datastore.ts";

View File

@ -4,7 +4,7 @@ import { EventedSpacePrimitives } from "$common/spaces/evented_space_primitives.
import { PlugSpacePrimitives } from "$common/spaces/plug_space_primitives.ts";
import { createSandbox } from "../lib/plugos/sandboxes/web_worker_sandbox.ts";
import { CronHook } from "../lib/plugos/hooks/cron.ts";
import { EventHook } from "../lib/plugos/hooks/event.ts";
import { EventHook } from "../common/hooks/event.ts";
import { MQHook } from "../lib/plugos/hooks/mq.ts";
import assetSyscalls from "../lib/plugos/syscalls/asset.ts";
import { eventSyscalls } from "../lib/plugos/syscalls/event.ts";
@ -75,8 +75,7 @@ export class ServerSystem extends CommonSystem {
this.ds = new DataStore(this.kvPrimitives);
// Event hook
const eventHook = new EventHook();
this.system.addHook(eventHook);
this.system.addHook(this.eventHook);
// Command hook, just for introspection
this.commandHook = new CommandHook(
@ -103,14 +102,14 @@ export class ServerSystem extends CommonSystem {
this.spacePrimitives,
plugNamespaceHook,
),
eventHook,
this.eventHook,
);
const space = new Space(this.spacePrimitives, eventHook);
const space = new Space(this.spacePrimitives, this.eventHook);
// Add syscalls
this.system.registerSyscalls(
[],
eventSyscalls(eventHook),
eventSyscalls(this.eventHook),
spaceReadSyscalls(space),
assetSyscalls(this.system),
yamlSyscalls(),
@ -151,26 +150,29 @@ export class ServerSystem extends CommonSystem {
space.updatePageList().catch(console.error);
}, fileListInterval);
eventHook.addLocalListener("file:changed", async (path, localChange) => {
if (!localChange && path.endsWith(".md")) {
const pageName = path.slice(0, -3);
const data = await this.spacePrimitives.readFile(path);
console.log("Outside page change: reindexing", pageName);
// Change made outside of editor, trigger reindex
await eventHook.dispatchEvent("page:index_text", {
name: pageName,
text: new TextDecoder().decode(data.data),
});
}
this.eventHook.addLocalListener(
"file:changed",
async (path, localChange) => {
if (!localChange && path.endsWith(".md")) {
const pageName = path.slice(0, -3);
const data = await this.spacePrimitives.readFile(path);
console.log("Outside page change: reindexing", pageName);
// Change made outside of editor, trigger reindex
await this.eventHook.dispatchEvent("page:index_text", {
name: pageName,
text: new TextDecoder().decode(data.data),
});
}
if (path.startsWith(plugPrefix) && path.endsWith(".plug.js")) {
console.log("Plug updated, reloading:", path);
this.system.unload(path);
await this.loadPlugFromSpace(path);
}
});
if (path.startsWith(plugPrefix) && path.endsWith(".plug.js")) {
console.log("Plug updated, reloading:", path);
this.system.unload(path);
await this.loadPlugFromSpace(path);
}
},
);
eventHook.addLocalListener(
this.eventHook.addLocalListener(
"file:listed",
(allFiles: FileMeta[]) => {
// Update list of known pages
@ -189,7 +191,7 @@ export class ServerSystem extends CommonSystem {
await indexPromise;
}
await eventHook.dispatchEvent("system:ready");
await this.eventHook.dispatchEvent("system:ready");
}
async loadPlugs() {

View File

@ -10,7 +10,7 @@ import {
} from "./deps.ts";
import { Space } from "../common/space.ts";
import { FilterOption } from "../type/web.ts";
import { EventHook } from "../lib/plugos/hooks/event.ts";
import { EventHook } from "../common/hooks/event.ts";
import { AppCommand } from "$common/hooks/command.ts";
import {
PageState,

View File

@ -1,7 +1,7 @@
import { PlugNamespaceHook } from "$common/hooks/plug_namespace.ts";
import { SilverBulletHooks } from "$common/manifest.ts";
import { CronHook } from "../lib/plugos/hooks/cron.ts";
import { EventHook } from "../lib/plugos/hooks/event.ts";
import { EventHook } from "../common/hooks/event.ts";
import { createSandbox } from "../lib/plugos/sandboxes/web_worker_sandbox.ts";
import assetSyscalls from "../lib/plugos/syscalls/asset.ts";

View File

@ -2,7 +2,7 @@ import { plugPrefix } from "$common/spaces/constants.ts";
import type { SpacePrimitives } from "$common/spaces/space_primitives.ts";
import { SpaceSync, SyncStatus, SyncStatusItem } from "$common/spaces/sync.ts";
import { sleep } from "$lib/async.ts";
import { EventHook } from "$lib/plugos/hooks/event.ts";
import { EventHook } from "../common/hooks/event.ts";
import { DataStore } from "$lib/data/datastore.ts";
import { Space } from "../common/space.ts";

View File

@ -31,12 +31,12 @@ If you use things like `console.log` in your script, you will see this output ei
# Runtime Environment & API
Space script is loaded directly in the browser environment on the client, and the Deno environment on the server.
While not very secure, some effort is put into running this code in a clean JavaScript environment, as such the following global variables are not available: `this`, `self`, `Deno`, `window`, and `globalThis`.
Depending on where code is run (client or server), a slightly different JavaScript API will be available. However, code should ideally primarily rely on the following explicitly exposed APIs:
* `silverbullet.registerFunction(definition, callback)`: registers a custom function (see [[#Custom functions]]).
* `silverbullet.registerCommand(definition, callback)`: registers a custom command (see [[#Custom commands]]).
* `silverbullet.registerFunction(def, callback)`: registers a custom function (see [[#Custom functions]]).
* `silverbullet.registerCommand(def, callback)`: registers a custom command (see [[#Custom commands]]).
* `silverbullet.registerEventListener`: registers an event listener (see [[#Custom event listeners]]).
* `silverbullet.registerAttributeExtractor(def, callback)`: registers a custom attribute extractor.
* `syscall(name, args...)`: invoke a syscall (see [[#Syscalls]]).
Many useful standard JavaScript APIs are available, such as:
@ -51,7 +51,7 @@ Since template rendering happens on the server (except in [[Client Modes#Synced
The `silverbullet.registerFunction` API takes two arguments:
* `options`: with currently just one option:
* `def`: with currently just one option:
* `name`: the name of the function to register
* `callback`: the callback function to invoke (can be `async` or not)
@ -88,7 +88,7 @@ You can run it via the command palette, or by pushing this [[Markdown/Command li
The `silverbullet.registerCommand` API takes two arguments:
* `options`:
* `def`:
* `name`: Name of the command
* `key` (optional): Keyboard shortcut for the command (Windows/Linux)
* `mac` (optional): Mac keyboard shortcut for the command
@ -96,6 +96,71 @@ The `silverbullet.registerCommand` API takes two arguments:
* `requireMode` (optional): Only make this command available in `ro` or `rw` mode.
* `callback`: the callback function to invoke (can be `async` or not)
# Custom event listeners
Various interesting events are triggered on SilverBullets central event bus. Space script can listen to these events and do something with them.
The `silverbullet.registerEventListener` API takes two arguments:
* `def`, currently just one option:
* `name`: Name of the event. This name can contain `*` as a wildcard.
* `callback`: the callback function to invoke (can be `async` or not). This callback is passed an object with two keys:
* `name`: the name of the event triggered (useful if you use a wildcard event listener)
* `data`: the event data
To discover what events exist, you can do something like the following to listen to all events and log them to the JavaScript console. Note that different events are triggered on the client and server, so watch both logs:
```space-script
silverbullet.registerEventListener({name: "*"}, (event) => {
// To avoid excessive logging this line comment it out, uncomment it in your code code to see the event stream
// console.log("Received event in space script:", event);
});
```
# Custom attribute extractors
SilverBullet indexes various types of content as [[Objects]]. There are various ways to define [[Attributes]] for these objects, such as the [attribute: my value] syntax. However, using space script you can write your own code to extract attribute values not natively supported.
The `silverbullet.registerAttributeExtractor` API takes two arguments:
* `def`, currently just one option:
* `tags`: Array of tags this extractor should be applied to, could be a built-in tag such as `item`, `page` or `task`, but also any custom tags you define
* `callback`: the callback function to invoke (can be `async` or not). This callback is passed two arguments:
* `text`: the text of the object to extract attributes for
* `tree`: the ParseTree of the object to extract attributes for (you can ignore this one if you dont need it)
This callback should return an object of attribute mappings.
## Example
Lets say you want to use the syntax `✅ 2024-02-27` in a task to signify when that task was completed:
* [x] Ive done this ✅ 2024-02-27
The following attribute extractor will accomplish this:
```space-script
silverbullet.registerAttributeExtractor({tags: ["task"]}, (text) => {
// Find the completion date using a regular expression
const completionRegex = /✅\s*(\w{4}-\w{2}-\w{2})/;
const match = completionRegex.exec(text);
if (match) {
// Let's customize the task name by stripping this completion date
// First strip the checkbox bit
let taskName = text.replace(/\[[^\]]+\]\s*/, "");
// Then remove the completion date and clean it up
taskName = taskName.replace(completionRegex, "").trim();
return {
name: taskName,
completed: match[1]
};
}
});
```
Note that built-in attributes can also be overridden (like `name` in this case).
Result:
```template
{{{task where page = @page.name select name, completed}}}
```
# Syscalls
The primary way to interact with the SilverBullet environment is using “syscalls”. Syscalls expose SilverBullet functionality largely available both on the client and server in a safe way.