mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
refactor(sync-providers): extract local file provider
This commit is contained in:
parent
0b597a1f44
commit
e4f9a4e2e5
24 changed files with 996 additions and 899 deletions
|
|
@ -50,7 +50,6 @@
|
|||
"stack-generator",
|
||||
"stacktrace-gps",
|
||||
"error-stack-parser",
|
||||
"spark-md5",
|
||||
"chrono-node",
|
||||
"dayjs",
|
||||
"query-string"
|
||||
|
|
|
|||
|
|
@ -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
8
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue