Add per-page bookmark with optional notes and new view.

This commit is contained in:
Mekanik 2024-01-01 16:09:25 -08:00
parent 22589a9c30
commit 89859f1e52
43 changed files with 1972 additions and 33 deletions

View File

@ -22,7 +22,7 @@ android {
defaultConfig {
applicationId = "eu.kanade.tachiyomi"
versionCode = 113
versionCode = 114
versionName = "0.14.7"
buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"")

View File

@ -22,6 +22,7 @@ import eu.kanade.domain.track.interactor.AddTracks
import eu.kanade.domain.track.interactor.RefreshTracks
import eu.kanade.domain.track.interactor.SyncChapterProgressWithTrack
import eu.kanade.domain.track.interactor.TrackChapter
import tachiyomi.data.bookmark.BookmarkRepositoryImpl
import tachiyomi.data.category.CategoryRepositoryImpl
import tachiyomi.data.chapter.ChapterRepositoryImpl
import tachiyomi.data.history.HistoryRepositoryImpl
@ -31,6 +32,13 @@ import tachiyomi.data.source.SourceRepositoryImpl
import tachiyomi.data.source.StubSourceRepositoryImpl
import tachiyomi.data.track.TrackRepositoryImpl
import tachiyomi.data.updates.UpdatesRepositoryImpl
import tachiyomi.domain.bookmark.interactor.DeleteBookmark
import tachiyomi.domain.bookmark.interactor.GetBookmark
import tachiyomi.domain.bookmark.interactor.GetBookmarkedMangas
import tachiyomi.domain.bookmark.interactor.GetBookmarkedPages
import tachiyomi.domain.bookmark.interactor.GetBookmarks
import tachiyomi.domain.bookmark.interactor.SetBookmark
import tachiyomi.domain.bookmark.repository.BookmarkRepository
import tachiyomi.domain.category.interactor.CreateCategoryWithName
import tachiyomi.domain.category.interactor.DeleteCategory
import tachiyomi.domain.category.interactor.GetCategories
@ -138,7 +146,7 @@ class DomainModule : InjektModule {
addFactory { UpdateChapter(get()) }
addFactory { SetReadStatus(get(), get(), get(), get()) }
addFactory { ShouldUpdateDbChapter() }
addFactory { SyncChaptersWithSource(get(), get(), get(), get(), get(), get(), get(), get()) }
addFactory { SyncChaptersWithSource(get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) }
addFactory { GetAvailableScanlators(get()) }
addSingletonFactory<HistoryRepository> { HistoryRepositoryImpl(get()) }
@ -167,5 +175,13 @@ class DomainModule : InjektModule {
addFactory { ToggleLanguage(get()) }
addFactory { ToggleSource(get()) }
addFactory { ToggleSourcePin(get()) }
addSingletonFactory<BookmarkRepository> { BookmarkRepositoryImpl(get()) }
addFactory { SetBookmark(get(), get()) }
addFactory { DeleteBookmark(get(), get()) }
addFactory { GetBookmark(get()) }
addFactory { GetBookmarks(get()) }
addFactory { GetBookmarkedMangas(get()) }
addFactory { GetBookmarkedPages(get()) }
}
}

View File

@ -11,6 +11,9 @@ import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.online.HttpSource
import tachiyomi.data.chapter.ChapterSanitizer
import tachiyomi.domain.bookmark.interactor.GetBookmarks
import tachiyomi.domain.bookmark.interactor.SetBookmark
import tachiyomi.domain.bookmark.model.BookmarkWithChapterNumber
import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
import tachiyomi.domain.chapter.interactor.ShouldUpdateDbChapter
import tachiyomi.domain.chapter.interactor.UpdateChapter
@ -34,6 +37,8 @@ class SyncChaptersWithSource(
private val updateChapter: UpdateChapter,
private val getChaptersByMangaId: GetChaptersByMangaId,
private val getExcludedScanlators: GetExcludedScanlators,
private val getBookmarks: GetBookmarks,
private val setBookmark: SetBookmark,
) {
/**
@ -143,6 +148,12 @@ class SyncChaptersWithSource(
}
val reAdded = mutableListOf<Chapter>()
val reAddedBookmarks = mutableListOf<BookmarkWithChapterNumber>()
val bookmarksByChapterNumber = if (newChapters.isEmpty()) {
emptyMap()
} else {
getBookmarks.awaitWithChapterNumbers(manga.id).groupBy { it.chapterNumber }
}
val deletedChapterNumbers = TreeSet<Double>()
val deletedReadChapterNumbers = TreeSet<Double>()
@ -170,6 +181,9 @@ class SyncChaptersWithSource(
bookmark = chapter.chapterNumber in deletedBookmarkedChapterNumbers,
)
// Existing bookmarks are saved to be moved to re-added chapters.
bookmarksByChapterNumber[chapter.chapterNumber]?.let { reAddedBookmarks.addAll(it) }
// Try to to use the fetch date of the original entry to not pollute 'Updates' tab
deletedChapterNumberDateFetchMap[chapter.chapterNumber]?.let {
chapter = chapter.copy(dateFetch = it)
@ -193,6 +207,22 @@ class SyncChaptersWithSource(
val chapterUpdates = updatedChapters.map { it.toChapterUpdate() }
updateChapter.awaitAll(chapterUpdates)
}
if (reAddedBookmarks.isNotEmpty()) {
val chapterIdByNumber = updatedToAdd.associate { it.chapterNumber to it.id }
val bookmarksToAdd = reAddedBookmarks.mapNotNull { bm ->
chapterIdByNumber[bm.chapterNumber]
?.let { chapterId ->
bm.toBookmarkImpl().copy(
mangaId = manga.id,
chapterId = chapterId,
)
}
}
setBookmark.awaitAll(bookmarksToAdd, updateChapters = false)
}
updateManga.awaitUpdateFetchInterval(manga, now, fetchWindow)
// Set this manga as updated since chapters were changed

View File

@ -0,0 +1,225 @@
package eu.kanade.presentation.bookmarks
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEachIndexed
import eu.kanade.presentation.manga.components.MangaCover
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.bookmarks.BookmarksTopScreenModel
import eu.kanade.tachiyomi.util.lang.toRelativeString
import tachiyomi.domain.bookmark.model.BookmarkedPage
import tachiyomi.presentation.core.components.ScrollbarLazyColumn
import tachiyomi.presentation.core.components.material.SecondaryItemAlpha
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.util.plus
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import tachiyomi.domain.manga.model.MangaCover as CoverData
@Composable
fun BookmarksDetailsScreenContent(
state: BookmarksTopScreenModel.State,
paddingValues: PaddingValues,
relativeTime: Boolean,
dateFormat: DateFormat,
onBookmarkClick: (mangaId: Long, chapterId: Long, pageIndex: Int?) -> Unit,
onMangaClick: (mangaId: Long) -> Unit,
) {
val statListState = rememberLazyListState()
ScrollbarLazyColumn(
contentPadding = paddingValues + PaddingValues(vertical = MaterialTheme.padding.medium),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.extraSmall),
state = statListState,
) {
state.groupsOfBookmarks.fastForEachIndexed { i, bookmark ->
// Header:
if (i == 0 || bookmark.mangaId != state.groupsOfBookmarks[i - 1].mangaId) {
item(
key = "bm-header-${bookmark.mangaId}",
contentType = "header",
) {
MangaCoverUiItem(
bookmark.coverData,
bookmark.mangaTitle,
onMangaClick = { onMangaClick(bookmark.mangaId) },
Modifier.animateItemPlacement(),
)
Spacer(modifier = Modifier.height(MaterialTheme.padding.extraSmall))
}
}
item(key = "bm-id-${bookmark.bookmarkId}") {
BookmarkUiItem(
modifier = Modifier.animateItemPlacement(),
info = bookmark,
relativeTime = relativeTime,
dateFormat = dateFormat,
onLongClick = {},
onClick = {
onBookmarkClick(
bookmark.mangaId,
bookmark.chapterId,
bookmark.pageIndex,
)
},
)
}
}
}
}
@Composable
fun MangaCoverUiItem(
coverData: CoverData,
title: String,
onMangaClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.combinedClickable(onClick = onMangaClick)
.height(56.dp)
.padding(horizontal = MaterialTheme.padding.medium),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.medium),
) {
MangaCover.Square(
modifier = Modifier
.padding(vertical = 6.dp)
.fillMaxHeight(),
data = coverData,
)
Text(
text = title,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(weight = 1f, fill = true),
)
}
}
@Composable
private fun BookmarkUiItem(
info: BookmarkedPage,
relativeTime: Boolean,
dateFormat: DateFormat,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val maxLinesForNoteText = 5
val haptic = LocalHapticFeedback.current
val context = LocalContext.current
Row(
modifier = modifier
.combinedClickable(
onClick = onClick,
onLongClick = {
onLongClick()
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
},
)
.padding(
vertical = MaterialTheme.padding.extraSmall,
horizontal = MaterialTheme.padding.medium,
),
verticalAlignment = Alignment.CenterVertically,
) {
Column(
modifier = Modifier
.weight(1f),
) {
Text(
text = info.chapterName,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodyMedium,
)
Row(
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small),
) {
Text(
text = info.pageIndex?.let { i ->
stringResource(R.string.bookmark_page_number, i + 1)
} ?: stringResource(R.string.bookmark_chapter),
style = MaterialTheme.typography.bodySmall,
color = LocalContentColor.current.copy(alpha = SecondaryItemAlpha),
)
Text(
text = Date(info.lastModifiedAt).toRelativeString(context, relativeTime, dateFormat),
maxLines = 1,
style = MaterialTheme.typography.bodySmall,
overflow = TextOverflow.Ellipsis,
color = LocalContentColor.current.copy(alpha = SecondaryItemAlpha),
)
}
info.note?.takeIf { it.isNotBlank() }?.let { text ->
Text(
text = text,
maxLines = maxLinesForNoteText,
style = MaterialTheme.typography.bodySmall,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
@Preview
@Composable
private fun BookmarkUiItemPreview() {
BookmarkUiItem(
modifier = Modifier,
info = BookmarkedPage(
lastModifiedAt = 123123,
note = "Very long note here, ....sdf ljsadf kaslkdfjlkjlkdf , long ,asdkl jaskdjlkajsdlklkjlksdf lasfd ABC",
bookmarkId = 1,
pageIndex = 12,
chapterName = "Chapte sadfjhks dfjksad kfjhksjdhf kjhsdfkj hkfdhkajdfh r",
mangaId = 1,
chapterId = 12,
chapterNumber = 1.0,
coverData = CoverData(
mangaId = 1,
isMangaFavorite = true,
lastModified = 1,
sourceId = 1,
url = null,
),
mangaTitle = "Manga",
),
relativeTime = true,
dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.getDefault()),
onClick = {},
onLongClick = {},
)
}

View File

@ -0,0 +1,169 @@
package eu.kanade.presentation.bookmarks
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bookmark
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import eu.kanade.presentation.manga.components.MangaCover
import eu.kanade.tachiyomi.ui.bookmarks.BookmarksTopScreenModel
import eu.kanade.tachiyomi.util.lang.toRelativeString
import tachiyomi.domain.bookmark.model.MangaWithBookmarks
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.ScrollbarLazyColumn
import tachiyomi.presentation.core.components.material.SecondaryItemAlpha
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.util.plus
import java.text.DateFormat
import java.util.Date
@Composable
fun BookmarksTopScreenContent(
state: BookmarksTopScreenModel.State,
paddingValues: PaddingValues,
relativeTime: Boolean,
dateFormat: DateFormat,
onMangaSelected: (Long) -> Unit,
) {
val statListState = rememberLazyListState()
ScrollbarLazyColumn(
contentPadding = paddingValues + PaddingValues(vertical = MaterialTheme.padding.medium),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.medium),
state = statListState,
) {
items(
items = state.mangaWithBookmarks,
key = { "bm-manga-${it.mangaId}" },
) {
MangaWithBookmarksUiItem(
info = it,
relativeTime = relativeTime,
dateFormat = dateFormat,
onLongClick = {
onMangaSelected(it.mangaId)
},
onClick = {
onMangaSelected(it.mangaId)
},
onClickCover = {
onMangaSelected(it.mangaId)
},
modifier = Modifier.animateItemPlacement(),
)
}
}
}
@Composable
private fun MangaWithBookmarksUiItem(
info: MangaWithBookmarks,
relativeTime: Boolean,
dateFormat: DateFormat,
onClick: () -> Unit,
onLongClick: () -> Unit,
onClickCover: (() -> Unit)?,
modifier: Modifier = Modifier,
) {
val haptic = LocalHapticFeedback.current
val context = LocalContext.current
Row(
modifier = modifier
.combinedClickable(
onClick = onClick,
onLongClick = {
onLongClick()
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
},
)
.height(56.dp)
.padding(horizontal = MaterialTheme.padding.medium),
verticalAlignment = Alignment.CenterVertically,
) {
MangaCover.Square(
modifier = Modifier
.padding(vertical = 6.dp)
.fillMaxHeight(),
data = info.coverData,
onClick = onClickCover,
)
Column(
modifier = Modifier
.padding(horizontal = MaterialTheme.padding.medium)
.weight(1f),
) {
Text(
text = info.mangaTitle,
maxLines = 1,
style = MaterialTheme.typography.bodyMedium,
overflow = TextOverflow.Ellipsis,
)
Row(verticalAlignment = Alignment.CenterVertically) {
var textHeight by remember { mutableIntStateOf(0) }
Icon(
imageVector = Icons.Filled.Bookmark,
contentDescription = stringResource(MR.strings.action_filter_bookmarked),
modifier = Modifier
.sizeIn(maxHeight = with(LocalDensity.current) { textHeight.toDp() - 2.dp }),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.width(2.dp))
Text(
text = stringResource(
MR.strings.bookmark_total_in_manga,
info.numberOfBookmarks,
),
maxLines = 1,
style = MaterialTheme.typography.bodySmall,
overflow = TextOverflow.Ellipsis,
onTextLayout = { textHeight = it.size.height },
modifier = Modifier
.weight(weight = 1f, fill = false),
)
}
if (info.bookmarkLastModified > 0) {
Text(
text = stringResource(
MR.strings.bookmark_last_updated_in_manga,
Date(info.bookmarkLastModified).toRelativeString(context, relativeTime, dateFormat),
),
maxLines = 1,
style = MaterialTheme.typography.bodySmall,
overflow = TextOverflow.Ellipsis,
color = LocalContentColor.current.copy(alpha = SecondaryItemAlpha),
)
}
}
}
}

View File

@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.HelpOutline
import androidx.compose.material.icons.automirrored.outlined.Label
import androidx.compose.material.icons.outlined.Bookmarks
import androidx.compose.material.icons.outlined.CloudOff
import androidx.compose.material.icons.outlined.GetApp
import androidx.compose.material.icons.outlined.Info
@ -46,6 +47,7 @@ fun MoreScreen(
onClickDownloadQueue: () -> Unit,
onClickCategories: () -> Unit,
onClickStats: () -> Unit,
onClickBookmarks: () -> Unit,
onClickDataAndStorage: () -> Unit,
onClickSettings: () -> Unit,
onClickAbout: () -> Unit,
@ -142,6 +144,13 @@ fun MoreScreen(
onPreferenceClick = onClickStats,
)
}
item {
TextPreferenceWidget(
title = stringResource(MR.strings.label_bookmarks),
icon = Icons.Outlined.Bookmarks,
onPreferenceClick = onClickBookmarks,
)
}
item {
TextPreferenceWidget(
title = stringResource(MR.strings.label_data_storage),

View File

@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Bookmark
import androidx.compose.material.icons.outlined.Photo
import androidx.compose.material.icons.outlined.Save
import androidx.compose.material.icons.outlined.Share
@ -19,6 +20,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import eu.kanade.presentation.components.AdaptiveSheet
import eu.kanade.tachiyomi.ui.bookmarks.EditBookmarkDialog
import tachiyomi.domain.bookmark.model.Bookmark
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.ActionButton
import tachiyomi.presentation.core.components.material.padding
@ -30,8 +33,12 @@ fun ReaderPageActionsDialog(
onSetAsCover: () -> Unit,
onShare: () -> Unit,
onSave: () -> Unit,
onBookmarkPage: (String) -> Unit,
onUnbookmarkPage: () -> Unit,
getPageBookmark: () -> Bookmark?,
) {
var showSetCoverDialog by remember { mutableStateOf(false) }
var showEditPageBookmarkDialog by remember { mutableStateOf(false) }
AdaptiveSheet(
onDismissRequest = onDismissRequest,
@ -64,6 +71,12 @@ fun ReaderPageActionsDialog(
onDismissRequest()
},
)
ActionButton(
modifier = Modifier.weight(1f),
title = stringResource(MR.strings.page_bookmark),
icon = Icons.Outlined.Bookmark,
onClick = { showEditPageBookmarkDialog = true },
)
}
}
@ -76,6 +89,23 @@ fun ReaderPageActionsDialog(
onDismiss = { showSetCoverDialog = false },
)
}
if (showEditPageBookmarkDialog) {
EditBookmarkDialog(
onConfirm = { bookmarkNote ->
onBookmarkPage(bookmarkNote)
showEditPageBookmarkDialog = false
onDismissRequest()
},
onDelete = {
onUnbookmarkPage()
showEditPageBookmarkDialog = false
onDismissRequest()
},
onDismiss = { showEditPageBookmarkDialog = false },
bookmark = getPageBookmark(),
)
}
}
@Composable

View File

@ -10,6 +10,7 @@ data class BackupOptions(
val chapters: Boolean = true,
val tracking: Boolean = true,
val history: Boolean = true,
val bookmarks: Boolean = true,
val appSettings: Boolean = true,
val sourceSettings: Boolean = true,
val privateSettings: Boolean = false,
@ -24,6 +25,7 @@ data class BackupOptions(
appSettings,
sourceSettings,
privateSettings,
bookmarks,
)
fun anyEnabled() = libraryEntries || appSettings || sourceSettings
@ -59,6 +61,12 @@ data class BackupOptions(
setter = { options, enabled -> options.copy(history = enabled) },
enabled = { it.libraryEntries },
),
Entry(
label = MR.strings.label_bookmarks,
getter = BackupOptions::bookmarks,
setter = { options, enabled -> options.copy(bookmarks = enabled) },
enabled = { it.libraryEntries },
),
)
val settingsOptions = persistentListOf(
@ -89,6 +97,7 @@ data class BackupOptions(
appSettings = array[5],
sourceSettings = array[6],
privateSettings = array[7],
bookmarks = array.getOrElse(8) { true },
)
}

View File

@ -4,6 +4,7 @@ import eu.kanade.tachiyomi.data.backup.create.BackupOptions
import eu.kanade.tachiyomi.data.backup.models.BackupChapter
import eu.kanade.tachiyomi.data.backup.models.BackupHistory
import eu.kanade.tachiyomi.data.backup.models.BackupManga
import eu.kanade.tachiyomi.data.backup.models.backupBookmarkMapper
import eu.kanade.tachiyomi.data.backup.models.backupChapterMapper
import eu.kanade.tachiyomi.data.backup.models.backupTrackMapper
import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode
@ -71,6 +72,15 @@ class MangaBackupCreator(
}
}
if (options.bookmarks) {
val bookmarks = handler.awaitList {
bookmarksQueries.getWithChapterInfoByMangaId(manga.id, backupBookmarkMapper)
}
if (bookmarks.isNotEmpty()) {
mangaObject.bookmarks = bookmarks
}
}
return mangaObject
}
}

View File

@ -0,0 +1,32 @@
package eu.kanade.tachiyomi.data.backup.models
import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber
import tachiyomi.domain.bookmark.model.Bookmark
@Serializable
class BackupBookmark(
@ProtoNumber(1) var chapterUrl: String,
@ProtoNumber(2) var pageIndex: Int? = null,
@ProtoNumber(3) var note: String? = null,
@ProtoNumber(4) var lastModifiedAt: Long = 0,
) {
fun toBookmarkImpl(): Bookmark {
return Bookmark.create()
.copy(
pageIndex = pageIndex,
note = note,
lastModifiedAt = lastModifiedAt,
)
}
}
val backupBookmarkMapper =
{ chapterUrl: String, _: Double, pageIndex: Long?, note: String?, lastModifiedAt: Long ->
BackupBookmark(
chapterUrl = chapterUrl,
pageIndex = pageIndex?.toInt(),
note = note,
lastModifiedAt = lastModifiedAt * 1000L,
)
}

View File

@ -38,6 +38,7 @@ data class BackupManga(
@ProtoNumber(105) var updateStrategy: UpdateStrategy = UpdateStrategy.ALWAYS_UPDATE,
@ProtoNumber(106) var lastModifiedAt: Long = 0,
@ProtoNumber(107) var favoriteModifiedAt: Long? = null,
@ProtoNumber(108) var bookmarks: List<BackupBookmark> = emptyList(),
) {
fun getMangaImpl(): Manga {
return Manga.create().copy(

View File

@ -1,6 +1,7 @@
package eu.kanade.tachiyomi.data.backup.restore.restorers
import eu.kanade.domain.manga.interactor.UpdateManga
import eu.kanade.tachiyomi.data.backup.models.BackupBookmark
import eu.kanade.tachiyomi.data.backup.models.BackupCategory
import eu.kanade.tachiyomi.data.backup.models.BackupChapter
import eu.kanade.tachiyomi.data.backup.models.BackupHistory
@ -8,6 +9,8 @@ import eu.kanade.tachiyomi.data.backup.models.BackupManga
import eu.kanade.tachiyomi.data.backup.models.BackupTracking
import tachiyomi.data.DatabaseHandler
import tachiyomi.data.UpdateStrategyColumnAdapter
import tachiyomi.domain.bookmark.interactor.SetBookmark
import tachiyomi.domain.bookmark.model.Bookmark
import tachiyomi.domain.category.interactor.GetCategories
import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
import tachiyomi.domain.chapter.model.Chapter
@ -31,6 +34,7 @@ class MangaRestorer(
private val updateManga: UpdateManga = Injekt.get(),
private val getTracks: GetTracks = Injekt.get(),
private val insertTrack: InsertTrack = Injekt.get(),
private val setBookmark: SetBookmark = Injekt.get(),
fetchInterval: FetchInterval = Injekt.get(),
) {
@ -72,6 +76,7 @@ class MangaRestorer(
backupCategories = backupCategories,
history = backupManga.history + backupManga.brokenHistory.map { it.toBackupHistory() },
tracks = backupManga.tracking,
bookmarks = backupManga.bookmarks,
)
}
@ -262,11 +267,13 @@ class MangaRestorer(
backupCategories: List<BackupCategory>,
history: List<BackupHistory>,
tracks: List<BackupTracking>,
bookmarks: List<BackupBookmark>,
): Manga {
restoreCategories(manga, categories, backupCategories)
restoreChapters(manga, chapters)
restoreTracking(manga, tracks)
restoreHistory(history)
restoreBookmarks(manga, bookmarks)
updateManga.awaitUpdateFetchInterval(manga, now, currentFetchWindow)
return manga
}
@ -398,5 +405,36 @@ class MangaRestorer(
}
}
private suspend fun restoreBookmarks(manga: Manga, backupBookmarks: List<BackupBookmark>) {
val chapters = getChaptersByMangaId.await(manga.id)
val bookmarks: List<Bookmark> =
if (backupBookmarks.isEmpty()) {
// No bookmarks list in the backup.
// It's either an older version backup or backup without bookmarks.
// Create chapter-level bookmarks (they will not affect existing chapter bookmarks).
chapters
.filter { it.bookmark }
.map { Bookmark.create().copy(mangaId = manga.id, chapterId = it.id) }
} else {
// Map chapters from backup to db chapters and insert bookmark records based on backup.
val chapterIdByUrl = chapters.associate { it.url to it.id }
backupBookmarks.mapNotNull {
chapterIdByUrl[it.chapterUrl]
?.let { chapterId ->
it.toBookmarkImpl()
.copy(
mangaId = manga.id,
chapterId = chapterId,
)
}
}
}
if (bookmarks.isNotEmpty()) {
setBookmark.awaitAll(bookmarks, updateChapters = false)
}
}
private fun Track.forComparison() = this.copy(id = 0L, mangaId = 0L)
}

View File

@ -0,0 +1,155 @@
package eu.kanade.tachiyomi.ui.bookmarks
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.DeleteSweep
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.presentation.bookmarks.BookmarksDetailsScreenContent
import eu.kanade.presentation.bookmarks.BookmarksTopScreenContent
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.AppBarActions
import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.ui.manga.MangaScreen
import kotlinx.collections.immutable.persistentListOf
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.PullRefresh
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.screens.EmptyScreen
import tachiyomi.presentation.core.screens.LoadingScreen
/**
* Top-level screen for bookmarks.
* Displays aggregated information by manga with details on manga selection.
*/
class BookmarksTopScreen : Screen() {
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
val screenModel = rememberScreenModel { BookmarksTopScreenModel() }
val state by screenModel.state.collectAsState()
var showRemoveAllConfirmationDialog by remember { mutableStateOf(false) }
val context = LocalContext.current
Scaffold(
topBar = { scrollBehavior ->
AppBar(
title = stringResource(MR.strings.label_bookmarks),
navigateUp = { screenModel.onNavigationUp(navigator) },
scrollBehavior = scrollBehavior,
actions = {
state.selectedMangaId?.let {
AppBarActions(
persistentListOf(
AppBar.Action(
title = stringResource(MR.strings.action_delete_all_bookmarks),
icon = Icons.Outlined.DeleteSweep,
onClick = {
showRemoveAllConfirmationDialog = true
},
),
),
)
}
},
)
},
) { contentPadding ->
when {
state.isLoading -> LoadingScreen(modifier = Modifier.padding(contentPadding))
state.isEmpty ->
EmptyScreen(
stringRes = MR.strings.information_no_bookmarks,
modifier = Modifier.padding(contentPadding),
)
else ->
PullRefresh(
refreshing = state.isRefreshing,
onRefresh = screenModel::refresh,
indicatorPadding = contentPadding,
enabled = { true },
) {
when (state.selectedMangaId) {
null ->
BookmarksTopScreenContent(
paddingValues = contentPadding,
state = state,
relativeTime = screenModel.relativeTime,
dateFormat = screenModel.dateFormat,
onMangaSelected = screenModel::onMangaSelected,
)
else -> BookmarksDetailsScreenContent(
paddingValues = contentPadding,
state = state,
relativeTime = screenModel.relativeTime,
dateFormat = screenModel.dateFormat,
onBookmarkClick = { mangaId, chapterId, pageIndex ->
screenModel.openReader(
context,
mangaId,
chapterId,
pageIndex,
)
},
onMangaClick = { mangaId ->
navigator.push(MangaScreen(mangaId))
},
)
}
}
}
}
if (showRemoveAllConfirmationDialog) {
BookmarksDeleteAllDialog(
onDelete = {
screenModel.delete()
showRemoveAllConfirmationDialog = false
},
onDismissRequest = { showRemoveAllConfirmationDialog = false },
)
}
}
}
@Composable
fun BookmarksDeleteAllDialog(
onDismissRequest: () -> Unit,
onDelete: () -> Unit,
) {
AlertDialog(
title = {
Text(text = stringResource(MR.strings.action_delete_all_bookmarks))
},
text = {
Text(text = stringResource(MR.strings.bookmark_delete_manga_confirmation))
},
onDismissRequest = onDismissRequest,
confirmButton = {
TextButton(onClick = onDelete) {
Text(text = stringResource(MR.strings.action_delete))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(MR.strings.action_cancel))
}
},
)
}

