Compare commits
3 Commits
main
...
feature/sy
Author | SHA1 | Date |
---|---|---|
jld3103 | dfa7332eea | 1 year ago |
jld3103 | be4dbb7073 | 1 year ago |
jld3103 | 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