Refactoring and adding ability to create custom commands from space functions

pull/690/head
Zef Hemel 2024-02-07 14:50:01 +01:00
parent b3dc303624
commit 05efbc8741
26 changed files with 316 additions and 213 deletions

View File

@ -8,6 +8,9 @@ import { EndpointHook } from "../plugos/hooks/endpoint.ts";
import { LocalShell } from "../server/shell_backend.ts";
import { Hono } from "../server/deps.ts";
import { KvPrimitives } from "../plugos/lib/kv_primitives.ts";
import { DataStore } from "../plugos/lib/datastore.ts";
import { DataStoreMQ } from "../plugos/lib/mq.datastore.ts";
import { EventHook } from "../plugos/hooks/event.ts";
export async function runPlug(
spacePath: string,
@ -23,6 +26,10 @@ export async function runPlug(
const endpointHook = new EndpointHook("/_/");
const ds = new DataStore(kvPrimitives);
const mq = new DataStoreMQ(ds);
const eventHook = new EventHook();
const serverSystem = new ServerSystem(
new AssetBundlePlugSpacePrimitives(
new DiskSpacePrimitives(spacePath),
@ -30,6 +37,9 @@ export async function runPlug(
),
kvPrimitives,
new LocalShell(spacePath),
mq,
ds,
eventHook,
false,
true,
);

72
common/common_system.ts Normal file
View File

@ -0,0 +1,72 @@
import { AppCommand, CommandHook } from "./hooks/command.ts";
import { PlugNamespaceHook } from "./hooks/plug_namespace.ts";
import { SilverBulletHooks } from "./manifest.ts";
import { buildQueryFunctions } from "./query_functions.ts";
import { ScriptEnvironment } from "./space_script.ts";
import { EventHook } from "../plugos/hooks/event.ts";
import { DataStore } from "../plugos/lib/datastore.ts";
import { System } from "../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 "../plugos/lib/mq.datastore.ts";
export abstract class CommonSystem {
system!: System<SilverBulletHooks>;
// Hooks
commandHook!: CommandHook;
slashCommandHook!: SlashCommandHook;
namespaceHook!: PlugNamespaceHook;
codeWidgetHook!: CodeWidgetHook;
panelWidgetHook!: PanelWidgetHook;
readonly allKnownPages = new Set<string>();
readonly spaceScriptCommands = new Map<string, AppCommand>();
constructor(
protected mq: DataStoreMQ,
protected ds: DataStore,
protected eventHook: EventHook,
public readOnlyMode: boolean,
protected enableSpaceScript: boolean,
) {
setInterval(() => {
// Timeout after 5s, retries 3 times, otherwise drops the message (no DLQ)
mq.requeueTimeouts(5000, 3, true).catch(console.error);
}, 20000); // Look to requeue every 20s
}
async loadSpaceScripts() {
let functions = buildQueryFunctions(
this.allKnownPages,
this.system,
);
const scriptEnv = new ScriptEnvironment();
if (this.enableSpaceScript) {
try {
await scriptEnv.loadFromSystem(this.system);
console.log(
"Loaded",
Object.keys(scriptEnv.functions).length,
"functions and",
Object.keys(scriptEnv.commands).length,
"commands from space-script",
);
} catch (e: any) {
console.error("Error loading space-script:", e.message);
}
functions = { ...functions, ...scriptEnv.functions };
// Reset the space script commands
this.spaceScriptCommands.clear();
for (const [name, command] of Object.entries(scriptEnv.commands)) {
this.spaceScriptCommands.set(name, command);
}
this.commandHook.throttledBuildAllCommands();
}
// Swap in the expanded function map
this.ds.functionMap = functions;
}
}

View File

@ -43,7 +43,10 @@ export class CommandHook extends EventEmitter<CommandHookEvents>
editorCommands = new Map<string, AppCommand>();
system!: System<CommandHookT>;
constructor(private readOnly: boolean) {
constructor(
private readOnly: boolean,
private additionalCommandsMap: Map<string, AppCommand>,
) {
super();
}
@ -76,6 +79,9 @@ export class CommandHook extends EventEmitter<CommandHookEvents>
}
}
await this.loadPageTemplateCommands();
for (const [name, cmd] of this.additionalCommandsMap) {
this.editorCommands.set(name, cmd);
}
this.emit("commandsUpdated", this.editorCommands);
}

View File

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

View File

@ -3,29 +3,15 @@ import { builtinFunctions } from "$sb/lib/builtin_query_functions.ts";
import { System } from "../plugos/system.ts";
import { Query } from "$sb/types.ts";
import { LimitedMap } from "$sb/lib/limited_map.ts";
import { ScriptEnvironment } from "./space_script.ts";
const pageCacheTtl = 10 * 1000; // 10s
export async function buildQueryFunctions(
export function buildQueryFunctions(
allKnownPages: Set<string>,
system: System<any>,
enableSpaceScript: boolean,
): Promise<FunctionMap> {
): FunctionMap {
const pageCache = new LimitedMap<string>(10);
const scriptEnv = new ScriptEnvironment();
if (enableSpaceScript) {
try {
await scriptEnv.loadFromSystem(system);
console.log(
"Loaded",
Object.keys(scriptEnv.functions).length,
"functions from space-script",
);
} catch (e: any) {
console.error("Error loading space-script:", e.message);
}
}
return {
...builtinFunctions,
pageExists(name: string) {
@ -54,7 +40,7 @@ export async function buildQueryFunctions(
if (cachedPage) {
return cachedPage;
} else {
return system.syscall({}, "space.readPage", [name]).then((page) => {
return system.localSyscall("space.readPage", [name]).then((page) => {
pageCache.set(name, page, pageCacheTtl);
return page;
}).catch((e: any) => {
@ -64,6 +50,5 @@ export async function buildQueryFunctions(
});
}
},
...scriptEnv.functions,
};
}

View File

@ -1,21 +1,47 @@
// deno-lint-ignore-file ban-types
import { System } from "../plugos/system.ts";
import { ScriptObject } from "../plugs/index/script.ts";
import { AppCommand, CommandDef } from "./hooks/command.ts";
export class ScriptEnvironment {
functions: Record<string, Function> = {};
functions: Record<string, (...args: any[]) => any> = {};
commands: Record<string, AppCommand> = {};
// Public API
registerFunction(name: string, fn: Function) {
registerFunction(name: string, fn: (...args: any[]) => any) {
this.functions[name] = fn;
}
registerCommand(command: CommandDef, fn: (...args: any[]) => any) {
this.commands[command.name] = {
command,
run: (...args: any[]) => {
return new Promise((resolve) => {
// Next tick
setTimeout(() => {
resolve(fn(...args));
});
});
},
};
}
// Internal API
evalScript(script: string, system: System<any>) {
try {
Function("silverbullet", "syscall", script)(
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,15 +1,15 @@
import { SyscallMeta } from "$sb/types.ts";
import { SysCallMapping, System } from "../../plugos/system.ts";
import type { ServerSystem } from "../../server/server_system.ts";
import type { Client } from "../../web/client.ts";
import { CommandDef } from "../../web/hooks/command.ts";
import { CommandDef } from "../hooks/command.ts";
import { proxySyscall } from "../../web/syscalls/util.ts";
import type { CommonSystem } from "../common_system.ts";
export function systemSyscalls(
system: System<any>,
readOnlyMode: boolean,
client: Client | undefined,
serverSystem: ServerSystem | undefined,
commonSystem: CommonSystem,
client?: Client,
): SysCallMapping {
const api: SysCallMapping = {
"system.invokeFunction": (
@ -50,8 +50,7 @@ export function systemSyscalls(
return client.runCommandByName(name, args);
},
"system.listCommands": (): { [key: string]: CommandDef } => {
const commandHook = client?.system.commandHook ||
serverSystem!.commandHook;
const commandHook = commonSystem!.commandHook;
const allCommands: { [key: string]: CommandDef } = {};
for (const [cmd, def] of commandHook.editorCommands) {
allCommands[cmd] = def.command;
@ -76,9 +75,9 @@ export function systemSyscalls(
return client.loadPlugs();
},
"system.loadSpaceScripts": async () => {
// Reload scripts locally
await commonSystem.loadSpaceScripts();
if (client) {
// If this is invoked on the client, we need to load the space scripts locally
await client.loadSpaceScripts();
// And we are in a hybrud mode, tell the server to do the same
if (system.env === "client") {
console.info(
@ -91,10 +90,6 @@ export function systemSyscalls(
[],
);
}
} else if (serverSystem) {
return serverSystem.loadSpaceScripts();
} else {
throw new Error("Load space scripts in an undefined environment");
}
},
"system.getEnv": () => {

View File

@ -1,4 +1,4 @@
import type { CommandDef } from "../../web/hooks/command.ts";
import type { CommandDef } from "../../common/hooks/command.ts";
import { SyscallMeta } from "$sb/types.ts";
import { syscall } from "./syscall.ts";

View File

@ -5,7 +5,10 @@ import { ReadOnlySpacePrimitives } from "../common/spaces/ro_space_primitives.ts
import { SpacePrimitives } from "../common/spaces/space_primitives.ts";
import { ensureAndLoadSettingsAndIndex } from "../common/util.ts";
import { AssetBundle } from "../plugos/asset_bundle/bundle.ts";
import { EventHook } from "../plugos/hooks/event.ts";
import { DataStore } from "../plugos/lib/datastore.ts";
import { KvPrimitives } from "../plugos/lib/kv_primitives.ts";
import { DataStoreMQ } from "../plugos/lib/mq.datastore.ts";
import { System } from "../plugos/system.ts";
import { BuiltinSettings } from "../web/types.ts";
import { JWTIssuer } from "./crypto.ts";
@ -30,6 +33,7 @@ export type SpaceServerConfig = {
clientEncryption: boolean;
};
// Equivalent of Client on the server
export class SpaceServer {
public pagesPath: string;
auth?: { user: string; pass: string };
@ -99,6 +103,11 @@ export class SpaceServer {
this.spacePrimitives = new ReadOnlySpacePrimitives(this.spacePrimitives);
}
const ds = new DataStore(this.kvPrimitives);
const mq = new DataStoreMQ(ds);
const eventHook = new EventHook();
// system = undefined in databaseless mode (no PlugOS instance on the server and no DB)
if (!this.syncOnly) {
// Enable server-side processing
@ -106,6 +115,9 @@ export class SpaceServer {
this.spacePrimitives,
this.kvPrimitives,
this.shellBackend,
mq,
ds,
eventHook,
this.readOnly,
this.enableSpaceScript,
);

View File

@ -25,7 +25,6 @@ import {
dataStoreReadSyscalls,
dataStoreWriteSyscalls,
} from "../plugos/syscalls/datastore.ts";
import { DataStoreMQ } from "../plugos/lib/mq.datastore.ts";
import { languageSyscalls } from "../common/syscalls/language.ts";
import { templateSyscalls } from "../common/syscalls/template.ts";
import { codeWidgetSyscalls } from "../web/syscalls/code_widget.ts";
@ -35,43 +34,29 @@ import { KvPrimitives } from "../plugos/lib/kv_primitives.ts";
import { ShellBackend } from "./shell_backend.ts";
import { ensureSpaceIndex } from "../common/space_index.ts";
import { FileMeta } from "$sb/types.ts";
import { buildQueryFunctions } from "../common/query_functions.ts";
import { CommandHook } from "../web/hooks/command.ts";
// // Important: load this before the actual plugs
// import {
// createSandbox as noSandboxFactory,
// runWithSystemLock,
// } from "../plugos/sandboxes/no_sandbox.ts";
// // Load list of builtin plugs
// import { plug as plugIndex } from "../dist_plug_bundle/_plug/index.plug.js";
// import { plug as plugFederation } from "../dist_plug_bundle/_plug/federation.plug.js";
// import { plug as plugQuery } from "../dist_plug_bundle/_plug/query.plug.js";
// import { plug as plugSearch } from "../dist_plug_bundle/_plug/search.plug.js";
// import { plug as plugTasks } from "../dist_plug_bundle/_plug/tasks.plug.js";
// import { plug as plugTemplate } from "../dist_plug_bundle/_plug/template.plug.js";
import { CommandHook } from "../common/hooks/command.ts";
import { CommonSystem } from "../common/common_system.ts";
import { MessageQueue } from "../plugos/lib/mq.ts";
import { DataStoreMQ } from "../plugos/lib/mq.datastore.ts";
const fileListInterval = 30 * 1000; // 30s
const plugNameExtractRegex = /([^/]+)\.plug\.js$/;
export class ServerSystem {
system!: System<SilverBulletHooks>;
public spacePrimitives!: SpacePrimitives;
// denoKv!: Deno.Kv;
export class ServerSystem extends CommonSystem {
listInterval?: number;
ds!: DataStore;
allKnownPages = new Set<string>();
commandHook!: CommandHook;
constructor(
private baseSpacePrimitives: SpacePrimitives,
readonly kvPrimitives: KvPrimitives,
public spacePrimitives: SpacePrimitives,
private kvPrimitives: KvPrimitives,
private shellBackend: ShellBackend,
private readOnlyMode: boolean,
private enableSpaceScript: boolean,
mq: DataStoreMQ,
ds: DataStore,
eventHook: EventHook,
readOnlyMode: boolean,
enableSpaceScript: boolean,
) {
super(mq, ds, eventHook, readOnlyMode, enableSpaceScript);
}
// Always needs to be invoked right after construction
@ -94,24 +79,20 @@ export class ServerSystem {
this.system.addHook(eventHook);
// Command hook, just for introspection
this.commandHook = new CommandHook(this.readOnlyMode);
this.commandHook = new CommandHook(
this.readOnlyMode,
this.spaceScriptCommands,
);
this.system.addHook(this.commandHook);
// Cron hook
const cronHook = new CronHook(this.system);
this.system.addHook(cronHook);
const mq = new DataStoreMQ(this.ds);
setInterval(() => {
// Timeout after 5s, retries 3 times, otherwise drops the message (no DLQ)
mq.requeueTimeouts(5000, 3, true).catch(console.error);
}, 20000); // Look to requeue every 20s
const plugNamespaceHook = new PlugNamespaceHook();
this.system.addHook(plugNamespaceHook);
this.system.addHook(new MQHook(this.system, mq));
this.system.addHook(new MQHook(this.system, this.mq));
const codeWidgetHook = new CodeWidgetHook();
@ -119,7 +100,7 @@ export class ServerSystem {
this.spacePrimitives = new EventedSpacePrimitives(
new PlugSpacePrimitives(
this.baseSpacePrimitives,
this.spacePrimitives,
plugNamespaceHook,
),
eventHook,
@ -133,8 +114,8 @@ export class ServerSystem {
spaceReadSyscalls(space),
assetSyscalls(this.system),
yamlSyscalls(),
systemSyscalls(this.system, this.readOnlyMode, undefined, this),
mqSyscalls(mq),
systemSyscalls(this.system, this.readOnlyMode, this),
mqSyscalls(this.mq),
languageSyscalls(),
templateSyscalls(this.ds),
dataStoreReadSyscalls(this.ds),
@ -167,9 +148,6 @@ export class ServerSystem {
await this.loadSpaceScripts();
this.listInterval = setInterval(() => {
// runWithSystemLock(this.system, async () => {
// await space.updatePageList();
// });
space.updatePageList().catch(console.error);
}, fileListInterval);
@ -211,9 +189,7 @@ export class ServerSystem {
await indexPromise;
}
// await runWithSystemLock(this.system, async () => {
await eventHook.dispatchEvent("system:ready");
// });
}
async loadPlugs() {
@ -224,15 +200,6 @@ export class ServerSystem {
}
}
async loadSpaceScripts() {
// Swap in the expanded function map
this.ds.functionMap = await buildQueryFunctions(
this.allKnownPages,
this.system,
this.enableSpaceScript,
);
}
async loadPlugFromSpace(path: string): Promise<Plug<SilverBulletHooks>> {
const { meta, data } = await this.spacePrimitives.readFile(path);
const plugName = path.match(plugNameExtractRegex)![1];

View File

@ -10,6 +10,12 @@ import { plugCompileCommand } from "./cmd/plug_compile.ts";
import { plugRunCommand } from "./cmd/plug_run.ts";
import { syncCommand } from "./cmd/sync.ts";
// Unhandled rejection, let's not crash
globalThis.addEventListener("unhandledrejection", (event) => {
console.error("Unhandled rejection:", event);
event.preventDefault();
});
await new Command()
.name("silverbullet")
.description("Markdown as a platform")

View File

@ -12,7 +12,7 @@ import { Space } from "./space.ts";
import { FilterOption } from "./types.ts";
import { ensureAndLoadSettingsAndIndex } from "../common/util.ts";
import { EventHook } from "../plugos/hooks/event.ts";
import { AppCommand } from "./hooks/command.ts";
import { AppCommand } from "../common/hooks/command.ts";
import {
PageState,
parsePageRefFromURI,
@ -57,7 +57,6 @@ import {
} from "../common/space_index.ts";
import { LimitedMap } from "$sb/lib/limited_map.ts";
import { renderTheTemplate } from "../common/syscalls/template.ts";
import { buildQueryFunctions } from "../common/query_functions.ts";
import { PageRef } from "$sb/lib/page.ts";
import { ReadOnlySpacePrimitives } from "../common/spaces/ro_space_primitives.ts";
import { KvPrimitives } from "../plugos/lib/kv_primitives.ts";
@ -83,7 +82,7 @@ declare global {
// history.scrollRestoration = "manual";
export class Client {
system!: ClientSystem;
clientSystem!: ClientSystem;
editorView!: EditorView;
keyHandlerCompartment?: Compartment;
@ -116,8 +115,6 @@ export class Client {
spaceKV?: KvPrimitives;
mq!: DataStoreMQ;
// Used by the "wiki link" highlighter to check if a page exists
public allKnownPages = new Set<string>();
onLoadPageRef: PageRef;
constructor(
@ -148,15 +145,11 @@ export class Client {
// Setup message queue
this.mq = new DataStoreMQ(this.stateDataStore);
setInterval(() => {
// Timeout after 5s, retries 3 times, otherwise drops the message (no DLQ)
this.mq.requeueTimeouts(5000, 3, true).catch(console.error);
}, 20000); // Look to requeue every 20s
// Event hook
this.eventHook = new EventHook();
// Instantiate a PlugOS system
this.system = new ClientSystem(
this.clientSystem = new ClientSystem(
this,
this.mq,
this.stateDataStore,
@ -193,7 +186,7 @@ export class Client {
this.focus();
this.system.init();
this.clientSystem.init();
await this.loadSettings();
@ -224,7 +217,7 @@ export class Client {
}
await this.loadPlugs();
await this.loadSpaceScripts();
await this.clientSystem.loadSpaceScripts();
await this.initNavigator();
await this.initSync();
@ -246,15 +239,6 @@ export class Client {
this.updatePageListCache().catch(console.error);
}
async loadSpaceScripts() {
// Swap in the expanded function map
this.stateDataStore.functionMap = await buildQueryFunctions(
this.allKnownPages,
this.system.system,
window.silverBulletConfig.enableSpaceScript,
);
}
async loadSettings() {
this.settings = await ensureAndLoadSettingsAndIndex(
this.space.spacePrimitives,
@ -286,14 +270,14 @@ export class Client {
// A full sync just completed
if (!initialSync) {
// If this was NOT the initial sync let's check if we need to perform a space reindex
ensureSpaceIndex(this.stateDataStore, this.system.system).catch(
ensureSpaceIndex(this.stateDataStore, this.clientSystem.system).catch(
console.error,
);
} else {
// This was the initial sync, let's mark a full index as completed
await markFullSpaceIndexComplete(this.stateDataStore);
// And load space scripts, which probably weren't loaded before
await this.loadSpaceScripts();
await this.clientSystem.loadSpaceScripts();
}
}
if (operations) {
@ -340,7 +324,6 @@ export class Client {
pageState.header !== undefined))
) {
setTimeout(() => {
console.log("Kicking off scroll to", pageState.scrollTop);
this.editorView.scrollDOM.scrollTop = pageState.scrollTop!;
});
adjustedPosition = true;
@ -544,7 +527,7 @@ export class Client {
this.plugSpaceRemotePrimitives = new PlugSpacePrimitives(
remoteSpacePrimitives,
this.system.namespaceHook,
this.clientSystem.namespaceHook,
this.syncMode ? undefined : "client",
);
@ -628,21 +611,21 @@ export class Client {
// Caching a list of known pages for the wiki_link highlighter (that checks if a page exists)
this.eventHook.addLocalListener("page:saved", (pageName: string) => {
// Make sure this page is in the list of known pages
this.allKnownPages.add(pageName);
this.clientSystem.allKnownPages.add(pageName);
});
this.eventHook.addLocalListener("page:deleted", (pageName: string) => {
this.allKnownPages.delete(pageName);
this.clientSystem.allKnownPages.delete(pageName);
});
this.eventHook.addLocalListener(
"file:listed",
(allFiles: FileMeta[]) => {
// Update list of known pages
this.allKnownPages.clear();
this.clientSystem.allKnownPages.clear();
allFiles.forEach((f) => {
if (f.name.endsWith(".md")) {
this.allKnownPages.add(f.name.slice(0, -3));
this.clientSystem.allKnownPages.add(f.name.slice(0, -3));
}
});
},
@ -741,11 +724,11 @@ export class Client {
async updatePageListCache() {
console.log("Updating page list cache");
if (!this.system.system.loadedPlugs.has("index")) {
if (!this.clientSystem.system.loadedPlugs.has("index")) {
console.warn("Index plug not loaded, cannot update page list cache");
return;
}
const allPages = await this.system.queryObjects<PageMeta>("page", {});
const allPages = await this.clientSystem.queryObjects<PageMeta>("page", {});
this.ui.viewDispatch({
type: "update-page-list",
allPages,
@ -828,7 +811,7 @@ export class Client {
}
async loadPlugs() {
await this.system.reloadPlugsFromSpace(this.space);
await this.clientSystem.reloadPlugsFromSpace(this.space);
await this.eventHook.dispatchEvent("system:ready");
await this.dispatchAppEvent("plugs:loaded");
}
@ -932,6 +915,7 @@ export class Client {
viewState.showPrompt,
].some(Boolean)
) {
console.log("not focusing");
// Some other modal UI element is visible, don't focus editor now
return;
}
@ -965,7 +949,7 @@ export class Client {
"Navigating to new page in new window",
`${location.origin}/${encodePageRef(pageRef)}`,
);
const win = window.open(
const win = globalThis.open(
`${location.origin}/${encodePageRef(pageRef)}`,
"_blank",
);
@ -1021,13 +1005,14 @@ export class Client {
perm: "rw",
} as PageMeta,
};
this.system.system.invokeFunction("template.newPage", [pageName]).then(
() => {
this.focus();
},
).catch(
console.error,
);
this.clientSystem.system.invokeFunction("template.newPage", [pageName])
.then(
() => {
this.focus();
},
).catch(
console.error,
);
} else {
this.flashNotification(
`Could not load page ${pageName}: ${e.message}`,

View File

@ -9,7 +9,7 @@ import { eventSyscalls } from "../plugos/syscalls/event.ts";
import { System } from "../plugos/system.ts";
import type { Client } from "./client.ts";
import { CodeWidgetHook } from "./hooks/code_widget.ts";
import { CommandHook } from "./hooks/command.ts";
import { CommandHook } from "../common/hooks/command.ts";
import { SlashCommandHook } from "./hooks/slash_command.ts";
import { clientStoreSyscalls } from "./syscalls/clientStore.ts";
import { debugSyscalls } from "./syscalls/debug.ts";
@ -41,24 +41,29 @@ import { deepObjectMerge } from "$sb/lib/json.ts";
import { Query } from "$sb/types.ts";
import { PanelWidgetHook } from "./hooks/panel_widget.ts";
import { createKeyBindings } from "./editor_state.ts";
import { CommonSystem } from "../common/common_system.ts";
import { DataStoreMQ } from "../plugos/lib/mq.datastore.ts";
const plugNameExtractRegex = /\/(.+)\.plug\.js$/;
export class ClientSystem {
commandHook: CommandHook;
slashCommandHook: SlashCommandHook;
namespaceHook: PlugNamespaceHook;
codeWidgetHook: CodeWidgetHook;
system: System<SilverBulletHooks>;
panelWidgetHook: PanelWidgetHook;
/**
* Wrapper around a System, used by the client
*/
export class ClientSystem extends CommonSystem {
constructor(
private client: Client,
private mq: MessageQueue,
private ds: DataStore,
private eventHook: EventHook,
private readOnlyMode: boolean,
mq: DataStoreMQ,
ds: DataStore,
eventHook: EventHook,
readOnlyMode: boolean,
) {
super(
mq,
ds,
eventHook,
readOnlyMode,
window.silverBulletConfig.enableSpaceScript,
);
// Only set environment to "client" when running in thin client mode, otherwise we run everything locally (hybrid)
this.system = new System(
client.syncMode ? undefined : "client",
@ -95,7 +100,10 @@ export class ClientSystem {
}
// Command hook
this.commandHook = new CommandHook(this.readOnlyMode);
this.commandHook = new CommandHook(
this.readOnlyMode,
this.spaceScriptCommands,
);
this.commandHook.on({
commandsUpdated: (commandMap) => {
this.client.ui?.viewDispatch({
@ -158,7 +166,7 @@ export class ClientSystem {
eventSyscalls(this.eventHook),
editorSyscalls(this.client),
spaceReadSyscalls(this.client),
systemSyscalls(this.system, false, this.client, undefined),
systemSyscalls(this.system, false, this, this.client),
markdownSyscalls(),
assetSyscalls(this.system),
yamlSyscalls(),

View File

@ -22,12 +22,13 @@ export function fencedCodePlugin(editor: Client) {
}
const text = state.sliceDoc(from, to);
const [_, lang] = text.match(/^```(\w+)?/)!;
const codeWidgetCallback = editor.system.codeWidgetHook
const codeWidgetCallback = editor.clientSystem.codeWidgetHook
.codeWidgetCallbacks
.get(lang);
const renderMode = editor.system.codeWidgetHook.codeWidgetModes.get(
lang,
);
const renderMode = editor.clientSystem.codeWidgetHook.codeWidgetModes
.get(
lang,
);
// Only custom render when we have a custom renderer, and the current page is not a template
if (codeWidgetCallback && !isTemplate(state.sliceDoc(0, from))) {
// We got a custom renderer!

View File

@ -64,7 +64,7 @@ export class MarkdownWidget extends WidgetType {
extendedMarkdownLanguage,
widgetContent.markdown!,
);
mdTree = await this.client.system.localSyscall(
mdTree = await this.client.clientSystem.localSyscall(
"system.invokeFunction",
[
"markdown.expandCodeWidgets",
@ -215,7 +215,7 @@ export class MarkdownWidget extends WidgetType {
// Update state in DOM as well for future toggles
e.target.dataset.state = newState;
console.log("Toggling task", taskRef);
this.client.system.localSyscall(
this.client.clientSystem.localSyscall(
"system.invokeFunction",
["tasks.updateTaskState", taskRef, oldState, newState],
).catch(
@ -234,7 +234,7 @@ export class MarkdownWidget extends WidgetType {
if (button.widgetTarget) {
div.addEventListener("click", () => {
console.log("Widget clicked");
this.client.system.localSyscall("system.invokeFunction", [
this.client.clientSystem.localSyscall("system.invokeFunction", [
button.invokeFunction,
this.from,
]).catch(console.error);
@ -245,7 +245,7 @@ export class MarkdownWidget extends WidgetType {
(e) => {
e.stopPropagation();
console.log("Button clicked:", button.description);
this.client.system.localSyscall("system.invokeFunction", [
this.client.clientSystem.localSyscall("system.invokeFunction", [
button.invokeFunction,
this.from,
]).then((newContent: string | undefined) => {

View File

@ -6,7 +6,7 @@ import { MarkdownWidget } from "./markdown_widget.ts";
export function postScriptPrefacePlugin(
editor: Client,
) {
const panelWidgetHook = editor.system.panelWidgetHook;
const panelWidgetHook = editor.clientSystem.panelWidgetHook;
return decoratorStateField((state: EditorState) => {
const widgets: any[] = [];
const topCallback = panelWidgetHook.callbacks.get("top");

View File

@ -33,7 +33,7 @@ export function cleanWikiLinkPlugin(client: Client) {
const pageRef = parsePageRef(page);
pageRef.page = resolvePath(client.currentPage, pageRef.page);
const lowerCasePageName = pageRef.page.toLowerCase();
for (const pageName of client.allKnownPages) {
for (const pageName of client.clientSystem.allKnownPages) {
if (pageName.toLowerCase() === lowerCasePageName) {
pageExists = true;
break;

View File

@ -1,7 +1,7 @@
import { isMacLike } from "../../common/util.ts";
import { FilterList } from "./filter.tsx";
import { CompletionContext, CompletionResult, featherIcons } from "../deps.ts";
import { AppCommand } from "../hooks/command.ts";
import { AppCommand } from "../../common/hooks/command.ts";
import { BuiltinSettings, FilterOption } from "../types.ts";
import { parseCommand } from "../../common/command.ts";

View File

@ -47,7 +47,7 @@ export function Panel({
break;
case "syscall": {
const { id, name, args } = data;
editor.system.localSyscall(name, args).then(
editor.clientSystem.localSyscall(name, args).then(
(result) => {
if (!iFrameRef.current?.contentWindow) {
// iFrame already went away

View File

@ -123,7 +123,7 @@ export function mountIFrame(
case "syscall": {
const { id, name, args } = data;
try {
const result = await client.system.localSyscall(name, args);
const result = await client.clientSystem.localSyscall(name, args);
if (!iframe.contentWindow) {
// iFrame already went away
return;

View File

@ -99,8 +99,8 @@ export function createEditorState(
autocompletion({
override: [
client.editorComplete.bind(client),
client.system.slashCommandHook.slashCommandCompleter.bind(
client.system.slashCommandHook,
client.clientSystem.slashCommandHook.slashCommandCompleter.bind(
client.clientSystem.slashCommandHook,
),
],
}),
@ -299,7 +299,7 @@ export function createCommandKeyBindings(client: Client): KeyBinding[] {
}
// Then add bindings for plug commands
for (const def of client.system.commandHook.editorCommands.values()) {
for (const def of client.clientSystem.commandHook.editorCommands.values()) {
if (def.command.key) {
// If we've already overridden this command, skip it
if (overriddenCommands.has(def.command.key)) {

View File

@ -210,7 +210,7 @@ export class MainUI {
return;
}
console.log("Now renaming page to...", newName);
await client.system.system.invokeFunction(
await client.clientSystem.system.invokeFunction(
"index.renamePageCommand",
[{ page: newName }],
);
@ -250,26 +250,29 @@ export class MainUI {
}]
: [],
...viewState.settings.actionButtons
.filter((button) => (typeof button.mobile === "undefined") || (button.mobile === viewState.isMobile))
.map((button) => {
const parsedCommand = parseCommand(button.command);
let featherIcon =
(featherIcons as any)[kebabToCamel(button.icon)];
if (!featherIcon) {
featherIcon = featherIcons.HelpCircle;
}
return {
icon: featherIcon,
description: button.description || "",
callback: () => {
client.runCommandByName(
parsedCommand.name,
parsedCommand.args,
);
},
href: "",
};
}),
.filter((button) =>
(typeof button.mobile === "undefined") ||
(button.mobile === viewState.isMobile)
)
.map((button) => {
const parsedCommand = parseCommand(button.command);
let featherIcon =
(featherIcons as any)[kebabToCamel(button.icon)];
if (!featherIcon) {
featherIcon = featherIcons.HelpCircle;
}
return {
icon: featherIcon,
description: button.description || "",
callback: () => {
client.runCommandByName(
parsedCommand.name,
parsedCommand.args,
);
},
href: "",
};
}),
]}
rhs={!!viewState.panels.rhs.mode && (
<div

View File

@ -116,7 +116,7 @@ export class SlashCommandHook implements Hook<SlashCommandHookT> {
});
// Replace with whatever the completion is
safeRun(async () => {
await this.editor.system.system.invokeFunction(
await this.editor.clientSystem.system.invokeFunction(
slashCompletion.invoke,
[slashCompletion],
);

View File

@ -60,8 +60,10 @@ export function editorSyscalls(client: Client): SysCallMapping {
},
"editor.reloadSettingsAndCommands": async () => {
await client.loadSettings();
await client.system.commandHook.buildAllCommands();
await client.system.system.localSyscall("system.loadSpaceScripts", []);
await client.clientSystem.system.localSyscall(
"system.loadSpaceScripts",
[],
);
},
"editor.openUrl": (_ctx, url: string, existingWindow = false) => {
if (!existingWindow) {

View File

@ -1,6 +1,6 @@
import { Manifest } from "../common/manifest.ts";
import { PageMeta } from "$sb/types.ts";
import { AppCommand } from "./hooks/command.ts";
import { AppCommand } from "../common/hooks/command.ts";
import { defaultSettings } from "../common/util.ts";
// Used by FilterBox

View File

@ -13,52 +13,49 @@ Space scripts are defined by simply using `space-script` fenced code blocks in y
Here is a trivial example:
```space-script
silverbullet.registerFunction("helloSayer", (name) => {
return `Hello ${name}!`;
silverbullet.registerFunction("helloYeller", (name) => {
return `Hello ${name}!`.toUpperCase();
})
```
You can now invoke this function as follows:
You can now invoke this function in a template or query:
```template
{{helloSayer("Pete")}}
{{helloYeller("Pete")}}
```
Upon client and server boot, all indexed scripts will be loaded and activated. To reload scripts on-demand, use the {[System: Reload]} command (bound to `Ctrl-Alt-r` for convenience).
If you use things like `console.log` in your script, you will see this output either in your servers logs or browsers JavaScript console (depending on how the script will be invoked).
If you use things like `console.log` in your script, you will see this output either in your servers logs or browsers JavaScript console (depending on where the script will be invoked).
# Runtime Environment & API
Space script is loaded both in the client and server (or only client, if you run in [[Install/Configuration#Security]] `SB_SYNC_ONLY` mode).
Space script is loaded directly in the browser environment on the client, and the Deno environment on the server.
Depending on where code is run, a slightly different JavaScript API will be available. However, code should ideally primarily rely on the following explicitly exposed APIs:
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`.
* `silverbullet.registerFunction(name, callback)`: register a custom function (see below)
* `syscall(name, args...)`: invoke a syscall
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:
Many other standard JavaScript APIs are also available, such as:
* `silverbullet.registerFunction(name, callback)`: registers a custom function (see [[#Custom functions]]).
* `silverbullet.registerCommand(definition, callback)`: registers a custom command (see [[#Custom commands]]).
* `syscall(name, args...)`: invoke a syscall (see [[#Syscalls]]).
* [fetch](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API)
* [Temporal](https://tc39.es/proposal-temporal/docs/)
Many useful standard JavaScript APIs are available, such as:
* [fetch](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) (making fetch calls directly from the browser on the client, and via Denos fetch implementation on the server)
* [Temporal](https://tc39.es/proposal-temporal/docs/) (implemented via a polyfill)
# Custom functions
SilverBullet offers a set of [[Functions]] you can use in its [[Template Language]]. You can extend this set of functions using space script using the `silverbullet.registerFunction` API.
Here is a simple example:
Since template rendering happens on the server (except in [[Client Modes#Synced mode]]), this logic is typically executed on the server.
```space-script
silverbullet.registerFunction("helloSayer", (name) => {
return `Hello ${name}!`;
})
```
The `silverbullet.registerFunction` API takes two arguments:
You can now invoke this function as follows:
* `name`: the function name to register
* `callback`: the callback function to invoke (can be `async` or not)
```template
{{helloSayer("Pete")}}
```
Even though a [[Functions#readPage(name)]] function already exist, you could implement it in Space Script as follows (lets name it `myReadPage` instead) using the `syscall` API (detailed further in [[#Syscalls]]):
## Example
Even though a [[Functions#readPage(name)]] function already exist, you could implement it in space script as follows (lets name it `myReadPage`) using the `syscall` API (detailed further in [[#Syscalls]]):
```space-script
silverbullet.registerFunction("myReadPage", async (name) => {
@ -75,8 +72,35 @@ This function can be invoked as follows:
{{myReadPage("internal/test page")}}
```
# Custom commands
You can also define custom commands using space script. Commands are _always_ executed on the client.
Here is an example of defining a custom command using space script:
```space-script
silverbullet.registerCommand({name: "My First Command"}, async () => {
await syscall("editor.flashNotification", "Hello there!");
});
```
You can run it via the command palette, or by pushing this [[Markdown/Command links|command link]]: {[My First Command]}
The `silverbullet.registerCommand` API takes two arguments:
* `options`:
* `name`: Name of the command
* `key` (optional): Keyboard shortcut for the command (Windows/Linux)
* `mac` (optional): Mac keyboard shortcut for the command
* `hide` (optional): Do not show this command in the command palette
* `requireMode` (optional): Only make this command available in `ro` or `rw` mode.
* `callback`: the callback function to invoke (can be `async` or not)
# Syscalls
You can invoke syscalls to get access to various useful SilverBullet APIs. A syscall is invoked via `syscall(name, arg1, arg2)` and returns a [Promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise) with the result.
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.
In your space script, a syscall is invoked via `syscall(name, arg1, arg2)` and usually returns a [Promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise) with the result.
Here are all available syscalls:
```template
{{#each @module in {syscall select replace(name, /\.\w+$/, "") as name}}}
@ -84,5 +108,6 @@ You can invoke syscalls to get access to various useful SilverBullet APIs. A sys
{{#each {syscall where @module.name = replace(name, /\.\w+$/, "")}}}
* `{{name}}`
{{/each}}
{{/each}}
```