refactor(sync-providers): extract local file provider

This commit is contained in:
Johannes Millan 2026-05-13 11:28:08 +02:00
parent 0b597a1f44
commit e4f9a4e2e5
24 changed files with 996 additions and 899 deletions

View file

@ -50,7 +50,6 @@
"stack-generator",
"stacktrace-gps",
"error-stack-parser",
"spark-md5",
"chrono-node",
"dayjs",
"query-string"

View file

@ -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

8
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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;

View file

@ -0,0 +1,47 @@
import { toSyncLogError } from '@sp/sync-core';
import { LocalFileSyncBase, type LocalFileSyncBaseDeps } from './local-file-sync-base';
interface LocalFileSafPort {
selectFolder(): Promise<string>;
checkPermission(uri?: string): Promise<boolean>;
}
export interface LocalFileSyncAndroidDeps extends LocalFileSyncBaseDeps {
saf: LocalFileSafPort;
}
export class LocalFileSyncAndroid extends LocalFileSyncBase {
constructor(private readonly _deps: LocalFileSyncAndroidDeps) {
super(_deps);
}
async isReady(): Promise<boolean> {
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<string> {
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<string> {
return targetPath;
}
}

View file

@ -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<boolean>;
async setPrivateCfg(privateCfg: LocalFileSyncPrivateCfg): Promise<void> {
await this.privateCfg.setComplete(privateCfg);
}
protected abstract getFilePath(targetPath: string): Promise<string>;
async listFiles(dirPath: string): Promise<string[]> {
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<void> {
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<string> {
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');
}
}

View file

@ -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<boolean>;
pickDirectory: () => Promise<string | void>;
}
export class LocalFileSyncElectron extends LocalFileSyncBase {
private static readonly L = 'LocalFileSyncElectron';
constructor(private readonly _deps: LocalFileSyncElectronDeps) {
super(_deps);
}
async isReady(): Promise<boolean> {
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<string> {
const folderPath = await this._getFolderPath();
const normalizedPath = targetPath.startsWith('/')
? targetPath.substring(1)
: targetPath;
return `${folderPath}/${normalizedPath}`;
}
async pickDirectory(): Promise<string | void> {
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<void> {
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<string> {
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<boolean> {
try {
return await this._deps.checkDirExists(dirPath);
} catch (e) {
this.logger.error(
`${LocalFileSyncElectron.L}.${this._checkDirExists.name}() error`,
toSyncLogError(e),
);
return false;
}
}
}

View file

@ -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;
}

View file

@ -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,

View file

@ -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],

View file

@ -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<typeof PROVIDER_ID_LOCAL_FILE, LocalFileSyncPrivateCfg> => {
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<typeof PROVIDER_ID_LOCAL_FILE, LocalFileSyncPrivateCfg>,
overrides: Partial<LocalFileSyncAndroidDeps['saf']> = {},
): {
provider: LocalFileSyncAndroid;
selectFolder: ReturnType<typeof vi.fn<() => Promise<string>>>;
checkPermission: ReturnType<typeof vi.fn<(uri?: string) => Promise<boolean>>>;
} => {
const selectFolder = vi.fn<() => Promise<string>>().mockResolvedValue('content://uri');
const checkPermission = vi
.fn<(uri?: string) => Promise<boolean>>()
.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<boolean>>()
.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',
);
});
});

View file

@ -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<typeof import('hash-wasm')>();
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<boolean> {
return this._isReady;
}
setReadyState(isReady: boolean): void {
this._isReady = isReady;
}
protected async getFilePath(targetPath: string): Promise<string> {
const normalizedTarget = targetPath.startsWith('/')
? targetPath.slice(1)
: targetPath;
return `${this._basePath}/${normalizedTarget}`;
}
}
class MockFileAdapter implements FileAdapter {
private _files = new Map<string, string>();
private _failError: Error | null = null;
async readFile(path: string): Promise<string> {
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<void> {
if (this._failError) {
throw this._failError;
}
this._files.set(path, content);
}
async deleteFile(path: string): Promise<void> {
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<string[]> {
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<typeof PROVIDER_ID_LOCAL_FILE, LocalFileSyncPrivateCfg> => {
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();
});
});

View file

@ -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<typeof PROVIDER_ID_LOCAL_FILE, LocalFileSyncPrivateCfg> => {
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<LocalFileSyncElectronDeps> = {},
): {
provider: LocalFileSyncElectron;
store: SyncCredentialStorePort<typeof PROVIDER_ID_LOCAL_FILE, LocalFileSyncPrivateCfg>;
pickDirectory: ReturnType<typeof vi.fn<() => Promise<string | void>>>;
checkDirExists: ReturnType<typeof vi.fn<(dirPath: string) => Promise<boolean>>>;
} => {
const store = fakeStore(null);
const pickDirectory = vi
.fn<() => Promise<string | void>>()
.mockResolvedValue('/picked/dir');
const checkDirExists = vi
.fn<(dirPath: string) => Promise<boolean>>()
.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<string | void>>
>,
checkDirExists: deps.checkDirExists as ReturnType<
typeof vi.fn<(dirPath: string) => Promise<boolean>>
>,
};
};
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<string | void>>().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);
});
});

View file

@ -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',

View file

@ -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';

View file

@ -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

View file

@ -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<boolean> {
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<string> {
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<void> {
await this.privateCfg.setComplete(privateCfg);
}
async getFilePath(targetPath: string): Promise<string> {
return targetPath;
}
}
export const createLocalFileSyncAndroid = (): PackageLocalFileSyncAndroid =>
new PackageLocalFileSyncAndroid(buildLocalFileSyncAndroidDeps());

View file

@ -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<SyncProviderId.LocalFile> {
private static readonly LB = 'LocalFileSyncBase';
readonly id = SyncProviderId.LocalFile;
readonly isUploadForcePossible: boolean = false;
readonly maxConcurrentRequests = 10;
public privateCfg: SyncCredentialStore<SyncProviderId.LocalFile>;
protected constructor(protected fileAdapter: FileAdapter) {
this.privateCfg = new SyncCredentialStore(SyncProviderId.LocalFile);
}
abstract isReady(): Promise<boolean>;
abstract setPrivateCfg(
privateCfg: PrivateCfgByProviderId<SyncProviderId.LocalFile>,
): Promise<void>;
protected abstract getFilePath(targetPath: string): Promise<string>;
async listFiles(dirPath: string): Promise<string[]> {
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<void> {
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<string> {
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);
}
}
}

View file

@ -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<SyncCredentialStore<SyncProviderId.LocalFile>>;
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();
});
});
});

View file

@ -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<boolean> {
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<void> {
await this.privateCfg.setComplete(privateCfg);
}
async getFilePath(targetPath: string): Promise<string> {
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<void> {
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<string> {
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<boolean> {
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<string | void> {
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());

View file

@ -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<boolean> {
return this._isReady;
}
setReadyState(isReady: boolean): void {
this._isReady = isReady;
}
async setPrivateCfg(
privateCfg: PrivateCfgByProviderId<SyncProviderId.LocalFile>,
): Promise<void> {
await this.privateCfg.setComplete(privateCfg);
}
protected async getFilePath(targetPath: string): Promise<string> {
// 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<string, string>();
private _shouldFail = false;
private _failError: Error | null = null;
async readFile(path: string): Promise<string> {
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<void> {
if (this._shouldFail && this._failError) {
throw this._failError;
}
this._files.set(path, content);
}
async deleteFile(path: string): Promise<void> {
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<string[]> {
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');
});
});
});

View file

@ -57,15 +57,15 @@ const _createProviders = async (): Promise<SyncProviderBase<SyncProviderId>[]> =
];
if (IS_ELECTRON) {
const { LocalFileSyncElectron } =
const { createLocalFileSyncElectron } =
await import('./file-based/local-file/local-file-sync-electron');
providers.push(new LocalFileSyncElectron() as SyncProviderBase<SyncProviderId>);
providers.push(createLocalFileSyncElectron() as SyncProviderBase<SyncProviderId>);
}
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<SyncProviderId>);
providers.push(createLocalFileSyncAndroid() as SyncProviderBase<SyncProviderId>);
}
return providers;

View file

@ -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<string> => {
return new Promise((resolve) => {
window.setTimeout(() => {
const hash = spark.hash(str);
resolve(hash);
}, 0);
});
};