Fix/watch fs large json (#657)

* fix: large json import still need to check identity

* fix: show wiki server error by hide view

* fix: possible loop when image load failed

fixes https://github.com/tiddly-gittly/TidGi-Desktop/issues/562

* fix: more check and reduce logs

* fix: sub wiki no sync notification

fixes https://github.com/tiddly-gittly/TidGi-Desktop/issues/526

* fix: type

* fix: update git sync js

fixes https://github.com/tiddly-gittly/TidGi-Desktop/issues/515

* fix: msix not uploaded

* typo

* lint

* Update comparison.ts
This commit is contained in:
lin onetwo 2025-11-24 01:45:33 +08:00 committed by GitHub
parent ccf825af06
commit 45e3f76da1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 248 additions and 108 deletions

View file

@ -134,7 +134,7 @@ jobs:
draft: true draft: true
generate_release_notes: true generate_release_notes: true
files: | files: |
${{ matrix.platform == 'win' && 'out/make/**/*.exe' || 'out/make/**/*' }} ${{ matrix.platform == 'win' && 'out/make/**/*.{exe,msix,appx}' || 'out/make/**/*' }}
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View file

@ -74,7 +74,7 @@
"espree": "^11.0.0", "espree": "^11.0.0",
"exponential-backoff": "^3.1.3", "exponential-backoff": "^3.1.3",
"fs-extra": "11.3.2", "fs-extra": "11.3.2",
"git-sync-js": "^2.3.0", "git-sync-js": "^2.3.1",
"graphql-hooks": "8.2.0", "graphql-hooks": "8.2.0",
"html-minifier-terser": "^7.2.0", "html-minifier-terser": "^7.2.0",
"i18next": "25.6.3", "i18next": "25.6.3",
@ -129,6 +129,7 @@
"optionalDependencies": { "optionalDependencies": {
"@electron-forge/maker-deb": "7.10.2", "@electron-forge/maker-deb": "7.10.2",
"@electron-forge/maker-flatpak": "7.10.2", "@electron-forge/maker-flatpak": "7.10.2",
"@electron-forge/maker-msix": "7.10.2",
"@electron-forge/maker-rpm": "7.10.2", "@electron-forge/maker-rpm": "7.10.2",
"@electron-forge/maker-snap": "7.10.2", "@electron-forge/maker-snap": "7.10.2",
"@electron-forge/maker-squirrel": "7.10.2", "@electron-forge/maker-squirrel": "7.10.2",
@ -138,7 +139,6 @@
"devDependencies": { "devDependencies": {
"@cucumber/cucumber": "^12.2.0", "@cucumber/cucumber": "^12.2.0",
"@electron-forge/cli": "7.10.2", "@electron-forge/cli": "7.10.2",
"@electron-forge/maker-msix": "^7.10.2",
"@electron-forge/plugin-auto-unpack-natives": "7.10.2", "@electron-forge/plugin-auto-unpack-natives": "7.10.2",
"@electron-forge/plugin-vite": "7.10.2", "@electron-forge/plugin-vite": "7.10.2",
"@electron/rebuild": "^4.0.1", "@electron/rebuild": "^4.0.1",

22
pnpm-lock.yaml generated
View file

@ -138,8 +138,8 @@ importers:
specifier: 11.3.2 specifier: 11.3.2
version: 11.3.2 version: 11.3.2
git-sync-js: git-sync-js:
specifier: ^2.3.0 specifier: ^2.3.1
version: 2.3.0 version: 2.3.1
graphql-hooks: graphql-hooks:
specifier: 8.2.0 specifier: 8.2.0
version: 8.2.0(react@19.2.0) version: 8.2.0(react@19.2.0)
@ -297,9 +297,6 @@ importers:
'@electron-forge/cli': '@electron-forge/cli':
specifier: 7.10.2 specifier: 7.10.2
version: 7.10.2(@swc/core@1.12.0)(bluebird@3.7.2)(encoding@0.1.13)(esbuild@0.27.0) version: 7.10.2(@swc/core@1.12.0)(bluebird@3.7.2)(encoding@0.1.13)(esbuild@0.27.0)
'@electron-forge/maker-msix':
specifier: ^7.10.2
version: 7.10.2(bluebird@3.7.2)
'@electron-forge/plugin-auto-unpack-natives': '@electron-forge/plugin-auto-unpack-natives':
specifier: 7.10.2 specifier: 7.10.2
version: 7.10.2(bluebird@3.7.2) version: 7.10.2(bluebird@3.7.2)
@ -448,6 +445,9 @@ importers:
'@electron-forge/maker-flatpak': '@electron-forge/maker-flatpak':
specifier: 7.10.2 specifier: 7.10.2
version: 7.10.2(bluebird@3.7.2) version: 7.10.2(bluebird@3.7.2)
'@electron-forge/maker-msix':
specifier: 7.10.2
version: 7.10.2(bluebird@3.7.2)
'@electron-forge/maker-rpm': '@electron-forge/maker-rpm':
specifier: 7.10.2 specifier: 7.10.2
version: 7.10.2(bluebird@3.7.2) version: 7.10.2(bluebird@3.7.2)
@ -4572,8 +4572,8 @@ packages:
gifwrap@0.10.1: gifwrap@0.10.1:
resolution: {integrity: sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw==} resolution: {integrity: sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw==}
git-sync-js@2.3.0: git-sync-js@2.3.1:
resolution: {integrity: sha512-RFyyWBjdZskl2Jxg3a9g2tzAShhhFkxyS83lON6asaKHov4Rks8P7irCMjQIRiFTlqLrvZFDgjeWbo7jc1IKhQ==} resolution: {integrity: sha512-EbX6SIsUc2hcXEQXsaYz0EyDSQwlKi3AbZk00hIB97YOyKQPtKeQWQ7+6w87GSKD6m2mfBGVyIgF57DA0ajo3g==}
github-from-package@0.0.0: github-from-package@0.0.0:
resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
@ -8456,6 +8456,7 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- bluebird - bluebird
- supports-color - supports-color
optional: true
'@electron-forge/maker-rpm@7.10.2(bluebird@3.7.2)': '@electron-forge/maker-rpm@7.10.2(bluebird@3.7.2)':
dependencies: dependencies:
@ -11122,6 +11123,7 @@ snapshots:
dependencies: dependencies:
ansi-styles: 4.3.0 ansi-styles: 4.3.0
supports-color: 7.2.0 supports-color: 7.2.0
optional: true
chalk@4.1.2: chalk@4.1.2:
dependencies: dependencies:
@ -11650,6 +11652,7 @@ snapshots:
xml-escape: 1.1.0 xml-escape: 1.1.0
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
optional: true
electron-winstaller@5.4.0: electron-winstaller@5.4.0:
dependencies: dependencies:
@ -12657,7 +12660,7 @@ snapshots:
image-q: 4.0.0 image-q: 4.0.0
omggif: 1.0.10 omggif: 1.0.10
git-sync-js@2.3.0: git-sync-js@2.3.1:
dependencies: dependencies:
dugite: 3.0.0-rc12 dugite: 3.0.0-rc12
fs-extra: 11.3.2 fs-extra: 11.3.2
@ -15844,7 +15847,8 @@ snapshots:
ws@8.18.3: {} ws@8.18.3: {}
xml-escape@1.1.0: {} xml-escape@1.1.0:
optional: true
xml-name-validator@5.0.0: {} xml-name-validator@5.0.0: {}

View file

@ -102,6 +102,8 @@ function initWikiGit(
*/ */
function commitAndSyncWiki(workspace: IWorkspace, configs: ICommitAndSyncConfigs, errorI18NDict: Record<string, string>): Observable<IGitLogMessage> { function commitAndSyncWiki(workspace: IWorkspace, configs: ICommitAndSyncConfigs, errorI18NDict: Record<string, string>): Observable<IGitLogMessage> {
return new Observable<IGitLogMessage>((observer) => { return new Observable<IGitLogMessage>((observer) => {
// For sub-wiki, show sync progress in main workspace
const workspaceIDForNotification = isWikiWorkspace(workspace) && workspace.isSubWiki ? workspace.mainWikiID! : workspace.id;
void commitAndSync({ void commitAndSync({
...configs, ...configs,
defaultGitInfo, defaultGitInfo,
@ -113,7 +115,7 @@ function commitAndSyncWiki(workspace: IWorkspace, configs: ICommitAndSyncConfigs
observer.next({ message, level: 'warn', meta: { callerFunction: 'commitAndSync', ...context } }); observer.next({ message, level: 'warn', meta: { callerFunction: 'commitAndSync', ...context } });
}, },
info: (message: GitStep, context: ILoggerContext): void => { info: (message: GitStep, context: ILoggerContext): void => {
observer.next({ message, level: 'info', meta: { handler: WikiChannel.syncProgress, id: workspace.id, callerFunction: 'commitAndSync', ...context } }); observer.next({ message, level: 'info', meta: { handler: WikiChannel.syncProgress, id: workspaceIDForNotification, callerFunction: 'commitAndSync', ...context } });
}, },
}, },
filesToIgnore: ['.DS_Store'], filesToIgnore: ['.DS_Store'],
@ -146,6 +148,8 @@ function forcePullWiki(workspace: IWorkspace, configs: IForcePullConfigs, errorI
observer.error(new Error('forcePullWiki can only be called on wiki workspaces')); observer.error(new Error('forcePullWiki can only be called on wiki workspaces'));
return; return;
} }
// For sub-wiki, show sync progress in main workspace
const workspaceIDForNotification = isWikiWorkspace(workspace) && workspace.isSubWiki ? workspace.mainWikiID! : workspace.id;
void forcePull({ void forcePull({
dir: workspace.wikiFolderLocation, dir: workspace.wikiFolderLocation,
...configs, ...configs,
@ -158,7 +162,7 @@ function forcePullWiki(workspace: IWorkspace, configs: IForcePullConfigs, errorI
observer.next({ message, level: 'warn', meta: { callerFunction: 'forcePull', ...context } }); observer.next({ message, level: 'warn', meta: { callerFunction: 'forcePull', ...context } });
}, },
info: (message: GitStep, context: ILoggerContext): void => { info: (message: GitStep, context: ILoggerContext): void => {
observer.next({ message, level: 'info', meta: { handler: WikiChannel.syncProgress, id: workspace.id, callerFunction: 'forcePull', ...context } }); observer.next({ message, level: 'info', meta: { handler: WikiChannel.syncProgress, id: workspaceIDForNotification, callerFunction: 'forcePull', ...context } });
}, },
}, },
}).then( }).then(

View file

@ -428,14 +428,14 @@ ${message.message}
} }
public formatFileUrlToAbsolutePath(urlWithFileProtocol: string): string { public formatFileUrlToAbsolutePath(urlWithFileProtocol: string): string {
logger.info('getting url', { url: urlWithFileProtocol, function: 'formatFileUrlToAbsolutePath' }); logger.debug('formatting file URL to absolute path', { url: urlWithFileProtocol, function: 'formatFileUrlToAbsolutePath' });
let pathname = ''; let pathname = '';
let hostname = ''; let hostname = '';
try { try {
({ hostname, pathname } = new URL(urlWithFileProtocol)); ({ hostname, pathname } = new URL(urlWithFileProtocol));
} catch { } catch {
pathname = urlWithFileProtocol.replace('file://', '').replace('open://', ''); pathname = urlWithFileProtocol.replace('file://', '').replace('open://', '');
logger.error(`Parse URL failed, use original url replace file:// instead`, { pathname, function: 'formatFileUrlToAbsolutePath.error' }); logger.debug(`Parse URL failed, using fallback string replace`, { pathname, function: 'formatFileUrlToAbsolutePath' });
} }
/** /**
* urlWithFileProtocol: `file://./files/xxx.png` * urlWithFileProtocol: `file://./files/xxx.png`
@ -446,38 +446,33 @@ ${message.message}
if (process.platform === 'win32' && filePath.startsWith('/')) { if (process.platform === 'win32' && filePath.startsWith('/')) {
filePath = filePath.substring(1); filePath = filePath.substring(1);
} }
logger.info('handle file:// or open:// This url will open file in-wiki', { hostname, pathname, filePath, function: 'formatFileUrlToAbsolutePath' });
let fileExists = fs.existsSync(filePath); // Strategy 1: Try as-is (for absolute paths)
logger.info('file exists (decodeURI)', { if (fs.existsSync(filePath)) {
function: 'formatFileUrlToAbsolutePath', logger.debug('file found (direct path)', { filePath, function: 'formatFileUrlToAbsolutePath' });
filePath,
exists: fileExists,
});
if (fileExists) {
return filePath; return filePath;
} }
logger.info(`try find file relative to workspace folder`, { filePath, function: 'formatFileUrlToAbsolutePath' });
// Strategy 2: Try relative to workspace folder
const workspaceService = container.get<IWorkspaceService>(serviceIdentifier.Workspace); const workspaceService = container.get<IWorkspaceService>(serviceIdentifier.Workspace);
const workspace = workspaceService.getActiveWorkspaceSync(); const workspace = workspaceService.getActiveWorkspaceSync();
if (workspace === undefined || !isWikiWorkspace(workspace)) { if (workspace !== undefined && isWikiWorkspace(workspace)) {
logger.error(`No active workspace or not a wiki workspace, abort. Try loading filePath as-is.`, { filePath, function: 'formatFileUrlToAbsolutePath' }); const filePathInWorkspaceFolder = path.resolve(workspace.wikiFolderLocation, filePath);
return filePath; if (fs.existsSync(filePathInWorkspaceFolder)) {
logger.debug('file found (workspace relative)', { filePathInWorkspaceFolder, function: 'formatFileUrlToAbsolutePath' });
return filePathInWorkspaceFolder;
}
} }
// try concat workspace path + file path to get relative path
const filePathInWorkspaceFolder = path.resolve(workspace.wikiFolderLocation, filePath); // Strategy 3: Try relative to TidGi App folder (for bundled assets)
fileExists = fs.existsSync(filePathInWorkspaceFolder);
logger.info(`This file ${fileExists ? '' : 'not '}exists in workspace folder.`, { filePathInWorkspaceFolder, function: 'formatFileUrlToAbsolutePath' });
if (fileExists) {
return filePathInWorkspaceFolder;
}
// on production, __dirname will be in .webpack/main
const inTidGiAppAbsoluteFilePath = path.join(app.getAppPath(), '.webpack', 'renderer', filePath); const inTidGiAppAbsoluteFilePath = path.join(app.getAppPath(), '.webpack', 'renderer', filePath);
logger.info(`try find file relative to TidGi App folder`, { inTidGiAppAbsoluteFilePath, function: 'formatFileUrlToAbsolutePath' }); if (fs.existsSync(inTidGiAppAbsoluteFilePath)) {
fileExists = fs.existsSync(inTidGiAppAbsoluteFilePath); logger.debug('file found (app relative)', { inTidGiAppAbsoluteFilePath, function: 'formatFileUrlToAbsolutePath' });
if (fileExists) {
return inTidGiAppAbsoluteFilePath; return inTidGiAppAbsoluteFilePath;
} }
logger.warn(`This url can't be loaded in-wiki. Try loading url as-is.`, { url: urlWithFileProtocol, function: 'formatFileUrlToAbsolutePath' });
// File not found - return original URL as fallback
logger.warn('file not found in any location, returning original URL', { url: urlWithFileProtocol, filePath, function: 'formatFileUrlToAbsolutePath' });
return urlWithFileProtocol; return urlWithFileProtocol;
} }

View file

@ -72,25 +72,60 @@ export function handleViewFileContentLoading(view: WebContentsView) {
function handleFileLink(details: Electron.OnBeforeRequestListenerDetails, callback: (response: Electron.CallbackResponse) => void) { function handleFileLink(details: Electron.OnBeforeRequestListenerDetails, callback: (response: Electron.CallbackResponse) => void) {
const nativeService = container.get<INativeService>(serviceIdentifier.NativeService); const nativeService = container.get<INativeService>(serviceIdentifier.NativeService);
const absolutePath: string | undefined = nativeService.formatFileUrlToAbsolutePath(details.url); const absolutePath: string = nativeService.formatFileUrlToAbsolutePath(details.url);
// When details.url is an absolute route, we just load it, don't need any redirect
// Prevent infinite redirect loop when path resolution failed
// formatFileUrlToAbsolutePath already checks file existence internally (3 times with different path strategies)
// If file not found, it returns the original URL as fallback
// Case 1: formatFileUrlToAbsolutePath returns the original URL (file not found fallback)
// Case 2: Resolved path still contains protocol (resolution failed)
// Case 3: Resolved path is relative (./xxx or ../xxx) - these cannot be loaded by Electron
if ( if (
`file://${absolutePath}` === decodeURI(details.url) || absolutePath === details.url ||
absolutePath === decodeURI(details.url) || absolutePath.startsWith('file://') ||
// also allow malformed `file:///` on `details.url` on windows, prevent infinite redirect when this check failed. absolutePath.startsWith('open://') ||
(process.platform === 'win32' && `file:///${absolutePath}` === decodeURI(details.url)) absolutePath.startsWith('./') ||
absolutePath.startsWith('../')
) { ) {
logger.debug('open file protocol', { logger.warn('File path resolution failed or returned invalid path, request canceled to prevent redirect loop', {
function: 'handleFileLink', function: 'handleFileLink',
absolutePath: absolutePath ?? '', originalUrl: details.url,
resolvedPath: absolutePath,
reason: absolutePath === details.url
? 'same as original'
: absolutePath.startsWith('file://') || absolutePath.startsWith('open://')
? 'contains protocol'
: 'relative path',
});
callback({
cancel: true,
});
return;
}
// When details.url is already an absolute file path, load it directly without redirect
const decodedUrl = decodeURI(details.url);
if (
`file://${absolutePath}` === decodedUrl ||
absolutePath === decodedUrl ||
// also allow malformed `file:///` on `details.url` on windows
(process.platform === 'win32' && `file:///${absolutePath}` === decodedUrl)
) {
logger.debug('Loading file without redirect', {
function: 'handleFileLink',
absolutePath,
originalUrl: details.url,
}); });
callback({ callback({
cancel: false, cancel: false,
}); });
} else { } else {
logger.info('redirecting file protocol', { // Need to redirect relative path to absolute path
logger.info('Redirecting file protocol to absolute path', {
function: 'handleFileLink', function: 'handleFileLink',
absolutePath: absolutePath ?? '', originalUrl: details.url,
absolutePath,
redirectURL: `file://${absolutePath}`,
}); });
callback({ callback({
cancel: false, cancel: false,

View file

@ -241,6 +241,15 @@ export class Wiki implements IWikiService {
function: 'startWiki', function: 'startWiki',
}); });
await workspaceService.updateMetaData(workspaceID, { isLoading: false, didFailLoadErrorMessage: errorMessage }); await workspaceService.updateMetaData(workspaceID, { isLoading: false, didFailLoadErrorMessage: errorMessage });
// For plugin errors that occur after wiki boot, realign the view to hide it and show error message
const isPluginError = message.source === 'plugin-error';
if (isPluginError && workspace.active) {
const workspaceViewService = container.get<IWorkspaceViewService>(serviceIdentifier.WorkspaceView);
await workspaceViewService.realignActiveWorkspace(workspaceID);
logger.info('Realigned view after plugin error', { workspaceID, function: 'startWiki' });
}
// fix "message":"listen EADDRINUSE: address already in use 0.0.0.0:5212" // fix "message":"listen EADDRINUSE: address already in use 0.0.0.0:5212"
if (errorMessage.includes('EADDRINUSE')) { if (errorMessage.includes('EADDRINUSE')) {
const portChange = { const portChange = {
@ -253,7 +262,11 @@ export class Wiki implements IWikiService {
reject(new WikiRuntimeError(new Error(message.message), wikiFolderLocation, true, { ...workspace, ...portChange })); reject(new WikiRuntimeError(new Error(message.message), wikiFolderLocation, true, { ...workspace, ...portChange }));
return; return;
} }
reject(new WikiRuntimeError(new Error(message.message), wikiFolderLocation, false, { ...workspace }));
// For plugin errors, don't reject - let user see the error and try to recover
if (!isPluginError) {
reject(new WikiRuntimeError(new Error(message.message), wikiFolderLocation, false, { ...workspace }));
}
} }
} }
} }

View file

@ -3,6 +3,7 @@ import fs from 'fs';
import nsfw from 'nsfw'; import nsfw from 'nsfw';
import path from 'path'; import path from 'path';
import type { IFileInfo, Tiddler, Wiki } from 'tiddlywiki'; import type { IFileInfo, Tiddler, Wiki } from 'tiddlywiki';
import { hasMeaningfulChanges } from './comparison';
import { FileSystemAdaptor } from './FileSystemAdaptor'; import { FileSystemAdaptor } from './FileSystemAdaptor';
import { type IBootFilesIndexItemWithTitle, InverseFilesIndex } from './InverseFilesIndex'; import { type IBootFilesIndexItemWithTitle, InverseFilesIndex } from './InverseFilesIndex';
@ -623,8 +624,20 @@ export class WatchFileSystemAdaptor extends FileSystemAdaptor {
tiddlerTitle, tiddlerTitle,
} as IBootFilesIndexItemWithTitle); } as IBootFilesIndexItemWithTitle);
// Add tiddler to wiki (this will update if it exists or add if new) // Get existing tiddler to check if content actually changed
const syncAdaptor = $tw.syncadaptor as { wiki: Wiki } | undefined | null; const syncAdaptor = $tw.syncadaptor as { wiki: Wiki } | undefined | null;
const existingTiddler = syncAdaptor?.wiki.getTiddler(tiddlerTitle);
// Use content comparison to prevent unnecessary saves
// This is critical for large JSON imports that would otherwise cause infinite loops
if (existingTiddler) {
if (!hasMeaningfulChanges(existingTiddler.fields, tiddler)) {
// Content is identical, skip addTiddler to prevent save cycle
continue;
}
}
// Add tiddler to wiki (this will trigger save if content changed)
syncAdaptor?.wiki.addTiddler(tiddler); syncAdaptor?.wiki.addTiddler(tiddler);
// Log appropriate event // Log appropriate event

View file

@ -1,6 +1,6 @@
import { workspace } from '@services/wiki/wikiWorker/services'; import { workspace } from '@services/wiki/wikiWorker/services';
import path from 'path'; import path from 'path';
import type { FileInfo, Tiddler, Wiki } from 'tiddlywiki'; import type { IFileInfo, Tiddler, Wiki } from 'tiddlywiki';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { FileSystemAdaptor } from '../FileSystemAdaptor'; import { FileSystemAdaptor } from '../FileSystemAdaptor';
@ -34,7 +34,7 @@ global.$tw = {
node: true, node: true,
boot: { boot: {
wikiTiddlersPath: '/test/wiki/tiddlers', wikiTiddlersPath: '/test/wiki/tiddlers',
files: {} as Record<string, FileInfo>, files: {} as Record<string, IFileInfo>,
}, },
utils: mockUtils, utils: mockUtils,
}; };
@ -139,7 +139,7 @@ describe('FileSystemAdaptor - Basic Functionality', () => {
describe('getTiddlerInfo', () => { describe('getTiddlerInfo', () => {
it('should return file info for existing tiddler', () => { it('should return file info for existing tiddler', () => {
const fileInfo: FileInfo = { const fileInfo: IFileInfo = {
filepath: '/test/wiki/tiddlers/test.tid', filepath: '/test/wiki/tiddlers/test.tid',
type: 'application/x-tiddler', type: 'application/x-tiddler',
hasMetaFile: false, hasMetaFile: false,

View file

@ -1,5 +1,5 @@
import { workspace } from '@services/wiki/wikiWorker/services'; import { workspace } from '@services/wiki/wikiWorker/services';
import type { FileInfo, Wiki } from 'tiddlywiki'; import type { IFileInfo, Wiki } from 'tiddlywiki';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { FileSystemAdaptor } from '../FileSystemAdaptor'; import { FileSystemAdaptor } from '../FileSystemAdaptor';
@ -33,7 +33,7 @@ global.$tw = {
node: true, node: true,
boot: { boot: {
wikiTiddlersPath: '/test/wiki/tiddlers', wikiTiddlersPath: '/test/wiki/tiddlers',
files: {} as Record<string, FileInfo>, files: {} as Record<string, IFileInfo>,
}, },
utils: mockUtils, utils: mockUtils,
}; };
@ -73,7 +73,7 @@ describe('FileSystemAdaptor - Delete Operations', () => {
describe('deleteTiddler - Callback Mode', () => { describe('deleteTiddler - Callback Mode', () => {
it('should delete tiddler and call callback on success', async () => { it('should delete tiddler and call callback on success', async () => {
const fileInfo: FileInfo = { const fileInfo: IFileInfo = {
filepath: '/test/wiki/tiddlers/test.tid', filepath: '/test/wiki/tiddlers/test.tid',
type: 'application/x-tiddler', type: 'application/x-tiddler',
hasMetaFile: false, hasMetaFile: false,
@ -105,7 +105,7 @@ describe('FileSystemAdaptor - Delete Operations', () => {
}); });
it('should handle EPERM error gracefully', async () => { it('should handle EPERM error gracefully', async () => {
const fileInfo: FileInfo = { const fileInfo: IFileInfo = {
filepath: '/test/wiki/tiddlers/test.tid', filepath: '/test/wiki/tiddlers/test.tid',
type: 'application/x-tiddler', type: 'application/x-tiddler',
hasMetaFile: false, hasMetaFile: false,
@ -137,7 +137,7 @@ describe('FileSystemAdaptor - Delete Operations', () => {
}); });
it('should handle EACCES error gracefully', async () => { it('should handle EACCES error gracefully', async () => {
const fileInfo: FileInfo = { const fileInfo: IFileInfo = {
filepath: '/test/wiki/tiddlers/test.tid', filepath: '/test/wiki/tiddlers/test.tid',
type: 'application/x-tiddler', type: 'application/x-tiddler',
hasMetaFile: false, hasMetaFile: false,
@ -163,7 +163,7 @@ describe('FileSystemAdaptor - Delete Operations', () => {
}); });
it('should propagate non-permission errors', async () => { it('should propagate non-permission errors', async () => {
const fileInfo: FileInfo = { const fileInfo: IFileInfo = {
filepath: '/test/wiki/tiddlers/test.tid', filepath: '/test/wiki/tiddlers/test.tid',
type: 'application/x-tiddler', type: 'application/x-tiddler',
hasMetaFile: false, hasMetaFile: false,
@ -186,7 +186,7 @@ describe('FileSystemAdaptor - Delete Operations', () => {
}); });
it('should not treat EPERM as graceful if syscall is not unlink', async () => { it('should not treat EPERM as graceful if syscall is not unlink', async () => {
const fileInfo: FileInfo = { const fileInfo: IFileInfo = {
filepath: '/test/wiki/tiddlers/test.tid', filepath: '/test/wiki/tiddlers/test.tid',
type: 'application/x-tiddler', type: 'application/x-tiddler',
hasMetaFile: false, hasMetaFile: false,
@ -212,7 +212,7 @@ describe('FileSystemAdaptor - Delete Operations', () => {
describe('deleteTiddler - Async/Await Mode', () => { describe('deleteTiddler - Async/Await Mode', () => {
it('should resolve successfully without callback', async () => { it('should resolve successfully without callback', async () => {
const fileInfo: FileInfo = { const fileInfo: IFileInfo = {
filepath: '/test/wiki/tiddlers/test.tid', filepath: '/test/wiki/tiddlers/test.tid',
type: 'application/x-tiddler', type: 'application/x-tiddler',
hasMetaFile: false, hasMetaFile: false,
@ -236,7 +236,7 @@ describe('FileSystemAdaptor - Delete Operations', () => {
}); });
it('should reject on non-permission errors', async () => { it('should reject on non-permission errors', async () => {
const fileInfo: FileInfo = { const fileInfo: IFileInfo = {
filepath: '/test/wiki/tiddlers/test.tid', filepath: '/test/wiki/tiddlers/test.tid',
type: 'application/x-tiddler', type: 'application/x-tiddler',
hasMetaFile: false, hasMetaFile: false,
@ -253,7 +253,7 @@ describe('FileSystemAdaptor - Delete Operations', () => {
}); });
it('should handle permission errors gracefully even without callback', async () => { it('should handle permission errors gracefully even without callback', async () => {
const fileInfo: FileInfo = { const fileInfo: IFileInfo = {
filepath: '/test/wiki/tiddlers/test.tid', filepath: '/test/wiki/tiddlers/test.tid',
type: 'application/x-tiddler', type: 'application/x-tiddler',
hasMetaFile: false, hasMetaFile: false,
@ -278,7 +278,7 @@ describe('FileSystemAdaptor - Delete Operations', () => {
describe('deleteTiddler - Error Conversion', () => { describe('deleteTiddler - Error Conversion', () => {
it('should convert string errors to Error objects', async () => { it('should convert string errors to Error objects', async () => {
const fileInfo: FileInfo = { const fileInfo: IFileInfo = {
filepath: '/test/wiki/tiddlers/test.tid', filepath: '/test/wiki/tiddlers/test.tid',
type: 'application/x-tiddler', type: 'application/x-tiddler',
hasMetaFile: false, hasMetaFile: false,
@ -300,7 +300,7 @@ describe('FileSystemAdaptor - Delete Operations', () => {
}); });
it('should convert unknown errors to Error objects', async () => { it('should convert unknown errors to Error objects', async () => {
const fileInfo: FileInfo = { const fileInfo: IFileInfo = {
filepath: '/test/wiki/tiddlers/test.tid', filepath: '/test/wiki/tiddlers/test.tid',
type: 'application/x-tiddler', type: 'application/x-tiddler',
hasMetaFile: false, hasMetaFile: false,

View file

@ -1,6 +1,6 @@
import { workspace } from '@services/wiki/wikiWorker/services'; import { workspace } from '@services/wiki/wikiWorker/services';
import type { IWikiWorkspace } from '@services/workspaces/interface'; import type { IWikiWorkspace } from '@services/workspaces/interface';
import type { FileInfo, Tiddler, Wiki } from 'tiddlywiki'; import type { IFileInfo, Tiddler, Wiki } from 'tiddlywiki';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { FileSystemAdaptor } from '../FileSystemAdaptor'; import { FileSystemAdaptor } from '../FileSystemAdaptor';
@ -34,7 +34,7 @@ global.$tw = {
node: true, node: true,
boot: { boot: {
wikiTiddlersPath: '/test/wiki/tiddlers', wikiTiddlersPath: '/test/wiki/tiddlers',
files: {} as Record<string, FileInfo>, files: {} as Record<string, IFileInfo>,
}, },
utils: mockUtils, utils: mockUtils,
}; };
@ -166,7 +166,7 @@ describe('FileSystemAdaptor - Routing Logic', () => {
}); });
it('should pass existing fileInfo with overwrite flag', async () => { it('should pass existing fileInfo with overwrite flag', async () => {
const existingFileInfo: FileInfo = { const existingFileInfo: IFileInfo = {
filepath: '/test/old.tid', filepath: '/test/old.tid',
type: 'application/x-tiddler', type: 'application/x-tiddler',
hasMetaFile: false, hasMetaFile: false,

View file

@ -1,5 +1,5 @@
import { workspace } from '@services/wiki/wikiWorker/services'; import { workspace } from '@services/wiki/wikiWorker/services';
import type { FileInfo, Tiddler, Wiki } from 'tiddlywiki'; import type { IFileInfo, Tiddler, Wiki } from 'tiddlywiki';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { FileSystemAdaptor } from '../FileSystemAdaptor'; import { FileSystemAdaptor } from '../FileSystemAdaptor';
@ -33,7 +33,7 @@ global.$tw = {
node: true, node: true,
boot: { boot: {
wikiTiddlersPath: '/test/wiki/tiddlers', wikiTiddlersPath: '/test/wiki/tiddlers',
files: {} as Record<string, FileInfo>, files: {} as Record<string, IFileInfo>,
}, },
utils: mockUtils, utils: mockUtils,
}; };
@ -77,7 +77,7 @@ describe('FileSystemAdaptor - Save Operations', () => {
fields: { title: 'TestTiddler', text: 'Test content' }, fields: { title: 'TestTiddler', text: 'Test content' },
} as Tiddler; } as Tiddler;
const fileInfo: FileInfo = { const fileInfo: IFileInfo = {
filepath: '/test/wiki/tiddlers/test.tid', filepath: '/test/wiki/tiddlers/test.tid',
type: 'application/x-tiddler', type: 'application/x-tiddler',
hasMetaFile: false, hasMetaFile: false,
@ -155,7 +155,7 @@ describe('FileSystemAdaptor - Save Operations', () => {
fields: { title: 'TestTiddler' }, fields: { title: 'TestTiddler' },
} as Tiddler; } as Tiddler;
const fileInfo: FileInfo = { const fileInfo: IFileInfo = {
filepath: '/test/wiki/tiddlers/test.tid', filepath: '/test/wiki/tiddlers/test.tid',
type: 'application/x-tiddler', type: 'application/x-tiddler',
hasMetaFile: false, hasMetaFile: false,
@ -182,7 +182,7 @@ describe('FileSystemAdaptor - Save Operations', () => {
fields: { title: 'TestTiddler' }, fields: { title: 'TestTiddler' },
} as Tiddler; } as Tiddler;
const fileInfo: FileInfo = { const fileInfo: IFileInfo = {
filepath: '/test/wiki/tiddlers/test.tid', filepath: '/test/wiki/tiddlers/test.tid',
type: 'application/x-tiddler', type: 'application/x-tiddler',
hasMetaFile: false, hasMetaFile: false,
@ -236,7 +236,7 @@ describe('FileSystemAdaptor - Save Operations', () => {
fields: { title: 'TestTiddler' }, fields: { title: 'TestTiddler' },
} as Tiddler; } as Tiddler;
const fileInfo: FileInfo = { const fileInfo: IFileInfo = {
filepath: '/test/wiki/tiddlers/test.tid', filepath: '/test/wiki/tiddlers/test.tid',
type: 'application/x-tiddler', type: 'application/x-tiddler',
hasMetaFile: false, hasMetaFile: false,
@ -264,7 +264,7 @@ describe('FileSystemAdaptor - Save Operations', () => {
fields: { title: 'TestTiddler' }, fields: { title: 'TestTiddler' },
} as Tiddler; } as Tiddler;
const fileInfo: FileInfo = { const fileInfo: IFileInfo = {
filepath: '/test/wiki/tiddlers/test.tid', filepath: '/test/wiki/tiddlers/test.tid',
type: 'application/x-tiddler', type: 'application/x-tiddler',
hasMetaFile: false, hasMetaFile: false,
@ -304,7 +304,7 @@ describe('FileSystemAdaptor - Save Operations', () => {
fields: { title: 'TestTiddler' }, fields: { title: 'TestTiddler' },
} as Tiddler; } as Tiddler;
const fileInfo: FileInfo = { const fileInfo: IFileInfo = {
filepath: '/test/wiki/tiddlers/test.tid', filepath: '/test/wiki/tiddlers/test.tid',
type: 'application/x-tiddler', type: 'application/x-tiddler',
hasMetaFile: false, hasMetaFile: false,

View file

@ -0,0 +1,61 @@
/**
* High-performance tiddler comparison utilities
* Used to detect if a tiddler has actually changed to prevent unnecessary saves
*/
/**
* Fields that change automatically during save and should be excluded from comparison
*/
const EXCLUDED_FIELDS = new Set([
'modified', // Always updated on save
'revision', // Internal TiddlyWiki revision counter
'bag', // TiddlyWeb specific
'created', // May be auto-generated
]);
/**
* Fast comparison of two tiddlers to detect real content changes
* Strategy:
* 1. Quick length check on text field (most common change)
* 2. Compare field counts
* 3. Deep compare only if needed
*
* @param oldTiddler - Existing tiddler in wiki
* @param newTiddler - New tiddler from file
* @returns true if tiddlers are meaningfully different
*/
export function hasMeaningfulChanges(
oldTiddler: Record<string, unknown>,
newTiddler: Record<string, unknown>,
): boolean {
// Fast path: Compare text field length first (most common change)
const oldText = oldTiddler.text as string | undefined;
const newText = newTiddler.text as string | undefined;
if ((oldText?.length ?? 0) !== (newText?.length ?? 0)) {
return true; // Text length changed - definitely different
}
// Get field keys excluding auto-generated ones
const oldKeys = new Set(Object.keys(oldTiddler)).difference(EXCLUDED_FIELDS);
const newKeys = new Set(Object.keys(newTiddler)).difference(EXCLUDED_FIELDS);
// Quick check: Different number of fields
if (oldKeys.size !== newKeys.size) {
return true;
}
// Check if there are any different keys (fields added or removed)
if (oldKeys.difference(newKeys).size > 0) {
return true;
}
// Deep comparison: Check each field value (keys are the same at this point)
for (const key of oldKeys) {
if (oldTiddler[key] !== newTiddler[key]) {
return true; // Field value changed
}
}
return false; // No meaningful changes
}

View file

@ -38,37 +38,52 @@ export function startNodeJSWiki({
userName, userName,
workspace, workspace,
}: IStartNodeJSWikiConfigs): Observable<IWikiMessage> { }: IStartNodeJSWikiConfigs): Observable<IWikiMessage> {
// Wait for services to be ready before using intercept with logFor
onWorkerServicesReady(() => {
void native.logFor(workspace.name, 'info', 'test-id-WorkerServicesReady');
const textDecoder = new TextDecoder();
intercept(
(newStdOut: string | Uint8Array) => {
const message = typeof newStdOut === 'string' ? newStdOut : textDecoder.decode(newStdOut);
// Send to main process logger if services are ready
void native.logFor(workspace.name, 'info', message).catch((error: unknown) => {
console.error('[intercept] Failed to send stdout to main process:', error, message, JSON.stringify(workspace));
});
return message;
},
(newStdError: string | Uint8Array) => {
const message = typeof newStdError === 'string' ? newStdError : textDecoder.decode(newStdError);
// Send to main process logger if services are ready
void native.logFor(workspace.name, 'error', message).catch((error: unknown) => {
console.error('[intercept] Failed to send stderr to main process:', error, message);
});
return message;
},
);
});
if (openDebugger === true) {
inspector.open();
inspector.waitForDebugger();
// eslint-disable-next-line no-debugger
debugger;
}
return new Observable<IWikiMessage>((observer) => { return new Observable<IWikiMessage>((observer) => {
if (openDebugger === true) {
inspector.open();
inspector.waitForDebugger();
// eslint-disable-next-line no-debugger
debugger;
}
// Wait for services to be ready before using intercept with logFor
onWorkerServicesReady(() => {
void native.logFor(workspace.name, 'info', 'test-id-WorkerServicesReady');
const textDecoder = new TextDecoder();
intercept(
(newStdOut: string | Uint8Array) => {
const message = typeof newStdOut === 'string' ? newStdOut : textDecoder.decode(newStdOut);
// Send to main process logger if services are ready
void native.logFor(workspace.name, 'info', message).catch((error: unknown) => {
console.error('[intercept] Failed to send stdout to main process:', error, message, JSON.stringify(workspace));
});
return message;
},
(newStdError: string | Uint8Array) => {
const message = typeof newStdError === 'string' ? newStdError : textDecoder.decode(newStdError);
// Send to main process logger if services are ready
void native.logFor(workspace.name, 'error', message).catch((error: unknown) => {
console.error('[intercept] Failed to send stderr to main process:', error, message);
});
// Detect critical plugin loading errors that can cause white screen
// These errors occur during TiddlyWiki boot module execution
if (
message.includes('Error executing boot module') ||
message.includes('Cannot find module')
) {
observer.next({
type: 'control',
source: 'plugin-error',
actions: WikiControlActions.error,
message,
argv: [],
});
}
return message;
},
);
});
let fullBootArgv: string[] = []; let fullBootArgv: string[] = [];
// mark isDev as used to satisfy lint when not needed directly // mark isDev as used to satisfy lint when not needed directly
void isDev; void isDev;