mirror of
https://github.com/tiddly-gittly/TidGi-Desktop.git
synced 2025-12-05 18:20:39 -08:00
* feat: new config for tidgi mini window * chore: upgrade electron-forge * fix: use 汉语 和 漢語 * feat: shortcut to open mini window * test: TidGiMenubarWindow * feat: allow updateWindowProperties on the fly * fix: wrong icon path * fix: log not showing error message and stack * refactor: directly log error when using logger.error * feat: shortcut to open window * fix: menubar not closed * test: e2e for menubar * test: keyboard shortcut * test: wiki web content, and refactor to files * test: update command * Update Testing.md * test: menubar settings about menubarSyncWorkspaceWithMainWindow, menubarFixedWorkspaceId * test: simplify menubar test and cleanup test config * fix: view missing when execute several test all together * refactor: use hook to cleanup menubar setting * refactor: I clear test ai settings to before hook * Add option to show title bar on menubar window Introduces a new preference 'showMenubarWindowTitleBar' allowing users to toggle the title bar visibility on the menubar window. Updates related services, interfaces, and UI components to support this feature, and adds corresponding localization strings for English and Chinese. * refactor: tidgiminiwindow * refactor: preference keys to right order * Refactor window dimension checks to use constants Replaces hardcoded window dimensions with values from windowDimension and WindowNames constants for improved maintainability and consistency in window identification and checks. * I cleanup test wiki * Update defaultPreferences.ts * test: mini window workspace switch * fix: image broken by ai, and lint * fix: can't switch to mini window * refactor: useless todo * Update index.ts * refactor: reuse serialize-error * Update index.ts * Update testKeyboardShortcuts.ts * refactor: dup logic * Update ui.ts * fix: electron-ipc-cat
828 lines
36 KiB
TypeScript
828 lines
36 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-dynamic-delete */
|
|
import { createWorkerProxy, terminateWorker } from '@services/libs/workerAdapter';
|
|
import { dialog, shell } from 'electron';
|
|
import { backOff } from 'exponential-backoff';
|
|
import { copy, createSymlink, exists, mkdir, mkdirp, mkdirs, pathExists, readdir, readFile, remove } from 'fs-extra';
|
|
import { inject, injectable } from 'inversify';
|
|
import path from 'path';
|
|
import { Worker } from 'worker_threads';
|
|
// @ts-expect-error - Vite worker import with ?nodeWorker query
|
|
import WikiWorkerFactory from './wikiWorker?nodeWorker';
|
|
|
|
import { container } from '@services/container';
|
|
|
|
import { WikiChannel } from '@/constants/channels';
|
|
import { TIDDLERS_PATH, TIDDLYWIKI_PACKAGE_FOLDER, TIDDLYWIKI_TEMPLATE_FOLDER_PATH } from '@/constants/paths';
|
|
import type { IAuthenticationService } from '@services/auth/interface';
|
|
import type { IGitService, IGitUserInfos } from '@services/git/interface';
|
|
import { i18n } from '@services/libs/i18n';
|
|
import { getWikiErrorLogFileName, logger, startWikiLogger } from '@services/libs/log';
|
|
import serviceIdentifier from '@services/serviceIdentifier';
|
|
import type { IViewService } from '@services/view/interface';
|
|
import type { IWindowService } from '@services/windows/interface';
|
|
import { WindowNames } from '@services/windows/WindowProperties';
|
|
import type { IWorkspace, IWorkspaceService } from '@services/workspaces/interface';
|
|
import { isWikiWorkspace } from '@services/workspaces/interface';
|
|
import type { IWorkspaceViewService } from '@services/workspacesView/interface';
|
|
import { Observable } from 'rxjs';
|
|
import type { IChangedTiddlers } from 'tiddlywiki';
|
|
import { AlreadyExistError, CopyWikiTemplateError, DoubleWikiInstanceError, HTMLCanNotLoadError, SubWikiSMainWikiNotExistError, WikiRuntimeError } from './error';
|
|
import type { IWikiService } from './interface';
|
|
import { WikiControlActions } from './interface';
|
|
import { getSubWikiPluginContent, updateSubWikiPluginContent } from './plugin/subWikiPlugin';
|
|
import type { ISubWikiPluginContent } from './plugin/subWikiPlugin';
|
|
import type { IStartNodeJSWikiConfigs, WikiWorker } from './wikiWorker';
|
|
import type { IpcServerRouteMethods, IpcServerRouteNames } from './wikiWorker/ipcServerRoutes';
|
|
|
|
import { LOG_FOLDER } from '@/constants/appPaths';
|
|
import { isDevelopmentOrTest } from '@/constants/environment';
|
|
import { isHtmlWiki } from '@/constants/fileNames';
|
|
import { defaultServerIP } from '@/constants/urls';
|
|
import type { IDatabaseService } from '@services/database/interface';
|
|
import type { IPreferenceService } from '@services/preferences/interface';
|
|
import type { ISyncService } from '@services/sync/interface';
|
|
import { wikiWorkerStartedEventName } from './constants';
|
|
import type { IWorkerWikiOperations } from './wikiOperations/executor/wikiOperationInServer';
|
|
import { getSendWikiOperationsToBrowser } from './wikiOperations/sender/sendWikiOperationsToBrowser';
|
|
import type { ISendWikiOperationsToBrowser } from './wikiOperations/sender/sendWikiOperationsToBrowser';
|
|
|
|
@injectable()
|
|
export class Wiki implements IWikiService {
|
|
constructor(
|
|
@inject(serviceIdentifier.Preference) private readonly preferenceService: IPreferenceService,
|
|
@inject(serviceIdentifier.Authentication) private readonly authService: IAuthenticationService,
|
|
@inject(serviceIdentifier.Database) private readonly databaseService: IDatabaseService,
|
|
) {
|
|
}
|
|
|
|
public async getSubWikiPluginContent(mainWikiPath: string): Promise<ISubWikiPluginContent[]> {
|
|
return await getSubWikiPluginContent(mainWikiPath);
|
|
}
|
|
|
|
// handlers
|
|
public async copyWikiTemplate(newFolderPath: string, folderName: string): Promise<void> {
|
|
logger.info('starting', {
|
|
newFolderPath,
|
|
folderName,
|
|
function: 'copyWikiTemplate',
|
|
});
|
|
try {
|
|
await this.createWiki(newFolderPath, folderName);
|
|
const entries = await readdir(path.join(newFolderPath, folderName));
|
|
logger.debug('completed', {
|
|
newFolderPath,
|
|
folderName,
|
|
function: 'copyWikiTemplate',
|
|
entries,
|
|
});
|
|
} catch (error) {
|
|
logger.error('failed', {
|
|
error,
|
|
newFolderPath,
|
|
folderName,
|
|
function: 'copyWikiTemplate',
|
|
});
|
|
throw new CopyWikiTemplateError(`${(error as Error).message}, (${newFolderPath}, ${folderName})`);
|
|
}
|
|
}
|
|
|
|
// key is same to workspace id, so we can get this worker by workspace id
|
|
private wikiWorkers: Partial<Record<string, { proxy: WikiWorker; nativeWorker: Worker }>> = {};
|
|
private nativeWorkers: Partial<Record<string, Worker>> = {};
|
|
|
|
public getWorker(id: string): WikiWorker | undefined {
|
|
return this.wikiWorkers[id]?.proxy;
|
|
}
|
|
|
|
private getNativeWorker(id: string): Worker | undefined {
|
|
return this.wikiWorkers[id]?.nativeWorker;
|
|
}
|
|
|
|
private readonly wikiWorkerStartedEventTarget = new EventTarget();
|
|
|
|
public async startWiki(workspaceID: string, userName: string): Promise<void> {
|
|
const workspaceService = container.get<IWorkspaceService>(serviceIdentifier.Workspace);
|
|
|
|
if (workspaceID === undefined) {
|
|
logger.error('Try to start wiki, but workspace ID not provided', { workspaceID });
|
|
return;
|
|
}
|
|
const previousWorker = this.getWorker(workspaceID);
|
|
if (previousWorker !== undefined) {
|
|
logger.error(new DoubleWikiInstanceError(workspaceID).message, { stack: new Error('stack').stack?.replace('Error:', '') ?? 'no stack' });
|
|
await this.stopWiki(workspaceID);
|
|
}
|
|
// use Promise to handle worker callbacks
|
|
const workspace = await workspaceService.get(workspaceID);
|
|
if (workspace === undefined) {
|
|
logger.error('Try to start wiki, but workspace not found', { workspace, workspaceID });
|
|
return;
|
|
}
|
|
if (!isWikiWorkspace(workspace)) {
|
|
logger.error('Try to start wiki, but workspace is not a wiki workspace', { workspace, workspaceID });
|
|
return;
|
|
}
|
|
const { port, rootTiddler, readOnlyMode, tokenAuth, homeUrl, lastUrl, https, excludedPlugins, isSubWiki, wikiFolderLocation, name, enableHTTPAPI, authToken } = workspace;
|
|
if (isSubWiki) {
|
|
logger.error('Try to start wiki, but workspace is sub wiki', { workspace, workspaceID });
|
|
return;
|
|
}
|
|
// wiki server is about to boot, but our webview is just start loading, wait for `view.webContents.on('did-stop-loading'` to set this to false
|
|
await workspaceService.updateMetaData(workspaceID, { isLoading: true });
|
|
if (tokenAuth && authToken) {
|
|
logger.debug('getOneTimeAdminAuthTokenForWorkspaceSync', {
|
|
tokenAuth: String(tokenAuth),
|
|
authToken,
|
|
function: 'startWiki',
|
|
});
|
|
}
|
|
const workerData: IStartNodeJSWikiConfigs = {
|
|
authToken,
|
|
constants: { TIDDLYWIKI_PACKAGE_FOLDER: String(TIDDLYWIKI_PACKAGE_FOLDER) },
|
|
enableHTTPAPI,
|
|
excludedPlugins,
|
|
homePath: wikiFolderLocation,
|
|
https,
|
|
isDev: isDevelopmentOrTest,
|
|
openDebugger: process.env.DEBUG_WORKER === 'true',
|
|
readOnlyMode,
|
|
rootTiddler,
|
|
tiddlyWikiHost: defaultServerIP,
|
|
tiddlyWikiPort: port,
|
|
tokenAuth,
|
|
userName,
|
|
};
|
|
logger.debug('initializing wikiWorker for workspace', {
|
|
workspaceID,
|
|
function: 'Wiki.startWiki',
|
|
});
|
|
|
|
// Create native worker using Vite's ?nodeWorker import
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
|
const nativeWorker = WikiWorkerFactory() as Worker;
|
|
const worker = createWorkerProxy<WikiWorker>(nativeWorker);
|
|
|
|
logger.debug(`wikiWorker initialized`, { function: 'Wiki.startWiki' });
|
|
this.wikiWorkers[workspaceID] = { proxy: worker, nativeWorker };
|
|
this.wikiWorkerStartedEventTarget.dispatchEvent(new Event(wikiWorkerStartedEventName(workspaceID)));
|
|
const wikiLogger = startWikiLogger(workspaceID, name);
|
|
const loggerMeta = { worker: 'NodeJSWiki', homePath: wikiFolderLocation };
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
// Handle worker errors
|
|
nativeWorker.on('error', (error: Error) => {
|
|
wikiLogger.error(error.message, { function: 'Worker.error' });
|
|
reject(new WikiRuntimeError(error, name, false));
|
|
});
|
|
|
|
// Handle worker exit
|
|
nativeWorker.on('exit', (code) => {
|
|
delete this.wikiWorkers[workspaceID];
|
|
const warningMessage = `NodeJSWiki ${workspaceID} Worker stopped with code ${code}`;
|
|
logger.info(warningMessage, loggerMeta);
|
|
if (code !== 0) {
|
|
reject(new Error(`Worker stopped with exit code ${code}`));
|
|
} else {
|
|
resolve();
|
|
}
|
|
});
|
|
|
|
// Handle worker messages (for logging)
|
|
nativeWorker.on('message', (message: unknown) => {
|
|
if (message && typeof message === 'object' && 'log' in message) {
|
|
wikiLogger.info('Worker message', { data: message });
|
|
}
|
|
});
|
|
|
|
// subscribe to the Observable that startNodeJSWiki returns, handle messages send by our code
|
|
logger.debug('startWiki calling startNodeJSWiki in the main process', { function: 'wikiWorker.startNodeJSWiki' });
|
|
|
|
worker.startNodeJSWiki(workerData).subscribe(async (message) => {
|
|
if (message.type === 'control') {
|
|
await workspaceService.update(workspaceID, { lastNodeJSArgv: message.argv }, true);
|
|
switch (message.actions) {
|
|
case WikiControlActions.booted: {
|
|
setTimeout(async () => {
|
|
logger.info('resolved with control booted', {
|
|
...loggerMeta,
|
|
message: message.message,
|
|
workspaceID,
|
|
function: 'startWiki',
|
|
});
|
|
resolve();
|
|
}, 100);
|
|
break;
|
|
}
|
|
case WikiControlActions.start: {
|
|
if (message.message !== undefined) {
|
|
logger.debug('WikiControlActions.start', { 'message.message': message.message, ...loggerMeta, workspaceID });
|
|
}
|
|
break;
|
|
}
|
|
case WikiControlActions.listening: {
|
|
// API server started, but we are using IPC to serve content now, so do nothing here.
|
|
if (message.message !== undefined) {
|
|
logger.info('WikiControlActions.listening ' + message.message, { ...loggerMeta, workspaceID });
|
|
}
|
|
break;
|
|
}
|
|
case WikiControlActions.error: {
|
|
const errorMessage = message.message ?? 'get WikiControlActions.error without message';
|
|
logger.error('rejected with control error', {
|
|
...loggerMeta,
|
|
message,
|
|
errorMessage,
|
|
workspaceID,
|
|
function: 'startWiki',
|
|
});
|
|
await workspaceService.updateMetaData(workspaceID, { isLoading: false, didFailLoadErrorMessage: errorMessage });
|
|
// fix "message":"listen EADDRINUSE: address already in use 0.0.0.0:5212"
|
|
if (errorMessage.includes('EADDRINUSE')) {
|
|
const portChange = {
|
|
port: port + 1,
|
|
homeUrl: homeUrl.replace(`:${port}`, `:${port + 1}`),
|
|
|
|
lastUrl: lastUrl?.replace(`:${port}`, `:${port + 1}`) ?? null,
|
|
};
|
|
await workspaceService.update(workspaceID, portChange, true);
|
|
reject(new WikiRuntimeError(new Error(message.message), wikiFolderLocation, true, { ...workspace, ...portChange }));
|
|
return;
|
|
}
|
|
reject(new WikiRuntimeError(new Error(message.message), wikiFolderLocation, false, { ...workspace }));
|
|
}
|
|
}
|
|
} else if (message.type === 'stderr' || message.type === 'stdout') {
|
|
wikiLogger.info(message.message, { function: 'startNodeJSWiki' });
|
|
}
|
|
});
|
|
});
|
|
void this.afterWikiStart(workspaceID);
|
|
}
|
|
|
|
private async afterWikiStart(workspaceID: string): Promise<void> {
|
|
const workspaceService = container.get<IWorkspaceService>(serviceIdentifier.Workspace);
|
|
const workspace = await workspaceService.get(workspaceID);
|
|
if (workspace === undefined) {
|
|
logger.error('get workspace failed', { workspaceID, function: 'afterWikiStart' });
|
|
return;
|
|
}
|
|
if (!isWikiWorkspace(workspace)) {
|
|
return;
|
|
}
|
|
const { isSubWiki, enableHTTPAPI } = workspace;
|
|
if (!isSubWiki && enableHTTPAPI) {
|
|
// Auto enable server filters if HTTP API is enabled. So this feature immediately available to 3rd party apps, reduce user confusion.
|
|
await this.wikiOperationInServer(WikiChannel.addTiddler, workspaceID, [
|
|
'$:/config/Server/AllowAllExternalFilters',
|
|
'yes',
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ensure you get a started worker. If not stated, it will await for it to start.
|
|
* @param workspaceID
|
|
*/
|
|
private async getWorkerEnsure(workspaceID: string): Promise<WikiWorker> {
|
|
let worker = this.getWorker(workspaceID);
|
|
if (worker === undefined) {
|
|
// wait for wiki worker started
|
|
await new Promise<void>(resolve => {
|
|
this.wikiWorkerStartedEventTarget.addEventListener(wikiWorkerStartedEventName(workspaceID), () => {
|
|
resolve();
|
|
});
|
|
});
|
|
} else {
|
|
return worker;
|
|
}
|
|
worker = this.getWorker(workspaceID);
|
|
if (worker === undefined) {
|
|
const errorMessage =
|
|
`Still no wiki for ${workspaceID} after wikiWorkerStartedEventTarget.addEventListener(wikiWorkerStartedEventName. No running worker, maybe tiddlywiki server in this workspace failed to start`;
|
|
logger.error(
|
|
errorMessage,
|
|
{
|
|
function: 'getWorkerEnsure',
|
|
},
|
|
);
|
|
throw new Error(errorMessage);
|
|
}
|
|
return worker;
|
|
}
|
|
|
|
public async callWikiIpcServerRoute<NAME extends IpcServerRouteNames>(workspaceID: string, route: NAME, ...arguments_: Parameters<IpcServerRouteMethods[NAME]>) {
|
|
// don't log full `arguments_` here, it might contains huge text
|
|
logger.debug(`callWikiIpcServerRoute get ${route}`, { workspaceID });
|
|
const worker = await this.getWorkerEnsure(workspaceID);
|
|
logger.debug(`callWikiIpcServerRoute got worker`);
|
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
// @ts-ignore Argument of type 'string | string[] | ITiddlerFields | undefined' is not assignable to parameter of type 'string'. Type 'undefined' is not assignable to type 'string'.ts(2345)
|
|
const response = await worker[route](...arguments_);
|
|
logger.debug(`callWikiIpcServerRoute returning response`, { route, code: response.statusCode });
|
|
return response;
|
|
}
|
|
|
|
public getWikiChangeObserver$(workspaceID: string): Observable<IChangedTiddlers> {
|
|
return new Observable((observer) => {
|
|
const getWikiChangeObserverIIFE = async () => {
|
|
const worker = await this.getWorkerEnsure(workspaceID);
|
|
const observable = worker.getWikiChangeObserver();
|
|
observable.subscribe(observer);
|
|
};
|
|
void getWikiChangeObserverIIFE();
|
|
});
|
|
}
|
|
|
|
public async extractWikiHTML(htmlWikiPath: string, saveWikiFolderPath: string): Promise<string | undefined> {
|
|
// hope saveWikiFolderPath = ParentFolderPath + wikifolderPath
|
|
// We want the folder where the WIKI is saved to be empty, and we want the input htmlWiki to be an HTML file even if it is a non-wikiHTML file. Otherwise the program will exit abnormally.
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
|
const nativeWorker = WikiWorkerFactory() as Worker;
|
|
const worker = createWorkerProxy<WikiWorker>(nativeWorker);
|
|
|
|
try {
|
|
if (!isHtmlWiki(htmlWikiPath)) {
|
|
throw new HTMLCanNotLoadError(htmlWikiPath);
|
|
}
|
|
if (await exists(saveWikiFolderPath)) {
|
|
throw new AlreadyExistError(saveWikiFolderPath);
|
|
}
|
|
await worker.extractWikiHTML(htmlWikiPath, saveWikiFolderPath, { TIDDLYWIKI_PACKAGE_FOLDER });
|
|
} catch (error) {
|
|
const result = `${(error as Error).name} ${(error as Error).message}`;
|
|
logger.error(result, { worker: 'NodeJSWiki', method: 'extractWikiHTML', htmlWikiPath, saveWikiFolderPath });
|
|
return result;
|
|
} finally {
|
|
// this worker is only for one time use. we will spawn a new one for starting wiki later.
|
|
await terminateWorker(nativeWorker);
|
|
}
|
|
}
|
|
|
|
public async packetHTMLFromWikiFolder(wikiFolderLocation: string, pathOfNewHTML: string): Promise<void> {
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
|
const nativeWorker = WikiWorkerFactory() as Worker;
|
|
const worker = createWorkerProxy<WikiWorker>(nativeWorker);
|
|
|
|
try {
|
|
await worker.packetHTMLFromWikiFolder(wikiFolderLocation, pathOfNewHTML, { TIDDLYWIKI_PACKAGE_FOLDER });
|
|
} finally {
|
|
// this worker is only for one time use. we will spawn a new one for starting wiki later.
|
|
await terminateWorker(nativeWorker);
|
|
}
|
|
}
|
|
|
|
public async stopWiki(id: string): Promise<void> {
|
|
const worker = this.getWorker(id);
|
|
const nativeWorker = this.getNativeWorker(id);
|
|
|
|
if (worker === undefined || nativeWorker === undefined) {
|
|
logger.warn(`No wiki for ${id}. No running worker, means maybe tiddlywiki server in this workspace failed to start`, {
|
|
function: 'stopWiki',
|
|
stack: new Error('stack').stack?.replace('Error:', '') ?? 'no stack',
|
|
});
|
|
return;
|
|
}
|
|
|
|
const syncService = container.get<ISyncService>(serviceIdentifier.Sync);
|
|
syncService.stopIntervalSync(id);
|
|
|
|
try {
|
|
logger.debug(`worker.beforeExit for ${id}`);
|
|
worker.beforeExit();
|
|
logger.debug(`terminateWorker for ${id}`);
|
|
await terminateWorker(nativeWorker);
|
|
} catch (error) {
|
|
logger.error('wiki worker stop failed', { function: 'stopWiki', error });
|
|
}
|
|
|
|
delete this.wikiWorkers[id];
|
|
logger.info(`Wiki-worker for ${id} stopped`, { function: 'stopWiki' });
|
|
}
|
|
|
|
/**
|
|
* Stop all worker_thread, use and await this before app.quit()
|
|
*/
|
|
public async stopAllWiki(): Promise<void> {
|
|
logger.debug('stopAllWiki', {
|
|
function: 'stopAllWiki',
|
|
});
|
|
const tasks = [];
|
|
for (const id of Object.keys(this.wikiWorkers)) {
|
|
tasks.push(this.stopWiki(id));
|
|
}
|
|
await Promise.all(tasks);
|
|
logger.info('All wiki workers are stopped', { function: 'stopAllWiki' });
|
|
}
|
|
|
|
/**
|
|
* Send message to UI via WikiChannel.createProgress
|
|
* @param message will show in the UI
|
|
*/
|
|
private readonly logProgress = (message: string): void => {
|
|
logger.info(message, { handler: WikiChannel.createProgress });
|
|
};
|
|
|
|
private readonly folderToContainSymlinks = 'subwiki';
|
|
/**
|
|
* Link a sub wiki to a main wiki, this will create a shortcut folder from main wiki to sub wiki, so when saving files to that shortcut folder, you will actually save file to the sub wiki
|
|
* We place symbol-link (short-cuts) in the tiddlers/subwiki/ folder, and ignore this folder in the .gitignore, so this symlink won't be commit to the git, as it contains computer specific path.
|
|
* @param {string} mainWikiPath folderPath of a wiki as link's destination
|
|
* @param {string} folderName sub-wiki's folder name
|
|
* @param {string} newWikiPath sub-wiki's folder path
|
|
*/
|
|
public async linkWiki(mainWikiPath: string, folderName: string, subWikiPath: string): Promise<void> {
|
|
const mainWikiTiddlersFolderSubWikisPath = path.join(mainWikiPath, TIDDLERS_PATH, this.folderToContainSymlinks);
|
|
const subwikiSymlinkPath = path.join(mainWikiTiddlersFolderSubWikisPath, folderName);
|
|
try {
|
|
try {
|
|
await remove(subwikiSymlinkPath);
|
|
} catch (_error: unknown) {
|
|
void _error;
|
|
}
|
|
await mkdirp(mainWikiTiddlersFolderSubWikisPath);
|
|
await createSymlink(subWikiPath, subwikiSymlinkPath, 'junction');
|
|
this.logProgress(i18n.t('AddWorkspace.CreateLinkFromSubWikiToMainWikiSucceed'));
|
|
} catch (error: unknown) {
|
|
throw new Error(i18n.t('AddWorkspace.CreateLinkFromSubWikiToMainWikiFailed', { subWikiPath, mainWikiTiddlersFolderPath: subwikiSymlinkPath, error }));
|
|
}
|
|
}
|
|
|
|
private async createWiki(newFolderPath: string, folderName: string): Promise<void> {
|
|
this.logProgress(i18n.t('AddWorkspace.StartUsingTemplateToCreateWiki'));
|
|
const newWikiPath = path.join(newFolderPath, folderName);
|
|
if (!(await pathExists(newFolderPath))) {
|
|
throw new Error(i18n.t('AddWorkspace.PathNotExist', { path: newFolderPath }));
|
|
}
|
|
if (!(await pathExists(TIDDLYWIKI_TEMPLATE_FOLDER_PATH))) {
|
|
throw new Error(i18n.t('AddWorkspace.WikiTemplateMissing', { TIDDLYWIKI_TEMPLATE_FOLDER_PATH }));
|
|
}
|
|
if (await pathExists(newWikiPath)) {
|
|
throw new Error(i18n.t('AddWorkspace.WikiExisted', { newWikiPath }));
|
|
}
|
|
try {
|
|
await copy(TIDDLYWIKI_TEMPLATE_FOLDER_PATH, newWikiPath, {
|
|
filter: (source: string, _destination: string) => {
|
|
// keep xxx/template/wiki/.gitignore
|
|
// keep xxx/template/wiki/.github
|
|
// ignore xxx/template/wiki/.git
|
|
// prevent copy wiki repo's .git folder
|
|
if (source.endsWith('.git')) {
|
|
return false;
|
|
}
|
|
// it will be copied if return true
|
|
return true;
|
|
},
|
|
});
|
|
} catch {
|
|
throw new Error(i18n.t('AddWorkspace.CantCreateFolderHere', { newWikiPath }));
|
|
}
|
|
this.logProgress(i18n.t('AddWorkspace.WikiTemplateCopyCompleted') + newWikiPath);
|
|
}
|
|
|
|
public async createSubWiki(parentFolderLocation: string, folderName: string, subWikiFolderName: string, mainWikiPath: string, tagName = '', onlyLink = false): Promise<void> {
|
|
this.logProgress(i18n.t('AddWorkspace.StartCreatingSubWiki'));
|
|
const newWikiPath = path.join(parentFolderLocation, folderName);
|
|
if (!(await pathExists(parentFolderLocation))) {
|
|
throw new Error(i18n.t('AddWorkspace.PathNotExist', { path: parentFolderLocation }));
|
|
}
|
|
if (!onlyLink) {
|
|
if (await pathExists(newWikiPath)) {
|
|
throw new Error(i18n.t('AddWorkspace.WikiExisted', { newWikiPath }));
|
|
}
|
|
try {
|
|
await mkdirs(newWikiPath);
|
|
} catch {
|
|
throw new Error(i18n.t('AddWorkspace.CantCreateFolderHere', { newWikiPath }));
|
|
}
|
|
}
|
|
this.logProgress(i18n.t('AddWorkspace.StartLinkingSubWikiToMainWiki'));
|
|
await this.linkWiki(mainWikiPath, folderName, newWikiPath);
|
|
if (typeof tagName === 'string' && tagName.length > 0) {
|
|
this.logProgress(i18n.t('AddWorkspace.AddFileSystemPath'));
|
|
updateSubWikiPluginContent(mainWikiPath, newWikiPath, { tagName, subWikiFolderName });
|
|
}
|
|
|
|
this.logProgress(i18n.t('AddWorkspace.SubWikiCreationCompleted'));
|
|
}
|
|
|
|
public async removeWiki(wikiPath: string, mainWikiToUnLink?: string, onlyRemoveLink = false): Promise<void> {
|
|
if (mainWikiToUnLink !== undefined) {
|
|
const subWikiName = path.basename(wikiPath);
|
|
await shell.trashItem(path.join(mainWikiToUnLink, TIDDLERS_PATH, this.folderToContainSymlinks, subWikiName));
|
|
}
|
|
if (!onlyRemoveLink) {
|
|
await shell.trashItem(wikiPath);
|
|
}
|
|
}
|
|
|
|
public async ensureWikiExist(wikiPath: string, shouldBeMainWiki: boolean): Promise<void> {
|
|
logger.debug('checking wiki folder', {
|
|
wikiPath,
|
|
shouldBeMainWiki,
|
|
});
|
|
if (!(await pathExists(wikiPath))) {
|
|
const error = i18n.t('AddWorkspace.PathNotExist', { path: wikiPath });
|
|
logger.error('path does not exist', {
|
|
wikiPath,
|
|
function: 'ensureWikiExist',
|
|
});
|
|
throw new Error(error);
|
|
}
|
|
const wikiInfoPath = path.resolve(wikiPath, 'tiddlywiki.info');
|
|
const wikiInfoExists = await pathExists(wikiInfoPath);
|
|
logger.debug('checked tiddlywiki.info', {
|
|
wikiInfoPath,
|
|
exists: wikiInfoExists,
|
|
});
|
|
if (shouldBeMainWiki && !wikiInfoExists) {
|
|
const entries = await readdir(wikiPath);
|
|
logger.error('tiddlywiki.info missing', {
|
|
wikiPath,
|
|
wikiInfoPath,
|
|
function: 'ensureWikiExist',
|
|
entries,
|
|
});
|
|
throw new Error(i18n.t('AddWorkspace.ThisPathIsNotAWikiFolder', { wikiPath, wikiInfoPath }));
|
|
}
|
|
const tiddlersPath = path.join(wikiPath, TIDDLERS_PATH);
|
|
const tiddlersExists = await pathExists(tiddlersPath);
|
|
logger.debug('checked tiddlers folder', {
|
|
tiddlersPath,
|
|
exists: tiddlersExists,
|
|
});
|
|
if (shouldBeMainWiki && !tiddlersExists) {
|
|
logger.error('tiddlers folder missing', {
|
|
wikiPath,
|
|
tiddlersPath,
|
|
function: 'ensureWikiExist',
|
|
});
|
|
throw new Error(i18n.t('AddWorkspace.ThisPathIsNotAWikiFolder', { wikiPath }));
|
|
}
|
|
logger.debug('validation passed', {
|
|
wikiPath,
|
|
function: 'ensureWikiExist',
|
|
});
|
|
}
|
|
|
|
public async checkWikiExist(workspace: IWorkspace, options: { shouldBeMainWiki?: boolean; showDialog?: boolean } = {}): Promise<string | true> {
|
|
if (!isWikiWorkspace(workspace)) {
|
|
return true; // dedicated workspaces always "exist"
|
|
}
|
|
const { wikiFolderLocation, id: workspaceID } = workspace;
|
|
const { shouldBeMainWiki, showDialog } = options;
|
|
try {
|
|
if (typeof wikiFolderLocation !== 'string' || wikiFolderLocation.length === 0 || !path.isAbsolute(wikiFolderLocation)) {
|
|
const errorMessage = i18n.t('Dialog.NeedCorrectTiddlywikiFolderPath') + wikiFolderLocation;
|
|
logger.error(errorMessage);
|
|
const windowService = container.get<IWindowService>(serviceIdentifier.Window);
|
|
const mainWindow = windowService.get(WindowNames.main);
|
|
if (mainWindow !== undefined && showDialog === true) {
|
|
await dialog.showMessageBox(mainWindow, {
|
|
title: i18n.t('Dialog.PathPassInCantUse'),
|
|
message: errorMessage,
|
|
buttons: ['OK'],
|
|
cancelId: 0,
|
|
defaultId: 0,
|
|
});
|
|
}
|
|
return errorMessage;
|
|
}
|
|
await this.ensureWikiExist(wikiFolderLocation, shouldBeMainWiki ?? false);
|
|
return true;
|
|
} catch (error) {
|
|
const checkResult = (error as Error).message;
|
|
|
|
const errorMessage = `${i18n.t('Dialog.CantFindWorkspaceFolderRemoveWorkspace')} ${wikiFolderLocation} ${checkResult}`;
|
|
logger.error(errorMessage);
|
|
const windowService = container.get<IWindowService>(serviceIdentifier.Window);
|
|
const mainWindow = windowService.get(WindowNames.main);
|
|
if (mainWindow !== undefined && showDialog === true) {
|
|
const { response } = await dialog.showMessageBox(mainWindow, {
|
|
title: i18n.t('Dialog.WorkspaceFolderRemoved'),
|
|
message: errorMessage,
|
|
buttons: [i18n.t('Dialog.RemoveWorkspace'), i18n.t('Dialog.DoNotCare')],
|
|
cancelId: 1,
|
|
defaultId: 0,
|
|
});
|
|
if (response === 0) {
|
|
const workspaceViewService = container.get<IWorkspaceViewService>(serviceIdentifier.WorkspaceView);
|
|
await workspaceViewService.removeWorkspaceView(workspaceID);
|
|
}
|
|
}
|
|
return errorMessage;
|
|
}
|
|
}
|
|
|
|
public async cloneWiki(parentFolderLocation: string, wikiFolderName: string, gitRepoUrl: string, gitUserInfo: IGitUserInfos): Promise<void> {
|
|
this.logProgress(i18n.t('AddWorkspace.StartCloningWiki'));
|
|
const newWikiPath = path.join(parentFolderLocation, wikiFolderName);
|
|
if (!(await pathExists(parentFolderLocation))) {
|
|
throw new Error(i18n.t('AddWorkspace.PathNotExist', { path: parentFolderLocation }));
|
|
}
|
|
if (await pathExists(newWikiPath)) {
|
|
throw new Error(i18n.t('AddWorkspace.WikiExisted', { newWikiPath }));
|
|
}
|
|
try {
|
|
await mkdir(newWikiPath);
|
|
} catch {
|
|
throw new Error(i18n.t('AddWorkspace.CantCreateFolderHere', { newWikiPath }));
|
|
}
|
|
const gitService = container.get<IGitService>(serviceIdentifier.Git);
|
|
await gitService.clone(gitRepoUrl, path.join(parentFolderLocation, wikiFolderName), gitUserInfo);
|
|
}
|
|
|
|
public async cloneSubWiki(
|
|
parentFolderLocation: string,
|
|
wikiFolderName: string,
|
|
mainWikiPath: string,
|
|
gitRepoUrl: string,
|
|
gitUserInfo: IGitUserInfos,
|
|
tagName = '',
|
|
): Promise<void> {
|
|
this.logProgress(i18n.t('AddWorkspace.StartCloningSubWiki'));
|
|
const newWikiPath = path.join(parentFolderLocation, wikiFolderName);
|
|
if (!(await pathExists(parentFolderLocation))) {
|
|
throw new Error(i18n.t('AddWorkspace.PathNotExist', { path: parentFolderLocation }));
|
|
}
|
|
if (await pathExists(newWikiPath)) {
|
|
throw new Error(i18n.t('AddWorkspace.WikiExisted', { newWikiPath }));
|
|
}
|
|
try {
|
|
await mkdir(newWikiPath);
|
|
} catch {
|
|
throw new Error(i18n.t('AddWorkspace.CantCreateFolderHere', { newWikiPath }));
|
|
}
|
|
const gitService = container.get<IGitService>(serviceIdentifier.Git);
|
|
await gitService.clone(gitRepoUrl, path.join(parentFolderLocation, wikiFolderName), gitUserInfo);
|
|
this.logProgress(i18n.t('AddWorkspace.StartLinkingSubWikiToMainWiki'));
|
|
await this.linkWiki(mainWikiPath, wikiFolderName, path.join(parentFolderLocation, wikiFolderName));
|
|
if (typeof tagName === 'string' && tagName.length > 0) {
|
|
this.logProgress(i18n.t('AddWorkspace.AddFileSystemPath'));
|
|
updateSubWikiPluginContent(mainWikiPath, newWikiPath, { tagName, subWikiFolderName: wikiFolderName });
|
|
}
|
|
}
|
|
|
|
// wiki-startup.ts
|
|
|
|
private justStartedWiki: Record<string, boolean> = {};
|
|
public setWikiStartLockOn(id: string): void {
|
|
this.justStartedWiki[id] = true;
|
|
}
|
|
|
|
public setAllWikiStartLockOff(): void {
|
|
this.justStartedWiki = {};
|
|
}
|
|
|
|
public checkWikiStartLock(id: string): boolean {
|
|
return this.justStartedWiki[id] ?? false;
|
|
}
|
|
|
|
public async wikiStartup(workspace: IWorkspace): Promise<void> {
|
|
if (!isWikiWorkspace(workspace)) {
|
|
return;
|
|
}
|
|
const { id, isSubWiki, name, mainWikiID } = workspace;
|
|
|
|
const userName = await this.authService.getUserName(workspace);
|
|
|
|
// if is main wiki
|
|
if (isSubWiki) {
|
|
// if is private repo wiki
|
|
// if we are creating a sub-wiki just now, restart the main wiki to load content from private wiki
|
|
if (typeof mainWikiID === 'string' && !this.checkWikiStartLock(mainWikiID)) {
|
|
const workspaceService = container.get<IWorkspaceService>(serviceIdentifier.Workspace);
|
|
const mainWorkspace = await workspaceService.get(mainWikiID);
|
|
if (mainWorkspace === undefined) {
|
|
throw new SubWikiSMainWikiNotExistError(name ?? id, mainWikiID);
|
|
}
|
|
await this.restartWiki(mainWorkspace);
|
|
}
|
|
} else {
|
|
try {
|
|
logger.debug('calling startWiki', {
|
|
function: 'startWiki',
|
|
});
|
|
await this.startWiki(id, userName);
|
|
logger.debug('done', {
|
|
function: 'startWiki',
|
|
});
|
|
} catch (error) {
|
|
logger.warn('startWiki failed', { function: 'startWiki', error });
|
|
if (error instanceof WikiRuntimeError && error.retry) {
|
|
logger.warn('startWiki retry', { function: 'startWiki', error });
|
|
// don't want it to throw here again, so no await here.
|
|
|
|
const workspaceViewService = container.get<IWorkspaceViewService>(serviceIdentifier.WorkspaceView);
|
|
return workspaceViewService.restartWorkspaceViewService(id);
|
|
} else if ((error as Error).message.includes('Did not receive an init message from worker after')) {
|
|
// https://github.com/andywer/threads.js/issues/426
|
|
// wait some time and restart the wiki will solve this
|
|
logger.warn('startWiki handle error, restarting', { function: 'startWiki', error });
|
|
await this.restartWiki(workspace);
|
|
} else {
|
|
logger.warn('unexpected error, throw it', { function: 'startWiki' });
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
const syncService = container.get<ISyncService>(serviceIdentifier.Sync);
|
|
await syncService.startIntervalSyncIfNeeded(workspace);
|
|
}
|
|
|
|
public async restartWiki(workspace: IWorkspace): Promise<void> {
|
|
if (!isWikiWorkspace(workspace)) {
|
|
return;
|
|
}
|
|
const { id, isSubWiki } = workspace;
|
|
// use workspace specific userName first, and fall back to preferences' userName, pass empty editor username if undefined
|
|
|
|
const userName = await this.authService.getUserName(workspace);
|
|
|
|
const syncService = container.get<ISyncService>(serviceIdentifier.Sync);
|
|
syncService.stopIntervalSync(id);
|
|
if (!isSubWiki) {
|
|
await this.stopWiki(id);
|
|
await this.startWiki(id, userName);
|
|
}
|
|
await syncService.startIntervalSyncIfNeeded(workspace);
|
|
}
|
|
|
|
public async updateSubWikiPluginContent(mainWikiPath: string, subWikiPath: string, newConfig?: IWorkspace, oldConfig?: IWorkspace): Promise<void> {
|
|
const newConfigTyped = newConfig && isWikiWorkspace(newConfig) ? newConfig : undefined;
|
|
const oldConfigTyped = oldConfig && isWikiWorkspace(oldConfig) ? oldConfig : undefined;
|
|
updateSubWikiPluginContent(mainWikiPath, subWikiPath, newConfigTyped, oldConfigTyped);
|
|
}
|
|
|
|
public async wikiOperationInBrowser<OP extends keyof ISendWikiOperationsToBrowser>(
|
|
operationType: OP,
|
|
workspaceID: string,
|
|
arguments_: Parameters<ISendWikiOperationsToBrowser[OP]>,
|
|
) {
|
|
// At least wait for wiki started. Otherwise some services like theme may try to call this method even on app start.
|
|
await this.getWorkerEnsure(workspaceID);
|
|
const viewService = container.get<IViewService>(serviceIdentifier.View);
|
|
await viewService.getLoadedViewEnsure(workspaceID, WindowNames.main);
|
|
const sendWikiOperationsToBrowser = getSendWikiOperationsToBrowser(workspaceID);
|
|
if (typeof sendWikiOperationsToBrowser[operationType] !== 'function') {
|
|
throw new TypeError(`${operationType} gets no useful handler`);
|
|
}
|
|
if (!Array.isArray(arguments_)) {
|
|
// TODO: better type handling here
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
throw new TypeError(`${(arguments_ as any) ?? ''} (${typeof arguments_}) is not a good argument array for ${operationType}`);
|
|
}
|
|
// @ts-expect-error A spread argument must either have a tuple type or be passed to a rest parameter.ts(2556) this maybe a bug of ts... try remove this comment after upgrade ts. And the result become void is weird too.
|
|
|
|
return await (sendWikiOperationsToBrowser[operationType](...arguments_) as unknown as ReturnType<ISendWikiOperationsToBrowser[OP]>);
|
|
}
|
|
|
|
public async wikiOperationInServer<OP extends keyof IWorkerWikiOperations>(
|
|
operationType: OP,
|
|
workspaceID: string,
|
|
arguments_: Parameters<IWorkerWikiOperations[OP]>,
|
|
) {
|
|
logger.debug(`Get ${operationType}`, { workspaceID, method: 'wikiOperationInServer' });
|
|
// This will never await if workspaceID isn't exist in user's workspace list. So prefer to check workspace existence before use this method.
|
|
const worker = await this.getWorkerEnsure(workspaceID);
|
|
|
|
logger.debug(`Get worker ${operationType}`, { workspaceID, hasWorker: worker !== undefined, method: 'wikiOperationInServer', arguments_ });
|
|
const result = await (worker.wikiOperation(operationType, ...arguments_) as unknown as ReturnType<IWorkerWikiOperations[OP]>);
|
|
logger.debug(`Get result ${operationType}`, { workspaceID, method: 'wikiOperationInServer' });
|
|
return result;
|
|
}
|
|
|
|
public async setWikiLanguage(workspaceID: string, tiddlywikiLanguageName: string): Promise<void> {
|
|
const twLanguageUpdateTimeout = 15_000;
|
|
// no need to wait setting wiki language, this sometimes cause slow PC to fail on this step
|
|
void backOff(async () => {
|
|
await (this.wikiOperationInBrowser(
|
|
WikiChannel.setTiddlerText,
|
|
workspaceID,
|
|
['$:/language', tiddlywikiLanguageName, { timeout: twLanguageUpdateTimeout }],
|
|
));
|
|
}, {
|
|
startingDelay: 2000,
|
|
});
|
|
}
|
|
|
|
public async getTiddlerFilePath(title: string, workspaceID?: string): Promise<string | undefined> {
|
|
const workspaceService = container.get<IWorkspaceService>(serviceIdentifier.Workspace);
|
|
const activeWorkspace = await workspaceService.getActiveWorkspace();
|
|
const wikiWorker = this.getWorker(workspaceID ?? activeWorkspace?.id ?? '');
|
|
if (wikiWorker !== undefined) {
|
|
const tiddlerFileMetadata = wikiWorker.getTiddlerFileMetadata(title);
|
|
if (tiddlerFileMetadata?.filepath !== undefined) {
|
|
return tiddlerFileMetadata.filepath;
|
|
}
|
|
}
|
|
}
|
|
|
|
public async getWikiErrorLogs(workspaceID: string, wikiName: string): Promise<{ content: string; filePath: string }> {
|
|
const filePath = path.join(LOG_FOLDER, getWikiErrorLogFileName(workspaceID, wikiName));
|
|
const content = await readFile(filePath, 'utf8');
|
|
return {
|
|
content,
|
|
filePath,
|
|
};
|
|
}
|
|
}
|