Added pageNamespace hook support

pull/3/head
Zef Hemel 2022-05-17 11:53:17 +02:00
parent cd89634fd6
commit 9a6a86f8b5
14 changed files with 302 additions and 15 deletions

View File

@ -4,12 +4,14 @@ import { CronHookT } from "@plugos/plugos/hooks/node_cron";
import { EventHookT } from "@plugos/plugos/hooks/event";
import { CommandHookT } from "@silverbulletmd/web/hooks/command";
import { SlashCommandHookT } from "@silverbulletmd/web/hooks/slash_command";
import { PageNamespaceHookT } from "../server/hooks/page_namespace";
export type SilverBulletHooks = CommandHookT &
SlashCommandHookT &
EndpointHookT &
CronHookT &
EventHookT;
EventHookT &
PageNamespaceHookT;
export type SyntaxExtensions = {
syntax?: { [key: string]: NodeDef };

View File

@ -56,6 +56,7 @@ export class DiskSpacePrimitives implements SpacePrimitives {
meta: {
name: pageName,
lastModified: s.mtime.getTime(),
perm: "rw",
},
};
} catch (e) {
@ -88,6 +89,7 @@ export class DiskSpacePrimitives implements SpacePrimitives {
return {
name: pageName,
lastModified: s.mtime.getTime(),
perm: "rw",
};
} catch (e) {
console.error("Error while writing page", pageName, e);
@ -102,6 +104,7 @@ export class DiskSpacePrimitives implements SpacePrimitives {
return {
name: pageName,
lastModified: s.mtime.getTime(),
perm: "rw",
};
} catch (e) {
console.error("Error while getting page meta", pageName, e);
@ -132,6 +135,7 @@ export class DiskSpacePrimitives implements SpacePrimitives {
pages.add({
name: this.pathToPageName(fullPath),
lastModified: s.mtime.getTime(),
perm: "rw",
});
}
}

View File

@ -42,6 +42,7 @@ export class HttpSpacePrimitives implements SpacePrimitives {
result.add({
name: pageName,
lastModified: meta.lastModified,
perm: "rw",
});
});
@ -160,10 +161,10 @@ export class HttpSpacePrimitives implements SpacePrimitives {
}
private responseToMeta(name: string, res: Response): PageMeta {
const meta = {
return {
name,
lastModified: +(res.headers.get("Last-Modified") || "0"),
perm: (res.headers.get("X-Permission") as "rw" | "ro") || "rw",
};
return meta;
}
}

View File

@ -72,9 +72,10 @@ export class IndexedDBSpacePrimitives implements SpacePrimitives {
selfUpdate?: boolean,
lastModified?: number
): Promise<PageMeta> {
let meta = {
const meta: PageMeta = {
name,
lastModified: lastModified ? lastModified : Date.now() + this.timeSkew,
perm: "rw",
};
await this.pageTable.put({
name,

View File

@ -31,9 +31,10 @@ export class Space extends EventEmitter<SpaceEvents> {
newPageList.pages.forEach((meta) => {
const pageName = meta.name;
const oldPageMeta = this.pageMetaCache.get(pageName);
const newPageMeta = {
const newPageMeta: PageMeta = {
name: pageName,
lastModified: meta.lastModified,
perm: meta.perm,
};
if (
!oldPageMeta &&

View File

@ -8,3 +8,19 @@ functions:
path: ./search.ts:queryProvider
events:
- query:full-text
searchCommand:
path: ./search.ts:searchCommand
command:
name: "Search Space"
key: Ctrl-Shift-f
mac: Cmd-Shift-f
readPageSearch:
path: ./search.ts:readPageSearch
pageNamespace:
pattern: "@search/.+"
operation: readPage
getPageMetaSearch:
path: ./search.ts:getPageMetaSearch
pageNamespace:
pattern: "@search/.+"
operation: getPageMeta

View File

@ -1,6 +1,11 @@
import { fullTextIndex, fullTextSearch } from "@plugos/plugos-syscall/fulltext";
import { renderToText } from "@silverbulletmd/common/tree";
import { PageMeta } from "@silverbulletmd/common/types";
import { scanPrefixGlobal } from "@silverbulletmd/plugos-silverbullet-syscall";
import {
navigate,
prompt,
} from "@silverbulletmd/plugos-silverbullet-syscall/editor";
import { IndexTreeEvent } from "@silverbulletmd/web/app_event";
import { applyQuery, QueryProviderEvent } from "../query/engine";
import { removeQueries } from "../query/util";
@ -38,3 +43,37 @@ export async function queryProvider({
results = applyQuery(query, results);
return results;
}
export async function searchCommand() {
let phrase = await prompt("Search for: ");
if (phrase) {
await navigate(`@search/${phrase}`);
}
}
export async function readPageSearch(
name: string
): Promise<{ text: string; meta: PageMeta }> {
let phrase = name.substring("@search/".length);
let results = await fullTextSearch(phrase, 100);
const text = `# Search results for "${phrase}"\n${results
.map((r: any) => `* [[${r.name}]] (score: ${r.rank})`)
.join("\n")}
`;
return {
text: text,
meta: {
name,
lastModified: 0,
perm: "ro",
},
};
}
export async function getPageMetaSearch(name: string): Promise<PageMeta> {
return {
name,
lastModified: 0,
perm: "ro",
};
}

View File

@ -36,6 +36,8 @@ import {
ensureFTSTable,
fullTextSearchSyscalls,
} from "@plugos/plugos/syscalls/fulltext.knex_sqlite";
import { PlugSpacePrimitives } from "./hooks/plug_space_primitives";
import { PageNamespaceHook } from "./hooks/page_namespace";
const safeFilename = /^[a-zA-Z0-9_\-\.]+$/;
@ -69,9 +71,14 @@ export class ExpressServer {
// Setup system
this.eventHook = new EventHook();
this.system.addHook(this.eventHook);
let namespaceHook = new PageNamespaceHook();
this.system.addHook(namespaceHook);
this.space = new Space(
new EventedSpacePrimitives(
new PlugSpacePrimitives(
new DiskSpacePrimitives(options.pagesPath),
namespaceHook
),
this.eventHook
),
true
@ -227,6 +234,7 @@ export class ExpressServer {
let pageData = await this.space.readPage(pageName);
res.status(200);
res.header("Last-Modified", "" + pageData.meta.lastModified);
res.header("X-Permission", pageData.meta.perm);
res.header("Content-Type", "text/markdown");
res.send(pageData.text);
} catch (e) {
@ -251,6 +259,7 @@ export class ExpressServer {
);
res.status(200);
res.header("Last-Modified", "" + meta.lastModified);
res.header("X-Permission", meta.perm);
res.send("OK");
} catch (err) {
res.status(500);
@ -264,6 +273,7 @@ export class ExpressServer {
const meta = await this.space.getPageMeta(pageName);
res.status(200);
res.header("Last-Modified", "" + meta.lastModified);
res.header("X-Permission", meta.perm);
res.header("Content-Type", "text/markdown");
res.send("");
} catch (e) {

View File

@ -0,0 +1,90 @@
import { Plug } from "@plugos/plugos/plug";
import { System } from "@plugos/plugos/system";
import { Hook, Manifest } from "@plugos/plugos/types";
import { Express, NextFunction, Request, Response, Router } from "express";
export type PageNamespaceOperation =
| "readPage"
| "writePage"
| "listPages"
| "getPageMeta"
| "deletePage";
export type PageNamespaceDef = {
pattern: string;
operation: PageNamespaceOperation;
};
export type PageNamespaceHookT = {
pageNamespace?: PageNamespaceDef;
};
type SpaceFunction = {
operation: PageNamespaceOperation;
pattern: RegExp;
plug: Plug<PageNamespaceHookT>;
name: string;
};
export class PageNamespaceHook implements Hook<PageNamespaceHookT> {
spaceFunctions: SpaceFunction[] = [];
constructor() {}
apply(system: System<PageNamespaceHookT>): void {
system.on({
plugLoaded: () => {
this.updateCache(system);
},
plugUnloaded: () => {
this.updateCache(system);
},
});
}
updateCache(system: System<PageNamespaceHookT>) {
this.spaceFunctions = [];
for (let plug of system.loadedPlugs.values()) {
if (plug.manifest?.functions) {
for (let [funcName, funcDef] of Object.entries(
plug.manifest.functions
)) {
if (funcDef.pageNamespace) {
this.spaceFunctions.push({
operation: funcDef.pageNamespace.operation,
pattern: new RegExp(funcDef.pageNamespace.pattern),
plug,
name: funcName,
});
}
}
}
}
}
validateManifest(manifest: Manifest<PageNamespaceHookT>): string[] {
let errors: string[] = [];
if (!manifest.functions) {
return [];
}
for (let [funcName, funcDef] of Object.entries(manifest.functions)) {
if (funcDef.pageNamespace) {
if (!funcDef.pageNamespace.pattern) {
errors.push(`Function ${funcName} has a namespace but no pattern`);
}
if (!funcDef.pageNamespace.operation) {
errors.push(`Function ${funcName} has a namespace but no operation`);
}
if (
!["readPage", "writePage", "getPageMeta", "listPages"].includes(
funcDef.pageNamespace.operation
)
) {
errors.push(
`Function ${funcName} has an invalid operation ${funcDef.pageNamespace.operation}`
);
}
}
}
return errors;
}
}

View File

@ -0,0 +1,103 @@
import { Plug } from "@plugos/plugos/plug";
import { SpacePrimitives } from "@silverbulletmd/common/spaces/space_primitives";
import { PageMeta } from "@silverbulletmd/common/types";
import { PageNamespaceHook, PageNamespaceOperation } from "./page_namespace";
export class PlugSpacePrimitives implements SpacePrimitives {
constructor(
private wrapped: SpacePrimitives,
private hook: PageNamespaceHook
) {}
performOperation(
type: PageNamespaceOperation,
pageName: string,
...args: any[]
): Promise<any> | false {
for (let { operation, pattern, plug, name } of this.hook.spaceFunctions) {
if (operation === type && pageName.match(pattern)) {
return plug.invoke(name, [pageName, ...args]);
}
}
return false;
}
async fetchPageList(): Promise<{
pages: Set<PageMeta>;
nowTimestamp: number;
}> {
let allPages = new Set<PageMeta>();
for (let { plug, name, operation } of this.hook.spaceFunctions) {
if (operation === "listPages") {
for (let pm of await plug.invoke(name, [])) {
allPages.add(pm);
}
}
}
let result = await this.wrapped.fetchPageList();
for (let pm of result.pages) {
allPages.add(pm);
}
return {
nowTimestamp: result.nowTimestamp,
pages: allPages,
};
}
readPage(name: string): Promise<{ text: string; meta: PageMeta }> {
let result = this.performOperation("readPage", name);
if (result) {
return result;
}
return this.wrapped.readPage(name);
}
getPageMeta(name: string): Promise<PageMeta> {
let result = this.performOperation("getPageMeta", name);
if (result) {
return result;
}
return this.wrapped.getPageMeta(name);
}
writePage(
name: string,
text: string,
selfUpdate?: boolean,
lastModified?: number
): Promise<PageMeta> {
let result = this.performOperation(
"writePage",
name,
text,
selfUpdate,
lastModified
);
if (result) {
return result;
}
return this.wrapped.writePage(name, text, selfUpdate, lastModified);
}
deletePage(name: string): Promise<void> {
let result = this.performOperation("deletePage", name);
if (result) {
return result;
}
return this.wrapped.deletePage(name);
}
proxySyscall(plug: Plug<any>, name: string, args: any[]): Promise<any> {
return this.wrapped.proxySyscall(plug, name, args);
}
invokeFunction(
plug: Plug<any>,
env: string,
name: string,
args: any[]
): Promise<any> {
return this.wrapped.invokeFunction(plug, env, name, args);
}
}

View File

@ -159,6 +159,7 @@ export function FilterList({
}
break;
}
e.stopPropagation();
}}
onClick={(e) => e.stopPropagation()}
/>

View File

@ -16,6 +16,7 @@ import {
highlightSpecialChars,
KeyBinding,
keymap,
runScopeHandlers,
ViewPlugin,
ViewUpdate,
} from "@codemirror/view";
@ -56,7 +57,7 @@ import {
MDExt,
} from "@silverbulletmd/common/markdown_ext";
import { FilterList } from "./components/filter";
import { FilterOption } from "@silverbulletmd/common/types";
import { FilterOption, PageMeta } from "@silverbulletmd/common/types";
import { syntaxTree } from "@codemirror/language";
import sandboxSyscalls from "@plugos/plugos/syscalls/sandbox";
import globalModules from "../common/dist/global.plug.json";
@ -144,6 +145,16 @@ export class Editor {
});
},
});
// Make keyboard shortcuts work even when the editor is in read only mode or not focused
window.addEventListener("keydown", (ev) => {
if (!this.editorView?.hasFocus) {
console.log("Window-level keyboard event", ev);
if (runScopeHandlers(this.editorView!, ev, "editor")) {
ev.preventDefault();
}
}
});
}
get currentPage(): string | undefined {
@ -454,7 +465,10 @@ export class Editor {
this.createEditorState(this.currentPage, editorView.state.sliceDoc())
);
if (editorView.contentDOM) {
this.tweakEditorDOM(editorView.contentDOM);
this.tweakEditorDOM(
editorView.contentDOM,
this.viewState.perm === "ro"
);
}
this.restoreState(this.currentPage);
@ -516,30 +530,31 @@ export class Editor {
console.log("Creating new page", pageName);
doc = {
text: "",
meta: { name: pageName, lastModified: 0 },
meta: { name: pageName, lastModified: 0, perm: "rw" } as PageMeta,
};
}
let editorState = this.createEditorState(pageName, doc.text);
editorView.setState(editorState);
if (editorView.contentDOM) {
this.tweakEditorDOM(editorView.contentDOM);
this.tweakEditorDOM(editorView.contentDOM, doc.meta.perm === "ro");
}
this.restoreState(pageName);
this.space.watchPage(pageName);
this.viewDispatch({
type: "page-loaded",
name: pageName,
meta: doc.meta,
});
await this.eventHook.dispatchEvent("editor:pageSwitched");
}
tweakEditorDOM(contentDOM: HTMLElement) {
tweakEditorDOM(contentDOM: HTMLElement, readOnly: boolean) {
contentDOM.spellcheck = true;
contentDOM.setAttribute("autocorrect", "on");
contentDOM.setAttribute("autocapitalize", "on");
contentDOM.setAttribute("contenteditable", readOnly ? "false" : "true");
}
private restoreState(pageName: string) {

View File

@ -14,12 +14,13 @@ export default function reducer(
...state,
allPages: new Set(
[...state.allPages].map((pageMeta) =>
pageMeta.name === action.name
pageMeta.name === action.meta.name
? { ...pageMeta, lastOpened: Date.now() }
: pageMeta
)
),
currentPage: action.name,
perm: action.meta.perm,
currentPage: action.meta.name,
};
case "page-changed":
return {

View File

@ -18,6 +18,8 @@ export type ActionButton = {
export type AppViewState = {
currentPage?: string;
perm: "ro" | "rw";
showPageNavigator: boolean;
showCommandPalette: boolean;
unsavedChanges: boolean;
@ -45,6 +47,7 @@ export type AppViewState = {
};
export const initialViewState: AppViewState = {
perm: "rw",
showPageNavigator: false,
showCommandPalette: false,
unsavedChanges: false,
@ -68,7 +71,7 @@ export const initialViewState: AppViewState = {
};
export type Action =
| { type: "page-loaded"; name: string }
| { type: "page-loaded"; meta: PageMeta }
| { type: "pages-listed"; pages: Set<PageMeta> }
| { type: "page-changed" }
| { type: "page-saved" }