mirror of
https://github.com/tiddly-gittly/TidGi-Desktop.git
synced 2026-01-24 05:21:02 -08:00
327 lines
12 KiB
TypeScript
327 lines
12 KiB
TypeScript
import type { Logger } from '$:/core/modules/utils/logger.js';
|
|
import type { IWikiServerStatusObject } from '@services/wiki/wikiWorker/ipcServerRoutes';
|
|
import type { WindowMeta, WindowNames } from '@services/windows/WindowProperties';
|
|
import debounce from 'lodash/debounce';
|
|
import type { IChangedTiddlers, ITiddlerFields, Syncer, Tiddler, Wiki } from 'tiddlywiki';
|
|
|
|
type ISyncAdaptorGetStatusCallback = (error: Error | null, isLoggedIn?: boolean, username?: string, isReadOnly?: boolean, isAnonymous?: boolean) => void;
|
|
type ISyncAdaptorGetTiddlersJSONCallback = (error: Error | null, tiddler?: Array<Omit<ITiddlerFields, 'text'>>) => void;
|
|
type ISyncAdaptorPutTiddlersCallback = (error: Error | null | string, etag?: {
|
|
bag: string;
|
|
}, version?: string) => void;
|
|
type ISyncAdaptorLoadTiddlerCallback = (error: Error | null, tiddler?: ITiddlerFields) => void;
|
|
type ISyncAdaptorDeleteTiddlerCallback = (error: Error | null, adaptorInfo?: { bag?: string } | null) => void;
|
|
|
|
class TidGiIPCSyncAdaptor {
|
|
name = 'tidgi-ipc';
|
|
supportsLazyLoading = true;
|
|
wiki: Wiki;
|
|
hasStatus: boolean;
|
|
logger: Logger;
|
|
isLoggedIn: boolean;
|
|
isAnonymous: boolean;
|
|
isReadOnly: boolean;
|
|
logoutIsAvailable: boolean;
|
|
wikiService: typeof window.service.wiki;
|
|
workspaceService: typeof window.service.workspace;
|
|
authService: typeof window.service.auth;
|
|
workspaceID: string;
|
|
recipe?: string;
|
|
|
|
constructor(options: { wiki: Wiki }) {
|
|
this.wiki = options.wiki;
|
|
this.wikiService = window.service.wiki;
|
|
this.workspaceService = window.service.workspace;
|
|
this.authService = window.service.auth;
|
|
this.hasStatus = false;
|
|
this.isAnonymous = false;
|
|
this.logger = new $tw.utils.Logger('TidGiIPCSyncAdaptor');
|
|
this.isLoggedIn = false;
|
|
this.isReadOnly = false;
|
|
this.logoutIsAvailable = true;
|
|
const workspaceID = (window.meta() as WindowMeta[WindowNames.view]).workspace?.id;
|
|
if (workspaceID === undefined) {
|
|
throw new Error('TidGiIPCSyncAdaptor: workspaceID is undefined. Cannot initialize sync adaptor without a valid workspace ID.');
|
|
}
|
|
this.workspaceID = workspaceID;
|
|
if (window.observables?.wiki?.getWikiChangeObserver$ !== undefined) {
|
|
// if install-electron-ipc-cat is faster than us, just subscribe to the observable. Otherwise we normally will wait for it to call us here.
|
|
this.setupSSE();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* This should be called after install-electron-ipc-cat, so this is called in `$:/plugins/linonetwo/tidgi-ipc-syncadaptor/Startup/install-electron-ipc-cat.js`
|
|
*/
|
|
setupSSE() {
|
|
console.log('setupSSE called in TidGiIPCSyncAdaptor');
|
|
if (window.observables?.wiki?.getWikiChangeObserver$ === undefined) {
|
|
console.error("getWikiChangeObserver$ is undefined in window.observables.wiki, can't subscribe to server changes.");
|
|
return;
|
|
}
|
|
const debouncedSync = debounce(() => {
|
|
if ($tw.syncer === undefined) {
|
|
console.error('Syncer is undefined in TidGiIPCSyncAdaptor. Abort the `syncFromServer` in `setupSSE debouncedSync`.');
|
|
return;
|
|
}
|
|
$tw.syncer.syncFromServer();
|
|
this.clearUpdatedTiddlers();
|
|
}, 500);
|
|
this.logger.log('setupSSE');
|
|
|
|
// After SSE is enabled, we can disable polling and else things that related to syncer. (build up complexer behavior with syncer.)
|
|
this.configSyncer();
|
|
|
|
window.observables.wiki.getWikiChangeObserver$(this.workspaceID).subscribe((change: IChangedTiddlers) => {
|
|
// `$tw.syncer.syncFromServer` calling `this.getUpdatedTiddlers`, so we need to update `this.updatedTiddlers` before it do so. See `core/modules/syncer.js` in the core
|
|
Object.keys(change).forEach(title => {
|
|
if (!change[title]) {
|
|
return;
|
|
}
|
|
|
|
if (change[title].deleted) {
|
|
// For deletions, we don't need to check modified time
|
|
this.updatedTiddlers.deletions.push(title);
|
|
} else if (change[title].modified) {
|
|
// Add to modifications - watch-fs already filtered out echoes via file exclusion
|
|
this.updatedTiddlers.modifications.push(title);
|
|
}
|
|
});
|
|
debouncedSync();
|
|
});
|
|
}
|
|
|
|
updatedTiddlers: { deletions: string[]; modifications: string[] } = {
|
|
// use $:/StoryList to trigger a initial sync, otherwise it won't do lazy load for Index tiddler after init, don't know why, maybe because we disabled the polling by changing pollTimerInterval.
|
|
modifications: [],
|
|
deletions: [],
|
|
};
|
|
|
|
clearUpdatedTiddlers() {
|
|
this.updatedTiddlers = {
|
|
modifications: [],
|
|
deletions: [],
|
|
};
|
|
}
|
|
|
|
private configSyncer() {
|
|
if ($tw.syncer === undefined) {
|
|
console.error('Syncer is undefined in TidGiIPCSyncAdaptor. Abort the configSyncer.');
|
|
return;
|
|
}
|
|
$tw.syncer.pollTimerInterval = 2_147_483_647;
|
|
}
|
|
|
|
getUpdatedTiddlers(_syncer: Syncer, callback: (error: Error | null | undefined, changes: { deletions: string[]; modifications: string[] }) => void): void {
|
|
this.logger.log('getUpdatedTiddlers');
|
|
callback(null, this.updatedTiddlers);
|
|
}
|
|
|
|
setLoggerSaveBuffer(loggerForSaving: Logger) {
|
|
this.logger.setSaveBuffer(loggerForSaving);
|
|
}
|
|
|
|
isReady() {
|
|
// We ipc sync adaptor is always ready to work! (Otherwise this will be false for first lazy-load event.) Seems first lazy load happened before the first status ipc call returns.
|
|
return true;
|
|
}
|
|
|
|
getTiddlerInfo(tiddler: Tiddler) {
|
|
return {
|
|
bag: tiddler.fields.bag,
|
|
};
|
|
}
|
|
|
|
getTiddlerRevision(title: string) {
|
|
const tiddler = this.wiki.getTiddler(title);
|
|
return tiddler?.fields.revision;
|
|
}
|
|
|
|
/*
|
|
Get the current status of the TiddlyWeb connection
|
|
*/
|
|
async getStatus(callback?: ISyncAdaptorGetStatusCallback) {
|
|
this.logger.log('Getting status');
|
|
try {
|
|
const workspace = await this.workspaceService.get(this.workspaceID);
|
|
const userName = workspace === undefined ? '' : await this.authService.getUserName(workspace);
|
|
const statusResponse = await this.wikiService.callWikiIpcServerRoute(this.workspaceID, 'getStatus', userName);
|
|
const status = statusResponse?.data as IWikiServerStatusObject;
|
|
if (status === undefined) {
|
|
throw new Error('No status returned from callWikiIpcServerRoute getStatus');
|
|
}
|
|
this.hasStatus = true;
|
|
// Record the recipe
|
|
this.recipe = status.space.recipe;
|
|
// Check if we're logged in
|
|
this.isLoggedIn = status.username !== 'GUEST';
|
|
this.isReadOnly = status.read_only ?? false;
|
|
this.isAnonymous = status.anonymous ?? false;
|
|
// this.logoutIsAvailable = 'logout_is_available' in status ? !!status.logout_is_available : true;
|
|
|
|
callback?.(null, this.isLoggedIn, status.username, this.isReadOnly, this.isAnonymous);
|
|
} catch (error) {
|
|
callback?.(error as Error);
|
|
}
|
|
}
|
|
|
|
/*
|
|
Get an array of skinny tiddler fields from the server
|
|
*/
|
|
async getSkinnyTiddlers(callback: ISyncAdaptorGetTiddlersJSONCallback) {
|
|
try {
|
|
this.logger.log('getSkinnyTiddlers');
|
|
/**
|
|
* This by default omit the text field.
|
|
*/
|
|
const tiddlersJSONResponse = await this.wikiService.callWikiIpcServerRoute(
|
|
this.workspaceID,
|
|
'getTiddlersJSON',
|
|
'[all[tiddlers]] -[[$:/isEncrypted]] -[prefix[$:/temp/]] -[prefix[$:/status/]] -[[$:/boot/boot.js]] -[[$:/boot/bootprefix.js]] -[[$:/library/sjcl.js]] -[[$:/core]]',
|
|
);
|
|
|
|
// Process the tiddlers to make sure the revision is a string
|
|
const skinnyTiddlers = tiddlersJSONResponse?.data as Array<Omit<ITiddlerFields, 'text'>> | undefined;
|
|
if (skinnyTiddlers === undefined) {
|
|
throw new Error('No tiddlers returned from callWikiIpcServerRoute getTiddlersJSON in getSkinnyTiddlers');
|
|
}
|
|
this.logger.log('skinnyTiddlers.length', skinnyTiddlers.length);
|
|
// Invoke the callback with the skinny tiddlers
|
|
callback(null, skinnyTiddlers);
|
|
} catch (error) {
|
|
callback(error as Error);
|
|
}
|
|
}
|
|
|
|
/*
|
|
Save a tiddler and invoke the callback with (err,adaptorInfo,revision)
|
|
*/
|
|
async saveTiddler(tiddler: Tiddler, callback: ISyncAdaptorPutTiddlersCallback, _options?: unknown) {
|
|
if (this.isReadOnly) {
|
|
callback(null);
|
|
return;
|
|
}
|
|
try {
|
|
const title = tiddler.fields.title;
|
|
const tiddlersToNotSave = $tw.utils.parseStringArray(this.wiki.getTiddlerText('$:/plugins/linonetwo/tidgi-ipc-syncadaptor/TiddlersToNotSave') ?? '');
|
|
if (tiddlersToNotSave.includes(title)) {
|
|
this.logger.log(`Ignore saveTiddler ${title}, config in TiddlersToNotSave`);
|
|
// if not calling callback in sync adaptor, will cause it waiting forever
|
|
callback(null);
|
|
return;
|
|
}
|
|
this.logger.log(`saveTiddler ${title}`);
|
|
const putTiddlerResponse = await this.wikiService.callWikiIpcServerRoute(
|
|
this.workspaceID,
|
|
'putTiddler',
|
|
title,
|
|
tiddler.fields,
|
|
);
|
|
if (putTiddlerResponse === undefined) {
|
|
throw new Error('saveTiddler returned undefined from callWikiIpcServerRoute putTiddler in saveTiddler');
|
|
}
|
|
// Save the details of the new revision of the tiddler
|
|
const etag = putTiddlerResponse.headers?.Etag;
|
|
if (etag === undefined) {
|
|
callback(new Error('Response from server is missing required `etag` header'));
|
|
} else {
|
|
const etagInfo = this.parseEtag(etag);
|
|
if (etagInfo !== undefined) {
|
|
// Invoke the callback
|
|
callback(null, {
|
|
bag: etagInfo.bag,
|
|
}, etagInfo.revision);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error(error);
|
|
callback(error as Error);
|
|
}
|
|
}
|
|
|
|
/*
|
|
Load a tiddler and invoke the callback with (err,tiddlerFields)
|
|
*/
|
|
async loadTiddler(title: string, callback?: ISyncAdaptorLoadTiddlerCallback) {
|
|
this.logger.log(`loadTiddler ${title}`);
|
|
try {
|
|
const getTiddlerResponse = await this.wikiService.callWikiIpcServerRoute(
|
|
this.workspaceID,
|
|
'getTiddler',
|
|
title,
|
|
);
|
|
if (getTiddlerResponse?.data === undefined) {
|
|
throw new Error('getTiddler returned undefined from callWikiIpcServerRoute getTiddler in loadTiddler');
|
|
}
|
|
callback?.(null, getTiddlerResponse.data as ITiddlerFields);
|
|
} catch (error) {
|
|
callback?.(error as Error);
|
|
}
|
|
}
|
|
|
|
/*
|
|
Delete a tiddler and invoke the callback with (err)
|
|
options include:
|
|
tiddlerInfo: the syncer's tiddlerInfo for this tiddler
|
|
*/
|
|
async deleteTiddler(title: string, callback: ISyncAdaptorDeleteTiddlerCallback) {
|
|
if (this.isReadOnly) {
|
|
callback(null);
|
|
return;
|
|
}
|
|
this.logger.log('deleteTiddler');
|
|
// For deletions, we don't track modified time since the tiddler is being removed
|
|
const getTiddlerResponse = await this.wikiService.callWikiIpcServerRoute(
|
|
this.workspaceID,
|
|
'deleteTiddler',
|
|
title,
|
|
);
|
|
try {
|
|
if (getTiddlerResponse?.data === undefined) {
|
|
throw new Error('getTiddler returned undefined from callWikiIpcServerRoute getTiddler in loadTiddler');
|
|
}
|
|
// Invoke the callback & return null adaptorInfo
|
|
callback(null, null);
|
|
} catch (error) {
|
|
callback(error as Error);
|
|
}
|
|
}
|
|
|
|
/*
|
|
Split a TiddlyWeb Etag into its constituent parts. For example:
|
|
|
|
```
|
|
"system-images_public/unsyncedIcon/946151:9f11c278ccde3a3149f339f4a1db80dd4369fc04"
|
|
```
|
|
|
|
Note that the value includes the opening and closing double quotes.
|
|
|
|
The parts are:
|
|
|
|
```
|
|
<bag>/<title>/<revision>:<hash>
|
|
```
|
|
*/
|
|
parseEtag(etag: string) {
|
|
const firstSlash = etag.indexOf('/');
|
|
const lastSlash = etag.lastIndexOf('/');
|
|
const colon = etag.lastIndexOf(':');
|
|
if (!(firstSlash === -1 || lastSlash === -1 || colon === -1)) {
|
|
return {
|
|
bag: $tw.utils.decodeURIComponentSafe(etag.substring(1, firstSlash)),
|
|
title: $tw.utils.decodeURIComponentSafe(etag.substring(firstSlash + 1, lastSlash)),
|
|
revision: etag.substring(lastSlash + 1, colon),
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($tw.browser && typeof window !== 'undefined') {
|
|
const isInTidGi = typeof document !== 'undefined' && document.location.protocol.startsWith('tidgi');
|
|
const servicesExposed = Boolean(window.service.wiki);
|
|
const hasWorkspaceIDinMeta = Boolean((window.meta() as WindowMeta[WindowNames.view] | undefined)?.workspace?.id);
|
|
if (isInTidGi && servicesExposed && hasWorkspaceIDinMeta) {
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
|
exports.adaptorClass = TidGiIPCSyncAdaptor;
|
|
}
|
|
}
|