diff --git a/docs/features/WikiWorkspaceCreation.md b/docs/features/WikiWorkspaceCreation.md index dc783be5..30e925ca 100644 --- a/docs/features/WikiWorkspaceCreation.md +++ b/docs/features/WikiWorkspaceCreation.md @@ -4,14 +4,14 @@ Wiki workspaces are the core concept in TidGi, representing individual TiddlyWiki instances with associated configuration, Git repositories, and UI views. This document explains how wiki workspaces are created in two scenarios: -1. **Automatic creation** when the application starts with no existing workspaces -2. **Manual creation** through the frontend UI +1. Automatic creation when the application starts with no existing workspaces +2. Manual creation through the frontend UI ## Automatic Workspace Creation ### Startup Flow -When TidGi launches without any existing workspaces, it automatically creates a default wiki workspace. This logic is implemented in the initialization chain: +When TidGi launches without any existing workspaces, it automatically creates a default wiki workspace. The initialization chain follows this sequence: ```mermaid sequenceDiagram @@ -43,123 +43,19 @@ sequenceDiagram ### Implementation Details -#### 1. Entry Point (main.ts) +The initialization process involves several key steps: -The initialization starts in `src/main.ts` in the `commonInit()` function: +1. Entry Point - The `commonInit()` function in [src/main.ts](../../src/main.ts) coordinates the startup sequence. It initializes the database, then calls `wikiGitWorkspaceService.initialize()` to handle automatic workspace creation. -```typescript -const commonInit = async (): Promise => { - await app.whenReady(); - await initDevelopmentExtension(); +2. Workspace Creation - The `initialize()` method in [src/services/wikiGitWorkspace/index.ts](../../src/services/wikiGitWorkspace/index.ts) checks if any wiki workspaces exist. If not, it creates a default workspace with basic configuration (name: 'wiki', port: 5212, local storage). - // Initialize database FIRST - all other services depend on it - await databaseService.initializeForApp(); +3. Template and Git Setup - The `initWikiGitTransaction()` method handles the complete creation process: + - Creates workspace record via `workspaceService.create()` + - Copies base TiddlyWiki files using `wikiService.copyWikiTemplate()` + - Initializes Git repository with `gitService.initWikiGit()` if needed + - Rolls back all changes if any step fails - // ... other initializations ... - - // Auto-create default wiki workspace if none exists - await wikiGitWorkspaceService.initialize(); - - // Create default page workspaces before initializing all workspace views - await workspaceService.initializeDefaultPageWorkspaces(); - - // Perform wiki startup and git sync for each workspace - await workspaceViewService.initializeAllWorkspaceView(); -}; -``` - -#### 2. WikiGitWorkspaceService.initialize() - -Located in `src/services/wikiGitWorkspace/index.ts`, this method checks if any wiki workspaces exist and creates a default one if needed: - -```typescript -public async initialize(): Promise { - const workspaceService = container.get(serviceIdentifier.Workspace); - const workspaces = await workspaceService.getWorkspacesAsList(); - const wikiWorkspaces = workspaces.filter(w => isWikiWorkspace(w) && !w.isSubWiki); - - // Exit if any wiki workspaces already exist - if (wikiWorkspaces.length > 0) return; - - // Construct minimal default config with required fields - const defaultConfig: INewWikiWorkspaceConfig = { - order: 0, - wikiFolderLocation: DEFAULT_FIRST_WIKI_PATH, - storageService: SupportedStorageServices.local, - name: 'wiki', - port: 5212, - isSubWiki: false, - backupOnInterval: true, - readOnlyMode: false, - tokenAuth: false, - tagName: null, - mainWikiToLink: null, - mainWikiID: null, - excludedPlugins: [], - enableHTTPAPI: false, - lastNodeJSArgv: [], - homeUrl: '', - gitUrl: null, - }; - - try { - // Copy the wiki template first - const wikiService = container.get(serviceIdentifier.Wiki); - await wikiService.copyWikiTemplate(DEFAULT_FIRST_WIKI_FOLDER_PATH, 'wiki'); - - // Create the workspace - await this.initWikiGitTransaction(defaultConfig); - } catch (error) { - logger.error(error.message, error); - } -} -``` - -#### 3. Wiki Template and Git Initialization - -The `initWikiGitTransaction` method handles the complete workspace creation: - -1. **Create workspace record**: Calls `workspaceService.create(newWorkspaceConfig)` to persist workspace configuration -2. **Copy wiki template**: Uses `wikiService.copyWikiTemplate()` to copy base TiddlyWiki files -3. **Initialize Git repository**: If not already initialized, calls `gitService.initWikiGit()` -4. **Rollback on failure**: If any step fails, removes the created workspace and wiki folder - -```typescript -public initWikiGitTransaction = async ( - newWorkspaceConfig: INewWikiWorkspaceConfig, - userInfo?: IGitUserInfos -): Promise => { - const workspaceService = container.get(serviceIdentifier.Workspace); - const newWorkspace = await workspaceService.create(newWorkspaceConfig); - - try { - // ... Git initialization logic ... - - if (await hasGit(wikiFolderLocation)) { - logger.warn('Skip git init because it already has a git setup.'); - } else { - const gitService = container.get(serviceIdentifier.Git); - await gitService.initWikiGit(wikiFolderLocation, isSyncedWiki, !isSubWiki, gitUrl, userInfo); - } - - return newWorkspace; - } catch (error) { - // Rollback: remove workspace and wiki folder - await workspaceService.remove(workspaceID); - await wikiService.removeWiki(wikiFolderLocation); - throw new InitWikiGitError(error.message); - } -}; -``` - -#### 4. View Initialization - -After workspace creation, `workspaceViewService.initializeAllWorkspaceView()` starts each wiki: - -1. **Check wiki validity**: Verifies the wiki folder contains valid TiddlyWiki files -2. **Start wiki server**: Launches the TiddlyWiki Node.js server -3. **Create browser view**: Creates an Electron WebContentsView to display the wiki -4. **Load initial URL**: Navigates the view to the wiki's home URL +4. View Initialization - After creation, `workspaceViewService.initializeAllWorkspaceView()` validates the wiki folder, starts the TiddlyWiki Node.js server, creates the browser view, and loads the initial URL. ## Manual Workspace Creation @@ -174,7 +70,7 @@ flowchart TD C -->|Create New| D[useNewWiki hook] C -->|Clone Existing| E[useCloneWiki hook] - C -->|Open Existing| F[useOpenWiki hook] + C -->|Open Existing| F[useExistedWiki hook] D --> G[Fill form: name, folder, port] E --> H[Fill form: git URL, credentials] @@ -193,201 +89,23 @@ flowchart TD ### Frontend Components -#### 1. Form State Management (useForm.ts) +1. Form State Management - The `useWikiWorkspaceForm` hook in [src/windows/AddWorkspace/useForm.ts](../../src/windows/AddWorkspace/useForm.ts) manages the workspace creation form state, including folder name, location, port, and storage provider settings. -Located in `src/pages/AddWorkspace/useForm.ts`, manages workspace creation form state: +2. Creation Hooks - Three hooks handle different creation methods: -```typescript -export function useWikiWorkspaceForm(options?: { fromExisted: boolean }) { - const [wikiFolderName, wikiFolderNameSetter] = useState('tiddlywiki'); - const [parentFolderLocation, parentFolderLocationSetter] = useState(''); - const [wikiPort, wikiPortSetter] = useState(5212); - const [storageProvider, storageProviderSetter] = useState( - SupportedStorageServices.local - ); - - // Initialize default folder path - useEffect(() => { - (async function getDefaultExistedWikiFolderPathEffect() { - const desktopPath = await window.service.context.get('DEFAULT_WIKI_FOLDER'); - parentFolderLocationSetter(desktopPath); - })(); - }, []); - - // ... rest of form state ... -} -``` + - useNewWiki - Creates a new wiki from template. Located in [src/windows/AddWorkspace/useNewWiki.ts](../../src/windows/AddWorkspace/useNewWiki.ts), it copies the wiki template and initializes the workspace. -#### 2. Creation Hooks + - useCloneWiki - Clones an existing wiki from a Git repository. Located in [src/windows/AddWorkspace/useCloneWiki.ts](../../src/windows/AddWorkspace/useCloneWiki.ts), it handles Git cloning and workspace initialization. -Three main hooks handle different creation methods: + - useExistedWiki - Opens an existing wiki folder without copying or cloning. Located in [src/windows/AddWorkspace/useExistedWiki.ts](../../src/windows/AddWorkspace/useExistedWiki.ts), it validates the existing folder and initializes the workspace. -##### useNewWiki (useNewWiki.ts) - -Creates a new wiki from template: - -```typescript -export function useNewWiki( - isCreateMainWorkspace: boolean, - isCreateSub: boolean, - form: IWikiWorkspaceForm, - wikiCreationMessageSetter: (m: string) => void, - // ... -): () => Promise { - const onSubmit = useCallback(async () => { - wikiCreationMessageSetter(t('AddWorkspace.Processing')); - - try { - const newWorkspaceConfig = workspaceConfigFromForm(form, isCreateMainWorkspace, true); - - // Create wiki folder and files - if (isCreateMainWorkspace) { - await window.service.wiki.copyWikiTemplate( - form.parentFolderLocation, - form.wikiFolderName - ); - } else { - await window.service.wiki.copySubWikiTemplate(/* ... */); - } - - // Initialize workspace and Git - await callWikiInitialization(newWorkspaceConfig, wikiCreationMessageSetter, t, gitUserInfo, { - from: WikiCreationMethod.Create - }); - } catch (error) { - wikiCreationMessageSetter(error.message); - hasErrorSetter(true); - } - }, [/* dependencies */]); - - return onSubmit; -} -``` - -##### useCloneWiki (useCloneWiki.ts) - -Clones an existing wiki from a Git repository: - -```typescript -export function useCloneWiki(/* ... */): () => Promise { - const onSubmit = useCallback(async () => { - wikiCreationMessageSetter(t('AddWorkspace.Processing')); - - try { - const newWorkspaceConfig = workspaceConfigFromForm(form, isCreateMainWorkspace, true); - - // Clone from Git repository - if (isCreateMainWorkspace) { - await window.service.wiki.cloneWiki( - form.parentFolderLocation, - form.wikiFolderName, - form.gitRepoUrl, - form.gitUserInfo! - ); - } else { - await window.service.wiki.cloneSubWiki(/* ... */); - } - - // Initialize workspace - await callWikiInitialization(newWorkspaceConfig, wikiCreationMessageSetter, t, gitUserInfo, { - from: WikiCreationMethod.Clone - }); - } catch (error) { - wikiCreationMessageSetter(error.message); - hasErrorSetter(true); - } - }, [/* dependencies */]); - - return onSubmit; -} -``` - -##### useOpenWiki (useOpenWiki.ts) - -Opens an existing wiki folder: - -```typescript -export function useOpenWiki(/* ... */): () => Promise { - const onSubmit = useCallback(async () => { - wikiCreationMessageSetter(t('AddWorkspace.Processing')); - - try { - const newWorkspaceConfig = workspaceConfigFromForm(form, isCreateMainWorkspace, false); - - // No need to copy template or clone, wiki folder already exists - // Just initialize workspace and start wiki - await callWikiInitialization(newWorkspaceConfig, wikiCreationMessageSetter, t, gitUserInfo, { - from: WikiCreationMethod.Open - }); - } catch (error) { - wikiCreationMessageSetter(error.message); - hasErrorSetter(true); - } - }, [/* dependencies */]); - - return onSubmit; -} -``` - -#### 3. Common Initialization (callWikiInitialization) - -Located in `src/pages/AddWorkspace/useCallWikiInitialization.ts`, this function performs the final workspace initialization steps: - -```typescript -export async function callWikiInitialization( - newWorkspaceConfig: INewWorkspaceConfig, - wikiCreationMessageSetter: (m: string) => void, - t: TFunction<'translation'>, - gitUserInfo: IGitUserInfos | undefined, - configs: ICallWikiInitConfig, -): Promise { - // Step 1: Initialize workspace and Git - wikiCreationMessageSetter(t('Log.InitializeWikiGit')); - const newWorkspace = await window.service.wikiGitWorkspace.initWikiGitTransaction( - newWorkspaceConfig, - gitUserInfo - ); - - if (newWorkspace === undefined) { - throw new Error('newWorkspace is undefined'); - } - - // Step 2: Initialize workspace view (starts wiki server, creates browser view) - wikiCreationMessageSetter(t('Log.InitializeWorkspaceView')); - await window.service.workspaceView.initializeWorkspaceView(newWorkspace, { - isNew: true, - from: configs.from - }); - - // Step 3: Activate the new workspace - wikiCreationMessageSetter(t('Log.InitializeWorkspaceViewDone')); - await window.service.workspaceView.setActiveWorkspaceView(newWorkspace.id); - - // Step 4: Close Add Workspace window (if not disabled) - if (!configs.notClose) { - await window.service.window.close(WindowNames.addWorkspace); - } -} -``` +3. Common Initialization - The `callWikiInitialization` function in [src/windows/AddWorkspace/useCallWikiInitialization.ts](../../src/windows/AddWorkspace/useCallWikiInitialization.ts) performs the final steps: initializing workspace and Git, creating the workspace view, activating it, and closing the Add Workspace window. ## Workspace Creation Validation -### Wiki Folder Validation +The `checkWikiExist` method in WikiService validates that a folder contains a valid TiddlyWiki by checking folder existence, tiddlywiki.info file, and required plugin files. If validation fails, it can prompt the user to remove the invalid workspace. -The `checkWikiExist` method in WikiService validates that a folder contains a valid TiddlyWiki: - -1. **Check folder exists**: Verifies the wiki folder path exists -2. **Check tiddlywiki.info**: For main wikis, requires `tiddlywiki.info` file -3. **Check plugin files**: Verifies required TiddlyWiki core files exist -4. **Show error dialog**: If validation fails and `showDialog: true`, prompts user to remove invalid workspace - -The error message in the CI logs shows this validation: - -```log -无法找到之前还在该处的工作区知识库文件夹!该目录不是一个知识库文件夹 -``` - -This occurs when `initWikiGit` completes but wiki template files are not yet created, causing `initializeAllWorkspaceView` to fail validation. +The error "无法找到之前还在该处的工作区知识库文件夹!该目录不是一个知识库文件夹" occurs when `initWikiGit` completes but wiki template files are not yet created, causing `initializeAllWorkspaceView` to fail validation. ## Related Code @@ -401,43 +119,25 @@ This occurs when `initWikiGit` completes but wiki template files are not yet cre ### Frontend UI Components -- [AddWorkspace/useForm.ts](../../src/pages/AddWorkspace/useForm.ts): Form state management -- [AddWorkspace/useNewWiki.ts](../../src/pages/AddWorkspace/useNewWiki.ts): Create new wiki -- [AddWorkspace/useCloneWiki.ts](../../src/pages/AddWorkspace/useCloneWiki.ts): Clone from Git -- [AddWorkspace/useOpenWiki.ts](../../src/pages/AddWorkspace/useOpenWiki.ts): Open existing wiki -- [AddWorkspace/useCallWikiInitialization.ts](../../src/pages/AddWorkspace/useCallWikiInitialization.ts): Common initialization logic +- [AddWorkspace/useForm.ts](../../src/windows/AddWorkspace/useForm.ts): Form state management +- [AddWorkspace/useNewWiki.ts](../../src/windows/AddWorkspace/useNewWiki.ts): Create new wiki +- [AddWorkspace/useCloneWiki.ts](../../src/windows/AddWorkspace/useCloneWiki.ts): Clone from Git +- [AddWorkspace/useExistedWiki.ts](../../src/windows/AddWorkspace/useExistedWiki.ts): Open existing wiki +- [AddWorkspace/useCallWikiInitialization.ts](../../src/windows/AddWorkspace/useCallWikiInitialization.ts): Common initialization logic ## Common Issues -### 1. Wiki Validation Failure +1. Wiki Validation Failure - Error "该目录不是一个知识库文件夹" appears when wiki template files are not fully created before validation runs. Ensure `copyWikiTemplate()` completes before calling `initWikiGitTransaction()`. -**Symptom**: Error message "该目录不是一个知识库文件夹" during initialization +2. Git Initialization Timeout - Workspace creation may hang during Git initialization in CI or slow network conditions. Consider implementing timeout protection in `initWikiGit()` or skipping Git init for local-only wikis. -**Cause**: Wiki template files not fully created before validation runs - -**Solution**: Ensure `copyWikiTemplate()` completes before calling `initWikiGitTransaction()` - -### 2. Git Initialization Timeout - -**Symptom**: Workspace creation hangs during Git initialization - -**Cause**: Git operations taking too long in CI or slow network conditions - -**Solution**: Implement timeout protection in `initWikiGit()` or skip Git init for local-only wikis - -### 3. Worker Not Starting - -**Symptom**: Wiki operations timeout after workspace creation - -**Cause**: Worker initialization fails if wiki folder validation fails - -**Solution**: Ensure wiki folder passes validation before starting worker +3. Worker Not Starting - Wiki operations timeout after workspace creation if worker initialization fails due to folder validation failure. Ensure wiki folder passes validation before starting worker. ## Best Practices -1. **Atomic Operations**: Use transactions (`initWikiGitTransaction`) to rollback on failure -2. **Validation First**: Always validate wiki folders before starting services -3. **Progress Feedback**: Use `wikiCreationMessageSetter` to show user progress -4. **Error Handling**: Catch and display user-friendly error messages -5. **Default Values**: Provide sensible defaults for optional configuration -6. **Cleanup on Failure**: Always remove partially created workspaces on error +1. Use transactions (`initWikiGitTransaction`) to rollback on failure +2. Always validate wiki folders before starting services +3. Use `wikiCreationMessageSetter` to show user progress +4. Catch and display user-friendly error messages +5. Provide sensible defaults for optional configuration +6. Always remove partially created workspaces on error diff --git a/docs/features/WorkspaceConfigSync.md b/docs/features/WorkspaceConfigSync.md new file mode 100644 index 00000000..f16dc5a4 --- /dev/null +++ b/docs/features/WorkspaceConfigSync.md @@ -0,0 +1,127 @@ +# Workspace Configuration Sync + +## Overview + +This document describes how workspace configurations are stored and synced across devices. Some configurations are device-specific and stored locally, while others can be synced via Git through a `tidgi.config.json` file in the wiki folder. + +## Configuration Categories + +### Local-only Fields (stored in database) + +These fields are device-specific and should NOT be synced: + +| Field | Reason | +|-------|--------| +| `id` | Unique identifier, different per installation | +| `order` | User preference for sidebar order, device-specific | +| `active` | Current active state, runtime only | +| `hibernated` | Current hibernation state, runtime only | +| `lastUrl` | Last visited URL, device-specific | +| `lastNodeJSArgv` | Node.js arguments, may vary by device | +| `homeUrl` | Generated from workspace id | +| `authToken` | Security token, should not be synced | +| `picturePath` | Local file path to workspace icon | +| `wikiFolderLocation` | Absolute path, different per device | +| `mainWikiToLink` | Absolute path to main wiki | +| `mainWikiID` | References local workspace id | +| `isSubWiki` | Structural relationship, set during creation | + +### Syncable Fields (stored in tidgi.config.json) + +These fields represent user preferences that should follow the wiki across devices: + +| Field | Description | +|-------|-------------| +| `name` | Display name for the workspace | +| `port` | Server port number | +| `gitUrl` | Git repository URL for syncing | +| `storageService` | Storage service type (github, gitlab, local) | +| `userName` | Git username for this workspace | +| `readOnlyMode` | Whether wiki is in readonly mode | +| `tokenAuth` | Whether token authentication is enabled | +| `enableHTTPAPI` | Whether HTTP API is enabled | +| `enableFileSystemWatch` | Whether file system watching is enabled | +| `ignoreSymlinks` | Whether to ignore symlinks in file watching | +| `backupOnInterval` | Whether to backup on interval | +| `syncOnInterval` | Whether to sync on interval | +| `syncOnStartup` | Whether to sync on startup | +| `disableAudio` | Whether audio is disabled | +| `disableNotifications` | Whether notifications are disabled | +| `hibernateWhenUnused` | Whether to hibernate when unused | +| `transparentBackground` | Whether background is transparent | +| `excludedPlugins` | List of plugins to exclude on startup | +| `tagNames` | Tag names for sub-wiki routing | +| `includeTagTree` | Whether to include tag tree for routing | +| `fileSystemPathFilterEnable` | Whether path filter is enabled | +| `fileSystemPathFilter` | Path filter expressions | +| `rootTiddler` | Root tiddler for lazy loading | +| `https` | HTTPS configuration | + +## File Location + +The syncable configuration is stored in: + +``` +{wikiFolderLocation}/tidgi.config.json +``` + +For main wikis, this is in the wiki root directory (alongside `tiddlywiki.info`). +For sub-wikis, this is in the sub-wiki folder (alongside tiddler files). + +## File Exclusion + +`tidgi.config.json` is excluded from being treated as a tiddler through multiple mechanisms: + +1. **Main wiki**: The file is in wiki root, not in `tiddlers/` folder, so TiddlyWiki's boot process ignores it +2. **Sub-wiki loading**: [loadWikiTiddlersWithSubWikis.ts](../../src/services/wiki/wikiWorker/loadWikiTiddlersWithSubWikis.ts) explicitly skips files named `tidgi.config.json` +3. **File watcher**: [FileSystemWatcher.ts](../../src/services/wiki/plugin/watchFileSystemAdaptor/FileSystemWatcher.ts) has `tidgi.config.json` in `excludedFileNames` list + +## File Format + +```json +{ + "$schema": "https://tidgi.app/schemas/tidgi.config.schema.json", + "version": 1, + "name": "My Wiki", + "port": 5212, + "storageService": "github", + "gitUrl": "https://github.com/user/wiki.git", + "readOnlyMode": false, + "enableHTTPAPI": false +} +``` + +Only non-default values are saved to keep the file minimal. When loading, missing fields use defaults from `syncableConfigDefaultValues`. + +## Loading Priority + +When loading a workspace: + +1. Read local config from database (includes device-specific fields) +2. Read `tidgi.config.json` from wiki folder (if exists) +3. Merge syncable config over local config +4. Apply default values for any missing fields + +This ensures synced preferences take precedence over stale local values. + +## Saving Behavior + +When saving workspace config: + +1. Separate fields into local and syncable categories +2. Save local fields to database (only non-default values) +3. Save syncable fields to `tidgi.config.json` (only non-default values) + +## Migration + +For existing workspaces without `tidgi.config.json`: + +1. On first load, create `tidgi.config.json` with current syncable values +2. This happens automatically when workspace is loaded or saved +3. Existing local database config is preserved + +## Related Code + +- [src/services/workspaces/interface.ts](../../src/services/workspaces/interface.ts) - Type definitions +- [src/services/workspaces/index.ts](../../src/services/workspaces/index.ts) - WorkspaceService implementation +- [src/services/workspaces/configSync.ts](../../src/services/workspaces/configSync.ts) - Config sync utilities diff --git a/docs/internal/IPCSyncAdaptorAndFSAdaptor.md b/docs/internal/IPCSyncAdaptorAndFSAdaptor.md index 71713659..8301758a 100644 --- a/docs/internal/IPCSyncAdaptorAndFSAdaptor.md +++ b/docs/internal/IPCSyncAdaptorAndFSAdaptor.md @@ -41,10 +41,12 @@ Frontend (Browser) Backend (Node.js Worker) ### 2. Syncer-Driven Updates (Refactored Architecture) **Previous Approach (Problematic):** + - FileSystemWatcher directly called `wiki.addTiddler()` when files changed - Led to echo problems and complex edge case handling **Current Approach (Syncer-Driven):** + - FileSystemWatcher only collects changes into `updatedTiddlers` list - Triggers `$tw.syncer.syncFromServer()` to let TiddlyWiki's syncer handle updates - Syncer calls `getUpdatedTiddlers()` to get change list @@ -52,6 +54,7 @@ Frontend (Browser) Backend (Node.js Worker) - Syncer uses `storeTiddler()` which properly updates changeCount to prevent echo Benefits: + - Leverages TiddlyWiki's built-in sync queue and throttling - Proper handling of batch changes (git checkout) - Eliminates echo loops via syncer's changeCount tracking @@ -59,11 +62,13 @@ Benefits: ### 3. Two-Layer Echo Prevention **IPC Layer (First Defense)**: + - `ipcServerRoutes.ts` tracks recently saved tiddlers in `recentlySavedTiddlers` Set - When `wiki.addTiddler()` triggers change event, filter out tiddlers in the Set - Prevents frontend from receiving its own save operations as change notifications **Watch-FS Layer (Second Defense)**: + - When saving/deleting, watch-fs temporarily excludes file from monitoring - Prevents watcher from detecting the file write operation - Re-includes file after operation completes (with delay for nsfw debounce) @@ -81,6 +86,7 @@ Benefits: **Purpose:** Monitor file system changes without directly modifying wiki state **Key Features:** + - Uses `nsfw` library for native file system watching - Maintains `updatedTiddlers` list for pending changes - Implements `getUpdatedTiddlers()` for syncer integration @@ -89,6 +95,7 @@ Benefits: - Manages file exclusion list for echo prevention **Key Methods:** + - `getUpdatedTiddlers(syncer, callback)`: Returns collected changes - `loadTiddler(title, callback)`: Loads tiddler content from file - `excludeFile(path)`: Temporarily exclude file from watching @@ -99,12 +106,14 @@ Benefits: **Purpose:** Coordinate between FileSystemWatcher and syncer, implement syncadaptor interface **Key Features:** + - Extends FileSystemAdaptor for file save/delete operations - Delegates file watching to FileSystemWatcher - Implements full syncadaptor interface for Node.js syncer - Coordinates file exclusion during save/delete operations **Key Methods:** + - `getUpdatedTiddlers()`: Delegates to FileSystemWatcher - `loadTiddler()`: Delegates to FileSystemWatcher - `saveTiddler()`: Saves to file with exclusion handling @@ -115,6 +124,7 @@ Benefits: **Purpose:** Handle tiddler file save/delete operations with sub-wiki routing **Key Features:** + - Routes tiddlers to sub-wikis based on tags - Generates file paths using TiddlyWiki's FileSystemPaths - Handles external attachment file movement @@ -125,6 +135,7 @@ Benefits: **Purpose:** Bridge between frontend TiddlyWiki and backend file system **Key Features:** + - Communicates via IPC using `tidgi://` custom protocol - Subscribes to change events via IPC Observable - Maintains `updatedTiddlers` list from IPC events @@ -289,6 +300,7 @@ SYNCER_TRIGGER_DELAY_MS = 200 // Debounce for syncer trigger ### Why Delay DELETE Events? Git operations often delete then recreate files quickly. The delay allows: + 1. CREATE event to arrive and cancel pending DELETE 2. Treat as modification instead of delete+create 3. Prevents "missing tiddler" errors during git operations diff --git a/features/stepDefinitions/application.ts b/features/stepDefinitions/application.ts index 1780a88b..3d3a5f7d 100644 --- a/features/stepDefinitions/application.ts +++ b/features/stepDefinitions/application.ts @@ -42,6 +42,7 @@ export class ApplicationWorld { currentWindow: Page | undefined; // New state-managed current window mockOpenAIServer: MockOpenAIServer | undefined; mockOAuthServer: MockOAuthServer | undefined; + savedWorkspaceId: string | undefined; // For storing workspace ID between steps // Helper method to check if window is visible async isWindowVisible(page: Page): Promise { diff --git a/features/stepDefinitions/wiki.ts b/features/stepDefinitions/wiki.ts index afedf604..2c4c0178 100644 --- a/features/stepDefinitions/wiki.ts +++ b/features/stepDefinitions/wiki.ts @@ -3,10 +3,15 @@ import { exec as gitExec } from 'dugite'; import { backOff } from 'exponential-backoff'; import fs from 'fs-extra'; import path from 'path'; -import type { IWorkspace } from '../../src/services/workspaces/interface'; +import type { IWikiWorkspace, IWorkspace } from '../../src/services/workspaces/interface'; import { settingsDirectory, settingsPath, wikiTestRootPath, wikiTestWikiPath } from '../supports/paths'; import type { ApplicationWorld } from './application'; +// Type guard for wiki workspace +function isWikiWorkspace(workspace: IWorkspace): workspace is IWikiWorkspace { + return 'wikiFolderLocation' in workspace && workspace.wikiFolderLocation !== undefined; +} + // Backoff configuration for retries const BACKOFF_OPTIONS = { numOfAttempts: 10, @@ -27,7 +32,8 @@ export async function waitForLogMarker(searchString: string, errorMessage: strin async () => { try { const files = await fs.readdir(logPath); - const logFiles = files.filter(f => patterns.some(p => f.startsWith(p)) && f.endsWith('.log')); + // Case-insensitive matching for log file patterns + const logFiles = files.filter(f => patterns.some(p => f.toLowerCase().startsWith(p.toLowerCase())) && f.endsWith('.log')); for (const file of logFiles) { const content = await fs.readFile(path.join(logPath, file), 'utf-8'); @@ -563,6 +569,15 @@ When('I delete file {string}', async function(this: ApplicationWorld, filePath: await fs.remove(actualPath); }); +When('I delete file {string} in {string}', async function(this: ApplicationWorld, fileName: string, simpleDirectoryPath: string) { + // Replace {tmpDir} with wiki test root + const directoryPath = simpleDirectoryPath.replace('{tmpDir}', wikiTestRootPath); + const filePath = path.join(directoryPath, fileName); + + // Delete the file + await fs.remove(filePath); +}); + When('I rename file {string} to {string}', async function(this: ApplicationWorld, oldPath: string, newPath: string) { // Replace {tmpDir} placeholder with actual temp directory const actualOldPath = oldPath.replace('{tmpDir}', wikiTestRootPath); @@ -758,13 +773,41 @@ When('I update workspace {string} settings:', async function(this: ApplicationWo const settings = await fs.readJson(settingsPath) as { workspaces?: Record }; const workspaces: Record = settings.workspaces ?? {}; - // Find workspace by name + // Find workspace by name or by wikiFolderLocation (in case name is removed from settings.json) let targetWorkspaceId: string | undefined; for (const [id, workspace] of Object.entries(workspaces)) { - if (!workspace.pageType && workspace.name === workspaceName) { + if (workspace.pageType) continue; // Skip page workspaces + + // Try to match by name (if available in settings.json) + if (workspace.name === workspaceName) { targetWorkspaceId = id; break; } + + // Try to read name from tidgi.config.json + if (isWikiWorkspace(workspace)) { + try { + const tidgiConfigPath = path.join(workspace.wikiFolderLocation, 'tidgi.config.json'); + if (await fs.pathExists(tidgiConfigPath)) { + const tidgiConfig = await fs.readJson(tidgiConfigPath) as { name?: string }; + if (tidgiConfig.name === workspaceName) { + targetWorkspaceId = id; + break; + } + } + } catch { + // Ignore errors + } + } + + // Fallback: try to match by folder name in wikiFolderLocation + if ('wikiFolderLocation' in workspace && workspace.wikiFolderLocation) { + const folderName = path.basename(workspace.wikiFolderLocation); + if (folderName === workspaceName) { + targetWorkspaceId = id; + break; + } + } } if (!targetWorkspaceId) { @@ -935,7 +978,6 @@ ${tiddler.content} hibernateWhenUnused: false, lastUrl: null, picturePath: null, - subWikiFolderName: 'subwiki', syncOnInterval: false, syncOnStartup: true, transparentBackground: false, @@ -972,7 +1014,6 @@ ${tiddler.content} hibernateWhenUnused: false, lastUrl: null, picturePath: null, - subWikiFolderName: 'subwiki', syncOnInterval: false, syncOnStartup: true, transparentBackground: false, @@ -1054,3 +1095,125 @@ async function clearTestIdLogs() { When('I clear test-id markers from logs', async function(this: ApplicationWorld) { await clearTestIdLogs(); }); + +/** + * Verify JSON file contains expected values using JSONPath + * Example: + * Then file "config-test-wiki/tidgi.config.json" should contain JSON with: + * | jsonPath | value | + * | $.name | ConfigTestWiki | + * | $.port | 5300 | + */ +Then('file {string} should contain JSON with:', async function(this: ApplicationWorld, fileName: string, dataTable: DataTable) { + const rows = dataTable.hashes(); + const filePath = path.join(wikiTestRootPath, fileName); + + await backOff( + async () => { + if (!await fs.pathExists(filePath)) { + throw new Error(`File not found: ${filePath}`); + } + + const content = await fs.readFile(filePath, 'utf-8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const json = JSON.parse(content); + + for (const row of rows) { + const jsonPath = row.jsonPath; + const expectedValue = row.value; + + // Simple JSONPath implementation for basic paths like $.name, $.port + const pathParts = jsonPath.replace(/^\$\./, '').split('.'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + let actualValue = json; + + for (const part of pathParts) { + if (actualValue && typeof actualValue === 'object' && part in actualValue) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + actualValue = actualValue[part]; + } else { + throw new Error(`Path ${jsonPath} not found in JSON`); + } + } + + // Convert to string for comparison + const actualValueString = String(actualValue); + if (actualValueString !== expectedValue) { + throw new Error(`Expected ${jsonPath} to be "${expectedValue}", but got "${actualValueString}"`); + } + } + }, + BACKOFF_OPTIONS, + ); +}); + +/** + * Remove workspace without deleting files (via API) + */ +When('I remove workspace {string} keeping files', async function(this: ApplicationWorld, workspaceName: string) { + if (!this.app) { + throw new Error('Application not launched'); + } + + if (!await fs.pathExists(settingsPath)) { + throw new Error(`Settings file not found at ${settingsPath}`); + } + + // Read settings file to get workspace ID + const settings = await fs.readJson(settingsPath) as { workspaces?: Record }; + const workspaces: Record = settings.workspaces ?? {}; + + // Find workspace by name - check both settings.json and tidgi.config.json + let targetWorkspaceId: string | undefined; + for (const [id, workspace] of Object.entries(workspaces)) { + if (workspace.pageType) continue; // Skip page workspaces + + let workspaceName_: string | undefined = workspace.name; + + // If name is not in settings.json, try to read from tidgi.config.json + if (!workspaceName_ && isWikiWorkspace(workspace)) { + try { + const tidgiConfigPath = path.join(workspace.wikiFolderLocation, 'tidgi.config.json'); + if (await fs.pathExists(tidgiConfigPath)) { + const tidgiConfig = await fs.readJson(tidgiConfigPath) as { name?: string }; + workspaceName_ = tidgiConfig.name; + } + } catch { + // Ignore errors reading tidgi.config.json + } + } + + if (workspaceName_ === workspaceName) { + targetWorkspaceId = id; + break; + } + } + + if (!targetWorkspaceId) { + throw new Error(`No workspace found with name: ${workspaceName}`); + } + + // Remove workspace via API (without showing dialog, directly call remove) + await this.app.evaluate(async ({ BrowserWindow }, { workspaceId }: { workspaceId: string }) => { + const windows = BrowserWindow.getAllWindows(); + const mainWindow = windows.find(win => !win.isDestroyed() && win.webContents && win.webContents.getURL().includes('index.html')); + + if (!mainWindow) { + throw new Error('Main window not found'); + } + + // Stop wiki and remove workspace without deleting files + await mainWindow.webContents.executeJavaScript(` + (async () => { + await window.service.wiki.stopWiki(${JSON.stringify(workspaceId)}); + await window.service.workspaceView.removeWorkspaceView(${JSON.stringify(workspaceId)}); + await window.service.workspace.remove(${JSON.stringify(workspaceId)}); + })(); + `); + }, { workspaceId: targetWorkspaceId }); + + // Wait for removal to propagate + await this.app.evaluate(async () => { + await new Promise(resolve => setTimeout(resolve, 500)); + }); +}); diff --git a/features/workspaceConfig.feature b/features/workspaceConfig.feature new file mode 100644 index 00000000..f4bd65aa --- /dev/null +++ b/features/workspaceConfig.feature @@ -0,0 +1,54 @@ +Feature: Workspace Configuration Sync + As a user + I want workspace settings saved to tidgi.config.json + So that settings persist when I remove and re-add a workspace + + Background: + Given I cleanup test wiki so it could create a new one on start + When I launch the TidGi application + And I wait for the page to load completely + + @workspace-config + Scenario: Workspace config is saved and restored via tidgi.config.json + # Wait for default wiki to fully initialize (browser view loaded) + And the browser view should be loaded and visible + Then I should see a "default wiki workspace" element with selector "div[data-testid^='workspace-']:has-text('wiki')" + # Step 1: Update workspace name via API (this triggers config file write) + When I update workspace "wiki" settings: + | property | value | + | name | WikiRenamed | + # Wait for config to be written to tidgi.config.json + Then I wait for "config file written" log marker "[test-id-TIDGI_CONFIG_WRITTEN]" + # Step 2: Verify tidgi.config.json was updated + Then file "wiki/tidgi.config.json" should exist in "wiki-test" + Then file "wiki/tidgi.config.json" should contain JSON with: + | jsonPath | value | + | $.name | WikiRenamed | + # Step 3: Remove the workspace (keep files) via API + When I remove workspace "WikiRenamed" keeping files + # Step 4: Verify workspace is removed but config file still exists + Then I should not see "wiki workspace in sidebar" elements with selectors: + | div[data-testid^='workspace-']:has-text('WikiRenamed') | + Then file "wiki/tidgi.config.json" should exist in "wiki-test" + # Step 5: Re-add the workspace by opening existing wiki + # Clear previous log markers before waiting for new ones + And I clear log lines containing "[test-id-WORKSPACE_CREATED]" + And I clear log lines containing "[test-id-VIEW_LOADED]" + When I click on an "add workspace button" element with selector "#add-workspace-button" + And I switch to "addWorkspace" window + And I wait for the page to load completely + When I click on a "open existing wiki tab" element with selector "button:has-text('导入本地知识库')" + When I prepare to select directory in dialog "wiki-test/wiki" + When I click on a "select folder button" element with selector "button:has-text('选择')" + # Click the import button to actually add the workspace + When I click on a "import wiki button" element with selector "button:has-text('导入知识库')" + # Wait for workspace to be created using log marker + Then I wait for "workspace created" log marker "[test-id-WORKSPACE_CREATED]" + # Switch back to main window first, then wait for view to load + When I switch to "main" window + # Wait for wiki view to fully load + Then I wait for "view loaded" log marker "[test-id-VIEW_LOADED]" + # Step 6: Verify workspace is back with the saved name from tidgi.config.json + Then I should see a "restored wiki workspace" element with selector "div[data-testid^='workspace-']:has-text('WikiRenamed')" + # Verify wiki is actually loaded and functional + And the browser view should be loaded and visible diff --git a/src/__tests__/__mocks__/services-container.ts b/src/__tests__/__mocks__/services-container.ts index 03a6b3aa..56cce3df 100644 --- a/src/__tests__/__mocks__/services-container.ts +++ b/src/__tests__/__mocks__/services-container.ts @@ -164,7 +164,6 @@ const defaultWorkspaces: IWorkspace[] = [ excludedPlugins: wikiWorkspaceDefaultValues.excludedPlugins, enableFileSystemWatch: wikiWorkspaceDefaultValues.enableFileSystemWatch, hibernateWhenUnused: wikiWorkspaceDefaultValues.hibernateWhenUnused, - subWikiFolderName: wikiWorkspaceDefaultValues.subWikiFolderName, }, { id: 'test-wiki-2', @@ -195,6 +194,5 @@ const defaultWorkspaces: IWorkspace[] = [ excludedPlugins: wikiWorkspaceDefaultValues.excludedPlugins, enableFileSystemWatch: wikiWorkspaceDefaultValues.enableFileSystemWatch, hibernateWhenUnused: wikiWorkspaceDefaultValues.hibernateWhenUnused, - subWikiFolderName: wikiWorkspaceDefaultValues.subWikiFolderName, }, ]; diff --git a/src/main.ts b/src/main.ts index 24a5b845..f8507a87 100755 --- a/src/main.ts +++ b/src/main.ts @@ -6,6 +6,7 @@ import './services/database/configSetting'; import { app, ipcMain, powerMonitor, protocol } from 'electron'; import unhandled from 'electron-unhandled'; import inspector from 'node:inspector'; +import { initJsonRepairLogger, initTidgiConfigLogger } from './services/database/configSetting'; import { MainChannel } from '@/constants/channels'; import { isDevelopmentOrTest, isTest } from '@/constants/environment'; @@ -15,6 +16,10 @@ import { initRendererI18NHandler } from '@services/libs/i18n'; import { destroyLogger, logger } from '@services/libs/log'; import { buildLanguageMenu } from '@services/menu/buildLanguageMenu'; +// Initialize loggers for modules that can't directly import logger (to avoid electron in worker bundles) +initJsonRepairLogger(logger); +initTidgiConfigLogger(logger); + import { bindServiceAndProxy } from '@services/libs/bindServiceAndProxy'; import serviceIdentifier from '@services/serviceIdentifier'; import { WindowNames } from '@services/windows/WindowProperties'; diff --git a/src/services/database/configSetting.ts b/src/services/database/configSetting.ts index e33613c1..3184f329 100644 --- a/src/services/database/configSetting.ts +++ b/src/services/database/configSetting.ts @@ -1,60 +1,30 @@ -import { SETTINGS_FOLDER } from '@/constants/appPaths'; -import { logger } from '@services/libs/log'; -import { parse as bestEffortJsonParser } from 'best-effort-json-parser'; -import settings from 'electron-settings'; -import fs from 'fs-extra'; -import { isWin } from '../../helpers/system'; +/** + * Configuration and settings utilities - re-exports from specialized modules. + * + * This module re-exports from: + * - settingsInit.ts: settings.json initialization and error recovery + * - tidgiConfig.ts: tidgi.config.json workspace config sync + * - jsonRepair.ts: JSON parsing and repair utilities + */ -function fixEmptyAndErrorSettingFileOnStartUp() { - try { - // Fix sometimes JSON is malformed https://github.com/nathanbuchar/electron-settings/issues/160 - if (fs.existsSync(settings.file())) { - try { - logger.info('Checking Setting file format.'); - fs.readJsonSync(settings.file()); - logger.info('Setting file format good.'); - } catch (jsonError) { - fixSettingFileWhenError(jsonError as Error); - } - } else { - // create an empty JSON file if not exist, to prevent error when reading it. fixes https://github.com/tiddly-gittly/TidGi-Desktop/issues/507 - fs.ensureFileSync(settings.file()); - fs.writeJSONSync(settings.file(), {}); - } - } catch (error) { - logger.error('Error when checking Setting file format', { function: 'fixEmptyAndErrorSettingFileOnStartUp', error }); - } -} +// Settings initialization utilities +export { ensureSettingFolderExist, fixSettingFileWhenError } from './settingsInit'; -export function ensureSettingFolderExist(): void { - if (!fs.existsSync(SETTINGS_FOLDER)) { - fs.mkdirSync(SETTINGS_FOLDER, { recursive: true }); - } -} -export function fixSettingFileWhenError(jsonError: Error, providedJSONContent?: string): void { - logger.error('Setting file format bad: ' + jsonError.message); - // fix empty content or empty string - fs.ensureFileSync(settings.file()); - const jsonContent = providedJSONContent || fs.readFileSync(settings.file(), 'utf8').trim() || '{}'; - logger.info('Try to fix JSON content.'); - try { - const repaired = bestEffortJsonParser(jsonContent) as Record; - logger.info('Fix JSON content done, writing it.'); - fs.writeJSONSync(settings.file(), repaired); - logger.info('Fix JSON content done, saved', { repaired }); - } catch (fixJSONError) { - const fixError = fixJSONError as Error; - logger.error('Setting file format bad, and cannot be fixed', { function: 'fixSettingFileWhenError', error: fixError, jsonContent }); - } -} +// JSON repair utilities +export { initJsonRepairLogger } from './jsonRepair'; -try { - ensureSettingFolderExist(); - settings.configure({ - dir: SETTINGS_FOLDER, - atomicSave: !isWin, - }); -} catch (error) { - logger.error('Error when configuring settings', { function: 'settings.configure', error }); -} -fixEmptyAndErrorSettingFileOnStartUp(); +// TidGi workspace config sync utilities +export { + extractSyncableConfig, + getTidgiConfigPath, + hasTidgiConfig, + initTidgiConfigLogger, + mergeWithSyncedConfig, + readTidgiConfig, + readTidgiConfigSync, + removeSyncableFields, + TIDGI_CONFIG_FILE, + TIDGI_CONFIG_VERSION, + writeTidgiConfig, +} from './tidgiConfig'; +export type { ITidgiConfigFile } from './tidgiConfig'; diff --git a/src/services/database/jsonRepair.ts b/src/services/database/jsonRepair.ts new file mode 100644 index 00000000..bd8670bf --- /dev/null +++ b/src/services/database/jsonRepair.ts @@ -0,0 +1,123 @@ +/** + * Shared JSON parsing and repair utilities. + * Uses best-effort-json-parser to recover from malformed JSON files. + * + * ⚠️ NOTE: This file must NOT import logger or any module that transitively imports 'electron' + * because it may be used by tidgiConfig.ts which is bundled with Worker code. + * Logger is injected via initJsonRepairLogger() to avoid this issue. + */ +import { parse as bestEffortJsonParser } from 'best-effort-json-parser'; +import fs from 'fs-extra'; + +/** + * Logger interface - minimal subset of winston logger + */ +interface ILogger { + info: (message: string, meta?: Record) => void; + warn: (message: string, meta?: Record) => void; + error: (message: string, meta?: Record) => void; +} + +/** + * Injected logger instance. Falls back to console if not initialized. + */ +let injectedLogger: ILogger | undefined; + +/** + * Initialize the logger for jsonRepair module. + * This should be called early in the main process initialization. + * @param loggerInstance The logger instance to use (typically from @services/libs/log) + */ +export function initJsonRepairLogger(loggerInstance: ILogger): void { + injectedLogger = loggerInstance; +} + +/** + * Get the logger, falling back to console if not initialized + */ +function getLogger(): ILogger { + if (injectedLogger) { + return injectedLogger; + } + // Fallback to console for cases where logger is not initialized + return { + info: (message: string, meta?: Record) => { + console.info(message, meta); + }, + warn: (message: string, meta?: Record) => { + console.warn(message, meta); + }, + error: (message: string, meta?: Record) => { + console.error(message, meta); + }, + }; +} + +/** + * Parse JSON content with automatic repair for malformed JSON. + * If parsing fails, attempts to repair using best-effort-json-parser. + * + * @param content - The JSON string to parse + * @param filePath - Path to the file (for logging and optional write-back) + * @param options - Configuration options + * @returns Parsed object or undefined if parsing and repair both fail + */ +// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters +export function parseJsonWithRepair( + content: string, + filePath: string, + options: { + /** Whether to write the repaired JSON back to the file */ + writeBack?: boolean; + /** Whether to use sync file operations (default: false) */ + sync?: boolean; + /** Custom log prefix for error messages */ + logPrefix?: string; + } = {}, +): T | undefined { + const { writeBack = true, sync = false, logPrefix = 'JSON file' } = options; + + try { + return JSON.parse(content) as T; + } catch (jsonError) { + getLogger().warn(`${logPrefix} format error, attempting to fix`, { + filePath, + error: (jsonError as Error).message, + }); + + try { + const repaired = bestEffortJsonParser(content) as T; + + if (writeBack) { + const repairedContent = JSON.stringify(repaired, null, 2); + if (sync) { + fs.writeFileSync(filePath, repairedContent, 'utf-8'); + } else { + void fs.writeFile(filePath, repairedContent, 'utf-8'); + } + getLogger().info(`Successfully repaired ${logPrefix}`, { filePath }); + } + + return repaired; + } catch (fixError) { + getLogger().error(`Failed to repair ${logPrefix}`, { + filePath, + error: (fixError as Error).message, + }); + return undefined; + } + } +} + +/** + * Parse JSON content synchronously with automatic repair. + * Convenience wrapper for parseJsonWithRepair with sync=true. + */ +// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters +export function parseJsonWithRepairSync( + content: string, + filePath: string, + options: Omit[2], 'sync'> = {}, +): T | undefined { + return parseJsonWithRepair(content, filePath, { ...options, sync: true }); +} diff --git a/src/services/database/settingsInit.ts b/src/services/database/settingsInit.ts new file mode 100644 index 00000000..789a6383 --- /dev/null +++ b/src/services/database/settingsInit.ts @@ -0,0 +1,84 @@ +/** + * Settings file initialization and error recovery utilities. + * This module handles settings.json initialization, validation, and repair. + */ +import { SETTINGS_FOLDER } from '@/constants/appPaths'; +import { logger } from '@services/libs/log'; +import settings from 'electron-settings'; +import fs from 'fs-extra'; +import { isWin } from '../../helpers/system'; +import { parseJsonWithRepairSync } from './jsonRepair'; + +/** + * Ensure the settings folder exists + */ +export function ensureSettingFolderExist(): void { + if (!fs.existsSync(SETTINGS_FOLDER)) { + fs.mkdirSync(SETTINGS_FOLDER, { recursive: true }); + } +} + +/** + * Fix a malformed settings.json file using best-effort JSON parser + */ +export function fixSettingFileWhenError(jsonError: Error, providedJSONContent?: string): void { + logger.error('Setting file format bad: ' + jsonError.message); + // fix empty content or empty string + fs.ensureFileSync(settings.file()); + const jsonContent = providedJSONContent || fs.readFileSync(settings.file(), 'utf8').trim() || '{}'; + logger.info('Try to fix JSON content.'); + + const repaired = parseJsonWithRepairSync>( + jsonContent, + settings.file(), + { logPrefix: 'settings.json', writeBack: false }, + ); + + if (repaired) { + fs.writeJSONSync(settings.file(), repaired); + logger.info('Fix JSON content done, saved', { repaired }); + } +} + +/** + * Check and fix settings.json format on startup + */ +function fixEmptyAndErrorSettingFileOnStartUp() { + try { + // Fix sometimes JSON is malformed https://github.com/nathanbuchar/electron-settings/issues/160 + if (fs.existsSync(settings.file())) { + try { + logger.info('Checking Setting file format.'); + fs.readJsonSync(settings.file()); + logger.info('Setting file format good.'); + } catch (jsonError) { + fixSettingFileWhenError(jsonError as Error); + } + } else { + // create an empty JSON file if not exist, to prevent error when reading it. fixes https://github.com/tiddly-gittly/TidGi-Desktop/issues/507 + fs.ensureFileSync(settings.file()); + fs.writeJSONSync(settings.file(), {}); + } + } catch (error) { + logger.error('Error when checking Setting file format', { function: 'fixEmptyAndErrorSettingFileOnStartUp', error }); + } +} + +/** + * Initialize settings on module load + */ +export function initializeSettings(): void { + try { + ensureSettingFolderExist(); + settings.configure({ + dir: SETTINGS_FOLDER, + atomicSave: !isWin, + }); + } catch (error) { + logger.error('Error when configuring settings', { function: 'settings.configure', error }); + } + fixEmptyAndErrorSettingFileOnStartUp(); +} + +// Auto-initialize on module load +initializeSettings(); diff --git a/src/services/database/tidgiConfig.ts b/src/services/database/tidgiConfig.ts new file mode 100644 index 00000000..5b7ba74f --- /dev/null +++ b/src/services/database/tidgiConfig.ts @@ -0,0 +1,244 @@ +/** + * Utilities for syncing workspace configuration to/from tidgi.config.json in wiki folder. + * This allows workspace preferences to be synced across devices via Git. + * + * ⚠️ IMPORTANT: When modifying this file, remember to also update: + * - src/services/workspaces/tidgi.config.schema.json (JSON Schema definition) + * - syncableConfigFields and syncableConfigDefaultValues in syncableConfig.ts + * + * ⚠️ NOTE: This file must NOT import from '../workspaces/interface' or any module that + * transitively imports 'electron' because it is bundled with Worker code. + * Import syncable config from '../workspaces/syncableConfig' instead. + * Logger is injected via initTidgiConfigLogger() to avoid this issue. + */ +import fs from 'fs-extra'; +import { isEqual, pickBy } from 'lodash'; +import path from 'path'; +// CRITICAL: Import from syncableConfig.ts, NOT interface.ts (which imports electron-ipc-cat) +import type { ISyncableWikiConfig, IWikiWorkspaceMinimal, SyncableConfigField } from '../workspaces/syncableConfig'; +import { syncableConfigDefaultValues, syncableConfigFields } from '../workspaces/syncableConfig'; +// Import JSON repair utilities (jsonRepair.ts also avoids electron imports) +import { parseJsonWithRepair, parseJsonWithRepairSync } from './jsonRepair'; + +/** + * The filename for workspace config in wiki folder + */ +export const TIDGI_CONFIG_FILE = 'tidgi.config.json'; + +/** + * Schema version for tidgi.config.json + */ +export const TIDGI_CONFIG_VERSION = 1; + +/** + * Interface for the tidgi.config.json file structure + */ +export interface ITidgiConfigFile { + $schema?: string; + version: number; + [key: string]: unknown; +} + +/** + * Logger interface - minimal subset of winston logger + */ +interface ILogger { + debug: (message: string, meta?: Record) => void; + warn: (message: string, meta?: Record) => void; + error: (message: string, meta?: Record) => void; +} + +/** + * Injected logger instance. Falls back to console if not initialized. + */ +let injectedLogger: ILogger | undefined; + +/** + * Initialize the logger for tidgiConfig module. + * This should be called early in the main process initialization. + * @param loggerInstance The logger instance to use (typically from @services/libs/log) + */ +export function initTidgiConfigLogger(loggerInstance: ILogger): void { + injectedLogger = loggerInstance; +} + +/** + * Get the logger, falling back to console if not initialized + */ +function getLogger(): ILogger { + if (injectedLogger) { + return injectedLogger; + } + // Fallback to console for cases where logger is not initialized + return { + debug: (message: string, meta?: Record) => { + console.debug(message, meta); + }, + warn: (message: string, meta?: Record) => { + console.warn(message, meta); + }, + error: (message: string, meta?: Record) => { + console.error(message, meta); + }, + }; +} + +/** + * Get the path to tidgi.config.json for a wiki + */ +export function getTidgiConfigPath(wikiFolderLocation: string): string { + return path.join(wikiFolderLocation, TIDGI_CONFIG_FILE); +} + +/** + * Extract syncable config fields from a workspace + */ +export function extractSyncableConfig(workspace: IWikiWorkspaceMinimal): Partial { + const syncableConfig: Partial = {}; + for (const field of syncableConfigFields) { + if (field in workspace) { + // Only include non-default values to keep the file minimal + const value = workspace[field as keyof IWikiWorkspaceMinimal]; + const defaultValue = syncableConfigDefaultValues[field]; + if (!isEqual(value, defaultValue)) { + (syncableConfig as Record)[field] = value; + } + } + } + return syncableConfig; +} + +/** + * Remove syncable config fields from a workspace, leaving only local-only fields + * This is used when saving to settings.json to avoid data duplication + */ +export function removeSyncableFields(workspace: IWikiWorkspaceMinimal): Partial { + const localWorkspace: Partial = { ...workspace }; + for (const field of syncableConfigFields) { + delete (localWorkspace as Record)[field]; + } + getLogger().debug('Removed syncable fields from workspace', { + workspaceId: workspace.id, + removedFields: syncableConfigFields.filter(field => field in workspace), + }); + return localWorkspace; +} + +/** + * Extract known syncable fields from parsed config + */ +function extractKnownFields(parsed: ITidgiConfigFile): Partial | undefined { + // Validate version + if (typeof parsed.version !== 'number') { + return undefined; + } + + const result: Partial = {}; + for (const field of syncableConfigFields) { + if (field in parsed && parsed[field] !== undefined) { + (result as Record)[field] = parsed[field]; + } + } + return result; +} + +/** + * Read syncable config from tidgi.config.json in wiki folder + * Returns undefined if file doesn't exist or is invalid + * Uses the same error recovery mechanism as settings.json + */ +export async function readTidgiConfig(wikiFolderLocation: string): Promise | undefined> { + const configPath = getTidgiConfigPath(wikiFolderLocation); + try { + if (!await fs.pathExists(configPath)) { + return undefined; + } + const content = await fs.readFile(configPath, 'utf-8'); + const parsed = parseJsonWithRepair(content, configPath, { logPrefix: 'tidgi.config.json' }); + if (!parsed) return undefined; + + const result = extractKnownFields(parsed); + if (!result) { + getLogger().warn('Invalid tidgi.config.json: missing version', { configPath }); + } + return result; + } catch (error) { + getLogger().warn('Failed to read tidgi.config.json', { configPath, error: (error as Error).message }); + return undefined; + } +} + +/** + * Read syncable config synchronously + */ +export function readTidgiConfigSync(wikiFolderLocation: string): Partial | undefined { + const configPath = getTidgiConfigPath(wikiFolderLocation); + try { + if (!fs.pathExistsSync(configPath)) { + return undefined; + } + const content = fs.readFileSync(configPath, 'utf-8'); + const parsed = parseJsonWithRepairSync(content, configPath, { logPrefix: 'tidgi.config.json' }); + if (!parsed) return undefined; + + return extractKnownFields(parsed); + } catch { + return undefined; + } +} + +/** + * Write syncable config to tidgi.config.json in wiki folder + * Only writes non-default values to keep the file minimal + */ +export async function writeTidgiConfig(wikiFolderLocation: string, config: Partial): Promise { + const configPath = getTidgiConfigPath(wikiFolderLocation); + try { + // Filter out default values + const nonDefaultConfig = pickBy(config, (value, key) => { + const defaultValue = syncableConfigDefaultValues[key as SyncableConfigField]; + return !isEqual(value, defaultValue); + }); + + const fileContent: ITidgiConfigFile = { + $schema: 'https://raw.githubusercontent.com/tiddly-gittly/TidGi-Desktop/master/src/services/workspaces/tidgi.config.schema.json', + version: TIDGI_CONFIG_VERSION, + ...nonDefaultConfig, + }; + + await fs.writeFile(configPath, JSON.stringify(fileContent, null, 2), 'utf-8'); + getLogger().debug(`[test-id-TIDGI_CONFIG_WRITTEN] Written tidgi.config.json`, { configPath, fields: Object.keys(nonDefaultConfig) }); + } catch (error) { + getLogger().error('Failed to write tidgi.config.json', { configPath, error: (error as Error).message }); + throw error; + } +} + +/** + * Merge syncable config from tidgi.config.json over local config + * Synced config takes precedence over local config + */ +export function mergeWithSyncedConfig( + localWorkspace: T, + syncedConfig: Partial | undefined, +): T { + if (!syncedConfig) { + return localWorkspace; + } + + // Apply synced config over local, with defaults for missing fields + const merged = { ...localWorkspace }; + for (const field of syncableConfigFields) { + if (field in syncedConfig) { + (merged as Record)[field] = syncedConfig[field]; + } + } + return merged; +} + +/** + * Check if tidgi.config.json exists for a wiki + */ +export async function hasTidgiConfig(wikiFolderLocation: string): Promise { + return fs.pathExists(getTidgiConfigPath(wikiFolderLocation)); +} diff --git a/src/services/preferences/index.ts b/src/services/preferences/index.ts index beebe891..ccafb81c 100755 --- a/src/services/preferences/index.ts +++ b/src/services/preferences/index.ts @@ -118,7 +118,7 @@ export class Preference implements IPreferenceService { const preferencesToSave = getPreferenceDifferencesFromDefaults(newPreferences, defaultPreferences); const databaseService = container.get(serviceIdentifier.Database); - databaseService.setSetting('preferences', preferencesToSave); + databaseService.setSetting('preferences', preferencesToSave as IPreferences); this.updatePreferenceSubject(); } diff --git a/src/services/wiki/index.ts b/src/services/wiki/index.ts index 4c4b88a5..c8f45dcf 100644 --- a/src/services/wiki/index.ts +++ b/src/services/wiki/index.ts @@ -119,6 +119,14 @@ export class Wiki implements IWikiService { return; } const { port, rootTiddler, readOnlyMode, tokenAuth, homeUrl, lastUrl, https, excludedPlugins, isSubWiki, wikiFolderLocation, name, enableHTTPAPI, authToken } = workspace; + logger.debug('startWiki: Got workspace from workspaceService', { + workspaceID, + name, + port, + enableHTTPAPI, + wikiFolderLocation, + hasAllRequiredFields: port !== undefined && name !== undefined, + }); if (isSubWiki) { logger.error('Try to start wiki, but workspace is sub wiki', { workspace, workspaceID }); return; @@ -156,6 +164,17 @@ export class Wiki implements IWikiService { userName, workspace, }; + logger.debug('Worker configuration prepared', { + workspaceID, + port, + userName, + enableHTTPAPI, + readOnlyMode, + tokenAuth, + wikiFolderLocation, + workspaceName: workspace.name, + function: 'Wiki.startWiki', + }); logger.debug('initializing wikiWorker for workspace', { workspaceID, function: 'Wiki.startWiki', @@ -183,6 +202,21 @@ export class Wiki implements IWikiService { reject(new WikiRuntimeError(error, name, false)); }); + // Capture worker stderr to diagnose crashes + if (wikiWorker.stderr) { + wikiWorker.stderr.on('data', (data: Buffer | string) => { + const message = typeof data === 'string' ? data : data.toString(); + logger.error('Worker stderr', { message: message.trim(), ...loggerMeta }); + }); + } + // Capture worker stdout before intercept is set up + if (wikiWorker.stdout) { + wikiWorker.stdout.on('data', (data: Buffer | string) => { + const message = typeof data === 'string' ? data : data.toString(); + logger.debug('Worker stdout', { message: message.trim(), ...loggerMeta }); + }); + } + // Handle worker exit wikiWorker.on('exit', (code) => { delete this.wikiWorkers[workspaceID]; diff --git a/src/services/wiki/plugin/watchFileSystemAdaptor/FileSystemWatcher.ts b/src/services/wiki/plugin/watchFileSystemAdaptor/FileSystemWatcher.ts index 96d33bd3..3827467d 100644 --- a/src/services/wiki/plugin/watchFileSystemAdaptor/FileSystemWatcher.ts +++ b/src/services/wiki/plugin/watchFileSystemAdaptor/FileSystemWatcher.ts @@ -81,9 +81,12 @@ export class FileSystemWatcher { /** Base excluded paths (permanent) */ private readonly baseExcludedPaths: string[] = []; - /** Excluded path patterns that apply to all wikis */ + /** Excluded path patterns that apply to all wikis (directory names) */ private readonly excludedPathPatterns: string[] = ['.git', 'node_modules', '.DS_Store']; + /** Excluded file names that should not be treated as tiddlers */ + private readonly excludedFileNames: string[] = ['tidgi.config.json']; + /** External attachments folder to exclude */ private externalAttachmentsFolder: string = 'files'; @@ -683,9 +686,11 @@ export class FileSystemWatcher { private shouldExcludeByPattern(filePath: string): boolean { const pathParts = filePath.split(path.sep); + const fileName = path.basename(filePath); const hasExcludedPattern = this.excludedPathPatterns.some(pattern => pathParts.includes(pattern)); + const hasExcludedFileName = this.excludedFileNames.includes(fileName); const hasExternalAttachmentsFolder = pathParts.includes(this.externalAttachmentsFolder); - return hasExcludedPattern || hasExternalAttachmentsFolder; + return hasExcludedPattern || hasExcludedFileName || hasExternalAttachmentsFolder; } private scheduleGitNotification(): void { diff --git a/src/services/wiki/wikiWorker/loadWikiTiddlersWithSubWikis.ts b/src/services/wiki/wikiWorker/loadWikiTiddlersWithSubWikis.ts index 288612a3..8d636152 100644 --- a/src/services/wiki/wikiWorker/loadWikiTiddlersWithSubWikis.ts +++ b/src/services/wiki/wikiWorker/loadWikiTiddlersWithSubWikis.ts @@ -1,3 +1,9 @@ +/** + * The filename for workspace config in wiki folder + * Duplicated here to avoid importing from configSetting which would pull in electron dependencies + */ +const TIDGI_CONFIG_FILE = 'tidgi.config.json'; + import type { IWikiWorkspace } from '@services/workspaces/interface'; import type { TiddlyWiki } from 'tiddlywiki'; @@ -49,6 +55,14 @@ export function createLoadWikiTiddlersWithSubWikis( const tiddlerFiles = wikiInstance.loadTiddlersFromPath(subWikiTiddlersPath); for (const tiddlerFile of tiddlerFiles) { + // Skip tidgi.config.json - it's a TidGi configuration file, not a tiddler + if (tiddlerFile.filepath) { + const fileName = tiddlerFile.filepath.split('/').pop() ?? ''; + if (fileName === TIDGI_CONFIG_FILE) { + continue; + } + } + // Skip files in the external attachments folder (default: files/) // These are referenced by tiddlers via _canonical_uri and should not be loaded as separate tiddlers if (tiddlerFile.filepath) { diff --git a/src/services/wikiEmbedding/__tests__/index.test.ts b/src/services/wikiEmbedding/__tests__/index.test.ts index dfc2ca3e..c798c9bf 100644 --- a/src/services/wikiEmbedding/__tests__/index.test.ts +++ b/src/services/wikiEmbedding/__tests__/index.test.ts @@ -76,7 +76,6 @@ describe('WikiEmbeddingService Integration Tests', () => { enableFileSystemWatch: wikiWorkspaceDefaultValues.enableFileSystemWatch, hibernateWhenUnused: wikiWorkspaceDefaultValues.hibernateWhenUnused, storageService: SupportedStorageServices.local, - subWikiFolderName: wikiWorkspaceDefaultValues.subWikiFolderName, }); // Set up spy for external API service diff --git a/src/services/workspaces/index.ts b/src/services/workspaces/index.ts index 7a12b561..d7d5dbf2 100644 --- a/src/services/workspaces/index.ts +++ b/src/services/workspaces/index.ts @@ -23,6 +23,7 @@ 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 { extractSyncableConfig, mergeWithSyncedConfig, readTidgiConfig, readTidgiConfigSync, removeSyncableFields, writeTidgiConfig } from '../database/configSetting'; import type { IDedicatedWorkspace, INewWikiWorkspaceConfig, @@ -107,9 +108,24 @@ export class Workspace implements IWorkspaceService { private getInitWorkspacesForCache(): Record { const databaseService = container.get(serviceIdentifier.Database); const workspacesFromDisk = databaseService.getSetting(`workspaces`) ?? {}; - return typeof workspacesFromDisk === 'object' && !Array.isArray(workspacesFromDisk) - ? mapValues(pickBy(workspacesFromDisk, (value) => !!value), (workspace) => this.sanitizeWorkspace(workspace)) - : {}; + logger.debug('getInitWorkspacesForCache: Loading workspaces from settings.json', { + workspaceIds: typeof workspacesFromDisk === 'object' ? Object.keys(workspacesFromDisk) : 'invalid', + }); + if (typeof workspacesFromDisk === 'object' && !Array.isArray(workspacesFromDisk)) { + const result = mapValues(pickBy(workspacesFromDisk, (value) => !!value), (workspace) => { + const sanitized = this.sanitizeWorkspace(workspace, true); + logger.debug('getInitWorkspacesForCache: Sanitized workspace', { + workspaceId: workspace.id, + hasName: 'name' in sanitized, + name: sanitized.name, + hasPort: 'port' in sanitized, + port: (sanitized as { port?: number }).port, + }); + return sanitized; + }); + return result; + } + return {}; } public async getWorkspaces(): Promise> { @@ -180,12 +196,41 @@ export class Workspace implements IWorkspaceService { const workspaces = this.getWorkspacesSync(); const workspaceToSave = this.sanitizeWorkspace(workspace); await this.reactBeforeWorkspaceChanged(workspaceToSave); + + // Write syncable config to tidgi.config.json in wiki folder + if (isWikiWorkspace(workspaceToSave)) { + try { + const syncableConfig = extractSyncableConfig(workspaceToSave); + await writeTidgiConfig(workspaceToSave.wikiFolderLocation, syncableConfig); + } catch (error) { + logger.warn('Failed to write tidgi.config.json', { + workspaceId: id, + error: (error as Error).message, + }); + } + } + + // Update memory cache with full workspace data (including syncable fields) workspaces[id] = workspaceToSave; + + // Save to settings.json - remove syncable fields from ALL wiki workspaces + // They are stored in tidgi.config.json in the wiki folder const databaseService = container.get(serviceIdentifier.Database); - databaseService.setSetting('workspaces', workspaces); + const workspacesForSettings: Record = {}; + for (const [key, ws] of Object.entries(workspaces)) { + if (isWikiWorkspace(ws)) { + // Remove syncable fields from wiki workspaces (they are in tidgi.config.json) + workspacesForSettings[key] = removeSyncableFields(ws) as IWorkspace; + } else { + // Keep dedicated workspaces as is + workspacesForSettings[key] = ws; + } + } + databaseService.setSetting('workspaces', workspacesForSettings); if (immediate === true) { await 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 @@ -219,41 +264,92 @@ export class Workspace implements IWorkspaceService { } /** - * Pure function that make sure workspace setting is consistent, or doing migration across updates + * Pure function that make sure workspace setting is consistent, or doing migration across updates. + * Also reads and merges syncable config from tidgi.config.json in wiki folder (only during initial load). * @param workspaceToSanitize User input workspace or loaded workspace, that may contains bad values + * @param applySyncedConfig Whether to apply config from tidgi.config.json (should only be true during initial load) */ - private sanitizeWorkspace(workspaceToSanitize: IWorkspace): IWorkspace { + private sanitizeWorkspace(workspaceToSanitize: IWorkspace, applySyncedConfig = false): IWorkspace { // For dedicated workspaces (help, guide, agent), no sanitization needed if (!isWikiWorkspace(workspaceToSanitize)) { return workspaceToSanitize; } - const fixingValues: Partial = {}; + logger.debug('sanitizeWorkspace: Starting', { + workspaceId: workspaceToSanitize.id, + applySyncedConfig, + hasName: 'name' in workspaceToSanitize, + inputName: workspaceToSanitize.name, + hasPort: 'port' in workspaceToSanitize, + inputPort: workspaceToSanitize.port, + wikiFolderLocation: workspaceToSanitize.wikiFolderLocation, + }); + + // Read syncable config from tidgi.config.json if it exists + // Only apply synced config during initial load, not during updates + // (to avoid overwriting user's changes with old file content) + let workspaceWithSyncedConfig = workspaceToSanitize; + if (applySyncedConfig) { + try { + const syncedConfig = readTidgiConfigSync(workspaceToSanitize.wikiFolderLocation); + if (syncedConfig) { + logger.debug('sanitizeWorkspace: Loaded syncable config from tidgi.config.json', { + workspaceId: workspaceToSanitize.id, + fields: Object.keys(syncedConfig), + syncedName: syncedConfig.name, + syncedPort: syncedConfig.port, + }); + workspaceWithSyncedConfig = mergeWithSyncedConfig(workspaceToSanitize, syncedConfig); + } else { + logger.debug('sanitizeWorkspace: No syncable config found in tidgi.config.json, will use defaults', { + workspaceId: workspaceToSanitize.id, + wikiFolderLocation: workspaceToSanitize.wikiFolderLocation, + }); + } + } catch (error) { + logger.warn('sanitizeWorkspace: Failed to read tidgi.config.json during sanitize', { + workspaceId: workspaceToSanitize.id, + error: (error as Error).message, + }); + } + } + + 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 (workspaceWithSyncedConfig.isSubWiki && !workspaceWithSyncedConfig.mainWikiID) { + const mainWorkspace = this.getMainWorkspace(workspaceWithSyncedConfig); if (mainWorkspace !== undefined) { fixingValues.mainWikiID = mainWorkspace.id; } } // Migrate old tagName (string) to tagNames (string[]) - const legacyTagName = (workspaceToSanitize as { tagName?: string | null }).tagName; - if (legacyTagName && (!workspaceToSanitize.tagNames || workspaceToSanitize.tagNames.length === 0)) { + const legacyTagName = (workspaceWithSyncedConfig as { tagName?: string | null }).tagName; + if (legacyTagName && (!workspaceWithSyncedConfig.tagNames || workspaceWithSyncedConfig.tagNames.length === 0)) { fixingValues.tagNames = [legacyTagName.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')) { + if (!workspaceWithSyncedConfig.lastUrl?.startsWith('tidgi')) { fixingValues.lastUrl = null; } - if (!workspaceToSanitize.homeUrl.startsWith('tidgi')) { - fixingValues.homeUrl = getDefaultTidGiUrl(workspaceToSanitize.id); + if (!workspaceWithSyncedConfig.homeUrl.startsWith('tidgi')) { + fixingValues.homeUrl = getDefaultTidGiUrl(workspaceWithSyncedConfig.id); } - if (workspaceToSanitize.tokenAuth && !workspaceToSanitize.authToken) { + if (workspaceWithSyncedConfig.tokenAuth && !workspaceWithSyncedConfig.authToken) { const authService = container.get(serviceIdentifier.Authentication); - fixingValues.authToken = authService.generateOneTimeAdminAuthTokenForWorkspaceSync(workspaceToSanitize.id); + fixingValues.authToken = authService.generateOneTimeAdminAuthTokenForWorkspaceSync(workspaceWithSyncedConfig.id); } - return { ...wikiWorkspaceDefaultValues, ...workspaceToSanitize, ...fixingValues }; + + // Apply defaults, then workspace data, then fixing values + // This ensures all required fields exist even if missing from settings.json/tidgi.config.json + const result = { ...wikiWorkspaceDefaultValues, ...workspaceWithSyncedConfig, ...fixingValues }; + logger.debug('sanitizeWorkspace: Complete', { + workspaceId: result.id, + finalName: result.name, + finalPort: result.port, + hasSyncedConfig: workspaceWithSyncedConfig !== workspaceToSanitize, + }); + return result; } /** @@ -426,9 +522,26 @@ export class Workspace implements IWorkspaceService { public async create(newWorkspaceConfig: INewWikiWorkspaceConfig): Promise { const newID = nanoid(); + + // Read existing config from tidgi.config.json if it exists (for re-adding an existing wiki) + // Synced config should take priority over the passed config for syncable fields + // This allows users to restore their previous settings when re-adding a wiki + let existingConfig: Partial = {}; + if (newWorkspaceConfig.wikiFolderLocation) { + const syncedConfig = await readTidgiConfig(newWorkspaceConfig.wikiFolderLocation); + if (syncedConfig) { + existingConfig = syncedConfig as Partial; + logger.info('Applied synced config from tidgi.config.json during workspace creation', { + wikiFolderLocation: newWorkspaceConfig.wikiFolderLocation, + syncedConfigFields: Object.keys(syncedConfig), + }); + } + } + const newWorkspace: IWorkspace = { ...wikiWorkspaceDefaultValues, - ...newWorkspaceConfig, + ...newWorkspaceConfig, // Apply config from UI/form first + ...existingConfig, // Then override with synced config (user's saved settings take priority) homeUrl: getDefaultTidGiUrl(newID), id: newID, lastUrl: null, @@ -438,6 +551,7 @@ export class Workspace implements IWorkspaceService { }; await this.set(newID, newWorkspace); + logger.info(`[test-id-WORKSPACE_CREATED] Workspace created`, { workspaceId: newID, workspaceName: newWorkspace.name, wikiFolderLocation: newWorkspace.wikiFolderLocation }); return newWorkspace; } diff --git a/src/services/workspaces/interface.ts b/src/services/workspaces/interface.ts index a4725067..40a6b6a7 100644 --- a/src/services/workspaces/interface.ts +++ b/src/services/workspaces/interface.ts @@ -10,6 +10,129 @@ import { SetOptional } from 'type-fest'; */ export const nonConfigFields = ['metadata', 'lastNodeJSArgv']; +/** + * Fields that should be synced to wiki folder's tidgi.config.json. + * These are user preferences that should follow the wiki across devices. + * + * ⚠️ IMPORTANT: When modifying this list, remember to also update: + * - src/services/workspaces/tidgi.config.schema.json (JSON Schema definition) + * - syncableConfigDefaultValues (default values) + */ +export const syncableConfigFields = [ + 'name', + 'port', + 'gitUrl', + 'storageService', + 'userName', + 'readOnlyMode', + 'tokenAuth', + 'enableHTTPAPI', + 'enableFileSystemWatch', + 'ignoreSymlinks', + 'backupOnInterval', + 'syncOnInterval', + 'syncOnStartup', + 'disableAudio', + 'disableNotifications', + 'hibernateWhenUnused', + 'transparentBackground', + 'excludedPlugins', + 'tagNames', + 'includeTagTree', + 'fileSystemPathFilterEnable', + 'fileSystemPathFilter', + 'rootTiddler', + 'https', +] as const; + +/** + * Type for syncable config fields + */ +export type SyncableConfigField = typeof syncableConfigFields[number]; + +/** + * Fields that are device-specific and should only be stored locally. + */ +export const localOnlyFields = [ + 'id', + 'order', + 'active', + 'hibernated', + 'lastUrl', + 'lastNodeJSArgv', + 'homeUrl', + 'authToken', + 'picturePath', + 'wikiFolderLocation', + 'mainWikiToLink', + 'mainWikiID', + 'isSubWiki', + 'pageType', +] as const; + +/** + * Default values for syncable config fields (stored in tidgi.config.json) + * + * ⚠️ IMPORTANT: When modifying this object, remember to also update: + * - src/services/workspaces/tidgi.config.schema.json (JSON Schema definition) + * - syncableConfigFields (field list) + */ +export const syncableConfigDefaultValues = { + name: '', + port: 5212, + gitUrl: null, + storageService: SupportedStorageServices.local, + userName: '', + readOnlyMode: false, + tokenAuth: false, + enableHTTPAPI: false, + enableFileSystemWatch: false, + ignoreSymlinks: true, + backupOnInterval: true, + syncOnInterval: false, + syncOnStartup: true, + disableAudio: false, + disableNotifications: false, + hibernateWhenUnused: false, + transparentBackground: false, + excludedPlugins: [] as string[], + tagNames: [] as string[], + includeTagTree: false, + fileSystemPathFilterEnable: false, + fileSystemPathFilter: null as string | null, + rootTiddler: undefined as string | undefined, + https: undefined as { enabled: boolean; tlsCert?: string; tlsKey?: string } | undefined, +} as const; + +/** + * Type for syncable config + * + * ⚠️ IMPORTANT: This type is derived from syncableConfigDefaultValues. + * When modifying types here, remember to also update: + * - src/services/workspaces/tidgi.config.schema.json (JSON Schema definition) + */ +export type ISyncableWikiConfig = { + -readonly [K in keyof typeof syncableConfigDefaultValues]: (typeof syncableConfigDefaultValues)[K]; +}; + +/** + * Default values for local-only fields (stored in database) + */ +export const localConfigDefaultValues = { + id: '', + order: 0, + active: false, + hibernated: false, + lastUrl: null as string | null, + lastNodeJSArgv: [] as string[], + homeUrl: '', + authToken: undefined as string | undefined, + picturePath: null as string | null, + mainWikiToLink: null as string | null, + mainWikiID: null as string | null, + pageType: null as PageType.wiki | null, +} as const; + /** * Default values for IWikiWorkspace fields. These are used for: * 1. Initializing new workspaces @@ -17,39 +140,9 @@ export const nonConfigFields = ['metadata', 'lastNodeJSArgv']; * 3. Determining which fields need to be saved (only non-default values are persisted) */ export const wikiWorkspaceDefaultValues = { - id: '', - name: '', - order: 0, - picturePath: null, - gitUrl: null, - active: false, - backupOnInterval: true, - disableAudio: false, - disableNotifications: false, - enableFileSystemWatch: false, - enableHTTPAPI: false, - excludedPlugins: [], - fileSystemPathFilter: null, - fileSystemPathFilterEnable: false, - hibernateWhenUnused: false, - hibernated: false, - ignoreSymlinks: true, - includeTagTree: false, - lastNodeJSArgv: [], - lastUrl: null, - mainWikiID: null, - mainWikiToLink: null, - pageType: null, - readOnlyMode: false, - storageService: SupportedStorageServices.github, - subWikiFolderName: 'subwiki', - syncOnInterval: false, - syncOnStartup: true, - tagNames: [], - tokenAuth: false, - transparentBackground: false, - userName: '', -} satisfies Omit; + ...localConfigDefaultValues, + ...syncableConfigDefaultValues, +} satisfies Omit; export interface IDedicatedWorkspace { /** @@ -163,10 +256,6 @@ export interface IWikiWorkspace extends IDedicatedWorkspace { * Storage service this workspace sync to */ storageService: SupportedStorageServices; - /** - * We basically place sub-wiki in main wiki's `tiddlers/subwiki/` folder, but the `subwiki` part can be configured. Default is `subwiki` - */ - subWikiFolderName: string; /** * Sync wiki every interval. * If this is false (false by default to save the CPU usage from chokidar watch), then sync will only happen if user manually trigger by click sync button in the wiki, or sync at the app open. @@ -269,7 +358,6 @@ export type INewWikiWorkspaceConfig = SetOptional< | 'disableNotifications' | 'disableAudio' | 'hibernateWhenUnused' - | 'subWikiFolderName' | 'userName' | 'order' | 'ignoreSymlinks' diff --git a/src/services/workspaces/syncableConfig.ts b/src/services/workspaces/syncableConfig.ts new file mode 100644 index 00000000..e74c3b1e --- /dev/null +++ b/src/services/workspaces/syncableConfig.ts @@ -0,0 +1,141 @@ +/** + * Syncable workspace configuration constants and types. + * + * ⚠️ CRITICAL: This file must NOT import any module that transitively imports 'electron' + * because it is used by tidgiConfig.ts which is bundled with Worker code. + * + * This includes: + * - Do NOT import from './interface' (it imports electron-ipc-cat) + * - Do NOT import from '@services/libs/log' (it imports electron) + * - Do NOT import rxjs, inversify, or other main-process modules + * + * Keep this file minimal with only pure TypeScript types and constants. + */ + +/** + * Supported storage services - duplicated here to avoid circular imports + * Keep in sync with @services/types.ts + */ +export enum SupportedStorageServicesForSync { + gitee = 'gitee', + github = 'github', + gitlab = 'gitlab', + gitea = 'gitea', + codeberg = 'codeberg', + local = 'local', + solid = 'solid', + testOAuth = 'testOAuth', +} + +/** + * Fields that should be synced to wiki folder's tidgi.config.json. + * These are user preferences that should follow the wiki across devices. + * + * ⚠️ IMPORTANT: When modifying this list, remember to also update: + * - src/services/workspaces/tidgi.config.schema.json (JSON Schema definition) + * - syncableConfigDefaultValues (default values) + */ +export const syncableConfigFields = [ + 'name', + 'port', + 'gitUrl', + 'storageService', + 'userName', + 'readOnlyMode', + 'tokenAuth', + 'enableHTTPAPI', + 'enableFileSystemWatch', + 'ignoreSymlinks', + 'backupOnInterval', + 'syncOnInterval', + 'syncOnStartup', + 'disableAudio', + 'disableNotifications', + 'hibernateWhenUnused', + 'transparentBackground', + 'excludedPlugins', + 'tagNames', + 'includeTagTree', + 'fileSystemPathFilterEnable', + 'fileSystemPathFilter', + 'rootTiddler', + 'https', +] as const; + +/** + * Type for syncable config fields + */ +export type SyncableConfigField = typeof syncableConfigFields[number]; + +/** + * Default values for syncable config fields (stored in tidgi.config.json) + * + * ⚠️ IMPORTANT: When modifying this object, remember to also update: + * - src/services/workspaces/tidgi.config.schema.json (JSON Schema definition) + * - syncableConfigFields (field list) + */ +export const syncableConfigDefaultValues = { + name: '', + port: 5212, + gitUrl: null as string | null, + storageService: SupportedStorageServicesForSync.local as string, + userName: '', + readOnlyMode: false, + tokenAuth: false, + enableHTTPAPI: false, + enableFileSystemWatch: false, + ignoreSymlinks: true, + backupOnInterval: true, + syncOnInterval: false, + syncOnStartup: true, + disableAudio: false, + disableNotifications: false, + hibernateWhenUnused: false, + transparentBackground: false, + excludedPlugins: [] as string[], + tagNames: [] as string[], + includeTagTree: false, + fileSystemPathFilterEnable: false, + fileSystemPathFilter: null as string | null, + rootTiddler: undefined as string | undefined, + https: undefined as { enabled: boolean; tlsCert?: string; tlsKey?: string } | undefined, +} as const; + +/** + * Type for syncable config - used by tidgiConfig.ts + */ +export type ISyncableWikiConfig = { + name: string; + port: number; + gitUrl: string | null; + storageService: string; + userName: string; + readOnlyMode: boolean; + tokenAuth: boolean; + enableHTTPAPI: boolean; + enableFileSystemWatch: boolean; + ignoreSymlinks: boolean; + backupOnInterval: boolean; + syncOnInterval: boolean; + syncOnStartup: boolean; + disableAudio: boolean; + disableNotifications: boolean; + hibernateWhenUnused: boolean; + transparentBackground: boolean; + excludedPlugins: string[]; + tagNames: string[]; + includeTagTree: boolean; + fileSystemPathFilterEnable: boolean; + fileSystemPathFilter: string | null; + rootTiddler?: string; + https?: { enabled: boolean; tlsCert?: string; tlsKey?: string }; +}; + +/** + * Minimal interface for wiki workspace - only fields needed by tidgiConfig.ts + * This avoids importing the full IWikiWorkspace from interface.ts + */ +export interface IWikiWorkspaceMinimal extends Partial { + id: string; + wikiFolderLocation: string; +} diff --git a/src/services/workspaces/tidgi.config.schema.json b/src/services/workspaces/tidgi.config.schema.json new file mode 100644 index 00000000..7049c20e --- /dev/null +++ b/src/services/workspaces/tidgi.config.schema.json @@ -0,0 +1,164 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://raw.githubusercontent.com/tiddly-gittly/TidGi-Desktop/master/src/services/workspaces/tidgi.config.schema.json", + "title": "TidGi Workspace Configuration", + "description": "Configuration file for TidGi workspace settings that sync across devices", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "JSON Schema reference" + }, + "version": { + "type": "number", + "description": "Configuration file format version", + "const": 1 + }, + "name": { + "type": "string", + "description": "Custom display name for the workspace", + "default": "" + }, + "port": { + "type": "number", + "description": "Port number for TiddlyWiki server", + "minimum": 1024, + "maximum": 65535, + "default": 5212 + }, + "gitUrl": { + "type": ["string", "null"], + "description": "Git repository URL for syncing", + "format": "uri", + "default": null + }, + "storageService": { + "type": "string", + "description": "Storage service type for syncing", + "enum": ["local", "github", "gitlab"], + "default": "local" + }, + "userName": { + "type": "string", + "description": "User name for TiddlyWiki", + "default": "" + }, + "readOnlyMode": { + "type": "boolean", + "description": "Enable read-only mode to prevent editing", + "default": false + }, + "tokenAuth": { + "type": "boolean", + "description": "Enable token-based authentication", + "default": false + }, + "enableHTTPAPI": { + "type": "boolean", + "description": "Enable HTTP API for TiddlyWiki", + "default": false + }, + "enableFileSystemWatch": { + "type": "boolean", + "description": "Enable file system watching for automatic reload", + "default": false + }, + "ignoreSymlinks": { + "type": "boolean", + "description": "Ignore symbolic links when scanning wiki folder", + "default": true + }, + "backupOnInterval": { + "type": "boolean", + "description": "Enable automatic periodic backups", + "default": true + }, + "syncOnInterval": { + "type": "boolean", + "description": "Enable automatic periodic Git sync", + "default": false + }, + "syncOnStartup": { + "type": "boolean", + "description": "Sync with Git repository on startup", + "default": true + }, + "disableAudio": { + "type": "boolean", + "description": "Disable audio playback in this workspace", + "default": false + }, + "disableNotifications": { + "type": "boolean", + "description": "Disable system notifications from this workspace", + "default": false + }, + "hibernateWhenUnused": { + "type": "boolean", + "description": "Hibernate workspace when not in use to save resources", + "default": false + }, + "transparentBackground": { + "type": "boolean", + "description": "Enable transparent window background", + "default": false + }, + "excludedPlugins": { + "type": "array", + "description": "List of TiddlyWiki plugins to exclude", + "items": { + "type": "string" + }, + "default": [] + }, + "tagNames": { + "type": "array", + "description": "Tag names for sub-wiki filtering (for sub-wikis only)", + "items": { + "type": "string" + }, + "default": [] + }, + "includeTagTree": { + "type": "boolean", + "description": "Include entire tag tree when filtering tiddlers for sub-wikis", + "default": false + }, + "fileSystemPathFilterEnable": { + "type": "boolean", + "description": "Enable custom file system path filter", + "default": false + }, + "fileSystemPathFilter": { + "type": ["string", "null"], + "description": "Custom TiddlyWiki filter for routing tiddlers to sub-wikis", + "default": null + }, + "rootTiddler": { + "type": "string", + "description": "Root tiddler to display on startup" + }, + "https": { + "type": "object", + "description": "HTTPS configuration for TiddlyWiki server", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable HTTPS" + }, + "tlsCert": { + "type": "string", + "description": "Path to TLS certificate file" + }, + "tlsKey": { + "type": "string", + "description": "Path to TLS private key file" + } + }, + "required": ["enabled"], + "additionalProperties": false + } + }, + "required": ["version"], + "additionalProperties": false +} diff --git a/src/windows/GitLog/index.tsx b/src/windows/GitLog/index.tsx index 14e0ba29..8fa1f9b5 100644 --- a/src/windows/GitLog/index.tsx +++ b/src/windows/GitLog/index.tsx @@ -47,7 +47,7 @@ export default function GitHistory(): React.JSX.Element { ); } - const { entries, loading, loadingMore, error, workspaceInfo, currentBranch, lastChangeType, hasMore, loadMore, setSearchParams } = useGitLogData(workspaceID); + const { entries, loading, loadingMore, error, workspaceInfo, currentBranch, lastChangeType, setLastChangeType, hasMore, loadMore, setSearchParams } = useGitLogData(workspaceID); const { selectedCommit, setSelectedCommit } = useCommitDetails(); const { selectedFile, @@ -104,6 +104,7 @@ export default function GitHistory(): React.JSX.Element { setShouldSelectFirst, setSelectedCommit, lastChangeType, + setLastChangeType, selectedCommit, setSearchParams, setCurrentSearchParameters, diff --git a/src/windows/GitLog/useGitHistoryLogic.ts b/src/windows/GitLog/useGitHistoryLogic.ts index 96a45eab..bd430fe2 100644 --- a/src/windows/GitLog/useGitHistoryLogic.ts +++ b/src/windows/GitLog/useGitHistoryLogic.ts @@ -122,24 +122,24 @@ export function useCommitSelection({ setSelectedFile, }: IUseCommitSelectionProps): IUseCommitSelectionReturn { // Track if we've already processed the current change type - const lastProcessedChangeRef = useRef(null); + const lastProcessedChangeReference = useRef(null); // Track if we've done initial selection - const hasInitialSelectionRef = useRef(false); + const hasInitialSelectionReference = useRef(false); // Auto-select on initial load: uncommitted changes if present, otherwise first commit useEffect(() => { - if (!hasInitialSelectionRef.current && entries.length > 0 && !selectedCommit) { + if (!hasInitialSelectionReference.current && entries.length > 0 && !selectedCommit) { // First try to find uncommitted changes const uncommittedEntry = entries.find((entry) => entry.hash === ''); if (uncommittedEntry) { setSelectedCommit(uncommittedEntry); - hasInitialSelectionRef.current = true; + hasInitialSelectionReference.current = true; } else { // If no uncommitted changes, select the first commit const firstCommit = entries[0]; if (firstCommit) { setSelectedCommit(firstCommit); - hasInitialSelectionRef.current = true; + hasInitialSelectionReference.current = true; } } } @@ -162,7 +162,7 @@ export function useCommitSelection({ if (selectedCommit && entries.length > 0 && !shouldSelectFirst) { // Try to find the same commit in the new entries const stillExists = entries.find((entry) => entry.hash === selectedCommit.hash); - + if (stillExists) { // Only update if data actually changed (compare by serialization) if (JSON.stringify(stillExists) !== JSON.stringify(selectedCommit)) { @@ -175,7 +175,11 @@ export function useCommitSelection({ // Select the first non-uncommitted commit const firstCommit = entries.find((entry) => entry.hash !== ''); if (firstCommit) { - void window.service.native.log('debug', '[test-id-selection-switched-from-uncommitted]', { oldHash: selectedCommit.hash, newHash: firstCommit.hash, newMessage: firstCommit.message }); + void window.service.native.log('debug', '[test-id-selection-switched-from-uncommitted]', { + oldHash: selectedCommit.hash, + newHash: firstCommit.hash, + newMessage: firstCommit.message, + }); setSelectedCommit(firstCommit); } } @@ -185,7 +189,7 @@ export function useCommitSelection({ // Handle post-operation selection based on lastChangeType useEffect(() => { // Skip if we've already processed this change type - if (lastChangeType && lastChangeType !== lastProcessedChangeRef.current) { + if (lastChangeType && lastChangeType !== lastProcessedChangeReference.current) { if (lastChangeType === 'revert' && entries.length > 0) { // After revert, wait for the new revert commit to appear in entries // The new revert commit should be the first one and different from the currently selected one @@ -195,7 +199,7 @@ export function useCommitSelection({ if (firstCommit && (!selectedCommit || firstCommit.hash !== selectedCommit.hash)) { void window.service.native.log('debug', '[test-id-revert-auto-select]', { hash: firstCommit.hash, message: firstCommit.message }); setSelectedCommit(firstCommit); - lastProcessedChangeRef.current = lastChangeType; + lastProcessedChangeReference.current = lastChangeType; } } else if (lastChangeType === 'undo' && entries.length > 0) { // After undo, select uncommitted changes if they exist @@ -203,7 +207,7 @@ export function useCommitSelection({ if (uncommittedEntry) { void window.service.native.log('debug', '[test-id-undo-auto-select]', { message: 'Selected uncommitted changes' }); setSelectedCommit(uncommittedEntry); - lastProcessedChangeRef.current = lastChangeType; + lastProcessedChangeReference.current = lastChangeType; } } } diff --git a/src/windows/GitLog/useGitLogData.ts b/src/windows/GitLog/useGitLogData.ts index e42900d5..521b3c56 100644 --- a/src/windows/GitLog/useGitLogData.ts +++ b/src/windows/GitLog/useGitLogData.ts @@ -15,6 +15,7 @@ export interface IGitLogData { currentBranch: string | null; workspaceInfo: IWorkspace | null; lastChangeType: string | null; + setLastChangeType: (value: string | null) => void; hasMore: boolean; loadMore: () => Promise; setSearchParams: (parameters: ISearchParameters) => void; @@ -222,17 +223,17 @@ export function useGitLogData(workspaceID: string): IGitLogData { }, [workspaceInfo, refreshTrigger, searchParameters]); // Track the last logged entries to detect actual changes - const lastLoggedEntriesRef = useRef(''); + const lastLoggedEntriesReference = useRef(''); // Log when entries are actually updated and rendered to DOM useEffect(() => { if (entries.length > 0 && workspaceInfo && 'wikiFolderLocation' in workspaceInfo) { // Create a fingerprint of current entries to detect real changes - const entriesFingerprint = entries.map(e => e.hash || 'uncommitted').join(','); - + const entriesFingerprint = entries.map(entry => entry.hash || 'uncommitted').join(','); + // Only log if entries actually changed - if (entriesFingerprint !== lastLoggedEntriesRef.current) { - lastLoggedEntriesRef.current = entriesFingerprint; + if (entriesFingerprint !== lastLoggedEntriesReference.current) { + lastLoggedEntriesReference.current = entriesFingerprint; // Use setTimeout to ensure DOM has been updated after state changes setTimeout(() => { void window.service.native.log('debug', '[test-id-git-log-data-rendered]', { @@ -335,6 +336,7 @@ export function useGitLogData(workspaceID: string): IGitLogData { currentBranch, workspaceInfo, lastChangeType, + setLastChangeType, hasMore, loadMore, setSearchParams: setSearchParameters, diff --git a/src/windows/Preferences/sections/__tests__/TidGiMiniWindow.test.tsx b/src/windows/Preferences/sections/__tests__/TidGiMiniWindow.test.tsx index e6360bd5..d7babf7b 100644 --- a/src/windows/Preferences/sections/__tests__/TidGiMiniWindow.test.tsx +++ b/src/windows/Preferences/sections/__tests__/TidGiMiniWindow.test.tsx @@ -41,7 +41,6 @@ const mockWorkspaces: IWorkspace[] = [ hibernateWhenUnused: false, readOnlyMode: false, storageService: SupportedStorageServices.local, - subWikiFolderName: 'subwiki', syncOnInterval: false, syncOnStartup: false, tokenAuth: false, @@ -71,7 +70,6 @@ const mockWorkspaces: IWorkspace[] = [ hibernateWhenUnused: false, readOnlyMode: false, storageService: SupportedStorageServices.local, - subWikiFolderName: 'subwiki', syncOnInterval: false, syncOnStartup: false, tokenAuth: false,