Rewrite database models as class extensions + data classes

This commit is contained in:
2025-07-14 20:15:51 +02:00
parent 61c8d27bb9
commit c6d97c73a9
21 changed files with 1343 additions and 1308 deletions

View File

@@ -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>(

View File

@@ -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),
],
),
);

View File

@@ -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();
},
);

View File

@@ -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'),
],
),
),

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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],
);
}

View 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(),
};
}
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View 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);
}

View File

@@ -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';

View File

@@ -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!);

View File

@@ -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),

View File

@@ -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,

View File

@@ -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!);
},
);

View File

@@ -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>(

View File

@@ -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;

View File

@@ -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,
);
}
}

View File

@@ -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"

View File

@@ -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: "",
);