From 89859f1e52bbbd4c6574c66256a49db4452d137b Mon Sep 17 00:00:00 2001 From: Mekanik Date: Mon, 1 Jan 2024 16:09:25 -0800 Subject: [PATCH] Add per-page bookmark with optional notes and new view. --- app/build.gradle.kts | 2 +- .../java/eu/kanade/domain/DomainModule.kt | 18 +- .../interactor/SyncChaptersWithSource.kt | 30 +++ .../BookmarksDetailsScreenContent.kt | 225 ++++++++++++++++++ .../bookmarks/BookmarksTopScreenContent.kt | 169 +++++++++++++ .../eu/kanade/presentation/more/MoreScreen.kt | 9 + .../reader/ReaderPageActionsDialog.kt | 30 +++ .../data/backup/create/BackupOptions.kt | 9 + .../create/creators/MangaBackupCreator.kt | 10 + .../data/backup/models/BackupBookmark.kt | 32 +++ .../data/backup/models/BackupManga.kt | 1 + .../backup/restore/restorers/MangaRestorer.kt | 38 +++ .../ui/bookmarks/BookmarksTopScreen.kt | 155 ++++++++++++ .../ui/bookmarks/BookmarksTopScreenModel.kt | 130 ++++++++++ .../ui/bookmarks/EditBookmarkDialog.kt | 112 +++++++++ .../ui/browse/migration/MigrationFlags.kt | 11 + .../browse/migration/search/MigrateDialog.kt | 56 ++++- .../tachiyomi/ui/manga/MangaScreenModel.kt | 18 +- .../eu/kanade/tachiyomi/ui/more/MoreTab.kt | 2 + .../tachiyomi/ui/reader/ReaderActivity.kt | 14 +- .../tachiyomi/ui/reader/ReaderViewModel.kt | 73 +++++- .../ui/updates/UpdatesScreenModel.kt | 27 ++- .../tachiyomi/data/bookmark/BookmarkMapper.kt | 94 ++++++++ .../data/bookmark/BookmarkRepositoryImpl.kt | 123 ++++++++++ .../sqldelight/tachiyomi/data/bookmarks.sq | 77 ++++++ .../sqldelight/tachiyomi/migrations/28.sqm | 80 +++++++ .../tachiyomi/view/bookmarksView.sq | 29 +++ .../tachiyomi/view/mangaWithBookmarksView.sq | 19 ++ .../bookmark/interactor/DeleteBookmark.kt | 89 +++++++ .../domain/bookmark/interactor/GetBookmark.kt | 12 + .../interactor/GetBookmarkedMangas.kt | 9 + .../bookmark/interactor/GetBookmarkedPages.kt | 10 + .../bookmark/interactor/GetBookmarks.kt | 13 + .../domain/bookmark/interactor/SetBookmark.kt | 131 ++++++++++ .../domain/bookmark/model/Bookmark.kt | 26 ++ .../domain/bookmark/model/BookmarkDelete.kt | 7 + .../domain/bookmark/model/BookmarkUpdate.kt | 6 + .../model/BookmarkWithChapterNumber.kt | 17 ++ .../domain/bookmark/model/BookmarkedPage.kt | 20 ++ .../bookmark/model/MangaWithBookmarks.kt | 14 ++ .../bookmark/repository/BookmarkRepository.kt | 27 +++ .../domain/chapter/model/ChapterUpdate.kt | 14 +- .../commonMain/resources/MR/base/strings.xml | 17 ++ 43 files changed, 1972 insertions(+), 33 deletions(-) create mode 100644 app/src/main/java/eu/kanade/presentation/bookmarks/BookmarksDetailsScreenContent.kt create mode 100644 app/src/main/java/eu/kanade/presentation/bookmarks/BookmarksTopScreenContent.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupBookmark.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/bookmarks/BookmarksTopScreen.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/bookmarks/BookmarksTopScreenModel.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/bookmarks/EditBookmarkDialog.kt create mode 100644 data/src/main/java/tachiyomi/data/bookmark/BookmarkMapper.kt create mode 100644 data/src/main/java/tachiyomi/data/bookmark/BookmarkRepositoryImpl.kt create mode 100644 data/src/main/sqldelight/tachiyomi/data/bookmarks.sq create mode 100644 data/src/main/sqldelight/tachiyomi/migrations/28.sqm create mode 100644 data/src/main/sqldelight/tachiyomi/view/bookmarksView.sq create mode 100644 data/src/main/sqldelight/tachiyomi/view/mangaWithBookmarksView.sq create mode 100644 domain/src/main/java/tachiyomi/domain/bookmark/interactor/DeleteBookmark.kt create mode 100644 domain/src/main/java/tachiyomi/domain/bookmark/interactor/GetBookmark.kt create mode 100644 domain/src/main/java/tachiyomi/domain/bookmark/interactor/GetBookmarkedMangas.kt create mode 100644 domain/src/main/java/tachiyomi/domain/bookmark/interactor/GetBookmarkedPages.kt create mode 100644 domain/src/main/java/tachiyomi/domain/bookmark/interactor/GetBookmarks.kt create mode 100644 domain/src/main/java/tachiyomi/domain/bookmark/interactor/SetBookmark.kt create mode 100644 domain/src/main/java/tachiyomi/domain/bookmark/model/Bookmark.kt create mode 100644 domain/src/main/java/tachiyomi/domain/bookmark/model/BookmarkDelete.kt create mode 100644 domain/src/main/java/tachiyomi/domain/bookmark/model/BookmarkUpdate.kt create mode 100644 domain/src/main/java/tachiyomi/domain/bookmark/model/BookmarkWithChapterNumber.kt create mode 100644 domain/src/main/java/tachiyomi/domain/bookmark/model/BookmarkedPage.kt create mode 100644 domain/src/main/java/tachiyomi/domain/bookmark/model/MangaWithBookmarks.kt create mode 100644 domain/src/main/java/tachiyomi/domain/bookmark/repository/BookmarkRepository.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 96c7ac3856..b9daa2cf70 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -22,7 +22,7 @@ android { defaultConfig { applicationId = "eu.kanade.tachiyomi" - versionCode = 113 + versionCode = 114 versionName = "0.14.7" buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"") diff --git a/app/src/main/java/eu/kanade/domain/DomainModule.kt b/app/src/main/java/eu/kanade/domain/DomainModule.kt index 778b7645c7..2f84c090de 100644 --- a/app/src/main/java/eu/kanade/domain/DomainModule.kt +++ b/app/src/main/java/eu/kanade/domain/DomainModule.kt @@ -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 { HistoryRepositoryImpl(get()) } @@ -167,5 +175,13 @@ class DomainModule : InjektModule { addFactory { ToggleLanguage(get()) } addFactory { ToggleSource(get()) } addFactory { ToggleSourcePin(get()) } + + addSingletonFactory { BookmarkRepositoryImpl(get()) } + addFactory { SetBookmark(get(), get()) } + addFactory { DeleteBookmark(get(), get()) } + addFactory { GetBookmark(get()) } + addFactory { GetBookmarks(get()) } + addFactory { GetBookmarkedMangas(get()) } + addFactory { GetBookmarkedPages(get()) } } } diff --git a/app/src/main/java/eu/kanade/domain/chapter/interactor/SyncChaptersWithSource.kt b/app/src/main/java/eu/kanade/domain/chapter/interactor/SyncChaptersWithSource.kt index abd13a8492..324d04d500 100644 --- a/app/src/main/java/eu/kanade/domain/chapter/interactor/SyncChaptersWithSource.kt +++ b/app/src/main/java/eu/kanade/domain/chapter/interactor/SyncChaptersWithSource.kt @@ -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() + val reAddedBookmarks = mutableListOf() + val bookmarksByChapterNumber = if (newChapters.isEmpty()) { + emptyMap() + } else { + getBookmarks.awaitWithChapterNumbers(manga.id).groupBy { it.chapterNumber } + } val deletedChapterNumbers = TreeSet() val deletedReadChapterNumbers = TreeSet() @@ -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 diff --git a/app/src/main/java/eu/kanade/presentation/bookmarks/BookmarksDetailsScreenContent.kt b/app/src/main/java/eu/kanade/presentation/bookmarks/BookmarksDetailsScreenContent.kt new file mode 100644 index 0000000000..7fb8539e3b --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/bookmarks/BookmarksDetailsScreenContent.kt @@ -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 = {}, + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/bookmarks/BookmarksTopScreenContent.kt b/app/src/main/java/eu/kanade/presentation/bookmarks/BookmarksTopScreenContent.kt new file mode 100644 index 0000000000..3225efca4b --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/bookmarks/BookmarksTopScreenContent.kt @@ -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), + ) + } + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/more/MoreScreen.kt b/app/src/main/java/eu/kanade/presentation/more/MoreScreen.kt index 8a3b1f9fe8..cc60bde0ca 100644 --- a/app/src/main/java/eu/kanade/presentation/more/MoreScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/MoreScreen.kt @@ -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), diff --git a/app/src/main/java/eu/kanade/presentation/reader/ReaderPageActionsDialog.kt b/app/src/main/java/eu/kanade/presentation/reader/ReaderPageActionsDialog.kt index 70cc58f207..f123ecc1ed 100644 --- a/app/src/main/java/eu/kanade/presentation/reader/ReaderPageActionsDialog.kt +++ b/app/src/main/java/eu/kanade/presentation/reader/ReaderPageActionsDialog.kt @@ -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 diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/BackupOptions.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/BackupOptions.kt index 868458b8a8..1e2a0c8007 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/BackupOptions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/BackupOptions.kt @@ -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 }, ) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/creators/MangaBackupCreator.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/creators/MangaBackupCreator.kt index 1c03f7fce1..f1c6ea8484 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/creators/MangaBackupCreator.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/creators/MangaBackupCreator.kt @@ -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 } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupBookmark.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupBookmark.kt new file mode 100644 index 0000000000..bd2fc8e36c --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupBookmark.kt @@ -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, + ) + } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupManga.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupManga.kt index 43a8a906c9..822e725e40 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupManga.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupManga.kt @@ -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 = emptyList(), ) { fun getMangaImpl(): Manga { return Manga.create().copy( diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/MangaRestorer.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/MangaRestorer.kt index a09d5b1d73..0bcefa1faa 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/MangaRestorer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/MangaRestorer.kt @@ -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, history: List, tracks: List, + bookmarks: List, ): 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) { + val chapters = getChaptersByMangaId.await(manga.id) + + val bookmarks: List = + 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) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/bookmarks/BookmarksTopScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/bookmarks/BookmarksTopScreen.kt new file mode 100644 index 0000000000..c9c9e12897 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/bookmarks/BookmarksTopScreen.kt @@ -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)) + } + }, + ) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/bookmarks/BookmarksTopScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/bookmarks/BookmarksTopScreenModel.kt new file mode 100644 index 0000000000..4da891f564 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/bookmarks/BookmarksTopScreenModel.kt @@ -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(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 = listOf(), + val groupsOfBookmarks: List = listOf(), + val selectedMangaId: Long? = null, + ) { + val isEmpty = + selectedMangaId?.let { groupsOfBookmarks.isEmpty() } ?: mangaWithBookmarks.isEmpty() + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/bookmarks/EditBookmarkDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/bookmarks/EditBookmarkDialog.kt new file mode 100644 index 0000000000..585421dab0 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/bookmarks/EditBookmarkDialog.kt @@ -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), + ) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/MigrationFlags.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/MigrationFlags.kt index 3064ad60f7..bc77081620 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/MigrationFlags.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/MigrationFlags.kt @@ -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 { val flags = mutableListOf() @@ -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 } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateDialog.kt index d6b70381bf..9289098849 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateDialog.kt @@ -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() + val addedBookmarks = mutableListOf() + + 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 diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreenModel.kt index aa85aec5a4..4dd0108e5b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreenModel.kt @@ -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(State.Loading) { @@ -737,11 +738,14 @@ class MangaScreenModel( * @param chapters the list of chapters to bookmark. */ fun bookmarkChapters(chapters: List, 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) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/more/MoreTab.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/more/MoreTab.kt index 3ca8418adc..bb82836211 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/more/MoreTab.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/more/MoreTab.kt @@ -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)) }, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt index c67a490840..665d165a11 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt @@ -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 -> {} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt index 37116eeefa..8c8dee5e39 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt @@ -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 { + suspend fun init( + mangaId: Long, + initialChapterId: Long, + pageIndex: Int? = null, + ): Result { 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. */ diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/updates/UpdatesScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/updates/UpdatesScreenModel.kt index 9574d0c7f5..d62a21ef11 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/updates/UpdatesScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/updates/UpdatesScreenModel.kt @@ -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(State()) { @@ -213,11 +216,21 @@ class UpdatesScreenModel( * @param updates the list of chapters to bookmark. */ fun bookmarkUpdates(updates: List, 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) } diff --git a/data/src/main/java/tachiyomi/data/bookmark/BookmarkMapper.kt b/data/src/main/java/tachiyomi/data/bookmark/BookmarkMapper.kt new file mode 100644 index 0000000000..19fdeb9ee6 --- /dev/null +++ b/data/src/main/java/tachiyomi/data/bookmark/BookmarkMapper.kt @@ -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, + ) +} diff --git a/data/src/main/java/tachiyomi/data/bookmark/BookmarkRepositoryImpl.kt b/data/src/main/java/tachiyomi/data/bookmark/BookmarkRepositoryImpl.kt new file mode 100644 index 0000000000..c5a1ac07de --- /dev/null +++ b/data/src/main/java/tachiyomi/data/bookmark/BookmarkRepositoryImpl.kt @@ -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 { + return handler.awaitList { + bookmarksQueries.getAllByMangaId(mangaId, BookmarkMapper::mapBookmark) + } + } + + override suspend fun getMangaWithBookmarks(): List { + return handler.awaitList { + mangaWithBookmarksViewQueries.mangaWithBookmarks(BookmarkMapper::mapMangaWithBookmarks) + } + } + + override suspend fun getBookmarkedPagesByMangaId(mangaId: Long): List { + return handler.awaitList { + bookmarksViewQueries.getBookmarksByManga(mangaId, BookmarkMapper::mapBookmarkedPage) + } + } + + override suspend fun getWithChapterNumberByMangaId(mangaId: Long): List { + 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, + bookmarksToAdd: List, + ) { + 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) { + 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(), + ) + } +} diff --git a/data/src/main/sqldelight/tachiyomi/data/bookmarks.sq b/data/src/main/sqldelight/tachiyomi/data/bookmarks.sq new file mode 100644 index 0000000000..bd0ef2c7a6 --- /dev/null +++ b/data/src/main/sqldelight/tachiyomi/data/bookmarks.sq @@ -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; diff --git a/data/src/main/sqldelight/tachiyomi/migrations/28.sqm b/data/src/main/sqldelight/tachiyomi/migrations/28.sqm new file mode 100644 index 0000000000..d8aca9d244 --- /dev/null +++ b/data/src/main/sqldelight/tachiyomi/migrations/28.sqm @@ -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); diff --git a/data/src/main/sqldelight/tachiyomi/view/bookmarksView.sq b/data/src/main/sqldelight/tachiyomi/view/bookmarksView.sq new file mode 100644 index 0000000000..97d5dc0b1a --- /dev/null +++ b/data/src/main/sqldelight/tachiyomi/view/bookmarksView.sq @@ -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; diff --git a/data/src/main/sqldelight/tachiyomi/view/mangaWithBookmarksView.sq b/data/src/main/sqldelight/tachiyomi/view/mangaWithBookmarksView.sq new file mode 100644 index 0000000000..6bc41b0b24 --- /dev/null +++ b/data/src/main/sqldelight/tachiyomi/view/mangaWithBookmarksView.sq @@ -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; diff --git a/domain/src/main/java/tachiyomi/domain/bookmark/interactor/DeleteBookmark.kt b/domain/src/main/java/tachiyomi/domain/bookmark/interactor/DeleteBookmark.kt new file mode 100644 index 0000000000..134fca2f8c --- /dev/null +++ b/domain/src/main/java/tachiyomi/domain/bookmark/interactor/DeleteBookmark.kt @@ -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) = 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) { + 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() + } +} diff --git a/domain/src/main/java/tachiyomi/domain/bookmark/interactor/GetBookmark.kt b/domain/src/main/java/tachiyomi/domain/bookmark/interactor/GetBookmark.kt new file mode 100644 index 0000000000..25fd46c3c1 --- /dev/null +++ b/domain/src/main/java/tachiyomi/domain/bookmark/interactor/GetBookmark.kt @@ -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) + } +} diff --git a/domain/src/main/java/tachiyomi/domain/bookmark/interactor/GetBookmarkedMangas.kt b/domain/src/main/java/tachiyomi/domain/bookmark/interactor/GetBookmarkedMangas.kt new file mode 100644 index 0000000000..1a447b6abf --- /dev/null +++ b/domain/src/main/java/tachiyomi/domain/bookmark/interactor/GetBookmarkedMangas.kt @@ -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() } +} diff --git a/domain/src/main/java/tachiyomi/domain/bookmark/interactor/GetBookmarkedPages.kt b/domain/src/main/java/tachiyomi/domain/bookmark/interactor/GetBookmarkedPages.kt new file mode 100644 index 0000000000..d4baca33b3 --- /dev/null +++ b/domain/src/main/java/tachiyomi/domain/bookmark/interactor/GetBookmarkedPages.kt @@ -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) } +} diff --git a/domain/src/main/java/tachiyomi/domain/bookmark/interactor/GetBookmarks.kt b/domain/src/main/java/tachiyomi/domain/bookmark/interactor/GetBookmarks.kt new file mode 100644 index 0000000000..1ac057a5d4 --- /dev/null +++ b/domain/src/main/java/tachiyomi/domain/bookmark/interactor/GetBookmarks.kt @@ -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) } +} diff --git a/domain/src/main/java/tachiyomi/domain/bookmark/interactor/SetBookmark.kt b/domain/src/main/java/tachiyomi/domain/bookmark/interactor/SetBookmark.kt new file mode 100644 index 0000000000..98086e931a --- /dev/null +++ b/domain/src/main/java/tachiyomi/domain/bookmark/interactor/SetBookmark.kt @@ -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, 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() + val toAdd = mutableListOf() + + 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): 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() + } +} diff --git a/domain/src/main/java/tachiyomi/domain/bookmark/model/Bookmark.kt b/domain/src/main/java/tachiyomi/domain/bookmark/model/Bookmark.kt new file mode 100644 index 0000000000..fa7ce9d531 --- /dev/null +++ b/domain/src/main/java/tachiyomi/domain/bookmark/model/Bookmark.kt @@ -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, + ) + } +} diff --git a/domain/src/main/java/tachiyomi/domain/bookmark/model/BookmarkDelete.kt b/domain/src/main/java/tachiyomi/domain/bookmark/model/BookmarkDelete.kt new file mode 100644 index 0000000000..1407365c2b --- /dev/null +++ b/domain/src/main/java/tachiyomi/domain/bookmark/model/BookmarkDelete.kt @@ -0,0 +1,7 @@ +package tachiyomi.domain.bookmark.model + +data class BookmarkDelete( + val mangaId: Long, + val chapterId: Long, + val pageIndex: Int? = null, +) diff --git a/domain/src/main/java/tachiyomi/domain/bookmark/model/BookmarkUpdate.kt b/domain/src/main/java/tachiyomi/domain/bookmark/model/BookmarkUpdate.kt new file mode 100644 index 0000000000..6bd5071a5f --- /dev/null +++ b/domain/src/main/java/tachiyomi/domain/bookmark/model/BookmarkUpdate.kt @@ -0,0 +1,6 @@ +package tachiyomi.domain.bookmark.model + +data class BookmarkUpdate( + val id: Long, + val note: String? = null, +) diff --git a/domain/src/main/java/tachiyomi/domain/bookmark/model/BookmarkWithChapterNumber.kt b/domain/src/main/java/tachiyomi/domain/bookmark/model/BookmarkWithChapterNumber.kt new file mode 100644 index 0000000000..c89eb79b8b --- /dev/null +++ b/domain/src/main/java/tachiyomi/domain/bookmark/model/BookmarkWithChapterNumber.kt @@ -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, + ) + } +} diff --git a/domain/src/main/java/tachiyomi/domain/bookmark/model/BookmarkedPage.kt b/domain/src/main/java/tachiyomi/domain/bookmark/model/BookmarkedPage.kt new file mode 100644 index 0000000000..105e322619 --- /dev/null +++ b/domain/src/main/java/tachiyomi/domain/bookmark/model/BookmarkedPage.kt @@ -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, +) diff --git a/domain/src/main/java/tachiyomi/domain/bookmark/model/MangaWithBookmarks.kt b/domain/src/main/java/tachiyomi/domain/bookmark/model/MangaWithBookmarks.kt new file mode 100644 index 0000000000..ded86f56d4 --- /dev/null +++ b/domain/src/main/java/tachiyomi/domain/bookmark/model/MangaWithBookmarks.kt @@ -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, +) diff --git a/domain/src/main/java/tachiyomi/domain/bookmark/repository/BookmarkRepository.kt b/domain/src/main/java/tachiyomi/domain/bookmark/repository/BookmarkRepository.kt new file mode 100644 index 0000000000..35e432ddce --- /dev/null +++ b/domain/src/main/java/tachiyomi/domain/bookmark/repository/BookmarkRepository.kt @@ -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 + + suspend fun getMangaWithBookmarks(): List + suspend fun getBookmarkedPagesByMangaId(mangaId: Long): List + suspend fun getWithChapterNumberByMangaId(mangaId: Long): List + + suspend fun insert(bookmark: Bookmark) + suspend fun updatePartial(update: BookmarkUpdate) + suspend fun insertOrReplaceAll(idsToDelete: List, bookmarksToAdd: List) + + suspend fun delete(bookmarkId: Long) + suspend fun delete(delete: BookmarkDelete) + suspend fun deleteAll(delete: List) + suspend fun deleteAllByMangaId(mangaId: Long) +} diff --git a/domain/src/main/java/tachiyomi/domain/chapter/model/ChapterUpdate.kt b/domain/src/main/java/tachiyomi/domain/chapter/model/ChapterUpdate.kt index 33d1d4fba5..7e42c3823c 100644 --- a/domain/src/main/java/tachiyomi/domain/chapter/model/ChapterUpdate.kt +++ b/domain/src/main/java/tachiyomi/domain/chapter/model/ChapterUpdate.kt @@ -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( diff --git a/i18n/src/commonMain/resources/MR/base/strings.xml b/i18n/src/commonMain/resources/MR/base/strings.xml index cad687b249..f21ea8d031 100644 --- a/i18n/src/commonMain/resources/MR/base/strings.xml +++ b/i18n/src/commonMain/resources/MR/base/strings.xml @@ -31,6 +31,7 @@ Backup and restore Data and storage Statistics + Bookmarks Migrate Extensions Extension info @@ -84,6 +85,11 @@ Download Bookmark chapter Unbookmark chapter + Add page bookmark + Update page bookmark + Update bookmark + Delete bookmark + Delete all bookmarks Delete Update library Enable all @@ -747,6 +753,7 @@ Custom filter Set as cover + Bookmark page Cover updated %1$s: %2$s, page %3$d Page: %1$d @@ -866,6 +873,7 @@ No downloads No recent updates + No bookmarks Nothing read recently Your library is empty Category is empty @@ -916,4 +924,13 @@ HTTP %d, check website in WebView No Internet connection Couldn\'t reach %s + + + Provide an optional bookmark note + Total bookmarks: %1$d + Last bookmark update: %1$s + Do you wish to delete the bookmark? + Page: %1$d + Chapter bookmark + Are you sure? Bookmarks will be lost.