mirror of
https://github.com/tiddly-gittly/TidGi-Desktop.git
synced 2025-12-05 18:20:39 -08:00
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:
parent
ccf825af06
commit
45e3f76da1
14 changed files with 248 additions and 108 deletions
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
|
|
@ -134,7 +134,7 @@ jobs:
|
|||
draft: true
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
${{ matrix.platform == 'win' && 'out/make/**/*.exe' || 'out/make/**/*' }}
|
||||
${{ matrix.platform == 'win' && 'out/make/**/*.{exe,msix,appx}' || 'out/make/**/*' }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@
|
|||
"espree": "^11.0.0",
|
||||
"exponential-backoff": "^3.1.3",
|
||||
"fs-extra": "11.3.2",
|
||||
"git-sync-js": "^2.3.0",
|
||||
"git-sync-js": "^2.3.1",
|
||||
"graphql-hooks": "8.2.0",
|
||||
"html-minifier-terser": "^7.2.0",
|
||||
"i18next": "25.6.3",
|
||||
|
|
@ -129,6 +129,7 @@
|
|||
"optionalDependencies": {
|
||||
"@electron-forge/maker-deb": "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-snap": "7.10.2",
|
||||
"@electron-forge/maker-squirrel": "7.10.2",
|
||||
|
|
@ -138,7 +139,6 @@
|
|||
"devDependencies": {
|
||||
"@cucumber/cucumber": "^12.2.0",
|
||||
"@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-vite": "7.10.2",
|
||||
"@electron/rebuild": "^4.0.1",
|
||||
|
|
|
|||
22
pnpm-lock.yaml
generated
22
pnpm-lock.yaml
generated
|
|
@ -138,8 +138,8 @@ importers:
|
|||
specifier: 11.3.2
|
||||
version: 11.3.2
|
||||
git-sync-js:
|
||||
specifier: ^2.3.0
|
||||
version: 2.3.0
|
||||
specifier: ^2.3.1
|
||||
version: 2.3.1
|
||||
graphql-hooks:
|
||||
specifier: 8.2.0
|
||||
version: 8.2.0(react@19.2.0)
|
||||
|
|
@ -297,9 +297,6 @@ importers:
|
|||
'@electron-forge/cli':
|
||||
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)
|
||||
'@electron-forge/maker-msix':
|
||||
specifier: ^7.10.2
|
||||
version: 7.10.2(bluebird@3.7.2)
|
||||
'@electron-forge/plugin-auto-unpack-natives':
|
||||
specifier: 7.10.2
|
||||
version: 7.10.2(bluebird@3.7.2)
|
||||
|
|
@ -448,6 +445,9 @@ importers:
|
|||
'@electron-forge/maker-flatpak':
|
||||
specifier: 7.10.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':
|
||||
specifier: 7.10.2
|
||||
version: 7.10.2(bluebird@3.7.2)
|
||||
|
|
@ -4572,8 +4572,8 @@ packages:
|
|||
gifwrap@0.10.1:
|
||||
resolution: {integrity: sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw==}
|
||||
|
||||
git-sync-js@2.3.0:
|
||||
resolution: {integrity: sha512-RFyyWBjdZskl2Jxg3a9g2tzAShhhFkxyS83lON6asaKHov4Rks8P7irCMjQIRiFTlqLrvZFDgjeWbo7jc1IKhQ==}
|
||||
git-sync-js@2.3.1:
|
||||
resolution: {integrity: sha512-EbX6SIsUc2hcXEQXsaYz0EyDSQwlKi3AbZk00hIB97YOyKQPtKeQWQ7+6w87GSKD6m2mfBGVyIgF57DA0ajo3g==}
|
||||
|
||||
github-from-package@0.0.0:
|
||||
resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
|
||||
|
|
@ -8456,6 +8456,7 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- bluebird
|
||||
- supports-color
|
||||
optional: true
|
||||
|
||||
'@electron-forge/maker-rpm@7.10.2(bluebird@3.7.2)':
|
||||
dependencies:
|
||||
|
|
@ -11122,6 +11123,7 @@ snapshots:
|
|||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
supports-color: 7.2.0
|
||||
optional: true
|
||||
|
||||
chalk@4.1.2:
|
||||
dependencies:
|
||||
|
|
@ -11650,6 +11652,7 @@ snapshots:
|
|||
xml-escape: 1.1.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
optional: true
|
||||
|
||||
electron-winstaller@5.4.0:
|
||||
dependencies:
|
||||
|
|
@ -12657,7 +12660,7 @@ snapshots:
|
|||
image-q: 4.0.0
|
||||
omggif: 1.0.10
|
||||
|
||||
git-sync-js@2.3.0:
|
||||
git-sync-js@2.3.1:
|
||||
dependencies:
|
||||
dugite: 3.0.0-rc12
|
||||
fs-extra: 11.3.2
|
||||
|
|
@ -15844,7 +15847,8 @@ snapshots:
|
|||
|
||||
ws@8.18.3: {}
|
||||
|
||||
xml-escape@1.1.0: {}
|
||||
xml-escape@1.1.0:
|
||||
optional: true
|
||||
|
||||
xml-name-validator@5.0.0: {}
|
||||
|
||||
|
|
|
|||
|
|
@ -102,6 +102,8 @@ function initWikiGit(
|
|||
*/
|
||||
function commitAndSyncWiki(workspace: IWorkspace, configs: ICommitAndSyncConfigs, errorI18NDict: Record<string, string>): Observable<IGitLogMessage> {
|
||||
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({
|
||||
...configs,
|
||||
defaultGitInfo,
|
||||
|
|
@ -113,7 +115,7 @@ function commitAndSyncWiki(workspace: IWorkspace, configs: ICommitAndSyncConfigs
|
|||
observer.next({ message, level: 'warn', meta: { callerFunction: 'commitAndSync', ...context } });
|
||||
},
|
||||
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'],
|
||||
|
|
@ -146,6 +148,8 @@ function forcePullWiki(workspace: IWorkspace, configs: IForcePullConfigs, errorI
|
|||
observer.error(new Error('forcePullWiki can only be called on wiki workspaces'));
|
||||
return;
|
||||
}
|
||||
// For sub-wiki, show sync progress in main workspace
|
||||
const workspaceIDForNotification = isWikiWorkspace(workspace) && workspace.isSubWiki ? workspace.mainWikiID! : workspace.id;
|
||||
void forcePull({
|
||||
dir: workspace.wikiFolderLocation,
|
||||
...configs,
|
||||
|
|
@ -158,7 +162,7 @@ function forcePullWiki(workspace: IWorkspace, configs: IForcePullConfigs, errorI
|
|||
observer.next({ message, level: 'warn', meta: { callerFunction: 'forcePull', ...context } });
|
||||
},
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -428,14 +428,14 @@ ${message.message}
|
|||
}
|
||||
|
||||
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 hostname = '';
|
||||
try {
|
||||
({ hostname, pathname } = new URL(urlWithFileProtocol));
|
||||
} catch {
|
||||
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`
|
||||
|
|
@ -446,38 +446,33 @@ ${message.message}
|
|||
if (process.platform === 'win32' && filePath.startsWith('/')) {
|
||||
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);
|
||||
logger.info('file exists (decodeURI)', {
|
||||
function: 'formatFileUrlToAbsolutePath',
|
||||
filePath,
|
||||
exists: fileExists,
|
||||
});
|
||||
if (fileExists) {
|
||||
|
||||
// Strategy 1: Try as-is (for absolute paths)
|
||||
if (fs.existsSync(filePath)) {
|
||||
logger.debug('file found (direct path)', { filePath, function: 'formatFileUrlToAbsolutePath' });
|
||||
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 workspace = workspaceService.getActiveWorkspaceSync();
|
||||
if (workspace === undefined || !isWikiWorkspace(workspace)) {
|
||||
logger.error(`No active workspace or not a wiki workspace, abort. Try loading filePath as-is.`, { filePath, function: 'formatFileUrlToAbsolutePath' });
|
||||
return filePath;
|
||||
if (workspace !== undefined && isWikiWorkspace(workspace)) {
|
||||
const filePathInWorkspaceFolder = path.resolve(workspace.wikiFolderLocation, 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);
|
||||
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
|
||||
|
||||
// Strategy 3: Try relative to TidGi App folder (for bundled assets)
|
||||
const inTidGiAppAbsoluteFilePath = path.join(app.getAppPath(), '.webpack', 'renderer', filePath);
|
||||
logger.info(`try find file relative to TidGi App folder`, { inTidGiAppAbsoluteFilePath, function: 'formatFileUrlToAbsolutePath' });
|
||||
fileExists = fs.existsSync(inTidGiAppAbsoluteFilePath);
|
||||
if (fileExists) {
|
||||
if (fs.existsSync(inTidGiAppAbsoluteFilePath)) {
|
||||
logger.debug('file found (app relative)', { inTidGiAppAbsoluteFilePath, function: 'formatFileUrlToAbsolutePath' });
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -72,25 +72,60 @@ export function handleViewFileContentLoading(view: WebContentsView) {
|
|||
|
||||
function handleFileLink(details: Electron.OnBeforeRequestListenerDetails, callback: (response: Electron.CallbackResponse) => void) {
|
||||
const nativeService = container.get<INativeService>(serviceIdentifier.NativeService);
|
||||
const absolutePath: string | undefined = nativeService.formatFileUrlToAbsolutePath(details.url);
|
||||
// When details.url is an absolute route, we just load it, don't need any redirect
|
||||
const absolutePath: string = nativeService.formatFileUrlToAbsolutePath(details.url);
|
||||
|
||||
// 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 (
|
||||
`file://${absolutePath}` === decodeURI(details.url) ||
|
||||
absolutePath === decodeURI(details.url) ||
|
||||
// also allow malformed `file:///` on `details.url` on windows, prevent infinite redirect when this check failed.
|
||||
(process.platform === 'win32' && `file:///${absolutePath}` === decodeURI(details.url))
|
||||
absolutePath === details.url ||
|
||||
absolutePath.startsWith('file://') ||
|
||||
absolutePath.startsWith('open://') ||
|
||||
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',
|
||||
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({
|
||||
cancel: false,
|
||||
});
|
||||
} else {
|
||||
logger.info('redirecting file protocol', {
|
||||
// Need to redirect relative path to absolute path
|
||||
logger.info('Redirecting file protocol to absolute path', {
|
||||
function: 'handleFileLink',
|
||||
absolutePath: absolutePath ?? '',
|
||||
originalUrl: details.url,
|
||||
absolutePath,
|
||||
redirectURL: `file://${absolutePath}`,
|
||||
});
|
||||
callback({
|
||||
cancel: false,
|
||||
|
|
|
|||
|
|
@ -241,6 +241,15 @@ export class Wiki implements IWikiService {
|
|||
function: 'startWiki',
|
||||
});
|
||||
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"
|
||||
if (errorMessage.includes('EADDRINUSE')) {
|
||||
const portChange = {
|
||||
|
|
@ -253,7 +262,11 @@ export class Wiki implements IWikiService {
|
|||
reject(new WikiRuntimeError(new Error(message.message), wikiFolderLocation, true, { ...workspace, ...portChange }));
|
||||
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 }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import fs from 'fs';
|
|||
import nsfw from 'nsfw';
|
||||
import path from 'path';
|
||||
import type { IFileInfo, Tiddler, Wiki } from 'tiddlywiki';
|
||||
import { hasMeaningfulChanges } from './comparison';
|
||||
import { FileSystemAdaptor } from './FileSystemAdaptor';
|
||||
import { type IBootFilesIndexItemWithTitle, InverseFilesIndex } from './InverseFilesIndex';
|
||||
|
||||
|
|
@ -623,8 +624,20 @@ export class WatchFileSystemAdaptor extends FileSystemAdaptor {
|
|||
tiddlerTitle,
|
||||
} 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 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);
|
||||
|
||||
// Log appropriate event
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { workspace } from '@services/wiki/wikiWorker/services';
|
||||
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 { FileSystemAdaptor } from '../FileSystemAdaptor';
|
||||
|
||||
|
|
@ -34,7 +34,7 @@ global.$tw = {
|
|||
node: true,
|
||||
boot: {
|
||||
wikiTiddlersPath: '/test/wiki/tiddlers',
|
||||
files: {} as Record<string, FileInfo>,
|
||||
files: {} as Record<string, IFileInfo>,
|
||||
},
|
||||
utils: mockUtils,
|
||||
};
|
||||
|
|
@ -139,7 +139,7 @@ describe('FileSystemAdaptor - Basic Functionality', () => {
|
|||
|
||||
describe('getTiddlerInfo', () => {
|
||||
it('should return file info for existing tiddler', () => {
|
||||
const fileInfo: FileInfo = {
|
||||
const fileInfo: IFileInfo = {
|
||||
filepath: '/test/wiki/tiddlers/test.tid',
|
||||
type: 'application/x-tiddler',
|
||||
hasMetaFile: false,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 { FileSystemAdaptor } from '../FileSystemAdaptor';
|
||||
|
||||
|
|
@ -33,7 +33,7 @@ global.$tw = {
|
|||
node: true,
|
||||
boot: {
|
||||
wikiTiddlersPath: '/test/wiki/tiddlers',
|
||||
files: {} as Record<string, FileInfo>,
|
||||
files: {} as Record<string, IFileInfo>,
|
||||
},
|
||||
utils: mockUtils,
|
||||
};
|
||||
|
|
@ -73,7 +73,7 @@ describe('FileSystemAdaptor - Delete Operations', () => {
|
|||
|
||||
describe('deleteTiddler - Callback Mode', () => {
|
||||
it('should delete tiddler and call callback on success', async () => {
|
||||
const fileInfo: FileInfo = {
|
||||
const fileInfo: IFileInfo = {
|
||||
filepath: '/test/wiki/tiddlers/test.tid',
|
||||
type: 'application/x-tiddler',
|
||||
hasMetaFile: false,
|
||||
|
|
@ -105,7 +105,7 @@ describe('FileSystemAdaptor - Delete Operations', () => {
|
|||
});
|
||||
|
||||
it('should handle EPERM error gracefully', async () => {
|
||||
const fileInfo: FileInfo = {
|
||||
const fileInfo: IFileInfo = {
|
||||
filepath: '/test/wiki/tiddlers/test.tid',
|
||||
type: 'application/x-tiddler',
|
||||
hasMetaFile: false,
|
||||
|
|
@ -137,7 +137,7 @@ describe('FileSystemAdaptor - Delete Operations', () => {
|
|||
});
|
||||
|
||||
it('should handle EACCES error gracefully', async () => {
|
||||
const fileInfo: FileInfo = {
|
||||
const fileInfo: IFileInfo = {
|
||||
filepath: '/test/wiki/tiddlers/test.tid',
|
||||
type: 'application/x-tiddler',
|
||||
hasMetaFile: false,
|
||||
|
|
@ -163,7 +163,7 @@ describe('FileSystemAdaptor - Delete Operations', () => {
|
|||
});
|
||||
|
||||
it('should propagate non-permission errors', async () => {
|
||||
const fileInfo: FileInfo = {
|
||||
const fileInfo: IFileInfo = {
|
||||
filepath: '/test/wiki/tiddlers/test.tid',
|
||||
type: 'application/x-tiddler',
|
||||
hasMetaFile: false,
|
||||
|
|
@ -186,7 +186,7 @@ describe('FileSystemAdaptor - Delete Operations', () => {
|
|||
});
|
||||
|
||||
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',
|
||||
type: 'application/x-tiddler',
|
||||
hasMetaFile: false,
|
||||
|
|
@ -212,7 +212,7 @@ describe('FileSystemAdaptor - Delete Operations', () => {
|
|||
|
||||
describe('deleteTiddler - Async/Await Mode', () => {
|
||||
it('should resolve successfully without callback', async () => {
|
||||
const fileInfo: FileInfo = {
|
||||
const fileInfo: IFileInfo = {
|
||||
filepath: '/test/wiki/tiddlers/test.tid',
|
||||
type: 'application/x-tiddler',
|
||||
hasMetaFile: false,
|
||||
|
|
@ -236,7 +236,7 @@ describe('FileSystemAdaptor - Delete Operations', () => {
|
|||
});
|
||||
|
||||
it('should reject on non-permission errors', async () => {
|
||||
const fileInfo: FileInfo = {
|
||||
const fileInfo: IFileInfo = {
|
||||
filepath: '/test/wiki/tiddlers/test.tid',
|
||||
type: 'application/x-tiddler',
|
||||
hasMetaFile: false,
|
||||
|
|
@ -253,7 +253,7 @@ describe('FileSystemAdaptor - Delete Operations', () => {
|
|||
});
|
||||
|
||||
it('should handle permission errors gracefully even without callback', async () => {
|
||||
const fileInfo: FileInfo = {
|
||||
const fileInfo: IFileInfo = {
|
||||
filepath: '/test/wiki/tiddlers/test.tid',
|
||||
type: 'application/x-tiddler',
|
||||
hasMetaFile: false,
|
||||
|
|
@ -278,7 +278,7 @@ describe('FileSystemAdaptor - Delete Operations', () => {
|
|||
|
||||
describe('deleteTiddler - Error Conversion', () => {
|
||||
it('should convert string errors to Error objects', async () => {
|
||||
const fileInfo: FileInfo = {
|
||||
const fileInfo: IFileInfo = {
|
||||
filepath: '/test/wiki/tiddlers/test.tid',
|
||||
type: 'application/x-tiddler',
|
||||
hasMetaFile: false,
|
||||
|
|
@ -300,7 +300,7 @@ describe('FileSystemAdaptor - Delete Operations', () => {
|
|||
});
|
||||
|
||||
it('should convert unknown errors to Error objects', async () => {
|
||||
const fileInfo: FileInfo = {
|
||||
const fileInfo: IFileInfo = {
|
||||
filepath: '/test/wiki/tiddlers/test.tid',
|
||||
type: 'application/x-tiddler',
|
||||
hasMetaFile: false,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { workspace } from '@services/wiki/wikiWorker/services';
|
||||
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 { FileSystemAdaptor } from '../FileSystemAdaptor';
|
||||
|
||||
|
|
@ -34,7 +34,7 @@ global.$tw = {
|
|||
node: true,
|
||||
boot: {
|
||||
wikiTiddlersPath: '/test/wiki/tiddlers',
|
||||
files: {} as Record<string, FileInfo>,
|
||||
files: {} as Record<string, IFileInfo>,
|
||||
},
|
||||
utils: mockUtils,
|
||||
};
|
||||
|
|
@ -166,7 +166,7 @@ describe('FileSystemAdaptor - Routing Logic', () => {
|
|||
});
|
||||
|
||||
it('should pass existing fileInfo with overwrite flag', async () => {
|
||||
const existingFileInfo: FileInfo = {
|
||||
const existingFileInfo: IFileInfo = {
|
||||
filepath: '/test/old.tid',
|
||||
type: 'application/x-tiddler',
|
||||
hasMetaFile: false,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 { FileSystemAdaptor } from '../FileSystemAdaptor';
|
||||
|
||||
|
|
@ -33,7 +33,7 @@ global.$tw = {
|
|||
node: true,
|
||||
boot: {
|
||||
wikiTiddlersPath: '/test/wiki/tiddlers',
|
||||
files: {} as Record<string, FileInfo>,
|
||||
files: {} as Record<string, IFileInfo>,
|
||||
},
|
||||
utils: mockUtils,
|
||||
};
|
||||
|
|
@ -77,7 +77,7 @@ describe('FileSystemAdaptor - Save Operations', () => {
|
|||
fields: { title: 'TestTiddler', text: 'Test content' },
|
||||
} as Tiddler;
|
||||
|
||||
const fileInfo: FileInfo = {
|
||||
const fileInfo: IFileInfo = {
|
||||
filepath: '/test/wiki/tiddlers/test.tid',
|
||||
type: 'application/x-tiddler',
|
||||
hasMetaFile: false,
|
||||
|
|
@ -155,7 +155,7 @@ describe('FileSystemAdaptor - Save Operations', () => {
|
|||
fields: { title: 'TestTiddler' },
|
||||
} as Tiddler;
|
||||
|
||||
const fileInfo: FileInfo = {
|
||||
const fileInfo: IFileInfo = {
|
||||
filepath: '/test/wiki/tiddlers/test.tid',
|
||||
type: 'application/x-tiddler',
|
||||
hasMetaFile: false,
|
||||
|
|
@ -182,7 +182,7 @@ describe('FileSystemAdaptor - Save Operations', () => {
|
|||
fields: { title: 'TestTiddler' },
|
||||
} as Tiddler;
|
||||
|
||||
const fileInfo: FileInfo = {
|
||||
const fileInfo: IFileInfo = {
|
||||
filepath: '/test/wiki/tiddlers/test.tid',
|
||||
type: 'application/x-tiddler',
|
||||
hasMetaFile: false,
|
||||
|
|
@ -236,7 +236,7 @@ describe('FileSystemAdaptor - Save Operations', () => {
|
|||
fields: { title: 'TestTiddler' },
|
||||
} as Tiddler;
|
||||
|
||||
const fileInfo: FileInfo = {
|
||||
const fileInfo: IFileInfo = {
|
||||
filepath: '/test/wiki/tiddlers/test.tid',
|
||||
type: 'application/x-tiddler',
|
||||
hasMetaFile: false,
|
||||
|
|
@ -264,7 +264,7 @@ describe('FileSystemAdaptor - Save Operations', () => {
|
|||
fields: { title: 'TestTiddler' },
|
||||
} as Tiddler;
|
||||
|
||||
const fileInfo: FileInfo = {
|
||||
const fileInfo: IFileInfo = {
|
||||
filepath: '/test/wiki/tiddlers/test.tid',
|
||||
type: 'application/x-tiddler',
|
||||
hasMetaFile: false,
|
||||
|
|
@ -304,7 +304,7 @@ describe('FileSystemAdaptor - Save Operations', () => {
|
|||
fields: { title: 'TestTiddler' },
|
||||
} as Tiddler;
|
||||
|
||||
const fileInfo: FileInfo = {
|
||||
const fileInfo: IFileInfo = {
|
||||
filepath: '/test/wiki/tiddlers/test.tid',
|
||||
type: 'application/x-tiddler',
|
||||
hasMetaFile: false,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -38,37 +38,52 @@ export function startNodeJSWiki({
|
|||
userName,
|
||||
workspace,
|
||||
}: 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) => {
|
||||
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[] = [];
|
||||
// mark isDev as used to satisfy lint when not needed directly
|
||||
void isDev;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue