Rewrite database models as class extensions + data classes
This commit is contained in:
@@ -4,14 +4,13 @@ import 'package:flutter_slidable/flutter_slidable.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:mugiten/bloc/theme/theme_bloc.dart';
|
||||
import 'package:mugiten/components/search/search_results_body/parts/circle_badge.dart';
|
||||
import 'package:mugiten/models/history_entry.dart';
|
||||
import 'package:sqflite/sqlite_api.dart';
|
||||
|
||||
import '../../models/history/history_entry.dart';
|
||||
import '../../routing/routes.dart';
|
||||
import '../../services/datetime.dart';
|
||||
import '../../settings.dart';
|
||||
import '../common/kanji_box.dart';
|
||||
import '../common/loading.dart';
|
||||
|
||||
class HistoryEntryTile extends StatelessWidget {
|
||||
final HistoryEntry entry;
|
||||
@@ -42,22 +41,14 @@ class HistoryEntryTile extends StatelessWidget {
|
||||
MaterialPageRoute get timestamps => MaterialPageRoute(
|
||||
builder: (context) => Scaffold(
|
||||
appBar: AppBar(title: const Text('Last searched')),
|
||||
body: FutureBuilder<List<DateTime>>(
|
||||
future: entry.timestamps(GetIt.instance.get<Database>()),
|
||||
builder: (context, snapshot) {
|
||||
// TODO: provide proper error handling
|
||||
if (snapshot.hasError) return ErrorWidget(snapshot.error!);
|
||||
if (!snapshot.hasData) return const LoadingScreen();
|
||||
return ListView(
|
||||
children: snapshot.data!
|
||||
.map(
|
||||
(ts) => ListTile(
|
||||
title: Text('${formatDate(ts)} ${formatTime(ts)}'),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
},
|
||||
body: ListView(
|
||||
children: entry.timestamps
|
||||
.map(
|
||||
(ts) => ListTile(
|
||||
title: Text('${formatDate(ts)} ${formatTime(ts)}'),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -72,7 +63,7 @@ class HistoryEntryTile extends StatelessWidget {
|
||||
backgroundColor: Colors.red,
|
||||
icon: Icons.delete,
|
||||
onPressed: (_) async {
|
||||
await entry.delete(GetIt.instance.get<Database>());
|
||||
await GetIt.instance.get<Database>().historyEntryDelete(entry.id);
|
||||
onDelete?.call();
|
||||
},
|
||||
),
|
||||
@@ -105,7 +96,7 @@ class HistoryEntryTile extends StatelessWidget {
|
||||
)
|
||||
: Expanded(child: Text(entry.word!))),
|
||||
if (entry.isKanji) Expanded(child: SizedBox.shrink()),
|
||||
if ((entry.timestampCount ?? 0) > 1)
|
||||
if (entry.timestampCount > 1)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: BlocBuilder<ThemeBloc, ThemeState>(
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:jadb/search.dart';
|
||||
import 'package:mugiten/models/library_list.dart';
|
||||
import 'package:ruby_text/ruby_text.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
|
||||
import '../../models/library/library_list.dart';
|
||||
import '../common/loading.dart';
|
||||
|
||||
Future<void> showAddToLibraryDialog({
|
||||
@@ -36,7 +36,7 @@ class AddToLibraryDialog extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _AddToLibraryDialogState extends State<AddToLibraryDialog> {
|
||||
Map<LibraryList, bool>? librariesContainEntry;
|
||||
Map<String, bool>? librariesContainEntry;
|
||||
|
||||
/// A lock to make sure that the local data and the database doesn't
|
||||
/// get out of sync.
|
||||
@@ -46,27 +46,30 @@ class _AddToLibraryDialogState extends State<AddToLibraryDialog> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
LibraryList.allListsContains(
|
||||
db: GetIt.instance.get<Database>(),
|
||||
jmdictEntryId: widget.jmdictEntryId,
|
||||
kanji: widget.kanji,
|
||||
).then((data) => setState(() => librariesContainEntry = data));
|
||||
GetIt.instance
|
||||
.get<Database>()
|
||||
.libraryListAllListsContain(
|
||||
jmdictEntryId: widget.jmdictEntryId,
|
||||
kanji: widget.kanji,
|
||||
)
|
||||
.then((data) => setState(() => librariesContainEntry = data));
|
||||
}
|
||||
|
||||
Future<void> toggleEntry({required LibraryList lib}) async {
|
||||
Future<void> toggleEntry(String libraryName) async {
|
||||
if (toggleLock) return;
|
||||
|
||||
setState(() => toggleLock = true);
|
||||
|
||||
await lib.toggleEntry(
|
||||
db: GetIt.instance.get<Database>(),
|
||||
jmdictEntryId: widget.jmdictEntryId,
|
||||
kanji: widget.kanji,
|
||||
);
|
||||
await GetIt.instance.get<Database>().libraryListToggleEntry(
|
||||
libraryName,
|
||||
jmdictEntryId: widget.jmdictEntryId,
|
||||
kanji: widget.kanji,
|
||||
);
|
||||
|
||||
setState(() {
|
||||
toggleLock = false;
|
||||
librariesContainEntry![lib] = !librariesContainEntry![lib]!;
|
||||
librariesContainEntry![libraryName] =
|
||||
!librariesContainEntry![libraryName]!;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -132,19 +135,19 @@ class _AddToLibraryDialogState extends State<AddToLibraryDialog> {
|
||||
? const LoadingScreen()
|
||||
: ListView(
|
||||
children: librariesContainEntry!.entries.map((e) {
|
||||
final lib = e.key;
|
||||
final libraryName = e.key;
|
||||
final checked = e.value;
|
||||
return ListTile(
|
||||
onTap: () => toggleEntry(lib: lib),
|
||||
onTap: () => toggleEntry(libraryName),
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(vertical: 5),
|
||||
title: Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: checked,
|
||||
onChanged: (_) => toggleEntry(lib: lib),
|
||||
onChanged: (_) => toggleEntry(libraryName),
|
||||
),
|
||||
Text(lib.name),
|
||||
Text(libraryName),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -3,10 +3,9 @@ import 'package:flutter_slidable/flutter_slidable.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:jadb/search.dart';
|
||||
import 'package:mugiten/components/search/search_results_body/search_card.dart';
|
||||
import 'package:mugiten/models/library_list.dart';
|
||||
import 'package:sqflite/sqlite_api.dart';
|
||||
|
||||
import '../../models/library/library_entry.dart';
|
||||
import '../../models/library/library_list.dart';
|
||||
import '../../routing/routes.dart';
|
||||
import '../../settings.dart';
|
||||
import '../common/kanji_box.dart';
|
||||
@@ -14,7 +13,7 @@ import '../common/kanji_box.dart';
|
||||
class LibraryListEntryTile extends StatelessWidget {
|
||||
final int? index;
|
||||
final LibraryList library;
|
||||
final LibraryEntry entry;
|
||||
final LibraryListEntry entry;
|
||||
final void Function()? onDelete;
|
||||
final void Function()? onUpdate;
|
||||
|
||||
@@ -52,11 +51,11 @@ class LibraryListEntryTile extends StatelessWidget {
|
||||
backgroundColor: Colors.red,
|
||||
icon: Icons.delete,
|
||||
onPressed: (_) async {
|
||||
await library.deleteEntry(
|
||||
db: GetIt.instance.get<Database>(),
|
||||
jmdictEntryId: entry.jmdictEntryId,
|
||||
kanji: entry.kanji,
|
||||
);
|
||||
await GetIt.instance.get<Database>().libraryListDeleteEntry(
|
||||
library.name,
|
||||
jmdictEntryId: entry.jmdictEntryId,
|
||||
kanji: entry.kanji,
|
||||
);
|
||||
onDelete?.call();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_slidable/flutter_slidable.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:mugiten/models/library_list.dart';
|
||||
import 'package:sqflite/sqlite_api.dart';
|
||||
|
||||
import '../../models/library/library_list.dart';
|
||||
import '../../routing/routes.dart';
|
||||
import '../common/loading.dart';
|
||||
|
||||
class LibraryListTile extends StatelessWidget {
|
||||
final Widget? leading;
|
||||
@@ -39,13 +38,14 @@ class LibraryListTile extends StatelessWidget {
|
||||
onUpdate?.call();
|
||||
},
|
||||
),
|
||||
// TODO: ask for confirmation before deleting
|
||||
SlidableAction(
|
||||
backgroundColor: Colors.red,
|
||||
icon: Icons.delete,
|
||||
onPressed: (_) async {
|
||||
await library.delete(
|
||||
GetIt.instance.get<Database>(),
|
||||
);
|
||||
await GetIt.instance
|
||||
.get<Database>()
|
||||
.libraryListDeleteList(library.name);
|
||||
onDelete?.call();
|
||||
},
|
||||
),
|
||||
@@ -61,14 +61,7 @@ class LibraryListTile extends StatelessWidget {
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(child: Text(library.name)),
|
||||
FutureBuilder<int>(
|
||||
future: library.length(GetIt.instance.get<Database>()),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError) return ErrorWidget(snapshot.error!);
|
||||
if (!snapshot.hasData) return const LoadingScreen();
|
||||
return Text('${snapshot.data} items');
|
||||
},
|
||||
),
|
||||
Text('${library.totalCount} items'),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:mugiten/models/library_list.dart';
|
||||
import 'package:sqflite/sqlite_api.dart';
|
||||
import '../../models/library/library_list.dart';
|
||||
|
||||
void Function() showNewLibraryDialog(context) => () async {
|
||||
final String? listName = await showDialog<String>(
|
||||
@@ -9,11 +9,10 @@ void Function() showNewLibraryDialog(context) => () async {
|
||||
barrierDismissible: true,
|
||||
builder: (_) => const NewLibraryDialog(),
|
||||
);
|
||||
|
||||
if (listName == null) return;
|
||||
LibraryList.insert(
|
||||
GetIt.instance.get<Database>(),
|
||||
listName,
|
||||
);
|
||||
|
||||
await GetIt.instance.get<Database>().libraryListInsertList(listName);
|
||||
};
|
||||
|
||||
class NewLibraryDialog extends StatefulWidget {
|
||||
@@ -42,10 +41,9 @@ class _NewLibraryDialogState extends State<NewLibraryDialog> {
|
||||
return;
|
||||
}
|
||||
|
||||
final nameAlreadyExists = await LibraryList.exists(
|
||||
GetIt.instance.get<Database>(),
|
||||
proposedListName,
|
||||
);
|
||||
final nameAlreadyExists = await GetIt.instance
|
||||
.get<Database>()
|
||||
.libraryListExists(proposedListName);
|
||||
if (nameAlreadyExists) {
|
||||
setState(() => nameState = _NameState.alreadyExists);
|
||||
} else {
|
||||
|
||||
@@ -4,7 +4,7 @@ import 'package:get_it/get_it.dart';
|
||||
import 'package:jadb/models/word_search/word_search_result.dart';
|
||||
import 'package:jadb/util/text_filtering.dart';
|
||||
import 'package:mugiten/components/library/add_to_library_dialog.dart';
|
||||
import 'package:mugiten/models/library/library_list.dart';
|
||||
import 'package:mugiten/models/library_list.dart';
|
||||
import 'package:sqflite/sqlite_api.dart';
|
||||
|
||||
import './parts/common_badge.dart';
|
||||
@@ -101,11 +101,12 @@ class _SearchResultCardState extends State<SearchResultCard> {
|
||||
SlidableAction(
|
||||
backgroundColor: Colors.yellow,
|
||||
icon: Icons.star,
|
||||
onPressed: (_) => LibraryList.favourites.toggleEntry(
|
||||
db: GetIt.instance.get<Database>(),
|
||||
jmdictEntryId: widget.result.entryId,
|
||||
kanji: null,
|
||||
),
|
||||
onPressed: (_) =>
|
||||
GetIt.instance.get<Database>().libraryListToggleEntry(
|
||||
"favourites",
|
||||
jmdictEntryId: widget.result.entryId,
|
||||
kanji: null,
|
||||
),
|
||||
),
|
||||
SlidableAction(
|
||||
backgroundColor: Colors.blue,
|
||||
|
||||
@@ -1,454 +0,0 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:mugiten/database/history/table_names.dart';
|
||||
import 'package:sqflite/sqlite_api.dart';
|
||||
|
||||
class HistoryEntry {
|
||||
int id;
|
||||
final String? kanji;
|
||||
final String? word;
|
||||
final DateTime lastTimestamp;
|
||||
int? _timestampCount;
|
||||
|
||||
/// Whether this item is a kanji search or a word search
|
||||
bool get isKanji => word == null;
|
||||
|
||||
int? get timestampCount => _timestampCount;
|
||||
|
||||
HistoryEntry.withKanji({
|
||||
required this.id,
|
||||
required this.kanji,
|
||||
required this.lastTimestamp,
|
||||
timestampCount,
|
||||
}) : word = null,
|
||||
_timestampCount = timestampCount,
|
||||
assert(
|
||||
kanji!.runes.length == 1,
|
||||
'Kanji must be a single character',
|
||||
);
|
||||
|
||||
HistoryEntry.withWord({
|
||||
required this.id,
|
||||
required this.word,
|
||||
required this.lastTimestamp,
|
||||
timestampCount,
|
||||
}) : kanji = null,
|
||||
_timestampCount = timestampCount,
|
||||
assert(
|
||||
word == word!.trim(),
|
||||
'Word must not contain leading or trailing whitespace',
|
||||
);
|
||||
|
||||
/// Reconstruct a HistoryEntry object with data from the database
|
||||
/// This is specifically intended for the historyEntryOrderedByTimestamp
|
||||
/// view, but it can also be used with custom searches as long as it
|
||||
/// contains the following attributes:
|
||||
///
|
||||
/// - entryId
|
||||
/// - timestamp
|
||||
/// - word?
|
||||
/// - kanji?
|
||||
factory HistoryEntry.fromDBMap(Map<String, Object?> dbObject) =>
|
||||
dbObject['word'] != null
|
||||
? HistoryEntry.withWord(
|
||||
id: dbObject['entryId']! as int,
|
||||
word: dbObject['word']! as String,
|
||||
lastTimestamp: DateTime.fromMillisecondsSinceEpoch(
|
||||
dbObject['timestamp']! as int,
|
||||
),
|
||||
timestampCount: dbObject.containsKey('timestampCount')
|
||||
? dbObject['timestampCount']! as int
|
||||
: null,
|
||||
)
|
||||
: HistoryEntry.withKanji(
|
||||
id: dbObject['entryId']! as int,
|
||||
kanji: dbObject['kanji']! as String,
|
||||
lastTimestamp: DateTime.fromMillisecondsSinceEpoch(
|
||||
dbObject['timestamp']! as int,
|
||||
),
|
||||
timestampCount: dbObject.containsKey('timestampCount')
|
||||
? dbObject['timestampCount']! as int
|
||||
: null,
|
||||
);
|
||||
|
||||
static Future<HistoryEntry?> getWord(
|
||||
DatabaseExecutor db,
|
||||
String word,
|
||||
) async {
|
||||
final result = await db.query(
|
||||
HistoryTableNames.historyEntryOrderedByTimestamp,
|
||||
where: 'word = ?',
|
||||
whereArgs: [word],
|
||||
);
|
||||
|
||||
if (result.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return HistoryEntry.fromDBMap(result.first);
|
||||
}
|
||||
|
||||
static Future<HistoryEntry?> getKanji(
|
||||
DatabaseExecutor db,
|
||||
String kanji,
|
||||
) async {
|
||||
final result = await db.query(
|
||||
HistoryTableNames.historyEntryOrderedByTimestamp,
|
||||
where: 'kanji = ?',
|
||||
whereArgs: [kanji],
|
||||
);
|
||||
|
||||
if (result.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return HistoryEntry.fromDBMap(result.first);
|
||||
}
|
||||
|
||||
// TODO: There is a lot in common with
|
||||
// insertKanji,
|
||||
// insertWord,
|
||||
// insertJsonEntry,
|
||||
// insertJsonEntries,
|
||||
// The commonalities should be factored into a helper function
|
||||
|
||||
/// Insert a kanji history entry into the database.
|
||||
/// If it already exists, only a timestamp will be added
|
||||
static Future<HistoryEntry> insertKanji({
|
||||
required Database db,
|
||||
required String kanji,
|
||||
}) =>
|
||||
db.transaction((txn) async {
|
||||
final DateTime timestamp = DateTime.now();
|
||||
late final int id;
|
||||
|
||||
final existingEntry = await txn.query(
|
||||
HistoryTableNames.historyEntryKanji,
|
||||
where: 'kanji = ?',
|
||||
whereArgs: [kanji],
|
||||
);
|
||||
|
||||
if (existingEntry.isNotEmpty) {
|
||||
// Retrieve entry record id, and add a timestamp.
|
||||
id = existingEntry.first['entryId']! as int;
|
||||
await txn.insert(HistoryTableNames.historyEntryTimestamp, {
|
||||
'entryId': id,
|
||||
'timestamp': timestamp.millisecondsSinceEpoch,
|
||||
});
|
||||
} else {
|
||||
// Create new record, and add a timestamp.
|
||||
id = await txn.insert(
|
||||
HistoryTableNames.historyEntry,
|
||||
{},
|
||||
nullColumnHack: 'id',
|
||||
);
|
||||
final Batch b = txn.batch();
|
||||
|
||||
b.insert(HistoryTableNames.historyEntryTimestamp, {
|
||||
'entryId': id,
|
||||
'timestamp': timestamp.millisecondsSinceEpoch,
|
||||
});
|
||||
b.insert(HistoryTableNames.historyEntryKanji, {
|
||||
'entryId': id,
|
||||
'kanji': kanji,
|
||||
});
|
||||
await b.commit();
|
||||
}
|
||||
|
||||
return HistoryEntry.withKanji(
|
||||
id: id,
|
||||
kanji: kanji,
|
||||
lastTimestamp: timestamp,
|
||||
);
|
||||
});
|
||||
|
||||
/// Insert a word history entry into the database.
|
||||
/// If it already exists, only a timestamp will be added
|
||||
static Future<HistoryEntry> insertWord({
|
||||
required Database db,
|
||||
required String word,
|
||||
String? language,
|
||||
}) =>
|
||||
db.transaction((txn) async {
|
||||
final DateTime timestamp = DateTime.now();
|
||||
late final int id;
|
||||
|
||||
final existingEntry = await txn.query(
|
||||
HistoryTableNames.historyEntryWord,
|
||||
where: 'word = ?',
|
||||
whereArgs: [word],
|
||||
);
|
||||
|
||||
if (existingEntry.isNotEmpty) {
|
||||
// Retrieve entry record id, and add a timestamp.
|
||||
id = existingEntry.first['entryId']! as int;
|
||||
await txn.insert(HistoryTableNames.historyEntryTimestamp, {
|
||||
'entryId': id,
|
||||
'timestamp': timestamp.millisecondsSinceEpoch,
|
||||
});
|
||||
} else {
|
||||
id = await txn.insert(
|
||||
HistoryTableNames.historyEntry,
|
||||
{},
|
||||
nullColumnHack: 'id',
|
||||
);
|
||||
final Batch b = txn.batch();
|
||||
|
||||
b.insert(HistoryTableNames.historyEntryTimestamp, {
|
||||
'entryId': id,
|
||||
'timestamp': timestamp.millisecondsSinceEpoch,
|
||||
});
|
||||
b.insert(HistoryTableNames.historyEntryWord, {
|
||||
'entryId': id,
|
||||
'word': word,
|
||||
'language': {
|
||||
null: null,
|
||||
'japanese': 'j',
|
||||
'english': 'e',
|
||||
}[language]
|
||||
});
|
||||
await b.commit();
|
||||
}
|
||||
|
||||
return HistoryEntry.withWord(
|
||||
id: id,
|
||||
word: word,
|
||||
lastTimestamp: timestamp,
|
||||
);
|
||||
});
|
||||
|
||||
/// All recorded timestamps for this specific HistoryEntry
|
||||
/// sorted in descending order.
|
||||
Future<List<DateTime>> timestamps(DatabaseExecutor db) async {
|
||||
final timestamps = await db.query(
|
||||
HistoryTableNames.historyEntryTimestamp,
|
||||
where: 'entryId = ?',
|
||||
whereArgs: [id],
|
||||
orderBy: 'timestamp DESC',
|
||||
);
|
||||
|
||||
return timestamps
|
||||
.map((t) => DateTime.fromMillisecondsSinceEpoch(t['timestamp']! as int))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Export to json for archival reasons
|
||||
/// Combined with [insertJsonEntry], this makes up functionality for exporting
|
||||
/// and importing data from the app.
|
||||
Future<Map<String, Object?>> toJson(DatabaseExecutor db) async {
|
||||
final rawTimestamps = await timestamps(db);
|
||||
final timestamps_ =
|
||||
rawTimestamps.map((ts) => ts.millisecondsSinceEpoch).toList();
|
||||
|
||||
return {
|
||||
'word': word,
|
||||
'kanji': kanji,
|
||||
'timestamps': timestamps_,
|
||||
};
|
||||
}
|
||||
|
||||
/// Insert archived json entry into database if it doesn't exist there already.
|
||||
/// Combined with [toJson], this makes up functionality for exporting and
|
||||
/// importing data from the app.
|
||||
static Future<HistoryEntry> insertJsonEntry(
|
||||
Database db,
|
||||
Map<String, Object?> json,
|
||||
) async =>
|
||||
db.transaction((txn) async {
|
||||
final b = txn.batch();
|
||||
final bool isKanji = json['word'] == null;
|
||||
final existingEntry = isKanji
|
||||
? await txn.query(
|
||||
HistoryTableNames.historyEntryKanji,
|
||||
where: 'kanji = ?',
|
||||
whereArgs: [json['kanji']! as String],
|
||||
)
|
||||
: await txn.query(
|
||||
HistoryTableNames.historyEntryWord,
|
||||
where: 'word = ?',
|
||||
whereArgs: [json['word']! as String],
|
||||
);
|
||||
|
||||
late final int id;
|
||||
if (existingEntry.isEmpty) {
|
||||
id = await txn.insert(
|
||||
HistoryTableNames.historyEntry,
|
||||
{},
|
||||
nullColumnHack: 'id',
|
||||
);
|
||||
if (isKanji) {
|
||||
b.insert(HistoryTableNames.historyEntryKanji, {
|
||||
'entryId': id,
|
||||
'kanji': json['kanji']! as String,
|
||||
});
|
||||
} else {
|
||||
b.insert(HistoryTableNames.historyEntryWord, {
|
||||
'entryId': id,
|
||||
'word': json['word']! as String,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
id = existingEntry.first['entryId']! as int;
|
||||
}
|
||||
final List<int> timestamps =
|
||||
(json['timestamps']! as List).map((ts) => ts as int).toList();
|
||||
for (final timestamp in timestamps) {
|
||||
b.insert(
|
||||
HistoryTableNames.historyEntryTimestamp,
|
||||
{
|
||||
'entryId': id,
|
||||
'timestamp': timestamp,
|
||||
},
|
||||
conflictAlgorithm: ConflictAlgorithm.ignore,
|
||||
);
|
||||
}
|
||||
|
||||
await b.commit();
|
||||
|
||||
return isKanji
|
||||
? HistoryEntry.withKanji(
|
||||
id: id,
|
||||
kanji: json['kanji']! as String,
|
||||
lastTimestamp:
|
||||
DateTime.fromMillisecondsSinceEpoch(timestamps.reduce(max)),
|
||||
)
|
||||
: HistoryEntry.withWord(
|
||||
id: id,
|
||||
word: json['word']! as String,
|
||||
lastTimestamp:
|
||||
DateTime.fromMillisecondsSinceEpoch(timestamps.reduce(max)),
|
||||
);
|
||||
});
|
||||
|
||||
/// An efficient implementation of [insertJsonEntry] for multiple
|
||||
/// entries.
|
||||
///
|
||||
/// This assumes that there are no duplicates within the elements
|
||||
/// in the json.
|
||||
static Future<List<HistoryEntry>> insertJsonEntries(
|
||||
Database db,
|
||||
List<Map<String, Object?>> json,
|
||||
) =>
|
||||
db.transaction((txn) async {
|
||||
final b = txn.batch();
|
||||
final List<HistoryEntry> entries = [];
|
||||
for (final jsonObject in json) {
|
||||
final bool isKanji = jsonObject['word'] == null;
|
||||
final existingEntry = isKanji
|
||||
? await txn.query(
|
||||
HistoryTableNames.historyEntryKanji,
|
||||
where: 'kanji = ?',
|
||||
whereArgs: [jsonObject['kanji']! as String],
|
||||
)
|
||||
: await txn.query(
|
||||
HistoryTableNames.historyEntryWord,
|
||||
where: 'word = ?',
|
||||
whereArgs: [jsonObject['word']! as String],
|
||||
);
|
||||
|
||||
late final int id;
|
||||
if (existingEntry.isEmpty) {
|
||||
id = await txn.insert(
|
||||
HistoryTableNames.historyEntry,
|
||||
{},
|
||||
nullColumnHack: 'id',
|
||||
);
|
||||
if (isKanji) {
|
||||
b.insert(HistoryTableNames.historyEntryKanji, {
|
||||
'entryId': id,
|
||||
'kanji': jsonObject['kanji']! as String,
|
||||
});
|
||||
} else {
|
||||
b.insert(HistoryTableNames.historyEntryWord, {
|
||||
'entryId': id,
|
||||
'word': jsonObject['word']! as String,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
id = existingEntry.first['entryId']! as int;
|
||||
}
|
||||
final List<int> timestamps = (jsonObject['timestamps']! as List)
|
||||
.map((ts) => ts as int)
|
||||
.toList();
|
||||
for (final timestamp in timestamps) {
|
||||
b.insert(
|
||||
HistoryTableNames.historyEntryTimestamp,
|
||||
{
|
||||
'entryId': id,
|
||||
'timestamp': timestamp,
|
||||
},
|
||||
conflictAlgorithm: ConflictAlgorithm.ignore,
|
||||
);
|
||||
}
|
||||
|
||||
entries.add(
|
||||
isKanji
|
||||
? HistoryEntry.withKanji(
|
||||
id: id,
|
||||
kanji: jsonObject['kanji']! as String,
|
||||
lastTimestamp: DateTime.fromMillisecondsSinceEpoch(
|
||||
timestamps.reduce(max),
|
||||
),
|
||||
)
|
||||
: HistoryEntry.withWord(
|
||||
id: id,
|
||||
word: jsonObject['word']! as String,
|
||||
lastTimestamp: DateTime.fromMillisecondsSinceEpoch(
|
||||
timestamps.reduce(max),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await b.commit();
|
||||
return entries;
|
||||
});
|
||||
|
||||
static Future<int> amountOfEntries(DatabaseExecutor db) async {
|
||||
final query = await db.query(
|
||||
HistoryTableNames.historyEntry,
|
||||
columns: ['COUNT(*) AS count'],
|
||||
);
|
||||
return query.first['count']! as int;
|
||||
}
|
||||
|
||||
static Future<List<HistoryEntry>> entriesFromDb(
|
||||
DatabaseExecutor db, {
|
||||
int? page,
|
||||
int? pageSize,
|
||||
}) async {
|
||||
assert(page == null || page >= 0);
|
||||
assert(pageSize == null || pageSize > 0);
|
||||
assert(
|
||||
pageSize != null || page == null,
|
||||
'pageSize must be provided if page is provided',
|
||||
);
|
||||
|
||||
final result = await db.query(
|
||||
HistoryTableNames.historyEntryOrderedByTimestamp,
|
||||
limit: pageSize,
|
||||
offset: page != null && pageSize != null ? page * pageSize : null,
|
||||
);
|
||||
|
||||
return result.map((e) => HistoryEntry.fromDBMap(e)).toList();
|
||||
}
|
||||
|
||||
Future<void> populateTimestampCount(DatabaseExecutor db) async {
|
||||
final result = await db.query(
|
||||
columns: ['COUNT(*) AS count'],
|
||||
HistoryTableNames.historyEntryTimestamp,
|
||||
where: 'entryId = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
|
||||
final count = result.firstOrNull?['count'] as int? ?? 0;
|
||||
|
||||
_timestampCount = count;
|
||||
}
|
||||
|
||||
Future<void> delete(DatabaseExecutor db) => db.delete(
|
||||
HistoryTableNames.historyEntry,
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
}
|
||||
435
lib/models/history_entry.dart
Normal file
435
lib/models/history_entry.dart
Normal file
@@ -0,0 +1,435 @@
|
||||
import 'package:jadb/models/kanji_search/kanji_search_result.dart';
|
||||
import 'package:jadb/models/word_search/word_search_result.dart';
|
||||
import 'package:jadb/search.dart';
|
||||
import 'package:mugiten/database/history/table_names.dart';
|
||||
import 'package:sqflite/sqlite_api.dart';
|
||||
|
||||
extension HistoryEntryExt on DatabaseExecutor {
|
||||
// Query
|
||||
|
||||
Future<HistoryEntry?> historyEntryGetWord(
|
||||
String word,
|
||||
// bool includeSearchResult = false,
|
||||
) async {
|
||||
assert(word.isNotEmpty, 'Word must not be empty');
|
||||
|
||||
final result = await query(
|
||||
HistoryTableNames.historyEntryWord,
|
||||
where: 'word = ?',
|
||||
whereArgs: [word],
|
||||
);
|
||||
|
||||
if (result.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final entryId = result.first['entryId']! as int;
|
||||
final language = result.first['language'] as String?;
|
||||
|
||||
final List<DateTime> timestamps = (await query(
|
||||
HistoryTableNames.historyEntryTimestamp,
|
||||
where: 'entryId = ?',
|
||||
whereArgs: [entryId],
|
||||
orderBy: 'timestamp DESC',
|
||||
))
|
||||
.map((e) => DateTime.fromMillisecondsSinceEpoch(e['timestamp']! as int))
|
||||
.toList();
|
||||
|
||||
// TODO: join with search result(s) if matching exactly one, or single search result
|
||||
|
||||
return HistoryEntry(
|
||||
id: entryId,
|
||||
timestamps: timestamps,
|
||||
word: word,
|
||||
language: language,
|
||||
wordSearchResult: null,
|
||||
);
|
||||
}
|
||||
|
||||
Future<HistoryEntry?> historyEntryGetKanji(
|
||||
String kanji, {
|
||||
bool includeSearchResult = false,
|
||||
}) async {
|
||||
assert(kanji.runes.length == 1, 'Kanji must be a single character');
|
||||
|
||||
final result = await query(
|
||||
HistoryTableNames.historyEntryKanji,
|
||||
where: 'kanji = ?',
|
||||
whereArgs: [kanji],
|
||||
);
|
||||
|
||||
if (result.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final entryId = result.first['entryId']! as int;
|
||||
|
||||
final List<DateTime> timestamps = (await query(
|
||||
HistoryTableNames.historyEntryTimestamp,
|
||||
where: 'entryId = ?',
|
||||
whereArgs: [entryId],
|
||||
orderBy: 'timestamp DESC',
|
||||
))
|
||||
.map((e) => DateTime.fromMillisecondsSinceEpoch(e['timestamp']! as int))
|
||||
.toList();
|
||||
|
||||
KanjiSearchResult? kanjiSearchResult =
|
||||
includeSearchResult ? await jadbSearchKanji(kanji) : null;
|
||||
|
||||
return HistoryEntry(
|
||||
id: entryId,
|
||||
timestamps: timestamps,
|
||||
kanji: kanji,
|
||||
kanjiSearchResult: kanjiSearchResult,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<HistoryEntry>> historyEntryGetAll({
|
||||
int? page,
|
||||
int? pageSize,
|
||||
// TODO: implement join against jadb
|
||||
// bool includeSearchResult = false,
|
||||
}) async {
|
||||
assert(page == null || page >= 0);
|
||||
assert(pageSize == null || pageSize > 0);
|
||||
assert(
|
||||
pageSize != null || page == null,
|
||||
'pageSize must be provided if page is provided',
|
||||
);
|
||||
|
||||
final result = await rawQuery(
|
||||
'''
|
||||
SELECT
|
||||
*,
|
||||
GROUP_CONCAT("${HistoryTableNames.historyEntryTimestamp}"."timestamp") AS "timestamps"
|
||||
FROM "${HistoryTableNames.historyEntryOrderedByTimestamp}"
|
||||
LEFT JOIN "${HistoryTableNames.historyEntryTimestamp}" USING ("entryId")
|
||||
GROUP BY "${HistoryTableNames.historyEntryOrderedByTimestamp}"."entryId"
|
||||
ORDER BY "${HistoryTableNames.historyEntryOrderedByTimestamp}"."timestamp" DESC
|
||||
${pageSize != null ? 'LIMIT ?' : ''}
|
||||
${page != null ? 'OFFSET ?' : ''}
|
||||
''',
|
||||
[
|
||||
if (pageSize != null) pageSize,
|
||||
if (page != null) page * pageSize!,
|
||||
],
|
||||
);
|
||||
|
||||
final List<HistoryEntry> entries = result.map((e) {
|
||||
final timestamps = (e['timestamps'] as String)
|
||||
.split(',')
|
||||
.map((ts) => DateTime.fromMillisecondsSinceEpoch(int.parse(ts)))
|
||||
.toList();
|
||||
|
||||
if (e['kanji'] != null) {
|
||||
return HistoryEntry(
|
||||
id: e['entryId']! as int,
|
||||
timestamps: timestamps,
|
||||
kanji: e['kanji'] as String,
|
||||
);
|
||||
} else {
|
||||
return HistoryEntry(
|
||||
id: e['entryId']! as int,
|
||||
timestamps: timestamps,
|
||||
word: e['word'] as String,
|
||||
);
|
||||
}
|
||||
}).toList();
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
Future<int> historyEntryAmount({
|
||||
/// Whether to ignore duplicate searches
|
||||
bool unique = true,
|
||||
}) async {
|
||||
late final int count;
|
||||
|
||||
if (unique) {
|
||||
final result = await query(
|
||||
HistoryTableNames.historyEntry,
|
||||
columns: ['COUNT(*) AS count'],
|
||||
);
|
||||
count = result.firstOrNull?['count'] as int? ?? 0;
|
||||
} else {
|
||||
final result = await rawQuery(
|
||||
'''
|
||||
SELECT COUNT(*) AS count
|
||||
FROM "${HistoryTableNames.historyEntryTimestamp}"
|
||||
''',
|
||||
);
|
||||
count = result.firstOrNull?['count'] as int? ?? 0;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
// Modification
|
||||
|
||||
Future<void> historyEntryInsertKanji(String kanji) async {
|
||||
final DateTime timestamp = DateTime.now();
|
||||
|
||||
final existingEntry = await query(
|
||||
HistoryTableNames.historyEntryKanji,
|
||||
where: 'kanji = ?',
|
||||
whereArgs: [kanji],
|
||||
);
|
||||
|
||||
late final int id;
|
||||
if (existingEntry.isNotEmpty) {
|
||||
// Retrieve entry record id, and add a timestamp.
|
||||
id = existingEntry.first['entryId']! as int;
|
||||
} else {
|
||||
id = await insert(
|
||||
HistoryTableNames.historyEntry,
|
||||
{},
|
||||
nullColumnHack: 'id',
|
||||
);
|
||||
await insert(
|
||||
HistoryTableNames.historyEntryKanji,
|
||||
{
|
||||
'entryId': id,
|
||||
'kanji': kanji,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
await insert(
|
||||
HistoryTableNames.historyEntryTimestamp,
|
||||
{
|
||||
'entryId': id,
|
||||
'timestamp': timestamp.millisecondsSinceEpoch,
|
||||
},
|
||||
conflictAlgorithm: ConflictAlgorithm.ignore,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> historyEntryInsertWord(String word, {String? language}) async {
|
||||
final DateTime timestamp = DateTime.now();
|
||||
|
||||
final existingEntry = await query(
|
||||
HistoryTableNames.historyEntryWord,
|
||||
where: 'word = ?',
|
||||
whereArgs: [word],
|
||||
);
|
||||
|
||||
late final int id;
|
||||
if (existingEntry.isNotEmpty) {
|
||||
// Retrieve entry record id, and add a timestamp.
|
||||
id = existingEntry.first['entryId']! as int;
|
||||
} else {
|
||||
id = await insert(
|
||||
HistoryTableNames.historyEntry,
|
||||
{},
|
||||
nullColumnHack: 'id',
|
||||
);
|
||||
await insert(
|
||||
HistoryTableNames.historyEntryWord,
|
||||
{
|
||||
'entryId': id,
|
||||
'word': word,
|
||||
// TODO: use an enum?
|
||||
'language': {
|
||||
null: null,
|
||||
'japanese': 'j',
|
||||
'english': 'e',
|
||||
}[language]
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
await insert(
|
||||
HistoryTableNames.historyEntryTimestamp,
|
||||
{
|
||||
'entryId': id,
|
||||
'timestamp': timestamp.millisecondsSinceEpoch,
|
||||
},
|
||||
conflictAlgorithm: ConflictAlgorithm.ignore,
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> historyEntryDelete(int entryId) async {
|
||||
await delete(
|
||||
HistoryTableNames.historyEntryTimestamp,
|
||||
where: 'entryId = ?',
|
||||
whereArgs: [entryId],
|
||||
);
|
||||
|
||||
await delete(
|
||||
HistoryTableNames.historyEntryKanji,
|
||||
where: 'entryId = ?',
|
||||
whereArgs: [entryId],
|
||||
);
|
||||
|
||||
await delete(
|
||||
HistoryTableNames.historyEntryWord,
|
||||
where: 'entryId = ?',
|
||||
whereArgs: [entryId],
|
||||
);
|
||||
|
||||
final result = await delete(
|
||||
HistoryTableNames.historyEntry,
|
||||
where: 'id = ?',
|
||||
whereArgs: [entryId],
|
||||
);
|
||||
|
||||
return result > 0;
|
||||
}
|
||||
|
||||
Future<bool> historyEntryDeleteTimestamp(
|
||||
int entryId,
|
||||
DateTime timestamp,
|
||||
) async {
|
||||
final timestampCount = await query(
|
||||
HistoryTableNames.historyEntryTimestamp,
|
||||
columns: ['COUNT(*) AS count'],
|
||||
where: 'entryId = ?',
|
||||
whereArgs: [entryId],
|
||||
);
|
||||
|
||||
if (timestampCount.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final result = await delete(
|
||||
HistoryTableNames.historyEntryTimestamp,
|
||||
where: 'entryId = ? AND timestamp = ?',
|
||||
whereArgs: [
|
||||
entryId,
|
||||
timestamp.millisecondsSinceEpoch,
|
||||
],
|
||||
);
|
||||
|
||||
if (result == 0) {
|
||||
return false; // No timestamp was deleted
|
||||
}
|
||||
|
||||
// If this is the last timestamp, delete the entry
|
||||
if (timestampCount.isEmpty || timestampCount.first['count']! as int <= 1) {
|
||||
return await historyEntryDelete(entryId);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> historyEntryInsertManyFromJson(
|
||||
List<Map<String, Object?>> json,
|
||||
) async {
|
||||
final b = batch();
|
||||
for (final jsonObject in json) {
|
||||
final bool isKanji = jsonObject['word'] == null;
|
||||
final existingEntry = isKanji
|
||||
? await query(
|
||||
HistoryTableNames.historyEntryKanji,
|
||||
where: 'kanji = ?',
|
||||
whereArgs: [jsonObject['kanji']! as String],
|
||||
)
|
||||
: await query(
|
||||
HistoryTableNames.historyEntryWord,
|
||||
where: 'word = ?',
|
||||
whereArgs: [jsonObject['word']! as String],
|
||||
);
|
||||
|
||||
late final int id;
|
||||
if (existingEntry.isEmpty) {
|
||||
id = await insert(
|
||||
HistoryTableNames.historyEntry,
|
||||
{},
|
||||
nullColumnHack: 'id',
|
||||
);
|
||||
if (isKanji) {
|
||||
b.insert(HistoryTableNames.historyEntryKanji, {
|
||||
'entryId': id,
|
||||
'kanji': jsonObject['kanji']! as String,
|
||||
});
|
||||
} else {
|
||||
b.insert(HistoryTableNames.historyEntryWord, {
|
||||
'entryId': id,
|
||||
'word': jsonObject['word']! as String,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
id = existingEntry.first['entryId']! as int;
|
||||
}
|
||||
final List<int> timestamps =
|
||||
(jsonObject['timestamps']! as List).map((ts) => ts as int).toList();
|
||||
for (final timestamp in timestamps) {
|
||||
b.insert(
|
||||
HistoryTableNames.historyEntryTimestamp,
|
||||
{
|
||||
'entryId': id,
|
||||
'timestamp': timestamp,
|
||||
},
|
||||
conflictAlgorithm: ConflictAlgorithm.ignore,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await b.commit();
|
||||
}
|
||||
}
|
||||
|
||||
class HistoryEntry {
|
||||
final int id;
|
||||
final List<DateTime> timestamps;
|
||||
|
||||
final String? word;
|
||||
final String? language;
|
||||
final WordSearchResult? wordSearchResult;
|
||||
|
||||
final String? kanji;
|
||||
final KanjiSearchResult? kanjiSearchResult;
|
||||
|
||||
HistoryEntry({
|
||||
required this.id,
|
||||
required this.timestamps,
|
||||
this.word,
|
||||
this.language,
|
||||
this.wordSearchResult,
|
||||
this.kanji,
|
||||
this.kanjiSearchResult,
|
||||
}) : assert(
|
||||
(word != null && kanji == null) || (word == null && kanji != null),
|
||||
'HistoryEntry must have either a word or a kanji, but not both',
|
||||
),
|
||||
assert(
|
||||
(language == null || word != null),
|
||||
'If language is provided, word must not be null',
|
||||
),
|
||||
assert(
|
||||
(kanjiSearchResult == null || kanji != null),
|
||||
'If kanjiSearchResult is provided, kanji must not be null',
|
||||
),
|
||||
assert(
|
||||
(wordSearchResult == null || word != null),
|
||||
'If wordSearchResult is provided, word must not be null',
|
||||
),
|
||||
assert(
|
||||
kanji == null || kanji.runes.length == 1,
|
||||
'Kanji must be a single character',
|
||||
),
|
||||
// TODO: This has not always been the case, so we should add a migration
|
||||
// or something to clean up the data.
|
||||
// assert(
|
||||
// word == null || word == word.trim(),
|
||||
// 'Word must not contain leading or trailing whitespace',
|
||||
// ),
|
||||
assert(
|
||||
timestamps.isNotEmpty,
|
||||
'Timestamps must not be empty',
|
||||
);
|
||||
|
||||
bool get isKanji => word == null;
|
||||
int get timestampCount => timestamps.length;
|
||||
DateTime get lastTimestamp => timestamps.isNotEmpty
|
||||
? timestamps.reduce((a, b) => a.isAfter(b) ? a : b)
|
||||
: DateTime.fromMillisecondsSinceEpoch(0);
|
||||
|
||||
Map<String, Object?> toJson() {
|
||||
return {
|
||||
'word': word,
|
||||
'kanji': kanji,
|
||||
'timestamps': timestamps.map((ts) => ts.millisecondsSinceEpoch).toList(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
class LibraryEntry {
|
||||
DateTime lastModified;
|
||||
String? kanji;
|
||||
int? jmdictEntryId;
|
||||
|
||||
LibraryEntry({
|
||||
DateTime? lastModified,
|
||||
this.kanji,
|
||||
this.jmdictEntryId,
|
||||
}) : lastModified = lastModified ?? DateTime.now(),
|
||||
assert(
|
||||
kanji != null || jmdictEntryId != null,
|
||||
"Library entry can't be empty",
|
||||
),
|
||||
assert(
|
||||
!(kanji != null && jmdictEntryId != null),
|
||||
"Library entry can't have both kanji and jmdictEntryId",
|
||||
);
|
||||
|
||||
LibraryEntry.fromJmdictId({
|
||||
required int this.jmdictEntryId,
|
||||
DateTime? lastModified,
|
||||
}) : lastModified = lastModified ?? DateTime.now();
|
||||
|
||||
LibraryEntry.fromKanji({
|
||||
required String this.kanji,
|
||||
DateTime? lastModified,
|
||||
}) : lastModified = lastModified ?? DateTime.now();
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'kanji': kanji,
|
||||
'jmdictEntryId': jmdictEntryId,
|
||||
'lastModified': lastModified.millisecondsSinceEpoch,
|
||||
};
|
||||
|
||||
factory LibraryEntry.fromJson(Map<String, Object?> json) {
|
||||
assert(
|
||||
(json.containsKey('kanji') && json['kanji'] != null) ||
|
||||
(json.containsKey('jmdictEntryId') && json['jmdictEntryId'] != null),
|
||||
"Library entry can't be empty",
|
||||
);
|
||||
assert(
|
||||
json.containsKey('lastModified'),
|
||||
"Library entry must have a lastModified timestamp",
|
||||
);
|
||||
|
||||
if (json.containsKey('kanji') && json['kanji'] != null) {
|
||||
return LibraryEntry.fromKanji(
|
||||
kanji: json['kanji']! as String,
|
||||
lastModified: DateTime.fromMillisecondsSinceEpoch(
|
||||
json['lastModified']! as int,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return LibraryEntry.fromJmdictId(
|
||||
jmdictEntryId: json['jmdictEntryId']! as int,
|
||||
lastModified: DateTime.fromMillisecondsSinceEpoch(
|
||||
json['lastModified']! as int,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: this just happens to be the same as the logic in `fromJson`
|
||||
factory LibraryEntry.fromDBMap(Map<String, Object?> dbObject) =>
|
||||
LibraryEntry.fromJson(dbObject);
|
||||
}
|
||||
@@ -1,611 +0,0 @@
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:mugiten/database/library_list/table_names.dart';
|
||||
import 'package:sqflite/sqlite_api.dart';
|
||||
|
||||
import '../../database/database_errors.dart';
|
||||
import 'library_entry.dart';
|
||||
|
||||
class LibraryList {
|
||||
final String name;
|
||||
|
||||
const LibraryList.byName(this.name);
|
||||
|
||||
static const LibraryList favourites = LibraryList.byName('favourites');
|
||||
|
||||
/// Get all entries within the library, in their custom order
|
||||
Future<List<LibraryEntry>> entries(
|
||||
DatabaseExecutor db, {
|
||||
int? page,
|
||||
int? pageSize,
|
||||
}) async {
|
||||
assert(
|
||||
page == null || page >= 0,
|
||||
);
|
||||
assert(pageSize == null || pageSize > 0);
|
||||
assert(
|
||||
page == null || pageSize != null,
|
||||
'If page is provided, pageSize must also be provided.',
|
||||
);
|
||||
|
||||
const columns = ['jmdictEntryId', 'kanji', 'lastModified'];
|
||||
final query = await db.rawQuery(
|
||||
'''
|
||||
WITH RECURSIVE
|
||||
"RecursionTable"(${columns.map((c) => '"$c"').join(', ')}) AS (
|
||||
SELECT ${columns.map((c) => '"$c"').join(', ')}
|
||||
FROM "${LibraryListTableNames.libraryListEntry}"
|
||||
WHERE
|
||||
"listName" = ?
|
||||
AND "prevEntryJmdictEntryId" IS NULL
|
||||
AND "prevEntryKanji" IS NULL
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT ${columns.map((c) => '"R"."$c"').join(', ')}
|
||||
FROM "${LibraryListTableNames.libraryListEntry}" AS "R", "RecursionTable"
|
||||
WHERE "R"."listName" = ?
|
||||
AND ("R"."prevEntryJmdictEntryId" = "RecursionTable"."jmdictEntryId"
|
||||
OR "R"."prevEntryKanji" = "RecursionTable"."kanji")
|
||||
)
|
||||
SELECT ${columns.map((c) => '"$c"').join(', ')} FROM "RecursionTable"
|
||||
${pageSize != null ? 'LIMIT ?' : ''}
|
||||
${page != null ? 'OFFSET ?' : ''}
|
||||
''',
|
||||
[
|
||||
name,
|
||||
name,
|
||||
if (pageSize != null) pageSize,
|
||||
if (page != null) page * pageSize!,
|
||||
],
|
||||
);
|
||||
|
||||
return query.map((e) => LibraryEntry.fromDBMap(e)).toList();
|
||||
}
|
||||
|
||||
/// Get all existing libraries in their custom order.
|
||||
static Future<List<LibraryList>> allLibraries(DatabaseExecutor db) async {
|
||||
final query = await db.query(LibraryListTableNames.libraryListOrdered);
|
||||
return query
|
||||
.map((lib) => LibraryList.byName(lib['name']! as String))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Generates a map of all the libraries, with the value being
|
||||
/// whether or not the specified entry is within the library.
|
||||
static Future<Map<LibraryList, bool>> allListsContains({
|
||||
required DatabaseExecutor db,
|
||||
required int? jmdictEntryId,
|
||||
required String? kanji,
|
||||
}) async {
|
||||
if ((jmdictEntryId == null) == (kanji == null)) {
|
||||
throw ArgumentError(
|
||||
'Either jmdictEntryId or kanji must be provided, but not both.',
|
||||
);
|
||||
}
|
||||
|
||||
final query = await db.rawQuery(
|
||||
'''
|
||||
SELECT
|
||||
*,
|
||||
EXISTS(
|
||||
SELECT * FROM "${LibraryListTableNames.libraryListEntry}"
|
||||
WHERE "listName" = "name" AND ("jmdictEntryId" = ? OR "kanji" = ?)
|
||||
) AS "exists"
|
||||
FROM "${LibraryListTableNames.libraryListOrdered}"
|
||||
''',
|
||||
[
|
||||
jmdictEntryId,
|
||||
kanji,
|
||||
],
|
||||
);
|
||||
|
||||
return Map.fromEntries(
|
||||
query.map(
|
||||
(lib) => MapEntry(
|
||||
LibraryList.byName(lib['name']! as String),
|
||||
lib['exists']! as int == 1,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Whether a library contains a specific entry
|
||||
Future<bool> contains({
|
||||
required DatabaseExecutor db,
|
||||
required int? jmdictEntryId,
|
||||
required String? kanji,
|
||||
}) async {
|
||||
if (jmdictEntryId == null && kanji == null) {
|
||||
return false;
|
||||
}
|
||||
if (jmdictEntryId != null && kanji != null) {
|
||||
throw ArgumentError(
|
||||
'Either jmdictEntryId or kanji must be provided, but not both.',
|
||||
);
|
||||
}
|
||||
|
||||
final query = await db.rawQuery(
|
||||
'''
|
||||
SELECT EXISTS(
|
||||
SELECT *
|
||||
FROM "${LibraryListTableNames.libraryListEntry}"
|
||||
WHERE "listName" = ? AND ("jmdictEntryId" = ? OR "kanji" = ?)
|
||||
) AS "exists"
|
||||
''',
|
||||
[name, jmdictEntryId, kanji],
|
||||
);
|
||||
return query.first['exists']! as int == 1;
|
||||
}
|
||||
|
||||
/// Whether a library contains a specific word entry
|
||||
Future<bool> containsJmdictEntryId(
|
||||
DatabaseExecutor db,
|
||||
int jmdictEntryId,
|
||||
) =>
|
||||
contains(
|
||||
db: db,
|
||||
jmdictEntryId: jmdictEntryId,
|
||||
kanji: null,
|
||||
);
|
||||
|
||||
/// Whether a library contains a specific kanji entry
|
||||
Future<bool> containsKanji(
|
||||
DatabaseExecutor db,
|
||||
String kanji,
|
||||
) =>
|
||||
contains(
|
||||
db: db,
|
||||
jmdictEntryId: null,
|
||||
kanji: kanji,
|
||||
);
|
||||
|
||||
/// Whether a library exists in the database
|
||||
static Future<bool> exists(
|
||||
DatabaseExecutor db,
|
||||
String libraryName,
|
||||
) async {
|
||||
final query = await db.rawQuery(
|
||||
'''
|
||||
SELECT EXISTS(
|
||||
SELECT *
|
||||
FROM "${LibraryListTableNames.libraryList}"
|
||||
WHERE "name" = ?
|
||||
) AS "exists"
|
||||
''',
|
||||
[libraryName],
|
||||
);
|
||||
return query.first['exists']! as int == 1;
|
||||
}
|
||||
|
||||
static Future<int> libraryCount(
|
||||
DatabaseExecutor db,
|
||||
) async {
|
||||
final query = await db.query(
|
||||
LibraryListTableNames.libraryList,
|
||||
columns: ['COUNT(*) AS count'],
|
||||
);
|
||||
return query.first['count']! as int;
|
||||
}
|
||||
|
||||
/// The amount of items within this library.
|
||||
Future<int> length(DatabaseExecutor db) async {
|
||||
final query = await db.query(
|
||||
LibraryListTableNames.libraryListEntry,
|
||||
columns: ['COUNT(*) AS count'],
|
||||
where: 'listName = ?',
|
||||
whereArgs: [name],
|
||||
);
|
||||
return query.first['count']! as int;
|
||||
}
|
||||
|
||||
/// Swaps two entries within a list
|
||||
/// Will throw an exception if the entry is already in the library
|
||||
Future<void> insertEntry({
|
||||
required DatabaseExecutor db,
|
||||
required int? jmdictEntryId,
|
||||
required String? kanji,
|
||||
int? position,
|
||||
DateTime? lastModified,
|
||||
}) async {
|
||||
if ((jmdictEntryId == null) == (kanji == null)) {
|
||||
throw ArgumentError(
|
||||
'Either jmdictEntryId or kanji must be provided, but not both.',
|
||||
);
|
||||
}
|
||||
// TODO: set up lastModified insertion
|
||||
|
||||
if (await contains(
|
||||
db: db,
|
||||
jmdictEntryId: jmdictEntryId,
|
||||
kanji: kanji,
|
||||
)) {
|
||||
throw DataAlreadyExistsError(
|
||||
tableName: LibraryListTableNames.libraryListEntry,
|
||||
illegalArguments: {
|
||||
'jmdictEntryId': jmdictEntryId,
|
||||
'kanji': kanji,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (position != null) {
|
||||
final len = await length(db);
|
||||
if (0 > position || position > len) {
|
||||
throw IndexError.withLength(
|
||||
position,
|
||||
len,
|
||||
indexable: this,
|
||||
name: 'position',
|
||||
message:
|
||||
'Data insertion position ($position) can not be between 0 and length ($len).',
|
||||
);
|
||||
} else if (position != len) {
|
||||
log(
|
||||
'Adding ${jmdictEntryId != null ? 'jmdict entry $jmdictEntryId' : 'kanji "$kanji"'} to library "$name" at position $position',
|
||||
);
|
||||
|
||||
// TODO: use a transaction instead of a batch
|
||||
final b = db.batch();
|
||||
|
||||
final entries_ = await entries(db);
|
||||
final prevEntry = entries_[position - 1];
|
||||
final nextEntry = entries_[position];
|
||||
|
||||
b.insert(LibraryListTableNames.libraryListEntry, {
|
||||
'listName': name,
|
||||
'jmdictEntryId': jmdictEntryId,
|
||||
'kanji': kanji,
|
||||
'prevEntryJmdictEntryId': prevEntry.jmdictEntryId,
|
||||
'prevEntryKanji': prevEntry.kanji,
|
||||
});
|
||||
|
||||
b.update(
|
||||
LibraryListTableNames.libraryListEntry,
|
||||
{
|
||||
'prevEntryJmdictEntryId': jmdictEntryId,
|
||||
'prevEntryKanji': kanji,
|
||||
},
|
||||
where: '"listName" = ? AND ("jmdictEntryId" = ? OR "kanji" = ?)',
|
||||
whereArgs: [name, nextEntry.jmdictEntryId, nextEntry.kanji],
|
||||
);
|
||||
|
||||
await b.commit();
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
log(
|
||||
'Adding ${jmdictEntryId != null ? 'jmdict entry $jmdictEntryId' : 'kanji "$kanji"'} to library "$name"',
|
||||
);
|
||||
|
||||
final LibraryEntry? prevEntry = (await entries(db)).lastOrNull;
|
||||
|
||||
await db.insert(LibraryListTableNames.libraryListEntry, {
|
||||
'listName': name,
|
||||
'jmdictEntryId': jmdictEntryId,
|
||||
'kanji': kanji,
|
||||
'prevEntryJmdictEntryId': prevEntry?.jmdictEntryId,
|
||||
'prevEntryKanji': prevEntry?.kanji,
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> insertJsonEntries(
|
||||
DatabaseExecutor db,
|
||||
List<Map<String, Object?>> jsonEntries,
|
||||
) async {
|
||||
List<LibraryEntry> entries =
|
||||
jsonEntries.map((e) => LibraryEntry.fromJson(e)).toList();
|
||||
|
||||
// TODO: batch
|
||||
for (final entry in entries) {
|
||||
if (entry.kanji != null) {
|
||||
await insertEntry(
|
||||
db: db,
|
||||
kanji: entry.kanji,
|
||||
jmdictEntryId: null,
|
||||
position: null,
|
||||
lastModified: entry.lastModified,
|
||||
);
|
||||
} else if (entry.jmdictEntryId != null) {
|
||||
await insertEntry(
|
||||
db: db,
|
||||
jmdictEntryId: entry.jmdictEntryId,
|
||||
kanji: null,
|
||||
position: null,
|
||||
lastModified: entry.lastModified,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes an entry within a list
|
||||
/// Will throw an exception if the entry is not in the library
|
||||
Future<void> deleteEntry({
|
||||
required DatabaseExecutor db,
|
||||
required int? jmdictEntryId,
|
||||
required String? kanji,
|
||||
}) async {
|
||||
if ((jmdictEntryId == null) == (kanji == null)) {
|
||||
throw ArgumentError(
|
||||
'Either jmdictEntryId or kanji must be provided, but not both.',
|
||||
);
|
||||
}
|
||||
|
||||
if (!await contains(
|
||||
db: db,
|
||||
jmdictEntryId: jmdictEntryId,
|
||||
kanji: kanji,
|
||||
)) {
|
||||
throw DataNotFoundError(
|
||||
tableName: LibraryListTableNames.libraryListEntry,
|
||||
illegalArguments: {
|
||||
'jmdictEntryId': jmdictEntryId,
|
||||
'kanji': kanji,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
log(
|
||||
'Deleting ${jmdictEntryId != null ? 'jmdict entry $jmdictEntryId' : 'kanji "$kanji"'} from library "$name"',
|
||||
);
|
||||
|
||||
// TODO: these queries might be combined into one
|
||||
final entryQuery = await db.query(
|
||||
LibraryListTableNames.libraryListEntry,
|
||||
columns: ['prevEntryJmdictEntryId', 'prevEntryKanji'],
|
||||
where: '"listName" = ? AND ("jmdictEntryId" = ? OR "kanji" = ?)',
|
||||
whereArgs: [name, jmdictEntryId, kanji],
|
||||
);
|
||||
|
||||
final nextEntryQuery = await db.query(
|
||||
LibraryListTableNames.libraryListEntry,
|
||||
where:
|
||||
'"listName" = ? AND ("prevEntryJmdictEntryId" = ? OR "prevEntryKanji" = ?)',
|
||||
whereArgs: [name, jmdictEntryId, kanji],
|
||||
);
|
||||
|
||||
final prevEntryJmdictEntryId =
|
||||
entryQuery.first['prevEntryJmdictEntryId'] as int?;
|
||||
final prevEntryKanji = entryQuery.first['prevEntryKanji'] as String?;
|
||||
|
||||
final LibraryEntry? nextEntry =
|
||||
nextEntryQuery.map((e) => LibraryEntry.fromDBMap(e)).firstOrNull;
|
||||
|
||||
// TODO: use a transaction instead of a batch
|
||||
final b = db.batch();
|
||||
|
||||
if (nextEntry != null) {
|
||||
b.update(
|
||||
LibraryListTableNames.libraryListEntry,
|
||||
{
|
||||
'prevEntryJmdictEntryId': prevEntryJmdictEntryId,
|
||||
'prevEntryKanji': prevEntryKanji,
|
||||
},
|
||||
where: '"listName" = ? AND ("jmdictEntryId" = ? OR "kanji" = ?)',
|
||||
whereArgs: [name, nextEntry.jmdictEntryId, nextEntry.kanji],
|
||||
);
|
||||
}
|
||||
|
||||
b.delete(
|
||||
LibraryListTableNames.libraryListEntry,
|
||||
where: '"listName" = ? AND ("jmdictEntryId" = ? OR "kanji" = ?)',
|
||||
whereArgs: [name, jmdictEntryId, kanji],
|
||||
);
|
||||
|
||||
b.commit();
|
||||
}
|
||||
|
||||
/// Swaps two entries within a list
|
||||
/// Will throw an error if both of the entries doesn't exist
|
||||
Future<void> swapEntries({
|
||||
required DatabaseExecutor db,
|
||||
required int? jmdictEntryId1,
|
||||
required String? kanji1,
|
||||
required int? jmdictEntryId2,
|
||||
required String? kanji2,
|
||||
}) async {
|
||||
if ((jmdictEntryId1 == null) == (kanji1 == null) ||
|
||||
(jmdictEntryId2 == null) == (kanji2 == null)) {
|
||||
throw ArgumentError(
|
||||
'Either jmdictEntryId or kanji must be provided for both entries, but not both.',
|
||||
);
|
||||
}
|
||||
|
||||
if (!await contains(
|
||||
db: db,
|
||||
jmdictEntryId: jmdictEntryId1,
|
||||
kanji: kanji1,
|
||||
)) {
|
||||
throw DataNotFoundError(
|
||||
tableName: LibraryListTableNames.libraryListEntry,
|
||||
illegalArguments: {
|
||||
'jmdictEntryId': jmdictEntryId1,
|
||||
'kanji': kanji1,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (!await contains(
|
||||
db: db,
|
||||
jmdictEntryId: jmdictEntryId2,
|
||||
kanji: kanji2,
|
||||
)) {
|
||||
throw DataNotFoundError(
|
||||
tableName: LibraryListTableNames.libraryListEntry,
|
||||
illegalArguments: {
|
||||
'jmdictEntryId': jmdictEntryId2,
|
||||
'kanji': kanji2,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
log(
|
||||
'Swapping ${jmdictEntryId1 != null ? 'jmdict entry $jmdictEntryId1' : 'kanji "$kanji1"'} with ${jmdictEntryId2 != null ? 'jmdict entry $jmdictEntryId2' : 'kanji "$kanji2"'} in library "$name"',
|
||||
);
|
||||
|
||||
// TODO: implement function.
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
/// Toggle whether an entry is in the library or not.
|
||||
/// If [overrideToggleOn] is given true or false, it will specifically insert or
|
||||
/// delete the entry respectively. Else, it will figure out whether the entry
|
||||
/// is in the library already automatically.
|
||||
Future<bool> toggleEntry({
|
||||
required DatabaseExecutor db,
|
||||
required int? jmdictEntryId,
|
||||
required String? kanji,
|
||||
bool? overrideToggleOn,
|
||||
}) async {
|
||||
if ((jmdictEntryId == null) == (kanji == null)) {
|
||||
throw ArgumentError(
|
||||
'Either jmdictEntryId or kanji must be provided, but not both.',
|
||||
);
|
||||
}
|
||||
|
||||
overrideToggleOn ??= !(await contains(
|
||||
db: db,
|
||||
jmdictEntryId: jmdictEntryId,
|
||||
kanji: kanji,
|
||||
));
|
||||
|
||||
if (overrideToggleOn) {
|
||||
await insertEntry(
|
||||
db: db,
|
||||
jmdictEntryId: jmdictEntryId,
|
||||
kanji: kanji,
|
||||
);
|
||||
} else {
|
||||
await deleteEntry(
|
||||
db: db,
|
||||
jmdictEntryId: jmdictEntryId,
|
||||
kanji: kanji,
|
||||
);
|
||||
}
|
||||
return overrideToggleOn;
|
||||
}
|
||||
|
||||
Future<void> deleteAllEntries(DatabaseExecutor db) => db.delete(
|
||||
LibraryListTableNames.libraryListEntry,
|
||||
where: 'listName = ?',
|
||||
whereArgs: [name],
|
||||
);
|
||||
|
||||
/// Insert a new library list into the database
|
||||
static Future<LibraryList> insert(
|
||||
DatabaseExecutor db,
|
||||
String libraryName,
|
||||
) async {
|
||||
if (await exists(db, libraryName)) {
|
||||
throw DataAlreadyExistsError(
|
||||
tableName: LibraryListTableNames.libraryList,
|
||||
illegalArguments: {
|
||||
'libraryName': libraryName,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// This is ok, because "favourites" should always exist.
|
||||
final prevList = (await allLibraries(db)).last;
|
||||
await db.insert(LibraryListTableNames.libraryList, {
|
||||
'name': libraryName,
|
||||
'prevList': prevList.name,
|
||||
});
|
||||
return LibraryList.byName(libraryName);
|
||||
}
|
||||
|
||||
/// Delete this library from the database
|
||||
Future<void> delete(Database db) async {
|
||||
if (name == 'favourites') {
|
||||
throw IllegalDeletionError(
|
||||
tableName: LibraryListTableNames.libraryList,
|
||||
illegalArguments: {'name': name},
|
||||
);
|
||||
}
|
||||
|
||||
await db.transaction((txn) async {
|
||||
await txn.delete(
|
||||
LibraryListTableNames.libraryListEntry,
|
||||
where: 'listName = ?',
|
||||
whereArgs: [name],
|
||||
);
|
||||
|
||||
final String prevName = await txn
|
||||
.query(
|
||||
LibraryListTableNames.libraryList,
|
||||
columns: ['prevList'],
|
||||
where: 'name = ?',
|
||||
whereArgs: [name],
|
||||
)
|
||||
.then((rows) => rows.first['prevList']! as String);
|
||||
|
||||
final String? nextName = await txn
|
||||
.query(
|
||||
LibraryListTableNames.libraryList,
|
||||
columns: ['name'],
|
||||
where: 'prevList = ?',
|
||||
whereArgs: [name],
|
||||
)
|
||||
.then((rows) => rows.firstOrNull?['name'] as String?);
|
||||
|
||||
await txn.delete(
|
||||
LibraryListTableNames.libraryList,
|
||||
where: 'name = ?',
|
||||
whereArgs: [name],
|
||||
);
|
||||
|
||||
if (nextName != null) {
|
||||
await txn.update(
|
||||
LibraryListTableNames.libraryList,
|
||||
{'prevList': prevName},
|
||||
where: 'name = ?',
|
||||
whereArgs: [nextName],
|
||||
);
|
||||
}
|
||||
|
||||
if (!await verifyLibrariesLinkedList(txn)) {
|
||||
print(
|
||||
'Library list "$name" has a broken linked list after deletion, rolling back');
|
||||
txn.execute('ROLLBACK');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<bool> verifyLibrariesLinkedList(
|
||||
DatabaseExecutor db,
|
||||
) async {
|
||||
final int allItemsCount = await db.query(
|
||||
LibraryListTableNames.libraryList,
|
||||
columns: ['COUNT(*) AS count'],
|
||||
).then((rows) => rows.first['count']! as int);
|
||||
|
||||
final int distinctPrevListCount = await db.query(
|
||||
LibraryListTableNames.libraryList,
|
||||
columns: ['COUNT(DISTINCT prevList) AS count'],
|
||||
).then((rows) => (rows.first['count']! as int) + 1);
|
||||
|
||||
final int recursiveCount = await db.query(
|
||||
LibraryListTableNames.libraryListOrdered,
|
||||
columns: ['COUNT(*) AS count'],
|
||||
).then((rows) => rows.first['count']! as int);
|
||||
|
||||
if (allItemsCount != distinctPrevListCount) {
|
||||
log(
|
||||
'Library list "$name" has a mismatch between all items count ($allItemsCount) and distinct prevList count ($distinctPrevListCount).',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (recursiveCount != allItemsCount) {
|
||||
log(
|
||||
'Library list "$name" has a mismatch between recursive count ($recursiveCount) and all items count ($allItemsCount).',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
748
lib/models/library_list.dart
Normal file
748
lib/models/library_list.dart
Normal file
@@ -0,0 +1,748 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:jadb/models/kanji_search/kanji_search_result.dart';
|
||||
import 'package:jadb/models/word_search/word_search_result.dart';
|
||||
import 'package:jadb/search.dart';
|
||||
import 'package:mugiten/database/library_list/table_names.dart';
|
||||
import 'package:sqflite/sqlite_api.dart';
|
||||
|
||||
extension LibraryListExt on DatabaseExecutor {
|
||||
// Query
|
||||
|
||||
Future<List<LibraryList>> libraryListGetLists({
|
||||
int? page,
|
||||
int? pageSize,
|
||||
}) async {
|
||||
final result = await rawQuery(
|
||||
'''
|
||||
SELECT
|
||||
"name",
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM "${LibraryListTableNames.libraryListEntry}"
|
||||
WHERE "listName" = "name"
|
||||
) AS "count"
|
||||
FROM "${LibraryListTableNames.libraryListOrdered}"
|
||||
${pageSize != null ? 'LIMIT ?' : ''}
|
||||
${page != null ? 'OFFSET ?' : ''}
|
||||
''',
|
||||
[
|
||||
if (pageSize != null) pageSize,
|
||||
if (page != null) page * pageSize!,
|
||||
],
|
||||
);
|
||||
|
||||
// COUNT(*) AS "count"
|
||||
// LEFT JOIN "${LibraryListTableNames.libraryListEntry}"
|
||||
|
||||
return result
|
||||
.map((row) => LibraryList(
|
||||
name: row['name'] as String,
|
||||
totalCount: row['count'] as int? ?? 0,
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<LibraryList?> libraryListGetList(String listName) async {
|
||||
assert(listName.isNotEmpty, 'Library list name must not be empty.');
|
||||
|
||||
final result = await rawQuery(
|
||||
'''
|
||||
SELECT
|
||||
"name",
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM "${LibraryListTableNames.libraryListEntry}"
|
||||
WHERE "listName" = "name"
|
||||
) AS "count"
|
||||
FROM "${LibraryListTableNames.libraryListOrdered}"
|
||||
WHERE "name" = ?
|
||||
''',
|
||||
[listName],
|
||||
);
|
||||
|
||||
if (result.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return LibraryList(
|
||||
name: result.first['name'] as String,
|
||||
totalCount: result.first['count'] as int? ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
Future<LibraryListPage?> libraryListGetListEntries(
|
||||
String listName, {
|
||||
int? page,
|
||||
int? pageSize,
|
||||
bool includeSearchResult = false,
|
||||
}) async {
|
||||
assert(listName.isNotEmpty, 'Library list name must not be empty.');
|
||||
assert(
|
||||
page == null || page >= 0,
|
||||
'Page must be null or a non-negative integer.',
|
||||
);
|
||||
assert(
|
||||
pageSize == null || pageSize > 0,
|
||||
'Page size must be null or a positive integer.',
|
||||
);
|
||||
assert(
|
||||
page == null || pageSize != null,
|
||||
'If page is provided, pageSize must also be provided.',
|
||||
);
|
||||
|
||||
final list = await libraryListGetList(listName);
|
||||
|
||||
if (list == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final entries = await rawQuery(
|
||||
'''
|
||||
WITH RECURSIVE
|
||||
"RecursionTable"(
|
||||
"jmdictEntryId",
|
||||
"kanji",
|
||||
"lastModified"
|
||||
) AS (
|
||||
SELECT
|
||||
"jmdictEntryId",
|
||||
"kanji",
|
||||
"lastModified"
|
||||
FROM "${LibraryListTableNames.libraryListEntry}"
|
||||
WHERE
|
||||
"listName" = ?
|
||||
AND "prevEntryJmdictEntryId" IS NULL
|
||||
AND "prevEntryKanji" IS NULL
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
"R"."jmdictEntryId",
|
||||
"R"."kanji",
|
||||
"R"."lastModified"
|
||||
FROM "${LibraryListTableNames.libraryListEntry}" AS "R", "RecursionTable"
|
||||
WHERE
|
||||
"R"."listName" = ?
|
||||
AND ("R"."prevEntryJmdictEntryId" = "RecursionTable"."jmdictEntryId"
|
||||
OR "R"."prevEntryKanji" = "RecursionTable"."kanji")
|
||||
)
|
||||
SELECT
|
||||
"jmdictEntryId",
|
||||
"kanji",
|
||||
"lastModified"
|
||||
FROM "RecursionTable"
|
||||
${pageSize != null ? 'LIMIT ?' : ''}
|
||||
${page != null ? 'OFFSET ?' : ''}
|
||||
|
||||
''',
|
||||
[
|
||||
listName,
|
||||
listName,
|
||||
if (pageSize != null) pageSize,
|
||||
if (page != null) page * pageSize!,
|
||||
],
|
||||
);
|
||||
|
||||
Map<int, WordSearchResult>? wordResults;
|
||||
Map<String, KanjiSearchResult>? kanjiResults;
|
||||
if (includeSearchResult) {
|
||||
final wordResultJmdictIds = entries
|
||||
.where((e) => e['jmdictEntryId'] != null)
|
||||
.map((e) => e['jmdictEntryId'] as int)
|
||||
.toSet();
|
||||
|
||||
wordResults = await jadbGetManyWordsByIds(wordResultJmdictIds);
|
||||
|
||||
final kanjiResultKanjis = entries
|
||||
.where((e) => e['kanji'] != null)
|
||||
.map((e) => e['kanji'] as String)
|
||||
.toSet();
|
||||
|
||||
kanjiResults = await jadbGetManyKanji(kanjiResultKanjis);
|
||||
}
|
||||
|
||||
final result = entries.map((entry) {
|
||||
if (entry['jmdictEntryId'] != null) {
|
||||
return LibraryListEntry.fromJmdictId(
|
||||
jmdictEntryId: entry['jmdictEntryId'] as int,
|
||||
wordSearchResult: wordResults?[entry['jmdictEntryId'] as int],
|
||||
lastModified: DateTime.fromMillisecondsSinceEpoch(
|
||||
entry['lastModified'] as int,
|
||||
),
|
||||
);
|
||||
} else if (entry['kanji'] != null) {
|
||||
return LibraryListEntry.fromKanji(
|
||||
kanji: entry['kanji'] as String,
|
||||
kanjiSearchResult: kanjiResults?[entry['kanji'] as String],
|
||||
lastModified: DateTime.fromMillisecondsSinceEpoch(
|
||||
entry['lastModified'] as int,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
throw ArgumentError(
|
||||
'Library list entry must have either jmdictEntryId or kanji.',
|
||||
);
|
||||
}
|
||||
}).toList();
|
||||
|
||||
return LibraryListPage(
|
||||
name: listName,
|
||||
totalCount: list.totalCount,
|
||||
entries: result,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<String, bool>> libraryListAllListsContain({
|
||||
int? jmdictEntryId,
|
||||
String? kanji,
|
||||
}) async {
|
||||
final result = await rawQuery(
|
||||
'''
|
||||
SELECT
|
||||
"name",
|
||||
EXISTS(
|
||||
SELECT * FROM "${LibraryListTableNames.libraryListEntry}"
|
||||
WHERE "listName" = "name"
|
||||
AND ("jmdictEntryId" = ? OR "kanji" = ?)
|
||||
) AS "exists"
|
||||
FROM "${LibraryListTableNames.libraryListOrdered}"
|
||||
''',
|
||||
[
|
||||
jmdictEntryId,
|
||||
kanji,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
for (final row in result)
|
||||
row['name'] as String: (row['exists'] as int) == 1,
|
||||
};
|
||||
}
|
||||
|
||||
Future<bool> libraryListListContains(
|
||||
String listName, {
|
||||
int? jmdictEntryId,
|
||||
String? kanji,
|
||||
}) async {
|
||||
assert(listName.isNotEmpty, 'Library list name must not be empty.');
|
||||
final result = await rawQuery(
|
||||
'''
|
||||
SELECT EXISTS(
|
||||
SELECT * FROM "${LibraryListTableNames.libraryListEntry}"
|
||||
WHERE "listName" = ?
|
||||
AND ("jmdictEntryId" = ? OR "kanji" = ?)
|
||||
) AS "exists"
|
||||
''',
|
||||
[
|
||||
listName,
|
||||
jmdictEntryId,
|
||||
kanji,
|
||||
],
|
||||
);
|
||||
|
||||
return (result.firstOrNull?['exists'] as int? ?? 0) == 1;
|
||||
}
|
||||
|
||||
Future<int> libraryListAmount() async {
|
||||
final result = await query(
|
||||
LibraryListTableNames.libraryList,
|
||||
columns: ['COUNT(*) AS count'],
|
||||
);
|
||||
|
||||
return result.firstOrNull?['count'] as int? ?? 0;
|
||||
}
|
||||
|
||||
Future<bool> libraryListExists(String listName) async {
|
||||
assert(listName.isNotEmpty, 'Library list name must not be empty.');
|
||||
final result = await rawQuery(
|
||||
'''
|
||||
SELECT EXISTS(
|
||||
SELECT * FROM "${LibraryListTableNames.libraryList}"
|
||||
WHERE "name" = ?
|
||||
) AS "exists"
|
||||
''',
|
||||
[listName],
|
||||
);
|
||||
|
||||
return (result.firstOrNull?['exists'] as int? ?? 0) == 1;
|
||||
}
|
||||
|
||||
// Modification
|
||||
|
||||
/// Inserts a new library list into the database.
|
||||
Future<bool> libraryListInsertList(
|
||||
String listName, {
|
||||
bool existsOk = true,
|
||||
}) async {
|
||||
assert(listName.isNotEmpty, 'Library list name must not be empty.');
|
||||
|
||||
if (!existsOk && await libraryListExists(listName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// // This is ok, because "favourites" should always exist.
|
||||
final prevList = (await libraryListGetLists()).last;
|
||||
await insert(
|
||||
LibraryListTableNames.libraryList,
|
||||
{
|
||||
'name': listName,
|
||||
'prevList': prevList.name,
|
||||
},
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Deletes a library list by its name.
|
||||
Future<bool> libraryListDeleteList(
|
||||
String listName, {
|
||||
bool notEmptyOk = true,
|
||||
bool doesNotExistOk = false,
|
||||
}) async {
|
||||
assert(listName.isNotEmpty, 'Library list name must not be empty.');
|
||||
assert(listName != "favourites", 'Cannot delete the "favourites" list.');
|
||||
|
||||
if (!doesNotExistOk && !(await libraryListExists(listName))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!notEmptyOk &&
|
||||
((await libraryListGetList(listName))?.totalCount ?? 0) > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final result = await delete(
|
||||
LibraryListTableNames.libraryList,
|
||||
where: '"name" = ?',
|
||||
whereArgs: [listName],
|
||||
);
|
||||
|
||||
return doesNotExistOk || result > 0;
|
||||
}
|
||||
|
||||
/// Deletes all entries in a library list.
|
||||
Future<bool> libraryListDeleteAllEntries(
|
||||
String listName, {
|
||||
bool doesNotExistOk = false,
|
||||
}) async {
|
||||
assert(listName.isNotEmpty, 'Library list name must not be empty.');
|
||||
|
||||
if (!doesNotExistOk && !(await libraryListExists(listName))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final result = await delete(
|
||||
LibraryListTableNames.libraryListEntry,
|
||||
where: '"listName" = ?',
|
||||
whereArgs: [listName],
|
||||
);
|
||||
|
||||
return doesNotExistOk || result > 0;
|
||||
}
|
||||
|
||||
/// Appends an entry into the library list, optionally at a specific position.
|
||||
///
|
||||
/// This function returns false if the position is out of bounds,
|
||||
/// if the list does not exist, or if the entry is already a part of the list.
|
||||
Future<bool> libraryListInsertEntry(
|
||||
String listName, {
|
||||
int? jmdictEntryId,
|
||||
String? kanji,
|
||||
int? position,
|
||||
}) async {
|
||||
assert(listName.isNotEmpty, 'Library list name must not be empty.');
|
||||
assert(
|
||||
(jmdictEntryId == null) != (kanji == null),
|
||||
'Either jmdictEntryId or kanji must be provided, but not both.',
|
||||
);
|
||||
// TODO: set up lastModified insertion
|
||||
|
||||
if (!await libraryListExists(listName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (await libraryListListContains(
|
||||
listName,
|
||||
jmdictEntryId: jmdictEntryId,
|
||||
kanji: kanji,
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (position != null) {
|
||||
final len = (await libraryListGetList(listName))!.totalCount;
|
||||
if (0 > position || position > len) {
|
||||
return false;
|
||||
} else if (position != len) {
|
||||
// TODO: use a transaction instead of a batch
|
||||
final b = batch();
|
||||
|
||||
final entries_ = (await libraryListGetListEntries(listName))!.entries;
|
||||
|
||||
// TODO: create a query to get entries at exact positions.
|
||||
final prevEntry = entries_[position - 1];
|
||||
final nextEntry = entries_[position];
|
||||
|
||||
b.insert(LibraryListTableNames.libraryListEntry, {
|
||||
'listName': listName,
|
||||
'jmdictEntryId': jmdictEntryId,
|
||||
'kanji': kanji,
|
||||
'prevEntryJmdictEntryId': prevEntry.jmdictEntryId,
|
||||
'prevEntryKanji': prevEntry.kanji,
|
||||
});
|
||||
|
||||
b.update(
|
||||
LibraryListTableNames.libraryListEntry,
|
||||
{
|
||||
'prevEntryJmdictEntryId': jmdictEntryId,
|
||||
'prevEntryKanji': kanji,
|
||||
},
|
||||
where: '"listName" = ? AND ("jmdictEntryId" = ? OR "kanji" = ?)',
|
||||
whereArgs: [listName, nextEntry.jmdictEntryId, nextEntry.kanji],
|
||||
);
|
||||
|
||||
await b.commit();
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
final LibraryListEntry? prevEntry =
|
||||
(await libraryListGetListEntries(listName))!.entries.lastOrNull;
|
||||
|
||||
await insert(
|
||||
LibraryListTableNames.libraryListEntry,
|
||||
{
|
||||
'listName': listName,
|
||||
'jmdictEntryId': jmdictEntryId,
|
||||
'kanji': kanji,
|
||||
'prevEntryJmdictEntryId': prevEntry?.jmdictEntryId,
|
||||
'prevEntryKanji': prevEntry?.kanji,
|
||||
},
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Deletes an entry at a specific position in the library list.
|
||||
///
|
||||
/// This function returns false if the list does not exist,
|
||||
/// or if the entry is not already a part of the list.
|
||||
Future<bool> libraryListDeleteEntry(
|
||||
String listName, {
|
||||
int? jmdictEntryId,
|
||||
String? kanji,
|
||||
}) async {
|
||||
assert(listName.isNotEmpty, 'Library list name must not be empty.');
|
||||
assert(
|
||||
(jmdictEntryId == null) != (kanji == null),
|
||||
'Either jmdictEntryId or kanji must be provided, but not both.',
|
||||
);
|
||||
|
||||
if (!await libraryListExists(listName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!await libraryListListContains(
|
||||
listName,
|
||||
jmdictEntryId: jmdictEntryId,
|
||||
kanji: kanji,
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// TODO: these queries might be combined into one
|
||||
final entryQuery = await query(
|
||||
LibraryListTableNames.libraryListEntry,
|
||||
columns: ['prevEntryJmdictEntryId', 'prevEntryKanji'],
|
||||
where: '"listName" = ? AND ("jmdictEntryId" = ? OR "kanji" = ?)',
|
||||
whereArgs: [listName, jmdictEntryId, kanji],
|
||||
);
|
||||
|
||||
final nextEntryQuery = await query(
|
||||
LibraryListTableNames.libraryListEntry,
|
||||
where:
|
||||
'"listName" = ? AND ("prevEntryJmdictEntryId" = ? OR "prevEntryKanji" = ?)',
|
||||
whereArgs: [listName, jmdictEntryId, kanji],
|
||||
);
|
||||
|
||||
final prevEntryJmdictEntryId =
|
||||
entryQuery.first['prevEntryJmdictEntryId'] as int?;
|
||||
final prevEntryKanji = entryQuery.first['prevEntryKanji'] as String?;
|
||||
|
||||
final LibraryListEntry? nextEntry =
|
||||
nextEntryQuery.map((e) => LibraryListEntry.fromDBMap(e)).firstOrNull;
|
||||
|
||||
// TODO: use a transaction instead of a batch
|
||||
final b = batch();
|
||||
|
||||
if (nextEntry != null) {
|
||||
b.update(
|
||||
LibraryListTableNames.libraryListEntry,
|
||||
{
|
||||
'prevEntryJmdictEntryId': prevEntryJmdictEntryId,
|
||||
'prevEntryKanji': prevEntryKanji,
|
||||
},
|
||||
where: '"listName" = ? AND ("jmdictEntryId" = ? OR "kanji" = ?)',
|
||||
whereArgs: [listName, nextEntry.jmdictEntryId, nextEntry.kanji],
|
||||
);
|
||||
}
|
||||
|
||||
b.delete(
|
||||
LibraryListTableNames.libraryListEntry,
|
||||
where: '"listName" = ? AND ("jmdictEntryId" = ? OR "kanji" = ?)',
|
||||
whereArgs: [listName, jmdictEntryId, kanji],
|
||||
);
|
||||
|
||||
b.commit();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Deletes an entry at a specific position in the library list.
|
||||
///
|
||||
/// This function returns false if the position is out of bounds,
|
||||
/// or if the list does not exist.
|
||||
///
|
||||
/// Avoid using this function if possible, as it has a time complexity of O(n),
|
||||
/// in contrast to `libraryListDeleteEntry` which has a time complexity of whatever
|
||||
/// SQLite uses for its indices.
|
||||
Future<bool> libraryListDeleteEntryByPosition(
|
||||
String listName,
|
||||
int position,
|
||||
) async {
|
||||
assert(listName.isNotEmpty, 'Library list name must not be empty.');
|
||||
|
||||
assert(position >= 0, 'Position must be a non-negative integer.');
|
||||
|
||||
if (!await libraryListExists(listName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final entries = (await libraryListGetListEntries(
|
||||
listName,
|
||||
page: 0,
|
||||
pageSize: position + 1,
|
||||
))
|
||||
?.entries;
|
||||
if (entries == null || position >= entries.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final entry = entries[position];
|
||||
|
||||
final result = await libraryListDeleteEntry(
|
||||
listName,
|
||||
jmdictEntryId: entry.jmdictEntryId,
|
||||
kanji: entry.kanji,
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Reorders an entry within the library list.
|
||||
///
|
||||
/// This function returns false if the position is out of bounds,
|
||||
/// if the list does not exist, or if the entry is not already a part of the list.
|
||||
Future<bool> libraryListMoveEntry(
|
||||
String listName,
|
||||
int newPosition, {
|
||||
int? jmdictEntryId,
|
||||
String? kanji,
|
||||
}) async {
|
||||
assert(listName.isNotEmpty, 'Library list name must not be empty.');
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
/// Appends an entry to the library list if it's not there already,
|
||||
/// or removes it if it is. Returns whether the entry is now in the list.
|
||||
Future<bool> libraryListToggleEntry(
|
||||
String listName, {
|
||||
int? jmdictEntryId,
|
||||
String? kanji,
|
||||
bool? overrideToggleOn,
|
||||
}) async {
|
||||
assert(listName.isNotEmpty, 'Library list name must not be empty.');
|
||||
|
||||
if ((jmdictEntryId == null) == (kanji == null)) {
|
||||
throw ArgumentError(
|
||||
'Either jmdictEntryId or kanji must be provided, but not both.',
|
||||
);
|
||||
}
|
||||
|
||||
final shouldToggleOn = overrideToggleOn ??
|
||||
!(await libraryListListContains(
|
||||
listName,
|
||||
jmdictEntryId: jmdictEntryId,
|
||||
kanji: kanji,
|
||||
));
|
||||
|
||||
if (shouldToggleOn) {
|
||||
final result = await libraryListInsertEntry(
|
||||
listName,
|
||||
jmdictEntryId: jmdictEntryId,
|
||||
kanji: kanji,
|
||||
);
|
||||
assert(
|
||||
result,
|
||||
'Failed to insert entry into library list "$listName".',
|
||||
);
|
||||
} else {
|
||||
final result = await libraryListDeleteEntry(
|
||||
listName,
|
||||
jmdictEntryId: jmdictEntryId,
|
||||
kanji: kanji,
|
||||
);
|
||||
assert(
|
||||
result,
|
||||
'Failed to delete entry from library list "$listName".',
|
||||
);
|
||||
}
|
||||
|
||||
return shouldToggleOn;
|
||||
}
|
||||
|
||||
/// Verifies the linked list structure of the list of library lists.
|
||||
Future<bool> libraryListVerifyLists() async {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
/// Verifies the linked list structure of a single library list.
|
||||
Future<bool> libraryListVerifyList(String listName) async {
|
||||
assert(listName.isNotEmpty, 'Library list name must not be empty.');
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
// Future<void> libraryListInsertJsonEntries(
|
||||
// List<Map<String, Object?>> jsonEntries,
|
||||
// ) async {
|
||||
// throw UnimplementedError();
|
||||
// }
|
||||
|
||||
Future<void> libraryListInsertJsonEntriesForSingleList(
|
||||
String listName,
|
||||
List<Map<String, Object?>> jsonEntries,
|
||||
) async {
|
||||
List<LibraryListEntry> entries =
|
||||
jsonEntries.map((e) => LibraryListEntry.fromJson(e)).toList();
|
||||
|
||||
// TODO: batch
|
||||
for (final entry in entries) {
|
||||
await libraryListInsertEntry(
|
||||
listName,
|
||||
kanji: entry.kanji,
|
||||
jmdictEntryId: entry.jmdictEntryId,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class LibraryList {
|
||||
final String name;
|
||||
final int totalCount;
|
||||
|
||||
const LibraryList({
|
||||
required this.name,
|
||||
required this.totalCount,
|
||||
});
|
||||
}
|
||||
|
||||
class LibraryListPage {
|
||||
final String name;
|
||||
final int totalCount;
|
||||
final List<LibraryListEntry> entries;
|
||||
|
||||
const LibraryListPage({
|
||||
required this.name,
|
||||
required this.totalCount,
|
||||
required this.entries,
|
||||
});
|
||||
}
|
||||
|
||||
class LibraryListEntry {
|
||||
DateTime lastModified;
|
||||
|
||||
final int? jmdictEntryId;
|
||||
final WordSearchResult? wordSearchResult;
|
||||
|
||||
final String? kanji;
|
||||
final KanjiSearchResult? kanjiSearchResult;
|
||||
|
||||
LibraryListEntry({
|
||||
DateTime? lastModified,
|
||||
this.wordSearchResult,
|
||||
this.jmdictEntryId,
|
||||
this.kanji,
|
||||
this.kanjiSearchResult,
|
||||
}) : lastModified = lastModified ?? DateTime.now(),
|
||||
assert(
|
||||
kanji != null || jmdictEntryId != null,
|
||||
"Library entry can't be empty",
|
||||
),
|
||||
assert(
|
||||
!(kanji != null && jmdictEntryId != null),
|
||||
"Library entry can't have both kanji and jmdictEntryId",
|
||||
),
|
||||
assert(
|
||||
kanjiSearchResult?.kanji == kanji,
|
||||
"KanjiSearchResult's kanji must match the kanji in LibraryListEntry",
|
||||
),
|
||||
assert(
|
||||
wordSearchResult?.entryId == jmdictEntryId,
|
||||
"WordSearchResult's jmdictEntryId must match the jmdictEntryId in LibraryListEntry",
|
||||
);
|
||||
|
||||
LibraryListEntry.fromJmdictId({
|
||||
required int this.jmdictEntryId,
|
||||
this.wordSearchResult,
|
||||
DateTime? lastModified,
|
||||
}) : lastModified = lastModified ?? DateTime.now(),
|
||||
kanji = null,
|
||||
kanjiSearchResult = null;
|
||||
|
||||
LibraryListEntry.fromKanji({
|
||||
required String this.kanji,
|
||||
this.kanjiSearchResult,
|
||||
DateTime? lastModified,
|
||||
}) : lastModified = lastModified ?? DateTime.now(),
|
||||
jmdictEntryId = null,
|
||||
wordSearchResult = null;
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'kanji': kanji,
|
||||
'jmdictEntryId': jmdictEntryId,
|
||||
'lastModified': lastModified.millisecondsSinceEpoch,
|
||||
};
|
||||
|
||||
factory LibraryListEntry.fromJson(Map<String, Object?> json) {
|
||||
assert(
|
||||
(json.containsKey('kanji') && json['kanji'] != null) ||
|
||||
(json.containsKey('jmdictEntryId') && json['jmdictEntryId'] != null),
|
||||
"Library entry can't be empty",
|
||||
);
|
||||
assert(
|
||||
json.containsKey('lastModified'),
|
||||
"Library entry must have a lastModified timestamp",
|
||||
);
|
||||
|
||||
if (json.containsKey('kanji') && json['kanji'] != null) {
|
||||
return LibraryListEntry.fromKanji(
|
||||
kanji: json['kanji']! as String,
|
||||
lastModified: DateTime.fromMillisecondsSinceEpoch(
|
||||
json['lastModified']! as int,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return LibraryListEntry.fromJmdictId(
|
||||
jmdictEntryId: json['jmdictEntryId']! as int,
|
||||
lastModified: DateTime.fromMillisecondsSinceEpoch(
|
||||
json['lastModified']! as int,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: this just happens to be the same as the logic in `fromJson`
|
||||
factory LibraryListEntry.fromDBMap(Map<String, Object?> dbObject) =>
|
||||
LibraryListEntry.fromJson(dbObject);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:mugiten/models/library/library_list.dart';
|
||||
import 'package:mugiten/models/library_list.dart';
|
||||
import 'package:mugiten/screens/info/changelog.dart';
|
||||
import 'package:mugiten/screens/library/library_content_view.dart';
|
||||
import 'package:mugiten/screens/search/kanji_search_result_page.dart';
|
||||
|
||||
@@ -7,7 +7,7 @@ import '../components/common/loading.dart';
|
||||
import '../components/common/opaque_box.dart';
|
||||
import '../components/history/date_divider.dart';
|
||||
import '../components/history/history_entry_tile.dart';
|
||||
import '../models/history/history_entry.dart';
|
||||
import '../models/history_entry.dart';
|
||||
import '../services/datetime.dart';
|
||||
|
||||
const int pageSize = 50;
|
||||
@@ -25,11 +25,11 @@ class _HistoryViewState extends State<HistoryView> {
|
||||
getNextPageKey: (state) =>
|
||||
state.lastPageIsEmpty ? null : state.nextIntPageKey,
|
||||
fetchPage: (pageKey) async {
|
||||
List<HistoryEntry?> result = await HistoryEntry.entriesFromDb(
|
||||
GetIt.instance.get<Database>(),
|
||||
page: pageKey - 1,
|
||||
pageSize: pageSize,
|
||||
);
|
||||
List<HistoryEntry?> result =
|
||||
await GetIt.instance.get<Database>().historyEntryGetAll(
|
||||
page: pageKey - 1,
|
||||
pageSize: pageSize,
|
||||
);
|
||||
|
||||
// Insert a null entry at the start in order to prepend a separator to the first actual entry.
|
||||
if (pageKey == 1) {
|
||||
@@ -48,8 +48,13 @@ class _HistoryViewState extends State<HistoryView> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
GetIt.instance.get<Database>().historyEntryGetAll(
|
||||
page: 0,
|
||||
pageSize: pageSize,
|
||||
);
|
||||
|
||||
return FutureBuilder<int>(
|
||||
future: HistoryEntry.amountOfEntries(GetIt.instance.get<Database>()),
|
||||
future: GetIt.instance.get<Database>().historyEntryAmount(),
|
||||
builder: (context, snapshot) {
|
||||
// TODO: provide proper error handling
|
||||
if (snapshot.hasError) return ErrorWidget(snapshot.error!);
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import 'package:confirm_dialog/confirm_dialog.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:mugiten/database/database.dart';
|
||||
import 'package:mugiten/models/library_list.dart';
|
||||
import 'package:sqflite/sqlite_api.dart';
|
||||
|
||||
import '../../components/common/loading.dart';
|
||||
import '../../components/library/library_list_entry_tile.dart';
|
||||
import '../../models/library/library_entry.dart';
|
||||
import '../../models/library/library_list.dart';
|
||||
|
||||
class LibraryContentView extends StatefulWidget {
|
||||
final LibraryList library;
|
||||
@@ -21,11 +19,15 @@ class LibraryContentView extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _LibraryContentViewState extends State<LibraryContentView> {
|
||||
List<LibraryEntry>? entries;
|
||||
List<LibraryListEntry>? entries;
|
||||
|
||||
Future<void> getEntriesFromDatabase() => widget.library
|
||||
.entries(GetIt.instance.get<Database>())
|
||||
.then((es) => setState(() => entries = es));
|
||||
Future<void> getEntriesFromDatabase() => GetIt.instance
|
||||
.get<Database>()
|
||||
.libraryListGetListEntries(
|
||||
widget.library.name,
|
||||
includeSearchResult: true,
|
||||
)
|
||||
.then((entries_) => setState(() => entries = entries_?.entries));
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -41,9 +43,7 @@ class _LibraryContentViewState extends State<LibraryContentView> {
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () async {
|
||||
final entryCount = await widget.library.length(
|
||||
GetIt.instance.get<Database>(),
|
||||
);
|
||||
final entryCount = widget.library.totalCount;
|
||||
if (!context.mounted) return;
|
||||
final bool userIsSure = await confirm(
|
||||
context,
|
||||
@@ -53,9 +53,10 @@ class _LibraryContentViewState extends State<LibraryContentView> {
|
||||
);
|
||||
if (!userIsSure) return;
|
||||
|
||||
await widget.library.deleteAllEntries(
|
||||
GetIt.instance.get<Database>(),
|
||||
);
|
||||
await GetIt.instance
|
||||
.get<Database>()
|
||||
.libraryListDeleteAllEntries(widget.library.name);
|
||||
|
||||
await getEntriesFromDatabase();
|
||||
},
|
||||
icon: const Icon(Icons.delete),
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:mugiten/models/library_list.dart';
|
||||
import 'package:sqflite/sqlite_api.dart';
|
||||
|
||||
import '../../components/common/loading.dart';
|
||||
import '../../components/library/library_list_tile.dart';
|
||||
import '../../models/library/library_list.dart';
|
||||
|
||||
class LibraryView extends StatefulWidget {
|
||||
const LibraryView({super.key});
|
||||
@@ -16,9 +16,10 @@ class LibraryView extends StatefulWidget {
|
||||
class _LibraryViewState extends State<LibraryView> {
|
||||
List<LibraryList>? libraries;
|
||||
|
||||
Future<void> getEntriesFromDatabase() =>
|
||||
LibraryList.allLibraries(GetIt.instance.get<Database>())
|
||||
.then((libs) => setState(() => libraries = libs));
|
||||
Future<void> getEntriesFromDatabase() => GetIt.instance
|
||||
.get<Database>()
|
||||
.libraryListGetLists()
|
||||
.then((libs) => setState(() => libraries = libs));
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -32,7 +33,7 @@ class _LibraryViewState extends State<LibraryView> {
|
||||
return Column(
|
||||
children: [
|
||||
LibraryListTile(
|
||||
library: LibraryList.favourites,
|
||||
library: libraries!.first,
|
||||
leading: const Icon(Icons.star),
|
||||
onDelete: getEntriesFromDatabase,
|
||||
onUpdate: getEntriesFromDatabase,
|
||||
|
||||
@@ -4,13 +4,12 @@ import 'package:jadb/models/kanji_search/kanji_search_result.dart';
|
||||
import 'package:jadb/search.dart';
|
||||
import 'package:mdi/mdi.dart';
|
||||
import 'package:mugiten/components/library/add_to_library_dialog.dart';
|
||||
import 'package:mugiten/models/history/history_entry.dart';
|
||||
import 'package:mugiten/models/library/library_list.dart';
|
||||
import 'package:mugiten/models/history_entry.dart';
|
||||
import 'package:mugiten/models/library_list.dart';
|
||||
import 'package:mugiten/services/snackbar.dart';
|
||||
import 'package:mugiten/settings.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
|
||||
// import './kanji_result_body/examples.dart';
|
||||
import '../../components/kanji/kanji_result_body/grade.dart';
|
||||
import '../../components/kanji/kanji_result_body/header.dart';
|
||||
import '../../components/kanji/kanji_result_body/jlpt_level.dart';
|
||||
@@ -114,9 +113,10 @@ class _KanjiSearchResultPageState extends State<KanjiSearchResultPage> {
|
||||
icon: const Icon(Icons.star),
|
||||
color: isFavourite ? Colors.yellow : null,
|
||||
onPressed: () {
|
||||
LibraryList.favourites
|
||||
.toggleEntry(
|
||||
db: GetIt.instance.get<Database>(),
|
||||
GetIt.instance
|
||||
.get<Database>()
|
||||
.libraryListToggleEntry(
|
||||
"favourites",
|
||||
jmdictEntryId: null,
|
||||
kanji: result.kanji,
|
||||
)
|
||||
@@ -165,12 +165,21 @@ class _KanjiSearchResultPageState extends State<KanjiSearchResultPage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
LibraryList.favourites
|
||||
.containsKanji(
|
||||
GetIt.instance.get<Database>(),
|
||||
widget.kanji,
|
||||
|
||||
GetIt.instance
|
||||
.get<Database>()
|
||||
.libraryListListContains(
|
||||
"favourites",
|
||||
kanji: widget.kanji,
|
||||
)
|
||||
.then((value) => setState(() => isFavourite = value));
|
||||
|
||||
if (!incognitoModeEnabled && !addedToDatabase) {
|
||||
GetIt.instance
|
||||
.get<Database>()
|
||||
.historyEntryInsertKanji(widget.kanji)
|
||||
.then((_) => setState(() => addedToDatabase = true));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -184,14 +193,6 @@ class _KanjiSearchResultPageState extends State<KanjiSearchResultPage> {
|
||||
}
|
||||
if (snapshot.hasError) return ErrorWidget(snapshot.error!);
|
||||
|
||||
if (!incognitoModeEnabled && !addedToDatabase) {
|
||||
HistoryEntry.insertKanji(
|
||||
db: GetIt.instance.get<Database>(),
|
||||
kanji: widget.kanji,
|
||||
);
|
||||
addedToDatabase = true;
|
||||
}
|
||||
|
||||
return _body(snapshot.data!);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@ import 'package:jadb/search.dart' show JaDBConnection;
|
||||
import 'package:mdi/mdi.dart';
|
||||
import 'package:mugiten/bloc/theme/theme_bloc.dart';
|
||||
import 'package:mugiten/components/search/search_results_body/parts/circle_badge.dart';
|
||||
import 'package:mugiten/models/history/history_entry.dart';
|
||||
import 'package:mugiten/models/history_entry.dart';
|
||||
import 'package:mugiten/services/snackbar.dart';
|
||||
import 'package:mugiten/settings.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
@@ -51,28 +51,27 @@ class _WordSearchResultPageState extends State<WordSearchResultPage> {
|
||||
super.initState();
|
||||
|
||||
if (!incognitoModeEnabled && !addedToDatabase) {
|
||||
HistoryEntry.insertWord(
|
||||
db: GetIt.instance.get<Database>(),
|
||||
word: widget.searchTerm,
|
||||
).then((historyEntry) async {
|
||||
await historyEntry
|
||||
.populateTimestampCount(GetIt.instance.get<Database>());
|
||||
return historyEntry;
|
||||
}).then(
|
||||
(entry) => setState(() {
|
||||
addedToDatabase = true;
|
||||
historyEntry = entry;
|
||||
}),
|
||||
);
|
||||
GetIt.instance
|
||||
.get<Database>()
|
||||
.historyEntryInsertWord(widget.searchTerm)
|
||||
.then((_) => GetIt.instance
|
||||
.get<Database>()
|
||||
.historyEntryGetWord(widget.searchTerm))
|
||||
.then(
|
||||
(entry) => setState(() {
|
||||
addedToDatabase = true;
|
||||
historyEntry = entry;
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
HistoryEntry.getWord(
|
||||
GetIt.instance.get<Database>(),
|
||||
widget.searchTerm,
|
||||
).then(
|
||||
(entry) => setState(() {
|
||||
historyEntry = entry;
|
||||
}),
|
||||
);
|
||||
GetIt.instance
|
||||
.get<Database>()
|
||||
.historyEntryGetWord(widget.searchTerm)
|
||||
.then(
|
||||
(entry) => setState(() {
|
||||
historyEntry = entry;
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +93,7 @@ class _WordSearchResultPageState extends State<WordSearchResultPage> {
|
||||
onPressed: () =>
|
||||
showSnackbar(context, 'History tracking is disabled'),
|
||||
),
|
||||
if (historyEntry != null && (historyEntry!.timestampCount ?? 0) > 1)
|
||||
if (historyEntry != null && historyEntry!.timestampCount > 1)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: BlocBuilder<ThemeBloc, ThemeState>(
|
||||
|
||||
@@ -11,7 +11,7 @@ import 'package:mugiten/bloc/theme/theme_bloc.dart';
|
||||
import 'package:mugiten/components/common/denshi_jisho_background.dart';
|
||||
import 'package:mugiten/database/history/table_names.dart';
|
||||
import 'package:mugiten/main.dart';
|
||||
import 'package:mugiten/models/history/history_entry.dart';
|
||||
import 'package:mugiten/models/history_entry.dart';
|
||||
import 'package:mugiten/models/themes/theme.dart';
|
||||
import 'package:mugiten/routing/routes.dart';
|
||||
import 'package:mugiten/services/data_export_import.dart';
|
||||
@@ -33,10 +33,8 @@ class _SettingsViewState extends State<SettingsView> {
|
||||
bool dataImportIsLoading = false;
|
||||
|
||||
Future<void> clearHistory(context) async {
|
||||
|
||||
final historyCount = await HistoryEntry.amountOfEntries(
|
||||
GetIt.instance.get<Database>(),
|
||||
);
|
||||
final historyCount =
|
||||
await GetIt.instance.get<Database>().historyEntryAmount();
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
|
||||
@@ -3,10 +3,9 @@ import 'dart:core';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:archive/archive_io.dart';
|
||||
import 'package:mugiten/database/history/table_names.dart';
|
||||
import 'package:mugiten/database/library_list/table_names.dart';
|
||||
import 'package:mugiten/models/history/history_entry.dart';
|
||||
import 'package:mugiten/models/library/library_list.dart';
|
||||
import 'package:mugiten/models/history_entry.dart';
|
||||
import 'package:mugiten/models/library_list.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
|
||||
// Example file Structure:
|
||||
@@ -105,19 +104,8 @@ Future<void> exportHistoryTo(
|
||||
final file = dir.historyFile;
|
||||
file.createSync();
|
||||
|
||||
final query =
|
||||
await db.query(HistoryTableNames.historyEntryOrderedByTimestamp);
|
||||
|
||||
final List<HistoryEntry> entries =
|
||||
query.map((e) => HistoryEntry.fromDBMap(e)).toList();
|
||||
|
||||
/// TODO: This creates a ton of sql statements. Ideally, the whole export
|
||||
/// should be done in only one query.
|
||||
///
|
||||
/// On second thought, is that even possible? It's a doubly nested list structure.
|
||||
final List<Map<String, Object?>> jsonEntries = await Future.wait(
|
||||
entries.map((historyEntry) async => historyEntry.toJson(db)),
|
||||
);
|
||||
final List<Map<String, Object?>> jsonEntries =
|
||||
(await db.historyEntryGetAll()).map((e) => e.toJson()).toList();
|
||||
|
||||
file.writeAsStringSync(jsonEncode(jsonEntries));
|
||||
}
|
||||
@@ -128,7 +116,7 @@ Future<void> importHistoryFrom(Database db, File file) async {
|
||||
.map((h) => h as Map<String, Object?>)
|
||||
.toList();
|
||||
// log('Importing ${json.length} entries from ${file.path}');
|
||||
await HistoryEntry.insertJsonEntries(db, json);
|
||||
await db.transaction((txn) => txn.historyEntryInsertManyFromJson(json));
|
||||
}
|
||||
|
||||
///////////////////
|
||||
@@ -158,7 +146,9 @@ Future<void> exportLibraryListTo(
|
||||
final file = File(dir.uri.resolve('$libraryName.json').toFilePath());
|
||||
await file.create();
|
||||
|
||||
final entries = (await LibraryList.byName(libraryName).entries(db))
|
||||
// TODO: properly null check
|
||||
final entries = (await db.libraryListGetListEntries(libraryName))!
|
||||
.entries
|
||||
.map((e) => e.toJson())
|
||||
.toList();
|
||||
|
||||
@@ -176,8 +166,8 @@ Future<void> importLibraryListsFrom(
|
||||
final libraryName =
|
||||
file.uri.pathSegments.last.replaceFirst(RegExp(r'\.json$'), '');
|
||||
|
||||
if (await LibraryList.exists(db, libraryName)) {
|
||||
if ((await LibraryList.byName(libraryName).length(db)) > 0) {
|
||||
if (await db.libraryListExists(libraryName)) {
|
||||
if ((await db.libraryListGetList(libraryName))!.totalCount > 0) {
|
||||
print(
|
||||
'Library list "$libraryName" already exists and is not empty. Skipping import.');
|
||||
continue;
|
||||
@@ -186,7 +176,7 @@ Future<void> importLibraryListsFrom(
|
||||
'Importing entries from file ${file.path}.');
|
||||
}
|
||||
} else {
|
||||
LibraryList.insert(db, libraryName);
|
||||
await db.libraryListInsertList(libraryName);
|
||||
}
|
||||
|
||||
final content = await file.readAsString();
|
||||
@@ -194,7 +184,9 @@ Future<void> importLibraryListsFrom(
|
||||
.map((e) => e as Map<String, Object?>)
|
||||
.toList();
|
||||
|
||||
final libraryList = LibraryList.byName(libraryName);
|
||||
await libraryList.insertJsonEntries(db, jsonEntries);
|
||||
await db.libraryListInsertJsonEntriesForSingleList(
|
||||
libraryName,
|
||||
jsonEntries,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,7 +317,7 @@ packages:
|
||||
description:
|
||||
path: "."
|
||||
ref: HEAD
|
||||
resolved-ref: d9006a0767aa0f903db0be1726fde3a14d0cd579
|
||||
resolved-ref: "0a3387e77a0fa3f789e91f3dce00221466f19c98"
|
||||
url: "https://git.pvv.ntnu.no/oysteikt/jadb.git"
|
||||
source: git
|
||||
version: "1.0.0"
|
||||
|
||||
@@ -6,7 +6,7 @@ import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:mugiten/database/database.dart';
|
||||
import 'package:mugiten/models/library/library_list.dart';
|
||||
import 'package:mugiten/models/library_list.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
import 'package:sqlite3/open.dart';
|
||||
@@ -46,15 +46,17 @@ Future<Database> createDatabaseCopy({
|
||||
}
|
||||
|
||||
Future<void> insertTestData(Database db) async {
|
||||
final libraryList1 = await LibraryList.insert(db, "Test Library 1");
|
||||
final libraryList1 = await db.libraryListInsertList("Test Library 1");
|
||||
assert(libraryList1 == true);
|
||||
|
||||
await libraryList1.insertEntry(
|
||||
db: db,
|
||||
await db.libraryListInsertEntry(
|
||||
"Test Library 1",
|
||||
jmdictEntryId: null,
|
||||
kanji: "漢",
|
||||
);
|
||||
await libraryList1.insertEntry(
|
||||
db: db,
|
||||
|
||||
await db.libraryListInsertEntry(
|
||||
"Test Library 1",
|
||||
jmdictEntryId: null,
|
||||
kanji: "字",
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user