Tweak database table and view schema (#3805)

* Select explicit columns and name same-typed arguments

SQLDelight expands SELECT * using the table's column order and binds
mappers to it by position, so reordering columns would silently swap
mapper arguments. Queries list their columns instead. Calls whose
arguments share a type pass them by name, so swapping two still fails to
compile.

The generated code is unchanged apart from the SQL text.

* table - manga

Rebuild `mangas` as `manga` with a UNIQUE(source_id, remote_url) constraint,
`remote_`/`user_`/`state_` column prefixes and `_id` -> `id`. `remote_` is what the
source reported, `user_` is what the user set, `state_` is local bookkeeping that is
neither; `source_id` keeps its name because it identifies the source rather than
carrying its data.

Entries sharing a source and url are merged rather than dropped. The copies are ranked
with the ones in the library first, since that is the entry the user has, then by age.
The first one stays, takes each detail it is missing from the first copy that has it,
and absorbs the children of the rest. The duplicate chapters that repointing creates
are merged too, read state and history included.

The child tables keep pointing at the table this replaces until their own step rebuilds
them. Foreign keys are off for the whole migration, so nothing reads those clauses in
between, and copying every child table here just to rewrite one clause would double the
work the migration does. For the same reason the children resolve the surviving entry
while they are copied rather than being rewritten here first.

Merging entries is also what creates duplicate chapters and duplicate tracks, but each of
those is resolved in the step that rebuilds its own table, where the constraint that makes
the collision matter is introduced.

Queries are only retargeted at the new names; using the new structure is left to later
changes.

Assisted-by: Claude:claude-opus-5

* table - chapter

Rebuild `chapters` as `chapter` with a UNIQUE(manga_id, remote_url) constraint,
`remote_`/`user_`/`state_` column prefixes and `_id` -> `id`.

The entries that merged in the previous step brought their chapters with them, so the
chapters move onto the surviving entry here and the duplicates that creates are merged:
the copy with reading history is kept, the oldest otherwise, and the read state and
history of the others are folded into it. It happens here rather than with the merge
because this is where the constraint that rejects the duplicates is introduced.

`chapters_manga_id_index`, `chapters_unread_by_manga_index` and `idx_chapters_url` go away:
the new unique constraint indexes `manga_id`, and the only url-only lookup has no callers.
The unread index served the updates view's read filter, but that view sorts by fetch date
and stops at a limit, which an index on `state_date_fetch` serves better with or without the
read filter, so that index takes its place. A second, partial one covers only the unread
chapters' fetch dates: most chapters in a library are read, so finding the unread ones
through the full index means walking nearly all of it.

Queries are only retargeted at the new names; using the new structure is left to later
changes.

Assisted-by: Claude:claude-opus-5

* table - category

Rebuild `categories` as `category` with `_id` -> `id` and `sort` -> `order`, the name it
already had everywhere above the database. `order` gains a UNIQUE constraint, since two
categories sharing a position was never meaningful.

`sort` was never unique, and reordering wrote colliding values often enough that it cannot
simply be copied under the constraint. The migration renumbers the rows from 0 in the order
they were displayed, ties broken by id, and keeps the system category at -1.

`mangas_categories` is rebuilt in the migration only to point its foreign key at the new
table; it gets its real rebuild in its own step. Queries are only retargeted at the new
names; using the new structure is left to later changes.

Assisted-by: Claude:claude-opus-5

* table - manga_category

Rebuild `mangas_categories` as `manga_category`. The surrogate `_id` went unused, so the
pair becomes the identity with a UNIQUE(manga_id, category_id) constraint; duplicate
memberships were never meaningful. `idx_mangas_categories_manga_id` goes away because the
unique constraint already indexes `manga_id` as its leading column.

Entries merged in the manga step bring their memberships along. When the merged entry is
in the library, only the copies that were in it decide its categories, since a copy
outside the library can still hold the categories it had before it was removed.

Queries are only retargeted at the new names; using the new structure is left to later
changes.

Assisted-by: Claude:claude-opus-5

* table - history

Rebuild `history` with `_id` -> `id`, `last_read` -> `read_at` and `time_read` ->
`read_duration`; the old names read as booleans rather than the timestamp and duration
they hold. `history_history_chapter_id_index` goes away because `chapter_id` is already
UNIQUE and therefore indexed.

A row also names the entry it belongs to now, not just the chapter. Reaching the entry
meant joining through the chapter every time, and the entry is the thing history is read
by. It is filled from the chapter it hangs off, and keyed to both: the entry first, then
the chapter. A row whose chapter is already gone cannot name an entry and is dropped,
which is what the foreign key would do to it once it is enforced again.

The table is the only one here that keeps its name, so the original is renamed aside and
the new one built under the real name. Building it beside the original and renaming it
into place makes SQLite rewrite the stored definition, which would leave this table
spelled differently from the same table on a fresh install.

Queries are only retargeted at the new names; using the new structure is left to later
changes.

Assisted-by: Claude:claude-opus-5

* table - excluded_scanlator

Rebuild `excluded_scanlators` as `excluded_scanlator`, singular like the other tables.
The pair becomes the identity with a UNIQUE(manga_id, scanlator) constraint; excluding
the same scanlator twice for one entry was never meaningful.
`excluded_scanlators_manga_id_index` goes away because the unique constraint already
indexes `manga_id` as its leading column.

Queries are only retargeted at the new names; using the new structure is left to later
changes.

Assisted-by: Claude:claude-opus-5

* table - manga_track

Rebuild `manga_sync` as `manga_track`: the table holds tracker entries, and nothing about
it is a sync. `_id` -> `id` and `sync_id` -> `tracker_id`, which is what it identifies.
`idx_manga_sync_manga_id` goes away because UNIQUE(manga_id, tracker_id) already indexes
`manga_id` as its leading column.

The constraint drops ON CONFLICT REPLACE and aborts like every other one. Replacing
deleted the row and inserted a new one under a new id, which lost anything keyed to the
old id, such as a queued tracking update, and it hid any other write that collided. The
insert now says what a conflict means: an existing track for the entry and tracker is
updated in place.

When entries merge, two rows for the same tracker can end up pointing at the survivor, so
the row to keep is chosen first: one on a copy in the library, then the one furthest read,
then the one on the copy ranked first, then the oldest. A track is taken whole rather than
merged field by field, because two rows on the same tracker can point at different remote
entries.

Queries are only retargeted at the new names; using the new structure is left to later
changes.

Assisted-by: Claude:claude-opus-5

* table - source

Rebuild `sources` as `source` with `_id` -> `id` and `lang` -> `language`, spelled out
like every other column here.

Queries are only retargeted at the new names; using the new structure is left to later
changes.

Assisted-by: Claude:claude-opus-5

* view - updates

The view is the Kotlin-facing shape of a row, so its columns are named the way the
callers read them; `last_page_read` and `datefetch` were the only two that weren't.

The migration builds every view once at its end, so this updates that definition rather
than dropping and recreating it again. Queries are only retargeted at the new names;
using the new structure is left to later changes.

Assisted-by: Claude:claude-opus-5

* view - library

Bound the chapter and category aggregates to the library. Both subqueries group over
every chapter and every category membership in the database, and only the outer query
drops the entries that are not in the library, so an entry that was merely browsed still
pays for its chapters every time the library is read. The entries the view keeps are the
only ones whose aggregates it uses, so the same filter belongs inside them, where
`manga_user_favorite_at_index` can serve it.

The view returns the same rows either way. On a 560k chapter database holding 400 entries
among 4400, reading the library goes from ~156ms to ~23ms.

Assisted-by: Claude:claude-opus-5

* view - history

The view is the Kotlin-facing shape of a row, so its columns are named the way the
callers read them: `source` becomes `sourceId` and `state_cover_last_modified` loses the
prefix it only carried because the column it reads has one.

The migration builds every view once at its end, so this updates that definition rather
than dropping and recreating it again. Queries are only retargeted at the new names;
using the new structure is left to later changes.

Assisted-by: Claude:claude-opus-5
This commit is contained in:
AntsyLich
2026-09-28 21:44:11 +06:00
committed by GitHub
parent ec614bb4c3
commit 93cc075116
29 changed files with 1470 additions and 832 deletions
@@ -36,15 +36,15 @@ object DatabaseBindings {
return Database(
driver = driver,
historyAdapter = History.Adapter(
last_readAdapter = DateColumnAdapter,
read_atAdapter = DateColumnAdapter,
),
mangasAdapter = Mangas.Adapter(
genreAdapter = StringListColumnAdapter,
update_strategyAdapter = UpdateStrategyColumnAdapter,
memoAdapter = MemoColumnAdapter,
mangaAdapter = Manga.Adapter(
remote_genreAdapter = StringListColumnAdapter,
remote_update_strategyAdapter = UpdateStrategyColumnAdapter,
remote_memoAdapter = MemoColumnAdapter,
),
chaptersAdapter = Chapters.Adapter(
memoAdapter = MemoColumnAdapter,
chapterAdapter = Chapter.Adapter(
remote_memoAdapter = MemoColumnAdapter,
),
)
}
@@ -35,10 +35,10 @@ class RestoreRepositoryImpl(
) : RestoreRepository {
override suspend fun getMangaUrlsBySourceId(): Map<Long, List<String>> {
return database.mangasQueries
return database.mangaQueries
.getAllMangaSourceAndUrl()
.awaitAsList()
.groupBy({ it.source }, { it.url })
.groupBy({ it.source_id }, { it.remote_url })
}
override suspend fun restoreManga(entries: List<RestoredManga>, update: suspend (Manga) -> MangaUpdate) {
@@ -92,7 +92,7 @@ class RestoreRepositoryImpl(
}
private suspend fun updateManga(manga: Manga): Manga {
database.mangasQueries.updateFromBackup(
database.mangaQueries.updateFromBackup(
favoriteAt = manga.favoriteAt,
artist = manga.artist,
author = manga.author,
@@ -115,7 +115,7 @@ class RestoreRepositoryImpl(
}
private suspend fun insertManga(manga: Manga): Long {
return database.mangasQueries.insertReturningId(
return database.mangaQueries.insertReturningId(
favoriteAt = manga.favoriteAt,
source = manga.source,
url = manga.url,
@@ -183,23 +183,23 @@ class RestoreRepositoryImpl(
.partition { it.id > 0 }
newChapters.forEach { chapter ->
database.chaptersQueries.insert(
chapter.mangaId,
chapter.url,
chapter.name,
chapter.scanlator,
chapter.read,
chapter.bookmark,
chapter.lastPageRead,
chapter.chapterNumber,
chapter.sourceOrder,
chapter.dateFetch,
chapter.dateUpload,
chapter.memo,
database.chapterQueries.insert(
mangaId = chapter.mangaId,
url = chapter.url,
name = chapter.name,
scanlator = chapter.scanlator,
read = chapter.read,
bookmark = chapter.bookmark,
lastPageRead = chapter.lastPageRead,
chapterNumber = chapter.chapterNumber,
sourceOrder = chapter.sourceOrder,
dateFetch = chapter.dateFetch,
dateUpload = chapter.dateUpload,
memo = chapter.memo,
)
}
existingChapters.forEach { chapter ->
database.chaptersQueries.updateFromBackup(
database.chapterQueries.updateFromBackup(
read = chapter.read,
bookmark = chapter.bookmark,
lastPageRead = chapter.lastPageRead,
@@ -223,25 +223,25 @@ class RestoreRepositoryImpl(
.awaitAsOneOrNull()
if (dbHistory == null) {
val chapter = database.chaptersQueries
val chapter = database.chapterQueries
.getChapterByUrlAndMangaId(chapterUrl, manga.id)
.awaitAsOneOrNull()
// Chapter doesn't exist; skip
?: return@mapNotNull null
// New history entry
return@mapNotNull Triple(chapter._id, Date(readAt), readDuration)
return@mapNotNull Triple(chapter.id, Date(readAt), readDuration)
}
// Update history entry
Triple(
dbHistory.chapter_id,
Date(max(readAt, dbHistory.last_read?.time ?: 0L)),
max(readDuration, dbHistory.time_read) - dbHistory.time_read,
Date(max(readAt, dbHistory.read_at?.time ?: 0L)),
max(readDuration, dbHistory.read_duration) - dbHistory.read_duration,
)
}
toUpdate.forEach { (chapterId, readAt, readDuration) ->
database.historyQueries.upsert(chapterId, readAt, readDuration)
database.historyQueries.upsert(chapterId = chapterId, readAt = readAt, readDuration = readDuration)
}
}
@@ -269,31 +269,31 @@ class RestoreRepositoryImpl(
}
existingTracks.forEach { track ->
database.manga_syncQueries.update(
track.mangaId,
track.trackerId,
track.remoteId,
track.libraryId,
track.title,
track.lastChapterRead,
track.totalChapters,
track.status,
track.score,
track.remoteUrl,
track.startDate,
track.finishDate,
track.private,
track.id,
database.manga_trackQueries.update(
mangaId = track.mangaId,
syncId = track.trackerId,
mediaId = track.remoteId,
libraryId = track.libraryId,
title = track.title,
lastChapterRead = track.lastChapterRead,
totalChapter = track.totalChapters,
status = track.status,
score = track.score,
trackingUrl = track.remoteUrl,
startDate = track.startDate,
finishDate = track.finishDate,
`private` = track.private,
id = track.id,
)
}
}
private suspend fun restoreExcludedScanlators(manga: Manga, excludedScanlators: List<String>) {
if (excludedScanlators.isEmpty()) return
val existingExcludedScanlators = database.excluded_scanlatorsQueries
val existingExcludedScanlators = database.excluded_scanlatorQueries
.getExcludedScanlatorsByMangaId(manga.id)
.awaitAsList()
val toInsert = excludedScanlators.filter { it !in existingExcludedScanlators }
toInsert.forEach { database.excluded_scanlatorsQueries.insert(manga.id, it) }
toInsert.forEach { database.excluded_scanlatorQueries.insert(manga.id, it) }
}
}
@@ -21,37 +21,37 @@ class CategoryRepositoryImpl(
) : CategoryRepository {
override suspend fun get(id: Long): Category? {
return database.categoriesQueries
return database.categoryQueries
.getCategory(id, ::mapCategory)
.awaitAsOneOrNull()
}
override suspend fun getAll(): List<Category> {
return database.categoriesQueries
return database.categoryQueries
.getCategories(::mapCategory)
.awaitAsList()
}
override fun getAllAsFlow(): Flow<List<Category>> {
return database.categoriesQueries
return database.categoryQueries
.getCategories(::mapCategory)
.subscribeToList()
}
override suspend fun getCategoriesByMangaId(mangaId: Long): List<Category> {
return database.categoriesQueries
return database.categoryQueries
.getCategoriesByMangaId(mangaId, ::mapCategory)
.awaitAsList()
}
override fun getCategoriesByMangaIdAsFlow(mangaId: Long): Flow<List<Category>> {
return database.categoriesQueries
return database.categoryQueries
.getCategoriesByMangaId(mangaId, ::mapCategory)
.subscribeToList()
}
override suspend fun insert(category: NewCategory) {
database.categoriesQueries.insert(name = category.name, flags = category.flags)
database.categoryQueries.insert(name = category.name, flags = category.flags)
}
override suspend fun insertAll(categories: List<NewCategory>) {
@@ -61,32 +61,32 @@ class CategoryRepositoryImpl(
}
override suspend fun updateName(categoryId: Long, name: String) {
database.categoriesQueries.updateName(name = name, categoryId = categoryId)
database.categoryQueries.updateName(name = name, categoryId = categoryId)
}
override suspend fun updateFlags(categoryId: Long, flags: Long) {
database.categoriesQueries.updateFlags(flags = flags, categoryId = categoryId)
database.categoryQueries.updateFlags(flags = flags, categoryId = categoryId)
}
override suspend fun updateAllFlags(flags: Long?) {
database.categoriesQueries.updateAllFlags(flags = flags)
database.categoryQueries.updateAllFlags(flags = flags)
}
override suspend fun updateAllOrders(orderedIds: List<Long>) {
database.transaction {
val current = database.categoriesQueries.getUserCategoryIds().awaitAsList()
val current = database.categoryQueries.getUserCategoryIds().awaitAsList()
val ids = orderedIds.filter { it in current } + current.filterNot { it in orderedIds }
ids.forEachIndexed { index, categoryId ->
database.categoriesQueries.updateOrder(order = -index - 2L, categoryId = categoryId)
database.categoryQueries.updateOrder(order = -index - 2L, categoryId = categoryId)
}
ids.forEachIndexed { index, categoryId ->
database.categoriesQueries.updateOrder(order = index.toLong(), categoryId = categoryId)
database.categoryQueries.updateOrder(order = index.toLong(), categoryId = categoryId)
}
}
}
override suspend fun delete(categoryId: Long) {
database.categoriesQueries.delete(categoryId = categoryId)
database.categoryQueries.delete(categoryId = categoryId)
}
private fun mapCategory(
@@ -39,37 +39,41 @@ class ChapterRepositoryImpl(
): List<Chapter> {
return database.transactionWithResult {
if (removedIds.isNotEmpty()) {
database.chaptersQueries.removeChaptersWithIds(removedIds)
database.chapterQueries.removeChaptersWithIds(removedIds)
}
val existing = added.map { it.mangaId }
.distinct()
.flatMap { mangaId ->
database.chaptersQueries
.getChaptersByMangaId(mangaId, applyScanlatorFilter = false.toLong(), ::mapChapter)
database.chapterQueries
.getChaptersByMangaId(
mangaId = mangaId,
applyScanlatorFilter = false.toLong(),
mapper = ::mapChapter,
)
.awaitAsList()
.map { mangaId to it.url }
}
.toMutableSet()
val stored = added.filter { existing.add(it.mangaId to it.url) }.map { chapter ->
val chapterId = database.chaptersQueries.insertReturningId(
chapter.mangaId,
chapter.url,
chapter.name,
chapter.scanlator,
chapter.read,
chapter.bookmark,
chapter.lastPageRead,
chapter.chapterNumber,
chapter.sourceOrder,
chapter.dateFetch,
chapter.dateUpload,
chapter.memo,
val chapterId = database.chapterQueries.insertReturningId(
mangaId = chapter.mangaId,
url = chapter.url,
name = chapter.name,
scanlator = chapter.scanlator,
read = chapter.read,
bookmark = chapter.bookmark,
lastPageRead = chapter.lastPageRead,
chapterNumber = chapter.chapterNumber,
sourceOrder = chapter.sourceOrder,
dateFetch = chapter.dateFetch,
dateUpload = chapter.dateUpload,
memo = chapter.memo,
)
.awaitAsOne()
chapter.copy(id = chapterId)
}
updated.forEach { chapterUpdate ->
database.chaptersQueries.updateRemote(
database.chapterQueries.updateRemote(
name = chapterUpdate.name,
scanlator = chapterUpdate.scanlator,
chapterNumber = chapterUpdate.chapterNumber,
@@ -86,7 +90,7 @@ class ChapterRepositoryImpl(
private suspend fun partialUpdate(vararg chapterUpdates: ChapterUpdate) {
database.transaction {
chapterUpdates.forEach { chapterUpdate ->
database.chaptersQueries.update(
database.chapterQueries.update(
read = chapterUpdate.read,
bookmark = chapterUpdate.bookmark,
lastPageRead = chapterUpdate.lastPageRead,
@@ -98,43 +102,51 @@ class ChapterRepositoryImpl(
}
override suspend fun getChapterByMangaId(mangaId: Long, applyScanlatorFilter: Boolean): List<Chapter> {
return database.chaptersQueries
.getChaptersByMangaId(mangaId, applyScanlatorFilter.toLong(), ::mapChapter)
return database.chapterQueries
.getChaptersByMangaId(
mangaId = mangaId,
applyScanlatorFilter = applyScanlatorFilter.toLong(),
mapper = ::mapChapter,
)
.awaitAsList()
}
override suspend fun getScanlatorsByMangaId(mangaId: Long): List<String> {
return database.chaptersQueries
return database.chapterQueries
.getScanlatorsByMangaId(mangaId) { it.orEmpty() }
.awaitAsList()
}
override fun getScanlatorsByMangaIdAsFlow(mangaId: Long): Flow<List<String>> {
return database.chaptersQueries
return database.chapterQueries
.getScanlatorsByMangaId(mangaId) { it.orEmpty() }
.subscribeToList()
}
override suspend fun getBookmarkedChaptersByMangaId(mangaId: Long): List<Chapter> {
return database.chaptersQueries
return database.chapterQueries
.getBookmarkedChaptersByMangaId(mangaId, ::mapChapter)
.awaitAsList()
}
override suspend fun getChapterById(id: Long): Chapter? {
return database.chaptersQueries
return database.chapterQueries
.getChapterById(id, ::mapChapter)
.awaitAsOneOrNull()
}
override suspend fun getChapterByMangaIdAsFlow(mangaId: Long, applyScanlatorFilter: Boolean): Flow<List<Chapter>> {
return database.chaptersQueries
.getChaptersByMangaId(mangaId, applyScanlatorFilter.toLong(), ::mapChapter)
return database.chapterQueries
.getChaptersByMangaId(
mangaId = mangaId,
applyScanlatorFilter = applyScanlatorFilter.toLong(),
mapper = ::mapChapter,
)
.subscribeToList()
}
override suspend fun getChapterByUrlAndMangaId(url: String, mangaId: Long): Chapter? {
return database.chaptersQueries
return database.chapterQueries
.getChapterByUrlAndMangaId(url, mangaId, ::mapChapter)
.awaitAsOneOrNull()
}
@@ -81,9 +81,9 @@ class HistoryRepositoryImpl(
override suspend fun upsertHistory(historyUpdate: HistoryUpdate) {
try {
database.historyQueries.upsert(
historyUpdate.chapterId,
historyUpdate.readAt,
historyUpdate.sessionReadDuration,
chapterId = historyUpdate.chapterId,
readAt = historyUpdate.readAt,
readDuration = historyUpdate.sessionReadDuration,
)
} catch (e: Exception) {
logcat(LogPriority.ERROR, throwable = e)
@@ -84,27 +84,27 @@ object MangaMapper {
categories: String,
): LibraryManga = LibraryManga(
manga = mapManga(
id,
source,
url,
artist,
author,
description,
genre,
title,
status,
thumbnailUrl,
lastUpdate,
nextUpdate,
initialized,
viewerFlags,
chapterFlags,
coverLastModified,
updateStrategy,
calculateInterval,
notes,
memo,
favoriteAt,
id = id,
source = source,
url = url,
artist = artist,
author = author,
description = description,
genre = genre,
title = title,
status = status,
thumbnailUrl = thumbnailUrl,
lastUpdate = lastUpdate,
nextUpdate = nextUpdate,
initialized = initialized,
viewerFlags = viewerFlags,
chapterFlags = chapterFlags,
coverLastModified = coverLastModified,
updateStrategy = updateStrategy,
calculateInterval = calculateInterval,
notes = notes,
memo = memo,
favoriteAt = favoriteAt,
),
categories = categories.split(",").map { it.toLong() },
totalChapters = totalCount,
@@ -140,27 +140,27 @@ object MangaMapper {
totalCount: Long,
): MangaWithChapterCount = MangaWithChapterCount(
manga = mapManga(
id,
source,
url,
artist,
author,
description,
genre,
title,
status,
thumbnailUrl,
lastUpdate,
nextUpdate,
initialized,
viewerFlags,
chapterFlags,
coverLastModified,
updateStrategy,
calculateInterval,
notes,
memo,
favoriteAt,
id = id,
source = source,
url = url,
artist = artist,
author = author,
description = description,
genre = genre,
title = title,
status = status,
thumbnailUrl = thumbnailUrl,
lastUpdate = lastUpdate,
nextUpdate = nextUpdate,
initialized = initialized,
viewerFlags = viewerFlags,
chapterFlags = chapterFlags,
coverLastModified = coverLastModified,
updateStrategy = updateStrategy,
calculateInterval = calculateInterval,
notes = notes,
memo = memo,
favoriteAt = favoriteAt,
),
chapterCount = totalCount,
)
@@ -35,37 +35,37 @@ class MangaRepositoryImpl(
) : MangaRepository {
override suspend fun getMangaById(id: Long): Manga {
return database.mangasQueries
return database.mangaQueries
.getMangaById(id, MangaMapper::mapManga)
.awaitAsOne()
}
override fun getMangaByIdAsFlow(id: Long): Flow<Manga> {
return database.mangasQueries
return database.mangaQueries
.getMangaById(id, MangaMapper::mapManga)
.subscribeToOne()
}
override suspend fun getMangaByUrlAndSourceId(url: String, sourceId: Long): Manga? {
return database.mangasQueries
return database.mangaQueries
.getMangaByUrlAndSource(url, sourceId, MangaMapper::mapManga)
.awaitAsOneOrNull()
}
override fun getMangaByUrlAndSourceIdAsFlow(url: String, sourceId: Long): Flow<Manga?> {
return database.mangasQueries
return database.mangaQueries
.getMangaByUrlAndSource(url, sourceId, MangaMapper::mapManga)
.subscribeToOneOrNull()
}
override suspend fun getFavorites(): List<Manga> {
return database.mangasQueries
return database.mangaQueries
.getFavorites(MangaMapper::mapManga)
.awaitAsList()
}
override suspend fun getReadMangaNotInLibrary(): List<Manga> {
return database.mangasQueries
return database.mangaQueries
.getReadMangaNotInLibrary(MangaMapper::mapManga)
.awaitAsList()
}
@@ -83,13 +83,13 @@ class MangaRepositoryImpl(
}
override fun getFavoritesBySourceId(sourceId: Long): Flow<List<Manga>> {
return database.mangasQueries
return database.mangaQueries
.getFavoriteBySourceId(sourceId, MangaMapper::mapManga)
.subscribeToList()
}
override suspend fun getDuplicateLibraryManga(id: Long, title: String): List<MangaWithChapterCount> {
return database.mangasQueries
return database.mangaQueries
.getDuplicateLibraryManga(id, title, MangaMapper::mapMangaWithChapterCount)
.awaitAsList()
}
@@ -102,7 +102,7 @@ class MangaRepositoryImpl(
val timeZone = TimeZone.currentSystemDefault()
val epochMillis =
Clock.System.now().toLocalDateTime(timeZone).date.atStartOfDayIn(timeZone).toEpochMilliseconds()
return database.mangasQueries
return database.mangaQueries
.getUpcomingManga(
startOfDay = epochMillis,
statuses = statuses,
@@ -117,7 +117,7 @@ class MangaRepositoryImpl(
override suspend fun resetViewerFlags(): Boolean {
return try {
database.mangasQueries.resetViewerFlags()
database.mangaQueries.resetViewerFlags()
true
} catch (e: Exception) {
logcat(LogPriority.ERROR, e)
@@ -126,27 +126,27 @@ class MangaRepositoryImpl(
}
override suspend fun deleteNonLibraryManga(sourceIds: List<Long>, keepReadManga: Boolean) {
database.mangasQueries.deleteNonLibraryManga(sourceIds, keepReadManga.toLong())
database.mangaQueries.deleteNonLibraryManga(sourceIds, keepReadManga.toLong())
}
override suspend fun setMangaCategories(mangaId: Long, categoryIds: List<Long>) {
database.transaction {
database.mangas_categoriesQueries.deleteMangaCategoryByMangaId(mangaId)
database.manga_categoryQueries.deleteMangaCategoryByMangaId(mangaId)
categoryIds.forEach { categoryId ->
database.mangas_categoriesQueries.insert(mangaId, categoryId)
database.manga_categoryQueries.insert(mangaId = mangaId, categoryId = categoryId)
}
}
}
override suspend fun getExcludedScanlators(mangaId: Long): Set<String> {
return database.excluded_scanlatorsQueries
return database.excluded_scanlatorQueries
.getExcludedScanlatorsByMangaId(mangaId)
.awaitAsList()
.toSet()
}
override fun getExcludedScanlatorsAsFlow(mangaId: Long): Flow<Set<String>> {
return database.excluded_scanlatorsQueries
return database.excluded_scanlatorQueries
.getExcludedScanlatorsByMangaId(mangaId)
.subscribeToList()
.map { it.toSet() }
@@ -154,14 +154,14 @@ class MangaRepositoryImpl(
override suspend fun setExcludedScanlators(mangaId: Long, excludedScanlators: Set<String>) {
database.transaction {
val current = database.excluded_scanlatorsQueries
val current = database.excluded_scanlatorQueries
.getExcludedScanlatorsByMangaId(mangaId)
.awaitAsList()
.toSet()
excludedScanlators.minus(current).forEach { scanlator ->
database.excluded_scanlatorsQueries.insert(mangaId, scanlator)
database.excluded_scanlatorQueries.insert(mangaId, scanlator)
}
database.excluded_scanlatorsQueries.remove(mangaId, current.minus(excludedScanlators))
database.excluded_scanlatorQueries.remove(mangaId, current.minus(excludedScanlators))
}
}
@@ -188,7 +188,7 @@ class MangaRepositoryImpl(
override suspend fun insertNetworkManga(manga: List<Manga>): List<Manga> {
return database.transactionWithResult {
manga.map {
database.mangasQueries.insertNetworkManga(
database.mangaQueries.insertNetworkManga(
source = it.source,
url = it.url,
artist = it.artist,
@@ -220,7 +220,7 @@ class MangaRepositoryImpl(
override suspend fun updateRemote(update: MangaRemoteUpdate): Boolean {
return try {
database.mangasQueries.updateRemote(
database.mangaQueries.updateRemote(
artist = update.artist,
author = update.author,
description = update.description,
@@ -245,7 +245,7 @@ class MangaRepositoryImpl(
database.transaction {
mangaUpdates.forEach { value ->
with(value) {
database.mangasQueries.update(
database.mangaQueries.update(
favoriteAtSet = isSet(::favoriteAt),
favoriteAt = favoriteAt,
lastUpdate = lastUpdate,
@@ -48,7 +48,7 @@ class SourceRepositoryImpl(
}
override fun getSourcesWithFavoriteCount(): Flow<List<Pair<DomainSource, Long>>> {
val sourceIdWithFavoriteCountFlow = database.mangasQueries
val sourceIdWithFavoriteCountFlow = database.mangaQueries
.getSourceIdWithFavoriteCount()
.subscribeToList()
return combine(sourceIdWithFavoriteCountFlow, sourceManager.sources) { sourceIdWithFavoriteCount, _ ->
@@ -66,7 +66,7 @@ class SourceRepositoryImpl(
}
override fun getSourcesWithNonLibraryManga(): Flow<List<SourceWithCount>> {
return database.mangasQueries
return database.mangaQueries
.getSourceIdsWithNonLibraryManga()
.subscribeToList()
.map { sourceId ->
@@ -19,19 +19,19 @@ class StubSourceRepositoryImpl(
) : StubSourceRepository {
override fun subscribeAll(): Flow<List<StubSource>> {
return database.sourcesQueries
return database.sourceQueries
.findAll(::mapStubSource)
.subscribeToList()
}
override suspend fun getStubSource(id: Long): StubSource? {
return database.sourcesQueries
return database.sourceQueries
.findOne(id, ::mapStubSource)
.awaitAsOneOrNull()
}
override suspend fun upsertStubSource(id: Long, lang: String, name: String) {
database.sourcesQueries.upsert(id, lang, name)
database.sourceQueries.upsert(id = id, lang = lang, name = name)
}
private fun mapStubSource(
@@ -20,31 +20,31 @@ class TrackRepositoryImpl(
) : TrackRepository {
override suspend fun getTrackById(id: Long): Track? {
return database.manga_syncQueries
return database.manga_trackQueries
.getTrackById(id, TrackMapper::mapTrack)
.awaitAsOneOrNull()
}
override suspend fun getTracksByMangaId(mangaId: Long): List<Track> {
return database.manga_syncQueries
return database.manga_trackQueries
.getTracksByMangaId(mangaId, TrackMapper::mapTrack)
.awaitAsList()
}
override fun getTracksAsFlow(): Flow<List<Track>> {
return database.manga_syncQueries
return database.manga_trackQueries
.getTracks(TrackMapper::mapTrack)
.subscribeToList()
}
override fun getTracksByMangaIdAsFlow(mangaId: Long): Flow<List<Track>> {
return database.manga_syncQueries
return database.manga_trackQueries
.getTracksByMangaId(mangaId, TrackMapper::mapTrack)
.subscribeToList()
}
override suspend fun delete(mangaId: Long, trackerId: Long) {
database.manga_syncQueries.delete(
database.manga_trackQueries.delete(
mangaId = mangaId,
syncId = trackerId,
)
@@ -61,7 +61,7 @@ class TrackRepositoryImpl(
private suspend fun upsertValues(vararg tracks: Track) {
database.transaction {
tracks.forEach { mangaTrack ->
database.manga_syncQueries.upsert(
database.manga_trackQueries.upsert(
mangaId = mangaTrack.mangaId,
syncId = mangaTrack.trackerId,
remoteId = mangaTrack.remoteId,
@@ -1,78 +0,0 @@
CREATE TABLE categories(
_id INTEGER NOT NULL PRIMARY KEY,
name TEXT NOT NULL,
sort INTEGER NOT NULL,
flags INTEGER NOT NULL
);
-- Insert system category
INSERT OR IGNORE INTO categories(_id, name, sort, flags) VALUES (0, "", -1, 0);
-- Disallow deletion of default category
CREATE TRIGGER IF NOT EXISTS system_category_delete_trigger BEFORE DELETE
ON categories
BEGIN SELECT CASE
WHEN old._id <= 0 THEN
RAISE(ABORT, "System category can't be deleted")
END;
END;
getCategory:
SELECT *
FROM categories
WHERE _id = :id
LIMIT 1;
getCategories:
SELECT
_id AS id,
name,
sort AS `order`,
flags
FROM categories
ORDER BY sort;
getCategoriesByMangaId:
SELECT
C._id AS id,
C.name,
C.sort AS `order`,
C.flags
FROM categories C
JOIN mangas_categories MC
ON C._id = MC.category_id
WHERE MC.manga_id = :mangaId
ORDER BY C.sort;
insert:
INSERT INTO categories(name, sort, flags)
SELECT :name, coalesce(max(sort), -1) + 1, :flags
FROM categories;
delete:
DELETE FROM categories
WHERE _id = :categoryId;
updateName:
UPDATE categories
SET name = :name
WHERE _id = :categoryId;
getUserCategoryIds:
SELECT _id
FROM categories
WHERE _id > 0
ORDER BY sort;
updateOrder:
UPDATE categories
SET sort = :order
WHERE _id = :categoryId;
updateFlags:
UPDATE categories
SET flags = :flags
WHERE _id = :categoryId;
updateAllFlags:
UPDATE categories SET
flags = coalesce(:flags, flags);
@@ -0,0 +1,78 @@
CREATE TABLE category(
id INTEGER NOT NULL PRIMARY KEY,
name TEXT NOT NULL,
`order` INTEGER NOT NULL UNIQUE,
flags INTEGER NOT NULL
);
-- Insert system category
INSERT OR IGNORE INTO category(id, name, `order`, flags) VALUES (0, "", -1, 0);
-- Disallow deletion of default category
CREATE TRIGGER IF NOT EXISTS system_category_delete_trigger BEFORE DELETE
ON category
BEGIN SELECT CASE
WHEN old.id <= 0 THEN
RAISE(ABORT, "System category can't be deleted")
END;
END;
getCategory:
SELECT id, name, `order`, flags
FROM category
WHERE id = :id
LIMIT 1;
getCategories:
SELECT
id,
name,
`order`,
flags
FROM category
ORDER BY `order`;
getCategoriesByMangaId:
SELECT
C.id,
C.name,
C.`order`,
C.flags
FROM category C
JOIN manga_category MC
ON C.id = MC.category_id
WHERE MC.manga_id = :mangaId
ORDER BY C.`order`;
insert:
INSERT INTO category(name, `order`, flags)
SELECT :name, coalesce(max(`order`), -1) + 1, :flags
FROM category;
delete:
DELETE FROM category
WHERE id = :categoryId;
updateName:
UPDATE category
SET name = :name
WHERE id = :categoryId;
getUserCategoryIds:
SELECT id
FROM category
WHERE id > 0
ORDER BY `order`;
updateOrder:
UPDATE category
SET `order` = :order
WHERE id = :categoryId;
updateFlags:
UPDATE category
SET flags = :flags
WHERE id = :categoryId;
updateAllFlags:
UPDATE category SET
flags = coalesce(:flags, flags);
@@ -0,0 +1,123 @@
import kotlin.Boolean;
import kotlinx.serialization.json.JsonObject;
CREATE TABLE chapter(
id INTEGER NOT NULL PRIMARY KEY,
manga_id INTEGER NOT NULL,
remote_url TEXT NOT NULL,
remote_name TEXT NOT NULL,
remote_scanlator TEXT,
remote_chapter_number REAL NOT NULL,
remote_date_upload INTEGER NOT NULL,
remote_order INTEGER NOT NULL,
remote_memo BLOB AS JsonObject NOT NULL,
user_read INTEGER AS Boolean NOT NULL,
user_bookmark INTEGER AS Boolean NOT NULL,
user_last_page_read INTEGER NOT NULL,
state_date_fetch INTEGER NOT NULL,
UNIQUE(manga_id, remote_url),
FOREIGN KEY(manga_id) REFERENCES manga (id)
ON DELETE CASCADE
);
CREATE INDEX chapter_state_date_fetch_index ON chapter(state_date_fetch);
CREATE INDEX chapter_unread_state_date_fetch_index ON chapter(state_date_fetch) WHERE user_read = 0;
getChapterById:
SELECT
id, manga_id, remote_url, remote_name, remote_scanlator,
user_read, user_bookmark, user_last_page_read, remote_chapter_number, remote_order,
state_date_fetch, remote_date_upload,
remote_memo
FROM chapter
WHERE id = :id;
getChaptersByMangaId:
SELECT
C.id, C.manga_id, C.remote_url, C.remote_name, C.remote_scanlator,
C.user_read, C.user_bookmark, C.user_last_page_read, C.remote_chapter_number, C.remote_order,
C.state_date_fetch, C.remote_date_upload,
C.remote_memo
FROM chapter C
LEFT JOIN excluded_scanlator ES
ON C.manga_id = ES.manga_id
AND C.remote_scanlator = ES.scanlator
WHERE C.manga_id = :mangaId
AND (
:applyScanlatorFilter = 0
OR ES.scanlator IS NULL
);
getScanlatorsByMangaId:
SELECT remote_scanlator
FROM chapter
WHERE manga_id = :mangaId;
getBookmarkedChaptersByMangaId:
SELECT
id, manga_id, remote_url, remote_name, remote_scanlator,
user_read, user_bookmark, user_last_page_read, remote_chapter_number, remote_order,
state_date_fetch, remote_date_upload,
remote_memo
FROM chapter
WHERE user_bookmark
AND manga_id = :mangaId;
getChapterByUrl:
SELECT
id, manga_id, remote_url, remote_name, remote_scanlator,
user_read, user_bookmark, user_last_page_read, remote_chapter_number, remote_order,
state_date_fetch, remote_date_upload,
remote_memo
FROM chapter
WHERE remote_url = :chapterUrl;
getChapterByUrlAndMangaId:
SELECT
id, manga_id, remote_url, remote_name, remote_scanlator,
user_read, user_bookmark, user_last_page_read, remote_chapter_number, remote_order,
state_date_fetch, remote_date_upload,
remote_memo
FROM chapter
WHERE remote_url = :chapterUrl
AND manga_id = :mangaId
LIMIT 1;
removeChaptersWithIds:
DELETE FROM chapter
WHERE id IN :chapterIds;
insert:
INSERT INTO chapter(manga_id, remote_url, remote_name, remote_scanlator, user_read, user_bookmark, user_last_page_read, remote_chapter_number, remote_order, state_date_fetch, remote_date_upload, remote_memo)
VALUES (:mangaId, :url, :name, :scanlator, :read, :bookmark, :lastPageRead, :chapterNumber, :sourceOrder, :dateFetch, :dateUpload, :memo);
insertReturningId:
INSERT INTO chapter(manga_id, remote_url, remote_name, remote_scanlator, user_read, user_bookmark, user_last_page_read, remote_chapter_number, remote_order, state_date_fetch, remote_date_upload, remote_memo)
VALUES (:mangaId, :url, :name, :scanlator, :read, :bookmark, :lastPageRead, :chapterNumber, :sourceOrder, :dateFetch, :dateUpload, :memo)
RETURNING id;
update:
UPDATE chapter
SET user_read = coalesce(:read, user_read),
user_bookmark = coalesce(:bookmark, user_bookmark),
user_last_page_read = coalesce(:lastPageRead, user_last_page_read),
state_date_fetch = coalesce(:dateFetch, state_date_fetch)
WHERE id = :chapterId;
updateFromBackup:
UPDATE chapter
SET user_read = coalesce(:read, user_read),
user_bookmark = coalesce(:bookmark, user_bookmark),
user_last_page_read = coalesce(:lastPageRead, user_last_page_read),
remote_memo = coalesce(:memo, remote_memo)
WHERE id = :chapterId;
updateRemote:
UPDATE chapter
SET remote_name = :name,
remote_scanlator = coalesce(:scanlator, remote_scanlator),
remote_chapter_number = :chapterNumber,
remote_order = :sourceOrder,
remote_date_upload = coalesce(:dateUpload, remote_date_upload),
remote_memo = :memo
WHERE id = :chapterId;
@@ -1,104 +0,0 @@
import kotlin.Boolean;
import kotlinx.serialization.json.JsonObject;
CREATE TABLE chapters(
_id INTEGER NOT NULL PRIMARY KEY,
manga_id INTEGER NOT NULL,
url TEXT NOT NULL,
name TEXT NOT NULL,
scanlator TEXT,
read INTEGER AS Boolean NOT NULL,
bookmark INTEGER AS Boolean NOT NULL,
last_page_read INTEGER NOT NULL,
chapter_number REAL NOT NULL,
source_order INTEGER NOT NULL,
date_fetch INTEGER NOT NULL,
date_upload INTEGER NOT NULL,
memo BLOB AS JsonObject NOT NULL DEFAULT '{}',
FOREIGN KEY(manga_id) REFERENCES mangas (_id)
ON DELETE CASCADE
);
CREATE INDEX chapters_manga_id_index ON chapters(manga_id);
CREATE INDEX chapters_unread_by_manga_index ON chapters(manga_id, read) WHERE read = 0;
CREATE INDEX idx_chapters_url ON chapters(url);
getChapterById:
SELECT *
FROM chapters
WHERE _id = :id;
getChaptersByMangaId:
SELECT C.*
FROM chapters C
LEFT JOIN excluded_scanlators ES
ON C.manga_id = ES.manga_id
AND C.scanlator = ES.scanlator
WHERE C.manga_id = :mangaId
AND (
:applyScanlatorFilter = 0
OR ES.scanlator IS NULL
);
getScanlatorsByMangaId:
SELECT scanlator
FROM chapters
WHERE manga_id = :mangaId;
getBookmarkedChaptersByMangaId:
SELECT *
FROM chapters
WHERE bookmark
AND manga_id = :mangaId;
getChapterByUrl:
SELECT *
FROM chapters
WHERE url = :chapterUrl;
getChapterByUrlAndMangaId:
SELECT *
FROM chapters
WHERE url = :chapterUrl
AND manga_id = :mangaId
LIMIT 1;
removeChaptersWithIds:
DELETE FROM chapters
WHERE _id IN :chapterIds;
insert:
INSERT INTO chapters(manga_id, url, name, scanlator, read, bookmark, last_page_read, chapter_number, source_order, date_fetch, date_upload, memo)
VALUES (:mangaId, :url, :name, :scanlator, :read, :bookmark, :lastPageRead, :chapterNumber, :sourceOrder, :dateFetch, :dateUpload, :memo);
insertReturningId:
INSERT INTO chapters(manga_id, url, name, scanlator, read, bookmark, last_page_read, chapter_number, source_order, date_fetch, date_upload, memo)
VALUES (:mangaId, :url, :name, :scanlator, :read, :bookmark, :lastPageRead, :chapterNumber, :sourceOrder, :dateFetch, :dateUpload, :memo)
RETURNING _id;
update:
UPDATE chapters
SET read = coalesce(:read, read),
bookmark = coalesce(:bookmark, bookmark),
last_page_read = coalesce(:lastPageRead, last_page_read),
date_fetch = coalesce(:dateFetch, date_fetch)
WHERE _id = :chapterId;
updateFromBackup:
UPDATE chapters
SET read = coalesce(:read, read),
bookmark = coalesce(:bookmark, bookmark),
last_page_read = coalesce(:lastPageRead, last_page_read),
memo = coalesce(:memo, memo)
WHERE _id = :chapterId;
updateRemote:
UPDATE chapters
SET name = :name,
scanlator = coalesce(:scanlator, scanlator),
chapter_number = :chapterNumber,
source_order = :sourceOrder,
date_upload = coalesce(:dateUpload, date_upload),
memo = :memo
WHERE _id = :chapterId;
@@ -0,0 +1,22 @@
CREATE TABLE excluded_scanlator(
manga_id INTEGER NOT NULL,
scanlator TEXT NOT NULL,
UNIQUE(manga_id, scanlator),
FOREIGN KEY(manga_id) REFERENCES manga (id)
ON DELETE CASCADE
);
insert:
INSERT INTO excluded_scanlator(manga_id, scanlator)
VALUES (:mangaId, :scanlator);
remove:
DELETE FROM excluded_scanlator
WHERE manga_id = :mangaId
AND scanlator IN :scanlators;
getExcludedScanlatorsByMangaId:
SELECT scanlator
FROM excluded_scanlator
WHERE manga_id = :mangaId;
@@ -1,23 +0,0 @@
CREATE TABLE excluded_scanlators(
manga_id INTEGER NOT NULL,
scanlator TEXT NOT NULL,
FOREIGN KEY(manga_id) REFERENCES mangas (_id)
ON DELETE CASCADE
);
CREATE INDEX excluded_scanlators_manga_id_index ON excluded_scanlators(manga_id);
CREATE INDEX idx_excluded_scanlators_scanlator ON excluded_scanlators(scanlator);
insert:
INSERT INTO excluded_scanlators(manga_id, scanlator)
VALUES (:mangaId, :scanlator);
remove:
DELETE FROM excluded_scanlators
WHERE manga_id = :mangaId
AND scanlator IN :scanlators;
getExcludedScanlatorsByMangaId:
SELECT scanlator
FROM excluded_scanlators
WHERE manga_id = :mangaId;
@@ -15,7 +15,9 @@ contains:
SELECT EXISTS(SELECT 0 FROM extension_store WHERE index_url = :indexUrl);
getAll:
SELECT *
SELECT
index_url, name, badge_label, signing_key,
contact_website, contact_discord, is_legacy, extension_list_url
FROM extension_store;
getCount:
@@ -1,75 +1,70 @@
import java.util.Date;
CREATE TABLE history(
_id INTEGER NOT NULL PRIMARY KEY,
id INTEGER NOT NULL PRIMARY KEY,
manga_id INTEGER NOT NULL,
chapter_id INTEGER NOT NULL UNIQUE,
last_read INTEGER AS Date,
time_read INTEGER NOT NULL,
FOREIGN KEY(chapter_id) REFERENCES chapters (_id)
read_at INTEGER AS Date,
read_duration INTEGER NOT NULL,
FOREIGN KEY(manga_id) REFERENCES manga (id)
ON DELETE CASCADE,
FOREIGN KEY(chapter_id) REFERENCES chapter (id)
ON DELETE CASCADE
);
CREATE INDEX history_history_chapter_id_index ON history(chapter_id);
CREATE INDEX idx_history_last_read ON history(last_read);
CREATE INDEX history_manga_id_index ON history(manga_id);
CREATE INDEX history_read_at_index ON history(read_at);
getHistoryByMangaId:
SELECT
H._id,
H.id,
H.chapter_id,
H.last_read,
H.time_read
H.read_at,
H.read_duration
FROM history H
JOIN chapters C
ON H.chapter_id = C._id
WHERE C.manga_id = :mangaId AND C._id = H.chapter_id;
WHERE H.manga_id = :mangaId;
getHistoryByChapterUrlAndMangaId:
SELECT
H._id,
H.id,
H.chapter_id,
H.last_read,
H.time_read
H.read_at,
H.read_duration
FROM history H
JOIN chapters C
ON H.chapter_id = C._id
WHERE C.url = :chapterUrl AND C.manga_id = :mangaId AND C._id = H.chapter_id
JOIN chapter C
ON H.chapter_id = C.id
WHERE C.remote_url = :chapterUrl AND C.manga_id = :mangaId
LIMIT 1;
resetHistoryById:
UPDATE history
SET last_read = 0
WHERE _id = :historyId;
SET read_at = 0
WHERE id = :historyId;
resetHistoryByMangaId:
UPDATE history
SET last_read = 0
WHERE _id IN (
SELECT H._id
FROM mangas M
INNER JOIN chapters C
ON M._id = C.manga_id
INNER JOIN history H
ON C._id = H.chapter_id
WHERE M._id = :mangaId
);
SET read_at = 0
WHERE manga_id = :mangaId;
removeAllHistory:
DELETE FROM history;
removeResettedHistory:
DELETE FROM history
WHERE last_read = 0;
WHERE read_at = 0;
upsert:
INSERT INTO history(chapter_id, last_read, time_read)
VALUES (:chapterId, :readAt, :time_read)
INSERT INTO history(manga_id, chapter_id, read_at, read_duration)
SELECT C.manga_id, :chapterId, :readAt, :readDuration
FROM chapter C
WHERE C.id = :chapterId
ON CONFLICT(chapter_id)
DO UPDATE
SET
last_read = :readAt,
time_read = time_read + :time_read
read_at = :readAt,
read_duration = read_duration + :readDuration
WHERE chapter_id = :chapterId;
getReadDuration:
SELECT coalesce(sum(time_read), 0)
SELECT coalesce(sum(read_duration), 0)
FROM history;
@@ -0,0 +1,311 @@
import eu.kanade.tachiyomi.source.model.UpdateStrategy;
import kotlin.Boolean;
import kotlin.String;
import kotlin.collections.List;
import kotlinx.serialization.json.JsonObject;
CREATE TABLE manga(
id INTEGER NOT NULL PRIMARY KEY,
source_id INTEGER NOT NULL,
remote_url TEXT NOT NULL,
remote_title TEXT NOT NULL,
remote_author TEXT,
remote_artist TEXT,
remote_description TEXT,
remote_genre TEXT AS List<String>,
remote_status INTEGER NOT NULL,
remote_cover TEXT,
remote_update_strategy INTEGER AS UpdateStrategy NOT NULL,
remote_memo BLOB AS JsonObject NOT NULL,
user_favorite_at INTEGER,
user_notes TEXT NOT NULL,
user_reader_flags INTEGER NOT NULL,
user_chapter_flags INTEGER NOT NULL,
state_chapter_last_update INTEGER,
state_chapter_next_update INTEGER,
state_chapter_fetch_interval INTEGER NOT NULL,
state_cover_last_modified INTEGER NOT NULL,
state_initialized INTEGER AS Boolean NOT NULL,
UNIQUE(source_id, remote_url)
);
CREATE INDEX manga_user_favorite_at_index ON manga(user_favorite_at) WHERE user_favorite_at IS NOT NULL;
getMangaById:
SELECT
id, source_id, remote_url, remote_artist, remote_author, remote_description, remote_genre, remote_title,
remote_status, remote_cover, state_chapter_last_update, state_chapter_next_update, state_initialized,
user_reader_flags, user_chapter_flags, state_cover_last_modified, remote_update_strategy,
state_chapter_fetch_interval, user_notes, remote_memo, user_favorite_at
FROM manga
WHERE id = :id;
-- TODO: this should ideally never really have more than 1 result
getMangaByUrlAndSource:
SELECT
id, source_id, remote_url, remote_artist, remote_author, remote_description, remote_genre, remote_title,
remote_status, remote_cover, state_chapter_last_update, state_chapter_next_update, state_initialized,
user_reader_flags, user_chapter_flags, state_cover_last_modified, remote_update_strategy,
state_chapter_fetch_interval, user_notes, remote_memo, user_favorite_at
FROM manga
WHERE remote_url = :url
AND source_id = :source
LIMIT 1;
getFavorites:
SELECT
id, source_id, remote_url, remote_artist, remote_author, remote_description, remote_genre, remote_title,
remote_status, remote_cover, state_chapter_last_update, state_chapter_next_update, state_initialized,
user_reader_flags, user_chapter_flags, state_cover_last_modified, remote_update_strategy,
state_chapter_fetch_interval, user_notes, remote_memo, user_favorite_at
FROM manga
WHERE user_favorite_at IS NOT NULL;
getReadMangaNotInLibrary:
SELECT
id, source_id, remote_url, remote_artist, remote_author, remote_description, remote_genre, remote_title,
remote_status, remote_cover, state_chapter_last_update, state_chapter_next_update, state_initialized,
user_reader_flags, user_chapter_flags, state_cover_last_modified, remote_update_strategy,
state_chapter_fetch_interval, user_notes, remote_memo, user_favorite_at
FROM manga
WHERE user_favorite_at IS NULL AND id IN (
SELECT DISTINCT chapter.manga_id
FROM chapter
WHERE user_read = 1 OR user_last_page_read != 0
);
getAllManga:
SELECT
id, source_id, remote_url, remote_artist, remote_author, remote_description, remote_genre, remote_title,
remote_status, remote_cover, state_chapter_last_update, state_chapter_next_update, state_initialized,
user_reader_flags, user_chapter_flags, state_cover_last_modified, remote_update_strategy,
state_chapter_fetch_interval, user_notes, remote_memo, user_favorite_at
FROM manga;
getAllMangaSourceAndUrl:
SELECT source_id, remote_url
FROM manga;
getSourceIdWithFavoriteCount:
SELECT
source_id,
count(*)
FROM manga
WHERE user_favorite_at IS NOT NULL
GROUP BY source_id;
getFavoriteBySourceId:
SELECT
id, source_id, remote_url, remote_artist, remote_author, remote_description, remote_genre, remote_title,
remote_status, remote_cover, state_chapter_last_update, state_chapter_next_update, state_initialized,
user_reader_flags, user_chapter_flags, state_cover_last_modified, remote_update_strategy,
state_chapter_fetch_interval, user_notes, remote_memo, user_favorite_at
FROM manga
WHERE user_favorite_at IS NOT NULL
AND source_id = :sourceId;
getDuplicateLibraryManga:
WITH
track_dupes AS (
SELECT DISTINCT S2.manga_id
FROM manga_track S1
INNER JOIN manga_track S2
ON S1.tracker_id = S2.tracker_id
AND S1.remote_id = S2.remote_id
AND S1.remote_id != 0
AND S1.manga_id != S2.manga_id
WHERE S1.manga_id = :id
),
duplicates AS (
SELECT
M.id, M.source_id, M.remote_url, M.remote_artist, M.remote_author, M.remote_description, M.remote_genre, M.remote_title,
M.remote_status, M.remote_cover, M.state_chapter_last_update, M.state_chapter_next_update, M.state_initialized,
M.user_reader_flags, M.user_chapter_flags, M.state_cover_last_modified, M.remote_update_strategy,
M.state_chapter_fetch_interval, M.user_notes, M.remote_memo, M.user_favorite_at
FROM manga M
LEFT JOIN track_dupes D
ON D.manga_id = id
WHERE user_favorite_at IS NOT NULL
AND id != :id
AND (lower(remote_title) LIKE '%' || lower(:title) || '%' OR D.manga_id IS NOT NULL)
),
chapter_counts AS (
SELECT
M.id AS manga_id,
count(*) AS chapter_count
FROM duplicates M
JOIN chapter C
ON M.id = C.manga_id
LEFT JOIN excluded_scanlator ES
ON C.manga_id = ES.manga_id
AND C.remote_scanlator = ES.scanlator
WHERE ES.scanlator IS NULL
GROUP BY M.id
)
SELECT
M.id, M.source_id, M.remote_url, M.remote_artist, M.remote_author, M.remote_description, M.remote_genre, M.remote_title,
M.remote_status, M.remote_cover, M.state_chapter_last_update, M.state_chapter_next_update, M.state_initialized,
M.user_reader_flags, M.user_chapter_flags, M.state_cover_last_modified, M.remote_update_strategy,
M.state_chapter_fetch_interval, M.user_notes, M.remote_memo, M.user_favorite_at,
coalesce(CC.chapter_count, 0) AS chapter_count
FROM duplicates M
LEFT JOIN chapter_counts CC
ON M.id = CC.manga_id;
getUpcomingManga:
SELECT
id, source_id, remote_url, remote_artist, remote_author, remote_description, remote_genre, remote_title,
remote_status, remote_cover, state_chapter_last_update, state_chapter_next_update, state_initialized,
user_reader_flags, user_chapter_flags, state_cover_last_modified, remote_update_strategy,
state_chapter_fetch_interval, user_notes, remote_memo, user_favorite_at
FROM manga
WHERE state_chapter_next_update >= :startOfDay
AND user_favorite_at IS NOT NULL
AND remote_status IN :statuses
AND (
-- includedEmpty being true expresses "don't care" state and bypasses the membership filter
:includedEmpty
OR EXISTS (
SELECT 1 FROM manga_category
WHERE manga_category.manga_id = manga.id
AND COALESCE(manga_category.category_id, 0) IN :includedCategories
)
OR (0 IN :includedCategories AND NOT EXISTS (
SELECT 1 FROM manga_category WHERE manga_category.manga_id = manga.id)
)
)
AND (
-- excludedEmpty being true expresses "don't care" state and bypasses the membership filter
:excludedEmpty
OR (
NOT EXISTS (
SELECT 1 FROM manga_category
WHERE manga_category.manga_id = manga.id
AND COALESCE(manga_category.category_id, 0) IN :excludedCategories
)
AND (
0 NOT IN :excludedCategories
OR EXISTS (SELECT 1 FROM manga_category WHERE manga_category.manga_id = manga.id)
)
)
)
ORDER BY state_chapter_next_update ASC;
resetViewerFlags:
UPDATE manga
SET user_reader_flags = 0;
getSourceIdsWithNonLibraryManga:
SELECT source_id, COUNT(*) AS manga_count
FROM manga
WHERE user_favorite_at IS NULL
GROUP BY source_id;
deleteNonLibraryManga:
DELETE FROM manga
WHERE user_favorite_at IS NULL
AND source_id IN :sourceIds
AND (
:keepReadManga = 0
OR id NOT IN (
SELECT DISTINCT manga_id
FROM chapter
WHERE user_read = 1
OR user_last_page_read != 0
)
);
insertReturningId:
INSERT INTO manga(source_id, remote_url, remote_artist, remote_author, remote_description, remote_genre, remote_title, remote_status, remote_cover, user_favorite_at, state_chapter_last_update, state_chapter_next_update, state_initialized, user_reader_flags, user_chapter_flags, state_cover_last_modified, remote_update_strategy, state_chapter_fetch_interval, user_notes, remote_memo)
VALUES (:source, :url, :artist, :author, :description, :genre, :title, :status, :thumbnailUrl, :favoriteAt, :lastUpdate, :nextUpdate, :initialized, :viewerFlags, :chapterFlags, :coverLastModified, :updateStrategy, :calculateInterval, :notes, :memo)
RETURNING id;
update:
UPDATE manga SET
-- NULL is a real value here (not in the library), which coalesce cannot write
user_favorite_at = CASE WHEN :favoriteAtSet THEN :favoriteAt ELSE user_favorite_at END,
state_chapter_last_update = coalesce(:lastUpdate, state_chapter_last_update),
state_chapter_next_update = coalesce(:nextUpdate, state_chapter_next_update),
user_reader_flags = coalesce(:viewer, user_reader_flags),
user_chapter_flags = coalesce(:chapterFlags, user_chapter_flags),
state_cover_last_modified = coalesce(:coverLastModified, state_cover_last_modified),
state_chapter_fetch_interval = coalesce(:calculateInterval, state_chapter_fetch_interval),
user_notes = coalesce(:notes, user_notes)
WHERE id = :mangaId;
updateFromBackup:
UPDATE manga SET
remote_artist = coalesce(:artist, remote_artist),
remote_author = coalesce(:author, remote_author),
remote_description = coalesce(:description, remote_description),
remote_genre = coalesce(:genre, remote_genre),
remote_title = coalesce(:title, remote_title),
remote_status = coalesce(:status, remote_status),
remote_cover = coalesce(:thumbnailUrl, remote_cover),
user_favorite_at = :favoriteAt,
state_chapter_last_update = coalesce(:lastUpdate, state_chapter_last_update),
state_initialized = coalesce(:initialized, state_initialized),
user_reader_flags = coalesce(:viewer, user_reader_flags),
user_chapter_flags = coalesce(:chapterFlags, user_chapter_flags),
state_cover_last_modified = coalesce(:coverLastModified, state_cover_last_modified),
remote_update_strategy = coalesce(:updateStrategy, remote_update_strategy),
user_notes = coalesce(:notes, user_notes),
remote_memo = coalesce(:memo, remote_memo)
WHERE id = :mangaId;
updateRemote:
UPDATE manga SET
remote_artist = coalesce(:artist, remote_artist),
remote_author = coalesce(:author, remote_author),
remote_description = coalesce(:description, remote_description),
remote_genre = coalesce(:genre, remote_genre),
remote_title = coalesce(:title, remote_title),
remote_status = :status,
remote_cover = coalesce(:thumbnailUrl, remote_cover),
state_initialized = :initialized,
state_cover_last_modified = coalesce(:coverLastModified, state_cover_last_modified),
remote_update_strategy = :updateStrategy,
remote_memo = :memo
WHERE id = :mangaId;
insertNetworkManga {
-- Insert the manga if it doesn't exist already
INSERT INTO manga(
source_id, remote_url, remote_artist, remote_author, remote_description, remote_genre, remote_title, remote_status, remote_cover, user_favorite_at,
state_chapter_last_update, state_chapter_next_update, state_initialized, user_reader_flags, user_chapter_flags, state_cover_last_modified,
remote_update_strategy, state_chapter_fetch_interval, user_notes, remote_memo
)
SELECT
:source, :url, :artist, :author, :description, :genre, :title, :status, :thumbnailUrl, :favoriteAt,
:lastUpdate, :nextUpdate, :initialized, :viewerFlags, :chapterFlags, :coverLastModified,
:updateStrategy, :calculateInterval, '', :memo
WHERE NOT EXISTS(SELECT 0 FROM manga WHERE source_id = :source AND remote_url = :url);
-- Update the relevant details if applicable and not favorite
UPDATE manga
SET
remote_title = CASE WHEN :updateTitle THEN :title ELSE remote_title END,
remote_cover = CASE WHEN :updateCover THEN :thumbnailUrl ELSE remote_cover END,
remote_author = CASE WHEN :updateDetails THEN :author ELSE remote_author END,
remote_artist = CASE WHEN :updateDetails THEN :artist ELSE remote_artist END,
remote_description = CASE WHEN :updateDetails THEN :description ELSE remote_description END,
remote_genre = CASE WHEN :updateDetails THEN :genre ELSE remote_genre END,
remote_status = CASE WHEN :updateDetails THEN :status ELSE remote_status END,
remote_update_strategy = CASE WHEN :updateDetails THEN :updateStrategy ELSE remote_update_strategy END,
state_initialized = :updateDetails,
remote_memo = :memo
WHERE source_id = :source
AND remote_url = :url
AND user_favorite_at IS NULL;
-- Finally return the manga
SELECT
id, source_id, remote_url, remote_artist, remote_author, remote_description, remote_genre, remote_title,
remote_status, remote_cover, state_chapter_last_update, state_chapter_next_update, state_initialized,
user_reader_flags, user_chapter_flags, state_cover_last_modified, remote_update_strategy,
state_chapter_fetch_interval, user_notes, remote_memo, user_favorite_at
FROM manga
WHERE source_id = :source
AND remote_url = :url
LIMIT 1;
}
@@ -0,0 +1,19 @@
CREATE TABLE manga_category(
manga_id INTEGER NOT NULL,
category_id INTEGER NOT NULL,
UNIQUE(manga_id, category_id),
FOREIGN KEY(category_id) REFERENCES category (id)
ON DELETE CASCADE,
FOREIGN KEY(manga_id) REFERENCES manga (id)
ON DELETE CASCADE
);
CREATE INDEX manga_category_category_id_index ON manga_category(category_id);
insert:
INSERT INTO manga_category(manga_id, category_id)
VALUES (:mangaId, :categoryId);
deleteMangaCategoryByMangaId:
DELETE FROM manga_category
WHERE manga_id = :mangaId;
@@ -1,49 +1,58 @@
import kotlin.Boolean;
CREATE TABLE manga_sync(
_id INTEGER NOT NULL PRIMARY KEY,
CREATE TABLE manga_track(
id INTEGER NOT NULL PRIMARY KEY,
manga_id INTEGER NOT NULL,
sync_id INTEGER NOT NULL,
tracker_id INTEGER NOT NULL,
remote_id INTEGER NOT NULL,
remote_url TEXT NOT NULL,
library_id INTEGER,
title TEXT NOT NULL,
last_chapter_read REAL NOT NULL,
total_chapters INTEGER NOT NULL,
status INTEGER NOT NULL,
score REAL NOT NULL,
remote_url TEXT NOT NULL,
start_date INTEGER NOT NULL,
finish_date INTEGER NOT NULL,
private INTEGER AS Boolean DEFAULT 0 NOT NULL,
UNIQUE (manga_id, sync_id) ON CONFLICT REPLACE,
FOREIGN KEY(manga_id) REFERENCES mangas (_id)
private INTEGER AS Boolean NOT NULL DEFAULT 0,
UNIQUE (manga_id, tracker_id),
FOREIGN KEY(manga_id) REFERENCES manga (id)
ON DELETE CASCADE
);
CREATE INDEX idx_manga_sync_manga_id ON manga_sync(manga_id);
CREATE INDEX manga_track_tracker_id_remote_id_index ON manga_track(tracker_id, remote_id);
delete:
DELETE FROM manga_sync
WHERE manga_id = :mangaId AND sync_id = :syncId;
DELETE FROM manga_track
WHERE manga_id = :mangaId AND tracker_id = :syncId;
getTracks:
SELECT *
FROM manga_sync;
SELECT
id, manga_id, tracker_id, remote_id, library_id,
title, last_chapter_read, total_chapters, status, score,
remote_url, start_date, finish_date, private
FROM manga_track;
getTrackById:
SELECT *
FROM manga_sync
WHERE _id = :id;
SELECT
id, manga_id, tracker_id, remote_id, library_id,
title, last_chapter_read, total_chapters, status, score,
remote_url, start_date, finish_date, private
FROM manga_track
WHERE id = :id;
getTracksByMangaId:
SELECT *
FROM manga_sync
SELECT
id, manga_id, tracker_id, remote_id, library_id,
title, last_chapter_read, total_chapters, status, score,
remote_url, start_date, finish_date, private
FROM manga_track
WHERE manga_id = :mangaId;
upsert:
INSERT INTO manga_sync(manga_id,sync_id,remote_id,library_id,title,last_chapter_read,total_chapters,status,score,remote_url,start_date,finish_date,private)
INSERT INTO manga_track(manga_id,tracker_id,remote_id,library_id,title,last_chapter_read,total_chapters,status,score,remote_url,start_date,finish_date,private)
VALUES (:mangaId,:syncId,:remoteId,:libraryId,:title,:lastChapterRead,:totalChapters,:status,:score,:remoteUrl,:startDate,:finishDate,:private)
ON CONFLICT(manga_id, sync_id)
ON CONFLICT(manga_id, tracker_id)
DO UPDATE
SET
remote_id = :remoteId,
@@ -57,13 +66,13 @@ SET
start_date = :startDate,
finish_date = :finishDate,
private = :private
WHERE manga_id = :mangaId AND sync_id = :syncId;
WHERE manga_id = :mangaId AND tracker_id = :syncId;
update:
UPDATE manga_sync
UPDATE manga_track
SET
manga_id = coalesce(:mangaId, manga_id),
sync_id = coalesce(:syncId, sync_id),
tracker_id = coalesce(:syncId, tracker_id),
remote_id = coalesce(:mediaId, remote_id),
library_id = coalesce(:libraryId, library_id),
title = coalesce(:title, title),
@@ -75,4 +84,4 @@ SET
start_date = coalesce(:startDate, start_date),
finish_date = coalesce(:finishDate, finish_date),
private = coalesce(:private, private)
WHERE _id = :id;
WHERE id = :id;
@@ -1,273 +0,0 @@
import eu.kanade.tachiyomi.source.model.UpdateStrategy;
import kotlin.Boolean;
import kotlin.String;
import kotlin.collections.List;
import kotlinx.serialization.json.JsonObject;
CREATE TABLE mangas(
_id INTEGER NOT NULL PRIMARY KEY,
source INTEGER NOT NULL,
url TEXT NOT NULL,
artist TEXT,
author TEXT,
description TEXT,
genre TEXT AS List<String>,
title TEXT NOT NULL,
status INTEGER NOT NULL,
thumbnail_url TEXT,
last_update INTEGER,
next_update INTEGER,
initialized INTEGER AS Boolean NOT NULL,
viewer INTEGER NOT NULL,
chapter_flags INTEGER NOT NULL,
cover_last_modified INTEGER NOT NULL,
update_strategy INTEGER AS UpdateStrategy NOT NULL DEFAULT 0,
calculate_interval INTEGER DEFAULT 0 NOT NULL,
notes TEXT NOT NULL DEFAULT "",
memo BLOB AS JsonObject NOT NULL DEFAULT '{}',
favorite_at INTEGER
);
CREATE INDEX library_favorite_at_index ON mangas(favorite_at) WHERE favorite_at IS NOT NULL;
CREATE INDEX mangas_url_index ON mangas(url);
CREATE INDEX idx_mangas_source ON mangas(source);
getMangaById:
SELECT *
FROM mangas
WHERE _id = :id;
-- TODO: this should ideally never really have more than 1 result
getMangaByUrlAndSource:
SELECT *
FROM mangas
WHERE url = :url
AND source = :source
LIMIT 1;
getFavorites:
SELECT *
FROM mangas
WHERE favorite_at IS NOT NULL;
getReadMangaNotInLibrary:
SELECT *
FROM mangas
WHERE favorite_at IS NULL AND _id IN (
SELECT DISTINCT chapters.manga_id
FROM chapters
WHERE read = 1 OR last_page_read != 0
);
getAllManga:
SELECT *
FROM mangas;
getAllMangaSourceAndUrl:
SELECT source, url
FROM mangas;
getSourceIdWithFavoriteCount:
SELECT
source,
count(*)
FROM mangas
WHERE favorite_at IS NOT NULL
GROUP BY source;
getFavoriteBySourceId:
SELECT *
FROM mangas
WHERE favorite_at IS NOT NULL
AND source = :sourceId;
getDuplicateLibraryManga:
WITH
track_dupes AS (
SELECT DISTINCT S2.manga_id
FROM manga_sync S1
INNER JOIN manga_sync S2
ON S1.sync_id = S2.sync_id
AND S1.remote_id = S2.remote_id
AND S1.remote_id != 0
AND S1.manga_id != S2.manga_id
WHERE S1.manga_id = :id
),
duplicates AS (
SELECT M.*
FROM mangas M
LEFT JOIN track_dupes D
ON D.manga_id = _id
WHERE favorite_at IS NOT NULL
AND _id != :id
AND (lower(title) LIKE '%' || lower(:title) || '%' OR D.manga_id IS NOT NULL)
),
chapter_counts AS (
SELECT
M._id AS manga_id,
count(*) AS chapter_count
FROM duplicates M
JOIN chapters C
ON M._id = C.manga_id
LEFT JOIN excluded_scanlators ES
ON C.manga_id = ES.manga_id
AND C.scanlator = ES.scanlator
WHERE ES.scanlator IS NULL
GROUP BY M._id
)
SELECT
M.*,
coalesce(CC.chapter_count, 0) AS chapter_count
FROM duplicates M
LEFT JOIN chapter_counts CC
ON M._id = CC.manga_id;
getUpcomingManga:
SELECT *
FROM mangas
WHERE next_update >= :startOfDay
AND favorite_at IS NOT NULL
AND status IN :statuses
AND (
-- includedEmpty being true expresses "don't care" state and bypasses the membership filter
:includedEmpty
OR EXISTS (
SELECT 1 FROM mangas_categories
WHERE mangas_categories.manga_id = mangas._id
AND COALESCE(mangas_categories.category_id, 0) IN :includedCategories
)
OR (0 IN :includedCategories AND NOT EXISTS (
SELECT 1 FROM mangas_categories WHERE mangas_categories.manga_id = mangas._id)
)
)
AND (
-- excludedEmpty being true expresses "don't care" state and bypasses the membership filter
:excludedEmpty
OR (
NOT EXISTS (
SELECT 1 FROM mangas_categories
WHERE mangas_categories.manga_id = mangas._id
AND COALESCE(mangas_categories.category_id, 0) IN :excludedCategories
)
AND (
0 NOT IN :excludedCategories
OR EXISTS (SELECT 1 FROM mangas_categories WHERE mangas_categories.manga_id = mangas._id)
)
)
)
ORDER BY next_update ASC;
resetViewerFlags:
UPDATE mangas
SET viewer = 0;
getSourceIdsWithNonLibraryManga:
SELECT source, COUNT(*) AS manga_count
FROM mangas
WHERE favorite_at IS NULL
GROUP BY source;
deleteNonLibraryManga:
DELETE FROM mangas
WHERE favorite_at IS NULL
AND source IN :sourceIds
AND (
:keepReadManga = 0
OR _id NOT IN (
SELECT DISTINCT manga_id
FROM chapters
WHERE read = 1
OR last_page_read != 0
)
);
insertReturningId:
INSERT INTO mangas(source, url, artist, author, description, genre, title, status, thumbnail_url, favorite_at, last_update, next_update, initialized, viewer, chapter_flags, cover_last_modified, update_strategy, calculate_interval, notes, memo)
VALUES (:source, :url, :artist, :author, :description, :genre, :title, :status, :thumbnailUrl, :favoriteAt, :lastUpdate, :nextUpdate, :initialized, :viewerFlags, :chapterFlags, :coverLastModified, :updateStrategy, :calculateInterval, :notes, :memo)
RETURNING _id;
update:
UPDATE mangas SET
-- NULL is a real value here (not in the library), which coalesce cannot write
favorite_at = CASE WHEN :favoriteAtSet THEN :favoriteAt ELSE favorite_at END,
last_update = coalesce(:lastUpdate, last_update),
next_update = coalesce(:nextUpdate, next_update),
viewer = coalesce(:viewer, viewer),
chapter_flags = coalesce(:chapterFlags, chapter_flags),
cover_last_modified = coalesce(:coverLastModified, cover_last_modified),
calculate_interval = coalesce(:calculateInterval, calculate_interval),
notes = coalesce(:notes, notes)
WHERE _id = :mangaId;
updateFromBackup:
UPDATE mangas SET
artist = coalesce(:artist, artist),
author = coalesce(:author, author),
description = coalesce(:description, description),
genre = coalesce(:genre, genre),
title = coalesce(:title, title),
status = coalesce(:status, status),
thumbnail_url = coalesce(:thumbnailUrl, thumbnail_url),
favorite_at = :favoriteAt,
last_update = coalesce(:lastUpdate, last_update),
initialized = coalesce(:initialized, initialized),
viewer = coalesce(:viewer, viewer),
chapter_flags = coalesce(:chapterFlags, chapter_flags),
cover_last_modified = coalesce(:coverLastModified, cover_last_modified),
update_strategy = coalesce(:updateStrategy, update_strategy),
notes = coalesce(:notes, notes),
memo = coalesce(:memo, memo)
WHERE _id = :mangaId;
updateRemote:
UPDATE mangas SET
artist = coalesce(:artist, artist),
author = coalesce(:author, author),
description = coalesce(:description, description),
genre = coalesce(:genre, genre),
title = coalesce(:title, title),
status = :status,
thumbnail_url = coalesce(:thumbnailUrl, thumbnail_url),
initialized = :initialized,
cover_last_modified = coalesce(:coverLastModified, cover_last_modified),
update_strategy = :updateStrategy,
memo = :memo
WHERE _id = :mangaId;
insertNetworkManga {
-- Insert the manga if it doesn't exist already
INSERT INTO mangas(
source, url, artist, author, description, genre, title, status, thumbnail_url, favorite_at,
last_update, next_update, initialized, viewer, chapter_flags, cover_last_modified,
update_strategy, calculate_interval, memo
)
SELECT
:source, :url, :artist, :author, :description, :genre, :title, :status, :thumbnailUrl, :favoriteAt,
:lastUpdate, :nextUpdate, :initialized, :viewerFlags, :chapterFlags, :coverLastModified,
:updateStrategy, :calculateInterval, :memo
WHERE NOT EXISTS(SELECT 0 FROM mangas WHERE source = :source AND url = :url);
-- Update the relevant details if applicable and not favorite
UPDATE mangas
SET
title = CASE WHEN :updateTitle THEN :title ELSE title END,
thumbnail_url = CASE WHEN :updateCover THEN :thumbnailUrl ELSE thumbnail_url END,
author = CASE WHEN :updateDetails THEN :author ELSE author END,
artist = CASE WHEN :updateDetails THEN :artist ELSE artist END,
description = CASE WHEN :updateDetails THEN :description ELSE description END,
genre = CASE WHEN :updateDetails THEN :genre ELSE genre END,
status = CASE WHEN :updateDetails THEN :status ELSE status END,
update_strategy = CASE WHEN :updateDetails THEN :updateStrategy ELSE update_strategy END,
initialized = :updateDetails,
memo = :memo
WHERE source = :source
AND url = :url
AND favorite_at IS NULL;
-- Finally return the manga
SELECT *
FROM mangas
WHERE source = :source
AND url = :url
LIMIT 1;
}
@@ -1,20 +0,0 @@
CREATE TABLE mangas_categories(
_id INTEGER NOT NULL PRIMARY KEY,
manga_id INTEGER NOT NULL,
category_id INTEGER NOT NULL,
FOREIGN KEY(category_id) REFERENCES categories (_id)
ON DELETE CASCADE,
FOREIGN KEY(manga_id) REFERENCES mangas (_id)
ON DELETE CASCADE
);
CREATE INDEX idx_mangas_categories_manga_id ON mangas_categories(manga_id);
CREATE INDEX idx_mangas_categories_category_id ON mangas_categories(category_id);
insert:
INSERT INTO mangas_categories(manga_id, category_id)
VALUES (:mangaId, :categoryId);
deleteMangaCategoryByMangaId:
DELETE FROM mangas_categories
WHERE manga_id = :mangaId;
@@ -0,0 +1,24 @@
CREATE TABLE source(
id INTEGER NOT NULL PRIMARY KEY,
name TEXT NOT NULL,
language TEXT NOT NULL
);
findAll:
SELECT id, language, name
FROM source;
findOne:
SELECT id, language, name
FROM source
WHERE id = :id;
upsert:
INSERT INTO source(id, language, name)
VALUES (:id, :lang, :name)
ON CONFLICT(id)
DO UPDATE
SET
language = :lang,
name = :name
WHERE id = :id;
@@ -1,24 +0,0 @@
CREATE TABLE sources(
_id INTEGER NOT NULL PRIMARY KEY,
lang TEXT NOT NULL,
name TEXT NOT NULL
);
findAll:
SELECT *
FROM sources;
findOne:
SELECT *
FROM sources
WHERE _id = :id;
upsert:
INSERT INTO sources(_id, lang, name)
VALUES (:id, :lang, :name)
ON CONFLICT(_id)
DO UPDATE
SET
lang = :lang,
name = :name
WHERE _id = :id;
@@ -0,0 +1,545 @@
import eu.kanade.tachiyomi.source.model.UpdateStrategy;
import kotlin.Boolean;
import kotlin.String;
import kotlin.collections.List;
import kotlinx.serialization.json.JsonObject;
-- ---------------------------------------------------------------------------------------------------------
-- table - manga
-- ---------------------------------------------------------------------------------------------------------
-- Every view reads `mangas`, so they are dropped here and recreated against the new table below
DROP VIEW historyView;
DROP VIEW libraryView;
DROP VIEW updatesView;
-- Entries sharing a source and url are merged into one, since the new table won't hold both. The copies are
-- ranked with the ones in the library first, since that is the entry the user has, then by age. The first
-- one stays, takes each detail it is missing from the first copy that has it, and takes over everything
-- attached to the others.
CREATE TEMP TABLE duplicate_mangas AS
SELECT
_id,
first_value(_id) OVER (PARTITION BY source, url ORDER BY favorite_at IS NOT NULL DESC, _id ASC) AS keep_id,
row_number() OVER (PARTITION BY source, url ORDER BY favorite_at IS NOT NULL DESC, _id ASC) AS precedence,
favorite_at IS NOT NULL AS favorite
FROM mangas
WHERE (source, url) IN (
SELECT source, url
FROM mangas
GROUP BY source, url
HAVING count(*) > 1
);
UPDATE mangas
SET favorite_at = merged.favorite_at,
initialized = merged.initialized,
artist = merged.artist,
author = merged.author,
description = merged.description,
genre = merged.genre,
thumbnail_url = merged.thumbnail_url,
status = merged.status,
viewer = merged.viewer,
chapter_flags = merged.chapter_flags,
notes = merged.notes,
memo = merged.memo,
last_update = merged.last_update,
cover_last_modified = merged.cover_last_modified
FROM (
SELECT DISTINCT
D.keep_id,
-- The earliest real date if any copy has one, else 0 if any copy is in the library, else NULL
coalesce(
min(nullif(M.favorite_at, 0)) OVER (PARTITION BY D.keep_id),
min(M.favorite_at) OVER (PARTITION BY D.keep_id)
) AS favorite_at,
max(M.initialized) OVER (PARTITION BY D.keep_id) AS initialized,
first_value(M.artist) OVER (PARTITION BY D.keep_id ORDER BY M.artist IS NULL, D.precedence) AS artist,
first_value(M.author) OVER (PARTITION BY D.keep_id ORDER BY M.author IS NULL, D.precedence) AS author,
first_value(M.description) OVER (PARTITION BY D.keep_id ORDER BY M.description IS NULL, D.precedence) AS description,
first_value(M.genre) OVER (PARTITION BY D.keep_id ORDER BY M.genre IS NULL, D.precedence) AS genre,
first_value(M.thumbnail_url) OVER (PARTITION BY D.keep_id ORDER BY M.thumbnail_url IS NULL, D.precedence) AS thumbnail_url,
first_value(M.status) OVER (PARTITION BY D.keep_id ORDER BY M.status = 0, D.precedence) AS status,
first_value(M.viewer) OVER (PARTITION BY D.keep_id ORDER BY M.viewer = 0, D.precedence) AS viewer,
first_value(M.chapter_flags) OVER (PARTITION BY D.keep_id ORDER BY M.chapter_flags = 0, D.precedence) AS chapter_flags,
first_value(M.notes) OVER (PARTITION BY D.keep_id ORDER BY M.notes = '', D.precedence) AS notes,
first_value(M.memo) OVER (PARTITION BY D.keep_id ORDER BY M.memo = '{}', D.precedence) AS memo,
max(M.last_update) OVER (PARTITION BY D.keep_id) AS last_update,
max(M.cover_last_modified) OVER (PARTITION BY D.keep_id) AS cover_last_modified
FROM duplicate_mangas D
JOIN mangas M
ON M._id = D._id
) AS merged
WHERE mangas._id = merged.keep_id;
-- Every child table is rebuilt further down and resolves the surviving entry there, so nothing
-- else has to be rewritten here first.
DELETE FROM mangas
WHERE _id IN (SELECT _id FROM duplicate_mangas WHERE _id != keep_id);
CREATE TABLE manga(
id INTEGER NOT NULL PRIMARY KEY,
source_id INTEGER NOT NULL,
remote_url TEXT NOT NULL,
remote_title TEXT NOT NULL,
remote_author TEXT,
remote_artist TEXT,
remote_description TEXT,
remote_genre TEXT AS List<String>,
remote_status INTEGER NOT NULL,
remote_cover TEXT,
remote_update_strategy INTEGER AS UpdateStrategy NOT NULL,
remote_memo BLOB AS JsonObject NOT NULL,
user_favorite_at INTEGER,
user_notes TEXT NOT NULL,
user_reader_flags INTEGER NOT NULL,
user_chapter_flags INTEGER NOT NULL,
state_chapter_last_update INTEGER,
state_chapter_next_update INTEGER,
state_chapter_fetch_interval INTEGER NOT NULL,
state_cover_last_modified INTEGER NOT NULL,
state_initialized INTEGER AS Boolean NOT NULL,
UNIQUE(source_id, remote_url)
);
INSERT INTO manga(
id, source_id, remote_url, remote_title, remote_author, remote_artist, remote_description, remote_genre,
remote_status, remote_cover, remote_update_strategy, remote_memo, user_favorite_at, user_notes,
user_reader_flags, user_chapter_flags, state_chapter_last_update, state_chapter_next_update, state_chapter_fetch_interval,
state_cover_last_modified, state_initialized
)
SELECT
_id, source, url, title, author, artist, description, genre,
status, thumbnail_url, update_strategy, memo,
favorite_at, notes,
viewer, chapter_flags, last_update, next_update, calculate_interval,
cover_last_modified, initialized
FROM mangas;
DROP TABLE mangas;
CREATE INDEX manga_user_favorite_at_index ON manga(user_favorite_at) WHERE user_favorite_at IS NOT NULL;
-- ---------------------------------------------------------------------------------------------------------
-- table - chapter
-- ---------------------------------------------------------------------------------------------------------
-- The entries that merged in the previous group brought their chapters with them
UPDATE chapters
SET manga_id = (SELECT keep_id FROM duplicate_mangas WHERE _id = chapters.manga_id)
WHERE manga_id IN (SELECT _id FROM duplicate_mangas WHERE _id != keep_id);
-- Moving the chapters over brings rows for the same chapter together, and duplicates like those are
-- what issue #647 is about in the first place. The one with reading history is kept, the oldest
-- otherwise, and the read state and history of the others are merged into it.
CREATE TEMP TABLE duplicate_chapters AS
SELECT
_id,
first_value(_id) OVER (PARTITION BY manga_id, url ORDER BY has_history DESC, _id ASC) AS keep_id
FROM (
SELECT
_id,
manga_id,
url,
EXISTS(SELECT 1 FROM history WHERE history.chapter_id = chapters._id) AS has_history
FROM chapters
WHERE (manga_id, url) IN (
SELECT manga_id, url
FROM chapters
GROUP BY manga_id, url
HAVING count(*) > 1
)
);
UPDATE chapters
SET read = merged.read,
bookmark = merged.bookmark,
last_page_read = merged.last_page_read,
date_fetch = merged.date_fetch,
date_upload = merged.date_upload
FROM (
SELECT
D.keep_id,
max(C.read) AS read,
max(C.bookmark) AS bookmark,
max(C.last_page_read) AS last_page_read,
-- 0 means the fetch date was never recorded rather than "the beginning of time", so it only
-- wins when no copy recorded one. Updates only lists chapters fetched after the entry joined
-- the library, so letting a 0 through would hide the merged chapter from it.
coalesce(min(nullif(C.date_fetch, 0)), 0) AS date_fetch,
max(C.date_upload) AS date_upload
FROM duplicate_chapters D
JOIN chapters C
ON C._id = D._id
GROUP BY D.keep_id
) AS merged
WHERE chapters._id = merged.keep_id;
-- The kept chapter has a history entry whenever any of its duplicates has one
UPDATE history
SET last_read = merged.last_read,
time_read = merged.time_read
FROM (
SELECT
D.keep_id,
max(H.last_read) AS last_read,
-- Each duplicate accumulated its own reading time, so the durations add up; only the
-- last-read timestamp is a point in time and takes the latest.
sum(H.time_read) AS time_read
FROM duplicate_chapters D
JOIN history H
ON H.chapter_id = D._id
GROUP BY D.keep_id
) AS merged
WHERE history.chapter_id = merged.keep_id;
-- Foreign keys are off during migrations, so the history of a discarded chapter goes explicitly
DELETE FROM history
WHERE chapter_id IN (SELECT _id FROM duplicate_chapters WHERE _id != keep_id);
DELETE FROM chapters
WHERE _id IN (SELECT _id FROM duplicate_chapters WHERE _id != keep_id);
DROP TABLE duplicate_chapters;
CREATE TABLE chapter(
id INTEGER NOT NULL PRIMARY KEY,
manga_id INTEGER NOT NULL,
remote_url TEXT NOT NULL,
remote_name TEXT NOT NULL,
remote_scanlator TEXT,
remote_chapter_number REAL NOT NULL,
remote_date_upload INTEGER NOT NULL,
remote_order INTEGER NOT NULL,
remote_memo BLOB NOT NULL,
user_read INTEGER NOT NULL,
user_bookmark INTEGER NOT NULL,
user_last_page_read INTEGER NOT NULL,
state_date_fetch INTEGER NOT NULL,
UNIQUE(manga_id, remote_url),
FOREIGN KEY(manga_id) REFERENCES manga (id)
ON DELETE CASCADE
);
INSERT INTO chapter(
id, manga_id, remote_url, remote_name, remote_scanlator, remote_chapter_number, remote_date_upload,
remote_order, remote_memo, user_read, user_bookmark, user_last_page_read, state_date_fetch
)
SELECT
_id, manga_id, url, name, scanlator, chapter_number, date_upload,
source_order, memo, read, bookmark, last_page_read, date_fetch
FROM chapters;
DROP TABLE chapters;
CREATE INDEX chapter_state_date_fetch_index ON chapter(state_date_fetch);
CREATE INDEX chapter_unread_state_date_fetch_index ON chapter(state_date_fetch) WHERE user_read = 0;
-- ---------------------------------------------------------------------------------------------------------
-- table - category
-- ---------------------------------------------------------------------------------------------------------
CREATE TABLE category(
id INTEGER NOT NULL PRIMARY KEY,
name TEXT NOT NULL,
`order` INTEGER NOT NULL UNIQUE,
flags INTEGER NOT NULL
);
-- `sort` was never unique, and reordering wrote colliding values often enough that it cannot simply be
-- copied under the new constraint. The rows keep the order they were displayed in and are renumbered from
-- 0, with ties broken by id. The system category is pinned to -1 rather than renumbered, because it is
-- identified by id 0 everywhere else and sorts ahead of the categories the user made.
INSERT INTO category(id, name, `order`, flags)
SELECT
_id,
name,
CASE
WHEN _id <= 0 THEN -1
ELSE row_number() OVER (PARTITION BY (_id <= 0) ORDER BY sort, _id) - 1
END,
flags
FROM categories;
-- Dropping the table takes `system_category_delete_trigger` with it
DROP TABLE categories;
CREATE TRIGGER IF NOT EXISTS system_category_delete_trigger BEFORE DELETE
ON category
BEGIN SELECT CASE
WHEN old.id <= 0 THEN
RAISE(ABORT, "System category can't be deleted")
END;
END;
-- ---------------------------------------------------------------------------------------------------------
-- table - manga_category
-- ---------------------------------------------------------------------------------------------------------
-- The surrogate key is gone: a manga belongs to a category once, and that pair is the identity of the row
CREATE TABLE manga_category(
manga_id INTEGER NOT NULL,
category_id INTEGER NOT NULL,
UNIQUE(manga_id, category_id),
FOREIGN KEY(category_id) REFERENCES category (id)
ON DELETE CASCADE,
FOREIGN KEY(manga_id) REFERENCES manga (id)
ON DELETE CASCADE
);
-- Entries merged in the manga group brought their category rows with them. When the entry is in the library,
-- only the copies in it say where it belongs; a copy outside it can still hold the categories it had before
-- it was removed. The same pair can appear twice by now, and membership carries nothing else, so the copies
-- simply collapse.
INSERT INTO manga_category(manga_id, category_id)
SELECT DISTINCT coalesce(D.keep_id, MC.manga_id), MC.category_id
FROM mangas_categories MC
LEFT JOIN duplicate_mangas D
ON D._id = MC.manga_id
LEFT JOIN manga K
ON K.id = D.keep_id
WHERE D._id IS NULL OR D.favorite OR K.user_favorite_at IS NULL;
DROP TABLE mangas_categories;
CREATE INDEX manga_category_category_id_index ON manga_category(category_id);
-- ---------------------------------------------------------------------------------------------------------
-- table - history
-- ---------------------------------------------------------------------------------------------------------
-- `last_read` is when the chapter was last read and `time_read` is how long it has been read for, so
-- both are renamed to say so. The chapter it belongs to is already unique, which leaves
-- `history_history_chapter_id_index` a prefix of that constraint.
-- The table keeps its name, so the original is renamed aside and the new one built under
-- the real name; renaming a table into place makes SQLite rewrite its stored definition,
-- leaving it spelled differently from the same table on a fresh install. The views were
-- dropped at the top of this migration, so nothing refers to the name being moved.
ALTER TABLE history RENAME TO history_temp;
CREATE TABLE history(
id INTEGER NOT NULL PRIMARY KEY,
manga_id INTEGER NOT NULL,
chapter_id INTEGER NOT NULL UNIQUE,
read_at INTEGER,
read_duration INTEGER NOT NULL,
FOREIGN KEY(manga_id) REFERENCES manga (id)
ON DELETE CASCADE,
FOREIGN KEY(chapter_id) REFERENCES chapter (id)
ON DELETE CASCADE
);
-- Reaching the entry a row belongs to meant going through its chapter; it names the entry
-- itself now, taken from that same chapter. A row whose chapter is already gone cannot name
-- one and is dropped, which is what the foreign key would do to it once it is enforced again.
INSERT INTO history(id, manga_id, chapter_id, read_at, read_duration)
SELECT H._id, C.manga_id, H.chapter_id, H.last_read, H.time_read
FROM history_temp H
JOIN chapter C
ON C.id = H.chapter_id;
DROP TABLE history_temp;
CREATE INDEX history_manga_id_index ON history(manga_id);
CREATE INDEX history_read_at_index ON history(read_at);
-- ---------------------------------------------------------------------------------------------------------
-- table - excluded_scanlator
-- ---------------------------------------------------------------------------------------------------------
CREATE TABLE excluded_scanlator(
manga_id INTEGER NOT NULL,
scanlator TEXT NOT NULL,
UNIQUE(manga_id, scanlator),
FOREIGN KEY(manga_id) REFERENCES manga (id)
ON DELETE CASCADE
);
-- Excluding a scanlator twice excludes it once, and merged entries brought their exclusions along, so
-- the copies collapse. `excluded_scanlators_manga_id_index` is a prefix of the new constraint.
INSERT INTO excluded_scanlator(manga_id, scanlator)
SELECT DISTINCT coalesce(D.keep_id, ES.manga_id), ES.scanlator
FROM excluded_scanlators ES
LEFT JOIN duplicate_mangas D
ON D._id = ES.manga_id;
DROP TABLE excluded_scanlators;
-- ---------------------------------------------------------------------------------------------------------
-- table - manga_track
-- ---------------------------------------------------------------------------------------------------------
-- `manga_sync` is what tracking was called when it was still called syncing; `sync_id` is the tracker.
-- An entry that merged can leave two rows for the same tracker on the survivor, which the unique constraint
-- rejects, so the one to keep is chosen first: one on a copy in the library, then the one furthest read, then
-- the one on the copy ranked first, then the oldest. A track is taken whole rather than merged field by field,
-- because two rows on the same tracker can point at different remote entries.
CREATE TEMP TABLE duplicate_tracks AS
SELECT
T._id,
first_value(T._id) OVER (
PARTITION BY D.keep_id, T.sync_id
ORDER BY D.favorite DESC, T.last_chapter_read DESC, D.precedence ASC, T._id ASC
) AS keep_track_id
FROM manga_sync T
JOIN duplicate_mangas D
ON D._id = T.manga_id;
DELETE FROM manga_sync
WHERE _id IN (SELECT _id FROM duplicate_tracks WHERE _id != keep_track_id);
DROP TABLE duplicate_tracks;
CREATE TABLE manga_track(
id INTEGER NOT NULL PRIMARY KEY,
manga_id INTEGER NOT NULL,
tracker_id INTEGER NOT NULL,
remote_id INTEGER NOT NULL,
remote_url TEXT NOT NULL,
library_id INTEGER,
title TEXT NOT NULL,
last_chapter_read REAL NOT NULL,
total_chapters INTEGER NOT NULL,
status INTEGER NOT NULL,
score REAL NOT NULL,
start_date INTEGER NOT NULL,
finish_date INTEGER NOT NULL,
private INTEGER NOT NULL DEFAULT 0,
UNIQUE (manga_id, tracker_id),
FOREIGN KEY(manga_id) REFERENCES manga (id)
ON DELETE CASCADE
);
INSERT INTO manga_track(
id, manga_id, tracker_id, remote_id, remote_url, library_id, title, last_chapter_read,
total_chapters, status, score, start_date, finish_date, private
)
SELECT
T._id, coalesce(D.keep_id, T.manga_id), T.sync_id, T.remote_id, T.remote_url, T.library_id,
T.title, T.last_chapter_read, T.total_chapters, T.status, T.score, T.start_date,
T.finish_date, T.private
FROM manga_sync T
LEFT JOIN duplicate_mangas D
ON D._id = T.manga_id;
DROP TABLE duplicate_mangas;
-- `idx_manga_sync_manga_id` is a prefix of the unique constraint
DROP TABLE manga_sync;
CREATE INDEX manga_track_tracker_id_remote_id_index ON manga_track(tracker_id, remote_id);
-- ---------------------------------------------------------------------------------------------------------
-- table - source
-- ---------------------------------------------------------------------------------------------------------
-- The last of the plural table names, and `lang` spelled out. Nothing references this table, so it is a
-- straight copy; name comes before language to read the way the row does.
CREATE TABLE source(
id INTEGER NOT NULL PRIMARY KEY,
name TEXT NOT NULL,
language TEXT NOT NULL
);
INSERT INTO source(id, name, language)
SELECT _id, name, lang
FROM sources;
DROP TABLE sources;
-- The views come back reading the new table
CREATE VIEW historyView AS
SELECT
history.id AS id,
manga.id AS mangaId,
chapter.id AS chapterId,
manga.remote_title AS title,
manga.remote_cover AS thumbnailUrl,
manga.source_id AS sourceId,
manga.user_favorite_at IS NOT NULL AS favorite,
manga.state_cover_last_modified AS coverLastModified,
chapter.remote_chapter_number AS chapterNumber,
history.read_at AS readAt,
history.read_duration AS readDuration,
max_read.read_at AS maxReadAt,
max_read.chapter_id AS maxReadAtChapterId
FROM manga
JOIN chapter
ON manga.id = chapter.manga_id
JOIN history
ON chapter.id = history.chapter_id
JOIN (
SELECT manga_id, chapter_id, MAX(read_at) AS read_at
FROM history
GROUP BY manga_id
) AS max_read
ON chapter.manga_id = max_read.manga_id;
CREATE VIEW libraryView AS
SELECT
M.*,
coalesce(C.total, 0) AS totalCount,
coalesce(C.readCount, 0) AS readCount,
coalesce(C.latestUpload, 0) AS latestUpload,
coalesce(C.fetchedAt, 0) AS chapterFetchedAt,
coalesce(C.lastRead, 0) AS lastRead,
coalesce(C.bookmarkCount, 0) AS bookmarkCount,
coalesce(MC.categories, '0') AS categories
FROM manga M
LEFT JOIN (
SELECT
chapter.manga_id,
count(*) AS total,
sum(user_read) AS readCount,
coalesce(max(chapter.remote_date_upload), 0) AS latestUpload,
coalesce(max(history.read_at), 0) AS lastRead,
coalesce(max(chapter.state_date_fetch), 0) AS fetchedAt,
sum(chapter.user_bookmark) AS bookmarkCount
FROM chapter
LEFT JOIN excluded_scanlator
ON chapter.manga_id = excluded_scanlator.manga_id
AND chapter.remote_scanlator = excluded_scanlator.scanlator
LEFT JOIN history
ON chapter.id = history.chapter_id
WHERE excluded_scanlator.scanlator IS NULL
AND chapter.manga_id IN (SELECT id FROM manga WHERE user_favorite_at IS NOT NULL)
GROUP BY chapter.manga_id
) AS C
ON M.id = C.manga_id
LEFT JOIN (
SELECT manga_id, group_concat(category_id) AS categories
FROM manga_category
WHERE manga_id IN (SELECT id FROM manga WHERE user_favorite_at IS NOT NULL)
GROUP BY manga_id
) AS MC
ON MC.manga_id = M.id
WHERE M.user_favorite_at IS NOT NULL;
CREATE VIEW updatesView AS
SELECT
manga.id AS mangaId,
manga.remote_title AS mangaTitle,
chapter.id AS chapterId,
chapter.remote_name AS chapterName,
chapter.remote_scanlator AS scanlator,
chapter.remote_url AS chapterUrl,
chapter.user_read AS read,
chapter.user_bookmark AS bookmark,
chapter.user_last_page_read AS lastPageRead,
manga.source_id AS sourceId,
manga.user_favorite_at IS NOT NULL AS favorite,
manga.remote_cover AS thumbnailUrl,
manga.state_cover_last_modified AS coverLastModified,
chapter.remote_date_upload AS dateUpload,
chapter.state_date_fetch AS dateFetch,
excluded_scanlator.scanlator AS excludedScanlator
FROM manga JOIN chapter
ON manga.id = chapter.manga_id
LEFT JOIN excluded_scanlator
ON manga.id = excluded_scanlator.manga_id
AND chapter.remote_scanlator = excluded_scanlator.scanlator
WHERE user_favorite_at IS NOT NULL
AND state_date_fetch > user_favorite_at
ORDER BY state_date_fetch DESC;
@@ -1,30 +1,29 @@
CREATE VIEW historyView AS
SELECT
history._id AS id,
mangas._id AS mangaId,
chapters._id AS chapterId,
mangas.title,
mangas.thumbnail_url AS thumbnailUrl,
mangas.source,
mangas.favorite_at IS NOT NULL AS favorite,
mangas.cover_last_modified,
chapters.chapter_number AS chapterNumber,
history.last_read AS readAt,
history.time_read AS readDuration,
max_last_read.last_read AS maxReadAt,
max_last_read.chapter_id AS maxReadAtChapterId
FROM mangas
JOIN chapters
ON mangas._id = chapters.manga_id
history.id AS id,
manga.id AS mangaId,
chapter.id AS chapterId,
manga.remote_title AS title,
manga.remote_cover AS thumbnailUrl,
manga.source_id AS sourceId,
manga.user_favorite_at IS NOT NULL AS favorite,
manga.state_cover_last_modified AS coverLastModified,
chapter.remote_chapter_number AS chapterNumber,
history.read_at AS readAt,
history.read_duration AS readDuration,
max_read.read_at AS maxReadAt,
max_read.chapter_id AS maxReadAtChapterId
FROM manga
JOIN chapter
ON manga.id = chapter.manga_id
JOIN history
ON chapters._id = history.chapter_id
ON chapter.id = history.chapter_id
JOIN (
SELECT chapters.manga_id,chapters._id AS chapter_id, MAX(history.last_read) AS last_read
FROM chapters JOIN history
ON chapters._id = history.chapter_id
GROUP BY chapters.manga_id
) AS max_last_read
ON chapters.manga_id = max_last_read.manga_id;
SELECT manga_id, chapter_id, MAX(read_at) AS read_at
FROM history
GROUP BY manga_id
) AS max_read
ON chapter.manga_id = max_read.manga_id;
history:
SELECT
@@ -33,9 +32,9 @@ mangaId,
chapterId,
title,
thumbnailUrl,
source,
sourceId,
favorite,
cover_last_modified,
coverLastModified,
chapterNumber,
readAt,
readDuration
@@ -52,9 +51,9 @@ mangaId,
chapterId,
title,
thumbnailUrl,
source,
sourceId,
favorite,
cover_last_modified,
coverLastModified,
chapterNumber,
readAt,
readDuration
@@ -8,34 +8,43 @@ SELECT
coalesce(C.lastRead, 0) AS lastRead,
coalesce(C.bookmarkCount, 0) AS bookmarkCount,
coalesce(MC.categories, '0') AS categories
FROM mangas M
FROM manga M
LEFT JOIN (
SELECT
chapters.manga_id,
chapter.manga_id,
count(*) AS total,
sum(read) AS readCount,
coalesce(max(chapters.date_upload), 0) AS latestUpload,
coalesce(max(history.last_read), 0) AS lastRead,
coalesce(max(chapters.date_fetch), 0) AS fetchedAt,
sum(chapters.bookmark) AS bookmarkCount
FROM chapters
LEFT JOIN excluded_scanlators
ON chapters.manga_id = excluded_scanlators.manga_id
AND chapters.scanlator = excluded_scanlators.scanlator
sum(user_read) AS readCount,
coalesce(max(chapter.remote_date_upload), 0) AS latestUpload,
coalesce(max(history.read_at), 0) AS lastRead,
coalesce(max(chapter.state_date_fetch), 0) AS fetchedAt,
sum(chapter.user_bookmark) AS bookmarkCount
FROM chapter
LEFT JOIN excluded_scanlator
ON chapter.manga_id = excluded_scanlator.manga_id
AND chapter.remote_scanlator = excluded_scanlator.scanlator
LEFT JOIN history
ON chapters._id = history.chapter_id
WHERE excluded_scanlators.scanlator IS NULL
GROUP BY chapters.manga_id
ON chapter.id = history.chapter_id
WHERE excluded_scanlator.scanlator IS NULL
AND chapter.manga_id IN (SELECT id FROM manga WHERE user_favorite_at IS NOT NULL)
GROUP BY chapter.manga_id
) AS C
ON M._id = C.manga_id
ON M.id = C.manga_id
LEFT JOIN (
SELECT manga_id, group_concat(category_id) AS categories
FROM mangas_categories
FROM manga_category
WHERE manga_id IN (SELECT id FROM manga WHERE user_favorite_at IS NOT NULL)
GROUP BY manga_id
) AS MC
ON MC.manga_id = M._id
WHERE M.favorite_at IS NOT NULL;
ON MC.manga_id = M.id
WHERE M.user_favorite_at IS NOT NULL;
library:
SELECT *
SELECT
id, source_id, remote_url, remote_artist, remote_author,
remote_description, remote_genre, remote_title, remote_status, remote_cover,
state_chapter_last_update, state_chapter_next_update, state_initialized, user_reader_flags,
user_chapter_flags, state_cover_last_modified, remote_update_strategy, state_chapter_fetch_interval,
user_notes, remote_memo,
user_favorite_at, totalCount, readCount, latestUpload, chapterFetchedAt,
lastRead, bookmarkCount, categories
FROM libraryView;
@@ -1,47 +1,55 @@
CREATE VIEW updatesView AS
SELECT
mangas._id AS mangaId,
mangas.title AS mangaTitle,
chapters._id AS chapterId,
chapters.name AS chapterName,
chapters.scanlator,
chapters.url AS chapterUrl,
chapters.read,
chapters.bookmark,
chapters.last_page_read,
mangas.source,
mangas.favorite_at IS NOT NULL AS favorite,
mangas.thumbnail_url AS thumbnailUrl,
mangas.cover_last_modified AS coverLastModified,
chapters.date_upload AS dateUpload,
chapters.date_fetch AS datefetch,
excluded_scanlators.scanlator AS excludedScanlator
FROM mangas JOIN chapters
ON mangas._id = chapters.manga_id
LEFT JOIN excluded_scanlators
ON mangas._id = excluded_scanlators.manga_id
AND chapters.scanlator = excluded_scanlators.scanlator
WHERE favorite_at IS NOT NULL
AND date_fetch > favorite_at
ORDER BY date_fetch DESC;
manga.id AS mangaId,
manga.remote_title AS mangaTitle,
chapter.id AS chapterId,
chapter.remote_name AS chapterName,
chapter.remote_scanlator AS scanlator,
chapter.remote_url AS chapterUrl,
chapter.user_read AS read,
chapter.user_bookmark AS bookmark,
chapter.user_last_page_read AS lastPageRead,
manga.source_id AS sourceId,
manga.user_favorite_at IS NOT NULL AS favorite,
manga.remote_cover AS thumbnailUrl,
manga.state_cover_last_modified AS coverLastModified,
chapter.remote_date_upload AS dateUpload,
chapter.state_date_fetch AS dateFetch,
excluded_scanlator.scanlator AS excludedScanlator
FROM manga JOIN chapter
ON manga.id = chapter.manga_id
LEFT JOIN excluded_scanlator
ON manga.id = excluded_scanlator.manga_id
AND chapter.remote_scanlator = excluded_scanlator.scanlator
WHERE user_favorite_at IS NOT NULL
AND state_date_fetch > user_favorite_at
ORDER BY state_date_fetch DESC;
getRecentUpdates:
SELECT *
SELECT
mangaId, mangaTitle, chapterId, chapterName,
scanlator, chapterUrl, read, bookmark,
lastPageRead, sourceId, favorite, thumbnailUrl,
coverLastModified, dateUpload, dateFetch, excludedScanlator
FROM updatesView
WHERE dateUpload > :after
ORDER BY datefetch DESC
ORDER BY dateFetch DESC
LIMIT :limit;
getRecentUpdatesWithFilters:
SELECT *
SELECT
mangaId, mangaTitle, chapterId, chapterName,
scanlator, chapterUrl, read, bookmark,
lastPageRead, sourceId, favorite, thumbnailUrl,
coverLastModified, dateUpload, dateFetch, excludedScanlator
FROM updatesView
WHERE dateUpload > :after
AND (:read IS NULL OR read = :read)
-- Started means some progress but not finished, Read means finished chapter, thus:
AND (
:started IS NULL
OR (:started = 1 AND last_page_read > 0 AND read = 0)
OR (:started = 0 AND last_page_read = 0 AND read = 0)
OR (:started = 1 AND lastPageRead > 0 AND read = 0)
OR (:started = 0 AND lastPageRead = 0 AND read = 0)
)
AND (:bookmarked IS NULL OR bookmark = :bookmarked)
AND (
@@ -51,12 +59,12 @@ AND (
-- includedEmpty being true expresses "don't care" state and bypasses the membership filter
:includedEmpty
OR EXISTS (
SELECT 1 FROM mangas_categories
WHERE mangas_categories.manga_id = mangaId
AND COALESCE(mangas_categories.category_id, 0) IN :includedCategories
SELECT 1 FROM manga_category
WHERE manga_category.manga_id = mangaId
AND COALESCE(manga_category.category_id, 0) IN :includedCategories
)
OR (0 IN :includedCategories AND NOT EXISTS (
SELECT 1 FROM mangas_categories mc WHERE mc.manga_id = mangaId)
SELECT 1 FROM manga_category mc WHERE mc.manga_id = mangaId)
)
)
AND (
@@ -64,23 +72,27 @@ AND (
:excludedEmpty
OR (
NOT EXISTS (
SELECT 1 FROM mangas_categories
WHERE mangas_categories.manga_id = mangaId
AND COALESCE(mangas_categories.category_id, 0) IN :excludedCategories
SELECT 1 FROM manga_category
WHERE manga_category.manga_id = mangaId
AND COALESCE(manga_category.category_id, 0) IN :excludedCategories
)
AND (
0 NOT IN :excludedCategories
OR EXISTS (SELECT 1 FROM mangas_categories WHERE mangas_categories.manga_id = mangaId)
OR EXISTS (SELECT 1 FROM manga_category WHERE manga_category.manga_id = mangaId)
)
)
)
ORDER BY datefetch DESC
ORDER BY dateFetch DESC
LIMIT :limit;
getUpdatesByReadStatus:
SELECT *
SELECT
mangaId, mangaTitle, chapterId, chapterName,
scanlator, chapterUrl, read, bookmark,
lastPageRead, sourceId, favorite, thumbnailUrl,
coverLastModified, dateUpload, dateFetch, excludedScanlator
FROM updatesView
WHERE read = :read
AND dateUpload > :after
ORDER BY datefetch DESC
ORDER BY dateFetch DESC
LIMIT :limit;