diff --git a/plugins/core/core.plugin.json b/plugins/core/core.plugin.json index 7fd9a0ba..b12ebbf4 100644 --- a/plugins/core/core.plugin.json +++ b/plugins/core/core.plugin.json @@ -21,13 +21,41 @@ "invoke": "toggle_h2", "mac": "Cmd-2", "key": "Ctrl-2" + }, + "Page: Delete": { + "invoke": "deletePage" + }, + "Page: Rename": { + "invoke": "renamePage" + }, + "Pages: Reindex": { + "invoke": "reindexPages" + }, + "Pages: Back Links": { + "invoke": "showBackLinks" } }, "events": { "page:click": ["taskToggle", "clickNavigate"], - "editor:complete": ["pageComplete"] + "editor:complete": ["pageComplete"], + "page:index": ["indexLinks"] }, "functions": { + "indexLinks": { + "path": "./page.ts:indexLinks" + }, + "deletePage": { + "path": "./page.ts:deletePage" + }, + "showBackLinks": { + "path": "./page.ts:showBackLinks" + }, + "renamePage": { + "path": "./page.ts:renamePage" + }, + "reindexPages": { + "path": "./page.ts:reindex" + }, "pageComplete": { "path": "./navigate.ts:pageComplete" }, diff --git a/plugins/core/page.ts b/plugins/core/page.ts new file mode 100644 index 00000000..f077a8fa --- /dev/null +++ b/plugins/core/page.ts @@ -0,0 +1,104 @@ +import { IndexEvent } from "../../webapp/src/app_event.ts"; +import { pageLinkRegex } from "../../webapp/src/constant.ts"; +import { syscall } from "./lib/syscall.ts"; + +const wikilinkRegex = new RegExp(pageLinkRegex, "g"); + +export async function indexLinks({ name, text }: IndexEvent) { + console.log("Now indexing", name); + let backLinks: { key: string; value: string }[] = []; + for (let match of text.matchAll(wikilinkRegex)) { + let toPage = match[1]; + let pos = match.index!; + backLinks.push({ + key: `pl:${toPage}:${pos}`, + value: name, + }); + } + console.log("Found", backLinks.length, "wiki link(s)"); + await syscall("indexer.batchSet", name, backLinks); +} + +export async function deletePage() { + let pageMeta = await syscall("editor.getCurrentPage"); + console.log("Navigating to start page"); + await syscall("editor.navigate", "start"); + console.log("Deleting page from space"); + await syscall("space.deletePage", pageMeta.name); + console.log("Reloading page list"); + await syscall("space.reloadPageList"); +} + +export async function renamePage() { + const pageMeta = await syscall("editor.getCurrentPage"); + const oldName = pageMeta.name; + const newName = await syscall("editor.prompt", `Rename ${oldName} to:`); + if (!newName) { + return; + } + console.log("New name", newName); + + let pagesToUpdate = await getBackLinks(oldName); + console.log("All pages containing backlinks", pagesToUpdate); + + let text = await syscall("editor.getText"); + console.log("Writing new page to space"); + await syscall("space.writePage", newName, text); + console.log("Deleting page from space"); + await syscall("space.deletePage", oldName); + console.log("Reloading page list"); + await syscall("space.reloadPageList"); + console.log("Navigating to new page"); + await syscall("editor.navigate", newName); + + let pageToUpdateSet = new Set(); + for (let pageToUpdate of pagesToUpdate) { + pageToUpdateSet.add(pageToUpdate.page); + } + + for (let pageToUpdate of pageToUpdateSet) { + console.log("Now going to update links in", pageToUpdate); + let { text } = await syscall("space.readPage", pageToUpdate); + if (!text) { + // Page likely does not exist, but at least we can skip it + continue; + } + let newText = text.replaceAll(`[[${oldName}]]`, `[[${newName}]]`); + if (text !== newText) { + console.log("Changes made, saving..."); + await syscall("space.writePage", pageToUpdate, newText); + } + } +} + +type BackLink = { + page: string; + pos: number; +}; + +async function getBackLinks(pageName: string): Promise { + let allBackLinks = await syscall( + "indexer.scanPrefixGlobal", + `pl:${pageName}:` + ); + let pagesToUpdate: BackLink[] = []; + for (let { key, value } of allBackLinks) { + let keyParts = key.split(":"); + pagesToUpdate.push({ + page: value, + pos: +keyParts[keyParts.length - 1], + }); + } + return pagesToUpdate; +} + +export async function showBackLinks() { + const pageMeta = await syscall("editor.getCurrentPage"); + let backLinks = await getBackLinks(pageMeta.name); + + console.log("Backlinks", backLinks); +} + +export async function reindex() { + await syscall("space.reindex"); +} diff --git a/server/server.ts b/server/server.ts index 061633cf..a56ec98f 100644 --- a/server/server.ts +++ b/server/server.ts @@ -18,7 +18,7 @@ const pagesPath = "../pages"; const fsRouter = new Router(); -fsRouter.use(oakCors({ methods: ["OPTIONS", "GET", "PUT", "POST"] })); +fsRouter.use(oakCors({ methods: ["OPTIONS", "GET", "PUT", "POST", "DELETE"] })); fsRouter.get("/", async (context) => { const localPath = pagesPath; @@ -96,6 +96,22 @@ fsRouter.put("/:page(.*)", async (context) => { context.response.body = "OK"; }); +fsRouter.delete("/:page(.*)", async (context) => { + const pageName = context.params.page; + const localPath = `${pagesPath}/${pageName}.md`; + try { + await Deno.remove(localPath); + } catch (e) { + console.error("Error deleting file", localPath, e); + context.response.status = 500; + context.response.body = e.message; + return; + } + console.log("Deleted", localPath); + + context.response.body = "OK"; +}); + const app = new Application(); app.use( new Router() @@ -109,7 +125,8 @@ app.use(async (context, next) => { index: "index.html", }); } catch { - next(); + await context.send({ root: "../webapp/dist", path: "index.html" }); + // next(); } }); diff --git a/webapp/package.json b/webapp/package.json index b9521f99..dba611ae 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -30,6 +30,7 @@ "@codemirror/state": "^0.19.7", "@codemirror/view": "^0.19.42", "@parcel/service-worker": "^2.3.2", + "dexie": "^3.2.1", "idb": "^7.0.0", "react": "^17.0.2", "react-dom": "^17.0.2" diff --git a/webapp/src/app_event.ts b/webapp/src/app_event.ts index 39e7a84d..2ca8c13f 100644 --- a/webapp/src/app_event.ts +++ b/webapp/src/app_event.ts @@ -2,6 +2,7 @@ export type AppEvent = | "app:ready" | "page:save" | "page:click" + | "page:index" | "editor:complete"; export type ClickEvent = { @@ -10,3 +11,12 @@ export type ClickEvent = { ctrlKey: boolean; altKey: boolean; }; + +export type IndexEvent = { + name: string; + text: string; +}; + +export interface AppEventDispatcher { + dispatchAppEvent(name: AppEvent, data?: any): Promise; +} diff --git a/webapp/src/components/page_navigator.tsx b/webapp/src/components/page_navigator.tsx index 056cb019..eb924bae 100644 --- a/webapp/src/components/page_navigator.tsx +++ b/webapp/src/components/page_navigator.tsx @@ -1,21 +1,35 @@ import { PageMeta } from "../types"; -import { FilterList } from "./filter"; +import { FilterList, Option } from "./filter"; export function PageNavigator({ - allPages: allPages, + allPages, onNavigate, + currentPage, }: { allPages: PageMeta[]; onNavigate: (page: string | undefined) => void; + currentPage?: PageMeta; }) { + let options: Option[] = []; + for (let pageMeta of allPages) { + if (currentPage && currentPage.name == pageMeta.name) { + continue; + } + // Order by last modified date in descending order + let orderId = -pageMeta.lastModified.getTime(); + // Unless it was opened and is still in memory + if (pageMeta.lastOpened) { + orderId = -pageMeta.lastOpened.getTime(); + } + options.push({ + ...pageMeta, + orderId: orderId, + }); + } return ( ({ - ...meta, - // Order by last modified date in descending order - orderId: -meta.lastModified.getTime(), - }))} + options={options} allowNew={true} newHint="Create page" onSelect={(opt) => { diff --git a/webapp/src/constant.ts b/webapp/src/constant.ts new file mode 100644 index 00000000..47d2e664 --- /dev/null +++ b/webapp/src/constant.ts @@ -0,0 +1 @@ +export const pageLinkRegex = /\[\[([\w\s\/\:,\.\-]+)\]\]/; diff --git a/webapp/src/editor.tsx b/webapp/src/editor.tsx index 47596b95..0fb5adb9 100644 --- a/webapp/src/editor.tsx +++ b/webapp/src/editor.tsx @@ -39,6 +39,7 @@ import customMarkdownStyle from "./style"; import dbSyscalls from "./syscalls/db.localstorage"; import { Plugin } from "./plugins/runtime"; import editorSyscalls from "./syscalls/editor.browser"; +import indexerSyscalls from "./syscalls/indexer.native"; import spaceSyscalls from "./syscalls/space.native"; import { Action, @@ -47,8 +48,15 @@ import { initialViewState, PageMeta, } from "./types"; -import { AppEvent, ClickEvent } from "./app_event"; +import { + AppEvent, + AppEventDispatcher, + ClickEvent, + IndexEvent, +} from "./app_event"; import { safeRun } from "./util"; +import { Indexer } from "./indexer"; +import { IPageNavigator, PathPageNavigator } from "./navigator"; class PageState { editorState: EditorState; @@ -64,21 +72,23 @@ class PageState { const watchInterval = 5000; -export class Editor { +export class Editor implements AppEventDispatcher { editorView?: EditorView; viewState: AppViewState; viewDispatch: React.Dispatch; - $hashChange?: () => void; openPages: Map; - fs: Space; + space: Space; editorCommands: Map; plugins: Plugin[]; + indexer: Indexer; + navigationResolve?: (val: undefined) => void; + pageNavigator: IPageNavigator; - constructor(fs: Space, parent: Element) { + constructor(space: Space, parent: Element) { this.editorCommands = new Map(); this.openPages = new Map(); this.plugins = []; - this.fs = fs; + this.space = space; this.viewState = initialViewState; this.viewDispatch = () => {}; this.render(parent); @@ -86,16 +96,30 @@ export class Editor { state: this.createEditorState(""), parent: document.getElementById("editor")!, }); - this.addListeners(); - // this.watch(); + this.pageNavigator = new PathPageNavigator(); + this.indexer = new Indexer("page-index", space); + this.watch(); } async init() { await this.loadPageList(); await this.loadPlugins(); - this.$hashChange!(); this.focus(); - await this.dispatchAppEvent("app:ready"); + + this.pageNavigator.subscribe(async (pageName) => { + await this.save(); + console.log("Now navigating to", pageName); + + if (!this.editorView) { + return; + } + + await this.loadPage(pageName); + }); + + if (this.pageNavigator.getCurrentPage() === "") { + this.pageNavigator.navigate("start"); + } } async loadPlugins() { @@ -103,7 +127,8 @@ export class Editor { system.registerSyscalls( dbSyscalls, editorSyscalls(this), - spaceSyscalls(this) + spaceSyscalls(this), + indexerSyscalls(this.indexer) ); await system.bootServiceWorker(); @@ -332,41 +357,20 @@ export class Editor { return null; } - click(event: MouseEvent, view: EditorView) { - // if (event.metaKey || event.ctrlKey) { - // let coords = view.posAtCoords(event)!; - // let node = syntaxTree(view.state).resolveInner(coords); - // if (node && node.name === "WikiLinkPage") { - // let pageName = view.state.sliceDoc(node.from, node.to); - // this.navigate(pageName); - // } - // if (node && node.name === "TaskMarker") { - // let checkBoxText = view.state.sliceDoc(node.from, node.to); - // if (checkBoxText === "[x]" || checkBoxText === "[X]") { - // view.dispatch({ - // changes: { from: node.from, to: node.to, insert: "[ ]" }, - // }); - // } else { - // view.dispatch({ - // changes: { from: node.from, to: node.to, insert: "[x]" }, - // }); - // } - // } - // return false; - // } - } - async save() { const editorState = this.editorView!.state; if (!this.currentPage) { return; } + + if (this.viewState.isSaved) { + console.log("Page not modified, skipping saving"); + return; + } // Write to file system - let pageMeta = await this.fs.writePage( - this.currentPage.name, - editorState.sliceDoc() - ); + let text = editorState.sliceDoc(); + let pageMeta = await this.space.writePage(this.currentPage.name, text); // Update in open page cache this.openPages.set( @@ -381,10 +385,18 @@ export class Editor { if (pageMeta.created) { await this.loadPageList(); } + + // Reindex page + await this.indexPage(text, pageMeta); + } + + private async indexPage(text: string, pageMeta: PageMeta) { + console.log("Indexing page", pageMeta.name); + this.indexer.indexPage(this, pageMeta, text, true); } async loadPageList() { - let pagesMeta = await this.fs.listPages(); + let pagesMeta = await this.space.listPages(); this.viewDispatch({ type: "pages-listed", pages: pagesMeta, @@ -394,63 +406,52 @@ export class Editor { watch() { setInterval(() => { safeRun(async () => { - if (!this.currentPage) { - return; - } - const currentPageName = this.currentPage.name; - let newPageMeta = await this.fs.getPageMeta(currentPageName); - if ( - this.currentPage.lastModified.getTime() < - newPageMeta.lastModified.getTime() - ) { - console.log("File changed on disk, reloading"); - let pageData = await this.fs.readPage(currentPageName); - this.openPages.set( - newPageMeta.name, - new PageState(this.createEditorState(pageData.text), 0, newPageMeta) - ); - await this.loadPage(currentPageName); + if (this.currentPage && this.viewState.isSaved) { + await this.checkForNewVersion(this.currentPage); } }); }, watchInterval); } + async checkForNewVersion(cachedMeta: PageMeta) { + const currentPageName = cachedMeta.name; + let newPageMeta = await this.space.getPageMeta(currentPageName); + if ( + cachedMeta.lastModified.getTime() !== newPageMeta.lastModified.getTime() + ) { + console.log("File changed on disk, reloading"); + let pageData = await this.space.readPage(currentPageName); + this.openPages.set( + newPageMeta.name, + new PageState(this.createEditorState(pageData.text), 0, newPageMeta) + ); + await this.loadPage(currentPageName); + } + } + focus() { this.editorView!.focus(); } async navigate(name: string) { - location.hash = encodeURIComponent(name); - } - - hashChange() { - Promise.resolve() - .then(async () => { - await this.save(); - const pageName = decodeURIComponent(location.hash.substring(1)); - console.log("Now navigating to", pageName); - - if (!this.editorView) { - return; - } - - await this.loadPage(pageName); - }) - .catch((e) => { - console.error(e); - }); + await this.pageNavigator.navigate(name); } async loadPage(pageName: string) { let pageState = this.openPages.get(pageName); if (!pageState) { - let pageData = await this.fs.readPage(pageName); + let pageData = await this.space.readPage(pageName); pageState = new PageState( this.createEditorState(pageData.text), 0, pageData.meta ); this.openPages.set(pageName, pageState!); + } else { + // Loaded page from in-mory cache, let's async see if this page hasn't been updated + this.checkForNewVersion(pageState.meta).catch((e) => { + console.error("Failed to check for new version"); + }); } this.editorView!.setState(pageState!.editorState); this.editorView!.scrollDOM.scrollTop = pageState!.scrollTop; @@ -459,16 +460,15 @@ export class Editor { type: "page-loaded", meta: pageState.meta, }); - } - addListeners() { - this.$hashChange = this.hashChange.bind(this); - window.addEventListener("hashchange", this.$hashChange); - } - - dispose() { - if (this.$hashChange) { - window.removeEventListener("hashchange", this.$hashChange); + let indexerPageMeta = await this.indexer.getPageIndexPageMeta(pageName); + if ( + (indexerPageMeta && + pageState.meta.lastModified.getTime() !== + indexerPageMeta.lastModified.getTime()) || + !indexerPageMeta + ) { + await this.indexPage(pageState.editorState.sliceDoc(), pageState.meta); } } @@ -477,12 +477,6 @@ export class Editor { this.viewState = viewState; this.viewDispatch = dispatch; - useEffect(() => { - if (!location.hash) { - this.navigate("start"); - } - }, []); - // Auto save useEffect(() => { const id = setTimeout(() => { @@ -508,18 +502,14 @@ export class Editor { {viewState.showPageNavigator && ( { dispatch({ type: "stop-navigate" }); - editor!.focus(); + editor.focus(); if (page) { - editor - ?.save() - .then(() => { - editor!.navigate(page); - }) - .catch((e) => { - alert("Could not save page, not switching"); - }); + safeRun(async () => { + editor.navigate(page); + }); } }} /> diff --git a/webapp/src/indexer.ts b/webapp/src/indexer.ts new file mode 100644 index 00000000..4b43c366 --- /dev/null +++ b/webapp/src/indexer.ts @@ -0,0 +1,155 @@ +import { Dexie, Table } from "dexie"; +import { AppEventDispatcher, IndexEvent } from "./app_event"; +import { Space } from "./space"; +import { PageMeta } from "./types"; + +function constructKey(pageName: string, key: string): string { + return `${pageName}:${key}`; +} + +function cleanKey(pageName: string, fromKey: string): string { + return fromKey.substring(pageName.length + 1); +} + +export type KV = { + key: string; + value: any; +}; + +export class Indexer { + db: Dexie; + pageIndex: Table; + space: Space; + + constructor(name: string, space: Space) { + this.db = new Dexie(name); + this.space = space; + this.db.version(1).stores({ + pageIndex: "ck, page, key", + }); + this.pageIndex = this.db.table("pageIndex"); + } + + async clearPageIndexForPage(pageName: string) { + await this.pageIndex.where({ page: pageName }).delete(); + } + + async clearPageIndex() { + await this.pageIndex.clear(); + } + + async setPageIndexPageMeta(pageName: string, meta: PageMeta) { + await this.set(pageName, "$meta", { + lastModified: meta.lastModified.getTime(), + }); + } + + async getPageIndexPageMeta(pageName: string): Promise { + let meta = await this.get(pageName, "$meta"); + if (meta) { + return { + name: pageName, + lastModified: new Date(meta.lastModified), + }; + } else { + return null; + } + } + + async indexPage( + appEventDispatcher: AppEventDispatcher, + pageMeta: PageMeta, + text: string, + withFlush: boolean + ) { + if (withFlush) { + await this.clearPageIndexForPage(pageMeta.name); + } + let indexEvent: IndexEvent = { + name: pageMeta.name, + text, + }; + await appEventDispatcher.dispatchAppEvent("page:index", indexEvent); + await this.setPageIndexPageMeta(pageMeta.name, pageMeta); + } + + async reindexSpace(space: Space, appEventDispatcher: AppEventDispatcher) { + await this.clearPageIndex(); + let allPages = await space.listPages(); + // TODO: Parallelize? + for (let page of allPages) { + let pageData = await space.readPage(page.name); + await this.indexPage( + appEventDispatcher, + pageData.meta, + pageData.text, + false + ); + } + } + + async set(pageName: string, key: string, value: any) { + await this.pageIndex.put({ + ck: constructKey(pageName, key), + page: pageName, + key: key, + value: value, + }); + } + + async batchSet(pageName: string, kvs: KV[]) { + await this.pageIndex.bulkPut( + kvs.map(({ key, value }) => ({ + ck: constructKey(pageName, key), + key: key, + page: pageName, + value: value, + })) + ); + } + + async get(pageName: string, key: string): Promise { + let result = await this.pageIndex.get({ + ck: constructKey(pageName, key), + }); + return result ? result.value : null; + } + + async scanPrefixForPage( + pageName: string, + keyPrefix: string + ): Promise<{ key: string; value: any }[]> { + let results = await this.pageIndex + .where("ck") + .startsWith(constructKey(pageName, keyPrefix)) + .toArray(); + return results.map((result) => ({ + key: cleanKey(pageName, result.key), + value: result.value, + })); + } + + async scanPrefixGlobal( + keyPrefix: string + ): Promise<{ key: string; value: any }[]> { + let results = await this.pageIndex + .where("key") + .startsWith(keyPrefix) + .toArray(); + return results.map((result) => ({ + key: result.key, + value: result.value, + })); + } + + async deletePrefixForPage(pageName: string, keyPrefix: string) { + await this.pageIndex + .where("ck") + .startsWith(constructKey(pageName, keyPrefix)) + .delete(); + } + + async delete(pageName: string, key: string) { + await this.pageIndex.delete(constructKey(pageName, key)); + } +} diff --git a/webapp/src/navigator.ts b/webapp/src/navigator.ts new file mode 100644 index 00000000..863b7d98 --- /dev/null +++ b/webapp/src/navigator.ts @@ -0,0 +1,71 @@ +import { safeRun } from "./util"; + +export interface IPageNavigator { + subscribe(pageLoadCallback: (pageName: string) => Promise): void; + navigate(page: string): void; + getCurrentPage(): string; +} + +function encodePageUrl(name: string): string { + return name.replaceAll(" ", "_"); +} + +function decodePageUrl(url: string): string { + return url.replaceAll("_", " "); +} + +export class PathPageNavigator implements IPageNavigator { + navigationResolve?: (value: undefined) => void; + async navigate(page: string) { + console.log("Pushing state", page); + window.history.pushState({ page: page }, page, `/${encodePageUrl(page)}`); + window.dispatchEvent(new PopStateEvent("popstate")); + await new Promise((resolve) => { + this.navigationResolve = resolve; + }); + this.navigationResolve = undefined; + } + subscribe(pageLoadCallback: (pageName: string) => Promise): void { + const cb = () => { + console.log("State popped", this.getCurrentPage()); + safeRun(async () => { + await pageLoadCallback(this.getCurrentPage()); + if (this.navigationResolve) { + this.navigationResolve(undefined); + } + }); + }; + window.addEventListener("popstate", cb); + cb(); + } + + getCurrentPage(): string { + return decodePageUrl(location.pathname.substring(1)); + } +} + +export class HashPageNavigator implements IPageNavigator { + navigationResolve?: (value: undefined) => void; + async navigate(page: string) { + location.hash = encodePageUrl(page); + await new Promise((resolve) => { + this.navigationResolve = resolve; + }); + this.navigationResolve = undefined; + } + subscribe(pageLoadCallback: (pageName: string) => Promise): void { + const cb = () => { + safeRun(async () => { + await pageLoadCallback(this.getCurrentPage()); + if (this.navigationResolve) { + this.navigationResolve(undefined); + } + }); + }; + window.addEventListener("hashchange", cb); + cb(); + } + getCurrentPage(): string { + return decodePageUrl(location.hash.substring(1)); + } +} diff --git a/webapp/src/parser.ts b/webapp/src/parser.ts index 63c0211c..f7c297ba 100644 --- a/webapp/src/parser.ts +++ b/webapp/src/parser.ts @@ -2,6 +2,11 @@ import { styleTags } from "@codemirror/highlight"; import { MarkdownConfig, TaskList } from "@lezer/markdown"; import { commonmark, mkLang } from "./markdown/markdown"; import * as ct from "./customtags"; +import { pageLinkRegex } from "./constant"; + +const pageLinkRegexPrefix = new RegExp( + "^" + pageLinkRegex.toString().slice(1, -1) +); const WikiLink: MarkdownConfig = { defineNodes: ["WikiLink", "WikiLinkPage"], @@ -12,13 +17,13 @@ const WikiLink: MarkdownConfig = { let match: RegExpMatchArray | null; if ( next != 91 /* '[' */ || - !(match = /^\[[^\]]+\]\]/.exec(cx.slice(pos + 1, cx.end))) + !(match = pageLinkRegexPrefix.exec(cx.slice(pos, cx.end))) ) { return -1; } return cx.addElement( cx.elt("WikiLink", pos, pos + match[0].length + 1, [ - cx.elt("WikiLinkPage", pos + 2, pos + match[0].length - 1), + cx.elt("WikiLinkPage", pos + 2, pos + match[0].length - 2), ]) ); }, diff --git a/webapp/src/reducer.ts b/webapp/src/reducer.ts index fd7861ec..b9f2932d 100644 --- a/webapp/src/reducer.ts +++ b/webapp/src/reducer.ts @@ -9,6 +9,11 @@ export default function reducer( case "page-loaded": return { ...state, + allPages: state.allPages.map((pageMeta) => + pageMeta.name === action.meta.name + ? { ...pageMeta, lastOpened: new Date() } + : pageMeta + ), currentPage: action.meta, isSaved: true, }; diff --git a/webapp/src/space.ts b/webapp/src/space.ts index 35de6eec..0eb03be0 100644 --- a/webapp/src/space.ts +++ b/webapp/src/space.ts @@ -4,6 +4,7 @@ export interface Space { listPages(): Promise; readPage(name: string): Promise<{ text: string; meta: PageMeta }>; writePage(name: string, text: string): Promise; + deletePage(name: string): Promise; getPageMeta(name: string): Promise; } @@ -12,6 +13,7 @@ export class HttpRemoteSpace implements Space { constructor(url: string) { this.url = url; } + async listPages(): Promise { let req = await fetch(this.url, { method: "GET", @@ -22,6 +24,7 @@ export class HttpRemoteSpace implements Space { lastModified: new Date(meta.lastModified), })); } + async readPage(name: string): Promise<{ text: string; meta: PageMeta }> { let req = await fetch(`${this.url}/${name}`, { method: "GET", @@ -34,6 +37,7 @@ export class HttpRemoteSpace implements Space { }, }; } + async writePage(name: string, text: string): Promise { let req = await fetch(`${this.url}/${name}`, { method: "PUT", @@ -47,6 +51,15 @@ export class HttpRemoteSpace implements Space { }; } + async deletePage(name: string): Promise { + let req = await fetch(`${this.url}/${name}`, { + method: "DELETE", + }); + if (req.status !== 200) { + throw Error(`Failed to delete page: ${req.statusText}`); + } + } + async getPageMeta(name: string): Promise { let req = await fetch(`${this.url}/${name}`, { method: "OPTIONS", diff --git a/webapp/src/syscalls/editor.browser.ts b/webapp/src/syscalls/editor.browser.ts index 5d81d479..144fa3f1 100644 --- a/webapp/src/syscalls/editor.browser.ts +++ b/webapp/src/syscalls/editor.browser.ts @@ -1,6 +1,7 @@ import { Editor } from "../editor"; import { syntaxTree } from "@codemirror/language"; import { Transaction } from "@codemirror/state"; +import { PageMeta } from "../types"; type SyntaxNode = { name: string; @@ -26,6 +27,9 @@ function ensureAnchor(expr: any, start: boolean) { } export default (editor: Editor) => ({ + "editor.getCurrentPage": (): PageMeta => { + return editor.currentPage!; + }, "editor.getText": () => { return editor.editorView?.state.sliceDoc(); }, @@ -120,4 +124,7 @@ export default (editor: Editor) => ({ "editor.dispatch": (change: Transaction) => { editor.editorView!.dispatch(change); }, + "editor.prompt": (message: string): string | null => { + return prompt(message); + }, }); diff --git a/webapp/src/syscalls/indexer.native.ts b/webapp/src/syscalls/indexer.native.ts new file mode 100644 index 00000000..742c11e7 --- /dev/null +++ b/webapp/src/syscalls/indexer.native.ts @@ -0,0 +1,22 @@ +import { Indexer, KV } from "../indexer"; + +export default (indexer: Indexer) => ({ + "indexer.scanPrefixForPage": async (pageName: string, keyPrefix: string) => { + return await indexer.scanPrefixForPage(pageName, keyPrefix); + }, + "indexer.scanPrefixGlobal": async (keyPrefix: string) => { + return await indexer.scanPrefixGlobal(keyPrefix); + }, + "indexer.get": async (pageName: string, key: string): Promise => { + return await indexer.get(pageName, key); + }, + "indexer.set": async (pageName: string, key: string, value: any) => { + await indexer.set(pageName, key, value); + }, + "indexer.batchSet": async (pageName: string, kvs: KV[]) => { + await indexer.batchSet(pageName, kvs); + }, + "indexer.delete": async (pageName: string, key: string) => { + await indexer.delete(pageName, key); + }, +}); diff --git a/webapp/src/syscalls/space.native.ts b/webapp/src/syscalls/space.native.ts index 983e16ed..56dd3126 100644 --- a/webapp/src/syscalls/space.native.ts +++ b/webapp/src/syscalls/space.native.ts @@ -5,12 +5,30 @@ export default (editor: Editor) => ({ "space.listPages": (): PageMeta[] => { return editor.viewState.allPages; }, + "space.reloadPageList": async () => { + await editor.loadPageList(); + }, + "space.reindex": async () => { + await editor.indexer.reindexSpace(editor.space, editor); + }, "space.readPage": async ( name: string ): Promise<{ text: string; meta: PageMeta }> => { - return await editor.fs.readPage(name); + return await editor.space.readPage(name); }, "space.writePage": async (name: string, text: string): Promise => { - return await editor.fs.writePage(name, text); + return await editor.space.writePage(name, text); + }, + "space.deletePage": async (name: string) => { + console.log("Clearing page index", name); + await editor.indexer.clearPageIndexForPage(name); + // If we're deleting the current page, navigate to the start page + if (editor.currentPage?.name === name) { + await editor.navigate("start"); + } + // Remove page from open pages in editor + editor.openPages.delete(name); + console.log("Deleting page"); + await editor.space.deletePage(name); }, }); diff --git a/webapp/src/types.ts b/webapp/src/types.ts index 4d087db4..eac95c14 100644 --- a/webapp/src/types.ts +++ b/webapp/src/types.ts @@ -4,6 +4,7 @@ export type PageMeta = { name: string; lastModified: Date; created?: boolean; + lastOpened?: Date; }; export type AppCommand = { diff --git a/webapp/yarn.lock b/webapp/yarn.lock index ec2cbaac..01039e46 100644 --- a/webapp/yarn.lock +++ b/webapp/yarn.lock @@ -1186,6 +1186,11 @@ detect-libc@^1.0.3: resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" integrity sha1-+hN8S9aY7fVc1c0CrFWfkaTEups= +dexie@^3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/dexie/-/dexie-3.2.1.tgz#ef21456d725e700c1ab7ac4307896e4fdabaf753" + integrity sha512-Y8oz3t2XC9hvjkP35B5I8rUkKKwM36GGRjWQCMjzIYScg7W+GHKDXobSYswkisW7CxL1/tKQtggMDsiWqDUc1g== + dom-serializer@^1.0.1: version "1.3.2" resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-1.3.2.tgz#6206437d32ceefaec7161803230c7a20bc1b4d91"