Compare commits
3 Commits
main
...
feature/sy
Author | SHA1 | Date |
---|---|---|
|
dfa7332eea | 1 year ago |
|
be4dbb7073 | 1 year ago |
|
30deb2e654 | 1 year ago |
63 changed files with 2741 additions and 186 deletions
@ -0,0 +1,272 @@ |
|||||||
|
import 'dart:async'; |
||||||
|
import 'dart:convert'; |
||||||
|
|
||||||
|
import 'package:collection/collection.dart'; |
||||||
|
import 'package:flutter/material.dart'; |
||||||
|
import 'package:neon/blocs.dart'; |
||||||
|
import 'package:neon/src/models/account.dart'; |
||||||
|
import 'package:neon/src/models/app_implementation.dart'; |
||||||
|
import 'package:neon/src/settings/models/storage.dart'; |
||||||
|
import 'package:neon/src/sync/models/conflicts.dart'; |
||||||
|
import 'package:neon/src/sync/models/implementation.dart'; |
||||||
|
import 'package:neon/src/sync/models/mapping.dart'; |
||||||
|
import 'package:neon/src/utils/sync_mapping_options.dart'; |
||||||
|
import 'package:rxdart/rxdart.dart'; |
||||||
|
import 'package:synchronize/synchronize.dart'; |
||||||
|
|
||||||
|
abstract interface class SyncBlocEvents { |
||||||
|
/// Adds a new [mapping] that will be synced. |
||||||
|
void addMapping(final SyncMapping<dynamic, dynamic> mapping); |
||||||
|
|
||||||
|
/// Removes an existing [mapping] that will no longer be synced. |
||||||
|
void removeMapping(final SyncMapping<dynamic, dynamic> mapping); |
||||||
|
|
||||||
|
/// Explicitly trigger a sync for the [mapping]. |
||||||
|
/// [solutions] can be use to apply solutions for conflicts. |
||||||
|
void syncMapping( |
||||||
|
final SyncMapping<dynamic, dynamic> mapping, { |
||||||
|
final Map<String, SyncConflictSolution> solutions = const {}, |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
abstract interface class SyncBlocStates { |
||||||
|
/// Map of [SyncMapping]s and their [SyncMappingStatus]es |
||||||
|
BehaviorSubject<Map<SyncMapping<dynamic, dynamic>, SyncMappingStatus>> get mappingStatuses; |
||||||
|
|
||||||
|
/// Stream of conflicts that have arisen during syncing. |
||||||
|
Stream<SyncConflicts<dynamic, dynamic>> get conflicts; |
||||||
|
} |
||||||
|
|
||||||
|
class SyncBloc extends InteractiveBloc implements SyncBlocEvents, SyncBlocStates { |
||||||
|
SyncBloc( |
||||||
|
this._accountsBloc, |
||||||
|
final Iterable<AppImplementation> appImplementations, |
||||||
|
) { |
||||||
|
_syncImplementations = appImplementations.map((final app) => app.syncImplementation).whereNotNull(); |
||||||
|
_timer = TimerBloc().registerTimer(const Duration(minutes: 1), refresh); |
||||||
|
|
||||||
|
_loadMappings(); |
||||||
|
mappingStatuses.value.keys.forEach(_watchMapping); |
||||||
|
unawaited(refresh()); |
||||||
|
} |
||||||
|
|
||||||
|
final AccountsBloc _accountsBloc; |
||||||
|
static const _storage = SingleValueStorage(StorageKeys.sync); |
||||||
|
late final Iterable<SyncImplementation<SyncMapping<dynamic, dynamic>, dynamic, dynamic>> _syncImplementations; |
||||||
|
late final NeonTimer _timer; |
||||||
|
final _conflictsController = StreamController<SyncConflicts<dynamic, dynamic>>(); |
||||||
|
final _watchControllers = <String, StreamController<void>>{}; |
||||||
|
final _syncMappingOptions = <String, SyncMappingOptions>{}; |
||||||
|
|
||||||
|
@override |
||||||
|
void dispose() { |
||||||
|
_timer.cancel(); |
||||||
|
for (final options in _syncMappingOptions.values) { |
||||||
|
options.dispose(); |
||||||
|
} |
||||||
|
for (final mapping in mappingStatuses.value.keys) { |
||||||
|
mapping.dispose(); |
||||||
|
} |
||||||
|
unawaited(mappingStatuses.close()); |
||||||
|
for (final controller in _watchControllers.values) { |
||||||
|
unawaited(controller.close()); |
||||||
|
} |
||||||
|
unawaited(_conflictsController.close()); |
||||||
|
|
||||||
|
super.dispose(); |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
late final Stream<SyncConflicts<dynamic, dynamic>> conflicts = _conflictsController.stream.asBroadcastStream(); |
||||||
|
|
||||||
|
@override |
||||||
|
final BehaviorSubject<Map<SyncMapping<dynamic, dynamic>, SyncMappingStatus>> mappingStatuses = BehaviorSubject(); |
||||||
|
|
||||||
|
@override |
||||||
|
Future<void> refresh() async { |
||||||
|
for (final mapping in mappingStatuses.value.keys) { |
||||||
|
await _updateMapping(mapping); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
Future<void> addMapping(final SyncMapping<dynamic, dynamic> mapping) async { |
||||||
|
debugPrint('Adding mapping: $mapping'); |
||||||
|
mappingStatuses.add({ |
||||||
|
...mappingStatuses.value, |
||||||
|
mapping: SyncMappingStatus.unknown, |
||||||
|
}); |
||||||
|
await _saveMappings(); |
||||||
|
// Directly trigger sync after adding the mapping |
||||||
|
await syncMapping(mapping); |
||||||
|
// And start watching for local or remote changes |
||||||
|
_watchMapping(mapping); |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
Future<void> removeMapping(final SyncMapping<dynamic, dynamic> mapping) async { |
||||||
|
debugPrint('Removing mapping: $mapping'); |
||||||
|
mappingStatuses.add(Map.fromEntries(mappingStatuses.value.entries.where((final m) => m.key != mapping))); |
||||||
|
mapping.dispose(); |
||||||
|
await _saveMappings(); |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
Future<void> syncMapping( |
||||||
|
final SyncMapping<dynamic, dynamic> mapping, { |
||||||
|
final Map<String, SyncConflictSolution> solutions = const {}, |
||||||
|
}) async { |
||||||
|
debugPrint('Syncing mapping: $mapping'); |
||||||
|
mappingStatuses.add({ |
||||||
|
...mappingStatuses.value, |
||||||
|
mapping: SyncMappingStatus.incomplete, |
||||||
|
}); |
||||||
|
|
||||||
|
final account = _accountsBloc.accounts.value.tryFind(mapping.accountId); |
||||||
|
if (account == null) { |
||||||
|
await removeMapping(mapping); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
final implementation = _syncImplementations.find(mapping.appId); |
||||||
|
final sources = await implementation.getSources(account, mapping); |
||||||
|
|
||||||
|
final diff = await computeSyncDiff( |
||||||
|
sources, |
||||||
|
mapping.journal, |
||||||
|
conflictSolutions: solutions, |
||||||
|
keepSkipsAsConflicts: true, |
||||||
|
); |
||||||
|
debugPrint('Journal: ${mapping.journal}'); |
||||||
|
debugPrint('Conflicts: ${diff.conflicts}'); |
||||||
|
debugPrint('Actions: ${diff.actions}'); |
||||||
|
|
||||||
|
if (diff.conflicts.isNotEmpty && diff.conflicts.whereNot((final conflict) => conflict.skipped).isNotEmpty) { |
||||||
|
_conflictsController.add( |
||||||
|
SyncConflicts<dynamic, dynamic>( |
||||||
|
account, |
||||||
|
implementation, |
||||||
|
mapping, |
||||||
|
diff.conflicts, |
||||||
|
), |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
await executeSyncDiff(sources, mapping.journal, diff); |
||||||
|
|
||||||
|
mappingStatuses.add({ |
||||||
|
...mappingStatuses.value, |
||||||
|
mapping: diff.conflicts.isEmpty ? SyncMappingStatus.complete : SyncMappingStatus.incomplete, |
||||||
|
}); |
||||||
|
} catch (e, s) { |
||||||
|
debugPrint(e.toString()); |
||||||
|
debugPrint(s.toString()); |
||||||
|
addError(e); |
||||||
|
} |
||||||
|
|
||||||
|
// Save after syncing even if an error occurred |
||||||
|
await _saveMappings(); |
||||||
|
} |
||||||
|
|
||||||
|
Future<void> _updateMapping(final SyncMapping<dynamic, dynamic> mapping) async { |
||||||
|
final account = _accountsBloc.accounts.value.tryFind(mapping.accountId); |
||||||
|
if (account == null) { |
||||||
|
await removeMapping(mapping); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
final options = getSyncMappingOptionsFor(mapping); |
||||||
|
if (options.automaticSync.value) { |
||||||
|
await syncMapping(mapping); |
||||||
|
} else { |
||||||
|
try { |
||||||
|
final status = await _getMappingStatus(account, mapping); |
||||||
|
mappingStatuses.add({ |
||||||
|
...mappingStatuses.value, |
||||||
|
mapping: status, |
||||||
|
}); |
||||||
|
} catch (e, s) { |
||||||
|
debugPrint(e.toString()); |
||||||
|
debugPrint(s.toString()); |
||||||
|
addError(e); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
Future<SyncMappingStatus> _getMappingStatus( |
||||||
|
final Account account, |
||||||
|
final SyncMapping<dynamic, dynamic> mapping, |
||||||
|
) async { |
||||||
|
final implementation = _syncImplementations.find(mapping.appId); |
||||||
|
final sources = await implementation.getSources(account, mapping); |
||||||
|
final diff = await computeSyncDiff(sources, mapping.journal); |
||||||
|
return diff.actions.isEmpty && diff.conflicts.isEmpty ? SyncMappingStatus.complete : SyncMappingStatus.incomplete; |
||||||
|
} |
||||||
|
|
||||||
|
void _loadMappings() { |
||||||
|
debugPrint('Loading mappings'); |
||||||
|
final loadedMappings = <SyncMapping<dynamic, dynamic>>[]; |
||||||
|
|
||||||
|
if (_storage.hasValue()) { |
||||||
|
final serializedMappings = (json.decode(_storage.getString()!) as Map<String, dynamic>) |
||||||
|
.map((final key, final value) => MapEntry(key, (value as List).map((final e) => e as Map<String, dynamic>))); |
||||||
|
|
||||||
|
for (final mapping in serializedMappings.entries) { |
||||||
|
final syncImplementation = _syncImplementations.tryFind(mapping.key); |
||||||
|
if (syncImplementation == null) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
for (final serializedMapping in mapping.value) { |
||||||
|
loadedMappings.add(syncImplementation.deserializeMapping(serializedMapping)); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
mappingStatuses.add({ |
||||||
|
for (final mapping in loadedMappings) mapping: SyncMappingStatus.unknown, |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
Future<void> _saveMappings() async { |
||||||
|
debugPrint('Saving mappings'); |
||||||
|
final serializedMappings = <String, List<Map<String, dynamic>>>{}; |
||||||
|
|
||||||
|
for (final mapping in mappingStatuses.value.keys) { |
||||||
|
final syncImplementation = _syncImplementations.find(mapping.appId); |
||||||
|
serializedMappings[mapping.appId] ??= []; |
||||||
|
serializedMappings[mapping.appId]!.add(syncImplementation.serializeMapping(mapping)); |
||||||
|
} |
||||||
|
|
||||||
|
await _storage.setString(json.encode(serializedMappings)); |
||||||
|
} |
||||||
|
|
||||||
|
void _watchMapping(final SyncMapping<dynamic, dynamic> mapping) { |
||||||
|
final syncImplementation = _syncImplementations.find(mapping.appId); |
||||||
|
if (_watchControllers.containsKey(syncImplementation.getMappingId(mapping))) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// ignore: close_sinks |
||||||
|
final controller = StreamController<void>(); |
||||||
|
// Debounce is required to stop bulk operations flooding the sync and potentially creating race conditions. |
||||||
|
controller.stream.debounceTime(const Duration(seconds: 1)).listen((final _) async { |
||||||
|
await _updateMapping(mapping); |
||||||
|
}); |
||||||
|
|
||||||
|
_watchControllers[syncImplementation.getMappingId(mapping)] = controller; |
||||||
|
|
||||||
|
mapping.watch(() { |
||||||
|
controller.add(null); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
SyncMappingOptions getSyncMappingOptionsFor(final SyncMapping<dynamic, dynamic> mapping) { |
||||||
|
final syncImplementation = _syncImplementations.find(mapping.appId); |
||||||
|
final id = syncImplementation.getGlobalUniqueMappingId(mapping); |
||||||
|
return _syncMappingOptions[id] ??= SyncMappingOptions( |
||||||
|
AppStorage(StorageKeys.sync, id), |
||||||
|
); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,156 @@ |
|||||||
|
import 'package:flutter/material.dart'; |
||||||
|
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; |
||||||
|
import 'package:meta/meta.dart'; |
||||||
|
import 'package:neon/l10n/localizations.dart'; |
||||||
|
import 'package:neon/src/blocs/accounts.dart'; |
||||||
|
import 'package:neon/src/blocs/sync.dart'; |
||||||
|
import 'package:neon/src/models/account.dart'; |
||||||
|
import 'package:neon/src/models/app_implementation.dart'; |
||||||
|
import 'package:neon/src/pages/sync_mapping_settings.dart'; |
||||||
|
import 'package:neon/src/settings/widgets/custom_settings_tile.dart'; |
||||||
|
import 'package:neon/src/settings/widgets/option_settings_tile.dart'; |
||||||
|
import 'package:neon/src/settings/widgets/settings_category.dart'; |
||||||
|
import 'package:neon/src/settings/widgets/settings_list.dart'; |
||||||
|
import 'package:neon/src/theme/dialog.dart'; |
||||||
|
import 'package:neon/src/utils/confirmation_dialog.dart'; |
||||||
|
import 'package:neon/src/widgets/account_selection_dialog.dart'; |
||||||
|
import 'package:neon/src/widgets/sync_status_icon.dart'; |
||||||
|
import 'package:neon/src/widgets/user_avatar.dart'; |
||||||
|
import 'package:provider/provider.dart'; |
||||||
|
|
||||||
|
@internal |
||||||
|
class AppImplementationSettingsPage extends StatelessWidget { |
||||||
|
const AppImplementationSettingsPage({ |
||||||
|
required this.appImplementation, |
||||||
|
super.key, |
||||||
|
}); |
||||||
|
|
||||||
|
final AppImplementation appImplementation; |
||||||
|
|
||||||
|
@override |
||||||
|
Widget build(final BuildContext context) { |
||||||
|
final accountsBloc = Provider.of<AccountsBloc>(context, listen: false); |
||||||
|
final syncBloc = Provider.of<SyncBloc>(context, listen: false); |
||||||
|
|
||||||
|
final appBar = AppBar( |
||||||
|
title: Text(appImplementation.name(context)), |
||||||
|
actions: [ |
||||||
|
IconButton( |
||||||
|
onPressed: () async { |
||||||
|
if (await showConfirmationDialog( |
||||||
|
context, |
||||||
|
NeonLocalizations.of(context).settingsResetForConfirmation(appImplementation.name(context)), |
||||||
|
)) { |
||||||
|
appImplementation.options.reset(); |
||||||
|
} |
||||||
|
}, |
||||||
|
tooltip: NeonLocalizations.of(context).settingsResetFor(appImplementation.name(context)), |
||||||
|
icon: const Icon(MdiIcons.cogRefresh), |
||||||
|
), |
||||||
|
], |
||||||
|
); |
||||||
|
|
||||||
|
final body = SettingsList( |
||||||
|
categories: [ |
||||||
|
for (final category in [...appImplementation.options.categories, null]) ...[ |
||||||
|
if (appImplementation.options.options.where((final option) => option.category == category).isNotEmpty) ...[ |
||||||
|
SettingsCategory( |
||||||
|
title: Text( |
||||||
|
category != null ? category.name(context) : NeonLocalizations.of(context).optionsCategoryOther, |
||||||
|
), |
||||||
|
tiles: [ |
||||||
|
for (final option |
||||||
|
in appImplementation.options.options.where((final option) => option.category == category)) ...[ |
||||||
|
OptionSettingsTile(option: option), |
||||||
|
], |
||||||
|
], |
||||||
|
), |
||||||
|
], |
||||||
|
], |
||||||
|
if (appImplementation.syncImplementation != null) ...[ |
||||||
|
StreamBuilder( |
||||||
|
stream: syncBloc.mappingStatuses, |
||||||
|
builder: (final context, final mappingStatuses) => !mappingStatuses.hasData |
||||||
|
? const SizedBox.shrink() |
||||||
|
: SettingsCategory( |
||||||
|
title: Text(NeonLocalizations.of(context).optionsCategorySync), |
||||||
|
tiles: [ |
||||||
|
for (final mappingStatus in mappingStatuses.requireData.entries |
||||||
|
.where((final mappingStatus) => mappingStatus.key.appId == appImplementation.id)) ...[ |
||||||
|
CustomSettingsTile( |
||||||
|
title: Text(appImplementation.syncImplementation!.getMappingDisplayTitle(mappingStatus.key)), |
||||||
|
subtitle: |
||||||
|
Text(appImplementation.syncImplementation!.getMappingDisplaySubtitle(mappingStatus.key)), |
||||||
|
leading: NeonUserAvatar( |
||||||
|
account: accountsBloc.accounts.value |
||||||
|
.singleWhere((final account) => account.id == mappingStatus.key.accountId), |
||||||
|
showStatus: false, |
||||||
|
), |
||||||
|
trailing: IconButton( |
||||||
|
onPressed: () async { |
||||||
|
await syncBloc.syncMapping(mappingStatus.key); |
||||||
|
}, |
||||||
|
tooltip: NeonLocalizations.of(context).syncOptionsSyncNow, |
||||||
|
iconSize: 30, |
||||||
|
icon: SyncStatusIcon( |
||||||
|
status: mappingStatus.value, |
||||||
|
), |
||||||
|
), |
||||||
|
onTap: () async { |
||||||
|
await Navigator.of(context).push( |
||||||
|
MaterialPageRoute<void>( |
||||||
|
builder: (final context) => SyncMappingSettingsPage( |
||||||
|
mapping: mappingStatus.key, |
||||||
|
), |
||||||
|
), |
||||||
|
); |
||||||
|
}, |
||||||
|
), |
||||||
|
], |
||||||
|
CustomSettingsTile( |
||||||
|
title: ElevatedButton.icon( |
||||||
|
onPressed: () async { |
||||||
|
final account = await showDialog<Account>( |
||||||
|
context: context, |
||||||
|
builder: (final context) => const NeonAccountSelectionDialog(), |
||||||
|
); |
||||||
|
if (account == null) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
if (!context.mounted) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
final mapping = await appImplementation.syncImplementation!.addMapping(context, account); |
||||||
|
if (mapping == null) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
await syncBloc.addMapping(mapping); |
||||||
|
}, |
||||||
|
icon: const Icon(MdiIcons.cloudSync), |
||||||
|
label: Text(NeonLocalizations.of(context).syncOptionsAdd), |
||||||
|
), |
||||||
|
), |
||||||
|
], |
||||||
|
), |
||||||
|
), |
||||||
|
], |
||||||
|
], |
||||||
|
); |
||||||
|
|
||||||
|
return Scaffold( |
||||||
|
resizeToAvoidBottomInset: false, |
||||||
|
appBar: appBar, |
||||||
|
body: SafeArea( |
||||||
|
child: Center( |
||||||
|
child: ConstrainedBox( |
||||||
|
constraints: NeonDialogTheme.of(context).constraints, |
||||||
|
child: body, |
||||||
|
), |
||||||
|
), |
||||||
|
), |
||||||
|
); |
||||||
|
} |
||||||
|
} |
@ -1,74 +0,0 @@ |
|||||||
import 'package:flutter/material.dart'; |
|
||||||
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; |
|
||||||
import 'package:meta/meta.dart'; |
|
||||||
import 'package:neon/l10n/localizations.dart'; |
|
||||||
import 'package:neon/src/models/app_implementation.dart'; |
|
||||||
import 'package:neon/src/settings/widgets/option_settings_tile.dart'; |
|
||||||
import 'package:neon/src/settings/widgets/settings_category.dart'; |
|
||||||
import 'package:neon/src/settings/widgets/settings_list.dart'; |
|
||||||
import 'package:neon/src/theme/dialog.dart'; |
|
||||||
import 'package:neon/src/utils/confirmation_dialog.dart'; |
|
||||||
|
|
||||||
@internal |
|
||||||
class NextcloudAppSettingsPage extends StatelessWidget { |
|
||||||
const NextcloudAppSettingsPage({ |
|
||||||
required this.appImplementation, |
|
||||||
super.key, |
|
||||||
}); |
|
||||||
|
|
||||||
final AppImplementation appImplementation; |
|
||||||
|
|
||||||
@override |
|
||||||
Widget build(final BuildContext context) { |
|
||||||
final appBar = AppBar( |
|
||||||
title: Text(appImplementation.name(context)), |
|
||||||
actions: [ |
|
||||||
IconButton( |
|
||||||
onPressed: () async { |
|
||||||
if (await showConfirmationDialog( |
|
||||||
context, |
|
||||||
NeonLocalizations.of(context).settingsResetForConfirmation(appImplementation.name(context)), |
|
||||||
)) { |
|
||||||
appImplementation.options.reset(); |
|
||||||
} |
|
||||||
}, |
|
||||||
tooltip: NeonLocalizations.of(context).settingsResetFor(appImplementation.name(context)), |
|
||||||
icon: const Icon(MdiIcons.cogRefresh), |
|
||||||
), |
|
||||||
], |
|
||||||
); |
|
||||||
|
|
||||||
final body = SettingsList( |
|
||||||
categories: [ |
|
||||||
for (final category in [...appImplementation.options.categories, null]) ...[ |
|
||||||
if (appImplementation.options.options.where((final option) => option.category == category).isNotEmpty) ...[ |
|
||||||
SettingsCategory( |
|
||||||
title: Text( |
|
||||||
category != null ? category.name(context) : NeonLocalizations.of(context).optionsCategoryOther, |
|
||||||
), |
|
||||||
tiles: [ |
|
||||||
for (final option |
|
||||||
in appImplementation.options.options.where((final option) => option.category == category)) ...[ |
|
||||||
OptionSettingsTile(option: option), |
|
||||||
], |
|
||||||
], |
|
||||||
), |
|
||||||
], |
|
||||||
], |
|
||||||
], |
|
||||||
); |
|
||||||
|
|
||||||
return Scaffold( |
|
||||||
resizeToAvoidBottomInset: false, |
|
||||||
appBar: appBar, |
|
||||||
body: SafeArea( |
|
||||||
child: Center( |
|
||||||
child: ConstrainedBox( |
|
||||||
constraints: NeonDialogTheme.of(context).constraints, |
|
||||||
child: body, |
|
||||||
), |
|
||||||
), |
|
||||||
), |
|
||||||
); |
|
||||||
} |
|
||||||
} |
|
@ -0,0 +1,72 @@ |
|||||||
|
import 'package:flutter/material.dart'; |
||||||
|
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; |
||||||
|
import 'package:neon/l10n/localizations.dart'; |
||||||
|
import 'package:neon/src/blocs/sync.dart'; |
||||||
|
import 'package:neon/src/settings/widgets/option_settings_tile.dart'; |
||||||
|
import 'package:neon/src/settings/widgets/settings_category.dart'; |
||||||
|
import 'package:neon/src/settings/widgets/settings_list.dart'; |
||||||
|
import 'package:neon/src/sync/models/mapping.dart'; |
||||||
|
import 'package:neon/src/utils/confirmation_dialog.dart'; |
||||||
|
import 'package:provider/provider.dart'; |
||||||
|
|
||||||
|
class SyncMappingSettingsPage extends StatelessWidget { |
||||||
|
const SyncMappingSettingsPage({ |
||||||
|
required this.mapping, |
||||||
|
super.key, |
||||||
|
}); |
||||||
|
|
||||||
|
final SyncMapping<dynamic, dynamic> mapping; |
||||||
|
|
||||||
|
@override |
||||||
|
Widget build(final BuildContext context) { |
||||||
|
final syncBloc = Provider.of<SyncBloc>(context, listen: false); |
||||||
|
final options = syncBloc.getSyncMappingOptionsFor(mapping); |
||||||
|
|
||||||
|
return Scaffold( |
||||||
|
appBar: AppBar( |
||||||
|
actions: [ |
||||||
|
IconButton( |
||||||
|
onPressed: () async { |
||||||
|
if (await showConfirmationDialog( |
||||||
|
context, |
||||||
|
NeonLocalizations.of(context).syncOptionsRemoveConfirmation, |
||||||
|
)) { |
||||||
|
await syncBloc.removeMapping(mapping); |
||||||
|
|
||||||
|
if (context.mounted) { |
||||||
|
Navigator.of(context).pop(); |
||||||
|
} |
||||||
|
} |
||||||
|
}, |
||||||
|
tooltip: NeonLocalizations.of(context).syncOptionsRemove, |
||||||
|
icon: const Icon(MdiIcons.delete), |
||||||
|
), |
||||||
|
IconButton( |
||||||
|
onPressed: () async { |
||||||
|
if (await showConfirmationDialog( |
||||||
|
context, |
||||||
|
NeonLocalizations.of(context).settingsResetAllConfirmation, |
||||||
|
)) { |
||||||
|
options.reset(); |
||||||
|
} |
||||||
|
}, |
||||||
|
tooltip: NeonLocalizations.of(context).settingsResetAll, |
||||||
|
icon: const Icon(MdiIcons.cogRefresh), |
||||||
|
), |
||||||
|
], |
||||||
|
), |
||||||
|
body: SettingsList( |
||||||
|
categories: [ |
||||||
|
SettingsCategory( |
||||||
|
title: Text(NeonLocalizations.of(context).optionsCategoryGeneral), |
||||||
|
tiles: [ |
||||||
|
ToggleSettingsTile( |
||||||
|
option: options.automaticSync, |
||||||
|
), |
||||||
|
], |
||||||
|
), |
||||||
|
], |
||||||
|
), |
||||||
|
); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,18 @@ |
|||||||
|
import 'package:neon/src/models/account.dart'; |
||||||
|
import 'package:neon/src/sync/models/implementation.dart'; |
||||||
|
import 'package:neon/src/sync/models/mapping.dart'; |
||||||
|
import 'package:synchronize/synchronize.dart'; |
||||||
|
|
||||||
|
class SyncConflicts<T1, T2> { |
||||||
|
SyncConflicts( |
||||||
|
this.account, |
||||||
|
this.implementation, |
||||||
|
this.mapping, |
||||||
|
this.conflicts, |
||||||
|
); |
||||||
|
|
||||||
|
final Account account; |
||||||
|
final SyncImplementation<SyncMapping<T1, T2>, T1, T2> implementation; |
||||||
|
final SyncMapping<T1, T2> mapping; |
||||||
|
final List<SyncConflict<T1, T2>> conflicts; |
||||||
|
} |
@ -0,0 +1,43 @@ |
|||||||
|
import 'dart:async'; |
||||||
|
|
||||||
|
import 'package:collection/collection.dart'; |
||||||
|
import 'package:flutter/material.dart'; |
||||||
|
import 'package:neon/src/models/account.dart'; |
||||||
|
import 'package:neon/src/sync/models/mapping.dart'; |
||||||
|
import 'package:synchronize/synchronize.dart'; |
||||||
|
|
||||||
|
@immutable |
||||||
|
abstract interface class SyncImplementation<S extends SyncMapping<T1, T2>, T1, T2> { |
||||||
|
String get appId; |
||||||
|
|
||||||
|
FutureOr<SyncSources<T1, T2>> getSources(final Account account, final S mapping); |
||||||
|
|
||||||
|
Map<String, dynamic> serializeMapping(final S mapping); |
||||||
|
|
||||||
|
S deserializeMapping(final Map<String, dynamic> json); |
||||||
|
|
||||||
|
FutureOr<S?> addMapping(final BuildContext context, final Account account); |
||||||
|
|
||||||
|
String getMappingDisplayTitle(final S mapping); |
||||||
|
|
||||||
|
String getMappingDisplaySubtitle(final S mapping); |
||||||
|
|
||||||
|
String getMappingId(final S mapping); |
||||||
|
|
||||||
|
Widget getConflictDetailsLocal(final BuildContext context, final T2 object); |
||||||
|
|
||||||
|
Widget getConflictDetailsRemote(final BuildContext context, final T1 object); |
||||||
|
} |
||||||
|
|
||||||
|
extension SyncImplementationGlobalUniqueMappingId |
||||||
|
on SyncImplementation<SyncMapping<dynamic, dynamic>, dynamic, dynamic> { |
||||||
|
String getGlobalUniqueMappingId(final SyncMapping<dynamic, dynamic> mapping) => |
||||||
|
'${mapping.accountId}-${mapping.appId}-${getMappingId(mapping)}'; |
||||||
|
} |
||||||
|
|
||||||
|
extension SyncImplementationFind on Iterable<SyncImplementation<SyncMapping<dynamic, dynamic>, dynamic, dynamic>> { |
||||||
|
SyncImplementation<SyncMapping<dynamic, dynamic>, dynamic, dynamic>? tryFind(final String appId) => |
||||||
|
singleWhereOrNull((final syncImplementation) => appId == syncImplementation.appId); |
||||||
|
|
||||||
|
SyncImplementation<SyncMapping<dynamic, dynamic>, dynamic, dynamic> find(final String appId) => tryFind(appId)!; |
||||||
|
} |
@ -0,0 +1,23 @@ |
|||||||
|
import 'package:meta/meta.dart'; |
||||||
|
import 'package:synchronize/synchronize.dart'; |
||||||
|
|
||||||
|
abstract interface class SyncMapping<T1, T2> { |
||||||
|
String get accountId; |
||||||
|
String get appId; |
||||||
|
SyncJournal get journal; |
||||||
|
|
||||||
|
/// This method can be implemented to watch local or remote changes and update the status accordingly. |
||||||
|
void watch(final void Function() onUpdated) {} |
||||||
|
|
||||||
|
@mustBeOverridden |
||||||
|
void dispose() {} |
||||||
|
|
||||||
|
@override |
||||||
|
String toString() => 'SyncMapping(accountId: $accountId, appId: $appId)'; |
||||||
|
} |
||||||
|
|
||||||
|
enum SyncMappingStatus { |
||||||
|
unknown, |
||||||
|
incomplete, |
||||||
|
complete, |
||||||
|
} |
@ -0,0 +1,115 @@ |
|||||||
|
import 'package:flutter/material.dart'; |
||||||
|
import 'package:neon/l10n/localizations.dart'; |
||||||
|
import 'package:neon/src/sync/models/conflicts.dart'; |
||||||
|
import 'package:neon/src/sync/widgets/sync_conflict_card.dart'; |
||||||
|
import 'package:neon/src/theme/dialog.dart'; |
||||||
|
import 'package:synchronize/synchronize.dart'; |
||||||
|
|
||||||
|
class NeonResolveSyncConflictsDialog<T1, T2> extends StatefulWidget { |
||||||
|
const NeonResolveSyncConflictsDialog({ |
||||||
|
required this.conflicts, |
||||||
|
super.key, |
||||||
|
}); |
||||||
|
|
||||||
|
final SyncConflicts<T1, T2> conflicts; |
||||||
|
|
||||||
|
@override |
||||||
|
State<NeonResolveSyncConflictsDialog<T1, T2>> createState() => _NeonResolveSyncConflictsDialogState<T1, T2>(); |
||||||
|
} |
||||||
|
|
||||||
|
class _NeonResolveSyncConflictsDialogState<T1, T2> extends State<NeonResolveSyncConflictsDialog<T1, T2>> { |
||||||
|
var _index = 0; |
||||||
|
final _solutions = <String, SyncConflictSolution>{}; |
||||||
|
|
||||||
|
SyncConflict<T1, T2> get conflict => widget.conflicts.conflicts[_index]; |
||||||
|
|
||||||
|
SyncConflictSolution? get selectedSolution => _solutions[conflict.id]; |
||||||
|
|
||||||
|
void onSolution(final SyncConflictSolution solution) { |
||||||
|
setState(() { |
||||||
|
_solutions[conflict.id] = solution; |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
bool get isFirst => _index == 0; |
||||||
|
bool get isLast => _index == widget.conflicts.conflicts.length - 1; |
||||||
|
|
||||||
|
@override |
||||||
|
Widget build(final BuildContext context) { |
||||||
|
final body = Column( |
||||||
|
children: [ |
||||||
|
Text( |
||||||
|
NeonLocalizations.of(context).syncResolveConflictsTitle( |
||||||
|
widget.conflicts.conflicts.length, |
||||||
|
NeonLocalizations.of(context).appImplementationName(widget.conflicts.implementation.appId), |
||||||
|
), |
||||||
|
style: Theme.of(context).textTheme.headlineMedium, |
||||||
|
), |
||||||
|
const Divider(), |
||||||
|
Column( |
||||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||||
|
children: [ |
||||||
|
SyncConflictCard( |
||||||
|
title: NeonLocalizations.of(context).syncResolveConflictsLocal, |
||||||
|
solution: SyncConflictSolution.overwriteA, |
||||||
|
selected: selectedSolution == SyncConflictSolution.overwriteA, |
||||||
|
onSelected: onSolution, |
||||||
|
child: widget.conflicts.implementation.getConflictDetailsLocal(context, conflict.objectB.data), |
||||||
|
), |
||||||
|
SyncConflictCard( |
||||||
|
title: NeonLocalizations.of(context).syncResolveConflictsRemote, |
||||||
|
solution: SyncConflictSolution.overwriteB, |
||||||
|
selected: selectedSolution == SyncConflictSolution.overwriteB, |
||||||
|
onSelected: onSolution, |
||||||
|
child: widget.conflicts.implementation.getConflictDetailsRemote(context, conflict.objectA.data), |
||||||
|
), |
||||||
|
], |
||||||
|
), |
||||||
|
const Divider(), |
||||||
|
Row( |
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
||||||
|
children: [ |
||||||
|
OutlinedButton( |
||||||
|
onPressed: () { |
||||||
|
if (isFirst) { |
||||||
|
Navigator.of(context).pop(); |
||||||
|
} else { |
||||||
|
setState(() { |
||||||
|
_index--; |
||||||
|
}); |
||||||
|
} |
||||||
|
}, |
||||||
|
child: Text( |
||||||
|
isFirst ? NeonLocalizations.of(context).actionCancel : NeonLocalizations.of(context).actionPrevious, |
||||||
|
), |
||||||
|
), |
||||||
|
ElevatedButton( |
||||||
|
onPressed: () { |
||||||
|
if (isLast) { |
||||||
|
Navigator.of(context).pop(_solutions); |
||||||
|
} else { |
||||||
|
setState(() { |
||||||
|
_index++; |
||||||
|
}); |
||||||
|
} |
||||||
|
}, |
||||||
|
child: Text( |
||||||
|
isLast ? NeonLocalizations.of(context).actionFinish : NeonLocalizations.of(context).actionNext, |
||||||
|
), |
||||||
|
), |
||||||
|
], |
||||||
|
), |
||||||
|
], |
||||||
|
); |
||||||
|
|
||||||
|
return Dialog( |
||||||
|
child: IntrinsicHeight( |
||||||
|
child: Container( |
||||||
|
padding: const EdgeInsets.all(24), |
||||||
|
constraints: NeonDialogTheme.of(context).constraints, |
||||||
|
child: body, |
||||||
|
), |
||||||
|
), |
||||||
|
); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,49 @@ |
|||||||
|
import 'package:flutter/material.dart'; |
||||||
|
import 'package:synchronize/synchronize.dart'; |
||||||
|
|
||||||
|
class SyncConflictCard extends StatelessWidget { |
||||||
|
const SyncConflictCard({ |
||||||
|
required this.title, |
||||||
|
required this.child, |
||||||
|
required this.selected, |
||||||
|
required this.solution, |
||||||
|
required this.onSelected, |
||||||
|
super.key, |
||||||
|
}); |
||||||
|
|
||||||
|
final String title; |
||||||
|
final Widget child; |
||||||
|
final bool selected; |
||||||
|
final SyncConflictSolution solution; |
||||||
|
final void Function(SyncConflictSolution solution) onSelected; |
||||||
|
|
||||||
|
@override |
||||||
|
Widget build(final BuildContext context) => Card( |
||||||
|
shape: selected |
||||||
|
? RoundedRectangleBorder( |
||||||
|
borderRadius: BorderRadius.circular(12), |
||||||
|
side: BorderSide( |
||||||
|
color: Theme.of(context).colorScheme.onBackground, |
||||||
|
), |
||||||
|
) |
||||||
|
: null, |
||||||
|
child: InkWell( |
||||||
|
onTap: () { |
||||||
|
onSelected(solution); |
||||||
|
}, |
||||||
|
child: Column( |
||||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||||
|
children: [ |
||||||
|
Padding( |
||||||
|
padding: const EdgeInsets.only(left: 8), |
||||||
|
child: Text( |
||||||
|
title, |
||||||
|
style: Theme.of(context).textTheme.headlineSmall, |
||||||
|
), |
||||||
|
), |
||||||
|
child, |
||||||
|
], |
||||||
|
), |
||||||
|
), |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,52 @@ |
|||||||
|
import 'dart:typed_data'; |
||||||
|
|
||||||
|
import 'package:file_picker/file_picker.dart'; |
||||||
|
import 'package:flutter_file_dialog/flutter_file_dialog.dart'; |
||||||
|
import 'package:neon/src/platform/platform.dart'; |
||||||
|
import 'package:universal_io/io.dart'; |
||||||
|
|
||||||
|
class FileUtils { |
||||||
|
FileUtils._(); |
||||||
|
|
||||||
|
/// Displays a dialog for selecting a location where to save a file with the [data] content. |
||||||
|
/// |
||||||
|
/// Set the the suggested [fileName] to use when saving the file. |
||||||
|
/// |
||||||
|
/// Returns the path of the saved file or `null` if the operation was cancelled. |
||||||
|
static Future<String?> saveFileWithPickDialog(final String fileName, final Uint8List data) async { |
||||||
|
if (NeonPlatform.instance.shouldUseFileDialog) { |
||||||
|
// TODO: https://github.com/nextcloud/neon/issues/8 |
||||||
|
return FlutterFileDialog.saveFile( |
||||||
|
params: SaveFileDialogParams( |
||||||
|
data: data, |
||||||
|
fileName: fileName, |
||||||
|
), |
||||||
|
); |
||||||
|
} else { |
||||||
|
final result = await FilePicker.platform.saveFile( |
||||||
|
fileName: fileName, |
||||||
|
); |
||||||
|
if (result != null) { |
||||||
|
await File(result).writeAsBytes(data); |
||||||
|
} |
||||||
|
|
||||||
|
return result; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
static Future<FilePickerResult?> loadFileWithPickDialog({ |
||||||
|
final bool withData = false, |
||||||
|
final bool allowMultiple = false, |
||||||
|
final FileType type = FileType.any, |
||||||
|
}) async { |
||||||
|
final result = await FilePicker.platform.pickFiles( |
||||||
|
withData: withData, |
||||||
|
allowMultiple: allowMultiple, |
||||||
|
type: type, |
||||||
|
); |
||||||
|
|
||||||
|
return result; |
||||||
|
} |
||||||
|
|
||||||
|
static Future<String?> pickDirectory() async => FilePicker.platform.getDirectoryPath(); |
||||||
|
} |
@ -1,32 +0,0 @@ |
|||||||
import 'dart:typed_data'; |
|
||||||
|
|
||||||
import 'package:file_picker/file_picker.dart'; |
|
||||||
import 'package:flutter_file_dialog/flutter_file_dialog.dart'; |
|
||||||
import 'package:neon/src/platform/platform.dart'; |
|
||||||
import 'package:universal_io/io.dart'; |
|
||||||
|
|
||||||
/// Displays a dialog for selecting a location where to save a file with the [data] content. |
|
||||||
/// |
|
||||||
/// Set the the suggested [fileName] to use when saving the file. |
|
||||||
/// |
|
||||||
/// Returns the path of the saved file or `null` if the operation was cancelled. |
|
||||||
Future<String?> saveFileWithPickDialog(final String fileName, final Uint8List data) async { |
|
||||||
if (NeonPlatform.instance.shouldUseFileDialog) { |
|
||||||
// TODO: https://github.com/nextcloud/neon/issues/8 |
|
||||||
return FlutterFileDialog.saveFile( |
|
||||||
params: SaveFileDialogParams( |
|
||||||
data: data, |
|
||||||
fileName: fileName, |
|
||||||
), |
|
||||||
); |
|
||||||
} else { |
|
||||||
final result = await FilePicker.platform.saveFile( |
|
||||||
fileName: fileName, |
|
||||||
); |
|
||||||
if (result != null) { |
|
||||||
await File(result).writeAsBytes(data); |
|
||||||
} |
|
||||||
|
|
||||||
return result; |
|
||||||
} |
|
||||||
} |
|
@ -0,0 +1,30 @@ |
|||||||
|
import 'package:meta/meta.dart'; |
||||||
|
import 'package:neon/l10n/localizations.dart'; |
||||||
|
import 'package:neon/settings.dart'; |
||||||
|
|
||||||
|
@internal |
||||||
|
@immutable |
||||||
|
class SyncMappingOptions extends OptionsCollection { |
||||||
|
SyncMappingOptions(super.storage); |
||||||
|
|
||||||
|
@override |
||||||
|
late final List<Option<dynamic>> options = [ |
||||||
|
automaticSync, |
||||||
|
]; |
||||||
|
|
||||||
|
late final automaticSync = ToggleOption( |
||||||
|
storage: storage, |
||||||
|
key: SyncMappingOptionKeys.automaticSync, |
||||||
|
label: (final context) => NeonLocalizations.of(context).syncOptionsAutomaticSync, |
||||||
|
defaultValue: true, |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
enum SyncMappingOptionKeys implements Storable { |
||||||
|
automaticSync._('automatic-sync'); |
||||||
|
|
||||||
|
const SyncMappingOptionKeys._(this.value); |
||||||
|
|
||||||
|
@override |
||||||
|
final String value; |
||||||
|
} |
@ -0,0 +1,44 @@ |
|||||||
|
import 'package:flutter/material.dart'; |
||||||
|
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; |
||||||
|
import 'package:neon/l10n/localizations.dart'; |
||||||
|
import 'package:neon/src/sync/models/mapping.dart'; |
||||||
|
import 'package:neon/src/theme/colors.dart'; |
||||||
|
|
||||||
|
class SyncStatusIcon extends StatelessWidget { |
||||||
|
const SyncStatusIcon({ |
||||||
|
required this.status, |
||||||
|
this.size, |
||||||
|
super.key, |
||||||
|
}); |
||||||
|
|
||||||
|
final SyncMappingStatus status; |
||||||
|
final double? size; |
||||||
|
|
||||||
|
@override |
||||||
|
Widget build(final BuildContext context) { |
||||||
|
final (icon, color, semanticLabel) = switch (status) { |
||||||
|
SyncMappingStatus.unknown => ( |
||||||
|
MdiIcons.cloudQuestion, |
||||||
|
NcColors.error, |
||||||
|
NeonLocalizations.of(context).syncOptionsStatusUnknown, |
||||||
|
), |
||||||
|
SyncMappingStatus.incomplete => ( |
||||||
|
MdiIcons.cloudSync, |
||||||
|
NcColors.warning, |
||||||
|
NeonLocalizations.of(context).syncOptionsStatusIncomplete, |
||||||
|
), |
||||||
|
SyncMappingStatus.complete => ( |
||||||
|
MdiIcons.cloudCheck, |
||||||
|
NcColors.success, |
||||||
|
NeonLocalizations.of(context).syncOptionsStatusComplete, |
||||||
|
), |
||||||
|
}; |
||||||
|
|
||||||
|
return Icon( |
||||||
|
icon, |
||||||
|
color: color, |
||||||
|
size: size, |
||||||
|
semanticLabel: semanticLabel, |
||||||
|
); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,4 @@ |
|||||||
|
export 'package:neon/src/sync/models/conflicts.dart'; |
||||||
|
export 'package:neon/src/sync/models/implementation.dart'; |
||||||
|
export 'package:neon/src/sync/models/mapping.dart'; |
||||||
|
export 'package:synchronize/synchronize.dart'; |
@ -0,0 +1,105 @@ |
|||||||
|
part of '../neon_files.dart'; |
||||||
|
|
||||||
|
@immutable |
||||||
|
class FilesSync implements SyncImplementation<FilesSyncMapping, WebDavFile, FileSystemEntity> { |
||||||
|
const FilesSync(); |
||||||
|
|
||||||
|
@override |
||||||
|
String get appId => AppIDs.files; |
||||||
|
|
||||||
|
@override |
||||||
|
Future<FilesSyncSources> getSources(final Account account, final FilesSyncMapping mapping) async { |
||||||
|
// This shouldn't be necessary, but it sadly is because of https://github.com/flutter/flutter/issues/25659. |
||||||
|
// Alternative would be to use https://pub.dev/packages/shared_storage, |
||||||
|
// but to be efficient we'd need https://github.com/alexrintt/shared-storage/issues/91 |
||||||
|
// or copy the files to the app cache (which is also not optimal). |
||||||
|
if (Platform.isAndroid && !await Permission.manageExternalStorage.request().isGranted) { |
||||||
|
throw const MissingPermissionException(Permission.manageExternalStorage); |
||||||
|
} |
||||||
|
return FilesSyncSources( |
||||||
|
account.client, |
||||||
|
mapping.remotePath, |
||||||
|
mapping.localPath, |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
Map<String, dynamic> serializeMapping(final FilesSyncMapping mapping) => mapping.toJson(); |
||||||
|
|
||||||
|
@override |
||||||
|
FilesSyncMapping deserializeMapping(final Map<String, dynamic> json) => FilesSyncMapping.fromJson(json); |
||||||
|
|
||||||
|
@override |
||||||
|
Future<FilesSyncMapping?> addMapping(final BuildContext context, final Account account) async { |
||||||
|
final accountsBloc = NeonProvider.of<AccountsBloc>(context); |
||||||
|
final appsBloc = accountsBloc.getAppsBlocFor(account); |
||||||
|
final filesBloc = appsBloc.getAppBlocByID(AppIDs.files)! as FilesBloc; |
||||||
|
final filesBrowserBloc = filesBloc.getNewFilesBrowserBloc(); |
||||||
|
|
||||||
|
final remotePath = await showDialog<PathUri>( |
||||||
|
context: context, |
||||||
|
builder: (final context) => FilesChooseFolderDialog( |
||||||
|
bloc: filesBrowserBloc, |
||||||
|
filesBloc: filesBloc, |
||||||
|
originalPath: PathUri.cwd(), |
||||||
|
), |
||||||
|
); |
||||||
|
filesBrowserBloc.dispose(); |
||||||
|
if (remotePath == null) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
final localPath = await FileUtils.pickDirectory(); |
||||||
|
if (localPath == null) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
if (!context.mounted) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
return FilesSyncMapping( |
||||||
|
appId: AppIDs.files, |
||||||
|
accountId: account.id, |
||||||
|
remotePath: remotePath, |
||||||
|
localPath: Directory(localPath), |
||||||
|
journal: SyncJournal(), |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
String getMappingDisplayTitle(final FilesSyncMapping mapping) => mapping.remotePath.toString(); |
||||||
|
|
||||||
|
@override |
||||||
|
String getMappingDisplaySubtitle(final FilesSyncMapping mapping) => mapping.localPath.path; |
||||||
|
|
||||||
|
@override |
||||||
|
String getMappingId(final FilesSyncMapping mapping) => |
||||||
|
'${Uri.encodeComponent(mapping.remotePath.toString())}-${Uri.encodeComponent(mapping.localPath.path)}'; |
||||||
|
|
||||||
|
@override |
||||||
|
Widget getConflictDetailsLocal(final BuildContext context, final FileSystemEntity object) { |
||||||
|
final stat = object.statSync(); |
||||||
|
return FilesFileTile( |
||||||
|
showFullPath: true, |
||||||
|
filesBloc: NeonProvider.of<FilesBloc>(context), |
||||||
|
details: FileDetails( |
||||||
|
uri: PathUri.parse(object.path), |
||||||
|
size: stat.size, |
||||||
|
etag: '', |
||||||
|
mimeType: '', |
||||||
|
lastModified: stat.modified, |
||||||
|
hasPreview: false, |
||||||
|
isFavorite: false, |
||||||
|
), |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
Widget getConflictDetailsRemote(final BuildContext context, final WebDavFile object) => FilesFileTile( |
||||||
|
showFullPath: true, |
||||||
|
filesBloc: NeonProvider.of<FilesBloc>(context), |
||||||
|
details: FileDetails.fromWebDav( |
||||||
|
file: object, |
||||||
|
), |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,63 @@ |
|||||||
|
import 'dart:async'; |
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart'; |
||||||
|
import 'package:json_annotation/json_annotation.dart'; |
||||||
|
import 'package:neon/sync.dart'; |
||||||
|
import 'package:nextcloud/webdav.dart' as webdav; |
||||||
|
import 'package:nextcloud/webdav.dart'; |
||||||
|
import 'package:universal_io/io.dart'; |
||||||
|
import 'package:watcher/watcher.dart'; |
||||||
|
|
||||||
|
part 'mapping.g.dart'; |
||||||
|
|
||||||
|
@JsonSerializable() |
||||||
|
class FilesSyncMapping implements SyncMapping<webdav.WebDavFile, FileSystemEntity> { |
||||||
|
FilesSyncMapping({ |
||||||
|
required this.accountId, |
||||||
|
required this.appId, |
||||||
|
required this.journal, |
||||||
|
required this.remotePath, |
||||||
|
required this.localPath, |
||||||
|
}); |
||||||
|
|
||||||
|
factory FilesSyncMapping.fromJson(final Map<String, dynamic> json) => _$FilesSyncMappingFromJson(json); |
||||||
|
Map<String, dynamic> toJson() => _$FilesSyncMappingToJson(this); |
||||||
|
|
||||||
|
@override |
||||||
|
final String accountId; |
||||||
|
|
||||||
|
@override |
||||||
|
final String appId; |
||||||
|
|
||||||
|
@override |
||||||
|
final SyncJournal journal; |
||||||
|
|
||||||
|
final PathUri remotePath; |
||||||
|
|
||||||
|
@JsonKey( |
||||||
|
fromJson: _directoryFromJson, |
||||||
|
toJson: _directoryToJson, |
||||||
|
) |
||||||
|
final Directory localPath; |
||||||
|
|
||||||
|
static Directory _directoryFromJson(final String value) => Directory(value); |
||||||
|
static String _directoryToJson(final Directory value) => value.path; |
||||||
|
|
||||||
|
StreamSubscription<WatchEvent>? _subscription; |
||||||
|
|
||||||
|
@override |
||||||
|
void watch(final void Function() onUpdated) { |
||||||
|
debugPrint('Watching file changes: $localPath'); |
||||||
|
_subscription ??= DirectoryWatcher(localPath.path).events.listen( |
||||||
|
(final event) { |
||||||
|
debugPrint('Registered file change: ${event.path} ${event.type}'); |
||||||
|
onUpdated(); |
||||||
|
}, |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
void dispose() { |
||||||
|
unawaited(_subscription?.cancel()); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,23 @@ |
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND |
||||||
|
|
||||||
|
part of 'mapping.dart'; |
||||||
|
|
||||||
|
// ************************************************************************** |
||||||
|
// JsonSerializableGenerator |
||||||
|
// ************************************************************************** |
||||||
|
|
||||||
|
FilesSyncMapping _$FilesSyncMappingFromJson(Map<String, dynamic> json) => FilesSyncMapping( |
||||||
|
accountId: json['accountId'] as String, |
||||||
|
appId: json['appId'] as String, |
||||||
|
journal: SyncJournal.fromJson(json['journal'] as Map<String, dynamic>), |
||||||
|
remotePath: Uri.parse(json['remotePath'] as String), |
||||||
|
localPath: FilesSyncMapping._directoryFromJson(json['localPath'] as String), |
||||||
|
); |
||||||
|
|
||||||
|
Map<String, dynamic> _$FilesSyncMappingToJson(FilesSyncMapping instance) => <String, dynamic>{ |
||||||
|
'accountId': instance.accountId, |
||||||
|
'appId': instance.appId, |
||||||
|
'journal': instance.journal, |
||||||
|
'remotePath': instance.remotePath.toString(), |
||||||
|
'localPath': FilesSyncMapping._directoryToJson(instance.localPath), |
||||||
|
}; |
@ -0,0 +1,142 @@ |
|||||||
|
part of '../neon_files.dart'; |
||||||
|
|
||||||
|
class FilesSyncSources implements SyncSources<WebDavFile, FileSystemEntity> { |
||||||
|
FilesSyncSources( |
||||||
|
final NextcloudClient client, |
||||||
|
final PathUri webdavBaseDir, |
||||||
|
final Directory ioBaseDir, |
||||||
|
) : sourceA = FilesSyncSourceWebDavFile(client, webdavBaseDir), |
||||||
|
sourceB = FilesSyncSourceFileSystemEntity(client, ioBaseDir); |
||||||
|
|
||||||
|
@override |
||||||
|
final SyncSource<WebDavFile, FileSystemEntity> sourceA; |
||||||
|
|
||||||
|
@override |
||||||
|
final SyncSource<FileSystemEntity, WebDavFile> sourceB; |
||||||
|
|
||||||
|
@override |
||||||
|
SyncConflictSolution? findSolution(final SyncObject<WebDavFile> objectA, final SyncObject<FileSystemEntity> objectB) { |
||||||
|
if (objectA.data.isDirectory && objectB.data is Directory) { |
||||||
|
return SyncConflictSolution.overwriteA; |
||||||
|
} |
||||||
|
|
||||||
|
return null; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
class FilesSyncSourceWebDavFile implements SyncSource<WebDavFile, FileSystemEntity> { |
||||||
|
FilesSyncSourceWebDavFile( |
||||||
|
this.client, |
||||||
|
this.baseDir, |
||||||
|
); |
||||||
|
|
||||||
|
/// [NextcloudClient] used by the WebDAV part. |
||||||
|
final NextcloudClient client; |
||||||
|
|
||||||
|
/// Base directory on the WebDAV server. |
||||||
|
final PathUri baseDir; |
||||||
|
|
||||||
|
final props = WebDavPropWithoutValues.fromBools( |
||||||
|
davgetetag: true, |
||||||
|
davgetlastmodified: true, |
||||||
|
nchaspreview: true, |
||||||
|
ocsize: true, |
||||||
|
ocfavorite: true, |
||||||
|
); |
||||||
|
|
||||||
|
PathUri _uri(final SyncObject<dynamic> object) => baseDir.join(PathUri.parse(object.id)); |
||||||
|
|
||||||
|
@override |
||||||
|
Future<List<SyncObject<WebDavFile>>> listObjects() async => (await client.webdav.propfind( |
||||||
|
baseDir, |
||||||
|
prop: props, |
||||||
|
depth: WebDavDepth.infinity, |
||||||
|
)) |
||||||
|
.toWebDavFiles() |
||||||
|
.sublist(1) |
||||||
|
.map( |
||||||
|
(final file) => ( |
||||||
|
id: file.path.pathSegments.sublist(baseDir.pathSegments.length).join('/'), |
||||||
|
data: file, |
||||||
|
), |
||||||
|
) |
||||||
|
.toList(); |
||||||
|
|
||||||
|
@override |
||||||
|
Future<String> getObjectETag(final SyncObject<WebDavFile> object) async => |
||||||
|
object.data.isDirectory ? '' : object.data.etag!; |
||||||
|
|
||||||
|
@override |
||||||
|
Future<SyncObject<WebDavFile>> writeObject(final SyncObject<FileSystemEntity> object) async { |
||||||
|
if (object.data is File) { |
||||||
|
final stat = await object.data.stat(); |
||||||
|
await client.webdav.putFile( |
||||||
|
object.data as File, |
||||||
|
stat, |
||||||
|
_uri(object), |
||||||
|
lastModified: stat.modified, |
||||||
|
); |
||||||
|
} else if (object.data is Directory) { |
||||||
|
await client.webdav.mkcol(_uri(object)); |
||||||
|
} else { |
||||||
|
throw Exception('Unable to sync FileSystemEntity of type ${object.data.runtimeType}'); |
||||||
|
} |
||||||
|
return ( |
||||||
|
id: object.id, |
||||||
|
data: (await client.webdav.propfind( |
||||||
|
_uri(object), |
||||||
|
prop: props, |
||||||
|
depth: WebDavDepth.zero, |
||||||
|
)) |
||||||
|
.toWebDavFiles() |
||||||
|
.single, |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
Future<void> deleteObject(final SyncObject<WebDavFile> object) async => client.webdav.delete(_uri(object)); |
||||||
|
} |
||||||
|
|
||||||
|
class FilesSyncSourceFileSystemEntity implements SyncSource<FileSystemEntity, WebDavFile> { |
||||||
|
FilesSyncSourceFileSystemEntity( |
||||||
|
this.client, |
||||||
|
this.baseDir, |
||||||
|
); |
||||||
|
|
||||||
|
/// [NextcloudClient] used by the WebDAV part. |
||||||
|
final NextcloudClient client; |
||||||
|
|
||||||
|
/// Base directory on the local filesystem. |
||||||
|
final Directory baseDir; |
||||||
|
|
||||||
|
@override |
||||||
|
Future<List<SyncObject<FileSystemEntity>>> listObjects() async => baseDir.listSync(recursive: true).map( |
||||||
|
(final e) { |
||||||
|
var path = p.relative(e.path, from: baseDir.path); |
||||||
|
if (path.endsWith('/')) { |
||||||
|
path = path.substring(0, path.length - 1); |
||||||
|
} |
||||||
|
return (id: path, data: e); |
||||||
|
}, |
||||||
|
).toList(); |
||||||
|
|
||||||
|
@override |
||||||
|
Future<String> getObjectETag(final SyncObject<FileSystemEntity> object) async => |
||||||
|
object.data is Directory ? '' : object.data.statSync().modified.millisecondsSinceEpoch.toString(); |
||||||
|
|
||||||
|
@override |
||||||
|
Future<SyncObject<FileSystemEntity>> writeObject(final SyncObject<WebDavFile> object) async { |
||||||
|
if (object.data.isDirectory) { |
||||||
|
final dir = Directory(p.join(baseDir.path, object.id))..createSync(); |
||||||
|
return (id: object.id, data: dir); |
||||||
|
} else { |
||||||
|
final file = File(p.join(baseDir.path, object.id)); |
||||||
|
await client.webdav.getFile(object.data.path, file); |
||||||
|
await file.setLastModified(object.data.lastModified!); |
||||||
|
return (id: object.id, data: file); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
Future<void> deleteObject(final SyncObject<FileSystemEntity> object) async => object.data.delete(); |
||||||
|
} |
@ -0,0 +1,94 @@ |
|||||||
|
part of '../neon_files.dart'; |
||||||
|
|
||||||
|
class FilesFileTile extends StatelessWidget { |
||||||
|
const FilesFileTile({ |
||||||
|
required this.filesBloc, |
||||||
|
required this.details, |
||||||
|
this.trailing, |
||||||
|
this.onTap, |
||||||
|
this.uploadProgress, |
||||||
|
this.downloadProgress, |
||||||
|
this.showFullPath = false, |
||||||
|
super.key, |
||||||
|
}); |
||||||
|
|
||||||
|
final FilesBloc filesBloc; |
||||||
|
final FileDetails details; |
||||||
|
final Widget? trailing; |
||||||
|
final GestureTapCallback? onTap; |
||||||
|
final int? uploadProgress; |
||||||
|
final int? downloadProgress; |
||||||
|
final bool showFullPath; |
||||||
|
|
||||||
|
@override |
||||||
|
Widget build(final BuildContext context) { |
||||||
|
Widget icon = Center( |
||||||
|
child: uploadProgress != null || downloadProgress != null |
||||||
|
? Column( |
||||||
|
children: [ |
||||||
|
Icon( |
||||||
|
uploadProgress != null ? MdiIcons.upload : MdiIcons.download, |
||||||
|
color: Theme.of(context).colorScheme.primary, |
||||||
|
), |
||||||
|
LinearProgressIndicator( |
||||||
|
value: (uploadProgress ?? downloadProgress)! / 100, |
||||||
|
), |
||||||
|
], |
||||||
|
) |
||||||
|
: FilePreview( |
||||||
|
bloc: filesBloc, |
||||||
|
details: details, |
||||||
|
withBackground: true, |
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(8)), |
||||||
|
), |
||||||
|
); |
||||||
|
if (details.isFavorite ?? false) { |
||||||
|
icon = Stack( |
||||||
|
children: [ |
||||||
|
icon, |
||||||
|
const Align( |
||||||
|
alignment: Alignment.bottomRight, |
||||||
|
child: Icon( |
||||||
|
Icons.star, |
||||||
|
size: 14, |
||||||
|
color: NcColors.starredColor, |
||||||
|
), |
||||||
|
), |
||||||
|
], |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
return ListTile( |
||||||
|
onTap: onTap, |
||||||
|
title: Text( |
||||||
|
showFullPath ? details.uri.path : details.name, |
||||||
|
overflow: TextOverflow.ellipsis, |
||||||
|
), |
||||||
|
subtitle: Row( |
||||||
|
children: [ |
||||||
|
if (details.lastModified != null) ...[ |
||||||
|
RelativeTime( |
||||||
|
date: details.lastModified!, |
||||||
|
), |
||||||
|
], |
||||||
|
if (details.size != null && details.size! > 0) ...[ |
||||||
|
const SizedBox( |
||||||
|
width: 10, |
||||||
|
), |
||||||
|
Text( |
||||||
|
filesize(details.size, 1), |
||||||
|
style: DefaultTextStyle.of(context).style.copyWith( |
||||||
|
color: Colors.grey, |
||||||
|
), |
||||||
|
), |
||||||
|
], |
||||||
|
], |
||||||
|
), |
||||||
|
leading: SizedBox.square( |
||||||
|
dimension: 40, |
||||||
|
child: icon, |
||||||
|
), |
||||||
|
trailing: trailing, |
||||||
|
); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,3 @@ |
|||||||
|
# synchronize |
||||||
|
|
||||||
|
A simple generic implementation of https://unterwaditzer.net/2016/sync-algorithm.html |
@ -0,0 +1 @@ |
|||||||
|
include: package:neon_lints/dart.yaml |
@ -0,0 +1,60 @@ |
|||||||
|
import 'package:meta/meta.dart'; |
||||||
|
import 'package:synchronize/src/object.dart'; |
||||||
|
|
||||||
|
/// Action to be executed in the sync process. |
||||||
|
@internal |
||||||
|
@immutable |
||||||
|
sealed class SyncAction<T> { |
||||||
|
/// Creates a new action. |
||||||
|
const SyncAction(this.object); |
||||||
|
|
||||||
|
/// The object that is part of the action. |
||||||
|
final SyncObject<T> object; |
||||||
|
|
||||||
|
@override |
||||||
|
String toString() => 'SyncAction<$T>(object: $object)'; |
||||||
|
} |
||||||
|
|
||||||
|
/// Action to delete on object from A. |
||||||
|
@internal |
||||||
|
@immutable |
||||||
|
interface class SyncActionDeleteFromA<T1, T2> extends SyncAction<T1> { |
||||||
|
/// Creates a new action to delete an object from A. |
||||||
|
const SyncActionDeleteFromA(super.object); |
||||||
|
|
||||||
|
@override |
||||||
|
String toString() => 'SyncActionDeleteFromA<$T1, $T2>(object: $object)'; |
||||||
|
} |
||||||
|
|
||||||
|
/// Action to delete an object from B. |
||||||
|
@internal |
||||||
|
@immutable |
||||||
|
interface class SyncActionDeleteFromB<T1, T2> extends SyncAction<T2> { |
||||||
|
/// Creates a new action to delete an object from B. |
||||||
|
const SyncActionDeleteFromB(super.object); |
||||||
|
|
||||||
|
@override |
||||||
|
String toString() => 'SyncActionDeleteFromB<$T1, $T2>(object: $object)'; |
||||||
|
} |
||||||
|
|
||||||
|
/// Action to write an object to A. |
||||||
|
@internal |
||||||
|
@immutable |
||||||
|
interface class SyncActionWriteToA<T1, T2> extends SyncAction<T2> { |
||||||
|
/// Creates a new action to write an object to A. |
||||||
|
const SyncActionWriteToA(super.object); |
||||||
|
|
||||||
|
@override |
||||||
|
String toString() => 'SyncActionWriteToA<$T1, $T2>(object: $object)'; |
||||||
|
} |
||||||
|
|
||||||
|
/// Action to write an object to B. |
||||||
|
@internal |
||||||
|
@immutable |
||||||
|
interface class SyncActionWriteToB<T1, T2> extends SyncAction<T1> { |
||||||
|
/// Creates a new action to write an object to B. |
||||||
|
const SyncActionWriteToB(super.object); |
||||||
|
|
||||||
|
@override |
||||||
|
String toString() => 'SyncActionWriteToB<$T1, $T2>(object: $object)'; |
||||||
|
} |
@ -0,0 +1,61 @@ |
|||||||
|
import 'package:meta/meta.dart'; |
||||||
|
import 'package:synchronize/src/object.dart'; |
||||||
|
|
||||||
|
/// Contains information about a conflict that appeared during sync. |
||||||
|
@immutable |
||||||
|
class SyncConflict<T1, T2> { |
||||||
|
/// Creates a new conflict. |
||||||
|
const SyncConflict({ |
||||||
|
required this.id, |
||||||
|
required this.type, |
||||||
|
required this.objectA, |
||||||
|
required this.objectB, |
||||||
|
this.skipped = false, |
||||||
|
}); |
||||||
|
|
||||||
|
/// Id of the objects involved in the conflict. |
||||||
|
final String id; |
||||||
|
|
||||||
|
/// Type of the conflict that appeared. See [SyncConflictType] for more info. |
||||||
|
final SyncConflictType type; |
||||||
|
|
||||||
|
/// Object A involved in the conflict. |
||||||
|
final SyncObject<T1> objectA; |
||||||
|
|
||||||
|
/// Object B involved in the conflict. |
||||||
|
final SyncObject<T2> objectB; |
||||||
|
|
||||||
|
/// Whether the conflict was skipped by the user, useful for ignoring it later on. |
||||||
|
final bool skipped; |
||||||
|
|
||||||
|
@override |
||||||
|
bool operator ==(final dynamic other) => other is SyncConflict && other.id == id; |
||||||
|
|
||||||
|
@override |
||||||
|
int get hashCode => id.hashCode; |
||||||
|
|
||||||
|
@override |
||||||
|
String toString() => |
||||||
|
'SyncConflict<$T1, $T2>(id: $id, type: $type, objectA: $objectA, objectB: $objectB, skipped: $skipped)'; |
||||||
|
} |
||||||
|
|
||||||
|
/// Types of conflicts that can appear during sync. |
||||||
|
enum SyncConflictType { |
||||||
|
/// New objects with the same id exist on both sides. |
||||||
|
bothNew, |
||||||
|
|
||||||
|
/// Both objects with the same id have changed. |
||||||
|
bothChanged, |
||||||
|
} |
||||||
|
|
||||||
|
/// Ways to resolve [SyncConflict]s. |
||||||
|
enum SyncConflictSolution { |
||||||
|
/// Overwrite the content of object A with the content of object B. |
||||||
|
overwriteA, |
||||||
|
|
||||||
|
/// Overwrite the content of object B with the content of object A. |
||||||
|
overwriteB, |
||||||
|
|
||||||
|
/// Skip the conflict and just do nothing. |
||||||
|
skip, |
||||||
|
} |
@ -0,0 +1,33 @@ |
|||||||
|
import 'package:json_annotation/json_annotation.dart'; |
||||||
|
import 'package:synchronize/src/journal_entry.dart'; |
||||||
|
|
||||||
|
part 'journal.g.dart'; |
||||||
|
|
||||||
|
/// Contains the journal. |
||||||
|
/// |
||||||
|
/// Used for detecting changes and new or deleted files. |
||||||
|
@JsonSerializable() |
||||||
|
class SyncJournal { |
||||||
|
/// Creates a new journal. |
||||||
|
// Note: This must not be const as otherwise the entries are not modifiable when a const set is used! |
||||||
|
SyncJournal([final Set<SyncJournalEntry>? entries]) : entries = entries ?? {}; |
||||||
|
|
||||||
|
/// Deserializes a journal from [json]. |
||||||
|
factory SyncJournal.fromJson(final Map<String, dynamic> json) => _$SyncJournalFromJson(json); |
||||||
|
|
||||||
|
/// Serializes a journal to JSON. |
||||||
|
Map<String, dynamic> toJson() => _$SyncJournalToJson(this); |
||||||
|
|
||||||
|
/// All entries contained in the journal. |
||||||
|
final Set<SyncJournalEntry> entries; |
||||||
|
|
||||||
|
/// Updates an [entry]. |
||||||
|
void updateEntry(final SyncJournalEntry entry) { |
||||||
|
entries |
||||||
|
..remove(entry) |
||||||
|
..add(entry); |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
String toString() => 'SyncJournal(entries: $entries)'; |
||||||
|
} |
@ -0,0 +1,15 @@ |
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND |
||||||
|
|
||||||
|
part of 'journal.dart'; |
||||||
|
|
||||||
|
// ************************************************************************** |
||||||
|
// JsonSerializableGenerator |
||||||
|
// ************************************************************************** |
||||||
|
|
||||||
|
SyncJournal _$SyncJournalFromJson(Map<String, dynamic> json) => SyncJournal( |
||||||
|
(json['entries'] as List<dynamic>).map((e) => SyncJournalEntry.fromJson(e as Map<String, dynamic>)).toSet(), |
||||||
|
); |
||||||
|
|
||||||
|
Map<String, dynamic> _$SyncJournalToJson(SyncJournal instance) => <String, dynamic>{ |
||||||
|
'entries': instance.entries.toList(), |
||||||
|
}; |
@ -0,0 +1,52 @@ |
|||||||
|
import 'package:collection/collection.dart'; |
||||||
|
import 'package:json_annotation/json_annotation.dart'; |
||||||
|
import 'package:meta/meta.dart'; |
||||||
|
import 'package:synchronize/src/journal.dart'; |
||||||
|
|
||||||
|
part 'journal_entry.g.dart'; |
||||||
|
|
||||||
|
/// Stores a single entry in the [SyncJournal]. |
||||||
|
/// |
||||||
|
/// It contains an [id] and ETags for each object, [etagA] and [etagB] respectively. |
||||||
|
@immutable |
||||||
|
@JsonSerializable() |
||||||
|
class SyncJournalEntry { |
||||||
|
/// Creates a new journal entry. |
||||||
|
const SyncJournalEntry( |
||||||
|
this.id, |
||||||
|
this.etagA, |
||||||
|
this.etagB, |
||||||
|
); |
||||||
|
|
||||||
|
/// Deserializes a journal entry from [json]. |
||||||
|
factory SyncJournalEntry.fromJson(final Map<String, dynamic> json) => _$SyncJournalEntryFromJson(json); |
||||||
|
|
||||||
|
/// Serializes a journal entry to JSON. |
||||||
|
Map<String, dynamic> toJson() => _$SyncJournalEntryToJson(this); |
||||||
|
|
||||||
|
/// Unique ID of the journal entry. |
||||||
|
final String id; |
||||||
|
|
||||||
|
/// ETag of the object A. |
||||||
|
final String etagA; |
||||||
|
|
||||||
|
/// ETag of the object B. |
||||||
|
final String etagB; |
||||||
|
|
||||||
|
@override |
||||||
|
bool operator ==(final Object other) => other is SyncJournalEntry && other.id == id; |
||||||
|
|
||||||
|
@override |
||||||
|
int get hashCode => id.hashCode; |
||||||
|
|
||||||
|
@override |
||||||
|
String toString() => 'SyncJournalEntry(id: $id, etagA: $etagA, etagB: $etagB)'; |
||||||
|
} |
||||||
|
|
||||||
|
/// Extension to find a [SyncJournalEntry]. |
||||||
|
extension SyncJournalEntriesFind on Iterable<SyncJournalEntry> { |
||||||
|
/// Finds the first [SyncJournalEntry] that has the [SyncJournalEntry.id] set to [id]. |
||||||
|
/// |
||||||
|
/// Returns `null` if no matching [SyncJournalEntry] was found. |
||||||
|
SyncJournalEntry? tryFind(final String id) => firstWhereOrNull((final entry) => entry.id == id); |
||||||
|
} |
@ -0,0 +1,19 @@ |
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND |
||||||
|
|
||||||
|
part of 'journal_entry.dart'; |
||||||
|
|
||||||
|
// ************************************************************************** |
||||||
|
// JsonSerializableGenerator |
||||||
|
// ************************************************************************** |
||||||
|
|
||||||
|
SyncJournalEntry _$SyncJournalEntryFromJson(Map<String, dynamic> json) => SyncJournalEntry( |
||||||
|
json['id'] as String, |
||||||
|
json['etagA'] as String, |
||||||
|
json['etagB'] as String, |
||||||
|
); |
||||||
|
|
||||||
|
Map<String, dynamic> _$SyncJournalEntryToJson(SyncJournalEntry instance) => <String, dynamic>{ |
||||||
|
'id': instance.id, |
||||||
|
'etagA': instance.etagA, |
||||||
|
'etagB': instance.etagB, |
||||||
|
}; |
@ -0,0 +1,12 @@ |
|||||||
|
import 'package:collection/collection.dart'; |
||||||
|
|
||||||
|
/// Wraps the actual data contained on each side. |
||||||
|
typedef SyncObject<T> = ({String id, T data}); |
||||||
|
|
||||||
|
/// Extension to find a [SyncObject]. |
||||||
|
extension SyncObjectsFind<T> on Iterable<SyncObject<T>> { |
||||||
|
/// Finds the first [SyncObject] that has the `id` set to [id]. |
||||||
|
/// |
||||||
|
/// Returns `null` if no matching [SyncObject] was found. |
||||||
|
SyncObject<T>? tryFind(final String id) => firstWhereOrNull((final object) => object.id == id); |
||||||
|
} |
@ -0,0 +1,39 @@ |
|||||||
|
import 'dart:async'; |
||||||
|
|
||||||
|
import 'package:meta/meta.dart'; |
||||||
|
import 'package:synchronize/src/conflict.dart'; |
||||||
|
import 'package:synchronize/src/object.dart'; |
||||||
|
|
||||||
|
/// The source the sync uses to sync from and to. |
||||||
|
@immutable |
||||||
|
abstract interface class SyncSource<T1, T2> { |
||||||
|
/// List all the objects. |
||||||
|
FutureOr<List<SyncObject<T1>>> listObjects(); |
||||||
|
|
||||||
|
/// Calculates the ETag of a given [object]. |
||||||
|
/// |
||||||
|
/// Must be something easy to compute like the mtime of a file and preferably not the hash of the whole content in order to be fast. |
||||||
|
FutureOr<String> getObjectETag(final SyncObject<T1> object); |
||||||
|
|
||||||
|
/// Writes the given [object]. |
||||||
|
FutureOr<SyncObject<T1>> writeObject(final SyncObject<T2> object); |
||||||
|
|
||||||
|
/// Deletes the given [object]. |
||||||
|
FutureOr<void> deleteObject(final SyncObject<T1> object); |
||||||
|
} |
||||||
|
|
||||||
|
/// The sources the sync uses to sync from and to. |
||||||
|
@immutable |
||||||
|
abstract interface class SyncSources<T1, T2> { |
||||||
|
/// Source A. |
||||||
|
SyncSource<T1, T2> get sourceA; |
||||||
|
|
||||||
|
/// Source B. |
||||||
|
SyncSource<T2, T1> get sourceB; |
||||||
|
|
||||||
|
/// Automatically find a solution for conflicts that don't matter. Useful e.g. for ignoring new directories. |
||||||
|
SyncConflictSolution? findSolution(final SyncObject<T1> objectA, final SyncObject<T2> objectB); |
||||||
|
|
||||||
|
@override |
||||||
|
String toString() => 'SyncSources<$T1, $T2>(sourceA: $sourceA, sourceB: $sourceB)'; |
||||||
|
} |
@ -0,0 +1,246 @@ |
|||||||
|
import 'package:synchronize/src/action.dart'; |
||||||
|
import 'package:synchronize/src/conflict.dart'; |
||||||
|
import 'package:synchronize/src/journal.dart'; |
||||||
|
import 'package:synchronize/src/journal_entry.dart'; |
||||||
|
import 'package:synchronize/src/object.dart'; |
||||||
|
import 'package:synchronize/src/sources.dart'; |
||||||
|
|
||||||
|
/// Sync between two [SyncSources]s. |
||||||
|
/// |
||||||
|
/// This implementation follows https://unterwaditzer.net/2016/sync-algorithm.html in a generic and abstract way |
||||||
|
/// and should work for any two kinds of sources and objects. |
||||||
|
Future<List<SyncConflict<T1, T2>>> sync<T1, T2>( |
||||||
|
final SyncSources<T1, T2> sources, |
||||||
|
final SyncJournal journal, { |
||||||
|
final Map<String, SyncConflictSolution>? conflictSolutions, |
||||||
|
final bool keepSkipsAsConflicts = false, |
||||||
|
}) async { |
||||||
|
final diff = await computeSyncDiff<T1, T2>( |
||||||
|
sources, |
||||||
|
journal, |
||||||
|
conflictSolutions: conflictSolutions, |
||||||
|
keepSkipsAsConflicts: keepSkipsAsConflicts, |
||||||
|
); |
||||||
|
await executeSyncDiff<T1, T2>( |
||||||
|
sources, |
||||||
|
journal, |
||||||
|
diff, |
||||||
|
); |
||||||
|
return diff.conflicts; |
||||||
|
} |
||||||
|
|
||||||
|
/// Differences between the two sources. |
||||||
|
class SyncDiff<T1, T2> { |
||||||
|
/// Creates a new diff. |
||||||
|
SyncDiff( |
||||||
|
this.actions, |
||||||
|
this.conflicts, |
||||||
|
); |
||||||
|
|
||||||
|
/// Actions required to solve the difference. |
||||||
|
final List<SyncAction<dynamic>> actions; |
||||||
|
|
||||||
|
/// Conflicts without solutions that need to be solved. |
||||||
|
final List<SyncConflict<T1, T2>> conflicts; |
||||||
|
} |
||||||
|
|
||||||
|
/// Executes the actions required to solve the difference. |
||||||
|
Future<void> executeSyncDiff<T1, T2>( |
||||||
|
final SyncSources<T1, T2> sources, |
||||||
|
final SyncJournal journal, |
||||||
|
final SyncDiff<T1, T2> diff, |
||||||
|
) async { |
||||||
|
for (final action in diff.actions) { |
||||||
|
switch (action) { |
||||||
|
case SyncActionDeleteFromA(): |
||||||
|
await sources.sourceA.deleteObject(action.object as SyncObject<T1>); |
||||||
|
journal.entries.removeWhere((final entry) => entry.id == action.object.id); |
||||||
|
case SyncActionDeleteFromB(): |
||||||
|
await sources.sourceB.deleteObject(action.object as SyncObject<T2>); |
||||||
|
journal.entries.removeWhere((final entry) => entry.id == action.object.id); |
||||||
|
case SyncActionWriteToA(): |
||||||
|
final objectA = await sources.sourceA.writeObject(action.object as SyncObject<T2>); |
||||||
|
journal.updateEntry( |
||||||
|
SyncJournalEntry( |
||||||
|
action.object.id, |
||||||
|
await sources.sourceA.getObjectETag(objectA), |
||||||
|
await sources.sourceB.getObjectETag(action.object as SyncObject<T2>), |
||||||
|
), |
||||||
|
); |
||||||
|
case SyncActionWriteToB(): |
||||||
|
final objectB = await sources.sourceB.writeObject(action.object as SyncObject<T1>); |
||||||
|
journal.updateEntry( |
||||||
|
SyncJournalEntry( |
||||||
|
action.object.id, |
||||||
|
await sources.sourceA.getObjectETag(action.object as SyncObject<T1>), |
||||||
|
await sources.sourceB.getObjectETag(objectB), |
||||||
|
), |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/// Computes the difference, useful for displaying if a sync is up to date. |
||||||
|
Future<SyncDiff<T1, T2>> computeSyncDiff<T1, T2>( |
||||||
|
final SyncSources<T1, T2> sources, |
||||||
|
final SyncJournal journal, { |
||||||
|
final Map<String, SyncConflictSolution>? conflictSolutions, |
||||||
|
final bool keepSkipsAsConflicts = false, |
||||||
|
}) async { |
||||||
|
final actions = <SyncAction<dynamic>>[]; |
||||||
|
final conflicts = <SyncConflict<T1, T2>>{}; |
||||||
|
var objectsA = await sources.sourceA.listObjects(); |
||||||
|
var objectsB = await sources.sourceB.listObjects(); |
||||||
|
|
||||||
|
for (final objectA in objectsA) { |
||||||
|
final objectB = objectsB.tryFind(objectA.id); |
||||||
|
final journalEntry = journal.entries.tryFind(objectA.id); |
||||||
|
|
||||||
|
// If the ID exists on side A and the journal, but not on B, it has been deleted on B. Delete it from A and the journal. |
||||||
|
if (journalEntry != null && objectB == null) { |
||||||
|
actions.add(SyncActionDeleteFromA<T1, T2>(objectA)); |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
// If the ID exists on side A and side B, but not in journal, we can not just create it in journal, since the two items might contain different content each. |
||||||
|
if (objectB != null && journalEntry == null) { |
||||||
|
conflicts.add( |
||||||
|
SyncConflict<T1, T2>( |
||||||
|
id: objectA.id, |
||||||
|
type: SyncConflictType.bothNew, |
||||||
|
objectA: objectA, |
||||||
|
objectB: objectB, |
||||||
|
), |
||||||
|
); |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
// If the ID exists on side A, but not on B or the journal, it must have been created on A. Copy the item from A to B and also insert it into journal. |
||||||
|
if (objectB == null || journalEntry == null) { |
||||||
|
actions.add(SyncActionWriteToB<T1, T2>(objectA)); |
||||||
|
continue; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
for (final objectB in objectsB) { |
||||||
|
final objectA = objectsA.tryFind(objectB.id); |
||||||
|
final journalEntry = journal.entries.tryFind(objectB.id); |
||||||
|
|
||||||
|
// If the ID exists on side B and the journal, but not on A, it has been deleted on A. Delete it from B and the journal. |
||||||
|
if (journalEntry != null && objectA == null) { |
||||||
|
actions.add(SyncActionDeleteFromB<T1, T2>(objectB)); |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
// If the ID exists on side B and side A, but not in journal, we can not just create it in journal, since the two items might contain different content each. |
||||||
|
if (objectA != null && journalEntry == null) { |
||||||
|
conflicts.add( |
||||||
|
SyncConflict<T1, T2>( |
||||||
|
id: objectA.id, |
||||||
|
type: SyncConflictType.bothNew, |
||||||
|
objectA: objectA, |
||||||
|
objectB: objectB, |
||||||
|
), |
||||||
|
); |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
// If the ID exists on side B, but not on A or the journal, it must have been created on B. Copy the item from B to A and also insert it into journal. |
||||||
|
if (objectA == null || journalEntry == null) { |
||||||
|
actions.add(SyncActionWriteToA<T1, T2>(objectB)); |
||||||
|
continue; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
objectsA = await sources.sourceA.listObjects(); |
||||||
|
objectsB = await sources.sourceB.listObjects(); |
||||||
|
final entries = journal.entries.toList(); |
||||||
|
for (final entry in entries) { |
||||||
|
final objectA = objectsA.tryFind(entry.id); |
||||||
|
final objectB = objectsB.tryFind(entry.id); |
||||||
|
|
||||||
|
// Remove all entries from journal that don't exist anymore |
||||||
|
if (objectA == null && objectB == null) { |
||||||
|
journal.entries.removeWhere((final e) => e.id == entry.id); |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
if (objectA != null && objectB != null) { |
||||||
|
final changedA = entry.etagA != await sources.sourceA.getObjectETag(objectA); |
||||||
|
final changedB = entry.etagB != await sources.sourceB.getObjectETag(objectB); |
||||||
|
|
||||||
|
if (changedA && changedB) { |
||||||
|
conflicts.add( |
||||||
|
SyncConflict<T1, T2>( |
||||||
|
id: objectA.id, |
||||||
|
type: SyncConflictType.bothChanged, |
||||||
|
objectA: objectA, |
||||||
|
objectB: objectB, |
||||||
|
), |
||||||
|
); |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
if (changedA && !changedB) { |
||||||
|
actions.add(SyncActionWriteToB<T1, T2>(objectA)); |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
if (changedB && !changedA) { |
||||||
|
actions.add(SyncActionWriteToA<T1, T2>(objectB)); |
||||||
|
continue; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
final unsolvedConflicts = <SyncConflict<T1, T2>>[]; |
||||||
|
for (final conflict in conflicts) { |
||||||
|
final solution = conflictSolutions?[conflict.id] ?? sources.findSolution(conflict.objectA, conflict.objectB); |
||||||
|
switch (solution) { |
||||||
|
case SyncConflictSolution.overwriteA: |
||||||
|
actions.add(SyncActionWriteToA<T1, T2>(conflict.objectB)); |
||||||
|
case SyncConflictSolution.overwriteB: |
||||||
|
actions.add(SyncActionWriteToB<T1, T2>(conflict.objectA)); |
||||||
|
case SyncConflictSolution.skip: |
||||||
|
if (keepSkipsAsConflicts) { |
||||||
|
unsolvedConflicts.add( |
||||||
|
SyncConflict<T1, T2>( |
||||||
|
id: conflict.id, |
||||||
|
type: conflict.type, |
||||||
|
objectA: conflict.objectA, |
||||||
|
objectB: conflict.objectB, |
||||||
|
skipped: true, |
||||||
|
), |
||||||
|
); |
||||||
|
} |
||||||
|
case null: |
||||||
|
unsolvedConflicts.add(conflict); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return SyncDiff<T1, T2>( |
||||||
|
_sortActions(actions), |
||||||
|
unsolvedConflicts, |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
List<SyncAction<dynamic>> _sortActions(final List<SyncAction<dynamic>> actions) { |
||||||
|
final addActions = <SyncAction<dynamic>>[]; |
||||||
|
final removeActions = <SyncAction<dynamic>>[]; |
||||||
|
for (final action in actions) { |
||||||
|
switch (action) { |
||||||
|
case SyncActionWriteToA(): |
||||||
|
addActions.add(action); |
||||||
|
case SyncActionWriteToB(): |
||||||
|
addActions.add(action); |
||||||
|
case SyncActionDeleteFromA(): |
||||||
|
removeActions.add(action); |
||||||
|
case SyncActionDeleteFromB(): |
||||||
|
removeActions.add(action); |
||||||
|
} |
||||||
|
} |
||||||
|
return _innerSortActions(addActions)..addAll(_innerSortActions(removeActions).reversed); |
||||||
|
} |
||||||
|
|
||||||
|
List<SyncAction<dynamic>> _innerSortActions(final List<SyncAction<dynamic>> actions) => |
||||||
|
actions..sort((final a, final b) => a.object.id.compareTo(b.object.id)); |
@ -0,0 +1,6 @@ |
|||||||
|
export 'package:synchronize/src/conflict.dart'; |
||||||
|
export 'package:synchronize/src/journal.dart'; |
||||||
|
export 'package:synchronize/src/journal_entry.dart'; |
||||||
|
export 'package:synchronize/src/object.dart'; |
||||||
|
export 'package:synchronize/src/sources.dart'; |
||||||
|
export 'package:synchronize/src/sync.dart'; |
@ -0,0 +1,20 @@ |
|||||||
|
name: synchronize |
||||||
|
version: 1.0.0 |
||||||
|
|
||||||
|
environment: |
||||||
|
sdk: '>=3.0.0 <4.0.0' |
||||||
|
|
||||||
|
dependencies: |
||||||
|
collection: ^1.0.0 |
||||||
|
json_annotation: ^4.8.1 |
||||||
|
meta: ^1.0.0 |
||||||
|
|
||||||
|
dev_dependencies: |
||||||
|
build_runner: ^2.4.6 |
||||||
|
crypto: ^3.0.0 |
||||||
|
json_serializable: ^6.7.1 |
||||||
|
neon_lints: |
||||||
|
git: |
||||||
|
url: https://github.com/nextcloud/neon |
||||||
|
path: packages/neon_lints |
||||||
|
test: ^1.24.9 |
@ -0,0 +1,4 @@ |
|||||||
|
# melos_managed_dependency_overrides: neon_lints |
||||||
|
dependency_overrides: |
||||||
|
neon_lints: |
||||||
|
path: ../neon_lints |
@ -0,0 +1,542 @@ |
|||||||
|
import 'dart:convert'; |
||||||
|
import 'dart:math'; |
||||||
|
|
||||||
|
import 'package:crypto/crypto.dart'; |
||||||
|
import 'package:synchronize/synchronize.dart'; |
||||||
|
import 'package:test/test.dart'; |
||||||
|
|
||||||
|
abstract class Wrap { |
||||||
|
Wrap(this.content); |
||||||
|
|
||||||
|
final String content; |
||||||
|
} |
||||||
|
|
||||||
|
class WrapA extends Wrap { |
||||||
|
WrapA(super.content); |
||||||
|
} |
||||||
|
|
||||||
|
class WrapB extends Wrap { |
||||||
|
WrapB(super.content); |
||||||
|
} |
||||||
|
|
||||||
|
class TestSyncState { |
||||||
|
TestSyncState( |
||||||
|
this.stateA, |
||||||
|
this.stateB, |
||||||
|
); |
||||||
|
|
||||||
|
final Map<String, WrapA> stateA; |
||||||
|
final Map<String, WrapB> stateB; |
||||||
|
} |
||||||
|
|
||||||
|
class TestSyncSourceA implements SyncSource<WrapA, WrapB> { |
||||||
|
TestSyncSourceA(this.state); |
||||||
|
|
||||||
|
final Map<String, WrapA> state; |
||||||
|
|
||||||
|
@override |
||||||
|
Future<List<SyncObject<WrapA>>> listObjects() async => |
||||||
|
state.keys.map((final key) => (id: key, data: state[key]!)).toList(); |
||||||
|
|
||||||
|
@override |
||||||
|
Future<String> getObjectETag(final SyncObject<WrapA> object) async => etagA(object.data.content); |
||||||
|
|
||||||
|
@override |
||||||
|
Future<SyncObject<WrapA>> writeObject(final SyncObject<WrapB> object) async { |
||||||
|
final wrap = WrapA(object.data.content); |
||||||
|
state[object.id] = wrap; |
||||||
|
return (id: object.id, data: wrap); |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
Future<void> deleteObject(final SyncObject<WrapA> object) async => state.remove(object.id); |
||||||
|
} |
||||||
|
|
||||||
|
class TestSyncSourceB implements SyncSource<WrapB, WrapA> { |
||||||
|
TestSyncSourceB(this.state); |
||||||
|
|
||||||
|
final Map<String, WrapB> state; |
||||||
|
|
||||||
|
@override |
||||||
|
Future<List<SyncObject<WrapB>>> listObjects() async => |
||||||
|
state.keys.map((final key) => (id: key, data: state[key]!)).toList(); |
||||||
|
|
||||||
|
@override |
||||||
|
Future<String> getObjectETag(final SyncObject<WrapB> object) async => etagB(object.data.content); |
||||||
|
|
||||||
|
@override |
||||||
|
Future<SyncObject<WrapB>> writeObject(final SyncObject<WrapA> object) async { |
||||||
|
final wrap = WrapB(object.data.content); |
||||||
|
state[object.id] = wrap; |
||||||
|
return (id: object.id, data: wrap); |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
Future<void> deleteObject(final SyncObject<WrapB> object) async => state.remove(object.id); |
||||||
|
} |
||||||
|
|
||||||
|
class TestSyncSources implements SyncSources<WrapA, WrapB> { |
||||||
|
TestSyncSources( |
||||||
|
this.sourceA, |
||||||
|
this.sourceB, |
||||||
|
); |
||||||
|
|
||||||
|
factory TestSyncSources.fromState(final TestSyncState state) => TestSyncSources( |
||||||
|
TestSyncSourceA(state.stateA), |
||||||
|
TestSyncSourceB(state.stateB), |
||||||
|
); |
||||||
|
|
||||||
|
@override |
||||||
|
final SyncSource<WrapA, WrapB> sourceA; |
||||||
|
|
||||||
|
@override |
||||||
|
final SyncSource<WrapB, WrapA> sourceB; |
||||||
|
|
||||||
|
@override |
||||||
|
SyncConflictSolution? findSolution(final SyncObject<WrapA> objectA, final SyncObject<WrapB> objectB) => null; |
||||||
|
} |
||||||
|
|
||||||
|
String etagA(final String content) => sha1.convert(utf8.encode('A$content')).toString(); |
||||||
|
|
||||||
|
String etagB(final String content) => sha1.convert(utf8.encode('B$content')).toString(); |
||||||
|
|
||||||
|
String randomEtag() => sha1.convert(utf8.encode(Random().nextDouble().toString())).toString(); |
||||||
|
|
||||||
|
Future<void> main() async { |
||||||
|
group('sync', () { |
||||||
|
group('stub', () { |
||||||
|
test('all empty', () async { |
||||||
|
final state = TestSyncState({}, {}); |
||||||
|
final sources = TestSyncSources.fromState(state); |
||||||
|
final journal = SyncJournal(); |
||||||
|
|
||||||
|
final conflicts = await sync(sources, journal); |
||||||
|
expect(conflicts, isEmpty); |
||||||
|
expect(state.stateA, isEmpty); |
||||||
|
expect(state.stateB, isEmpty); |
||||||
|
expect(journal.entries, isEmpty); |
||||||
|
}); |
||||||
|
|
||||||
|
group('copy', () { |
||||||
|
group('missing', () { |
||||||
|
test('to A', () async { |
||||||
|
const id = '123'; |
||||||
|
const content = '456'; |
||||||
|
final state = TestSyncState( |
||||||
|
{}, |
||||||
|
{ |
||||||
|
id: WrapB(content), |
||||||
|
}, |
||||||
|
); |
||||||
|
final sources = TestSyncSources.fromState(state); |
||||||
|
final journal = SyncJournal(); |
||||||
|
|
||||||
|
final conflicts = await sync(sources, journal); |
||||||
|
expect(conflicts, isEmpty); |
||||||
|
expect(state.stateA, hasLength(1)); |
||||||
|
expect(state.stateA[id]!.content, content); |
||||||
|
expect(state.stateB, hasLength(1)); |
||||||
|
expect(state.stateB[id]!.content, content); |
||||||
|
expect(journal.entries, hasLength(1)); |
||||||
|
expect(journal.entries.tryFind(id)!.etagA, etagA(content)); |
||||||
|
expect(journal.entries.tryFind(id)!.etagB, etagB(content)); |
||||||
|
}); |
||||||
|
|
||||||
|
test('to B', () async { |
||||||
|
const id = '123'; |
||||||
|
const content = '456'; |
||||||
|
final state = TestSyncState( |
||||||
|
{ |
||||||
|
id: WrapA(content), |
||||||
|
}, |
||||||
|
{}, |
||||||
|
); |
||||||
|
final sources = TestSyncSources.fromState(state); |
||||||
|
final journal = SyncJournal(); |
||||||
|
|
||||||
|
final conflicts = await sync(sources, journal); |
||||||
|
expect(conflicts, isEmpty); |
||||||
|
expect(state.stateA, hasLength(1)); |
||||||
|
expect(state.stateA[id]!.content, content); |
||||||
|
expect(state.stateB, hasLength(1)); |
||||||
|
expect(state.stateB[id]!.content, content); |
||||||
|
expect(journal.entries, hasLength(1)); |
||||||
|
expect(journal.entries.tryFind(id)!.etagA, etagA(content)); |
||||||
|
expect(journal.entries.tryFind(id)!.etagB, etagB(content)); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
group('changed', () { |
||||||
|
test('to A', () async { |
||||||
|
const id = '123'; |
||||||
|
const contentA = '456'; |
||||||
|
const contentB = '789'; |
||||||
|
final state = TestSyncState( |
||||||
|
{ |
||||||
|
id: WrapA(contentA), |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: WrapB(contentB), |
||||||
|
}, |
||||||
|
); |
||||||
|
final sources = TestSyncSources.fromState(state); |
||||||
|
final journal = SyncJournal({ |
||||||
|
SyncJournalEntry(id, etagA(contentA), randomEtag()), |
||||||
|
}); |
||||||
|
|
||||||
|
final conflicts = await sync(sources, journal); |
||||||
|
expect(conflicts, isEmpty); |
||||||
|
expect(state.stateA, hasLength(1)); |
||||||
|
expect(state.stateA[id]!.content, contentB); |
||||||
|
expect(state.stateB, hasLength(1)); |
||||||
|
expect(state.stateB[id]!.content, contentB); |
||||||
|
expect(journal.entries, hasLength(1)); |
||||||
|
expect(journal.entries.tryFind(id)!.etagA, etagA(contentB)); |
||||||
|
expect(journal.entries.tryFind(id)!.etagB, etagB(contentB)); |
||||||
|
}); |
||||||
|
|
||||||
|
test('to B', () async { |
||||||
|
const id = '123'; |
||||||
|
const contentA = '456'; |
||||||
|
const contentB = '789'; |
||||||
|
final state = TestSyncState( |
||||||
|
{ |
||||||
|
id: WrapA(contentA), |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: WrapB(contentB), |
||||||
|
}, |
||||||
|
); |
||||||
|
final sources = TestSyncSources.fromState(state); |
||||||
|
final journal = SyncJournal({ |
||||||
|
SyncJournalEntry(id, randomEtag(), etagB(contentB)), |
||||||
|
}); |
||||||
|
|
||||||
|
final conflicts = await sync(sources, journal); |
||||||
|
expect(conflicts, isEmpty); |
||||||
|
expect(state.stateA, hasLength(1)); |
||||||
|
expect(state.stateA[id]!.content, contentA); |
||||||
|
expect(state.stateB, hasLength(1)); |
||||||
|
expect(state.stateB[id]!.content, contentA); |
||||||
|
expect(journal.entries, hasLength(1)); |
||||||
|
expect(journal.entries.tryFind(id)!.etagA, etagA(contentA)); |
||||||
|
expect(journal.entries.tryFind(id)!.etagB, etagB(contentA)); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
group('delete', () { |
||||||
|
test('from A', () async { |
||||||
|
const id = '123'; |
||||||
|
const content = '456'; |
||||||
|
final state = TestSyncState( |
||||||
|
{ |
||||||
|
id: WrapA(content), |
||||||
|
}, |
||||||
|
{}, |
||||||
|
); |
||||||
|
final sources = TestSyncSources.fromState(state); |
||||||
|
final journal = SyncJournal({ |
||||||
|
SyncJournalEntry(id, etagA(content), etagB(content)), |
||||||
|
}); |
||||||
|
|
||||||
|
final conflicts = await sync(sources, journal); |
||||||
|
expect(conflicts, isEmpty); |
||||||
|
expect(state.stateA, isEmpty); |
||||||
|
expect(state.stateB, isEmpty); |
||||||
|
expect(journal.entries, isEmpty); |
||||||
|
}); |
||||||
|
|
||||||
|
test('from B', () async { |
||||||
|
const id = '123'; |
||||||
|
const content = '456'; |
||||||
|
final state = TestSyncState( |
||||||
|
{}, |
||||||
|
{ |
||||||
|
id: WrapB(content), |
||||||
|
}, |
||||||
|
); |
||||||
|
final sources = TestSyncSources.fromState(state); |
||||||
|
final journal = SyncJournal({ |
||||||
|
SyncJournalEntry(id, etagA(content), etagB(content)), |
||||||
|
}); |
||||||
|
|
||||||
|
final conflicts = await sync(sources, journal); |
||||||
|
expect(conflicts, isEmpty); |
||||||
|
expect(state.stateA, isEmpty); |
||||||
|
expect(state.stateB, isEmpty); |
||||||
|
expect(journal.entries, isEmpty); |
||||||
|
}); |
||||||
|
|
||||||
|
test('from journal', () async { |
||||||
|
const id = '123'; |
||||||
|
const content = '456'; |
||||||
|
final state = TestSyncState({}, {}); |
||||||
|
final sources = TestSyncSources.fromState(state); |
||||||
|
final journal = SyncJournal({ |
||||||
|
SyncJournalEntry(id, etagA(content), etagB(content)), |
||||||
|
}); |
||||||
|
|
||||||
|
final conflicts = await sync(sources, journal); |
||||||
|
expect(conflicts, isEmpty); |
||||||
|
expect(state.stateA, isEmpty); |
||||||
|
expect(state.stateB, isEmpty); |
||||||
|
expect(journal.entries, isEmpty); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
group('conflict', () { |
||||||
|
test('journal missing', () async { |
||||||
|
const id = '123'; |
||||||
|
const content = '456'; |
||||||
|
final state = TestSyncState( |
||||||
|
{ |
||||||
|
id: WrapA(content), |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: WrapB(content), |
||||||
|
}, |
||||||
|
); |
||||||
|
final sources = TestSyncSources.fromState(state); |
||||||
|
final journal = SyncJournal(); |
||||||
|
|
||||||
|
final conflicts = await sync(sources, journal); |
||||||
|
expect(conflicts, hasLength(1)); |
||||||
|
expect(conflicts[0].type, SyncConflictType.bothNew); |
||||||
|
expect(state.stateA, hasLength(1)); |
||||||
|
expect(state.stateA[id]!.content, content); |
||||||
|
expect(state.stateB, hasLength(1)); |
||||||
|
expect(state.stateB[id]!.content, content); |
||||||
|
expect(journal.entries, isEmpty); |
||||||
|
}); |
||||||
|
|
||||||
|
test('both changed', () async { |
||||||
|
const id = '123'; |
||||||
|
const contentA = '456'; |
||||||
|
const contentB = '789'; |
||||||
|
final state = TestSyncState( |
||||||
|
{ |
||||||
|
id: WrapA(contentA), |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: WrapB(contentB), |
||||||
|
}, |
||||||
|
); |
||||||
|
final sources = TestSyncSources.fromState(state); |
||||||
|
final journal = SyncJournal({ |
||||||
|
SyncJournalEntry(id, randomEtag(), randomEtag()), |
||||||
|
}); |
||||||
|
|
||||||
|
final conflicts = await sync(sources, journal); |
||||||
|
expect(conflicts, hasLength(1)); |
||||||
|
expect(conflicts[0].type, SyncConflictType.bothChanged); |
||||||
|
expect(state.stateA, hasLength(1)); |
||||||
|
expect(state.stateA[id]!.content, contentA); |
||||||
|
expect(state.stateB, hasLength(1)); |
||||||
|
expect(state.stateB[id]!.content, contentB); |
||||||
|
expect(journal.entries, hasLength(1)); |
||||||
|
}); |
||||||
|
|
||||||
|
group('solution', () { |
||||||
|
group('journal missing', () { |
||||||
|
test('skip', () async { |
||||||
|
const id = '123'; |
||||||
|
const contentA = '456'; |
||||||
|
const contentB = '789'; |
||||||
|
final state = TestSyncState( |
||||||
|
{ |
||||||
|
id: WrapA(contentA), |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: WrapB(contentB), |
||||||
|
}, |
||||||
|
); |
||||||
|
final sources = TestSyncSources.fromState(state); |
||||||
|
final journal = SyncJournal(); |
||||||
|
|
||||||
|
final conflicts = await sync( |
||||||
|
sources, |
||||||
|
journal, |
||||||
|
conflictSolutions: { |
||||||
|
id: SyncConflictSolution.skip, |
||||||
|
}, |
||||||
|
); |
||||||
|
expect(conflicts, isEmpty); |
||||||
|
expect(state.stateA, hasLength(1)); |
||||||
|
expect(state.stateA[id]!.content, contentA); |
||||||
|
expect(state.stateB, hasLength(1)); |
||||||
|
expect(state.stateB[id]!.content, contentB); |
||||||
|
expect(journal.entries, isEmpty); |
||||||
|
}); |
||||||
|
|
||||||
|
test('overwrite A', () async { |
||||||
|
const id = '123'; |
||||||
|
const contentA = '456'; |
||||||
|
const contentB = '789'; |
||||||
|
final state = TestSyncState( |
||||||
|
{ |
||||||
|
id: WrapA(contentA), |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: WrapB(contentB), |
||||||
|
}, |
||||||
|
); |
||||||
|
final sources = TestSyncSources.fromState(state); |
||||||
|
final journal = SyncJournal(); |
||||||
|
|
||||||
|
final conflicts = await sync( |
||||||
|
sources, |
||||||
|
journal, |
||||||
|
conflictSolutions: { |
||||||
|
id: SyncConflictSolution.overwriteA, |
||||||
|
}, |
||||||
|
); |
||||||
|
expect(conflicts, isEmpty); |
||||||
|
expect(state.stateA, hasLength(1)); |
||||||
|
expect(state.stateA[id]!.content, contentB); |
||||||
|
expect(state.stateB, hasLength(1)); |
||||||
|
expect(state.stateB[id]!.content, contentB); |
||||||
|
expect(journal.entries, hasLength(1)); |
||||||
|
expect(journal.entries.tryFind(id)!.etagA, etagA(contentB)); |
||||||
|
expect(journal.entries.tryFind(id)!.etagB, etagB(contentB)); |
||||||
|
}); |
||||||
|
|
||||||
|
test('overwrite B', () async { |
||||||
|
const id = '123'; |
||||||
|
const contentA = '456'; |
||||||
|
const contentB = '789'; |
||||||
|
final state = TestSyncState( |
||||||
|
{ |
||||||
|
id: WrapA(contentA), |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: WrapB(contentB), |
||||||
|
}, |
||||||
|
); |
||||||
|
final sources = TestSyncSources.fromState(state); |
||||||
|
final journal = SyncJournal(); |
||||||
|
|
||||||
|
final conflicts = await sync( |
||||||
|
sources, |
||||||
|
journal, |
||||||
|
conflictSolutions: { |
||||||
|
id: SyncConflictSolution.overwriteB, |
||||||
|
}, |
||||||
|
); |
||||||
|
expect(conflicts, isEmpty); |
||||||
|
expect(state.stateA, hasLength(1)); |
||||||
|
expect(state.stateA[id]!.content, contentA); |
||||||
|
expect(state.stateB, hasLength(1)); |
||||||
|
expect(state.stateB[id]!.content, contentA); |
||||||
|
expect(journal.entries, hasLength(1)); |
||||||
|
expect(journal.entries.tryFind(id)!.etagA, etagA(contentA)); |
||||||
|
expect(journal.entries.tryFind(id)!.etagB, etagB(contentA)); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
group('both changed', () { |
||||||
|
test('skip', () async { |
||||||
|
const id = '123'; |
||||||
|
const contentA = '456'; |
||||||
|
const contentB = '789'; |
||||||
|
final state = TestSyncState( |
||||||
|
{ |
||||||
|
id: WrapA(contentA), |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: WrapB(contentB), |
||||||
|
}, |
||||||
|
); |
||||||
|
final sources = TestSyncSources.fromState(state); |
||||||
|
final journal = SyncJournal({ |
||||||
|
SyncJournalEntry(id, randomEtag(), randomEtag()), |
||||||
|
}); |
||||||
|
|
||||||
|
final conflicts = await sync( |
||||||
|
sources, |
||||||
|
journal, |
||||||
|
conflictSolutions: { |
||||||
|
id: SyncConflictSolution.skip, |
||||||
|
}, |
||||||
|
); |
||||||
|
expect(conflicts, isEmpty); |
||||||
|
expect(state.stateA, hasLength(1)); |
||||||
|
expect(state.stateA[id]!.content, contentA); |
||||||
|
expect(state.stateB, hasLength(1)); |
||||||
|
expect(state.stateB[id]!.content, contentB); |
||||||
|
expect(journal.entries, hasLength(1)); |
||||||
|
}); |
||||||
|
|
||||||
|
test('overwrite A', () async { |
||||||
|
const id = '123'; |
||||||
|
const contentA = '456'; |
||||||
|
const contentB = '789'; |
||||||
|
final state = TestSyncState( |
||||||
|
{ |
||||||
|
id: WrapA(contentA), |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: WrapB(contentB), |
||||||
|
}, |
||||||
|
); |
||||||
|
final sources = TestSyncSources.fromState(state); |
||||||
|
final journal = SyncJournal({ |
||||||
|
SyncJournalEntry(id, randomEtag(), randomEtag()), |
||||||
|
}); |
||||||
|
|
||||||
|
final conflicts = await sync( |
||||||
|
sources, |
||||||
|
journal, |
||||||
|
conflictSolutions: { |
||||||
|
id: SyncConflictSolution.overwriteA, |
||||||
|
}, |
||||||
|
); |
||||||
|
expect(conflicts, isEmpty); |
||||||
|
expect(state.stateA, hasLength(1)); |
||||||
|
expect(state.stateA[id]!.content, contentB); |
||||||
|
expect(state.stateB, hasLength(1)); |
||||||
|
expect(state.stateB[id]!.content, contentB); |
||||||
|
expect(journal.entries, hasLength(1)); |
||||||
|
expect(journal.entries.tryFind(id)!.etagA, etagA(contentB)); |
||||||
|
expect(journal.entries.tryFind(id)!.etagB, etagB(contentB)); |
||||||
|
}); |
||||||
|
|
||||||
|
test('overwrite B', () async { |
||||||
|
const id = '123'; |
||||||
|
const contentA = '456'; |
||||||
|
const contentB = '789'; |
||||||
|
final state = TestSyncState( |
||||||
|
{ |
||||||
|
id: WrapA(contentA), |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: WrapB(contentB), |
||||||
|
}, |
||||||
|
); |
||||||
|
final sources = TestSyncSources.fromState(state); |
||||||
|
final journal = SyncJournal({ |
||||||
|
SyncJournalEntry(id, randomEtag(), randomEtag()), |
||||||
|
}); |
||||||
|
|
||||||
|
final conflicts = await sync( |
||||||
|
sources, |
||||||
|
journal, |
||||||
|
conflictSolutions: { |
||||||
|
id: SyncConflictSolution.overwriteB, |
||||||
|
}, |
||||||
|
); |
||||||
|
expect(conflicts, isEmpty); |
||||||
|
expect(state.stateA, hasLength(1)); |
||||||
|
expect(state.stateA[id]!.content, contentA); |
||||||
|
expect(state.stateB, hasLength(1)); |
||||||
|
expect(state.stateB[id]!.content, contentA); |
||||||
|
expect(journal.entries, hasLength(1)); |
||||||
|
expect(journal.entries.tryFind(id)!.etagA, etagA(contentA)); |
||||||
|
expect(journal.entries.tryFind(id)!.etagB, etagB(contentA)); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
} |
Loading…
Reference in new issue