From 53022cd200d3e65aa2f85fb7e0ca3c614fa7c93f Mon Sep 17 00:00:00 2001 From: Hampus Hammarlund Date: Sat, 7 Sep 2024 15:51:23 +0900 Subject: [PATCH 1/2] feat: add search and text analysis history to backups --- lib/datamodels/user_backup.dart | 14 +++++- lib/services/dictionary_service.dart | 36 ++++++++++++++++ lib/services/isar_service.dart | 12 ++++++ pubspec.lock | 4 +- pubspec.yaml | 2 +- test/services/dictionary_service_test.dart | 50 ++++++++++++++++++++++ test/services/isar_service_test.dart | 30 +++++++++++++ 7 files changed, 144 insertions(+), 4 deletions(-) diff --git a/lib/datamodels/user_backup.dart b/lib/datamodels/user_backup.dart index 404c352..dd3017c 100644 --- a/lib/datamodels/user_backup.dart +++ b/lib/datamodels/user_backup.dart @@ -11,6 +11,8 @@ class UserBackup { final Map vocabSpacedRepetitionDataEnglish; final Map kanjiSpacedRepetitionData; final Map kanjiSpacedRepetitionDataEnglish; + final List searchHistory; + final List textAnalysisHistory; const UserBackup({ required this.dictionaryVersion, @@ -21,6 +23,8 @@ class UserBackup { required this.vocabSpacedRepetitionDataEnglish, required this.kanjiSpacedRepetitionData, required this.kanjiSpacedRepetitionDataEnglish, + required this.searchHistory, + required this.textAnalysisHistory, }); String toBackupJson() { @@ -40,7 +44,10 @@ class UserBackup { SagaseDictionaryConstants.backupKanjiSpacedRepetitionData: kanjiSpacedRepetitionData, SagaseDictionaryConstants.backupKanjiSpacedRepetitionDataEnglish: - kanjiSpacedRepetitionDataEnglish + kanjiSpacedRepetitionDataEnglish, + SagaseDictionaryConstants.backupSearchHistory: searchHistory, + SagaseDictionaryConstants.backupTextAnalysisHistory: + textAnalysisHistory, }, ); } @@ -74,6 +81,11 @@ class UserBackup { kanjiSpacedRepetitionDataEnglish: map[SagaseDictionaryConstants.backupKanjiSpacedRepetitionDataEnglish] .cast(), + searchHistory: (map[SagaseDictionaryConstants.backupSearchHistory] ?? []) + .cast(), + textAnalysisHistory: + (map[SagaseDictionaryConstants.backupTextAnalysisHistory] ?? []) + .cast(), ); } } diff --git a/lib/services/dictionary_service.dart b/lib/services/dictionary_service.dart index 899b069..6a60b99 100644 --- a/lib/services/dictionary_service.dart +++ b/lib/services/dictionary_service.dart @@ -553,6 +553,20 @@ class DictionaryService { } } + // Search history + final List searchHistoryBackups = []; + final searchHistoryItems = await getSearchHistory(); + for (final item in searchHistoryItems) { + searchHistoryBackups.add(item.searchText); + } + + // Text analysis history + final List textAnalysisHistoryBackups = []; + final textAnalysisHistoryItems = await getTextAnalysisHistory(); + for (final item in textAnalysisHistoryItems) { + textAnalysisHistoryBackups.add(item.analysisText); + } + // Create instance DateTime now = DateTime.now(); final backup = UserBackup( @@ -566,6 +580,8 @@ class DictionaryService { kanjiSpacedRepetitionData: kanjiSpacedRepetitionDataBackups, kanjiSpacedRepetitionDataEnglish: kanjiSpacedRepetitionDataEnglishBackups, + searchHistory: searchHistoryBackups, + textAnalysisHistory: textAnalysisHistoryBackups, ); // Create file and write to it @@ -646,6 +662,26 @@ class DictionaryService { await _database.spacedRepetitionDatasDao.set(spacedRepetitionData); } + + // Search history + for (int i = 0; i < userBackup.searchHistory.length; i++) { + await setSearchHistoryItem( + SearchHistoryItem( + id: userBackup.searchHistory.length - i, + searchText: userBackup.searchHistory[i], + ), + ); + } + + // Text analysis history + for (int i = 0; i < userBackup.textAnalysisHistory.length; i++) { + await setTextAnalysisHistoryItem( + TextAnalysisHistoryItem( + id: userBackup.textAnalysisHistory.length - i, + analysisText: userBackup.textAnalysisHistory[i], + ), + ); + } }); return true; diff --git a/lib/services/isar_service.dart b/lib/services/isar_service.dart index 670442c..1fce718 100644 --- a/lib/services/isar_service.dart +++ b/lib/services/isar_service.dart @@ -106,6 +106,16 @@ class IsarService { kanji.spacedRepetitionDataEnglish!.toBackupJson(); } + // Search history + final List searchHistoryBackups = []; + final searchHistoryItems = await _isar.searchHistoryItems + .where() + .sortByTimestampDesc() + .findAll(); + for (final item in searchHistoryItems) { + searchHistoryBackups.add(item.searchQuery); + } + // Create instance DateTime now = DateTime.now(); final backup = UserBackup( @@ -119,6 +129,8 @@ class IsarService { kanjiSpacedRepetitionData: kanjiSpacedRepetitionDataBackups, kanjiSpacedRepetitionDataEnglish: kanjiSpacedRepetitionDataEnglishBackups, + searchHistory: searchHistoryBackups, + textAnalysisHistory: [], ); // Create file and write to it diff --git a/pubspec.lock b/pubspec.lock index e086861..061c498 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -958,8 +958,8 @@ packages: dependency: "direct main" description: path: "." - ref: d9bfefd - resolved-ref: d9bfefd2a69b49955c44ebbc04595368b3a139c0 + ref: "224fe0f" + resolved-ref: "224fe0f519a304a2bb5870afff22bb0e4889c215" url: "https://github.com/Moseco/sagase_dictionary" source: git version: "1.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index 3e78a23..43d5f3a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -72,7 +72,7 @@ dependencies: sagase_dictionary: git: url: https://github.com/Moseco/sagase_dictionary - ref: 'd9bfefd' + ref: '224fe0f' url_launcher: ^6.1.12 dio: ^5.3.3 in_app_review: ^2.0.8 diff --git a/test/services/dictionary_service_test.dart b/test/services/dictionary_service_test.dart index c818dff..10288e3 100644 --- a/test/services/dictionary_service_test.dart +++ b/test/services/dictionary_service_test.dart @@ -256,6 +256,14 @@ void main() { map[SagaseDictionaryConstants.backupKanjiSpacedRepetitionDataEnglish], isEmpty, ); + expect( + map[SagaseDictionaryConstants.backupSearchHistory], + isEmpty, + ); + expect( + map[SagaseDictionaryConstants.backupTextAnalysisHistory], + isEmpty, + ); // Import the backup await service.importUserData(path); @@ -268,6 +276,14 @@ void main() { await service.getFlashcardSets(), isEmpty, ); + expect( + await service.getSearchHistory(), + isEmpty, + ); + expect( + await service.getTextAnalysisHistory(), + isEmpty, + ); // Cleanup await service.close(); @@ -333,6 +349,22 @@ void main() { ).copyWith(interval: 4), ); + // Create search history + await service.setSearchHistoryItem( + const SearchHistoryItem(id: 0, searchText: 'older'), + ); + await service.setSearchHistoryItem( + const SearchHistoryItem(id: 1, searchText: 'newer'), + ); + + // Create text analysis history + await service.setTextAnalysisHistoryItem( + const TextAnalysisHistoryItem(id: 0, analysisText: 'older!'), + ); + await service.setTextAnalysisHistoryItem( + const TextAnalysisHistoryItem(id: 1, analysisText: 'newer!'), + ); + // Export data and validate contents String path = (await service.exportUserData())!; final file = File(path); @@ -422,6 +454,12 @@ void main() { ); expect(spaced5.interval, 4); + // Search history + expect(userBackup.searchHistory, ['newer', 'older']); + + // Text analysis history + expect(userBackup.textAnalysisHistory, ['newer!', 'older!']); + // Close original service await service.close(); @@ -472,6 +510,18 @@ void main() { expect(kanjiList[0].spacedRepetitionData, null); expect(kanjiList[1].spacedRepetitionData!.interval, 4); + // Search history + final searchHistory = await newService.getSearchHistory(); + expect(searchHistory.length, 2); + expect(searchHistory[0].searchText, 'newer'); + expect(searchHistory[1].searchText, 'older'); + + // Text analysis history + final textAnalysisHistory = await newService.getTextAnalysisHistory(); + expect(textAnalysisHistory.length, 2); + expect(textAnalysisHistory[0].analysisText, 'newer!'); + expect(textAnalysisHistory[1].analysisText, 'older!'); + // Cleanup await newService.close(); }); diff --git a/test/services/isar_service_test.dart b/test/services/isar_service_test.dart index 2c3e178..642e729 100644 --- a/test/services/isar_service_test.dart +++ b/test/services/isar_service_test.dart @@ -6,6 +6,7 @@ import 'package:isar/isar.dart'; import 'package:sagase/datamodels/isar/flashcard_set.dart'; import 'package:sagase/datamodels/isar/kanji.dart'; import 'package:sagase/datamodels/isar/my_dictionary_list.dart'; +import 'package:sagase/datamodels/isar/search_history_item.dart'; import 'package:sagase/datamodels/isar/spaced_repetition_data.dart'; import 'package:sagase/datamodels/isar/vocab.dart'; import 'package:sagase/datamodels/user_backup.dart'; @@ -78,6 +79,14 @@ void main() { map[SagaseDictionaryConstants.backupKanjiSpacedRepetitionDataEnglish], isEmpty, ); + expect( + map[SagaseDictionaryConstants.backupSearchHistory], + isEmpty, + ); + expect( + map[SagaseDictionaryConstants.backupTextAnalysisHistory], + isEmpty, + ); }); test('exportUserData - with data', () async { @@ -168,6 +177,17 @@ void main() { ..vocabShowReading = true ..myDictionaryLists = [myDictionaryListId], ); + // Add search history + await isar.searchHistoryItems.put( + SearchHistoryItem() + ..searchQuery = 'older' + ..timestamp = DateTime.now().subtract(const Duration(seconds: 5)), + ); + await isar.searchHistoryItems.put( + SearchHistoryItem() + ..searchQuery = 'newer' + ..timestamp = DateTime.now(), + ); }); final service = IsarService(isar: isar); @@ -253,6 +273,16 @@ void main() { .kanjiSpacedRepetitionDataEnglish['c'.kanjiCodePoint().toString()])); expect(spaced5.interval, 6); + + expect( + userBackup.searchHistory, + ['newer', 'older'], + ); + + expect( + userBackup.textAnalysisHistory, + isEmpty, + ); }); }); } From a4d3a1d840a1009c84c930febb62fd14aea74fe5 Mon Sep 17 00:00:00 2001 From: Hampus Hammarlund Date: Sat, 7 Sep 2024 23:19:32 +0900 Subject: [PATCH 2/2] feat: implement restore from backup --- lib/services/dictionary_service.dart | 17 +++ lib/ui/views/search/search_viewmodel.dart | 7 +- lib/ui/views/settings/settings_view.dart | 6 +- lib/ui/views/settings/settings_viewmodel.dart | 63 +++++---- pubspec.lock | 4 +- pubspec.yaml | 2 +- test/services/dictionary_service_test.dart | 123 ++++++++++++++++++ 7 files changed, 187 insertions(+), 35 deletions(-) diff --git a/lib/services/dictionary_service.dart b/lib/services/dictionary_service.dart index 6a60b99..5f901bf 100644 --- a/lib/services/dictionary_service.dart +++ b/lib/services/dictionary_service.dart @@ -505,6 +505,23 @@ class DictionaryService { } } + Future restoreFromBackup(String backupFilePath) async { + bool result = false; + + await _database.transaction(() async { + // Delete existing user data + await _database.flashcardSetsDao.deleteAll(); + await _database.myDictionaryListsDao.deleteAll(); + await _database.spacedRepetitionDatasDao.deleteAll(); + await deleteSearchHistory(); + + // Import user data from backup + result = await importUserData(backupFilePath); + }); + + return result; + } + Future exportUserData() async { try { // My dictionary lists diff --git a/lib/ui/views/search/search_viewmodel.dart b/lib/ui/views/search/search_viewmodel.dart index e4c86cc..920a040 100644 --- a/lib/ui/views/search/search_viewmodel.dart +++ b/lib/ui/views/search/search_viewmodel.dart @@ -45,8 +45,7 @@ class SearchViewModel extends FutureViewModel { @override Future futureToRun() async { - searchHistory = await _dictionaryService.getSearchHistory(); - notifyListeners(); + await loadSearchHistory(); } void navigateToVocab(Vocab vocab) { @@ -157,8 +156,8 @@ class SearchViewModel extends FutureViewModel { notifyListeners(); } - void clearSearchHistory() { - searchHistory.clear(); + Future loadSearchHistory() async { + searchHistory = await _dictionaryService.getSearchHistory(); _currentSearchHistoryItem = null; notifyListeners(); } diff --git a/lib/ui/views/settings/settings_view.dart b/lib/ui/views/settings/settings_view.dart index 0bbbcab..2f09a9f 100644 --- a/lib/ui/views/settings/settings_view.dart +++ b/lib/ui/views/settings/settings_view.dart @@ -168,11 +168,11 @@ class SettingsView extends StatelessWidget { onPressed: (_) => viewModel.backupData(), ), SettingsTile.navigation( - title: const Text('Import backup'), + title: const Text('Restore from backup'), description: const Text( - 'This will merge the current app data with the data from the backup file. Conflicting data will be overwritten by the backup data.', + 'This will delete all user data and then import new user data from the selected backup file.', ), - onPressed: (_) => viewModel.importData(), + onPressed: (_) => viewModel.restoreFromBackup(), ), ], ), diff --git a/lib/ui/views/settings/settings_viewmodel.dart b/lib/ui/views/settings/settings_viewmodel.dart index b911f09..8d4faf7 100644 --- a/lib/ui/views/settings/settings_viewmodel.dart +++ b/lib/ui/views/settings/settings_viewmodel.dart @@ -161,7 +161,7 @@ class SettingsViewModel extends BaseViewModel { if (response != null && response.confirmed) { _dictionaryService.deleteSearchHistory(); - locator().clearSearchHistory(); + locator().loadSearchHistory(); } } @@ -200,36 +200,49 @@ class SettingsViewModel extends BaseViewModel { await File(path).delete(); } - Future importData() async { - // Ask user for the file they want to import - String? filePath; - try { - filePath = await FlutterFileDialog.pickFile( - params: const OpenFileDialogParams(fileExtensionsFilter: ['sagase']), - ); - } catch (_) { - filePath = null; - } + Future restoreFromBackup() async { + final response = await _dialogService.showCustomDialog( + variant: DialogType.confirmation, + title: 'Restore from backup?', + description: + 'This will delete all user data and then import new user data from the selected backup file.', + mainButtonTitle: 'Confirm', + secondaryButtonTitle: 'Cancel', + barrierDismissible: true, + ); - if (filePath != null) { - // Show progress indicator dialog - _dialogService.showCustomDialog( - variant: DialogType.progressIndicator, - title: 'Importing data', - barrierDismissible: false, - ); + if (response != null && response.confirmed) { + // Ask user for the file they want to import + String? filePath; + try { + filePath = await FlutterFileDialog.pickFile( + params: const OpenFileDialogParams(fileExtensionsFilter: ['sagase']), + ); + } catch (_) { + filePath = null; + } + + if (filePath != null) { + // Show progress indicator dialog + _dialogService.showCustomDialog( + variant: DialogType.progressIndicator, + title: 'Importing data', + barrierDismissible: false, + ); - bool result = await _dictionaryService.importUserData(filePath); + bool result = await _dictionaryService.restoreFromBackup(filePath); - _dialogService.completeDialog(DialogResponse()); + _dialogService.completeDialog(DialogResponse()); - if (result) { - _snackbarService.showSnackbar(message: 'Import successful'); + if (result) { + locator().loadSearchHistory(); + _snackbarService.showSnackbar(message: 'Import successful'); + } else { + _snackbarService.showSnackbar(message: 'Import failed'); + } } else { - _snackbarService.showSnackbar(message: 'Import failed'); + _snackbarService.showSnackbar(message: 'Import cancelled'); } - } else { - _snackbarService.showSnackbar(message: 'Import cancelled'); } } diff --git a/pubspec.lock b/pubspec.lock index 061c498..a7bdfe3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -958,8 +958,8 @@ packages: dependency: "direct main" description: path: "." - ref: "224fe0f" - resolved-ref: "224fe0f519a304a2bb5870afff22bb0e4889c215" + ref: "4638c14" + resolved-ref: "4638c143437ed1e78f8d4c630aea3890e869bdf5" url: "https://github.com/Moseco/sagase_dictionary" source: git version: "1.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index 43d5f3a..110ce2a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -72,7 +72,7 @@ dependencies: sagase_dictionary: git: url: https://github.com/Moseco/sagase_dictionary - ref: '224fe0f' + ref: '4638c14' url_launcher: ^6.1.12 dio: ^5.3.3 in_app_review: ^2.0.8 diff --git a/test/services/dictionary_service_test.dart b/test/services/dictionary_service_test.dart index 10288e3..6fb4bbd 100644 --- a/test/services/dictionary_service_test.dart +++ b/test/services/dictionary_service_test.dart @@ -208,6 +208,129 @@ void main() { }); }); + test('restoreFromBackup', () async { + final service = await setUpDictionaryData(); + + // Create initial data + // Create my dictionary list + final dictionaryList = await service.createMyDictionaryList('list1'); + await service.addToMyDictionaryList( + dictionaryList, + await service.getVocab(2), + ); + await service.addToMyDictionaryList( + dictionaryList, + await service.getKanji('二'), + ); + + // Create flashcard set + final flashcardSet = await service.createFlashcardSet('set1'); + flashcardSet.myDictionaryLists.add(dictionaryList.id); + await service.updateFlashcardSet(flashcardSet); + + // Create spaced repetition data + await service.setSpacedRepetitionData( + SpacedRepetitionData.initial( + dictionaryItem: await service.getVocab(2), + frontType: FrontType.japanese, + ), + ); + + // Create search history + await service.setSearchHistoryItem( + const SearchHistoryItem(id: 0, searchText: 'search'), + ); + + // Create text analysis history + await service.setTextAnalysisHistoryItem( + const TextAnalysisHistoryItem(id: 0, analysisText: 'analysis!'), + ); + + // Export data + String path = (await service.exportUserData())!; + final file = File(path); + + // Create/modify existing data that will be overwritten + // My dictionary list + await service.addToMyDictionaryList( + dictionaryList, + await service.getVocab(3), + ); + await service.addToMyDictionaryList( + dictionaryList, + await service.getKanji('三'), + ); + await service.renameMyDictionaryList(dictionaryList, 'new name'); + + // Flashcard set + flashcardSet.name = 'set name change'; + await service.updateFlashcardSet(flashcardSet); + + // Create spaced repetition data + await service.setSpacedRepetitionData( + SpacedRepetitionData.initial( + dictionaryItem: await service.getVocab(3), + frontType: FrontType.japanese, + ).copyWith(interval: 1), + ); + await service.setSpacedRepetitionData( + SpacedRepetitionData.initial( + dictionaryItem: await service.getKanji('二'), + frontType: FrontType.japanese, + ).copyWith(interval: 4), + ); + await service.setSpacedRepetitionData( + SpacedRepetitionData.initial( + dictionaryItem: await service.getKanji('三'), + frontType: FrontType.english, + ).copyWith(interval: 4), + ); + + // Create search history + await service.setSearchHistoryItem( + const SearchHistoryItem(id: 1, searchText: 'newer'), + ); + + // Create text analysis history + await service.setTextAnalysisHistoryItem( + const TextAnalysisHistoryItem(id: 1, analysisText: 'newer!'), + ); + + // Restore from backup + await service.restoreFromBackup(file.path); + + // Verify contents + final myDictionaryLists = await service.getAllMyDictionaryLists(); + expect(myDictionaryLists.length, 1); + expect(myDictionaryLists[0].name, 'list1'); + final myDictionaryListItems = + await service.getMyDictionaryListItems(myDictionaryLists[0]); + expect(myDictionaryListItems.vocabIds, [2]); + expect(myDictionaryListItems.kanjiIds, ['二'.kanjiCodePoint()]); + + final flashcardSets = await service.getFlashcardSets(); + expect(flashcardSets.length, 1); + expect(flashcardSets[0].name, 'set1'); + final flashcards = + await service.getFlashcardSetFlashcards(flashcardSets[0]); + expect(flashcards.length, 2); + expect(flashcards[0].id, 2); + expect(flashcards[0].spacedRepetitionData, isNotNull); + expect(flashcards[1].id, '二'.kanjiCodePoint()); + expect(flashcards[1].spacedRepetitionData, null); + + final searchHistory = await service.getSearchHistory(); + expect(searchHistory.length, 1); + expect(searchHistory[0].searchText, 'search'); + + final textAnalysisHistory = await service.getTextAnalysisHistory(); + expect(textAnalysisHistory.length, 1); + expect(textAnalysisHistory[0].analysisText, 'analysis!'); + + // Cleanup + await service.close(); + }); + test('exportUserData/importUserData - empty', () async { final service = await setUpDictionaryData();