View File

@ -0,0 +1,130 @@
package eu.kanade.tachiyomi.ui.bookmarks
import android.content.Context
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import cafe.adriel.voyager.navigator.Navigator
import eu.kanade.core.preference.asState
import eu.kanade.domain.ui.UiPreferences
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.update
import tachiyomi.core.util.lang.launchIO
import tachiyomi.domain.bookmark.interactor.DeleteBookmark
import tachiyomi.domain.bookmark.interactor.GetBookmarkedMangas
import tachiyomi.domain.bookmark.interactor.GetBookmarkedPages
import tachiyomi.domain.bookmark.model.BookmarkedPage
import tachiyomi.domain.bookmark.model.MangaWithBookmarks
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import kotlin.time.Duration.Companion.seconds
class BookmarksTopScreenModel(
private val getBookmarkedMangas: GetBookmarkedMangas = Injekt.get(),
private val getBookmarkedPages: GetBookmarkedPages = Injekt.get(),
private val deleteBookmark: DeleteBookmark = Injekt.get(),
uiPreferences: UiPreferences = Injekt.get(),
) : StateScreenModel<BookmarksTopScreenModel.State>(State()) {
val relativeTime by uiPreferences.relativeTime().asState(screenModelScope)
val dateFormat by mutableStateOf(UiPreferences.dateFormat(uiPreferences.dateFormat().get()))
init {
screenModelScope.launchIO {
loadMangaWithBookmarks()
}
}
fun refresh() {
screenModelScope.launchIO {
mutableState.update { it.copy(isRefreshing = true) }
delay(0.5.seconds)
state.value.selectedMangaId?.let { mangaId ->
loadGroupedBookmarks(mangaId)
} ?: loadMangaWithBookmarks()
}
}
fun openReader(context: Context, mangaId: Long, chapterId: Long, pageIndex: Int?) {
context.startActivity(ReaderActivity.newIntent(context, mangaId, chapterId, pageIndex))
}
fun onMangaSelected(mangaId: Long) {
screenModelScope.launchIO {
loadGroupedBookmarks(mangaId)
}
}
fun onNavigationUp(navigator: Navigator) {
if (state.value.selectedMangaId != null) {
mutableState.update {
it.copy(
selectedMangaId = null,
)
}
} else {
navigator.pop()
}
}
fun delete() {
state.value.selectedMangaId?.let { mangaId ->
screenModelScope.launchIO {
deleteBookmark.awaitAllByMangaId(mangaId)
// Refresh after deletion and return to top level view.
loadMangaWithBookmarks()
}
}
}
private suspend fun loadMangaWithBookmarks() {
val mangaWithBookmarks = getBookmarkedMangas.await()
mutableState.update {
it.copy(
mangaWithBookmarks = mangaWithBookmarks,
groupsOfBookmarks = listOf(),
isLoading = false,
isRefreshing = false,
selectedMangaId = null,
)
}
}
private suspend fun loadGroupedBookmarks(mangaId: Long) {
val bookmarks = getBookmarkedPages.await(mangaId)
val groupsOfBookmarks = bookmarks
.sortedWith(
compareBy(
{ it.mangaId },
{ it.chapterNumber },
// chapterName is needed for sorting when all numbers are -1 (e.g. Volume 1, Volume 2)
{ it.chapterName },
{ it.pageIndex ?: -1 },
),
)
mutableState.update {
it.copy(
groupsOfBookmarks = groupsOfBookmarks,
isLoading = false,
isRefreshing = false,
selectedMangaId = mangaId,
)
}
}
@Immutable
data class State(
val isLoading: Boolean = true,
val isRefreshing: Boolean = false,
val mangaWithBookmarks: List<MangaWithBookmarks> = listOf(),
val groupsOfBookmarks: List<BookmarkedPage> = listOf(),
val selectedMangaId: Long? = null,
) {
val isEmpty =
selectedMangaId?.let { groupsOfBookmarks.isEmpty() } ?: mangaWithBookmarks.isEmpty()
}
}

View File

@ -0,0 +1,112 @@
package eu.kanade.tachiyomi.ui.bookmarks
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.tooling.preview.Preview
import kotlinx.coroutines.delay
import tachiyomi.domain.bookmark.model.Bookmark
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource
import kotlin.time.Duration.Companion.seconds
/**
* Dialog for creating a new Bookmark, for updating or removing an existing Bookmark.
*/
@Composable
fun EditBookmarkDialog(
onConfirm: (note: String) -> Unit,
onDelete: () -> Unit,
onDismiss: () -> Unit,
bookmark: Bookmark?,
) {
var bookmarkNoteText by remember { mutableStateOf(bookmark?.note ?: "") }
// For in-place delete confirmation.
var showDeleteConfirmation by remember { mutableStateOf(false) }
val focusRequester = remember { FocusRequester() }
val saveButtonText = bookmark?.let { stringResource(MR.strings.action_update_bookmark) }
?: stringResource(MR.strings.action_add)
val titleText = bookmark?.let { stringResource(MR.strings.action_update_page_bookmark) }
?: stringResource(MR.strings.action_add_page_bookmark)
AlertDialog(
title = {
Text(
text = if (showDeleteConfirmation) {
stringResource(MR.strings.action_delete_bookmark)
} else {
titleText
},
)
},
text = {
if (showDeleteConfirmation) {
Text(text = stringResource(MR.strings.delete_bookmark_confirmation))
} else {
OutlinedTextField(
modifier = Modifier.focusRequester(focusRequester),
value = bookmarkNoteText,
onValueChange = { bookmarkNoteText = it },
label = { Text(stringResource(MR.strings.bookmark_note_placeholder)) },
singleLine = false,
maxLines = 10,
)
}
},
confirmButton = {
Row {
if (showDeleteConfirmation) {
TextButton(onClick = { showDeleteConfirmation = false }) {
Text(text = stringResource(MR.strings.action_cancel))
}
TextButton(onClick = { onDelete() }) {
Text(text = stringResource(MR.strings.action_delete))
}
} else {
if (bookmark != null) {
TextButton(onClick = { showDeleteConfirmation = true }) {
Text(text = stringResource(MR.strings.action_delete))
}
}
TextButton(onClick = { onConfirm(bookmarkNoteText) }) {
Text(text = saveButtonText)
}
TextButton(onClick = onDismiss) {
Text(stringResource(MR.strings.action_cancel))
}
}
}
},
onDismissRequest = onDismiss,
)
LaunchedEffect(focusRequester) {
// TODO: https://issuetracker.google.com/issues/204502668
delay(0.1.seconds)
focusRequester.requestFocus()
}
}
@Preview
@Composable
fun EditPageBookmarkDialogPreview() {
EditBookmarkDialog(
onConfirm = { },
onDelete = {},
onDismiss = {},
bookmark = Bookmark(1, 1, 1, 10, "ABC", 2),
)
}

View File

@ -4,6 +4,8 @@ import dev.icerock.moko.resources.StringResource
import eu.kanade.domain.manga.model.hasCustomCover
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.download.DownloadCache
import kotlinx.coroutines.runBlocking
import tachiyomi.domain.bookmark.interactor.GetBookmarks
import tachiyomi.domain.manga.model.Manga
import tachiyomi.i18n.MR
import uy.kohesive.injekt.injectLazy
@ -30,9 +32,11 @@ object MigrationFlags {
private const val CATEGORIES = 0b00010
private const val CUSTOM_COVER = 0b01000
private const val DELETE_DOWNLOADED = 0b10000
private const val BOOKMARKS = 0b100000
private val coverCache: CoverCache by injectLazy()
private val downloadCache: DownloadCache by injectLazy()
private val getBookmarks: GetBookmarks by injectLazy()
fun hasChapters(value: Int): Boolean {
return value and CHAPTERS != 0
@ -50,6 +54,10 @@ object MigrationFlags {
return value and DELETE_DOWNLOADED != 0
}
fun hasBookmarks(value: Int): Boolean {
return value and BOOKMARKS != 0
}
/** Returns information about applicable flags with default selections. */
fun getFlags(manga: Manga?, defaultSelectedBitMap: Int): List<MigrationFlag> {
val flags = mutableListOf<MigrationFlag>()
@ -63,6 +71,9 @@ object MigrationFlags {
if (downloadCache.getDownloadCount(manga) > 0) {
flags += MigrationFlag.create(DELETE_DOWNLOADED, defaultSelectedBitMap, MR.strings.delete_downloaded)
}
if (runBlocking { getBookmarks.await(manga.id) }.isNotEmpty()) {
flags += MigrationFlag.create(BOOKMARKS, defaultSelectedBitMap, MR.strings.label_bookmarks)
}
}
return flags
}

View File

@ -36,10 +36,15 @@ import tachiyomi.core.preference.Preference
import tachiyomi.core.preference.PreferenceStore
import tachiyomi.core.util.lang.launchIO
import tachiyomi.core.util.lang.withUIContext
import tachiyomi.domain.bookmark.interactor.DeleteBookmark
import tachiyomi.domain.bookmark.interactor.GetBookmarks
import tachiyomi.domain.bookmark.interactor.SetBookmark
import tachiyomi.domain.bookmark.model.Bookmark
import tachiyomi.domain.category.interactor.GetCategories
import tachiyomi.domain.category.interactor.SetMangaCategories
import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
import tachiyomi.domain.chapter.interactor.UpdateChapter
import tachiyomi.domain.chapter.model.Chapter
import tachiyomi.domain.chapter.model.toChapterUpdate
import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.manga.model.MangaUpdate
@ -153,6 +158,9 @@ internal class MigrateDialogScreenModel(
private val getChaptersByMangaId: GetChaptersByMangaId = Injekt.get(),
private val syncChaptersWithSource: SyncChaptersWithSource = Injekt.get(),
private val updateChapter: UpdateChapter = Injekt.get(),
private val getBookmarks: GetBookmarks = Injekt.get(),
private val setBookmark: SetBookmark = Injekt.get(),
private val deleteBookmark: DeleteBookmark = Injekt.get(),
private val getCategories: GetCategories = Injekt.get(),
private val setMangaCategories: SetMangaCategories = Injekt.get(),
private val getTracks: GetTracks = Injekt.get(),
@ -213,6 +221,7 @@ internal class MigrateDialogScreenModel(
val migrateCategories = MigrationFlags.hasCategories(flags)
val migrateCustomCover = MigrationFlags.hasCustomCover(flags)
val deleteDownloaded = MigrationFlags.hasDeleteDownloaded(flags)
val migrateBookmarks = MigrationFlags.hasBookmarks(flags)
try {
syncChaptersWithSource.await(sourceChapters, newManga, newSource)
@ -229,7 +238,17 @@ internal class MigrateDialogScreenModel(
.filter { it.read }
.maxOfOrNull { it.chapterNumber }
val updatedMangaChapters = mangaChapters.map { mangaChapter ->
val bookmarksByChapterId =
if (migrateBookmarks) {
getBookmarks.await(oldManga.id).groupBy { it.chapterId }
} else {
null
}
val updatedMangaChapters = mutableListOf<Chapter>()
val addedBookmarks = mutableListOf<Bookmark>()
mangaChapters.forEach { mangaChapter ->
var updatedChapter = mangaChapter
if (updatedChapter.isRecognizedNumber) {
val prevChapter = prevMangaChapters
@ -238,8 +257,27 @@ internal class MigrateDialogScreenModel(
if (prevChapter != null) {
updatedChapter = updatedChapter.copy(
dateFetch = prevChapter.dateFetch,
bookmark = prevChapter.bookmark,
)
if (migrateBookmarks) {
// Don't unbookmark anything, but copy existing bookmarks to updated.
if (prevChapter.bookmark) {
updatedChapter = updatedChapter.copy(bookmark = true)
}
bookmarksByChapterId
?.get(prevChapter.id)
?.let { bookmarks ->
addedBookmarks.addAll(
bookmarks.map { bookmark ->
bookmark.copy(
mangaId = newManga.id,
chapterId = updatedChapter.id,
)
},
)
}
}
}
if (maxChapterRead != null && updatedChapter.chapterNumber <= maxChapterRead) {
@ -247,11 +285,23 @@ internal class MigrateDialogScreenModel(
}
}
updatedChapter
updatedMangaChapters.add(updatedChapter)
}
val chapterUpdates = updatedMangaChapters.map { it.toChapterUpdate() }
updateChapter.awaitAll(chapterUpdates)
// Update bookmarks
if (migrateBookmarks) {
// Delete first, then insert/update in case manga is migrated into itself.
if (replace && bookmarksByChapterId?.isNotEmpty() == true) {
deleteBookmark.awaitAllByMangaId(oldManga.id, updateChapters = false)
}
if (addedBookmarks.isNotEmpty()) {
setBookmark.awaitAll(addedBookmarks, updateChapters = false)
}
}
}
// Update categories

View File

@ -57,13 +57,13 @@ import tachiyomi.core.util.lang.launchNonCancellable
import tachiyomi.core.util.lang.withIOContext
import tachiyomi.core.util.lang.withUIContext
import tachiyomi.core.util.system.logcat
import tachiyomi.domain.bookmark.interactor.DeleteBookmark
import tachiyomi.domain.bookmark.interactor.SetBookmark
import tachiyomi.domain.category.interactor.GetCategories
import tachiyomi.domain.category.interactor.SetMangaCategories
import tachiyomi.domain.category.model.Category
import tachiyomi.domain.chapter.interactor.SetMangaDefaultChapterFlags
import tachiyomi.domain.chapter.interactor.UpdateChapter
import tachiyomi.domain.chapter.model.Chapter
import tachiyomi.domain.chapter.model.ChapterUpdate
import tachiyomi.domain.chapter.model.NoChaptersException
import tachiyomi.domain.chapter.service.calculateChapterGap
import tachiyomi.domain.chapter.service.getChapterSort
@ -101,7 +101,6 @@ class MangaScreenModel(
private val setMangaChapterFlags: SetMangaChapterFlags = Injekt.get(),
private val setMangaDefaultChapterFlags: SetMangaDefaultChapterFlags = Injekt.get(),
private val setReadStatus: SetReadStatus = Injekt.get(),
private val updateChapter: UpdateChapter = Injekt.get(),
private val updateManga: UpdateManga = Injekt.get(),
private val syncChaptersWithSource: SyncChaptersWithSource = Injekt.get(),
private val getCategories: GetCategories = Injekt.get(),
@ -109,6 +108,8 @@ class MangaScreenModel(
private val addTracks: AddTracks = Injekt.get(),
private val setMangaCategories: SetMangaCategories = Injekt.get(),
private val mangaRepository: MangaRepository = Injekt.get(),
private val setBookmark: SetBookmark = Injekt.get(),
private val deleteBookmark: DeleteBookmark = Injekt.get(),
val snackbarHostState: SnackbarHostState = SnackbarHostState(),
) : StateScreenModel<MangaScreenModel.State>(State.Loading) {
@ -737,11 +738,14 @@ class MangaScreenModel(
* @param chapters the list of chapters to bookmark.
*/
fun bookmarkChapters(chapters: List<Chapter>, bookmarked: Boolean) {
val toUpdate = chapters.filterNot { it.bookmark == bookmarked }
screenModelScope.launchIO {
chapters
.filterNot { it.bookmark == bookmarked }
.map { ChapterUpdate(id = it.id, bookmark = bookmarked) }
.let { updateChapter.awaitAll(it) }
if (bookmarked) {
setBookmark.awaitByChapters(toUpdate)
} else {
deleteBookmark.awaitByChapters(toUpdate)
}
}
toggleAllSelection(false)
}

View File

@ -22,6 +22,7 @@ import eu.kanade.presentation.more.MoreScreen
import eu.kanade.presentation.util.Tab
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.ui.bookmarks.BookmarksTopScreen
import eu.kanade.tachiyomi.ui.category.CategoryScreen
import eu.kanade.tachiyomi.ui.download.DownloadQueueScreen
import eu.kanade.tachiyomi.ui.setting.SettingsScreen
@ -72,6 +73,7 @@ object MoreTab : Tab {
onClickDownloadQueue = { navigator.push(DownloadQueueScreen) },
onClickCategories = { navigator.push(CategoryScreen()) },
onClickStats = { navigator.push(StatsScreen()) },
onClickBookmarks = { navigator.push(BookmarksTopScreen()) },
onClickDataAndStorage = { navigator.push(SettingsScreen(SettingsScreen.Destination.DataAndStorage)) },
onClickSettings = { navigator.push(SettingsScreen()) },
onClickAbout = { navigator.push(SettingsScreen(SettingsScreen.Destination.About)) },

View File

@ -96,10 +96,16 @@ import uy.kohesive.injekt.api.get
class ReaderActivity : BaseActivity() {
companion object {
fun newIntent(context: Context, mangaId: Long?, chapterId: Long?): Intent {
fun newIntent(
context: Context,
mangaId: Long?,
chapterId: Long?,
pageIndex: Int? = null,
): Intent {
return Intent(context, ReaderActivity::class.java).apply {
putExtra("manga", mangaId)
putExtra("chapter", chapterId)
pageIndex?.let { page -> putExtra("page", page) }
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
}
}
@ -150,10 +156,11 @@ class ReaderActivity : BaseActivity() {
finish()
return
}
val page = intent.extras?.getInt("page", -1) ?: -1
NotificationReceiver.dismissNotification(this, manga.hashCode(), Notifications.ID_NEW_CHAPTERS)
lifecycleScope.launchNonCancellable {
val initResult = viewModel.init(manga, chapter)
val initResult = viewModel.init(manga, chapter, page)
if (!initResult.getOrDefault(false)) {
val exception = initResult.exceptionOrNull() ?: IllegalStateException("Unknown err")
withUIContext {
@ -448,6 +455,9 @@ class ReaderActivity : BaseActivity() {
onSetAsCover = viewModel::setAsCover,
onShare = viewModel::shareImage,
onSave = viewModel::saveImage,
onBookmarkPage = viewModel::updateCurrentPageBookmark,
onUnbookmarkPage = viewModel::deleteCurrentPageBookmark,
getPageBookmark = viewModel::getPageBookmark,
)
}
null -> {}

View File

@ -60,6 +60,10 @@ import tachiyomi.core.util.lang.launchNonCancellable
import tachiyomi.core.util.lang.withIOContext
import tachiyomi.core.util.lang.withUIContext
import tachiyomi.core.util.system.logcat
import tachiyomi.domain.bookmark.interactor.DeleteBookmark
import tachiyomi.domain.bookmark.interactor.GetBookmark
import tachiyomi.domain.bookmark.interactor.SetBookmark
import tachiyomi.domain.bookmark.model.Bookmark
import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
import tachiyomi.domain.chapter.interactor.UpdateChapter
import tachiyomi.domain.chapter.model.ChapterUpdate
@ -97,6 +101,9 @@ class ReaderViewModel @JvmOverloads constructor(
private val getNextChapters: GetNextChapters = Injekt.get(),
private val upsertHistory: UpsertHistory = Injekt.get(),
private val updateChapter: UpdateChapter = Injekt.get(),
private val setBookmark: SetBookmark = Injekt.get(),
private val deleteBookmark: DeleteBookmark = Injekt.get(),
private val getBookmark: GetBookmark = Injekt.get(),
private val setMangaViewerFlags: SetMangaViewerFlags = Injekt.get(),
) : ViewModel() {
@ -255,10 +262,15 @@ class ReaderViewModel @JvmOverloads constructor(
}
/**
* Initializes this presenter with the given [mangaId] and [initialChapterId]. This method will
* fetch the manga from the database and initialize the initial chapter.
* Initializes this presenter with the given [mangaId], [initialChapterId] and [pageIndex].
* [pageIndex] is optional, if provided, reader will open that page.
* This method will fetch the manga from the database and initialize the initial chapter.
*/
suspend fun init(mangaId: Long, initialChapterId: Long): Result<Boolean> {
suspend fun init(
mangaId: Long,
initialChapterId: Long,
pageIndex: Int? = null,
): Result<Boolean> {
if (!needsInit()) return Result.success(true)
return withIOContext {
try {
@ -271,7 +283,15 @@ class ReaderViewModel @JvmOverloads constructor(
val source = sourceManager.getOrStub(manga.source)
loader = ChapterLoader(context, downloadManager, downloadProvider, manga, source)
loadChapter(loader!!, chapterList.first { chapterId == it.chapter.id })
val chapter = chapterList.first { chapterId == it.chapter.id }
// TODO: this is hacky solution, what is proper?
// Don't update requestedPage if it's already >= 0, as initialized as -1.
// But maybe it's sometimes not -1 but expected to be updated?
if (pageIndex != null && pageIndex >= 0) {
chapter.requestedPage = pageIndex
chapter.chapter.last_page_read = pageIndex
}
loadChapter(loader!!, chapter)
Result.success(true)
} else {
// Unlikely but okay
@ -611,12 +631,11 @@ class ReaderViewModel @JvmOverloads constructor(
chapter.bookmark = bookmarked
viewModelScope.launchNonCancellable {
updateChapter.await(
ChapterUpdate(
id = chapter.id!!.toLong(),
bookmark = bookmarked,
),
)
if (bookmarked) {
setBookmark.await(chapter.manga_id!!.toLong(), chapter.id!!.toLong(), null, null)
} else {
deleteBookmark.await(chapter.manga_id!!.toLong(), chapter.id!!.toLong(), null)
}
}
mutableState.update {
@ -626,6 +645,40 @@ class ReaderViewModel @JvmOverloads constructor(
}
}
/**
* Adds or updates a page bookmark for a page selected in page-actions dialog.
*/
fun updateCurrentPageBookmark(bookmarkNote: String) {
val manga = manga ?: return
val page = (state.value.dialog as? Dialog.PageActions)?.page ?: return
viewModelScope.launchNonCancellable {
setBookmark.await(manga.id, chapterId, page.index, bookmarkNote)
}
}
/**
* Deletes the bookmark for the currently selected page.
*/
fun deleteCurrentPageBookmark() {
val manga = manga ?: return
val page = (state.value.dialog as? Dialog.PageActions)?.page ?: return
viewModelScope.launchNonCancellable {
deleteBookmark.await(manga.id, chapterId, page.index)
}
}
/**
* Tries to retrieve the bookmark for the currently selected page.
* @return The bookmark for the current page, or null if it doesn't exist.
*/
fun getPageBookmark(): Bookmark? {
val manga = manga ?: return null
val page = (state.value.dialog as? Dialog.PageActions)?.page ?: return null
return runBlocking { getBookmark.await(manga.id, chapterId, page.index) }
}
/**
* Returns the viewer position used by this manga or the default one.
*/

View File

@ -35,9 +35,11 @@ import logcat.LogPriority
import tachiyomi.core.util.lang.launchIO
import tachiyomi.core.util.lang.launchNonCancellable
import tachiyomi.core.util.system.logcat
import tachiyomi.domain.bookmark.interactor.DeleteBookmark
import tachiyomi.domain.bookmark.interactor.SetBookmark
import tachiyomi.domain.bookmark.model.Bookmark
import tachiyomi.domain.bookmark.model.BookmarkDelete
import tachiyomi.domain.chapter.interactor.GetChapter
import tachiyomi.domain.chapter.interactor.UpdateChapter
import tachiyomi.domain.chapter.model.ChapterUpdate
import tachiyomi.domain.library.service.LibraryPreferences
import tachiyomi.domain.manga.interactor.GetManga
import tachiyomi.domain.source.service.SourceManager
@ -52,12 +54,13 @@ class UpdatesScreenModel(
private val sourceManager: SourceManager = Injekt.get(),
private val downloadManager: DownloadManager = Injekt.get(),
private val downloadCache: DownloadCache = Injekt.get(),
private val updateChapter: UpdateChapter = Injekt.get(),
private val setReadStatus: SetReadStatus = Injekt.get(),
private val getUpdates: GetUpdates = Injekt.get(),
private val getManga: GetManga = Injekt.get(),
private val getChapter: GetChapter = Injekt.get(),
private val libraryPreferences: LibraryPreferences = Injekt.get(),
private val setBookmark: SetBookmark = Injekt.get(),
private val deleteBookmark: DeleteBookmark = Injekt.get(),
val snackbarHostState: SnackbarHostState = SnackbarHostState(),
) : StateScreenModel<UpdatesScreenModel.State>(State()) {
@ -213,11 +216,21 @@ class UpdatesScreenModel(
* @param updates the list of chapters to bookmark.
*/
fun bookmarkUpdates(updates: List<UpdatesItem>, bookmark: Boolean) {
val toUpdate = updates.filterNot { it.update.bookmark == bookmark }
screenModelScope.launchIO {
updates
.filterNot { it.update.bookmark == bookmark }
.map { ChapterUpdate(id = it.update.chapterId, bookmark = bookmark) }
.let { updateChapter.awaitAll(it) }
if (bookmark) {
toUpdate
.map {
Bookmark.create().copy(mangaId = it.update.mangaId, chapterId = it.update.chapterId)
}
.let { setBookmark.awaitAll(it) }
} else {
toUpdate
.map {
BookmarkDelete(mangaId = it.update.mangaId, chapterId = it.update.chapterId)
}
.let { deleteBookmark.awaitAll(it) }
}
}
toggleAllSelection(false)
}

View File

@ -0,0 +1,94 @@
package tachiyomi.data.bookmark
import tachiyomi.domain.bookmark.model.Bookmark
import tachiyomi.domain.bookmark.model.BookmarkWithChapterNumber
import tachiyomi.domain.bookmark.model.BookmarkedPage
import tachiyomi.domain.bookmark.model.MangaWithBookmarks
import tachiyomi.domain.manga.model.MangaCover
object BookmarkMapper {
fun mapBookmark(
id: Long,
mangaId: Long,
chapterId: Long,
pageIndex: Long?,
note: String?,
lastModifiedAt: Long,
): Bookmark = Bookmark(
id = id,
mangaId = mangaId,
chapterId = chapterId,
pageIndex = pageIndex?.toInt(),
note = note,
lastModifiedAt = lastModifiedAt * 1000L,
)
fun mapMangaWithBookmarks(
mangaId: Long,
mangaTitle: String,
mangaThumbnailUrl: String?,
mangaSource: Long,
isMangaFavorite: Boolean,
mangaCoverLastModified: Long,
numberOfBookmarks: Long,
bookmarkLastModified: Long?,
): MangaWithBookmarks = MangaWithBookmarks(
mangaId = mangaId,
mangaTitle = mangaTitle,
numberOfBookmarks = numberOfBookmarks,
bookmarkLastModified = (bookmarkLastModified ?: 0L) * 1000L,
coverData = MangaCover(
mangaId = mangaId,
sourceId = mangaSource,
isMangaFavorite = isMangaFavorite,
url = mangaThumbnailUrl,
lastModified = mangaCoverLastModified,
),
)
fun mapBookmarkedPage(
bookmarkId: Long,
mangaId: Long,
chapterId: Long,
pageIndex: Long?,
mangaTitle: String,
mangaThumbnailUrl: String?,
mangaSource: Long,
isMangaFavorite: Boolean,
mangaCoverLastModified: Long,
chapterNumber: Double,
chapterName: String,
note: String?,
lastModifiedAt: Long,
): BookmarkedPage = BookmarkedPage(
bookmarkId = bookmarkId,
mangaId = mangaId,
chapterId = chapterId,
pageIndex = pageIndex?.toInt(),
mangaTitle = mangaTitle,
chapterNumber = chapterNumber,
chapterName = chapterName,
note = note,
lastModifiedAt = lastModifiedAt * 1000L,
coverData = MangaCover(
mangaId = mangaId,
sourceId = mangaSource,
isMangaFavorite = isMangaFavorite,
url = mangaThumbnailUrl,
lastModified = mangaCoverLastModified,
),
)
fun mapBookmarkWithChapterNumber(
@Suppress("UNUSED_PARAMETER") chapterUrl: String,
chapterNumber: Double,
pageIndex: Long?,
note: String?,
lastModifiedAt: Long,
): BookmarkWithChapterNumber = BookmarkWithChapterNumber(
chapterNumber = chapterNumber,
pageIndex = pageIndex?.toInt(),
note = note,
lastModifiedAt = lastModifiedAt * 1000L,
)
}

View File

@ -0,0 +1,123 @@
package tachiyomi.data.bookmark
import tachiyomi.data.Database
import tachiyomi.data.DatabaseHandler
import tachiyomi.domain.bookmark.model.Bookmark
import tachiyomi.domain.bookmark.model.BookmarkDelete
import tachiyomi.domain.bookmark.model.BookmarkUpdate
import tachiyomi.domain.bookmark.model.BookmarkWithChapterNumber
import tachiyomi.domain.bookmark.model.BookmarkedPage
import tachiyomi.domain.bookmark.model.MangaWithBookmarks
import tachiyomi.domain.bookmark.repository.BookmarkRepository
class BookmarkRepositoryImpl(
private val handler: DatabaseHandler,
) : BookmarkRepository {
override suspend fun get(id: Long): Bookmark? {
return handler.awaitOneOrNull { bookmarksQueries.getBookmarkById(id, BookmarkMapper::mapBookmark) }
}
override suspend fun get(mangaId: Long, chapterId: Long, pageIndex: Int?): Bookmark? {
return handler.awaitOneOrNull {
bookmarksQueries.getBookmarkByMangaAndChapterPage(
mangaId,
chapterId,
pageIndex?.toLong(),
BookmarkMapper::mapBookmark,
)
}
}
override suspend fun getAllByMangaId(mangaId: Long): List<Bookmark> {
return handler.awaitList {
bookmarksQueries.getAllByMangaId(mangaId, BookmarkMapper::mapBookmark)
}
}
override suspend fun getMangaWithBookmarks(): List<MangaWithBookmarks> {
return handler.awaitList {
mangaWithBookmarksViewQueries.mangaWithBookmarks(BookmarkMapper::mapMangaWithBookmarks)
}
}
override suspend fun getBookmarkedPagesByMangaId(mangaId: Long): List<BookmarkedPage> {
return handler.awaitList {
bookmarksViewQueries.getBookmarksByManga(mangaId, BookmarkMapper::mapBookmarkedPage)
}
}
override suspend fun getWithChapterNumberByMangaId(mangaId: Long): List<BookmarkWithChapterNumber> {
return handler.awaitList {
bookmarksQueries.getWithChapterInfoByMangaId(mangaId, BookmarkMapper::mapBookmarkWithChapterNumber)
}
}
override suspend fun insert(bookmark: Bookmark) {
handler.await {
bookmarksQueries.insert(
mangaId = bookmark.mangaId,
chapterId = bookmark.chapterId,
pageIndex = bookmark.pageIndex?.toLong(),
note = bookmark.note,
// Seconds in DB.
lastModifiedAt = bookmark.lastModifiedAt / 1000,
)
}
}
override suspend fun updatePartial(update: BookmarkUpdate) {
handler.await {
bookmarksQueries.update(
bookmarkId = update.id,
note = update.note,
)
}
}
override suspend fun insertOrReplaceAll(
idsToDelete: List<Long>,
bookmarksToAdd: List<Bookmark>,
) {
handler.await(inTransaction = true) {
idsToDelete.forEach { bookmarkId -> bookmarksQueries.delete(bookmarkId) }
bookmarksToAdd.forEach { bookmark ->
bookmarksQueries.insert(
mangaId = bookmark.mangaId,
chapterId = bookmark.chapterId,
pageIndex = bookmark.pageIndex?.toLong(),
note = bookmark.note,
lastModifiedAt = bookmark.lastModifiedAt / 1000,
)
}
}
}
override suspend fun delete(bookmarkId: Long) {
handler.await { bookmarksQueries.delete(bookmarkId = bookmarkId) }
}
override suspend fun delete(delete: BookmarkDelete) {
handler.await { deleteBlocking(delete) }
}
override suspend fun deleteAll(delete: List<BookmarkDelete>) {
handler.await(inTransaction = true) {
for (bookmark in delete) {
deleteBlocking(bookmark)
}
}
}
override suspend fun deleteAllByMangaId(mangaId: Long) {
handler.await { bookmarksQueries.deleteAllByMangaId(mangaId = mangaId) }
}
private fun Database.deleteBlocking(delete: BookmarkDelete) {
bookmarksQueries.deleteByMangaAndChapterPage(
mangaId = delete.mangaId,
chapterId = delete.chapterId,
pageIndex = delete.pageIndex?.toLong(),
)
}
}

View File

@ -0,0 +1,77 @@
CREATE TABLE bookmarks(
_id INTEGER NOT NULL PRIMARY KEY,
manga_id INTEGER NOT NULL,
chapter_id INTEGER NOT NULL,
page_index INTEGER,
note TEXT,
last_modified_at INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY(manga_id) REFERENCES mangas (_id)
ON DELETE CASCADE,
FOREIGN KEY(chapter_id) REFERENCES chapters (_id)
ON DELETE CASCADE
);
-- Only single bookmark per page is allowed.
CREATE UNIQUE INDEX bookmark_manga_id_chapter_id_page_index
ON bookmarks(manga_id, chapter_id, page_index);
-- For chapters FK with DELETE CASCADE.
CREATE INDEX bookmark_chapter_id ON bookmarks(chapter_id);
CREATE TRIGGER update_last_modified_at_bookmarks
AFTER UPDATE ON bookmarks
FOR EACH ROW
BEGIN
UPDATE bookmarks
SET last_modified_at = strftime('%s', 'now')
WHERE _id = new._id;
END;
-- Methods
getBookmarkById:
SELECT *
FROM bookmarks
WHERE _id = :id;
getAllByMangaId:
SELECT *
FROM bookmarks
WHERE manga_id = :mangaId;
getWithChapterInfoByMangaId:
SELECT
chapters.url,
chapters.chapter_number,
bookmarks.page_index,
bookmarks.note,
bookmarks.last_modified_at
FROM bookmarks JOIN chapters ON chapters._id = bookmarks.chapter_id
WHERE bookmarks.manga_id = :mangaId;
getBookmarkByMangaAndChapterPage:
SELECT *
FROM bookmarks
WHERE manga_id = :mangaId AND chapter_id = :chapterId AND page_index = :pageIndex;
insert:
INSERT INTO bookmarks(manga_id, chapter_id, page_index, note, last_modified_at)
VALUES (:mangaId, :chapterId, :pageIndex, :note, :lastModifiedAt);
update:
UPDATE bookmarks SET
note = coalesce(:note, note)
WHERE _id = :bookmarkId;
delete:
DELETE FROM bookmarks
WHERE _id = :bookmarkId;
deleteByMangaAndChapterPage:
DELETE FROM bookmarks
WHERE manga_id = :mangaId AND chapter_id = :chapterId AND page_index = :pageIndex;
deleteAllByMangaId:
DELETE FROM bookmarks
WHERE manga_id = :mangaId;

View File

@ -0,0 +1,80 @@
-- Tables and views for page bookmars with notes.
CREATE TABLE IF NOT EXISTS bookmarks(
_id INTEGER NOT NULL PRIMARY KEY,
manga_id INTEGER NOT NULL,
chapter_id INTEGER NOT NULL,
page_index INTEGER,
note TEXT,
last_modified_at INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY(manga_id) REFERENCES mangas (_id)
ON DELETE CASCADE,
FOREIGN KEY(chapter_id) REFERENCES chapters (_id)
ON DELETE CASCADE
);
-- Only single bookmark per page is allowed.
CREATE UNIQUE INDEX IF NOT EXISTS bookmark_manga_id_chapter_id_page_index
ON bookmarks(manga_id, chapter_id, page_index);
-- For chapters FK with DELETE CASCADE.
CREATE INDEX IF NOT EXISTS bookmark_chapter_id ON bookmarks(chapter_id);
CREATE TRIGGER IF NOT EXISTS update_last_modified_at_bookmarks
AFTER UPDATE ON bookmarks
FOR EACH ROW
BEGIN
UPDATE bookmarks
SET last_modified_at = strftime('%s', 'now')
WHERE _id = new._id;
END;
DROP VIEW IF EXISTS bookmarksView;
CREATE VIEW bookmarksView AS
SELECT
bookmarks._id AS bookmarkId,
mangas._id AS mangaId,
chapters._id AS chapterId,
bookmarks.page_index AS pageIndex,
mangas.title AS mangaTitle,
mangas.thumbnail_url AS mangaThumbnailUrl,
mangas.source AS mangaSource,
mangas.favorite AS isMangaFavorite,
mangas.cover_last_modified AS mangaCoverLastModified,
chapters.chapter_number AS chapterNumber,
chapters.name AS chapterName,
bookmarks.note,
bookmarks.last_modified_at AS lastModifiedAt
FROM bookmarks
JOIN mangas ON mangas._id = bookmarks.manga_id
JOIN chapters ON chapters._id = bookmarks.chapter_id
ORDER BY mangaTitle, chapterNumber ASC, pageIndex ASC;
DROP VIEW IF EXISTS mangaWithBookmarksView;
CREATE VIEW mangaWithBookmarksView AS
SELECT
mangas._id AS mangaId,
mangas.title AS mangaTitle,
mangas.thumbnail_url AS mangaThumbnailUrl,
mangas.source AS mangaSource,
mangas.favorite AS isMangaFavorite,
mangas.cover_last_modified AS mangaCoverLastModified,
COUNT(*) AS numberOfBookmarks,
MAX(bookmarks.last_modified_at) AS bookmarkLastModified
FROM bookmarks
JOIN mangas ON mangas._id = bookmarks.manga_id
WHERE mangas.favorite = 1
GROUP BY mangas._id
ORDER BY numberOfBookmarks DESC;
-- One-time insert new records for all bookmarked chapters.
INSERT INTO bookmarks (manga_id, chapter_id, page_index, note, last_modified_at)
SELECT manga_id, _id, NULL, NULL, last_modified_at
FROM chapters
WHERE bookmark = 1 AND NOT EXISTS (SELECT * FROM bookmarks WHERE bookmarks.page_index IS NULL);

View File

@ -0,0 +1,29 @@
CREATE VIEW bookmarksView AS
SELECT
bookmarks._id AS bookmarkId,
mangas._id AS mangaId,
chapters._id AS chapterId,
bookmarks.page_index AS pageIndex,
mangas.title AS mangaTitle,
mangas.thumbnail_url AS mangaThumbnailUrl,
mangas.source AS mangaSource,
mangas.favorite AS isMangaFavorite,
mangas.cover_last_modified AS mangaCoverLastModified,
chapters.chapter_number AS chapterNumber,
chapters.name AS chapterName,
bookmarks.note,
bookmarks.last_modified_at AS lastModifiedAt
FROM bookmarks
JOIN mangas ON mangas._id = bookmarks.manga_id
JOIN chapters ON chapters._id = bookmarks.chapter_id
ORDER BY mangaTitle, chapterNumber ASC, pageIndex ASC;
getBookmarksByManga:
SELECT *
FROM bookmarksView
WHERE mangaId = :mangaId;
getBookmarksByNotePattern:
SELECT *
FROM bookmarksView
WHERE note LIKE :notePattern;

View File

@ -0,0 +1,19 @@
CREATE VIEW mangaWithBookmarksView AS
SELECT
mangas._id AS mangaId,
mangas.title AS mangaTitle,
mangas.thumbnail_url AS mangaThumbnailUrl,
mangas.source AS mangaSource,
mangas.favorite AS isMangaFavorite,
mangas.cover_last_modified AS mangaCoverLastModified,
COUNT(*) AS numberOfBookmarks,
MAX(bookmarks.last_modified_at) AS bookmarkLastModified
FROM bookmarks
JOIN mangas ON mangas._id = bookmarks.manga_id
WHERE mangas.favorite = 1
GROUP BY mangas._id
ORDER BY numberOfBookmarks DESC;
mangaWithBookmarks:
SELECT *
FROM mangaWithBookmarksView;

View File

@ -0,0 +1,89 @@
package tachiyomi.domain.bookmark.interactor
import logcat.LogPriority
import tachiyomi.core.util.lang.withNonCancellableContext
import tachiyomi.core.util.system.logcat
import tachiyomi.domain.bookmark.model.BookmarkDelete
import tachiyomi.domain.bookmark.repository.BookmarkRepository
import tachiyomi.domain.chapter.model.Chapter
import tachiyomi.domain.chapter.model.ChapterUpdate
import tachiyomi.domain.chapter.repository.ChapterRepository
class DeleteBookmark(
private val bookmarkRepository: BookmarkRepository,
private val chapterRepository: ChapterRepository,
) {
suspend fun await(mangaId: Long, chapterId: Long, pageIndex: Int?) = withNonCancellableContext {
try {
if (pageIndex == null) {
chapterRepository.update(ChapterUpdate.bookmarkUpdate(chapterId, false))
}
bookmarkRepository.delete(
BookmarkDelete(
mangaId = mangaId,
chapterId = chapterId,
pageIndex = pageIndex,
),
)
Result.Success
} catch (e: Exception) {
logcat(LogPriority.ERROR, e)
return@withNonCancellableContext Result.InternalError(e)
}
}
suspend fun awaitAll(delete: List<BookmarkDelete>) = withNonCancellableContext {
try {
// Not in transaction, but chapters first
// to not to affect existing chapter-level bookmarks.
val chapters = delete
.mapNotNull {
when (it.pageIndex) {
null -> ChapterUpdate.bookmarkUpdate(it.chapterId, false)
else -> null
}
}
if (chapters.isNotEmpty()) {
chapterRepository.updateAll(chapters)
}
bookmarkRepository.deleteAll(delete)
Result.Success
} catch (e: Exception) {
logcat(LogPriority.ERROR, e)
return@withNonCancellableContext Result.InternalError(e)
}
}
suspend fun awaitByChapters(chaptersToUnbookmark: List<Chapter>) {
return chaptersToUnbookmark
.map { BookmarkDelete(mangaId = it.mangaId, chapterId = it.id) }
.let { awaitAll(it) }
}
suspend fun awaitAllByMangaId(mangaId: Long, updateChapters: Boolean = true) =
withNonCancellableContext {
try {
if (updateChapters) {
val unbookmarkChapters =
chapterRepository.getBookmarkedChaptersByMangaId(mangaId)
.map { ChapterUpdate.bookmarkUpdate(id = it.id, bookmark = false) }
if (unbookmarkChapters.isNotEmpty()) {
chapterRepository.updateAll(unbookmarkChapters)
}
}
bookmarkRepository.deleteAllByMangaId(mangaId)
Result.Success
} catch (e: Exception) {
logcat(LogPriority.ERROR, e)
return@withNonCancellableContext Result.InternalError(e)
}
}
sealed class Result {
object Success : Result()
data class InternalError(val error: Throwable) : Result()
}
}

View File

@ -0,0 +1,12 @@
package tachiyomi.domain.bookmark.interactor
import tachiyomi.domain.bookmark.model.Bookmark
import tachiyomi.domain.bookmark.repository.BookmarkRepository
class GetBookmark(
private val bookmarkRepository: BookmarkRepository,
) {
suspend fun await(mangaId: Long, chapterId: Long, pageIndex: Int): Bookmark? {
return bookmarkRepository.get(mangaId, chapterId, pageIndex)
}
}

View File

@ -0,0 +1,9 @@
package tachiyomi.domain.bookmark.interactor
import tachiyomi.domain.bookmark.repository.BookmarkRepository
class GetBookmarkedMangas(
private val bookmarkRepository: BookmarkRepository,
) {
suspend fun await() = run { bookmarkRepository.getMangaWithBookmarks() }
}

View File

@ -0,0 +1,10 @@
package tachiyomi.domain.bookmark.interactor
import tachiyomi.domain.bookmark.repository.BookmarkRepository
class GetBookmarkedPages(
private val bookmarkRepository: BookmarkRepository,
) {
suspend fun await(mangaId: Long) =
run { bookmarkRepository.getBookmarkedPagesByMangaId(mangaId) }
}

View File

@ -0,0 +1,13 @@
package tachiyomi.domain.bookmark.interactor
import tachiyomi.domain.bookmark.repository.BookmarkRepository
class GetBookmarks(
private val bookmarkRepository: BookmarkRepository,
) {
suspend fun await(mangaId: Long) =
run { bookmarkRepository.getAllByMangaId(mangaId) }
suspend fun awaitWithChapterNumbers(mangaId: Long) =
run { bookmarkRepository.getWithChapterNumberByMangaId(mangaId) }
}

View File

@ -0,0 +1,131 @@
package tachiyomi.domain.bookmark.interactor
import logcat.LogPriority
import tachiyomi.core.util.lang.withNonCancellableContext
import tachiyomi.core.util.system.logcat
import tachiyomi.domain.bookmark.model.Bookmark
import tachiyomi.domain.bookmark.model.BookmarkUpdate
import tachiyomi.domain.bookmark.repository.BookmarkRepository
import tachiyomi.domain.chapter.model.Chapter
import tachiyomi.domain.chapter.model.ChapterUpdate
import tachiyomi.domain.chapter.repository.ChapterRepository
import java.util.Date
class SetBookmark(
private val bookmarkRepository: BookmarkRepository,
private val chapterRepository: ChapterRepository,
) {
/**
* Inserts a new bookmark or updates the note of an existing bookmark if one already exists
* for the given manga, chapter, and page index.
* Ensures that at most one bookmark per page exists.
* Updates correspondent chapter bookmark field.
*
* @param pageIndex when null, bookmark is considered as chapter bookmark.
* @param note Optional text note to be associated with the bookmark.
*/
suspend fun await(
mangaId: Long,
chapterId: Long,
pageIndex: Int?,
note: String?,
lastModifiedAt: Long? = null,
): Result =
withNonCancellableContext {
try {
if (pageIndex == null) {
chapterRepository.update(ChapterUpdate.bookmarkUpdate(chapterId, true))
}
val existingBookmark = bookmarkRepository.get(mangaId, chapterId, pageIndex)
if (existingBookmark != null) {
bookmarkRepository.updatePartial(
BookmarkUpdate(
id = existingBookmark.id,
note = note,
),
)
} else {
val newBookmark =
Bookmark.create()
.copy(
mangaId = mangaId,
chapterId = chapterId,
pageIndex = pageIndex,
note = note,
lastModifiedAt = lastModifiedAt ?: Date().time,
)
bookmarkRepository.insert(newBookmark)
}
Result.Success
} catch (e: Exception) {
logcat(LogPriority.ERROR, e)
Result.InternalError(e)
}
}
/**
* Updates or inserts new bookmarks
* By default updates correspondent chapter bookmark field.
*/
suspend fun awaitAll(addedBookmarks: List<Bookmark>, updateChapters: Boolean = true): Result {
return try {
if (updateChapters) {
val chapters = addedBookmarks
.mapNotNull {
when (it.pageIndex) {
null -> ChapterUpdate.bookmarkUpdate(it.chapterId, true)
else -> null
}
}
if (chapters.isNotEmpty()) {
chapterRepository.updateAll(chapters)
}
}
// Check what should be removed to avoid duplication and when update can be skipped.
val oldBookmarks = addedBookmarks
.map { it.mangaId }
.distinct()
.flatMap { mangaId ->
bookmarkRepository.getAllByMangaId(mangaId)
}
.associate { Triple(it.mangaId, it.chapterId, it.pageIndex) to it.id }
val idsToDelete = mutableListOf<Long>()
val toAdd = mutableListOf<Bookmark>()
addedBookmarks.forEach { bookmark ->
val oldBookmarkId =
oldBookmarks[Triple(bookmark.mangaId, bookmark.chapterId, bookmark.pageIndex)]
// Don't delete & insert if there's already an old bookmark and new has no note set.
// However, this prevents merging or backup restores to remove existing notes.
// If needed, then add `bookmark.pageIndex == null` to only check for chapter bookmarks.
val isUpdateNeeded = oldBookmarkId == null || bookmark.note?.isNotBlank() ?: false
if (isUpdateNeeded) {
oldBookmarkId?.let { idsToDelete.add(it) }
toAdd.add(bookmark)
}
}
if (toAdd.isNotEmpty()) {
bookmarkRepository.insertOrReplaceAll(idsToDelete, toAdd)
}
Result.Success
} catch (e: Exception) {
logcat(LogPriority.ERROR, e)
Result.InternalError(e)
}
}
suspend fun awaitByChapters(chaptersToBookmark: List<Chapter>): Result {
return chaptersToBookmark
.map { Bookmark.create().copy(mangaId = it.mangaId, chapterId = it.id) }
.let { awaitAll(it) }
}
sealed class Result {
object Success : Result()
data class InternalError(val error: Throwable) : Result()
}
}

View File

@ -0,0 +1,26 @@
package tachiyomi.domain.bookmark.model
import java.util.Date
data class Bookmark(
val id: Long,
val mangaId: Long,
val chapterId: Long,
/**
* null is for chapter-level bookmark. Currently only non-null values are supported.
*/
val pageIndex: Int?,
val note: String?,
val lastModifiedAt: Long,
) {
companion object {
fun create() = Bookmark(
id = -1,
mangaId = -1,
chapterId = -1,
pageIndex = null,
note = null,
lastModifiedAt = Date().time,
)
}
}

View File

@ -0,0 +1,7 @@
package tachiyomi.domain.bookmark.model
data class BookmarkDelete(
val mangaId: Long,
val chapterId: Long,
val pageIndex: Int? = null,
)

View File

@ -0,0 +1,6 @@
package tachiyomi.domain.bookmark.model
data class BookmarkUpdate(
val id: Long,
val note: String? = null,
)

View File

@ -0,0 +1,17 @@
package tachiyomi.domain.bookmark.model
data class BookmarkWithChapterNumber(
val pageIndex: Int?,
val note: String?,
val lastModifiedAt: Long,
val chapterNumber: Double,
) {
fun toBookmarkImpl(): Bookmark {
return Bookmark.create()
.copy(
pageIndex = pageIndex,
note = note,
lastModifiedAt = lastModifiedAt,
)
}
}

View File

@ -0,0 +1,20 @@
package tachiyomi.domain.bookmark.model
import tachiyomi.domain.manga.model.MangaCover
/**
* Represents a single bookmarked page with information
* about manga, chapter and bookmark.
*/
data class BookmarkedPage(
val bookmarkId: Long,
val mangaId: Long,
val chapterId: Long,
val pageIndex: Int?,
val mangaTitle: String,
val chapterNumber: Double,
val chapterName: String,
val note: String?,
val lastModifiedAt: Long,
val coverData: MangaCover,
)

View File

@ -0,0 +1,14 @@
package tachiyomi.domain.bookmark.model
import tachiyomi.domain.manga.model.MangaCover
/**
* Represents a single Manga with information about number of bookmarks.
*/
data class MangaWithBookmarks(
val mangaId: Long,
val mangaTitle: String,
val numberOfBookmarks: Long,
val bookmarkLastModified: Long,
val coverData: MangaCover,
)

View File

@ -0,0 +1,27 @@
package tachiyomi.domain.bookmark.repository
import tachiyomi.domain.bookmark.model.Bookmark
import tachiyomi.domain.bookmark.model.BookmarkDelete
import tachiyomi.domain.bookmark.model.BookmarkUpdate
import tachiyomi.domain.bookmark.model.BookmarkWithChapterNumber
import tachiyomi.domain.bookmark.model.BookmarkedPage
import tachiyomi.domain.bookmark.model.MangaWithBookmarks
interface BookmarkRepository {
suspend fun get(id: Long): Bookmark?
suspend fun get(mangaId: Long, chapterId: Long, pageIndex: Int?): Bookmark?
suspend fun getAllByMangaId(mangaId: Long): List<Bookmark>
suspend fun getMangaWithBookmarks(): List<MangaWithBookmarks>
suspend fun getBookmarkedPagesByMangaId(mangaId: Long): List<BookmarkedPage>
suspend fun getWithChapterNumberByMangaId(mangaId: Long): List<BookmarkWithChapterNumber>
suspend fun insert(bookmark: Bookmark)
suspend fun updatePartial(update: BookmarkUpdate)
suspend fun insertOrReplaceAll(idsToDelete: List<Long>, bookmarksToAdd: List<Bookmark>)
suspend fun delete(bookmarkId: Long)
suspend fun delete(delete: BookmarkDelete)
suspend fun deleteAll(delete: List<BookmarkDelete>)
suspend fun deleteAllByMangaId(mangaId: Long)
}

View File

@ -4,7 +4,7 @@ data class ChapterUpdate(
val id: Long,
val mangaId: Long? = null,
val read: Boolean? = null,
val bookmark: Boolean? = null,
private var _bookmark: Boolean? = null,
val lastPageRead: Long? = null,
val dateFetch: Long? = null,
val sourceOrder: Long? = null,
@ -13,7 +13,17 @@ data class ChapterUpdate(
val dateUpload: Long? = null,
val chapterNumber: Double? = null,
val scanlator: String? = null,
)
) {
val bookmark: Boolean?
get() = _bookmark
companion object {
// Only to be used from set/delete bookmarks components to keep bookmarks record consistent.
fun bookmarkUpdate(id: Long, bookmark: Boolean): ChapterUpdate {
return ChapterUpdate(id, _bookmark = bookmark)
}
}
}
fun Chapter.toChapterUpdate(): ChapterUpdate {
return ChapterUpdate(

View File

@ -31,6 +31,7 @@
<string name="label_backup">Backup and restore</string>
<string name="label_data_storage">Data and storage</string>
<string name="label_stats">Statistics</string>
<string name="label_bookmarks">Bookmarks</string>
<string name="label_migration">Migrate</string>
<string name="label_extensions">Extensions</string>
<string name="label_extension_info">Extension info</string>
@ -84,6 +85,11 @@
<string name="action_download">Download</string>
<string name="action_bookmark">Bookmark chapter</string>
<string name="action_remove_bookmark">Unbookmark chapter</string>
<string name="action_add_page_bookmark">Add page bookmark</string>
<string name="action_update_page_bookmark">Update page bookmark</string>
<string name="action_update_bookmark">Update bookmark</string>
<string name="action_delete_bookmark">Delete bookmark</string>
<string name="action_delete_all_bookmarks">Delete all bookmarks</string>
<string name="action_delete">Delete</string>
<string name="action_update_library">Update library</string>
<string name="action_enable_all">Enable all</string>
@ -747,6 +753,7 @@
<!-- Reader activity -->
<string name="custom_filter">Custom filter</string>
<string name="set_as_cover">Set as cover</string>
<string name="page_bookmark">Bookmark page</string>
<string name="cover_updated">Cover updated</string>
<string name="share_page_info">%1$s: %2$s, page %3$d</string>
<string name="chapter_progress">Page: %1$d</string>
@ -866,6 +873,7 @@
<!-- Information Text -->
<string name="information_no_downloads">No downloads</string>
<string name="information_no_recent">No recent updates</string>
<string name="information_no_bookmarks">No bookmarks</string>
<string name="information_no_recent_manga">Nothing read recently</string>
<string name="information_empty_library">Your library is empty</string>
<string name="information_no_manga_category">Category is empty</string>
@ -916,4 +924,13 @@
<string name="exception_http">HTTP %d, check website in WebView</string>
<string name="exception_offline">No Internet connection</string>
<string name="exception_unknown_host">Couldn\'t reach %s</string>
<!-- Bookmarks view and editing. -->
<string name="bookmark_note_placeholder">Provide an optional bookmark note</string>
<string name="bookmark_total_in_manga">Total bookmarks: %1$d</string>
<string name="bookmark_last_updated_in_manga">Last bookmark update: %1$s</string>
<string name="delete_bookmark_confirmation">Do you wish to delete the bookmark?</string>
<string name="bookmark_page_number">Page: %1$d</string>
<string name="bookmark_chapter">Chapter bookmark</string>
<string name="bookmark_delete_manga_confirmation">Are you sure? Bookmarks will be lost.</string>
</resources>