import { plugPrefix } from "../lib/spaces/constants.ts"; import type { EventHook } from "../web/hooks/event.ts"; import { jitter, safeRun } from "../lib/async.ts"; import { localDateString } from "../lib/dates.ts"; import type { DocumentMeta, FileMeta, PageMeta } from "../type/index.ts"; import type { SpacePrimitives } from "../lib/spaces/space_primitives.ts"; import { getPathExtension, type Path, } from "@silverbulletmd/silverbullet/lib/ref"; const pageWatchInterval = 3000; // + jitter export class Space { // We do watch files in the background to detect changes // This set of pages should only ever contain 1 page watchedFiles = new Set(); watchInterval?: number; // private initialPageListLoad = true; private saving = false; constructor( readonly spacePrimitives: SpacePrimitives, eventHook: EventHook, ) { eventHook.addLocalListener("file:deleted", (fileName: string) => { if (this.watchedFiles.has(fileName)) { // Stop watching deleted files already this.watchedFiles.delete(fileName); } }); setTimeout(() => { // Next tick, to ensure that the space is initialized this.updatePageList().catch(console.error); }); } public async updatePageList() { // The only reason to do this is to trigger events await this.spacePrimitives.fetchFileList(); } async deletePage(name: string): Promise { await this.getPageMeta(name); // Check if page exists, if not throws Error await this.spacePrimitives.deleteFile(`${name}.md`); } async getPageMeta(name: string): Promise { return fileMetaToPageMeta( await this.spacePrimitives.getFileMeta(`${name}.md`), ); } async listPlugs(): Promise { const files = await this.deduplicatedFileList(); return files .filter((fileMeta) => fileMeta.name.startsWith(plugPrefix) && fileMeta.name.endsWith(".plug.js") ); } async readPage(name: string): Promise<{ text: string; meta: PageMeta }> { const pageData = await this.spacePrimitives.readFile(`${name}.md`); return { text: new TextDecoder().decode(pageData.data), meta: fileMetaToPageMeta(pageData.meta), }; } async writePage( name: string, text: string, ): Promise { try { this.saving = true; const pageMeta = fileMetaToPageMeta( await this.spacePrimitives.writeFile( `${name}.md`, new TextEncoder().encode(text), ), ); // Note: we don't do very elaborate cache invalidation work here, quite quickly the cache will be flushed anyway return pageMeta; } finally { this.saving = false; } } // We're listing all pages that don't start with a _ isListedPage(fileMeta: FileMeta): boolean { return fileMeta.name.endsWith(".md") && !fileMeta.name.startsWith("_"); } async fetchPageList(): Promise { return (await this.deduplicatedFileList()) .filter(this.isListedPage) .map(fileMetaToPageMeta); } async fetchDocumentList(): Promise { return (await this.deduplicatedFileList()).flatMap((fileMeta) => !this.isListedPage(fileMeta) && !fileMeta.name.endsWith(".plug.js") ? [fileMetaToDocumentMeta(fileMeta)] : [] ); } async deduplicatedFileList(): Promise { const files = await this.spacePrimitives.fetchFileList(); const fileMap = new Map(); for (const file of files) { if (fileMap.has(file.name)) { const existing = fileMap.get(file.name)!; if (existing.lastModified < file.lastModified) { fileMap.set(file.name, file); } } else { fileMap.set(file.name, file); } } return [...fileMap.values()]; } /** * Reads a document * @param name path of the document * @returns */ async readDocument( name: string, ): Promise<{ data: Uint8Array; meta: DocumentMeta }> { const file = await this.spacePrimitives.readFile(name); return { data: file.data, meta: fileMetaToDocumentMeta(file.meta) }; } async getDocumentMeta(name: string): Promise { return fileMetaToDocumentMeta( await this.spacePrimitives.getFileMeta(name), ); } async writeDocument( name: string, data: Uint8Array, ): Promise { return fileMetaToDocumentMeta( await this.spacePrimitives.writeFile(name, data), ); } deleteDocument(name: string): Promise { return this.spacePrimitives.deleteFile(name); } // Even though changes coming from a sync cycle will immediately trigger a reload // there are scenarios in which other tabs run the sync, so we have to poll for changes watch() { if (this.watchInterval) { clearInterval(this.watchInterval); } this.watchInterval = setInterval(() => { safeRun(async () => { if (this.saving) { return; } for (const fileName of this.watchedFiles) { // Setting observing to true here to hint that we may be interested in more active syncing await this.spacePrimitives.getFileMeta(fileName, true); } }); }, pageWatchInterval + jitter()); } unwatch() { if (this.watchInterval) { clearInterval(this.watchInterval); } } watchFile(fileName: string) { this.watchedFiles.add(fileName); } unwatchFile(fileName: string) { this.watchedFiles.delete(fileName); } } export function fileMetaToPageMeta(fileMeta: FileMeta): PageMeta { const name = fileMeta.name.substring(0, fileMeta.name.length - 3); try { return { ...fileMeta, ref: name, tag: "page", tags: [], name, created: localDateString(new Date(fileMeta.created)), lastModified: localDateString(new Date(fileMeta.lastModified)), } as PageMeta; } catch (e) { console.error("Failed to convert fileMeta to pageMeta", fileMeta, e); throw e; } } export function fileMetaToDocumentMeta( fileMeta: FileMeta, ): DocumentMeta { try { return { ...fileMeta, ref: fileMeta.name, tag: "document", created: localDateString(new Date(fileMeta.created)), lastModified: localDateString(new Date(fileMeta.lastModified)), // Name is always equal to the path for documents extension: getPathExtension(fileMeta.name as Path), } as DocumentMeta; } catch (e) { console.error("Failed to convert fileMeta to documentMeta", fileMeta, e); throw e; } }