From e4f9a4e2e5a54bd7563a86ac9ca1893dabc473a0 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Wed, 13 May 2026 11:28:08 +0200 Subject: [PATCH] refactor(sync-providers): extract local file provider --- angular.json | 1 - .../sync-core-extraction-plan.md | 78 ++-- package-lock.json | 8 - package.json | 1 - packages/sync-providers/src/errors/index.ts | 4 + .../local-file/local-file-sync-android.ts | 47 +++ .../local-file/local-file-sync-base.ts | 217 +++++++++++ .../local-file/local-file-sync-electron.ts | 110 ++++++ .../file-based/local-file/local-file.model.ts | 14 + packages/sync-providers/src/index.ts | 17 + packages/sync-providers/tests/errors.spec.ts | 2 + .../local-file-sync-android.spec.ts | 102 +++++ .../local-file/local-file-sync-base.spec.ts | 233 ++++++++++++ .../local-file-sync-electron.spec.ts | 121 ++++++ .../core/errors/sync-errors.identity.spec.ts | 7 + src/app/op-log/core/errors/sync-errors.ts | 5 +- src/app/op-log/core/types/sync.types.ts | 16 +- .../local-file/local-file-sync-android.ts | 75 ++-- .../local-file/local-file-sync-base.ts | 190 ---------- .../local-file-sync-electron.spec.ts | 135 ------- .../local-file/local-file-sync-electron.ts | 138 ++----- .../local-file/local-file-sync.spec.ts | 352 ------------------ .../sync-providers/sync-providers.factory.ts | 8 +- src/app/util/md5-hash.ts | 14 - 24 files changed, 996 insertions(+), 899 deletions(-) create mode 100644 packages/sync-providers/src/file-based/local-file/local-file-sync-android.ts create mode 100644 packages/sync-providers/src/file-based/local-file/local-file-sync-base.ts create mode 100644 packages/sync-providers/src/file-based/local-file/local-file-sync-electron.ts create mode 100644 packages/sync-providers/src/file-based/local-file/local-file.model.ts create mode 100644 packages/sync-providers/tests/file-based/local-file/local-file-sync-android.spec.ts create mode 100644 packages/sync-providers/tests/file-based/local-file/local-file-sync-base.spec.ts create mode 100644 packages/sync-providers/tests/file-based/local-file/local-file-sync-electron.spec.ts delete mode 100644 src/app/op-log/sync-providers/file-based/local-file/local-file-sync-base.ts delete mode 100644 src/app/op-log/sync-providers/file-based/local-file/local-file-sync-electron.spec.ts delete mode 100644 src/app/op-log/sync-providers/file-based/local-file/local-file-sync.spec.ts delete mode 100644 src/app/util/md5-hash.ts diff --git a/angular.json b/angular.json index 40d817d320..a923d4fd69 100644 --- a/angular.json +++ b/angular.json @@ -50,7 +50,6 @@ "stack-generator", "stacktrace-gps", "error-stack-parser", - "spark-md5", "chrono-node", "dayjs", "query-string" diff --git a/docs/long-term-plans/sync-core-extraction-plan.md b/docs/long-term-plans/sync-core-extraction-plan.md index 6ced75de76..af98a296d3 100644 --- a/docs/long-term-plans/sync-core-extraction-plan.md +++ b/docs/long-term-plans/sync-core-extraction-plan.md @@ -7,11 +7,9 @@ > has shipped the `@sp/sync-providers` scaffold, provider boundary lint, > provider-neutral contracts, a credential-store port, the file-based sync > envelope types, PKCE helpers, the native-HTTP retry helpers, the shared -> provider error classes, and the Dropbox provider (behind -> `ProviderPlatformInfo` + `WebFetchFactory` + `NativeHttpExecutor` ports). -> Remaining provider work — WebDAV + Nextcloud, SuperSync, LocalFile — should -> reuse those ports while keeping app-owned IDs, OAuth routing, config UI, and -> platform bridges app-side.** +> provider error classes, and the bundled Dropbox, WebDAV + Nextcloud, +> SuperSync, and LocalFile providers behind injected platform/credential/file +> ports. Next up: PR 6 final boundary hardening and documentation.** **Goal:** Carve the sync engine out of `src/app/op-log/` into a reusable, framework-agnostic, **domain-agnostic** `@sp/sync-core` package, plus a sibling @@ -182,7 +180,7 @@ Current extraction state and remaining immediate debt: package-side with app-owned diagnostics, sync-core source comments were rechecked for SP entity examples, and the core boundary grep was rerun with no forbidden source imports. -- PR 5 has its initial package boundary: `packages/sync-providers/` exists with +- PR 5 has its package boundary: `packages/sync-providers/` exists with tsup/Vitest scaffolding, root scripts, build-package wiring, the `@sp/sync-providers` path alias, package-local generated-artifact ignores, and ESLint restrictions that reject Angular, NgRx, app imports, @@ -200,6 +198,11 @@ Current extraction state and remaining immediate debt: - Dropbox PKCE code generation now lives in `@sp/sync-providers`, including the existing WebCrypto-first and `hash-wasm` fallback behavior. The app-side Dropbox helper path remains a compatibility re-export. +- Dropbox, WebDAV + Nextcloud, SuperSync, and LocalFile provider + implementations now live in `@sp/sync-providers`. App-side shims keep + `SyncProviderId`, OAuth routing, config UI, credential-store implementation, + response validators that depend on `@sp/shared-schema`, and the + Electron/SAF platform bridges app-side. Suggested next order: @@ -211,10 +214,9 @@ Suggested next order: 3. Treat the current PR 4a/4b/4c port, small-helper, and replay-coordinator slices as complete for this round; keep the Angular `OperationApplierService` shell app-side unless a later port proves another small extraction safe. -4. Finish PR 5 in three larger implementation slices behind the new provider - contracts: HTTP file providers first, SuperSync integration second, and - LocalFile last. Provider-specific `SyncLog`/`OpLog` routing should be - handled as provider files move behind provider-package ports. +4. Treat PR 5 provider lift as complete for this branch. Continue with PR 6: + final boundary hardening, manifest/public-export audits, and a short + architecture note for package dependency direction. ## PR 1 - Thin First Slice (#7546) @@ -1175,9 +1177,9 @@ PROVIDER_ID_WEBDAV | typeof PROVIDER_ID_NEXTCLOUD`. Four `as unknown as SyncProviderId.WebDAV` double-casts deleted. - **`md5HashSync` migrated to `hash-wasm` async.** `WebdavApi._computeContentHash` became `async`; ripple touched ~5 spec call sites. `spark-md5` no - longer appears in the package surface. App-side `local-file-sync-base.ts:178` - still uses `md5HashPromise` / `spark-md5` — out of scope until the - LocalFile slice. + longer appears in the package surface. The later LocalFile slice also + migrated its rev hashing to `hash-wasm` and removed the app-side + `spark-md5` helper/dependency. - **CORS heuristic tightened.** `webdav-http-adapter.ts:180-219` collapsed to a ~3-line check (`error instanceof TypeError && error.message.includes('cors')`). Ambiguous-error log path that @@ -1304,20 +1306,48 @@ consensus, then tightened by follow-up commits from review findings: `/webdav`, `/super-sync`, …) remain deferred to post-provider-lift polish. +### Current Eighth Slice + +Shipped the LocalFile final slice: + +- `LocalFileSyncBase`, `LocalFileSyncElectron`, + `LocalFileSyncAndroid`, `LocalFileSyncPrivateCfg`, and + `PROVIDER_ID_LOCAL_FILE` now live in + `packages/sync-providers/src/file-based/local-file/`. +- LocalFile provider behavior is package-owned, but Electron and Android + platform bridges stay app-side: + - Electron file operations still go through the app's + `ElectronFileAdapter` and `window.ea` bridge. + - Android SAF operations still go through `SafService` / + `SafFileAdapter`. + - App factory shims (`createLocalFileSyncElectron` / + `createLocalFileSyncAndroid`) inject those bridges plus + `SyncCredentialStore` and `OP_LOG_SYNC_LOGGER` into the package + classes. The dead app-side abstract base shim and duplicate spec were + removed. +- LocalFile rev hashing now uses `hash-wasm` directly in the package, + matching the WebDAV move. The unused app-side `src/app/util/md5-hash.ts`, + `spark-md5` dependency, lockfile entry, and Angular CommonJS allowance + were removed. +- `FileHashCreationAPIError` moved into `@sp/sync-providers` with the + other provider-shared errors. The app error module re-exports it so + cross-import `instanceof` identity remains guarded. +- Package LocalFile Vitest coverage was added for the base file provider, + Electron folder-picker/path logic, and Android SAF permission/setup + logic. The remaining app-side Jasmine spec continues to cover the + Electron compatibility shim. + ### Remaining Slice Plan -Finish PR 5 with one remaining provider slice: +PR 5 is complete for this branch. Continue with PR 6 final boundary +hardening: -1. **LocalFile final slice** (next) - - Move LocalFile provider implementation last. - - Put Electron/local-file APIs behind an app-provided file port and - keep the Electron bridge implementation app-side. - - Keep Android/browser LocalFile behavior covered by app shims while - the package owns only platform-neutral provider logic. - - Migrate `md5HashPromise` / `spark-md5` usage in - `local-file-sync-base.ts:178` to `hash-wasm` (already a package - runtime dep), mirroring the WebDAV slice's `md5HashSync` - migration. +1. Recheck package boundary rules and forbidden import greps for both + `sync-core` and `sync-providers`. +2. Audit manifests/runtime deps now that all bundled providers moved. +3. Audit the `@sp/sync-providers` public barrel for app-owned concepts and + consider whether tiered exports should remain polish-only. +4. Add the short package-boundary architecture note described in PR 6. ### Verification diff --git a/package-lock.json b/package-lock.json index 12e2c009c0..000635a3ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -136,7 +136,6 @@ "query-string": "^7.1.3", "rxjs": "^7.8.2", "shepherd.js": "^11.2.0", - "spark-md5": "^3.0.2", "stacktrace-js": "^2.0.2", "start-server-and-test": "^2.1.3", "stylelint": "^17.9.1", @@ -25391,13 +25390,6 @@ "node": ">=0.10.0" } }, - "node_modules/spark-md5": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/spark-md5/-/spark-md5-3.0.2.tgz", - "integrity": "sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==", - "dev": true, - "license": "(WTFPL OR MIT)" - }, "node_modules/spdx-exceptions": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", diff --git a/package.json b/package.json index 4a7415f747..1ff9f96064 100644 --- a/package.json +++ b/package.json @@ -301,7 +301,6 @@ "query-string": "^7.1.3", "rxjs": "^7.8.2", "shepherd.js": "^11.2.0", - "spark-md5": "^3.0.2", "stacktrace-js": "^2.0.2", "start-server-and-test": "^2.1.3", "stylelint": "^17.9.1", diff --git a/packages/sync-providers/src/errors/index.ts b/packages/sync-providers/src/errors/index.ts index c0a6b057b7..faa9491cd0 100644 --- a/packages/sync-providers/src/errors/index.ts +++ b/packages/sync-providers/src/errors/index.ts @@ -42,6 +42,10 @@ export class NoRevAPIError extends AdditionalLogErrorBase { override name = ' NoRevAPIError'; } +export class FileHashCreationAPIError extends AdditionalLogErrorBase { + override name = ' FileHashCreationAPIError'; +} + export class TooManyRequestsAPIError extends AdditionalLogErrorBase<{ status: number; retryAfter?: number; diff --git a/packages/sync-providers/src/file-based/local-file/local-file-sync-android.ts b/packages/sync-providers/src/file-based/local-file/local-file-sync-android.ts new file mode 100644 index 0000000000..23bc75102e --- /dev/null +++ b/packages/sync-providers/src/file-based/local-file/local-file-sync-android.ts @@ -0,0 +1,47 @@ +import { toSyncLogError } from '@sp/sync-core'; +import { LocalFileSyncBase, type LocalFileSyncBaseDeps } from './local-file-sync-base'; + +interface LocalFileSafPort { + selectFolder(): Promise; + checkPermission(uri?: string): Promise; +} + +export interface LocalFileSyncAndroidDeps extends LocalFileSyncBaseDeps { + saf: LocalFileSafPort; +} + +export class LocalFileSyncAndroid extends LocalFileSyncBase { + constructor(private readonly _deps: LocalFileSyncAndroidDeps) { + super(_deps); + } + + async isReady(): Promise { + const privateCfg = await this.privateCfg.load(); + if (privateCfg?.safFolderUri?.length) { + const hasPermission = await this._deps.saf.checkPermission(privateCfg.safFolderUri); + if (!hasPermission) { + await this.privateCfg.updatePartial({ safFolderUri: undefined }); + return false; + } + return true; + } + return false; + } + + async setupSaf(): Promise { + try { + const uri = await this._deps.saf.selectFolder(); + await this.privateCfg.upsertPartial({ + safFolderUri: uri, + }); + return uri; + } catch (error) { + this.logger.err('Failed to setup SAF', toSyncLogError(error)); + throw error; + } + } + + async getFilePath(targetPath: string): Promise { + return targetPath; + } +} diff --git a/packages/sync-providers/src/file-based/local-file/local-file-sync-base.ts b/packages/sync-providers/src/file-based/local-file/local-file-sync-base.ts new file mode 100644 index 0000000000..15eb15e54a --- /dev/null +++ b/packages/sync-providers/src/file-based/local-file/local-file-sync-base.ts @@ -0,0 +1,217 @@ +import { md5 } from 'hash-wasm'; +import type { SyncLogger } from '@sp/sync-core'; +import type { SyncCredentialStorePort } from '../../credential-store-port'; +import type { FileAdapter } from '../../file-adapter'; +import { errorMeta } from '../../log/error-meta'; +import type { FileSyncProvider } from '../../provider.types'; +import { + FileHashCreationAPIError, + InvalidDataSPError, + NoRevAPIError, + RemoteFileNotFoundAPIError, + UploadRevToMatchMismatchAPIError, +} from '../../errors'; +import { PROVIDER_ID_LOCAL_FILE, type LocalFileSyncPrivateCfg } from './local-file.model'; + +export interface LocalFileSyncBaseDeps { + logger: SyncLogger; + fileAdapter: FileAdapter; + credentialStore: SyncCredentialStorePort< + typeof PROVIDER_ID_LOCAL_FILE, + LocalFileSyncPrivateCfg + >; +} + +export abstract class LocalFileSyncBase implements FileSyncProvider< + typeof PROVIDER_ID_LOCAL_FILE, + LocalFileSyncPrivateCfg +> { + private static readonly LB = 'LocalFileSyncBase'; + + readonly id = PROVIDER_ID_LOCAL_FILE; + readonly isUploadForcePossible = false; + readonly maxConcurrentRequests = 10; + + public privateCfg: SyncCredentialStorePort< + typeof PROVIDER_ID_LOCAL_FILE, + LocalFileSyncPrivateCfg + >; + + protected readonly fileAdapter: FileAdapter; + protected readonly logger: SyncLogger; + + protected constructor(deps: LocalFileSyncBaseDeps) { + this.fileAdapter = deps.fileAdapter; + this.logger = deps.logger; + this.privateCfg = deps.credentialStore; + } + + abstract isReady(): Promise; + + async setPrivateCfg(privateCfg: LocalFileSyncPrivateCfg): Promise { + await this.privateCfg.setComplete(privateCfg); + } + + protected abstract getFilePath(targetPath: string): Promise; + + async listFiles(dirPath: string): Promise { + this.logger.normal(`${LocalFileSyncBase.LB}.${this.listFiles.name}()`); + if (!this.fileAdapter.listFiles) { + throw new Error('FileAdapter does not support listFiles'); + } + const fullPath = await this.getFilePath(dirPath); + return this.fileAdapter.listFiles(fullPath); + } + + async getFileRev( + targetPath: string, + _localRev: string | null, + ): Promise<{ rev: string }> { + this.logger.normal(`${LocalFileSyncBase.LB}.${this.getFileRev.name}`, { + targetPath, + }); + try { + const r = await this.downloadFile(targetPath); + return { rev: r.rev }; + } catch (e) { + this.logger.critical( + `${LocalFileSyncBase.LB}.${this.getFileRev.name} error`, + errorMeta(e, { targetPath }), + ); + throw e; + } + } + + async downloadFile(targetPath: string): Promise<{ rev: string; dataStr: string }> { + this.logger.normal(`${LocalFileSyncBase.LB}.${this.downloadFile.name}()`, { + targetPath, + }); + + try { + const filePath = await this.getFilePath(targetPath); + const dataStr = await this.fileAdapter.readFile(filePath); + + if (!dataStr || dataStr === '') { + throw new RemoteFileNotFoundAPIError({ targetPath }); + } + if (dataStr.length <= 3) { + throw new InvalidDataSPError(`File content too short: ${dataStr.length} chars`); + } + return { + rev: await this._getLocalRev(dataStr), + dataStr, + }; + } catch (e) { + if (this._isDownloadFileNotFoundError(e)) { + throw new RemoteFileNotFoundAPIError(targetPath); + } + + this.logger.critical( + `${LocalFileSyncBase.LB}.${this.downloadFile.name}() error`, + errorMeta(e, { targetPath }), + ); + throw e; + } + } + + async uploadFile( + targetPath: string, + dataStr: string, + revToMatch: string | null, + isForceOverwrite = false, + ): Promise<{ rev: string }> { + this.logger.normal(`${LocalFileSyncBase.LB}.${this.uploadFile.name}()`, { + targetPath, + dataLength: dataStr?.length, + hasRevToMatch: !!revToMatch, + isForceOverwrite, + }); + + try { + if (!isForceOverwrite && revToMatch) { + try { + const existingFile = await this.downloadFile(targetPath); + if (existingFile.rev !== revToMatch) { + this.logger.critical( + `${LocalFileSyncBase.LB}.${this.uploadFile.name}() rev mismatch`, + { targetPath }, + ); + throw new UploadRevToMatchMismatchAPIError(); + } + } catch (err) { + if (!(err instanceof RemoteFileNotFoundAPIError)) { + throw err; + } + } + } + + const filePath = await this.getFilePath(targetPath); + await this.fileAdapter.writeFile(filePath, dataStr); + + const newRev = await this._getLocalRev(dataStr); + return { rev: newRev }; + } catch (e) { + this.logger.critical( + `${LocalFileSyncBase.LB}.${this.uploadFile.name}() error`, + errorMeta(e, { targetPath }), + ); + throw e; + } + } + + async removeFile(targetPath: string): Promise { + this.logger.normal(`${LocalFileSyncBase.LB}.${this.removeFile.name}`, { + targetPath, + }); + try { + const filePath = await this.getFilePath(targetPath); + await this.fileAdapter.deleteFile(filePath); + } catch (e) { + if (this._isRemoveFileNotFoundError(e)) { + this.logger.normal( + `${LocalFileSyncBase.LB}.${this.removeFile.name} - file does not exist`, + { targetPath }, + ); + return; + } + + this.logger.critical( + `${LocalFileSyncBase.LB}.${this.removeFile.name} error`, + errorMeta(e, { targetPath }), + ); + throw e; + } + } + + private async _getLocalRev(dataStr: string): Promise { + if (!dataStr) { + throw new InvalidDataSPError('Empty data string when creating rev'); + } + + let hash: string; + try { + hash = await md5(dataStr); + } catch (e) { + throw new FileHashCreationAPIError(e); + } + + if (!hash) { + throw new NoRevAPIError(); + } + return hash; + } + + private _isDownloadFileNotFoundError(error: unknown): boolean { + const message = String(error); + return ( + message.includes('File not found') || + message.includes('does not exist') || + message.includes('ENOENT') + ); + } + + private _isRemoveFileNotFoundError(error: unknown): boolean { + const message = String(error); + return message.includes('File does not exist') || message.includes('ENOENT'); + } +} diff --git a/packages/sync-providers/src/file-based/local-file/local-file-sync-electron.ts b/packages/sync-providers/src/file-based/local-file/local-file-sync-electron.ts new file mode 100644 index 0000000000..172a2bf970 --- /dev/null +++ b/packages/sync-providers/src/file-based/local-file/local-file-sync-electron.ts @@ -0,0 +1,110 @@ +import { toSyncLogError } from '@sp/sync-core'; +import { LocalFileSyncBase, type LocalFileSyncBaseDeps } from './local-file-sync-base'; + +export interface LocalFileSyncElectronDeps extends LocalFileSyncBaseDeps { + isElectron: boolean; + checkDirExists: (dirPath: string) => Promise; + pickDirectory: () => Promise; +} + +export class LocalFileSyncElectron extends LocalFileSyncBase { + private static readonly L = 'LocalFileSyncElectron'; + + constructor(private readonly _deps: LocalFileSyncElectronDeps) { + super(_deps); + } + + async isReady(): Promise { + if (!this._deps.isElectron) { + throw new Error('LocalFileSyncElectron is only available in electron'); + } + const privateCfg = await this.privateCfg.load(); + return !!privateCfg?.syncFolderPath; + } + + async getFilePath(targetPath: string): Promise { + const folderPath = await this._getFolderPath(); + const normalizedPath = targetPath.startsWith('/') + ? targetPath.substring(1) + : targetPath; + return `${folderPath}/${normalizedPath}`; + } + + async pickDirectory(): Promise { + this.logger.normal(`${LocalFileSyncElectron.L}.pickDirectory()`); + + try { + const dir = await this._deps.pickDirectory(); + if (dir) { + await this.privateCfg.upsertPartial({ syncFolderPath: dir }); + } + return dir; + } catch (e) { + this.logger.error( + `${LocalFileSyncElectron.L}.pickDirectory() error`, + toSyncLogError(e), + ); + throw e; + } + } + + private async _checkDirAndOpenPickerIfNotExists(): Promise { + this.logger.normal( + `${LocalFileSyncElectron.L}.${this._checkDirAndOpenPickerIfNotExists.name}`, + ); + + try { + const privateCfg = await this.privateCfg.load(); + const folderPath = privateCfg?.syncFolderPath; + + if (!folderPath) { + this.logger.critical( + `${LocalFileSyncElectron.L} - no sync folder configured, opening picker`, + ); + await this.pickDirectory(); + return; + } + + const isDirExists = await this._checkDirExists(folderPath); + if (!isDirExists) { + this.logger.critical( + `${LocalFileSyncElectron.L} - no valid directory, opening picker`, + ); + await this.pickDirectory(); + } + } catch (err) { + this.logger.error( + `${LocalFileSyncElectron.L}.${this._checkDirAndOpenPickerIfNotExists.name}() error`, + toSyncLogError(err), + ); + await this.pickDirectory(); + } + } + + private async _getFolderPath(): Promise { + const privateCfg = await this.privateCfg.load(); + const folderPath = privateCfg?.syncFolderPath; + if (!folderPath) { + await this._checkDirAndOpenPickerIfNotExists(); + const updatedCfg = await this.privateCfg.load(); + const updatedPath = updatedCfg?.syncFolderPath; + if (!updatedPath) { + throw new Error('No sync folder path configured after directory picker'); + } + return updatedPath; + } + return folderPath; + } + + private async _checkDirExists(dirPath: string): Promise { + try { + return await this._deps.checkDirExists(dirPath); + } catch (e) { + this.logger.error( + `${LocalFileSyncElectron.L}.${this._checkDirExists.name}() error`, + toSyncLogError(e), + ); + return false; + } + } +} diff --git a/packages/sync-providers/src/file-based/local-file/local-file.model.ts b/packages/sync-providers/src/file-based/local-file/local-file.model.ts new file mode 100644 index 0000000000..0e91e3aa83 --- /dev/null +++ b/packages/sync-providers/src/file-based/local-file/local-file.model.ts @@ -0,0 +1,14 @@ +/** + * Stable runtime identifier for the LocalFile provider. The string + * literal keeps the package free of app-level enums while remaining + * structurally compatible with `SyncProviderId.LocalFile` app-side. + */ +export const PROVIDER_ID_LOCAL_FILE = 'LocalFile' as const; + +export interface LocalFileSyncPrivateCfg { + encryptKey?: string; + /** Electron-selected sync folder path. */ + syncFolderPath?: string; + /** Android Storage Access Framework folder URI. */ + safFolderUri?: string; +} diff --git a/packages/sync-providers/src/index.ts b/packages/sync-providers/src/index.ts index 76b3371563..fd8a064437 100644 --- a/packages/sync-providers/src/index.ts +++ b/packages/sync-providers/src/index.ts @@ -7,6 +7,7 @@ export { AuthFailSPError, EmptyRemoteBodySPError, extractErrorMessage, + FileHashCreationAPIError, HttpNotOkAPIError, InvalidDataSPError, MissingCredentialsSPError, @@ -66,6 +67,22 @@ export { testWebdavConnection, type TestWebdavConnectionDeps, } from './file-based/webdav/test-connection'; +export { + LocalFileSyncBase, + type LocalFileSyncBaseDeps, +} from './file-based/local-file/local-file-sync-base'; +export { + LocalFileSyncElectron, + type LocalFileSyncElectronDeps, +} from './file-based/local-file/local-file-sync-electron'; +export { + LocalFileSyncAndroid, + type LocalFileSyncAndroidDeps, +} from './file-based/local-file/local-file-sync-android'; +export { + PROVIDER_ID_LOCAL_FILE, + type LocalFileSyncPrivateCfg, +} from './file-based/local-file/local-file.model'; export { PROVIDER_ID_SUPER_SYNC, SUPER_SYNC_DEFAULT_BASE_URL, diff --git a/packages/sync-providers/tests/errors.spec.ts b/packages/sync-providers/tests/errors.spec.ts index fd80123284..04f35e9cd2 100644 --- a/packages/sync-providers/tests/errors.spec.ts +++ b/packages/sync-providers/tests/errors.spec.ts @@ -3,6 +3,7 @@ import { AdditionalLogErrorBase, AuthFailSPError, EmptyRemoteBodySPError, + FileHashCreationAPIError, extractErrorMessage, HttpNotOkAPIError, InvalidDataSPError, @@ -162,6 +163,7 @@ describe('Error class identity (single definition per class)', () => { ['EmptyRemoteBodySPError', EmptyRemoteBodySPError], ['RemoteFileNotFoundAPIError', RemoteFileNotFoundAPIError], ['NoRevAPIError', NoRevAPIError], + ['FileHashCreationAPIError', FileHashCreationAPIError], ['HttpNotOkAPIError', HttpNotOkAPIError], ['MissingCredentialsSPError', MissingCredentialsSPError], ['MissingRefreshTokenAPIError', MissingRefreshTokenAPIError], diff --git a/packages/sync-providers/tests/file-based/local-file/local-file-sync-android.spec.ts b/packages/sync-providers/tests/file-based/local-file/local-file-sync-android.spec.ts new file mode 100644 index 0000000000..508c5d7183 --- /dev/null +++ b/packages/sync-providers/tests/file-based/local-file/local-file-sync-android.spec.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, vi } from 'vitest'; +import { NOOP_SYNC_LOGGER } from '@sp/sync-core'; +import { + LocalFileSyncAndroid, + type FileAdapter, + type LocalFileSyncAndroidDeps, + type LocalFileSyncPrivateCfg, + PROVIDER_ID_LOCAL_FILE, + type SyncCredentialStorePort, +} from '../../../src'; + +const fakeStore = ( + initial: LocalFileSyncPrivateCfg | null, +): SyncCredentialStorePort => { + let state = initial; + return { + load: vi.fn(async () => state), + setComplete: vi.fn(async (cfg) => { + state = cfg; + }), + updatePartial: vi.fn(async (updates) => { + state = { ...(state ?? {}), ...updates }; + }), + upsertPartial: vi.fn(async (updates) => { + state = { ...(state ?? {}), ...updates }; + }), + clear: vi.fn(async () => { + state = null; + }), + }; +}; + +const noopFileAdapter: FileAdapter = { + readFile: async () => '', + writeFile: async () => undefined, + deleteFile: async () => undefined, +}; + +const makeProvider = ( + store: SyncCredentialStorePort, + overrides: Partial = {}, +): { + provider: LocalFileSyncAndroid; + selectFolder: ReturnType Promise>>; + checkPermission: ReturnType Promise>>; +} => { + const selectFolder = vi.fn<() => Promise>().mockResolvedValue('content://uri'); + const checkPermission = vi + .fn<(uri?: string) => Promise>() + .mockResolvedValue(true); + const provider = new LocalFileSyncAndroid({ + logger: NOOP_SYNC_LOGGER, + fileAdapter: noopFileAdapter, + credentialStore: store, + saf: { + selectFolder, + checkPermission, + ...overrides, + }, + }); + return { provider, selectFolder, checkPermission }; +}; + +describe('LocalFileSyncAndroid', () => { + it('is ready when SAF URI exists and permission is still granted', async () => { + const store = fakeStore({ safFolderUri: 'content://uri' }); + const { provider, checkPermission } = makeProvider(store); + + await expect(provider.isReady()).resolves.toBe(true); + expect(checkPermission).toHaveBeenCalledWith('content://uri'); + }); + + it('clears stale SAF URI when permission was revoked', async () => { + const store = fakeStore({ safFolderUri: 'content://uri' }); + const { provider } = makeProvider(store, { + checkPermission: vi + .fn<(uri?: string) => Promise>() + .mockResolvedValue(false), + }); + + await expect(provider.isReady()).resolves.toBe(false); + expect(await store.load()).toEqual({ safFolderUri: undefined }); + }); + + it('setupSaf stores selected URI', async () => { + const store = fakeStore(null); + const { provider, selectFolder } = makeProvider(store); + + await expect(provider.setupSaf()).resolves.toBe('content://uri'); + expect(selectFolder).toHaveBeenCalledTimes(1); + expect(await store.load()).toEqual({ safFolderUri: 'content://uri' }); + }); + + it('uses targetPath directly as file adapter path', async () => { + const store = fakeStore(null); + const { provider } = makeProvider(store); + + await expect(provider.getFilePath('/sync-data.json')).resolves.toBe( + '/sync-data.json', + ); + }); +}); diff --git a/packages/sync-providers/tests/file-based/local-file/local-file-sync-base.spec.ts b/packages/sync-providers/tests/file-based/local-file/local-file-sync-base.spec.ts new file mode 100644 index 0000000000..3f175ad066 --- /dev/null +++ b/packages/sync-providers/tests/file-based/local-file/local-file-sync-base.spec.ts @@ -0,0 +1,233 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { md5 } from 'hash-wasm'; +import { NOOP_SYNC_LOGGER } from '@sp/sync-core'; +import { + InvalidDataSPError, + LocalFileSyncBase, + type FileAdapter, + type LocalFileSyncBaseDeps, + type LocalFileSyncPrivateCfg, + NoRevAPIError, + PROVIDER_ID_LOCAL_FILE, + RemoteFileNotFoundAPIError, + type SyncCredentialStorePort, + UploadRevToMatchMismatchAPIError, +} from '../../../src'; + +vi.mock('hash-wasm', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + md5: vi.fn(actual.md5), + }; +}); + +class TestableLocalFileSync extends LocalFileSyncBase { + private _isReady = true; + private _basePath = '/test/sync'; + + constructor(fileAdapter: FileAdapter) { + super({ + logger: NOOP_SYNC_LOGGER, + fileAdapter, + credentialStore: fakeStore(null), + }); + } + + async isReady(): Promise { + return this._isReady; + } + + setReadyState(isReady: boolean): void { + this._isReady = isReady; + } + + protected async getFilePath(targetPath: string): Promise { + const normalizedTarget = targetPath.startsWith('/') + ? targetPath.slice(1) + : targetPath; + return `${this._basePath}/${normalizedTarget}`; + } +} + +class MockFileAdapter implements FileAdapter { + private _files = new Map(); + private _failError: Error | null = null; + + async readFile(path: string): Promise { + if (this._failError) { + throw this._failError; + } + const content = this._files.get(path); + if (content === undefined) { + throw new Error('ENOENT: no such file or directory'); + } + return content; + } + + async writeFile(path: string, content: string): Promise { + if (this._failError) { + throw this._failError; + } + this._files.set(path, content); + } + + async deleteFile(path: string): Promise { + if (this._failError) { + throw this._failError; + } + if (!this._files.has(path)) { + throw new Error('ENOENT: File does not exist'); + } + this._files.delete(path); + } + + async listFiles(dirPath: string): Promise { + return [...this._files.keys()].filter((path) => path.startsWith(dirPath)); + } + + setFile(path: string, content: string): void { + this._files.set(path, content); + } + + getFile(path: string): string | undefined { + return this._files.get(path); + } + + hasFile(path: string): boolean { + return this._files.has(path); + } + + setFailure(error: Error | null): void { + this._failError = error; + } +} + +const fakeStore = ( + initial: LocalFileSyncPrivateCfg | null, +): SyncCredentialStorePort => { + let state = initial; + return { + load: async () => state, + setComplete: async (cfg) => { + state = cfg; + }, + updatePartial: async (updates) => { + state = { ...(state ?? {}), ...updates }; + }, + upsertPartial: async (updates) => { + state = { ...(state ?? {}), ...updates }; + }, + clear: async () => { + state = null; + }, + }; +}; + +describe('LocalFileSyncBase', () => { + afterEach(() => { + vi.mocked(md5).mockClear(); + }); + + it('downloads file content and returns hash-wasm md5 rev', async () => { + const fileAdapter = new MockFileAdapter(); + const provider = new TestableLocalFileSync(fileAdapter); + const testContent = 'test file content for sync'; + fileAdapter.setFile('/test/sync/sync-data.json', testContent); + + const result = await provider.downloadFile('sync-data.json'); + + expect(result.dataStr).toBe(testContent); + expect(result.rev).toBe(await md5(testContent)); + }); + + it('uploads file and returns a stable md5 rev', async () => { + const fileAdapter = new MockFileAdapter(); + const provider = new TestableLocalFileSync(fileAdapter); + const testContent = 'new file content to upload'; + + const result = await provider.uploadFile('sync-data.json', testContent, null); + + expect(result.rev).toBe(await md5(testContent)); + expect(fileAdapter.getFile('/test/sync/sync-data.json')).toBe(testContent); + }); + + it('throws RemoteFileNotFoundAPIError for missing or empty files', async () => { + const fileAdapter = new MockFileAdapter(); + const provider = new TestableLocalFileSync(fileAdapter); + fileAdapter.setFile('/test/sync/empty.json', ''); + + await expect(provider.downloadFile('nonexistent.json')).rejects.toBeInstanceOf( + RemoteFileNotFoundAPIError, + ); + await expect(provider.downloadFile('empty.json')).rejects.toBeInstanceOf( + RemoteFileNotFoundAPIError, + ); + }); + + it('throws InvalidDataSPError for file content that is too short', async () => { + const fileAdapter = new MockFileAdapter(); + const provider = new TestableLocalFileSync(fileAdapter); + fileAdapter.setFile('/test/sync/short.json', 'ab'); + + await expect(provider.downloadFile('short.json')).rejects.toBeInstanceOf( + InvalidDataSPError, + ); + }); + + it('enforces revToMatch unless force overwrite is requested', async () => { + const fileAdapter = new MockFileAdapter(); + const provider = new TestableLocalFileSync(fileAdapter); + fileAdapter.setFile('/test/sync/sync-data.json', 'initial content'); + + await expect( + provider.uploadFile('sync-data.json', 'updated content', 'wrong-rev'), + ).rejects.toBeInstanceOf(UploadRevToMatchMismatchAPIError); + + expect(fileAdapter.getFile('/test/sync/sync-data.json')).toBe('initial content'); + + await expect( + provider.uploadFile('sync-data.json', 'force content', 'wrong-rev', true), + ).resolves.toEqual({ rev: await md5('force content') }); + expect(fileAdapter.getFile('/test/sync/sync-data.json')).toBe('force content'); + }); + + it('preserves NoRevAPIError identity if hashing returns an empty rev', async () => { + vi.mocked(md5).mockResolvedValueOnce(''); + const fileAdapter = new MockFileAdapter(); + const provider = new TestableLocalFileSync(fileAdapter); + + await expect( + provider.uploadFile('sync-data.json', 'content', null), + ).rejects.toBeInstanceOf(NoRevAPIError); + }); + + it('ignores missing-file errors when removing', async () => { + const provider = new TestableLocalFileSync(new MockFileAdapter()); + + await expect(provider.removeFile('nonexistent.json')).resolves.toBeUndefined(); + }); + + it('lists files through the injected file adapter', async () => { + const fileAdapter = new MockFileAdapter(); + const provider = new TestableLocalFileSync(fileAdapter); + fileAdapter.setFile('/test/sync/file1.json', 'content1'); + fileAdapter.setFile('/test/sync/file2.json', 'content2'); + fileAdapter.setFile('/test/sync/subdir/file3.json', 'content3'); + + const files = await provider.listFiles('/'); + + expect(files).toContain('/test/sync/file1.json'); + expect(files).toContain('/test/sync/file2.json'); + expect(files).toContain('/test/sync/subdir/file3.json'); + }); + + it('accepts dependency shape used by shims', () => { + const deps: LocalFileSyncBaseDeps = { + logger: NOOP_SYNC_LOGGER, + fileAdapter: new MockFileAdapter(), + credentialStore: fakeStore(null), + }; + expect(deps.credentialStore).toBeTruthy(); + }); +}); diff --git a/packages/sync-providers/tests/file-based/local-file/local-file-sync-electron.spec.ts b/packages/sync-providers/tests/file-based/local-file/local-file-sync-electron.spec.ts new file mode 100644 index 0000000000..e4d30dfbc9 --- /dev/null +++ b/packages/sync-providers/tests/file-based/local-file/local-file-sync-electron.spec.ts @@ -0,0 +1,121 @@ +import { describe, expect, it, vi } from 'vitest'; +import { NOOP_SYNC_LOGGER } from '@sp/sync-core'; +import { + LocalFileSyncElectron, + type FileAdapter, + type LocalFileSyncElectronDeps, + type LocalFileSyncPrivateCfg, + PROVIDER_ID_LOCAL_FILE, + type SyncCredentialStorePort, +} from '../../../src'; + +const fakeStore = ( + initial: LocalFileSyncPrivateCfg | null, +): SyncCredentialStorePort => { + let state = initial; + return { + load: vi.fn(async () => state), + setComplete: vi.fn(async (cfg) => { + state = cfg; + }), + updatePartial: vi.fn(async (updates) => { + state = { ...(state ?? {}), ...updates }; + }), + upsertPartial: vi.fn(async (updates) => { + state = { ...(state ?? {}), ...updates }; + }), + clear: vi.fn(async () => { + state = null; + }), + }; +}; + +const noopFileAdapter: FileAdapter = { + readFile: async () => '', + writeFile: async () => undefined, + deleteFile: async () => undefined, + listFiles: async () => [], +}; + +const makeProvider = ( + overrides: Partial = {}, +): { + provider: LocalFileSyncElectron; + store: SyncCredentialStorePort; + pickDirectory: ReturnType Promise>>; + checkDirExists: ReturnType Promise>>; +} => { + const store = fakeStore(null); + const pickDirectory = vi + .fn<() => Promise>() + .mockResolvedValue('/picked/dir'); + const checkDirExists = vi + .fn<(dirPath: string) => Promise>() + .mockResolvedValue(true); + const deps: LocalFileSyncElectronDeps = { + logger: NOOP_SYNC_LOGGER, + fileAdapter: noopFileAdapter, + credentialStore: store, + isElectron: true, + pickDirectory, + checkDirExists, + ...overrides, + }; + return { + provider: new LocalFileSyncElectron(deps), + store, + pickDirectory: deps.pickDirectory as ReturnType< + typeof vi.fn<() => Promise> + >, + checkDirExists: deps.checkDirExists as ReturnType< + typeof vi.fn<(dirPath: string) => Promise> + >, + }; +}; + +describe('LocalFileSyncElectron', () => { + it('reports ready only on Electron with configured folder path', async () => { + const ready = makeProvider({ credentialStore: fakeStore({ syncFolderPath: '/x' }) }); + await expect(ready.provider.isReady()).resolves.toBe(true); + + const missingPath = makeProvider({ credentialStore: fakeStore(null) }); + await expect(missingPath.provider.isReady()).resolves.toBe(false); + + const notElectron = makeProvider({ isElectron: false }); + await expect(notElectron.provider.isReady()).rejects.toThrow( + 'LocalFileSyncElectron is only available in electron', + ); + }); + + it('normalizes target paths under the configured folder path', async () => { + const { provider } = makeProvider({ + credentialStore: fakeStore({ syncFolderPath: '/my/sync' }), + }); + + await expect(provider.getFilePath('/data.json')).resolves.toBe('/my/sync/data.json'); + }); + + it('opens the picker once when no folder is configured', async () => { + const store = fakeStore(null); + const { provider, pickDirectory } = makeProvider({ credentialStore: store }); + + await expect(provider.getFilePath('data.json')).resolves.toBe( + '/picked/dir/data.json', + ); + expect(pickDirectory).toHaveBeenCalledTimes(1); + expect(await store.load()).toEqual({ syncFolderPath: '/picked/dir' }); + }); + + it('throws when picker is cancelled and avoids recursive retries', async () => { + const { provider, pickDirectory, store } = makeProvider({ + pickDirectory: vi.fn<() => Promise>().mockResolvedValue(undefined), + }); + + await expect(provider.getFilePath('data.json')).rejects.toThrow( + 'No sync folder path configured after directory picker', + ); + + expect(pickDirectory).toHaveBeenCalledTimes(1); + expect(store.load).toHaveBeenCalledTimes(3); + }); +}); diff --git a/src/app/op-log/core/errors/sync-errors.identity.spec.ts b/src/app/op-log/core/errors/sync-errors.identity.spec.ts index 0d1b330172..d213f3e018 100644 --- a/src/app/op-log/core/errors/sync-errors.identity.spec.ts +++ b/src/app/op-log/core/errors/sync-errors.identity.spec.ts @@ -1,6 +1,7 @@ import { AuthFailSPError as PackageAuthFailSPError, EmptyRemoteBodySPError as PackageEmptyRemoteBodySPError, + FileHashCreationAPIError as PackageFileHashCreationAPIError, HttpNotOkAPIError as PackageHttpNotOkAPIError, InvalidDataSPError as PackageInvalidDataSPError, MissingCredentialsSPError as PackageMissingCredentialsSPError, @@ -15,6 +16,7 @@ import { import { AuthFailSPError, EmptyRemoteBodySPError, + FileHashCreationAPIError, HttpNotOkAPIError, InvalidDataSPError, MissingCredentialsSPError, @@ -45,6 +47,11 @@ describe('sync-errors identity (single class definition across import paths)', ( PackageRemoteFileNotFoundAPIError, ], ['NoRevAPIError', NoRevAPIError, PackageNoRevAPIError], + [ + 'FileHashCreationAPIError', + FileHashCreationAPIError, + PackageFileHashCreationAPIError, + ], ['HttpNotOkAPIError', HttpNotOkAPIError, PackageHttpNotOkAPIError], [ 'MissingCredentialsSPError', diff --git a/src/app/op-log/core/errors/sync-errors.ts b/src/app/op-log/core/errors/sync-errors.ts index 38cd7fbfce..220d4ba4a9 100644 --- a/src/app/op-log/core/errors/sync-errors.ts +++ b/src/app/op-log/core/errors/sync-errors.ts @@ -15,6 +15,7 @@ import { OP_LOG_SYNC_LOGGER } from '../sync-logger.adapter'; export { AuthFailSPError, EmptyRemoteBodySPError, + FileHashCreationAPIError, HttpNotOkAPIError, InvalidDataSPError, MissingCredentialsSPError, @@ -80,10 +81,6 @@ export class FileExistsAPIError extends Error { override name = ' FileExistsAPIError'; } -export class FileHashCreationAPIError extends AdditionalLogErrorBase { - override name = ' FileHashCreationAPIError'; -} - // --------------OTHER SYNC ERRORS-------------- export class NoSyncProviderSetError extends Error { override name = 'NoSyncProviderSetError'; diff --git a/src/app/op-log/core/types/sync.types.ts b/src/app/op-log/core/types/sync.types.ts index 122b136050..4f9ea92822 100644 --- a/src/app/op-log/core/types/sync.types.ts +++ b/src/app/op-log/core/types/sync.types.ts @@ -69,14 +69,6 @@ export interface SyncProviderPrivateCfgBase { encryptKey?: string; } -// Local file sync config that works for both platforms -export interface LocalFileSyncPrivateCfg extends SyncProviderPrivateCfgBase { - // Electron specific - syncFolderPath?: string; - // Android SAF specific - safFolderUri?: string; -} - // Note: DropboxPrivateCfg, WebdavPrivateCfg, SuperSyncPrivateCfg are defined // in their respective provider files and extend SyncProviderPrivateCfgBase. // They are imported lazily via type imports where needed. @@ -88,9 +80,15 @@ export interface LocalFileSyncPrivateCfg extends SyncProviderPrivateCfgBase { // Forward declarations for provider-specific types // These are imported from their respective modules where used import type { DropboxPrivateCfg } from '../../sync-providers/file-based/dropbox/dropbox'; -import type { NextcloudPrivateCfg, WebdavPrivateCfg } from '@sp/sync-providers'; +import type { + LocalFileSyncPrivateCfg as PackageLocalFileSyncPrivateCfg, + NextcloudPrivateCfg, + WebdavPrivateCfg, +} from '@sp/sync-providers'; import type { SuperSyncPrivateCfg } from '../../sync-providers/super-sync/super-sync.model'; +export type LocalFileSyncPrivateCfg = PackageLocalFileSyncPrivateCfg; + export type SyncProviderPrivateCfg = | DropboxPrivateCfg | WebdavPrivateCfg diff --git a/src/app/op-log/sync-providers/file-based/local-file/local-file-sync-android.ts b/src/app/op-log/sync-providers/file-based/local-file/local-file-sync-android.ts index 989ffc0485..842a6b4f6d 100644 --- a/src/app/op-log/sync-providers/file-based/local-file/local-file-sync-android.ts +++ b/src/app/op-log/sync-providers/file-based/local-file/local-file-sync-android.ts @@ -1,53 +1,28 @@ -import { Directory } from '@capacitor/filesystem'; -import { LocalFileSyncBase } from './local-file-sync-base'; -import { LocalFileSyncPrivateCfg } from '../../../core/types/sync.types'; -import { SafService } from './droid-saf/saf.service'; +import { + LocalFileSyncAndroid as PackageLocalFileSyncAndroid, + type LocalFileSyncAndroidDeps, +} from '@sp/sync-providers'; +import { OP_LOG_SYNC_LOGGER } from '../../../core/sync-logger.adapter'; +import { SyncCredentialStore } from '../../credential-store.service'; +import { SyncProviderId } from '../../provider.const'; import { SafFileAdapter } from './droid-saf/saf-file-adapter'; -import { SyncLog } from '../../../../core/log'; +import { SafService } from './droid-saf/saf.service'; -export class LocalFileSyncAndroid extends LocalFileSyncBase { - constructor(public directory = Directory.Documents) { - super( - new SafFileAdapter(async () => { - const cfg = await this.privateCfg.load(); - return cfg?.safFolderUri; - }), - ); - } +const buildLocalFileSyncAndroidDeps = (): LocalFileSyncAndroidDeps => { + const credentialStore = new SyncCredentialStore(SyncProviderId.LocalFile); + return { + logger: OP_LOG_SYNC_LOGGER, + fileAdapter: new SafFileAdapter(async () => { + const cfg = await credentialStore.load(); + return cfg?.safFolderUri; + }), + credentialStore: credentialStore as LocalFileSyncAndroidDeps['credentialStore'], + saf: { + selectFolder: () => SafService.selectFolder(), + checkPermission: (uri) => SafService.checkPermission(uri), + }, + }; +}; - async isReady(): Promise { - const privateCfg = await this.privateCfg.load(); - // Check if SAF is enabled and has valid permissions - if (privateCfg?.safFolderUri?.length) { - const hasPermission = await SafService.checkPermission(privateCfg.safFolderUri); - if (!hasPermission) { - // SAF was enabled but permission was revoked - await this.privateCfg.updatePartial({ safFolderUri: undefined }); - return false; - } - return true; - } - return false; - } - - async setupSaf(): Promise { - try { - const uri = await SafService.selectFolder(); - await this.privateCfg.upsertPartial({ - safFolderUri: uri, - }); - return uri; - } catch (error) { - SyncLog.err('Failed to setup SAF:', error); - throw error; - } - } - - async setPrivateCfg(privateCfg: LocalFileSyncPrivateCfg): Promise { - await this.privateCfg.setComplete(privateCfg); - } - - async getFilePath(targetPath: string): Promise { - return targetPath; - } -} +export const createLocalFileSyncAndroid = (): PackageLocalFileSyncAndroid => + new PackageLocalFileSyncAndroid(buildLocalFileSyncAndroidDeps()); diff --git a/src/app/op-log/sync-providers/file-based/local-file/local-file-sync-base.ts b/src/app/op-log/sync-providers/file-based/local-file/local-file-sync-base.ts deleted file mode 100644 index 3989a78d0d..0000000000 --- a/src/app/op-log/sync-providers/file-based/local-file/local-file-sync-base.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { SyncProviderServiceInterface } from '../../provider.interface'; -import { SyncProviderId } from '../../provider.const'; -import { SyncCredentialStore } from '../../credential-store.service'; -import { FileAdapter } from './file-adapter.interface'; -import { - FileHashCreationAPIError, - InvalidDataSPError, - NoRevAPIError, - RemoteFileNotFoundAPIError, - UploadRevToMatchMismatchAPIError, - WebCryptoNotAvailableError, -} from '../../../core/errors/sync-errors'; -import { md5HashPromise } from '../../../../util/md5-hash'; -import { SyncLog } from '../../../../core/log'; -import { PrivateCfgByProviderId } from '../../../core/types/sync.types'; - -export abstract class LocalFileSyncBase implements SyncProviderServiceInterface { - private static readonly LB = 'LocalFileSyncBase'; - - readonly id = SyncProviderId.LocalFile; - readonly isUploadForcePossible: boolean = false; - readonly maxConcurrentRequests = 10; - - public privateCfg: SyncCredentialStore; - - protected constructor(protected fileAdapter: FileAdapter) { - this.privateCfg = new SyncCredentialStore(SyncProviderId.LocalFile); - } - - abstract isReady(): Promise; - - abstract setPrivateCfg( - privateCfg: PrivateCfgByProviderId, - ): Promise; - - protected abstract getFilePath(targetPath: string): Promise; - - async listFiles(dirPath: string): Promise { - SyncLog.normal(`${LocalFileSyncBase.LB}.${this.listFiles.name}()`, { dirPath }); - if (!this.fileAdapter.listFiles) { - throw new Error('FileAdapter does not support listFiles'); - } - // Assume getFilePath can correctly interpret dirPath as a directory - const fullPath = await this.getFilePath(dirPath); - return this.fileAdapter.listFiles(fullPath); - } - - async getFileRev( - targetPath: string, - localRev: string | null, - ): Promise<{ rev: string }> { - SyncLog.normal(`${LocalFileSyncBase.LB}.${this.getFileRev.name}`, { - targetPath, - localRev, - }); - try { - const r = await this.downloadFile(targetPath); - return { rev: r.rev }; - } catch (e) { - SyncLog.critical(`${LocalFileSyncBase.LB}.${this.getFileRev.name} error`, e); - throw e; - } - } - - async downloadFile(targetPath: string): Promise<{ rev: string; dataStr: string }> { - SyncLog.normal(`${LocalFileSyncBase.LB}.${this.downloadFile.name}()`, { - targetPath, - }); - - try { - const filePath = await this.getFilePath(targetPath); - const dataStr = await this.fileAdapter.readFile(filePath); - - // Validate content - if (!dataStr || dataStr === '') { - throw new RemoteFileNotFoundAPIError({ targetPath }); - } - if (dataStr.length <= 3) { - throw new InvalidDataSPError(`File content too short: ${dataStr.length} chars`); - } - return { - rev: await this._getLocalRev(dataStr), - dataStr, - }; - } catch (e) { - // TODO move to file adapters - // Handle common file not found errors - if ( - e?.toString?.().includes('File not found') || - e?.toString?.().includes('does not exist') || - e?.toString?.().includes('ENOENT') - ) { - throw new RemoteFileNotFoundAPIError(targetPath); - } - - SyncLog.critical(`${LocalFileSyncBase.LB}.${this.downloadFile.name}() error`, e); - throw e; - } - } - - async uploadFile( - targetPath: string, - dataStr: string, - revToMatch: string | null, - isForceOverwrite: boolean = false, - ): Promise<{ rev: string }> { - SyncLog.normal(`${LocalFileSyncBase.LB}.${this.uploadFile.name}()`, { - targetPath, - dataLength: dataStr?.length, - revToMatch, - isForceOverwrite, - }); - - try { - // Check if file exists and compare revs if not force overwrite - if (!isForceOverwrite && revToMatch) { - try { - const existingFile = await this.downloadFile(targetPath); - if (existingFile.rev !== revToMatch) { - SyncLog.critical( - `${LocalFileSyncBase.LB}.${this.uploadFile.name}() rev mismatch`, - existingFile.rev, - revToMatch, - ); - throw new UploadRevToMatchMismatchAPIError(); - } - } catch (err) { - // File doesn't exist yet, that's fine for upload - if (!(err instanceof RemoteFileNotFoundAPIError)) { - throw err; - } - } - } - - const filePath = await this.getFilePath(targetPath); - await this.fileAdapter.writeFile(filePath, dataStr); - - const newRev = await this._getLocalRev(dataStr); - return { rev: newRev }; - } catch (e) { - SyncLog.critical(`${LocalFileSyncBase.LB}.${this.uploadFile.name}() error`, e); - throw e; - } - } - - async removeFile(targetPath: string): Promise { - SyncLog.normal(`${LocalFileSyncBase.LB}.${this.removeFile.name}`, { targetPath }); - try { - const filePath = await this.getFilePath(targetPath); - await this.fileAdapter.deleteFile(filePath); - } catch (e) { - // Ignore file not found errors when removing - if ( - // TODO move to file adapters - e?.toString?.().includes('File does not exist') || - e?.toString?.().includes('ENOENT') - ) { - SyncLog.normal( - `${LocalFileSyncBase.LB}.${this.removeFile.name} - file doesn't exist`, - { - targetPath, - }, - ); - return; - } - - SyncLog.critical(`${LocalFileSyncBase.LB}.${this.removeFile.name} error`, e); - throw e; - } - } - - private async _getLocalRev(dataStr: string): Promise { - if (!dataStr) { - throw new InvalidDataSPError('Empty data string when creating rev'); - } - - try { - const hash = await md5HashPromise(dataStr); - if (!hash) { - throw new NoRevAPIError(); - } - return hash; - } catch (e) { - if (e instanceof WebCryptoNotAvailableError) { - throw e; - } - throw new FileHashCreationAPIError(e); - } - } -} diff --git a/src/app/op-log/sync-providers/file-based/local-file/local-file-sync-electron.spec.ts b/src/app/op-log/sync-providers/file-based/local-file/local-file-sync-electron.spec.ts deleted file mode 100644 index f8fde36acc..0000000000 --- a/src/app/op-log/sync-providers/file-based/local-file/local-file-sync-electron.spec.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { LocalFileSyncElectron } from './local-file-sync-electron'; -import { SyncCredentialStore } from '../../credential-store.service'; -import { SyncProviderId } from '../../provider.const'; - -describe('LocalFileSyncElectron', () => { - let instance: LocalFileSyncElectron; - let mockEa: { - checkDirExists: jasmine.Spy; - pickDirectory: jasmine.Spy; - fileSyncLoad: jasmine.Spy; - fileSyncSave: jasmine.Spy; - fileSyncRemove: jasmine.Spy; - fileSyncListFiles: jasmine.Spy; - }; - let mockPrivateCfg: jasmine.SpyObj>; - - beforeEach(() => { - mockEa = { - checkDirExists: jasmine.createSpy('checkDirExists').and.resolveTo(true), - pickDirectory: jasmine.createSpy('pickDirectory').and.resolveTo('/picked/dir'), - fileSyncLoad: jasmine.createSpy('fileSyncLoad').and.resolveTo({ dataStr: '' }), - fileSyncSave: jasmine.createSpy('fileSyncSave').and.resolveTo(undefined), - fileSyncRemove: jasmine.createSpy('fileSyncRemove').and.resolveTo(undefined), - fileSyncListFiles: jasmine.createSpy('fileSyncListFiles').and.resolveTo([]), - }; - (window as any).ea = mockEa; - - mockPrivateCfg = jasmine.createSpyObj('SyncCredentialStore', [ - 'load', - 'setComplete', - 'upsertPartial', - ]); - - instance = new LocalFileSyncElectron(); - instance.privateCfg = mockPrivateCfg as any; - }); - - afterEach(() => { - delete (window as any).ea; - }); - - describe('getFilePath', () => { - it('should return correct path when folder is configured', async () => { - mockPrivateCfg.load.and.resolveTo({ syncFolderPath: '/my/sync' }); - mockEa.checkDirExists.and.resolveTo(true); - - const result = await instance.getFilePath('data.json'); - - expect(result).toBe('/my/sync/data.json'); - }); - - it('should normalize target path with leading slash', async () => { - mockPrivateCfg.load.and.resolveTo({ syncFolderPath: '/my/sync' }); - - const result = await instance.getFilePath('/data.json'); - - expect(result).toBe('/my/sync/data.json'); - }); - - it('should open picker and use picked directory when no folder configured', async () => { - let callCount = 0; - mockPrivateCfg.load.and.callFake(async () => { - callCount++; - // First two calls: no folder (from _getFolderPath and _checkDir) - // Third call: folder set after picker - if (callCount <= 2) { - return null; - } - return { syncFolderPath: '/picked/dir' }; - }); - mockPrivateCfg.upsertPartial.and.resolveTo(undefined); - - const result = await instance.getFilePath('data.json'); - - expect(result).toBe('/picked/dir/data.json'); - expect(mockEa.pickDirectory).toHaveBeenCalledTimes(1); - }); - - it('should throw when no folder configured and picker is cancelled', async () => { - mockPrivateCfg.load.and.resolveTo(null); - mockEa.pickDirectory.and.resolveTo(undefined); - - await expectAsync(instance.getFilePath('data.json')).toBeRejectedWithError( - 'No sync folder path configured after directory picker', - ); - }); - - it('should not infinitely recurse when no folder is configured', async () => { - // This is the key regression test for the infinite loop fix. - // Before the fix, _getFolderPath() and _checkDirAndOpenPickerIfNotExists() - // called each other endlessly when no syncFolderPath was set. - mockPrivateCfg.load.and.resolveTo(null); - mockEa.pickDirectory.and.resolveTo(undefined); - - await expectAsync(instance.getFilePath('data.json')).toBeRejected(); - - // privateCfg.load should be called a small number of times, not hundreds - expect(mockPrivateCfg.load.calls.count()).toBeLessThan(10); - // pickDirectory should be called exactly once - expect(mockEa.pickDirectory).toHaveBeenCalledTimes(1); - }); - - it('should use configured folder path without checking directory existence', async () => { - mockPrivateCfg.load.and.resolveTo({ syncFolderPath: '/configured/dir' }); - - const result = await instance.getFilePath('data.json'); - - expect(result).toBe('/configured/dir/data.json'); - expect(mockEa.pickDirectory).not.toHaveBeenCalled(); - expect(mockEa.checkDirExists).not.toHaveBeenCalled(); - }); - }); - - describe('pickDirectory', () => { - it('should save picked directory to config', async () => { - mockEa.pickDirectory.and.resolveTo('/new/dir'); - mockPrivateCfg.load.and.resolveTo(null); - mockPrivateCfg.upsertPartial.and.resolveTo(undefined); - - await instance.pickDirectory(); - - expect(mockPrivateCfg.upsertPartial).toHaveBeenCalledWith({ - syncFolderPath: '/new/dir', - }); - }); - - it('should not save when picker is cancelled', async () => { - mockEa.pickDirectory.and.resolveTo(undefined); - - await instance.pickDirectory(); - - expect(mockPrivateCfg.upsertPartial).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/src/app/op-log/sync-providers/file-based/local-file/local-file-sync-electron.ts b/src/app/op-log/sync-providers/file-based/local-file/local-file-sync-electron.ts index 282763afb2..dc8633cffa 100644 --- a/src/app/op-log/sync-providers/file-based/local-file/local-file-sync-electron.ts +++ b/src/app/op-log/sync-providers/file-based/local-file/local-file-sync-electron.ts @@ -1,114 +1,38 @@ -import { LocalFileSyncBase } from './local-file-sync-base'; +import { + LocalFileSyncElectron as PackageLocalFileSyncElectron, + type LocalFileSyncElectronDeps, +} from '@sp/sync-providers'; import { IS_ELECTRON } from '../../../../app.constants'; -import { SyncLog } from '../../../../core/log'; +import type { ElectronAPI } from '../../../../../../electron/electronAPI'; +import { OP_LOG_SYNC_LOGGER } from '../../../core/sync-logger.adapter'; +import { SyncCredentialStore } from '../../credential-store.service'; +import { SyncProviderId } from '../../provider.const'; import { ElectronFileAdapter } from './electron-file-adapter'; -import { LocalFileSyncPrivateCfg } from '../../../core/types/sync.types'; -export class LocalFileSyncElectron extends LocalFileSyncBase { - private static readonly L = 'LocalFileSyncElectron'; - - constructor() { - super(new ElectronFileAdapter()); +const getElectronApi = (): ElectronAPI => { + const maybeWindow = window as Window & { ea?: ElectronAPI }; + if (!maybeWindow.ea) { + throw new Error('Electron API is not available'); } + return maybeWindow.ea; +}; - async isReady(): Promise { - if (!IS_ELECTRON) { - throw new Error('LocalFileSyncElectron is only available in electron'); +const buildLocalFileSyncElectronDeps = (): LocalFileSyncElectronDeps => ({ + logger: OP_LOG_SYNC_LOGGER, + fileAdapter: new ElectronFileAdapter(), + credentialStore: new SyncCredentialStore( + SyncProviderId.LocalFile, + ) as LocalFileSyncElectronDeps['credentialStore'], + isElectron: IS_ELECTRON, + checkDirExists: async (dirPath) => { + const r = await getElectronApi().checkDirExists({ dirPath }); + if (r instanceof Error) { + throw r; } - const privateCfg = await this.privateCfg.load(); - return !!privateCfg?.syncFolderPath; - } + return r; + }, + pickDirectory: () => getElectronApi().pickDirectory(), +}); - async setPrivateCfg(privateCfg: LocalFileSyncPrivateCfg): Promise { - await this.privateCfg.setComplete(privateCfg); - } - - async getFilePath(targetPath: string): Promise { - const folderPath = await this._getFolderPath(); - // Normalize path: remove leading slash if present in targetPath - const normalizedPath = targetPath.startsWith('/') - ? targetPath.substring(1) - : targetPath; - return `${folderPath}/${normalizedPath}`; - } - - private async _checkDirAndOpenPickerIfNotExists(): Promise { - SyncLog.normal( - `${LocalFileSyncElectron.L}.${this._checkDirAndOpenPickerIfNotExists.name}`, - ); - - try { - // Read directly from config to avoid mutual recursion with _getFolderPath() - const privateCfg = await this.privateCfg.load(); - const folderPath = privateCfg?.syncFolderPath; - - if (!folderPath) { - SyncLog.critical( - `${LocalFileSyncElectron.L} - No sync folder configured, opening picker`, - ); - await this.pickDirectory(); - return; - } - - const isDirExists = await this._checkDirExists(folderPath); - if (!isDirExists) { - SyncLog.critical( - `${LocalFileSyncElectron.L} - No valid directory, opening picker`, - ); - await this.pickDirectory(); - } - } catch (err) { - SyncLog.error( - `${LocalFileSyncElectron.L}.${this._checkDirAndOpenPickerIfNotExists.name}() error`, - err, - ); - await this.pickDirectory(); - } - } - - private async _getFolderPath(): Promise { - const privateCfg = await this.privateCfg.load(); - const folderPath = privateCfg?.syncFolderPath; - if (!folderPath) { - await this._checkDirAndOpenPickerIfNotExists(); - const updatedCfg = await this.privateCfg.load(); - const updatedPath = updatedCfg?.syncFolderPath; - if (!updatedPath) { - throw new Error('No sync folder path configured after directory picker'); - } - return updatedPath; - } - return folderPath; - } - - private async _checkDirExists(dirPath: string): Promise { - try { - const r = await (window as any).ea.checkDirExists({ dirPath }); - if (r instanceof Error) { - throw r; - } - return r; - } catch (e) { - SyncLog.critical( - `${LocalFileSyncElectron.L}.${this._checkDirExists.name}() error`, - e, - ); - return false; - } - } - - async pickDirectory(): Promise { - SyncLog.normal(`${LocalFileSyncElectron.L}.pickDirectory()`); - - try { - const dir = await (window as any).ea.pickDirectory(); - if (dir) { - await this.privateCfg.upsertPartial({ syncFolderPath: dir }); - } - return dir; - } catch (e) { - SyncLog.critical(`${LocalFileSyncElectron.L}.pickDirectory() error`, e); - throw e; - } - } -} +export const createLocalFileSyncElectron = (): PackageLocalFileSyncElectron => + new PackageLocalFileSyncElectron(buildLocalFileSyncElectronDeps()); diff --git a/src/app/op-log/sync-providers/file-based/local-file/local-file-sync.spec.ts b/src/app/op-log/sync-providers/file-based/local-file/local-file-sync.spec.ts deleted file mode 100644 index babe39271d..0000000000 --- a/src/app/op-log/sync-providers/file-based/local-file/local-file-sync.spec.ts +++ /dev/null @@ -1,352 +0,0 @@ -import { LocalFileSyncBase } from './local-file-sync-base'; -import { FileAdapter } from './file-adapter.interface'; -import { SyncProviderId } from '../../provider.const'; -import { - InvalidDataSPError, - RemoteFileNotFoundAPIError, - UploadRevToMatchMismatchAPIError, -} from '../../../core/errors/sync-errors'; -import { PrivateCfgByProviderId } from '../../../core/types/sync.types'; - -/** - * Test implementation of LocalFileSyncBase for unit testing - */ -class TestableLocalFileSync extends LocalFileSyncBase { - private _isReady = true; - private _basePath = '/test/sync'; - - constructor(fileAdapter: FileAdapter) { - super(fileAdapter); - } - - async isReady(): Promise { - return this._isReady; - } - - setReadyState(isReady: boolean): void { - this._isReady = isReady; - } - - async setPrivateCfg( - privateCfg: PrivateCfgByProviderId, - ): Promise { - await this.privateCfg.setComplete(privateCfg); - } - - protected async getFilePath(targetPath: string): Promise { - // Simple path normalization for testing - const normalizedTarget = targetPath.startsWith('/') - ? targetPath.slice(1) - : targetPath; - return `${this._basePath}/${normalizedTarget}`; - } - - setBasePath(path: string): void { - this._basePath = path; - } -} - -/** - * Mock FileAdapter that stores files in memory - */ -class MockFileAdapter implements FileAdapter { - private _files = new Map(); - private _shouldFail = false; - private _failError: Error | null = null; - - async readFile(path: string): Promise { - if (this._shouldFail && this._failError) { - throw this._failError; - } - const content = this._files.get(path); - if (content === undefined) { - throw new Error('ENOENT: no such file or directory'); - } - return content; - } - - async writeFile(path: string, content: string): Promise { - if (this._shouldFail && this._failError) { - throw this._failError; - } - this._files.set(path, content); - } - - async deleteFile(path: string): Promise { - if (this._shouldFail && this._failError) { - throw this._failError; - } - if (!this._files.has(path)) { - throw new Error('ENOENT: File does not exist'); - } - this._files.delete(path); - } - - async listFiles(dirPath: string): Promise { - const files: string[] = []; - for (const path of this._files.keys()) { - if (path.startsWith(dirPath)) { - files.push(path); - } - } - return files; - } - - // Test helpers - setFile(path: string, content: string): void { - this._files.set(path, content); - } - - getFile(path: string): string | undefined { - return this._files.get(path); - } - - hasFile(path: string): boolean { - return this._files.has(path); - } - - clear(): void { - this._files.clear(); - } - - setFailure(error: Error | null): void { - this._shouldFail = error !== null; - this._failError = error; - } -} - -describe('LocalFileSyncBase', () => { - let fileAdapter: MockFileAdapter; - let localFileSync: TestableLocalFileSync; - - beforeEach(() => { - fileAdapter = new MockFileAdapter(); - localFileSync = new TestableLocalFileSync(fileAdapter); - }); - - describe('Basic Operations', () => { - it('should download file and return content with MD5 hash rev', async () => { - const testContent = 'test file content for sync'; - fileAdapter.setFile('/test/sync/sync-data.json', testContent); - - const result = await localFileSync.downloadFile('sync-data.json'); - - expect(result.dataStr).toBe(testContent); - expect(result.rev).toBeTruthy(); - // MD5 hash should be 32 chars hex - expect(result.rev.length).toBe(32); - expect(/^[a-f0-9]+$/.test(result.rev)).toBe(true); - }); - - it('should upload file and return MD5 hash rev', async () => { - const testContent = 'new file content to upload'; - - const result = await localFileSync.uploadFile('sync-data.json', testContent, null); - - expect(result.rev).toBeTruthy(); - expect(result.rev.length).toBe(32); - - // Verify file was written - expect(fileAdapter.getFile('/test/sync/sync-data.json')).toBe(testContent); - }); - - it('should return consistent rev for same content', async () => { - const testContent = 'consistent content for rev'; - fileAdapter.setFile('/test/sync/file1.json', testContent); - fileAdapter.setFile('/test/sync/file2.json', testContent); - - const result1 = await localFileSync.downloadFile('file1.json'); - const result2 = await localFileSync.downloadFile('file2.json'); - - // Same content = same MD5 hash - expect(result1.rev).toBe(result2.rev); - }); - - it('should remove file successfully', async () => { - fileAdapter.setFile('/test/sync/to-delete.json', 'content'); - - await localFileSync.removeFile('to-delete.json'); - - expect(fileAdapter.hasFile('/test/sync/to-delete.json')).toBe(false); - }); - }); - - describe('Error Handling', () => { - it('should throw RemoteFileNotFoundAPIError for missing file', async () => { - await expectAsync( - localFileSync.downloadFile('nonexistent.json'), - ).toBeRejectedWithError(RemoteFileNotFoundAPIError); - }); - - it('should throw RemoteFileNotFoundAPIError for empty file', async () => { - fileAdapter.setFile('/test/sync/empty.json', ''); - - await expectAsync(localFileSync.downloadFile('empty.json')).toBeRejectedWithError( - RemoteFileNotFoundAPIError, - ); - }); - - it('should throw InvalidDataSPError for file content too short', async () => { - fileAdapter.setFile('/test/sync/short.json', 'ab'); // 2 chars < 3 - - await expectAsync(localFileSync.downloadFile('short.json')).toBeRejectedWithError( - InvalidDataSPError, - ); - }); - - it('should ignore ENOENT errors when removing nonexistent file', async () => { - // Should not throw - await expectAsync(localFileSync.removeFile('nonexistent.json')).toBeResolved(); - }); - }); - - describe('Revision Matching', () => { - it('should succeed when revToMatch matches current file', async () => { - const initialContent = 'initial content'; - fileAdapter.setFile('/test/sync/sync-data.json', initialContent); - - // Get the rev of the current file - const current = await localFileSync.downloadFile('sync-data.json'); - - // Upload with matching rev - const newContent = 'updated content'; - const result = await localFileSync.uploadFile( - 'sync-data.json', - newContent, - current.rev, - ); - - expect(result.rev).toBeTruthy(); - expect(fileAdapter.getFile('/test/sync/sync-data.json')).toBe(newContent); - }); - - it('should throw UploadRevToMatchMismatchAPIError when rev does not match', async () => { - const initialContent = 'initial content'; - fileAdapter.setFile('/test/sync/sync-data.json', initialContent); - - const newContent = 'updated content'; - - await expectAsync( - localFileSync.uploadFile('sync-data.json', newContent, 'wrong-rev'), - ).toBeRejectedWithError(UploadRevToMatchMismatchAPIError); - - // File should not be modified - expect(fileAdapter.getFile('/test/sync/sync-data.json')).toBe(initialContent); - }); - - it('should allow upload without revToMatch (null)', async () => { - const newContent = 'content without rev check'; - - const result = await localFileSync.uploadFile('new-file.json', newContent, null); - - expect(result.rev).toBeTruthy(); - expect(fileAdapter.getFile('/test/sync/new-file.json')).toBe(newContent); - }); - - it('should allow upload with force overwrite regardless of rev', async () => { - const initialContent = 'initial content'; - fileAdapter.setFile('/test/sync/sync-data.json', initialContent); - - const newContent = 'force overwritten content'; - - // Force overwrite with wrong rev should succeed - const result = await localFileSync.uploadFile( - 'sync-data.json', - newContent, - 'wrong-rev', - true, // isForceOverwrite - ); - - expect(result.rev).toBeTruthy(); - expect(fileAdapter.getFile('/test/sync/sync-data.json')).toBe(newContent); - }); - - it('should handle file not existing when revToMatch is provided', async () => { - // File doesn't exist, but revToMatch is provided - // This is fine for upload (creates new file) - const newContent = 'new file with rev'; - - const result = await localFileSync.uploadFile( - 'nonexistent.json', - newContent, - 'some-rev', - ); - - expect(result.rev).toBeTruthy(); - expect(fileAdapter.getFile('/test/sync/nonexistent.json')).toBe(newContent); - }); - }); - - describe('Path Normalization', () => { - it('should handle paths with leading slash', async () => { - const content = 'test content'; - fileAdapter.setFile('/test/sync/file.json', content); - - // With leading slash - const result = await localFileSync.downloadFile('/file.json'); - expect(result.dataStr).toBe(content); - }); - - it('should handle paths without leading slash', async () => { - const content = 'test content'; - fileAdapter.setFile('/test/sync/file.json', content); - - // Without leading slash - const result = await localFileSync.downloadFile('file.json'); - expect(result.dataStr).toBe(content); - }); - - it('should handle nested paths', async () => { - const content = 'nested content'; - fileAdapter.setFile('/test/sync/subdir/file.json', content); - - const result = await localFileSync.downloadFile('subdir/file.json'); - expect(result.dataStr).toBe(content); - }); - }); - - describe('isReady', () => { - it('should return true when ready', async () => { - localFileSync.setReadyState(true); - expect(await localFileSync.isReady()).toBe(true); - }); - - it('should return false when not ready', async () => { - localFileSync.setReadyState(false); - expect(await localFileSync.isReady()).toBe(false); - }); - }); - - describe('getFileRev', () => { - it('should return rev from file content', async () => { - const content = 'content for rev check'; - fileAdapter.setFile('/test/sync/file.json', content); - - const { rev } = await localFileSync.getFileRev('file.json', 'any-local-rev'); - - // Should match the rev from downloading - const downloaded = await localFileSync.downloadFile('file.json'); - expect(rev).toBe(downloaded.rev); - }); - - it('should throw RemoteFileNotFoundAPIError for missing file', async () => { - await expectAsync( - localFileSync.getFileRev('missing.json', 'any-rev'), - ).toBeRejectedWithError(RemoteFileNotFoundAPIError); - }); - }); - - describe('listFiles', () => { - it('should list files in directory', async () => { - fileAdapter.setFile('/test/sync/file1.json', 'content1'); - fileAdapter.setFile('/test/sync/file2.json', 'content2'); - fileAdapter.setFile('/test/sync/subdir/file3.json', 'content3'); - - const files = await localFileSync.listFiles('/'); - - expect(files.length).toBe(3); - expect(files).toContain('/test/sync/file1.json'); - expect(files).toContain('/test/sync/file2.json'); - }); - }); -}); diff --git a/src/app/op-log/sync-providers/sync-providers.factory.ts b/src/app/op-log/sync-providers/sync-providers.factory.ts index 6f07d534ab..e235398c78 100644 --- a/src/app/op-log/sync-providers/sync-providers.factory.ts +++ b/src/app/op-log/sync-providers/sync-providers.factory.ts @@ -57,15 +57,15 @@ const _createProviders = async (): Promise[]> = ]; if (IS_ELECTRON) { - const { LocalFileSyncElectron } = + const { createLocalFileSyncElectron } = await import('./file-based/local-file/local-file-sync-electron'); - providers.push(new LocalFileSyncElectron() as SyncProviderBase); + providers.push(createLocalFileSyncElectron() as SyncProviderBase); } if (IS_ANDROID_WEB_VIEW) { - const { LocalFileSyncAndroid } = + const { createLocalFileSyncAndroid } = await import('./file-based/local-file/local-file-sync-android'); - providers.push(new LocalFileSyncAndroid() as SyncProviderBase); + providers.push(createLocalFileSyncAndroid() as SyncProviderBase); } return providers; diff --git a/src/app/util/md5-hash.ts b/src/app/util/md5-hash.ts deleted file mode 100644 index 296319cb74..0000000000 --- a/src/app/util/md5-hash.ts +++ /dev/null @@ -1,14 +0,0 @@ -import spark from 'spark-md5'; - -export const md5HashSync = (str: string): string => { - return spark.hash(str); -}; - -export const md5HashPromise = (str: string): Promise => { - return new Promise((resolve) => { - window.setTimeout(() => { - const hash = spark.hash(str); - resolve(hash); - }, 0); - }); -};