/** * Expose apis that ipc sync adaptor will use */ import fs from 'fs-extra'; import omit from 'lodash/omit'; import path from 'path'; import { Observable } from 'rxjs'; import type { IChangedTiddlers, ITiddlerFields, ITiddlyWiki, OutputMimeTypes } from 'tiddlywiki'; export interface IWikiServerStatusObject { anonymous: boolean; read_only: boolean; space: { recipe: string; }; tiddlywiki_version: string; username: string; } export interface IWikiServerRouteResponse { data?: string | Buffer | Array> | IWikiServerStatusObject | ITiddlerFields; headers?: Record; statusCode?: number; } export class IpcServerRoutes { private wikiInstance!: ITiddlyWiki; private readonly pendingIpcServerRoutesRequests: Array<(value: void | PromiseLike) => void> = []; #readonlyMode = false; /** Track tiddlers that were just saved via IPC to prevent echo */ private readonly recentlySavedTiddlers = new Set(); setConfig({ readOnlyMode }: { readOnlyMode?: boolean }) { this.#readonlyMode = Boolean(readOnlyMode); } setWikiInstance(wikiInstance: ITiddlyWiki) { this.wikiInstance = wikiInstance; this.pendingIpcServerRoutesRequests.forEach((resolve) => { resolve(); }); } private async waitForIpcServerRoutesAvailable() { if (this.wikiInstance !== undefined) { return; } await new Promise((resolve) => { this.pendingIpcServerRoutesRequests.push(resolve); }); } // ████████ ██ ██████ ██████ ██ ██ ██ ██ ██ ███████ ██████ // ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ // ██ ██ ██ ██ ██ ██ ██ ████ █████ ██ █ ██ █████ ██████ // ██ ██ ██ ██ ██ ██ ██ ██ ██ ███ ██ ██ ██ ██ // ██ ██ ██████ ██████ ███████ ██ ███ ███ ███████ ██████ async deleteTiddler(title: string): Promise { await this.waitForIpcServerRoutesAvailable(); this.wikiInstance.wiki.deleteTiddler(title); return { headers: { 'Content-Type': 'text/plain' }, data: 'OK', statusCode: 204 }; } async getFavicon(): Promise { await this.waitForIpcServerRoutesAvailable(); const buffer = this.wikiInstance.wiki.getTiddlerText('$:/favicon.ico', ''); return { headers: { 'Content-Type': 'image/x-icon; charset=base64' }, data: buffer, statusCode: 200 }; } async getFile(suppliedFilename: string): Promise { await this.waitForIpcServerRoutesAvailable(); if (this.wikiInstance.boot.wikiPath === undefined) { return { statusCode: 404, headers: { 'Content-Type': 'text/plain' }, data: `$tw.wiki.boot.wikiPath === undefined.` }; } const baseFilename = path.resolve(this.wikiInstance.boot.wikiPath, 'files'); const filename = path.resolve(baseFilename, suppliedFilename); const extension = path.extname(filename); if (path.relative(baseFilename, filename).indexOf('..') === 0) { return { statusCode: 404, headers: { 'Content-Type': 'text/plain' }, data: `File ${suppliedFilename} not found` }; } else { // Send the file try { const data = await fs.readFile(filename); const type = this.wikiInstance.config.fileExtensionInfo[extension] ? this.wikiInstance.config.fileExtensionInfo[extension].type : 'application/octet-stream'; return ({ statusCode: 200, headers: { 'Content-Type': type }, data }); } catch (error) { return { statusCode: 404, headers: { 'Content-Type': 'text/plain' }, data: `Error accessing file ${suppliedFilename} with error: ${(error as Error).toString()}` }; } } } async getIndex(rootTiddler: string): Promise { await this.waitForIpcServerRoutesAvailable(); const wikiHTML = this.wikiInstance.wiki.renderTiddler('text/plain', rootTiddler); return { statusCode: 200, headers: { 'Content-Type': 'text/html' }, data: wikiHTML }; } async getStatus(userName: string): Promise { await this.waitForIpcServerRoutesAvailable(); const data: IWikiServerStatusObject = { username: userName, anonymous: false, read_only: this.#readonlyMode, space: { recipe: 'default', }, tiddlywiki_version: this.wikiInstance.version, }; return { statusCode: 200, headers: { 'Content-Type': 'application/json' }, data }; } async getTiddler(title: string): Promise { await this.waitForIpcServerRoutesAvailable(); const tiddler = this.wikiInstance.wiki.getTiddler(title); if (tiddler === undefined) { return { statusCode: 404, headers: { 'Content-Type': 'text/plain' }, data: `Tiddler "${title}" not exist` }; } const tiddlerFields = { ...tiddler.fields }; // only add revision if it > 0 or exists if (this.wikiInstance.wiki.getChangeCount(title)) { tiddlerFields.revision = String(this.wikiInstance.wiki.getChangeCount(title)); } tiddlerFields.bag = 'default'; tiddlerFields.type = tiddlerFields.type ?? 'text/vnd.tiddlywiki'; return { statusCode: 200, headers: { 'Content-Type': 'application/json; charset=utf8' }, data: tiddlerFields as ITiddlerFields }; } async getTiddlersJSON( filter = '[all[tiddlers]!is[system]sort[title]]', excludeFields = ['text'], options?: { ignoreSyncSystemConfig?: boolean; toTiddler?: boolean }, ): Promise { await this.waitForIpcServerRoutesAvailable(); if (!(options?.ignoreSyncSystemConfig === true) && this.wikiInstance.wiki.getTiddlerText('$:/config/SyncSystemTiddlersFromServer') !== 'yes') { filter += '+[!is[system]]'; } const titles = this.wikiInstance.wiki.filterTiddlers(filter); const tiddlers: Array> = options?.toTiddler === false ? titles.map(title => ({ title })) : titles.map(title => { const tiddler = this.wikiInstance.wiki.getTiddler(title); if (tiddler === undefined) { return tiddler; } const tiddlerFields = omit(tiddler.fields, excludeFields) as Record; // only add revision if it > 0 or exists if (this.wikiInstance.wiki.getChangeCount(title)) { tiddlerFields.revision = String(this.wikiInstance.wiki.getChangeCount(title)); } tiddlerFields.type = tiddlerFields.type ?? 'text/vnd.tiddlywiki'; return tiddlerFields as Omit; }) .filter((item): item is Omit => item !== undefined); return { statusCode: 200, headers: { 'Content-Type': 'application/json' }, data: tiddlers }; } async putTiddler(title: string, fields: ITiddlerFields): Promise { await this.waitForIpcServerRoutesAvailable(); const tiddlerFieldsToPut = omit(fields, ['fields', 'revision', '_is_skinny']) as Record; // Pull up any subfields in the `fields` object // we skip this part by not creating the fields object at the beginning at src\services\wiki\plugin\ipcSyncAdaptor\index.ts // if ('fields' in fields) { // tiddlerFieldsToPut = { // ...fields.fields as Record, // }; // } // tiddlerFieldsToPut = { // ...tiddlerFieldsToPut, // // Remove any revision field // ...omit(fields, ['fields', 'revision', '_is_skinny']) as Record, // }; // If this is a skinny tiddler, it means the client never got the full // version of the tiddler to edit. So we must preserve whatever text // already exists on the server, or else we'll inadvertently delete it. if (fields._is_skinny !== undefined) { const tiddler = this.wikiInstance.wiki.getTiddler(title); if (tiddler !== undefined) { tiddlerFieldsToPut.text = tiddler.fields.text; } } tiddlerFieldsToPut.title = title; // Mark this tiddler as recently saved to prevent echo this.recentlySavedTiddlers.add(title); this.wikiInstance.wiki.addTiddler(new this.wikiInstance.Tiddler(tiddlerFieldsToPut)); // Note: The change event is triggered synchronously by addTiddler // The event handler in getWikiChangeObserver$ will check recentlySavedTiddlers // and remove the mark after filtering const changeCount = this.wikiInstance.wiki.getChangeCount(title).toString(); return { statusCode: 204, headers: { 'Content-Type': 'text/plain', Etag: `"default/${encodeURIComponent(title)}/${changeCount}:"` }, data: 'OK' }; } async getTiddlerHtml(title: string): Promise { await this.waitForIpcServerRoutesAvailable(); const tiddler = this.wikiInstance.wiki.getTiddler(title); if (tiddler === undefined) { return { statusCode: 404, headers: { 'Content-Type': 'text/plain' }, data: `Tiddler "${title}" not exist` }; } else { let renderType: OutputMimeTypes = tiddler.getFieldString('_render_type') as OutputMimeTypes; let renderTemplate: string = tiddler.getFieldString('_render_template'); // Tiddler fields '_render_type' and '_render_template' overwrite // system wide settings for render type and template if (this.wikiInstance.wiki.isSystemTiddler(title)) { renderType = renderType ?? /* this.wikiInstance.server.get('system-tiddler-render-type') ?? */ 'text/plain'; renderTemplate = renderTemplate ?? /* this.wikiInstance.server.get('system-tiddler-render-template') ?? */ '$:/core/templates/wikified-tiddler'; } else { renderType = renderType ?? /* this.wikiInstance.server.get('tiddler-render-type') ?? */ 'text/html'; renderTemplate = renderTemplate ?? /* this.wikiInstance.server.get('tiddler-render-template') ?? */ '$:/core/templates/server/static.tiddler.html'; } const text = this.wikiInstance.wiki.renderTiddler(renderType, renderTemplate, { parseAsInline: true, variables: { currentTiddler: title } }); // Naughty not to set a content-type, but it's the easiest way to ensure the browser will see HTML pages as HTML, and accept plain text tiddlers as CSS or JS return { statusCode: 200, headers: { 'Content-Type': '; charset=utf8' }, data: text }; } } // ████████ ██ ██ ███████ ███████ ███████ // ██ ██ ██ ██ ██ ██ // ██ ██ █ ██ █████ ███████ ███████ █████ // ██ ██ ███ ██ ██ ██ ██ // ██ ███ ███ ███████ ███████ ███████ getWikiChangeObserver() { return new Observable((observer) => { const getWikiChangeObserverInWorkerIIFE = async () => { await this.waitForIpcServerRoutesAvailable(); if (this.wikiInstance === undefined) { observer.error(new Error(`this.wikiInstance is undefined, maybe something went wrong between waitForIpcServerRoutesAvailable and return new Observable.`)); } this.wikiInstance.wiki.addEventListener('change', (changes) => { // Filter out tiddlers that were just saved via IPC to prevent echo const filteredChanges: IChangedTiddlers = {}; let hasChanges = false; for (const title in changes) { if (this.recentlySavedTiddlers.has(title)) { // This change was caused by our own putTiddler, skip it to prevent echo this.recentlySavedTiddlers.delete(title); continue; } filteredChanges[title] = changes[title]; hasChanges = true; } // Only notify if there are actual changes after filtering if (hasChanges) { observer.next(filteredChanges); } }); // Log SSE ready every time a new observer subscribes (including after worker restart) // Include timestamp to make each log entry unique for test detection const timestamp = new Date().toISOString(); console.debug(`[test-id-SSE_READY] Wiki change observer registered and ready at ${timestamp}`); }; void getWikiChangeObserverInWorkerIIFE(); }); } } export const ipcServerRoutes: IpcServerRoutes = new IpcServerRoutes(); export const ipcServerRoutesMethods = { deleteTiddler: ipcServerRoutes.deleteTiddler.bind(ipcServerRoutes), getFavicon: ipcServerRoutes.getFavicon.bind(ipcServerRoutes), getIndex: ipcServerRoutes.getIndex.bind(ipcServerRoutes), getStatus: ipcServerRoutes.getStatus.bind(ipcServerRoutes), getTiddler: ipcServerRoutes.getTiddler.bind(ipcServerRoutes), getTiddlerHtml: ipcServerRoutes.getTiddlerHtml.bind(ipcServerRoutes), getTiddlersJSON: ipcServerRoutes.getTiddlersJSON.bind(ipcServerRoutes), putTiddler: ipcServerRoutes.putTiddler.bind(ipcServerRoutes), getFile: ipcServerRoutes.getFile.bind(ipcServerRoutes), getWikiChangeObserver: ipcServerRoutes.getWikiChangeObserver.bind(ipcServerRoutes), }; /** * Available methods for ipcServerRoutes exposed from wiki worker */ export type IpcServerRouteMethods = Omit; export type IpcServerRouteNames = keyof IpcServerRouteMethods;