mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-01-22 18:30:09 +00:00
744 lines
28 KiB
TypeScript
744 lines
28 KiB
TypeScript
import { inject, Injectable } from '@angular/core';
|
|
import { BehaviorSubject, firstValueFrom, Observable, of } from 'rxjs';
|
|
import { GlobalConfigService } from '../../features/config/global-config.service';
|
|
import {
|
|
distinctUntilChanged,
|
|
filter,
|
|
first,
|
|
map,
|
|
shareReplay,
|
|
switchMap,
|
|
take,
|
|
timeout,
|
|
} from 'rxjs/operators';
|
|
import { toObservable } from '@angular/core/rxjs-interop';
|
|
import {
|
|
SyncAlreadyInProgressError,
|
|
LocalDataConflictError,
|
|
} from '../../op-log/core/errors/sync-errors';
|
|
import { SyncConfig } from '../../features/config/global-config.model';
|
|
import { TranslateService } from '@ngx-translate/core';
|
|
import { MatDialog, MatDialogRef } from '@angular/material/dialog';
|
|
import { SnackService } from '../../core/snack/snack.service';
|
|
import {
|
|
AuthFailSPError,
|
|
CanNotMigrateMajorDownError,
|
|
ConflictData,
|
|
ConflictReason,
|
|
DecryptError,
|
|
DecryptNoPasswordError,
|
|
LockPresentError,
|
|
NoRemoteModelFile,
|
|
PotentialCorsError,
|
|
RevMismatchForModelError,
|
|
SyncInvalidTimeValuesError,
|
|
SyncProviderId,
|
|
SyncStatus,
|
|
toSyncProviderId,
|
|
} from '../../op-log/sync-exports';
|
|
import { SyncProviderManager } from '../../op-log/sync-providers/provider-manager.service';
|
|
import { LegacyPfDbService } from '../../core/persistence/legacy-pf-db.service';
|
|
import { T } from '../../t.const';
|
|
import { getSyncErrorStr } from './get-sync-error-str';
|
|
import { DialogGetAndEnterAuthCodeComponent } from './dialog-get-and-enter-auth-code/dialog-get-and-enter-auth-code.component';
|
|
import { DialogConflictResolutionResult } from './sync.model';
|
|
import { DialogSyncConflictComponent } from './dialog-sync-conflict/dialog-sync-conflict.component';
|
|
import { ReminderService } from '../../features/reminder/reminder.service';
|
|
import { DataInitService } from '../../core/data-init/data-init.service';
|
|
import { DialogSyncInitialCfgComponent } from './dialog-sync-initial-cfg/dialog-sync-initial-cfg.component';
|
|
import { DialogIncompleteSyncComponent } from './dialog-incomplete-sync/dialog-incomplete-sync.component';
|
|
import { DialogHandleDecryptErrorComponent } from './dialog-handle-decrypt-error/dialog-handle-decrypt-error.component';
|
|
import { DialogIncoherentTimestampsErrorComponent } from './dialog-incoherent-timestamps-error/dialog-incoherent-timestamps-error.component';
|
|
import { SyncLog } from '../../core/log';
|
|
import { promiseTimeout } from '../../util/promise-timeout';
|
|
import { devError } from '../../util/dev-error';
|
|
import { UserInputWaitStateService } from './user-input-wait-state.service';
|
|
import { LegacySyncProvider } from './legacy-sync-provider.model';
|
|
import { SYNC_WAIT_TIMEOUT_MS, SYNC_REINIT_DELAY_MS } from './sync.const';
|
|
import { SuperSyncStatusService } from '../../op-log/sync/super-sync-status.service';
|
|
import { IS_ELECTRON } from '../../app.constants';
|
|
import { OperationLogStoreService } from '../../op-log/persistence/operation-log-store.service';
|
|
import { OperationLogSyncService } from '../../op-log/sync/operation-log-sync.service';
|
|
import { WrappedProviderService } from '../../op-log/sync-providers/wrapped-provider.service';
|
|
|
|
@Injectable({
|
|
providedIn: 'root',
|
|
})
|
|
export class SyncWrapperService {
|
|
private _providerManager = inject(SyncProviderManager);
|
|
private _legacyPfDb = inject(LegacyPfDbService);
|
|
private _globalConfigService = inject(GlobalConfigService);
|
|
private _translateService = inject(TranslateService);
|
|
private _snackService = inject(SnackService);
|
|
private _matDialog = inject(MatDialog);
|
|
private _dataInitService = inject(DataInitService);
|
|
private _reminderService = inject(ReminderService);
|
|
private _userInputWaitState = inject(UserInputWaitStateService);
|
|
private _superSyncStatusService = inject(SuperSyncStatusService);
|
|
private _opLogStore = inject(OperationLogStoreService);
|
|
private _opLogSyncService = inject(OperationLogSyncService);
|
|
private _wrappedProvider = inject(WrappedProviderService);
|
|
|
|
syncState$ = this._providerManager.syncStatus$;
|
|
|
|
syncCfg$: Observable<SyncConfig> = this._globalConfigService.cfg$.pipe(
|
|
map((cfg) => cfg?.sync),
|
|
);
|
|
syncProviderId$: Observable<SyncProviderId | null> = this.syncCfg$.pipe(
|
|
map((cfg) => toSyncProviderId(cfg.syncProvider)),
|
|
);
|
|
|
|
// SuperSync always uses 1 minute interval; other providers use configured value
|
|
// Return 0 when manual sync only is enabled to disable automatic triggers
|
|
syncInterval$: Observable<number> = this.syncCfg$.pipe(
|
|
map((cfg) => {
|
|
if (cfg.isManualSyncOnly) return 0;
|
|
return cfg.syncProvider === LegacySyncProvider.SuperSync ? 60000 : cfg.syncInterval;
|
|
}),
|
|
);
|
|
|
|
isEnabledAndReady$: Observable<boolean> = this._providerManager.isProviderReady$;
|
|
|
|
// NOTE we don't use this._pfapiService.isSyncInProgress$ since it does not include handling and re-init view model
|
|
private _isSyncInProgress$ = new BehaviorSubject(false);
|
|
isSyncInProgress$ = this._isSyncInProgress$.asObservable();
|
|
|
|
/**
|
|
* Observable for UI: true when all local changes have been uploaded.
|
|
* Used for all sync providers to show the single checkmark indicator.
|
|
*/
|
|
hasNoPendingOps$: Observable<boolean> = toObservable(
|
|
this._superSyncStatusService.hasNoPendingOps,
|
|
).pipe(distinctUntilChanged(), shareReplay(1));
|
|
|
|
/**
|
|
* Observable for UI: true when sync is confirmed fully in sync
|
|
* (no pending ops AND remote recently checked within 1 minute).
|
|
* Used for all sync providers to show the double checkmark indicator.
|
|
*/
|
|
superSyncIsConfirmedInSync$: Observable<boolean> = toObservable(
|
|
this._superSyncStatusService.isConfirmedInSync,
|
|
).pipe(distinctUntilChanged(), shareReplay(1));
|
|
|
|
isSyncInProgressSync(): boolean {
|
|
return this._isSyncInProgress$.getValue();
|
|
}
|
|
|
|
// Expose shared user input wait state for other services (e.g., SyncTriggerService)
|
|
isWaitingForUserInput$ = this._userInputWaitState.isWaitingForUserInput$;
|
|
|
|
afterCurrentSyncDoneOrSyncDisabled$: Observable<unknown> = this.isEnabledAndReady$.pipe(
|
|
switchMap((isEnabled) =>
|
|
isEnabled
|
|
? this._isSyncInProgress$.pipe(
|
|
filter((isInProgress) => !isInProgress),
|
|
timeout({
|
|
each: SYNC_WAIT_TIMEOUT_MS,
|
|
with: () =>
|
|
// If waiting for user input, don't error - just wait indefinitely
|
|
this._userInputWaitState.isWaitingForUserInput$.pipe(
|
|
switchMap((isWaiting) => {
|
|
if (isWaiting) {
|
|
// Continue waiting for sync to complete (no timeout)
|
|
return this._isSyncInProgress$.pipe(
|
|
filter((isInProgress) => !isInProgress),
|
|
);
|
|
}
|
|
devError('Sync wait timeout exceeded');
|
|
return of(undefined);
|
|
}),
|
|
),
|
|
}),
|
|
)
|
|
: of(undefined),
|
|
),
|
|
first(),
|
|
);
|
|
|
|
async sync(): Promise<SyncStatus | 'HANDLED_ERROR'> {
|
|
// Race condition fix: Check-and-set atomically before starting sync
|
|
if (this._isSyncInProgress$.getValue()) {
|
|
SyncLog.log('Sync already in progress, skipping concurrent sync attempt');
|
|
return 'HANDLED_ERROR';
|
|
}
|
|
this._isSyncInProgress$.next(true);
|
|
// Set SYNCING status so ImmediateUploadService knows not to interfere
|
|
this._providerManager.setSyncStatus('SYNCING');
|
|
return this._sync().finally(() => {
|
|
this._isSyncInProgress$.next(false);
|
|
// Safeguard: if _sync() threw or completed without setting a final status,
|
|
// reset from SYNCING to UNKNOWN_OR_CHANGED to avoid getting stuck in SYNCING state
|
|
if (this._providerManager.isSyncInProgress) {
|
|
this._providerManager.setSyncStatus('UNKNOWN_OR_CHANGED');
|
|
}
|
|
});
|
|
}
|
|
|
|
private async _sync(): Promise<SyncStatus | 'HANDLED_ERROR'> {
|
|
const providerId = await this.syncProviderId$.pipe(take(1)).toPromise();
|
|
if (!providerId) {
|
|
throw new Error('No Sync Provider for sync()');
|
|
}
|
|
|
|
try {
|
|
// PERF: For legacy sync providers (WebDAV, Dropbox, LocalFile), sync the vector clock
|
|
// from SUP_OPS to pf.META_MODEL before sync. This bridges the gap between the new
|
|
// atomic write system (vector clock in SUP_OPS) and legacy sync which reads from pf.
|
|
// SuperSync uses operation log directly, so it doesn't need this bridge.
|
|
if (providerId !== SyncProviderId.SuperSync) {
|
|
await this._syncVectorClockToPfapi();
|
|
}
|
|
|
|
// Get the sync-capable version of the provider
|
|
// - SuperSync: returned as-is (already implements OperationSyncCapable)
|
|
// - File-based (Dropbox, WebDAV, LocalFile): wrapped with FileBasedSyncAdapterService
|
|
const rawProvider = this._providerManager.getActiveProvider();
|
|
const syncCapableProvider =
|
|
await this._wrappedProvider.getOperationSyncCapable(rawProvider);
|
|
|
|
if (!syncCapableProvider) {
|
|
SyncLog.warn('SyncWrapperService: Provider does not support operation sync');
|
|
return SyncStatus.InSync;
|
|
}
|
|
|
|
// Perform actual sync: download first, then upload
|
|
SyncLog.log('SyncWrapperService: Starting op-log sync...');
|
|
|
|
// 1. Download remote ops first (important for fresh clients to receive data)
|
|
const downloadResult =
|
|
await this._opLogSyncService.downloadRemoteOps(syncCapableProvider);
|
|
SyncLog.log(
|
|
`SyncWrapperService: Download complete. newOps=${downloadResult.newOpsCount}, migration=${downloadResult.serverMigrationHandled}`,
|
|
);
|
|
|
|
// If user cancelled the sync import conflict dialog, skip upload entirely.
|
|
// This keeps the local state unchanged and doesn't push it to the server.
|
|
if (downloadResult.cancelled) {
|
|
SyncLog.log('SyncWrapperService: Sync cancelled by user. Skipping upload phase.');
|
|
this._providerManager.setSyncStatus('UNKNOWN_OR_CHANGED');
|
|
return SyncStatus.NotConfigured;
|
|
}
|
|
|
|
// 2. Upload pending local ops
|
|
const uploadResult =
|
|
await this._opLogSyncService.uploadPendingOps(syncCapableProvider);
|
|
if (uploadResult) {
|
|
SyncLog.log(
|
|
`SyncWrapperService: Upload complete. uploaded=${uploadResult.uploadedCount}, piggybacked=${uploadResult.piggybackedOps.length}`,
|
|
);
|
|
}
|
|
|
|
// 3. If LWW created local-win ops, upload them
|
|
const totalLocalWinOps =
|
|
(downloadResult.localWinOpsCreated ?? 0) +
|
|
(uploadResult?.localWinOpsCreated ?? 0);
|
|
if (totalLocalWinOps > 0) {
|
|
SyncLog.log(
|
|
`SyncWrapperService: Re-uploading ${totalLocalWinOps} local-win op(s) from LWW...`,
|
|
);
|
|
await this._opLogSyncService.uploadPendingOps(syncCapableProvider);
|
|
}
|
|
|
|
// 4. Check for rejected full-state ops - this is a critical failure that should
|
|
// NOT be reported as "in sync" to the user
|
|
if (uploadResult?.rejectedCount && uploadResult.rejectedCount > 0) {
|
|
const hasPayloadError = uploadResult.rejectedOps?.some(
|
|
(r) =>
|
|
r.error?.includes('Payload too complex') ||
|
|
r.error?.includes('Payload too large'),
|
|
);
|
|
|
|
if (hasPayloadError) {
|
|
SyncLog.err(
|
|
'SyncWrapperService: Upload rejected - payload too large/complex',
|
|
uploadResult.rejectedOps,
|
|
);
|
|
this._providerManager.setSyncStatus('ERROR');
|
|
// Use alert for maximum visibility - this is a critical error
|
|
alert(this._translateService.instant(T.F.SYNC.S.ERROR_PAYLOAD_TOO_LARGE));
|
|
return 'HANDLED_ERROR';
|
|
}
|
|
|
|
// Other rejections - still shouldn't claim success
|
|
SyncLog.err(
|
|
'SyncWrapperService: Upload had rejected operations, not marking as IN_SYNC',
|
|
uploadResult.rejectedOps,
|
|
);
|
|
this._providerManager.setSyncStatus('ERROR');
|
|
return 'HANDLED_ERROR';
|
|
}
|
|
|
|
// Mark as in-sync for all providers after successful sync
|
|
// This indicates the sync operation completed successfully
|
|
this._providerManager.setSyncStatus('IN_SYNC');
|
|
SyncLog.log('SyncWrapperService: Sync complete, status=IN_SYNC');
|
|
return SyncStatus.InSync;
|
|
} catch (error) {
|
|
SyncLog.err(error);
|
|
|
|
if (error instanceof PotentialCorsError) {
|
|
this._snackService.open({
|
|
msg: T.F.SYNC.S.ERROR_CORS,
|
|
type: 'ERROR',
|
|
// a bit longer since it is a long message
|
|
config: { duration: 12000 },
|
|
});
|
|
return 'HANDLED_ERROR';
|
|
} else if (error instanceof AuthFailSPError) {
|
|
this._snackService.open({
|
|
msg: T.F.SYNC.S.INCOMPLETE_CFG,
|
|
type: 'ERROR',
|
|
actionFn: async () => this._matDialog.open(DialogSyncInitialCfgComponent),
|
|
actionStr: T.F.SYNC.S.BTN_CONFIGURE,
|
|
});
|
|
return 'HANDLED_ERROR';
|
|
} else if (error instanceof SyncInvalidTimeValuesError) {
|
|
// Handle async dialog result properly to avoid silent error swallowing
|
|
this._handleIncoherentTimestampsDialog();
|
|
return 'HANDLED_ERROR';
|
|
} else if (
|
|
error instanceof RevMismatchForModelError ||
|
|
error instanceof NoRemoteModelFile
|
|
) {
|
|
SyncLog.log(error, Object.keys(error));
|
|
// Extract modelId safely with proper type validation
|
|
const modelId = this._extractModelIdFromError(error);
|
|
// Handle async dialog result properly to avoid silent error swallowing
|
|
this._handleIncompleteSyncDialog(modelId);
|
|
return 'HANDLED_ERROR';
|
|
} else if (error instanceof LockPresentError) {
|
|
this._snackService.open({
|
|
// TODO translate
|
|
msg: T.F.SYNC.S.ERROR_DATA_IS_CURRENTLY_WRITTEN,
|
|
type: 'ERROR',
|
|
actionFn: async () => this.forceUpload(),
|
|
actionStr: T.F.SYNC.S.BTN_FORCE_OVERWRITE,
|
|
});
|
|
return 'HANDLED_ERROR';
|
|
} else if (
|
|
error instanceof DecryptNoPasswordError ||
|
|
error instanceof DecryptError
|
|
) {
|
|
this._handleDecryptionError();
|
|
return 'HANDLED_ERROR';
|
|
} else if (error instanceof CanNotMigrateMajorDownError) {
|
|
// Log warning instead of alert - user can't do anything about version mismatch during import
|
|
SyncLog.warn(
|
|
'Remote model version newer than local - app update may be required',
|
|
);
|
|
return 'HANDLED_ERROR';
|
|
} else if (error instanceof SyncAlreadyInProgressError) {
|
|
// Silently ignore concurrent sync attempts (using proper error class)
|
|
SyncLog.log('Sync already in progress, skipping concurrent sync attempt');
|
|
return 'HANDLED_ERROR';
|
|
} else if (error instanceof LocalDataConflictError) {
|
|
// File-based sync: Local data exists and remote snapshot would overwrite it
|
|
// Show conflict dialog to let user choose between local and remote data
|
|
return this._handleLocalDataConflict(error);
|
|
} else if (this._isPermissionError(error)) {
|
|
this._snackService.open({
|
|
msg: this._getPermissionErrorMessage(),
|
|
type: 'ERROR',
|
|
config: { duration: 12000 },
|
|
});
|
|
return 'HANDLED_ERROR';
|
|
} else {
|
|
const errStr = getSyncErrorStr(error);
|
|
this._snackService.open({
|
|
// msg: T.F.SYNC.S.UNKNOWN_ERROR,
|
|
msg: errStr,
|
|
type: 'ERROR',
|
|
translateParams: {
|
|
err: errStr,
|
|
},
|
|
});
|
|
return 'HANDLED_ERROR';
|
|
}
|
|
}
|
|
}
|
|
|
|
async forceUpload(): Promise<void> {
|
|
if (!this._c(this._translateService.instant(T.F.SYNC.C.FORCE_UPLOAD))) {
|
|
return;
|
|
}
|
|
|
|
SyncLog.log('SyncWrapperService: forceUpload called - uploading local state');
|
|
|
|
try {
|
|
const rawProvider = this._providerManager.getActiveProvider();
|
|
const syncCapableProvider =
|
|
await this._wrappedProvider.getOperationSyncCapable(rawProvider);
|
|
|
|
if (!syncCapableProvider) {
|
|
SyncLog.warn('SyncWrapperService: Cannot force upload - provider not available');
|
|
return;
|
|
}
|
|
|
|
await this._opLogSyncService.forceUploadLocalState(syncCapableProvider);
|
|
this._providerManager.setSyncStatus('IN_SYNC');
|
|
SyncLog.log('SyncWrapperService: Force upload complete');
|
|
} catch (error) {
|
|
SyncLog.err('SyncWrapperService: Force upload failed:', error);
|
|
const errStr = getSyncErrorStr(error);
|
|
this._snackService.open({
|
|
msg: errStr,
|
|
type: 'ERROR',
|
|
});
|
|
}
|
|
}
|
|
|
|
async configuredAuthForSyncProviderIfNecessary(
|
|
providerId: SyncProviderId,
|
|
): Promise<{ wasConfigured: boolean }> {
|
|
const provider = this._providerManager.getProviderById(providerId);
|
|
|
|
if (!provider) {
|
|
return { wasConfigured: false };
|
|
}
|
|
|
|
if (!provider.getAuthHelper) {
|
|
return { wasConfigured: false };
|
|
}
|
|
|
|
if (await provider.isReady()) {
|
|
SyncLog.warn('Provider already configured');
|
|
return { wasConfigured: false };
|
|
}
|
|
|
|
try {
|
|
const { authUrl, codeVerifier, verifyCodeChallenge } =
|
|
await provider.getAuthHelper();
|
|
if (authUrl && codeVerifier && verifyCodeChallenge) {
|
|
const authCode = await this._matDialog
|
|
.open(DialogGetAndEnterAuthCodeComponent, {
|
|
restoreFocus: true,
|
|
data: {
|
|
providerName: provider.id,
|
|
url: authUrl,
|
|
},
|
|
})
|
|
.afterClosed()
|
|
.toPromise();
|
|
if (authCode) {
|
|
const r = await verifyCodeChallenge(authCode);
|
|
// Preserve existing config (especially encryptKey) when updating auth
|
|
const existingConfig = await provider.privateCfg.load();
|
|
await this._providerManager.setProviderConfig(provider.id, {
|
|
...existingConfig,
|
|
...r,
|
|
});
|
|
// NOTE: exec sync afterward; promise not awaited
|
|
setTimeout(() => {
|
|
this.sync();
|
|
}, 1000);
|
|
return { wasConfigured: true };
|
|
} else {
|
|
return { wasConfigured: false };
|
|
}
|
|
}
|
|
} catch (error) {
|
|
SyncLog.err(error);
|
|
this._snackService.open({
|
|
// TODO don't limit snack to dropbox
|
|
msg: T.F.DROPBOX.S.UNABLE_TO_GENERATE_PKCE_CHALLENGE,
|
|
type: 'ERROR',
|
|
});
|
|
return { wasConfigured: false };
|
|
}
|
|
return { wasConfigured: false };
|
|
}
|
|
|
|
/**
|
|
* Handle incoherent timestamps dialog with proper async error handling.
|
|
* Uses fire-and-forget pattern but logs errors instead of swallowing them.
|
|
*/
|
|
private _handleIncoherentTimestampsDialog(): void {
|
|
const dialogRef = this._matDialog.open(DialogIncoherentTimestampsErrorComponent, {
|
|
disableClose: true,
|
|
autoFocus: false,
|
|
});
|
|
|
|
// Use firstValueFrom for proper async handling
|
|
firstValueFrom(dialogRef.afterClosed())
|
|
.then(async (res) => {
|
|
if (res === 'FORCE_UPDATE_REMOTE') {
|
|
await this.forceUpload();
|
|
} else if (res === 'FORCE_UPDATE_LOCAL') {
|
|
// Op-log architecture handles this differently
|
|
SyncLog.log(
|
|
'SyncWrapperService: forceDownload called (delegated to op-log sync)',
|
|
);
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
SyncLog.err('Error handling incoherent timestamps dialog result:', err);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handle incomplete sync dialog with proper async error handling.
|
|
* Uses fire-and-forget pattern but logs errors instead of swallowing them.
|
|
*/
|
|
private _handleIncompleteSyncDialog(modelId: string | undefined): void {
|
|
const dialogRef = this._matDialog.open(DialogIncompleteSyncComponent, {
|
|
data: { modelId },
|
|
disableClose: true,
|
|
autoFocus: false,
|
|
});
|
|
|
|
// Use firstValueFrom for proper async handling
|
|
firstValueFrom(dialogRef.afterClosed())
|
|
.then(async (res) => {
|
|
if (res === 'FORCE_UPDATE_REMOTE') {
|
|
await this.forceUpload();
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
SyncLog.err('Error handling incomplete sync dialog result:', err);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Safely extract modelId from error with proper type validation.
|
|
*/
|
|
private _extractModelIdFromError(
|
|
error: RevMismatchForModelError | NoRemoteModelFile,
|
|
): string | undefined {
|
|
if (!error.additionalLog) {
|
|
return undefined;
|
|
}
|
|
// Handle both array and string formats
|
|
if (Array.isArray(error.additionalLog) && error.additionalLog.length > 0) {
|
|
const firstItem = error.additionalLog[0];
|
|
return typeof firstItem === 'string' ? firstItem : undefined;
|
|
}
|
|
if (typeof error.additionalLog === 'string') {
|
|
return error.additionalLog;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
private _handleDecryptionError(): void {
|
|
this._matDialog
|
|
.open(DialogHandleDecryptErrorComponent, {
|
|
disableClose: true,
|
|
autoFocus: false,
|
|
})
|
|
.afterClosed()
|
|
.subscribe(({ isReSync, isForceUpload }) => {
|
|
if (isReSync) {
|
|
this.sync();
|
|
}
|
|
if (isForceUpload) {
|
|
this.forceUpload();
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handles LocalDataConflictError by showing a conflict resolution dialog.
|
|
* This occurs when file-based sync (Dropbox, WebDAV) detects local unsynced
|
|
* changes that would be lost if the remote snapshot is applied.
|
|
*
|
|
* User can choose:
|
|
* - USE_LOCAL: Upload local data, overwriting remote (uses forceUploadLocalState)
|
|
* - USE_REMOTE: Download remote data, discarding local (uses forceDownloadRemoteState)
|
|
*/
|
|
private async _handleLocalDataConflict(
|
|
error: LocalDataConflictError,
|
|
): Promise<SyncStatus | 'HANDLED_ERROR'> {
|
|
// Signal that we're waiting for user input (prevents sync timeout)
|
|
const stopWaiting = this._userInputWaitState.startWaiting('local-data-conflict');
|
|
|
|
try {
|
|
// Build ConflictData for the dialog
|
|
const vcEntry = await this._opLogStore.getVectorClockEntry();
|
|
const localClock = vcEntry?.clock;
|
|
const localLastUpdate = vcEntry?.lastUpdate || Date.now();
|
|
|
|
const conflictData: ConflictData = {
|
|
reason: ConflictReason.NoLastSync,
|
|
remote: {
|
|
lastUpdate: Date.now(), // Remote snapshot doesn't have a timestamp, use now
|
|
lastUpdateAction: 'Remote data',
|
|
revMap: {},
|
|
crossModelVersion: 1,
|
|
mainModelData: error.remoteSnapshotState,
|
|
isFullData: true,
|
|
vectorClock: error.remoteVectorClock,
|
|
},
|
|
local: {
|
|
lastUpdate: localLastUpdate,
|
|
lastUpdateAction: `${error.unsyncedCount} local changes pending`,
|
|
revMap: {},
|
|
crossModelVersion: 1,
|
|
lastSyncedUpdate: null,
|
|
metaRev: null,
|
|
vectorClock: localClock,
|
|
lastSyncedVectorClock: null,
|
|
},
|
|
};
|
|
|
|
SyncLog.log(
|
|
`SyncWrapperService: Showing conflict dialog for ${error.unsyncedCount} local changes vs remote snapshot`,
|
|
);
|
|
|
|
const resolution = await firstValueFrom(this._openConflictDialog$(conflictData));
|
|
|
|
// Get sync provider for the resolution operation
|
|
const rawProvider = this._providerManager.getActiveProvider();
|
|
const syncCapableProvider =
|
|
await this._wrappedProvider.getOperationSyncCapable(rawProvider);
|
|
|
|
if (!syncCapableProvider) {
|
|
SyncLog.err(
|
|
'SyncWrapperService: Cannot resolve conflict - provider not available',
|
|
);
|
|
return 'HANDLED_ERROR';
|
|
}
|
|
|
|
if (resolution === 'USE_LOCAL') {
|
|
// User chose to keep local data and upload it to remote
|
|
SyncLog.log(
|
|
'SyncWrapperService: User chose USE_LOCAL - uploading local state to overwrite remote',
|
|
);
|
|
await this._opLogSyncService.forceUploadLocalState(syncCapableProvider);
|
|
this._providerManager.setSyncStatus('IN_SYNC');
|
|
return SyncStatus.InSync;
|
|
} else if (resolution === 'USE_REMOTE') {
|
|
// User chose to discard local data and download remote
|
|
SyncLog.log(
|
|
'SyncWrapperService: User chose USE_REMOTE - downloading remote state, discarding local',
|
|
);
|
|
await this._opLogSyncService.forceDownloadRemoteState(syncCapableProvider);
|
|
this._providerManager.setSyncStatus('IN_SYNC');
|
|
return SyncStatus.InSync;
|
|
} else {
|
|
// User cancelled the dialog
|
|
SyncLog.log('SyncWrapperService: User cancelled first sync conflict dialog');
|
|
this._snackService.open({
|
|
msg: T.F.SYNC.S.LOCAL_DATA_REPLACE_CANCELLED,
|
|
});
|
|
return 'HANDLED_ERROR';
|
|
}
|
|
} catch (resolutionError) {
|
|
// Error during conflict resolution (forceUpload or forceDownload failed)
|
|
SyncLog.err(
|
|
'SyncWrapperService: Error during conflict resolution:',
|
|
resolutionError,
|
|
);
|
|
const errStr = getSyncErrorStr(resolutionError);
|
|
this._snackService.open({
|
|
msg: errStr,
|
|
type: 'ERROR',
|
|
});
|
|
return 'HANDLED_ERROR';
|
|
} finally {
|
|
stopWaiting();
|
|
}
|
|
}
|
|
|
|
private async _reInitAppAfterDataModelChange(
|
|
downloadedMainModelData?: Record<string, unknown>,
|
|
): Promise<void> {
|
|
SyncLog.log('Starting data re-initialization after sync...');
|
|
|
|
try {
|
|
await Promise.all([
|
|
// Use reInitFromRemoteSync() which now uses the passed downloaded data
|
|
// instead of reading from IndexedDB (entity models aren't stored there)
|
|
this._dataInitService.reInitFromRemoteSync(downloadedMainModelData),
|
|
]);
|
|
// wait an extra frame to potentially avoid follow up problems
|
|
await promiseTimeout(SYNC_REINIT_DELAY_MS);
|
|
SyncLog.log('Data re-initialization complete');
|
|
// Signal that data reload is complete
|
|
} catch (error) {
|
|
SyncLog.err('Error during data re-initialization:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
private _c(str: string): boolean {
|
|
return confirm(this._translateService.instant(str));
|
|
}
|
|
|
|
private _isPermissionError(error: unknown): boolean {
|
|
const errStr = String(error);
|
|
return /EROFS|EACCES|EPERM|read-only file system|permission denied/i.test(errStr);
|
|
}
|
|
|
|
private _getPermissionErrorMessage(): string {
|
|
if (IS_ELECTRON && window.ea?.isFlatpak?.()) {
|
|
return T.F.SYNC.S.ERROR_PERMISSION_FLATPAK;
|
|
}
|
|
if (IS_ELECTRON && window.ea?.isSnap?.()) {
|
|
return T.F.SYNC.S.ERROR_PERMISSION_SNAP;
|
|
}
|
|
return T.F.SYNC.S.ERROR_PERMISSION;
|
|
}
|
|
|
|
private lastConflictDialog?: MatDialogRef<any, any>;
|
|
|
|
private _openConflictDialog$(
|
|
conflictData: ConflictData,
|
|
): Observable<DialogConflictResolutionResult> {
|
|
if (this.lastConflictDialog) {
|
|
this.lastConflictDialog.close();
|
|
}
|
|
this.lastConflictDialog = this._matDialog.open(DialogSyncConflictComponent, {
|
|
restoreFocus: true,
|
|
autoFocus: false,
|
|
disableClose: true,
|
|
data: conflictData,
|
|
});
|
|
return this.lastConflictDialog.afterClosed();
|
|
}
|
|
|
|
/**
|
|
* Syncs the current vector clock from SUP_OPS to pf.META_MODEL.
|
|
* Called before legacy sync providers start syncing.
|
|
* This ensures the legacy sync provider sees the latest vector clock.
|
|
*/
|
|
private async _syncVectorClockToPfapi(): Promise<void> {
|
|
const vcEntry = await this._opLogStore.getVectorClockEntry();
|
|
|
|
if (vcEntry) {
|
|
SyncLog.log('[SyncWrapper] Syncing vector clock to pf.META_MODEL', {
|
|
clockSize: Object.keys(vcEntry.clock).length,
|
|
lastUpdate: vcEntry.lastUpdate,
|
|
});
|
|
|
|
const existing = await this._legacyPfDb.loadMetaModel();
|
|
await this._legacyPfDb.saveMetaModel({
|
|
...existing,
|
|
vectorClock: vcEntry.clock,
|
|
lastUpdate: vcEntry.lastUpdate,
|
|
});
|
|
} else {
|
|
SyncLog.log('[SyncWrapper] No vector clock in SUP_OPS, skipping sync');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Syncs the vector clock from pf.META_MODEL to SUP_OPS.vector_clock.
|
|
* Called after downloading data from remote (UpdateLocal/UpdateLocalAll).
|
|
* This ensures SUP_OPS has the latest vector clock from the remote,
|
|
* so subsequent syncs correctly detect changes.
|
|
*/
|
|
private async _syncVectorClockFromPfapi(): Promise<void> {
|
|
const metaModel = await this._legacyPfDb.loadMetaModel();
|
|
if (metaModel?.vectorClock && Object.keys(metaModel.vectorClock).length > 0) {
|
|
SyncLog.log('[SyncWrapper] Syncing vector clock from pf.META_MODEL to SUP_OPS', {
|
|
clockSize: Object.keys(metaModel.vectorClock).length,
|
|
lastUpdate: metaModel.lastUpdate,
|
|
});
|
|
|
|
await this._opLogStore.setVectorClock(metaModel.vectorClock);
|
|
} else {
|
|
SyncLog.log(
|
|
'[SyncWrapper] No vector clock in pf.META_MODEL, skipping sync to SUP_OPS',
|
|
);
|
|
}
|
|
}
|
|
}
|