Files
mugiten/lib/models/library_list.dart

761 lines
21 KiB
Dart

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 ?' : ''}
''',
[?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, ?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<void> libraryListRenameList(String oldName, String newName) async {
if (oldName.isEmpty) {
throw ArgumentError('Old library list name must not be empty.');
}
if (newName.isEmpty) {
throw ArgumentError('New library list name must not be empty.');
}
if (oldName == 'favourites') {
throw ArgumentError('Cannot rename the "favourites" list.');
}
if (!await libraryListExists(oldName)) {
throw ArgumentError('Library list "$oldName" does not exist.');
}
if (await libraryListExists(newName)) {
throw ArgumentError('Library list "$newName" already exists.');
}
final b = batch();
b.update(
LibraryListTableNames.libraryList,
{'name': newName},
where: '"name" = ?',
whereArgs: [oldName],
);
b.update(
LibraryListTableNames.libraryListEntry,
{'listName': newName},
where: '"listName" = ?',
whereArgs: [oldName],
);
await b.commit();
}
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 {
final 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);
}