/* eslint-disable @typescript-eslint/strict-boolean-expressions */ /* eslint-disable @typescript-eslint/no-unsafe-argument */ /* eslint-disable @typescript-eslint/require-await */ /* eslint-disable unicorn/no-null */ import { app } from 'electron'; import fsExtra from 'fs-extra'; import { injectable } from 'inversify'; import { Jimp } from 'jimp'; import { mapValues, pickBy } from 'lodash'; import { nanoid } from 'nanoid'; import path from 'path'; import { BehaviorSubject, Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { DELAY_MENU_REGISTER } from '@/constants/parameters'; import { getDefaultTidGiUrl } from '@/constants/urls'; import { IAuthenticationService } from '@services/auth/interface'; import { lazyInject } from '@services/container'; import { IDatabaseService } from '@services/database/interface'; import { i18n } from '@services/libs/i18n'; import { logger } from '@services/libs/log'; import type { IMenuService } from '@services/menu/interface'; import { IPagesService } from '@services/pages/interface'; import serviceIdentifier from '@services/serviceIdentifier'; import { SupportedStorageServices } from '@services/types'; import type { IViewService } from '@services/view/interface'; import type { IWikiService } from '@services/wiki/interface'; import { WindowNames } from '@services/windows/WindowProperties'; import type { IWorkspaceViewService } from '@services/workspacesView/interface'; import type { INewWorkspaceConfig, IWorkspace, IWorkspaceMetaData, IWorkspaceService, IWorkspacesWithMetadata, IWorkspaceWithMetadata } from './interface'; import { registerMenu } from './registerMenu'; import { workspaceSorter } from './utils'; @injectable() export class Workspace implements IWorkspaceService { /** * Record from workspace id to workspace settings */ private workspaces: Record | undefined; public workspaces$ = new BehaviorSubject(undefined); @lazyInject(serviceIdentifier.Wiki) private readonly wikiService!: IWikiService; @lazyInject(serviceIdentifier.Database) private readonly databaseService!: IDatabaseService; @lazyInject(serviceIdentifier.View) private readonly viewService!: IViewService; @lazyInject(serviceIdentifier.WorkspaceView) private readonly workspaceViewService!: IWorkspaceViewService; @lazyInject(serviceIdentifier.MenuService) private readonly menuService!: IMenuService; @lazyInject(serviceIdentifier.Authentication) private readonly authService!: IAuthenticationService; @lazyInject(serviceIdentifier.Pages) private readonly pagesService!: IPagesService; constructor() { setTimeout(() => { void registerMenu(); }, DELAY_MENU_REGISTER); } public getWorkspacesWithMetadata(): IWorkspacesWithMetadata { return mapValues(this.getWorkspacesSync(), (workspace: IWorkspace, id): IWorkspaceWithMetadata => ({ ...workspace, metadata: this.getMetaDataSync(id) })); } public updateWorkspaceSubject(): void { this.workspaces$.next(this.getWorkspacesWithMetadata()); } /** * Update items like "activate workspace1" or "open devtool in workspace1" in the menu */ private async updateWorkspaceMenuItems(): Promise { const newMenuItems = (await this.getWorkspacesAsList()).flatMap((workspace, index) => [ { label: (): string => workspace.name || `Workspace ${index + 1}`, id: workspace.id, type: 'checkbox' as const, checked: () => workspace.active, click: async (): Promise => { await this.workspaceViewService.setActiveWorkspaceView(workspace.id); // manually update menu since we have alter the active workspace await this.menuService.buildMenu(); }, accelerator: `CmdOrCtrl+${index + 1}`, }, { label: () => `${workspace.name || `Workspace ${index + 1}`} ${i18n.t('Menu.DeveloperToolsActiveWorkspace')}`, id: `${workspace.id}-devtool`, click: async () => { const view = this.viewService.getView(workspace.id, WindowNames.main); if (view !== undefined) { view.webContents.toggleDevTools(); } }, }, ]); /* eslint-enable @typescript-eslint/no-misused-promises */ await this.menuService.insertMenu('Workspaces', newMenuItems, undefined, undefined, 'updateWorkspaceMenuItems'); } /** * load workspaces in sync, and ensure it is an Object */ private getInitWorkspacesForCache(): Record { const workspacesFromDisk = this.databaseService.getSetting(`workspaces`) ?? {}; return typeof workspacesFromDisk === 'object' && !Array.isArray(workspacesFromDisk) ? mapValues(pickBy(workspacesFromDisk, (value) => value !== null) as unknown as Record, (workspace) => this.sanitizeWorkspace(workspace)) : {}; } public async getWorkspaces(): Promise> { return this.getWorkspacesSync(); } private getWorkspacesSync(): Record { // store in memory to boost performance if (this.workspaces === undefined) { this.workspaces = this.getInitWorkspacesForCache(); } return this.workspaces; } public async countWorkspaces(): Promise { return Object.keys(this.getWorkspacesSync()).length; } /** * Get sorted workspace list * Async so proxy type is async */ public async getWorkspacesAsList(): Promise { return Object.values(this.getWorkspacesSync()).sort(workspaceSorter); } /** * Get sorted workspace list * Sync for internal use */ private getWorkspacesAsListSync(): IWorkspace[] { return Object.values(this.getWorkspacesSync()).sort(workspaceSorter); } public async getSubWorkspacesAsList(workspaceID: string): Promise { const workspace = this.getSync(workspaceID); if (workspace === undefined) return []; if (workspace.isSubWiki) return []; return this.getWorkspacesAsListSync().filter((w) => w.mainWikiID === workspaceID).sort(workspaceSorter); } public getSubWorkspacesAsListSync(workspaceID: string): IWorkspace[] { const workspace = this.getSync(workspaceID); if (workspace === undefined) return []; if (workspace.isSubWiki) return []; return this.getWorkspacesAsListSync().filter((w) => w.mainWikiID === workspaceID).sort(workspaceSorter); } public async get(id: string): Promise { return this.getSync(id); } private getSync(id: string): IWorkspace | undefined { return this.getWorkspacesSync()[id]; } public get$(id: string): Observable { return this.workspaces$.pipe(map((workspaces) => workspaces?.[id])); } public async set(id: string, workspace: IWorkspace, immediate?: boolean): Promise { const workspaces = this.getWorkspacesSync(); const workspaceToSave = this.sanitizeWorkspace(workspace); await this.reactBeforeWorkspaceChanged(workspaceToSave); workspaces[id] = workspaceToSave; this.databaseService.setSetting('workspaces', workspaces); if (immediate === true) { await this.databaseService.immediatelyStoreSettingsToFile(); } // update subject so ui can react to it this.updateWorkspaceSubject(); // menu is mostly invisible, so we don't need to update it immediately void this.updateWorkspaceMenuItems(); } public async update(id: string, workspaceSetting: Partial, immediate?: boolean): Promise { const workspace = this.getSync(id); if (workspace === undefined) { logger.error(`Could not update workspace ${id} because it does not exist`); return; } await this.set(id, { ...workspace, ...workspaceSetting }, immediate); } public async setWorkspaces(newWorkspaces: Record): Promise { for (const id in newWorkspaces) { await this.set(id, newWorkspaces[id]); } } public getMainWorkspace(subWorkspace: IWorkspace): IWorkspace | undefined { const { mainWikiID, isSubWiki, mainWikiToLink } = subWorkspace; if (!isSubWiki) return undefined; if (mainWikiID) return this.getSync(mainWikiID); const mainWorkspace = (this.getWorkspacesAsListSync() ?? []).find( (workspaceToSearch) => mainWikiToLink === workspaceToSearch.wikiFolderLocation, ); return mainWorkspace; } /** * Pure function that make sure workspace setting is consistent, or doing migration across updates * @param workspaceToSanitize User input workspace or loaded workspace, that may contains bad values */ private sanitizeWorkspace(workspaceToSanitize: IWorkspace): IWorkspace { const defaultValues: Partial = { storageService: SupportedStorageServices.github, backupOnInterval: true, excludedPlugins: [], enableHTTPAPI: false, }; const fixingValues: Partial = {}; // we add mainWikiID in creation, we fix this value for old existed workspaces if (workspaceToSanitize.isSubWiki && !workspaceToSanitize.mainWikiID) { const mainWorkspace = this.getMainWorkspace(workspaceToSanitize); if (mainWorkspace !== undefined) { fixingValues.mainWikiID = mainWorkspace.id; } } // fix WikiChannel.openTiddler in src/services/wiki/wikiOperations/executor/wikiOperationInBrowser.ts have \n on the end if (workspaceToSanitize.tagName?.endsWith('\n') === true) { fixingValues.tagName = workspaceToSanitize.tagName.replaceAll('\n', ''); } // before 0.8.0, tidgi was loading http content, so lastUrl will be http protocol, but later we switch to tidgi:// protocol, so old value can't be used. if (!workspaceToSanitize.lastUrl?.startsWith('tidgi')) { fixingValues.lastUrl = null; } if (!workspaceToSanitize.homeUrl?.startsWith('tidgi')) { fixingValues.homeUrl = getDefaultTidGiUrl(workspaceToSanitize.id); } if (workspaceToSanitize.tokenAuth && !workspaceToSanitize.authToken) { fixingValues.authToken = this.authService.generateOneTimeAdminAuthTokenForWorkspaceSync(workspaceToSanitize.id); } return { ...defaultValues, ...workspaceToSanitize, ...fixingValues }; } /** * Do some side effect before config change, update other services or filesystem, with new and old values * This happened after values sanitized * @param newWorkspaceConfig new workspace settings */ private async reactBeforeWorkspaceChanged(newWorkspaceConfig: IWorkspace): Promise { const existedWorkspace = this.getSync(newWorkspaceConfig.id); const { id, tagName } = newWorkspaceConfig; // when update tagName of subWiki if (existedWorkspace !== undefined && existedWorkspace.isSubWiki && typeof tagName === 'string' && tagName.length > 0 && existedWorkspace.tagName !== tagName) { const { mainWikiToLink, wikiFolderLocation } = existedWorkspace; if (typeof mainWikiToLink !== 'string') { throw new TypeError( `mainWikiToLink is null in reactBeforeWorkspaceChanged when try to updateSubWikiPluginContent, workspacesID: ${id}\n${ JSON.stringify( this.workspaces, ) }`, ); } await this.wikiService.updateSubWikiPluginContent(mainWikiToLink, wikiFolderLocation, newWorkspaceConfig, { ...newWorkspaceConfig, tagName: existedWorkspace.tagName, }); await this.wikiService.wikiStartup(newWorkspaceConfig); } } public async getByWikiFolderLocation(wikiFolderLocation: string): Promise { return (await this.getWorkspacesAsList()).find((workspace) => workspace.wikiFolderLocation === wikiFolderLocation); } public getPreviousWorkspace = async (id: string): Promise => { const workspaceList = await this.getWorkspacesAsList(); let currentWorkspaceIndex = 0; for (const [index, workspace] of workspaceList.entries()) { if (workspace.id === id) { currentWorkspaceIndex = index; break; } } if (currentWorkspaceIndex === 0) { return workspaceList.at(-1); } return workspaceList[currentWorkspaceIndex - 1]; }; public getNextWorkspace = async (id: string): Promise => { const workspaceList = await this.getWorkspacesAsList(); let currentWorkspaceIndex = 0; for (const [index, workspace] of workspaceList.entries()) { if (workspace.id === id) { currentWorkspaceIndex = index; break; } } if (currentWorkspaceIndex === workspaceList.length - 1) { return workspaceList[0]; } return workspaceList[currentWorkspaceIndex + 1]; }; public getActiveWorkspace = async (): Promise => { return this.getActiveWorkspaceSync(); }; public getActiveWorkspaceSync = (): IWorkspace | undefined => { return this.getWorkspacesAsListSync().find((workspace) => workspace.active); }; public getFirstWorkspace = async (): Promise => { return this.getFirstWorkspaceSync(); }; public getFirstWorkspaceSync = (): IWorkspace | undefined => { return this.getWorkspacesAsListSync()[0]; }; public async setActiveWorkspace(id: string, oldActiveWorkspaceID: string | undefined): Promise { // active new one await this.update(id, { active: true, hibernated: false }); // de-active the other one if (oldActiveWorkspaceID !== id) { await this.clearActiveWorkspace(oldActiveWorkspaceID); } // switch from page to workspace, clear active page to switch to WikiBackground page const activePage = this.pagesService.getActivePageSync(); // instead of switch to a wiki workspace, we simply clear active page, because wiki page logic is not implemented yet, we are still using workspace logic. await this.pagesService.clearActivePage(activePage?.id); } public async clearActiveWorkspace(oldActiveWorkspaceID: string | undefined): Promise { // de-active the other one if (typeof oldActiveWorkspaceID === 'string') { await this.update(oldActiveWorkspaceID, { active: false }); } } /** * @param id workspace id * @param sourcePicturePath image path, could be an image in app's resource folder or temp folder, we will copy it into app data folder */ public async setWorkspacePicture(id: string, sourcePicturePath: string): Promise { const workspace = this.getSync(id); if (workspace === undefined) { throw new Error(`Try to setWorkspacePicture() but this workspace is not existed ${id}`); } const pictureID = nanoid(); if (workspace.picturePath === sourcePicturePath) { return; } const destinationPicturePath = path.join(app.getPath('userData'), 'pictures', `${pictureID}.png`) as `${string}.${string}`; const newImage = await Jimp.read(sourcePicturePath); await newImage.clone().resize({ w: 128, h: 128 }).write(destinationPicturePath); const currentPicturePath = this.getSync(id)?.picturePath; await this.update(id, { picturePath: destinationPicturePath, }); if (currentPicturePath) { try { await fsExtra.remove(currentPicturePath); } catch (error) { console.error(error); } } } public async removeWorkspacePicture(id: string): Promise { const workspace = this.getSync(id); if (workspace === undefined) { throw new Error(`Try to removeWorkspacePicture() but this workspace is not existed ${id}`); } if (workspace.picturePath) { await fsExtra.remove(workspace.picturePath); await this.set(id, { ...workspace, picturePath: null, }); } } public async remove(id: string): Promise { const workspaces = this.getWorkspacesSync(); if (id in workspaces) { // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete workspaces[id]; this.databaseService.setSetting('workspaces', workspaces); } else { throw new Error(`Try to remote workspace, but id ${id} is not existed`); } this.updateWorkspaceSubject(); void this.updateWorkspaceMenuItems(); } public async create(newWorkspaceConfig: INewWorkspaceConfig): Promise { const newID = nanoid(); // find largest order const workspaceLst = await this.getWorkspacesAsList(); let max = 0; for (const element of workspaceLst) { if (element.order > max) { max = element.order; } } const newWorkspace: IWorkspace = { userName: '', ...newWorkspaceConfig, active: false, disableAudio: false, disableNotifications: false, hibernated: false, hibernateWhenUnused: false, homeUrl: getDefaultTidGiUrl(newID), id: newID, lastUrl: null, lastNodeJSArgv: [], order: max + 1, picturePath: null, subWikiFolderName: 'subwiki', syncOnInterval: false, syncOnStartup: true, transparentBackground: false, enableHTTPAPI: false, excludedPlugins: [], }; await this.set(newID, newWorkspace); return newWorkspace; } /** to keep workspace variables (meta) that * are not saved to disk * badge count, error, etc */ private metaData: Record> = {}; public getMetaData = async (id: string): Promise> => this.getMetaDataSync(id); private readonly getMetaDataSync = (id: string): Partial => this.metaData[id] ?? {}; public getAllMetaData = async (): Promise>> => this.metaData; public updateMetaData = async (id: string, options: Partial): Promise => { logger.debug(`updateMetaData(${id})`, options); this.metaData[id] = { ...this.metaData[id], ...options, }; this.updateWorkspaceSubject(); }; public async workspaceDidFailLoad(id: string): Promise { const workspaceMetaData = this.getMetaDataSync(id); return typeof workspaceMetaData?.didFailLoadErrorMessage === 'string' && workspaceMetaData.didFailLoadErrorMessage.length > 0; } }