Support export writing check history and translate history

This commit is contained in:
Mumulhl
2026-01-24 12:16:34 +08:00
parent baf786c56e
commit abb3a05120
10 changed files with 1728 additions and 38 deletions
+2
View File
@@ -25,5 +25,7 @@ final mddAudioListDao = MddAudioListDao(mainDatabase);
final mddAudioResourceDao = MddAudioResourceDao(mainDatabase);
final wordbookDao = WordbookDao(mainDatabase);
final wordbookTagsDao = WordbookTagsDao(mainDatabase);
final writingCheckHistoryDao = WritingCheckHistoryDao(mainDatabase);
final translateHistoryDao = TranslateHistoryDao(mainDatabase);
final talker = TalkerFlutter.init();
+43 -1
View File
@@ -169,10 +169,18 @@ class WordbookDao extends DatabaseAccessor<AppDatabase>
WordbookDao(super.attachedDatabase);
Future<void> addAllWords(List<WordbookData> data) async {
final existing = await getAllWords();
final existingSet = existing.map((e) => "${e.word}_${e.tag}").toSet();
final toInsert =
data.where((e) => !existingSet.contains("${e.word}_${e.tag}")).toList();
if (toInsert.isNotEmpty) {
await batch((batch) {
batch.insertAll(wordbook, data);
batch.insertAll(wordbook, toInsert);
});
}
}
Future<int> countTotalWords() async {
final count = countAll();
@@ -458,6 +466,26 @@ class WritingCheckHistoryDao extends DatabaseAccessor<AppDatabase>
);
}
Future<void> addAllHistory(List<WritingCheckHistoryData> data) async {
final existing = await getAllHistory();
final existingSet = existing
.map((e) =>
"${e.inputText}_${e.outputText}_${e.createdAt.millisecondsSinceEpoch}")
.toSet();
final toInsert = data.where((e) {
final key =
"${e.inputText}_${e.outputText}_${e.createdAt.millisecondsSinceEpoch}";
return !existingSet.contains(key);
}).toList();
if (toInsert.isNotEmpty) {
await batch((batch) {
batch.insertAll(writingCheckHistory, toInsert);
});
}
}
Future<List<WritingCheckHistoryData>> getAllHistory() {
return (select(writingCheckHistory)
..orderBy(
@@ -493,6 +521,20 @@ class TranslateHistoryDao extends DatabaseAccessor<AppDatabase>
);
}
Future<void> addAllHistory(List<TranslateHistoryData> data) async {
final existing = await getAllHistory();
final existingSet = existing.map((e) => e.inputText).toSet();
final toInsert =
data.where((e) => !existingSet.contains(e.inputText)).toList();
if (toInsert.isNotEmpty) {
await batch((batch) {
batch.insertAll(translateHistory, toInsert);
});
}
}
Future<List<TranslateHistoryData>> getAllHistory() {
return (select(translateHistory)
..orderBy(
+17 -1
View File
@@ -7,12 +7,16 @@ class BackupData {
final List<WordbookData> wordbookWords;
final List<WordbookTag> wordbookTags;
final List<String> history;
final List<WritingCheckHistoryData> writingCheckHistory;
final List<TranslateHistoryData> translateHistory;
BackupData({
required this.version,
required this.wordbookWords,
required this.wordbookTags,
this.history = const [],
this.writingCheckHistory = const [],
this.translateHistory = const [],
});
factory BackupData.fromJson(Map<String, dynamic> json) {
@@ -25,6 +29,16 @@ class BackupData {
.map((e) => WordbookTag.fromJson(e as Map<String, dynamic>))
.toList(),
history: (json["history"] as List<dynamic>?)?.cast<String>() ?? [],
writingCheckHistory: (json["writingCheckHistory"] as List<dynamic>?)
?.map((e) =>
WritingCheckHistoryData.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
translateHistory: (json["translateHistory"] as List<dynamic>?)
?.map((e) =>
TranslateHistoryData.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
);
}
@@ -34,7 +48,9 @@ class BackupData {
"wordbookWords": wordbookWords.map((e) => e.toJson()).toList(),
"wordbookTags": wordbookTags.map((e) => e.toJson()).toList(),
"history": history,
"writingCheckHistory":
writingCheckHistory.map((e) => e.toJson()).toList(),
"translateHistory": translateHistory.map((e) => e.toJson()).toList(),
});
}
}
+19 -2
View File
@@ -77,6 +77,8 @@ class BackupService {
final WordbookDao wordbookDao;
final WordbookTagsDao wordbookTagsDao;
final HistoryDao historyDao;
final WritingCheckHistoryDao writingCheckHistoryDao;
final TranslateHistoryDao translateHistoryDao;
final BackupFileHandler fileHandler;
final WordbookModel? _wordbookModel;
@@ -84,6 +86,8 @@ class BackupService {
required this.wordbookDao,
required this.wordbookTagsDao,
required this.historyDao,
required this.writingCheckHistoryDao,
required this.translateHistoryDao,
required this.fileHandler,
WordbookModel? wordbookModel,
}) : _wordbookModel = wordbookModel;
@@ -96,14 +100,21 @@ class BackupService {
}) async {
final words = await wordbookDao.getAllWords(),
tags = await wordbookTagsDao.getAllTags(),
history = await historyDao.getAllHistory();
history = await historyDao.getAllHistory(),
writingCheckHistory = await writingCheckHistoryDao.getAllHistory(),
translateHistory = await translateHistoryDao.getAllHistory();
if (words.isNotEmpty || history.isNotEmpty) {
if (words.isNotEmpty ||
history.isNotEmpty ||
writingCheckHistory.isNotEmpty ||
translateHistory.isNotEmpty) {
final backupData = BackupData(
version: Backup.version,
wordbookWords: words,
wordbookTags: tags,
history: history.map((e) => e.word).toList(),
writingCheckHistory: writingCheckHistory,
translateHistory: translateHistory,
);
final jsonContent = backupData.toJson();
@@ -149,6 +160,8 @@ class BackupService {
for (final word in backupData.history.reversed) {
await historyDao.addHistory(word);
}
await writingCheckHistoryDao.addAllHistory(backupData.writingCheckHistory);
await translateHistoryDao.addAllHistory(backupData.translateHistory);
}
}
@@ -160,6 +173,8 @@ class Backup {
wordbookDao: wordbookDao,
wordbookTagsDao: wordbookTagsDao,
historyDao: historyDao,
writingCheckHistoryDao: writingCheckHistoryDao,
translateHistoryDao: translateHistoryDao,
fileHandler: DefaultBackupFileHandler(),
);
await service.export(
@@ -175,6 +190,8 @@ class Backup {
wordbookDao: wordbookDao,
wordbookTagsDao: wordbookTagsDao,
historyDao: historyDao,
writingCheckHistoryDao: writingCheckHistoryDao,
translateHistoryDao: translateHistoryDao,
fileHandler: DefaultBackupFileHandler(),
);
await service.import();
-25
View File
@@ -1,25 +0,0 @@
{
"lastValidatedTimestamp": 1755744900263,
"projects": {},
"pnpmfiles": [],
"settings": {
"autoInstallPeers": true,
"dedupeDirectDeps": false,
"dedupeInjectedDeps": true,
"dedupePeerDependents": true,
"dev": true,
"excludeLinksFromLockfile": false,
"hoistPattern": [
"*"
],
"hoistWorkspacePackages": true,
"injectWorkspacePackages": false,
"linkWorkspacePackages": false,
"nodeLinker": "isolated",
"optional": true,
"preferWorkspacePackages": false,
"production": true,
"publicHoistPattern": []
},
"filteredInstall": false
}
-3
View File
@@ -1,3 +0,0 @@
{
"packageManager": "pnpm@10.15.0+sha512.486ebc259d3e999a4e8691ce03b5cac4a71cbeca39372a9b762cb500cfdf0873e2cb16abe3d951b1ee2cf012503f027b98b6584e4df22524e0c7450d9ec7aa7b"
}
+150
View File
@@ -0,0 +1,150 @@
import 'package:ciyue/database/app/app.dart';
import 'package:ciyue/database/app/daos.dart';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
late AppDatabase database;
late WordbookDao wordbookDao;
late TranslateHistoryDao translateHistoryDao;
late WritingCheckHistoryDao writingCheckHistoryDao;
setUp(() {
// Use an in-memory database for testing
database = AppDatabase(NativeDatabase.memory());
wordbookDao = WordbookDao(database);
translateHistoryDao = TranslateHistoryDao(database);
writingCheckHistoryDao = WritingCheckHistoryDao(database);
});
tearDown(() async {
await database.close();
});
group('WordbookDao', () {
test('addAllWords should avoid duplicates based on word and tag', () async {
// 1. Insert an initial record
await wordbookDao.addWord('apple', tag: 1);
final now = DateTime.now();
// 2. Prepare import data
final newWords = [
// Duplicate: same word and tag (should be skipped)
WordbookData(word: 'apple', tag: 1, createdAt: now),
// New: same word but different tag (should be inserted)
WordbookData(word: 'apple', tag: 2, createdAt: now),
// New: different word (should be inserted)
WordbookData(word: 'banana', tag: 1, createdAt: now),
];
// 3. Perform batch add
await wordbookDao.addAllWords(newWords);
// 4. Verify results
final allWords = await wordbookDao.getAllWords();
// We expect 3 records total:
// 1. original 'apple' (tag 1)
// 2. 'apple' (tag 2)
// 3. 'banana' (tag 1)
expect(allWords.length, 3);
// Verify duplicate was not added (count remains 1 for apple/tag:1)
expect(allWords.where((w) => w.word == 'apple' && w.tag == 1).length, 1);
// Verify new records were added
expect(allWords.where((w) => w.word == 'apple' && w.tag == 2).length, 1);
expect(allWords.where((w) => w.word == 'banana').length, 1);
});
});
group('TranslateHistoryDao', () {
test('addAllHistory should avoid duplicates based on inputText', () async {
// 1. Insert an initial record
await translateHistoryDao.addHistory('hello');
final now = DateTime.now();
// 2. Prepare import data
final newHistory = [
// Duplicate: same inputText (should be skipped)
TranslateHistoryData(id: 100, inputText: 'hello', createdAt: now),
// New: different inputText (should be inserted)
TranslateHistoryData(id: 101, inputText: 'world', createdAt: now),
];
// 3. Perform batch add
await translateHistoryDao.addAllHistory(newHistory);
// 4. Verify results
final allHistory = await translateHistoryDao.getAllHistory();
// Expect 2 records: 'hello' and 'world'
expect(allHistory.length, 2);
expect(
allHistory.map((e) => e.inputText), containsAll(['hello', 'world']));
});
});
group('WritingCheckHistoryDao', () {
test('addAllHistory should avoid duplicates based on content matches',
() async {
final now = DateTime.fromMillisecondsSinceEpoch(1000000);
// 1. Insert an initial record directly to set specific fields
await database
.into(database.writingCheckHistory)
.insert(WritingCheckHistoryCompanion(
inputText: Value('input1'),
outputText: Value('output1'),
createdAt: Value(now),
));
// 2. Prepare import data
final newHistory = [
// Duplicate: Exact match on input, output, and time (should be skipped)
WritingCheckHistoryData(
id: 100,
inputText: 'input1',
outputText: 'output1',
createdAt: now),
// New: Different output (should be inserted)
WritingCheckHistoryData(
id: 101,
inputText: 'input1',
outputText: 'output2',
createdAt: now),
// New: Different time (should be inserted)
WritingCheckHistoryData(
id: 102,
inputText: 'input1',
outputText: 'output1',
createdAt: now.add(const Duration(seconds: 1))),
];
// 3. Perform batch add
await writingCheckHistoryDao.addAllHistory(newHistory);
// 4. Verify results
final allHistory = await writingCheckHistoryDao.getAllHistory();
// Expect 3 records:
// 1. Original
// 2. Different output
// 3. Different time
// The exact duplicate should be skipped.
expect(allHistory.length, 3);
// Verify the duplicate logic by counting entries with original content
final originalContentCount = allHistory
.where((e) =>
e.inputText == 'input1' &&
e.outputText == 'output1' &&
e.createdAt.millisecondsSinceEpoch == now.millisecondsSinceEpoch)
.length;
expect(originalContentCount, 1,
reason: "Should satisfy exact match deduplication");
});
});
}
+21
View File
@@ -20,12 +20,29 @@ void main() {
const WordbookTag(id: 1, tag: 'tag1'),
];
final history = ['history1', 'history2'];
final writingCheckHistory = [
WritingCheckHistoryData(
id: 1,
inputText: 'input',
outputText: 'output',
createdAt: now,
)
];
final translateHistory = [
TranslateHistoryData(
id: 1,
inputText: 'input',
createdAt: now,
)
];
final backupData = BackupData(
version: 1,
wordbookWords: words,
wordbookTags: tags,
history: history,
writingCheckHistory: writingCheckHistory,
translateHistory: translateHistory,
);
final jsonString = backupData.toJson();
@@ -35,6 +52,8 @@ void main() {
expect(decoded['wordbookWords'].length, 1);
expect(decoded['wordbookTags'].length, 1);
expect(decoded['history'].length, 2);
expect(decoded['writingCheckHistory'].length, 1);
expect(decoded['translateHistory'].length, 1);
// Verify word data
final wordJson = decoded['wordbookWords'][0];
@@ -54,6 +73,8 @@ void main() {
expect(importedData.wordbookTags.first.tag, 'tag1');
expect(importedData.history, equals(history));
expect(importedData.writingCheckHistory.first.inputText, 'input');
expect(importedData.translateHistory.first.inputText, 'input');
});
});
}
+57 -4
View File
@@ -9,12 +9,21 @@ import 'package:mockito/mockito.dart';
import 'backup_test.mocks.dart';
@GenerateMocks(
[WordbookDao, WordbookTagsDao, HistoryDao, BackupFileHandler, WordbookModel])
@GenerateMocks([
WordbookDao,
WordbookTagsDao,
HistoryDao,
WritingCheckHistoryDao,
TranslateHistoryDao,
BackupFileHandler,
WordbookModel
])
void main() {
late MockWordbookDao mockWordbookDao;
late MockWordbookTagsDao mockWordbookTagsDao;
late MockHistoryDao mockHistoryDao;
late MockWritingCheckHistoryDao mockWritingCheckHistoryDao;
late MockTranslateHistoryDao mockTranslateHistoryDao;
late MockBackupFileHandler mockFileHandler;
late MockWordbookModel mockWordbookModel;
late BackupService backupService;
@@ -23,6 +32,8 @@ void main() {
mockWordbookDao = MockWordbookDao();
mockWordbookTagsDao = MockWordbookTagsDao();
mockHistoryDao = MockHistoryDao();
mockWritingCheckHistoryDao = MockWritingCheckHistoryDao();
mockTranslateHistoryDao = MockTranslateHistoryDao();
mockFileHandler = MockBackupFileHandler();
mockWordbookModel = MockWordbookModel();
@@ -30,6 +41,8 @@ void main() {
wordbookDao: mockWordbookDao,
wordbookTagsDao: mockWordbookTagsDao,
historyDao: mockHistoryDao,
writingCheckHistoryDao: mockWritingCheckHistoryDao,
translateHistoryDao: mockTranslateHistoryDao,
fileHandler: mockFileHandler,
wordbookModel: mockWordbookModel,
);
@@ -45,11 +58,29 @@ void main() {
];
final tags = [const WordbookTag(id: 1, tag: 'tag1')];
final history = [const HistoryData(id: 1, word: 'history1')];
final writingCheckHistory = [
WritingCheckHistoryData(
id: 1,
inputText: 'input',
outputText: 'output',
createdAt: DateTime.now(),
)
];
final translateHistory = [
TranslateHistoryData(
id: 1,
inputText: 'input',
createdAt: DateTime.now(),
)
];
test('should do nothing if no words or history to export', () async {
when(mockWordbookDao.getAllWords()).thenAnswer((_) async => []);
when(mockWordbookTagsDao.getAllTags()).thenAnswer((_) async => []);
when(mockHistoryDao.getAllHistory()).thenAnswer((_) async => []);
when(mockWritingCheckHistoryDao.getAllHistory())
.thenAnswer((_) async => []);
when(mockTranslateHistoryDao.getAllHistory()).thenAnswer((_) async => []);
await backupService.export(
autoExport: false,
@@ -66,6 +97,10 @@ void main() {
when(mockWordbookDao.getAllWords()).thenAnswer((_) async => words);
when(mockWordbookTagsDao.getAllTags()).thenAnswer((_) async => tags);
when(mockHistoryDao.getAllHistory()).thenAnswer((_) async => history);
when(mockWritingCheckHistoryDao.getAllHistory())
.thenAnswer((_) async => writingCheckHistory);
when(mockTranslateHistoryDao.getAllHistory())
.thenAnswer((_) async => translateHistory);
when(mockFileHandler.isAndroid).thenReturn(true);
when(mockFileHandler.writeManualExportAndroid(any))
.thenAnswer((_) async {});
@@ -82,6 +117,10 @@ void main() {
when(mockWordbookDao.getAllWords()).thenAnswer((_) async => words);
when(mockWordbookTagsDao.getAllTags()).thenAnswer((_) async => tags);
when(mockHistoryDao.getAllHistory()).thenAnswer((_) async => history);
when(mockWritingCheckHistoryDao.getAllHistory())
.thenAnswer((_) async => writingCheckHistory);
when(mockTranslateHistoryDao.getAllHistory())
.thenAnswer((_) async => translateHistory);
when(mockFileHandler.isAndroid).thenReturn(false);
when(mockFileHandler.writeManualExportDesktop(any))
.thenAnswer((_) async {});
@@ -98,6 +137,10 @@ void main() {
when(mockWordbookDao.getAllWords()).thenAnswer((_) async => words);
when(mockWordbookTagsDao.getAllTags()).thenAnswer((_) async => tags);
when(mockHistoryDao.getAllHistory()).thenAnswer((_) async => history);
when(mockWritingCheckHistoryDao.getAllHistory())
.thenAnswer((_) async => writingCheckHistory);
when(mockTranslateHistoryDao.getAllHistory())
.thenAnswer((_) async => translateHistory);
when(mockFileHandler.isAndroid).thenReturn(true);
when(mockFileHandler.writeAutoExportAndroid(any, any, any))
.thenAnswer((_) async {});
@@ -116,6 +159,10 @@ void main() {
when(mockWordbookDao.getAllWords()).thenAnswer((_) async => words);
when(mockWordbookTagsDao.getAllTags()).thenAnswer((_) async => tags);
when(mockHistoryDao.getAllHistory()).thenAnswer((_) async => history);
when(mockWritingCheckHistoryDao.getAllHistory())
.thenAnswer((_) async => writingCheckHistory);
when(mockTranslateHistoryDao.getAllHistory())
.thenAnswer((_) async => translateHistory);
when(mockFileHandler.isAndroid).thenReturn(false);
when(mockFileHandler.writeAutoExportDesktop(any, any))
.thenAnswer((_) async {});
@@ -137,7 +184,9 @@ void main() {
"version": 1,
"wordbookWords": [],
"wordbookTags": [],
"history": ["history1", "history2"]
"history": ["history1", "history2"],
"writingCheckHistory": [],
"translateHistory": []
});
when(mockFileHandler.readImportFile())
@@ -145,6 +194,9 @@ void main() {
when(mockWordbookModel.addAllWords(any)).thenAnswer((_) async {});
when(mockWordbookTagsDao.addAllTags(any)).thenAnswer((_) async {});
when(mockHistoryDao.addHistory(any)).thenAnswer((_) async => 1);
when(mockWritingCheckHistoryDao.addAllHistory(any))
.thenAnswer((_) async {});
when(mockTranslateHistoryDao.addAllHistory(any)).thenAnswer((_) async {});
await backupService.import();
@@ -153,6 +205,8 @@ void main() {
// History should be added in reverse order: history2 then history1
verify(mockHistoryDao.addHistory("history2")).called(1);
verify(mockHistoryDao.addHistory("history1")).called(1);
verify(mockWritingCheckHistoryDao.addAllHistory(any)).called(1);
verify(mockTranslateHistoryDao.addAllHistory(any)).called(1);
});
test('should do nothing if file selection canceled', () async {
@@ -164,4 +218,3 @@ void main() {
});
});
}
File diff suppressed because it is too large Load Diff