mirror of
https://github.com/tachiyomiorg/tachiyomi.git
synced 2024-11-20 07:39:15 +01:00
Add per-page bookmark with optional notes and new view.
This commit is contained in:
parent
22589a9c30
commit
89859f1e52
@ -22,7 +22,7 @@ android {
|
||||
defaultConfig {
|
||||
applicationId = "eu.kanade.tachiyomi"
|
||||
|
||||
versionCode = 113
|
||||
versionCode = 114
|
||||
versionName = "0.14.7"
|
||||
|
||||
buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"")
|
||||
|
@ -22,6 +22,7 @@ import eu.kanade.domain.track.interactor.AddTracks
|
||||
import eu.kanade.domain.track.interactor.RefreshTracks
|
||||
import eu.kanade.domain.track.interactor.SyncChapterProgressWithTrack
|
||||
import eu.kanade.domain.track.interactor.TrackChapter
|
||||
import tachiyomi.data.bookmark.BookmarkRepositoryImpl
|
||||
import tachiyomi.data.category.CategoryRepositoryImpl
|
||||
import tachiyomi.data.chapter.ChapterRepositoryImpl
|
||||
import tachiyomi.data.history.HistoryRepositoryImpl
|
||||
@ -31,6 +32,13 @@ import tachiyomi.data.source.SourceRepositoryImpl
|
||||
import tachiyomi.data.source.StubSourceRepositoryImpl
|
||||
import tachiyomi.data.track.TrackRepositoryImpl
|
||||
import tachiyomi.data.updates.UpdatesRepositoryImpl
|
||||
import tachiyomi.domain.bookmark.interactor.DeleteBookmark
|
||||
import tachiyomi.domain.bookmark.interactor.GetBookmark
|
||||
import tachiyomi.domain.bookmark.interactor.GetBookmarkedMangas
|
||||
import tachiyomi.domain.bookmark.interactor.GetBookmarkedPages
|
||||
import tachiyomi.domain.bookmark.interactor.GetBookmarks
|
||||
import tachiyomi.domain.bookmark.interactor.SetBookmark
|
||||
import tachiyomi.domain.bookmark.repository.BookmarkRepository
|
||||
import tachiyomi.domain.category.interactor.CreateCategoryWithName
|
||||
import tachiyomi.domain.category.interactor.DeleteCategory
|
||||
import tachiyomi.domain.category.interactor.GetCategories
|
||||
@ -138,7 +146,7 @@ class DomainModule : InjektModule {
|
||||
addFactory { UpdateChapter(get()) }
|
||||
addFactory { SetReadStatus(get(), get(), get(), get()) }
|
||||
addFactory { ShouldUpdateDbChapter() }
|
||||
addFactory { SyncChaptersWithSource(get(), get(), get(), get(), get(), get(), get(), get()) }
|
||||
addFactory { SyncChaptersWithSource(get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) }
|
||||
addFactory { GetAvailableScanlators(get()) }
|
||||
|
||||
addSingletonFactory<HistoryRepository> { HistoryRepositoryImpl(get()) }
|
||||
@ -167,5 +175,13 @@ class DomainModule : InjektModule {
|
||||
addFactory { ToggleLanguage(get()) }
|
||||
addFactory { ToggleSource(get()) }
|
||||
addFactory { ToggleSourcePin(get()) }
|
||||
|
||||
addSingletonFactory<BookmarkRepository> { BookmarkRepositoryImpl(get()) }
|
||||
addFactory { SetBookmark(get(), get()) }
|
||||
addFactory { DeleteBookmark(get(), get()) }
|
||||
addFactory { GetBookmark(get()) }
|
||||
addFactory { GetBookmarks(get()) }
|
||||
addFactory { GetBookmarkedMangas(get()) }
|
||||
addFactory { GetBookmarkedPages(get()) }
|
||||
}
|
||||
}
|
||||
|
@ -11,6 +11,9 @@ import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import tachiyomi.data.chapter.ChapterSanitizer
|
||||
import tachiyomi.domain.bookmark.interactor.GetBookmarks
|
||||
import tachiyomi.domain.bookmark.interactor.SetBookmark
|
||||
import tachiyomi.domain.bookmark.model.BookmarkWithChapterNumber
|
||||
import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
|
||||
import tachiyomi.domain.chapter.interactor.ShouldUpdateDbChapter
|
||||
import tachiyomi.domain.chapter.interactor.UpdateChapter
|
||||
@ -34,6 +37,8 @@ class SyncChaptersWithSource(
|
||||
private val updateChapter: UpdateChapter,
|
||||
private val getChaptersByMangaId: GetChaptersByMangaId,
|
||||
private val getExcludedScanlators: GetExcludedScanlators,
|
||||
private val getBookmarks: GetBookmarks,
|
||||
private val setBookmark: SetBookmark,
|
||||
) {
|
||||
|
||||
/**
|
||||
@ -143,6 +148,12 @@ class SyncChaptersWithSource(
|
||||
}
|
||||
|
||||
val reAdded = mutableListOf<Chapter>()
|
||||
val reAddedBookmarks = mutableListOf<BookmarkWithChapterNumber>()
|
||||
val bookmarksByChapterNumber = if (newChapters.isEmpty()) {
|
||||
emptyMap()
|
||||
} else {
|
||||
getBookmarks.awaitWithChapterNumbers(manga.id).groupBy { it.chapterNumber }
|
||||
}
|
||||
|
||||
val deletedChapterNumbers = TreeSet<Double>()
|
||||
val deletedReadChapterNumbers = TreeSet<Double>()
|
||||
@ -170,6 +181,9 @@ class SyncChaptersWithSource(
|
||||
bookmark = chapter.chapterNumber in deletedBookmarkedChapterNumbers,
|
||||
)
|
||||
|
||||
// Existing bookmarks are saved to be moved to re-added chapters.
|
||||
bookmarksByChapterNumber[chapter.chapterNumber]?.let { reAddedBookmarks.addAll(it) }
|
||||
|
||||
// Try to to use the fetch date of the original entry to not pollute 'Updates' tab
|
||||
deletedChapterNumberDateFetchMap[chapter.chapterNumber]?.let {
|
||||
chapter = chapter.copy(dateFetch = it)
|
||||
@ -193,6 +207,22 @@ class SyncChaptersWithSource(
|
||||
val chapterUpdates = updatedChapters.map { it.toChapterUpdate() }
|
||||
updateChapter.awaitAll(chapterUpdates)
|
||||
}
|
||||
|
||||
if (reAddedBookmarks.isNotEmpty()) {
|
||||
val chapterIdByNumber = updatedToAdd.associate { it.chapterNumber to it.id }
|
||||
val bookmarksToAdd = reAddedBookmarks.mapNotNull { bm ->
|
||||
chapterIdByNumber[bm.chapterNumber]
|
||||
?.let { chapterId ->
|
||||
bm.toBookmarkImpl().copy(
|
||||
mangaId = manga.id,
|
||||
chapterId = chapterId,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
setBookmark.awaitAll(bookmarksToAdd, updateChapters = false)
|
||||
}
|
||||
|
||||
updateManga.awaitUpdateFetchInterval(manga, now, fetchWindow)
|
||||
|
||||
// Set this manga as updated since chapters were changed
|
||||
|
@ -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 = {},
|
||||
)
|
||||
}
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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),
|
||||
|
@ -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
|
||||
|
@ -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 },
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
)
|
||||
}
|
@ -38,6 +38,7 @@ data class BackupManga(
|
||||
@ProtoNumber(105) var updateStrategy: UpdateStrategy = UpdateStrategy.ALWAYS_UPDATE,
|
||||
@ProtoNumber(106) var lastModifiedAt: Long = 0,
|
||||
@ProtoNumber(107) var favoriteModifiedAt: Long? = null,
|
||||
@ProtoNumber(108) var bookmarks: List<BackupBookmark> = emptyList(),
|
||||
) {
|
||||
fun getMangaImpl(): Manga {
|
||||
return Manga.create().copy(
|
||||
|
@ -1,6 +1,7 @@
|
||||
package eu.kanade.tachiyomi.data.backup.restore.restorers
|
||||
|
||||
import eu.kanade.domain.manga.interactor.UpdateManga
|
||||
import eu.kanade.tachiyomi.data.backup.models.BackupBookmark
|
||||
import eu.kanade.tachiyomi.data.backup.models.BackupCategory
|
||||
import eu.kanade.tachiyomi.data.backup.models.BackupChapter
|
||||
import eu.kanade.tachiyomi.data.backup.models.BackupHistory
|
||||
@ -8,6 +9,8 @@ import eu.kanade.tachiyomi.data.backup.models.BackupManga
|
||||
import eu.kanade.tachiyomi.data.backup.models.BackupTracking
|
||||
import tachiyomi.data.DatabaseHandler
|
||||
import tachiyomi.data.UpdateStrategyColumnAdapter
|
||||
import tachiyomi.domain.bookmark.interactor.SetBookmark
|
||||
import tachiyomi.domain.bookmark.model.Bookmark
|
||||
import tachiyomi.domain.category.interactor.GetCategories
|
||||
import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
|
||||
import tachiyomi.domain.chapter.model.Chapter
|
||||
@ -31,6 +34,7 @@ class MangaRestorer(
|
||||
private val updateManga: UpdateManga = Injekt.get(),
|
||||
private val getTracks: GetTracks = Injekt.get(),
|
||||
private val insertTrack: InsertTrack = Injekt.get(),
|
||||
private val setBookmark: SetBookmark = Injekt.get(),
|
||||
fetchInterval: FetchInterval = Injekt.get(),
|
||||
) {
|
||||
|
||||
@ -72,6 +76,7 @@ class MangaRestorer(
|
||||
backupCategories = backupCategories,
|
||||
history = backupManga.history + backupManga.brokenHistory.map { it.toBackupHistory() },
|
||||
tracks = backupManga.tracking,
|
||||
bookmarks = backupManga.bookmarks,
|
||||
)
|
||||
}
|
||||
|
||||
@ -262,11 +267,13 @@ class MangaRestorer(
|
||||
backupCategories: List<BackupCategory>,
|
||||
history: List<BackupHistory>,
|
||||
tracks: List<BackupTracking>,
|
||||
bookmarks: List<BackupBookmark>,
|
||||
): Manga {
|
||||
restoreCategories(manga, categories, backupCategories)
|
||||
restoreChapters(manga, chapters)
|
||||
restoreTracking(manga, tracks)
|
||||
restoreHistory(history)
|
||||
restoreBookmarks(manga, bookmarks)
|
||||
updateManga.awaitUpdateFetchInterval(manga, now, currentFetchWindow)
|
||||
return manga
|
||||
}
|
||||
@ -398,5 +405,36 @@ class MangaRestorer(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun restoreBookmarks(manga: Manga, backupBookmarks: List<BackupBookmark>) {
|
||||
val chapters = getChaptersByMangaId.await(manga.id)
|
||||
|
||||
val bookmarks: List<Bookmark> =
|
||||
if (backupBookmarks.isEmpty()) {
|
||||
// No bookmarks list in the backup.
|
||||
// It's either an older version backup or backup without bookmarks.
|
||||
// Create chapter-level bookmarks (they will not affect existing chapter bookmarks).
|
||||
chapters
|
||||
.filter { it.bookmark }
|
||||
.map { Bookmark.create().copy(mangaId = manga.id, chapterId = it.id) }
|
||||
} else {
|
||||
// Map chapters from backup to db chapters and insert bookmark records based on backup.
|
||||
val chapterIdByUrl = chapters.associate { it.url to it.id }
|
||||
backupBookmarks.mapNotNull {
|
||||
chapterIdByUrl[it.chapterUrl]
|
||||
?.let { chapterId ->
|
||||
it.toBookmarkImpl()
|
||||
.copy(
|
||||
mangaId = manga.id,
|
||||
chapterId = chapterId,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bookmarks.isNotEmpty()) {
|
||||
setBookmark.awaitAll(bookmarks, updateChapters = false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Track.forComparison() = this.copy(id = 0L, mangaId = 0L)
|
||||
}
|
||||
|
@ -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))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
@ -0,0 +1,130 @@
|
||||
package eu.kanade.tachiyomi.ui.bookmarks
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import cafe.adriel.voyager.core.model.StateScreenModel
|
||||
import cafe.adriel.voyager.core.model.screenModelScope
|
||||
import cafe.adriel.voyager.navigator.Navigator
|
||||
import eu.kanade.core.preference.asState
|
||||
import eu.kanade.domain.ui.UiPreferences
|
||||
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.update
|
||||
import tachiyomi.core.util.lang.launchIO
|
||||
import tachiyomi.domain.bookmark.interactor.DeleteBookmark
|
||||
import tachiyomi.domain.bookmark.interactor.GetBookmarkedMangas
|
||||
import tachiyomi.domain.bookmark.interactor.GetBookmarkedPages
|
||||
import tachiyomi.domain.bookmark.model.BookmarkedPage
|
||||
import tachiyomi.domain.bookmark.model.MangaWithBookmarks
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class BookmarksTopScreenModel(
|
||||
private val getBookmarkedMangas: GetBookmarkedMangas = Injekt.get(),
|
||||
private val getBookmarkedPages: GetBookmarkedPages = Injekt.get(),
|
||||
private val deleteBookmark: DeleteBookmark = Injekt.get(),
|
||||
uiPreferences: UiPreferences = Injekt.get(),
|
||||
) : StateScreenModel<BookmarksTopScreenModel.State>(State()) {
|
||||
val relativeTime by uiPreferences.relativeTime().asState(screenModelScope)
|
||||
val dateFormat by mutableStateOf(UiPreferences.dateFormat(uiPreferences.dateFormat().get()))
|
||||
|
||||
init {
|
||||
screenModelScope.launchIO {
|
||||
loadMangaWithBookmarks()
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
screenModelScope.launchIO {
|
||||
mutableState.update { it.copy(isRefreshing = true) }
|
||||
delay(0.5.seconds)
|
||||
|
||||
state.value.selectedMangaId?.let { mangaId ->
|
||||
loadGroupedBookmarks(mangaId)
|
||||
} ?: loadMangaWithBookmarks()
|
||||
}
|
||||
}
|
||||
|
||||
fun openReader(context: Context, mangaId: Long, chapterId: Long, pageIndex: Int?) {
|
||||
context.startActivity(ReaderActivity.newIntent(context, mangaId, chapterId, pageIndex))
|
||||
}
|
||||
|
||||
fun onMangaSelected(mangaId: Long) {
|
||||
screenModelScope.launchIO {
|
||||
loadGroupedBookmarks(mangaId)
|
||||
}
|
||||
}
|
||||
|
||||
fun onNavigationUp(navigator: Navigator) {
|
||||
if (state.value.selectedMangaId != null) {
|
||||
mutableState.update {
|
||||
it.copy(
|
||||
selectedMangaId = null,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
navigator.pop()
|
||||
}
|
||||
}
|
||||
|
||||
fun delete() {
|
||||
state.value.selectedMangaId?.let { mangaId ->
|
||||
screenModelScope.launchIO {
|
||||
deleteBookmark.awaitAllByMangaId(mangaId)
|
||||
// Refresh after deletion and return to top level view.
|
||||
loadMangaWithBookmarks()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadMangaWithBookmarks() {
|
||||
val mangaWithBookmarks = getBookmarkedMangas.await()
|
||||
mutableState.update {
|
||||
it.copy(
|
||||
mangaWithBookmarks = mangaWithBookmarks,
|
||||
groupsOfBookmarks = listOf(),
|
||||
isLoading = false,
|
||||
isRefreshing = false,
|
||||
selectedMangaId = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadGroupedBookmarks(mangaId: Long) {
|
||||
val bookmarks = getBookmarkedPages.await(mangaId)
|
||||
val groupsOfBookmarks = bookmarks
|
||||
.sortedWith(
|
||||
compareBy(
|
||||
{ it.mangaId },
|
||||
{ it.chapterNumber },
|
||||
// chapterName is needed for sorting when all numbers are -1 (e.g. Volume 1, Volume 2)
|
||||
{ it.chapterName },
|
||||
{ it.pageIndex ?: -1 },
|
||||
),
|
||||
)
|
||||
|
||||
mutableState.update {
|
||||
it.copy(
|
||||
groupsOfBookmarks = groupsOfBookmarks,
|
||||
isLoading = false,
|
||||
isRefreshing = false,
|
||||
selectedMangaId = mangaId,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class State(
|
||||
val isLoading: Boolean = true,
|
||||
val isRefreshing: Boolean = false,
|
||||
val mangaWithBookmarks: List<MangaWithBookmarks> = listOf(),
|
||||
val groupsOfBookmarks: List<BookmarkedPage> = listOf(),
|
||||
val selectedMangaId: Long? = null,
|
||||
) {
|
||||
val isEmpty =
|
||||
selectedMangaId?.let { groupsOfBookmarks.isEmpty() } ?: mangaWithBookmarks.isEmpty()
|
||||
}
|
||||
}
|
@ -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),
|
||||
)
|
||||
}
|
@ -4,6 +4,8 @@ import dev.icerock.moko.resources.StringResource
|
||||
import eu.kanade.domain.manga.model.hasCustomCover
|
||||
import eu.kanade.tachiyomi.data.cache.CoverCache
|
||||
import eu.kanade.tachiyomi.data.download.DownloadCache
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import tachiyomi.domain.bookmark.interactor.GetBookmarks
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
import tachiyomi.i18n.MR
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
@ -30,9 +32,11 @@ object MigrationFlags {
|
||||
private const val CATEGORIES = 0b00010
|
||||
private const val CUSTOM_COVER = 0b01000
|
||||
private const val DELETE_DOWNLOADED = 0b10000
|
||||
private const val BOOKMARKS = 0b100000
|
||||
|
||||
private val coverCache: CoverCache by injectLazy()
|
||||
private val downloadCache: DownloadCache by injectLazy()
|
||||
private val getBookmarks: GetBookmarks by injectLazy()
|
||||
|
||||
fun hasChapters(value: Int): Boolean {
|
||||
return value and CHAPTERS != 0
|
||||
@ -50,6 +54,10 @@ object MigrationFlags {
|
||||
return value and DELETE_DOWNLOADED != 0
|
||||
}
|
||||
|
||||
fun hasBookmarks(value: Int): Boolean {
|
||||
return value and BOOKMARKS != 0
|
||||
}
|
||||
|
||||
/** Returns information about applicable flags with default selections. */
|
||||
fun getFlags(manga: Manga?, defaultSelectedBitMap: Int): List<MigrationFlag> {
|
||||
val flags = mutableListOf<MigrationFlag>()
|
||||
@ -63,6 +71,9 @@ object MigrationFlags {
|
||||
if (downloadCache.getDownloadCount(manga) > 0) {
|
||||
flags += MigrationFlag.create(DELETE_DOWNLOADED, defaultSelectedBitMap, MR.strings.delete_downloaded)
|
||||
}
|
||||
if (runBlocking { getBookmarks.await(manga.id) }.isNotEmpty()) {
|
||||
flags += MigrationFlag.create(BOOKMARKS, defaultSelectedBitMap, MR.strings.label_bookmarks)
|
||||
}
|
||||
}
|
||||
return flags
|
||||
}
|
||||
|
@ -36,10 +36,15 @@ import tachiyomi.core.preference.Preference
|
||||
import tachiyomi.core.preference.PreferenceStore
|
||||
import tachiyomi.core.util.lang.launchIO
|
||||
import tachiyomi.core.util.lang.withUIContext
|
||||
import tachiyomi.domain.bookmark.interactor.DeleteBookmark
|
||||
import tachiyomi.domain.bookmark.interactor.GetBookmarks
|
||||
import tachiyomi.domain.bookmark.interactor.SetBookmark
|
||||
import tachiyomi.domain.bookmark.model.Bookmark
|
||||
import tachiyomi.domain.category.interactor.GetCategories
|
||||
import tachiyomi.domain.category.interactor.SetMangaCategories
|
||||
import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
|
||||
import tachiyomi.domain.chapter.interactor.UpdateChapter
|
||||
import tachiyomi.domain.chapter.model.Chapter
|
||||
import tachiyomi.domain.chapter.model.toChapterUpdate
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
import tachiyomi.domain.manga.model.MangaUpdate
|
||||
@ -153,6 +158,9 @@ internal class MigrateDialogScreenModel(
|
||||
private val getChaptersByMangaId: GetChaptersByMangaId = Injekt.get(),
|
||||
private val syncChaptersWithSource: SyncChaptersWithSource = Injekt.get(),
|
||||
private val updateChapter: UpdateChapter = Injekt.get(),
|
||||
private val getBookmarks: GetBookmarks = Injekt.get(),
|
||||
private val setBookmark: SetBookmark = Injekt.get(),
|
||||
private val deleteBookmark: DeleteBookmark = Injekt.get(),
|
||||
private val getCategories: GetCategories = Injekt.get(),
|
||||
private val setMangaCategories: SetMangaCategories = Injekt.get(),
|
||||
private val getTracks: GetTracks = Injekt.get(),
|
||||
@ -213,6 +221,7 @@ internal class MigrateDialogScreenModel(
|
||||
val migrateCategories = MigrationFlags.hasCategories(flags)
|
||||
val migrateCustomCover = MigrationFlags.hasCustomCover(flags)
|
||||
val deleteDownloaded = MigrationFlags.hasDeleteDownloaded(flags)
|
||||
val migrateBookmarks = MigrationFlags.hasBookmarks(flags)
|
||||
|
||||
try {
|
||||
syncChaptersWithSource.await(sourceChapters, newManga, newSource)
|
||||
@ -229,7 +238,17 @@ internal class MigrateDialogScreenModel(
|
||||
.filter { it.read }
|
||||
.maxOfOrNull { it.chapterNumber }
|
||||
|
||||
val updatedMangaChapters = mangaChapters.map { mangaChapter ->
|
||||
val bookmarksByChapterId =
|
||||
if (migrateBookmarks) {
|
||||
getBookmarks.await(oldManga.id).groupBy { it.chapterId }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val updatedMangaChapters = mutableListOf<Chapter>()
|
||||
val addedBookmarks = mutableListOf<Bookmark>()
|
||||
|
||||
mangaChapters.forEach { mangaChapter ->
|
||||
var updatedChapter = mangaChapter
|
||||
if (updatedChapter.isRecognizedNumber) {
|
||||
val prevChapter = prevMangaChapters
|
||||
@ -238,8 +257,27 @@ internal class MigrateDialogScreenModel(
|
||||
if (prevChapter != null) {
|
||||
updatedChapter = updatedChapter.copy(
|
||||
dateFetch = prevChapter.dateFetch,
|
||||
bookmark = prevChapter.bookmark,
|
||||
)
|
||||
|
||||
if (migrateBookmarks) {
|
||||
// Don't unbookmark anything, but copy existing bookmarks to updated.
|
||||
if (prevChapter.bookmark) {
|
||||
updatedChapter = updatedChapter.copy(bookmark = true)
|
||||
}
|
||||
|
||||
bookmarksByChapterId
|
||||
?.get(prevChapter.id)
|
||||
?.let { bookmarks ->
|
||||
addedBookmarks.addAll(
|
||||
bookmarks.map { bookmark ->
|
||||
bookmark.copy(
|
||||
mangaId = newManga.id,
|
||||
chapterId = updatedChapter.id,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (maxChapterRead != null && updatedChapter.chapterNumber <= maxChapterRead) {
|
||||
@ -247,11 +285,23 @@ internal class MigrateDialogScreenModel(
|
||||
}
|
||||
}
|
||||
|
||||
updatedChapter
|
||||
updatedMangaChapters.add(updatedChapter)
|
||||
}
|
||||
|
||||
val chapterUpdates = updatedMangaChapters.map { it.toChapterUpdate() }
|
||||
updateChapter.awaitAll(chapterUpdates)
|
||||
|
||||
// Update bookmarks
|
||||
if (migrateBookmarks) {
|
||||
// Delete first, then insert/update in case manga is migrated into itself.
|
||||
if (replace && bookmarksByChapterId?.isNotEmpty() == true) {
|
||||
deleteBookmark.awaitAllByMangaId(oldManga.id, updateChapters = false)
|
||||
}
|
||||
|
||||
if (addedBookmarks.isNotEmpty()) {
|
||||
setBookmark.awaitAll(addedBookmarks, updateChapters = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update categories
|
||||
|
@ -57,13 +57,13 @@ import tachiyomi.core.util.lang.launchNonCancellable
|
||||
import tachiyomi.core.util.lang.withIOContext
|
||||
import tachiyomi.core.util.lang.withUIContext
|
||||
import tachiyomi.core.util.system.logcat
|
||||
import tachiyomi.domain.bookmark.interactor.DeleteBookmark
|
||||
import tachiyomi.domain.bookmark.interactor.SetBookmark
|
||||
import tachiyomi.domain.category.interactor.GetCategories
|
||||
import tachiyomi.domain.category.interactor.SetMangaCategories
|
||||
import tachiyomi.domain.category.model.Category
|
||||
import tachiyomi.domain.chapter.interactor.SetMangaDefaultChapterFlags
|
||||
import tachiyomi.domain.chapter.interactor.UpdateChapter
|
||||
import tachiyomi.domain.chapter.model.Chapter
|
||||
import tachiyomi.domain.chapter.model.ChapterUpdate
|
||||
import tachiyomi.domain.chapter.model.NoChaptersException
|
||||
import tachiyomi.domain.chapter.service.calculateChapterGap
|
||||
import tachiyomi.domain.chapter.service.getChapterSort
|
||||
@ -101,7 +101,6 @@ class MangaScreenModel(
|
||||
private val setMangaChapterFlags: SetMangaChapterFlags = Injekt.get(),
|
||||
private val setMangaDefaultChapterFlags: SetMangaDefaultChapterFlags = Injekt.get(),
|
||||
private val setReadStatus: SetReadStatus = Injekt.get(),
|
||||
private val updateChapter: UpdateChapter = Injekt.get(),
|
||||
private val updateManga: UpdateManga = Injekt.get(),
|
||||
private val syncChaptersWithSource: SyncChaptersWithSource = Injekt.get(),
|
||||
private val getCategories: GetCategories = Injekt.get(),
|
||||
@ -109,6 +108,8 @@ class MangaScreenModel(
|
||||
private val addTracks: AddTracks = Injekt.get(),
|
||||
private val setMangaCategories: SetMangaCategories = Injekt.get(),
|
||||
private val mangaRepository: MangaRepository = Injekt.get(),
|
||||
private val setBookmark: SetBookmark = Injekt.get(),
|
||||
private val deleteBookmark: DeleteBookmark = Injekt.get(),
|
||||
val snackbarHostState: SnackbarHostState = SnackbarHostState(),
|
||||
) : StateScreenModel<MangaScreenModel.State>(State.Loading) {
|
||||
|
||||
@ -737,11 +738,14 @@ class MangaScreenModel(
|
||||
* @param chapters the list of chapters to bookmark.
|
||||
*/
|
||||
fun bookmarkChapters(chapters: List<Chapter>, bookmarked: Boolean) {
|
||||
val toUpdate = chapters.filterNot { it.bookmark == bookmarked }
|
||||
|
||||
screenModelScope.launchIO {
|
||||
chapters
|
||||
.filterNot { it.bookmark == bookmarked }
|
||||
.map { ChapterUpdate(id = it.id, bookmark = bookmarked) }
|
||||
.let { updateChapter.awaitAll(it) }
|
||||
if (bookmarked) {
|
||||
setBookmark.awaitByChapters(toUpdate)
|
||||
} else {
|
||||
deleteBookmark.awaitByChapters(toUpdate)
|
||||
}
|
||||
}
|
||||
toggleAllSelection(false)
|
||||
}
|
||||
|
@ -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)) },
|
||||
|
@ -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 -> {}
|
||||
|
@ -60,6 +60,10 @@ import tachiyomi.core.util.lang.launchNonCancellable
|
||||
import tachiyomi.core.util.lang.withIOContext
|
||||
import tachiyomi.core.util.lang.withUIContext
|
||||
import tachiyomi.core.util.system.logcat
|
||||
import tachiyomi.domain.bookmark.interactor.DeleteBookmark
|
||||
import tachiyomi.domain.bookmark.interactor.GetBookmark
|
||||
import tachiyomi.domain.bookmark.interactor.SetBookmark
|
||||
import tachiyomi.domain.bookmark.model.Bookmark
|
||||
import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
|
||||
import tachiyomi.domain.chapter.interactor.UpdateChapter
|
||||
import tachiyomi.domain.chapter.model.ChapterUpdate
|
||||
@ -97,6 +101,9 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||
private val getNextChapters: GetNextChapters = Injekt.get(),
|
||||
private val upsertHistory: UpsertHistory = Injekt.get(),
|
||||
private val updateChapter: UpdateChapter = Injekt.get(),
|
||||
private val setBookmark: SetBookmark = Injekt.get(),
|
||||
private val deleteBookmark: DeleteBookmark = Injekt.get(),
|
||||
private val getBookmark: GetBookmark = Injekt.get(),
|
||||
private val setMangaViewerFlags: SetMangaViewerFlags = Injekt.get(),
|
||||
) : ViewModel() {
|
||||
|
||||
@ -255,10 +262,15 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes this presenter with the given [mangaId] and [initialChapterId]. This method will
|
||||
* fetch the manga from the database and initialize the initial chapter.
|
||||
* Initializes this presenter with the given [mangaId], [initialChapterId] and [pageIndex].
|
||||
* [pageIndex] is optional, if provided, reader will open that page.
|
||||
* This method will fetch the manga from the database and initialize the initial chapter.
|
||||
*/
|
||||
suspend fun init(mangaId: Long, initialChapterId: Long): Result<Boolean> {
|
||||
suspend fun init(
|
||||
mangaId: Long,
|
||||
initialChapterId: Long,
|
||||
pageIndex: Int? = null,
|
||||
): Result<Boolean> {
|
||||
if (!needsInit()) return Result.success(true)
|
||||
return withIOContext {
|
||||
try {
|
||||
@ -271,7 +283,15 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||
val source = sourceManager.getOrStub(manga.source)
|
||||
loader = ChapterLoader(context, downloadManager, downloadProvider, manga, source)
|
||||
|
||||
loadChapter(loader!!, chapterList.first { chapterId == it.chapter.id })
|
||||
val chapter = chapterList.first { chapterId == it.chapter.id }
|
||||
// TODO: this is hacky solution, what is proper?
|
||||
// Don't update requestedPage if it's already >= 0, as initialized as -1.
|
||||
// But maybe it's sometimes not -1 but expected to be updated?
|
||||
if (pageIndex != null && pageIndex >= 0) {
|
||||
chapter.requestedPage = pageIndex
|
||||
chapter.chapter.last_page_read = pageIndex
|
||||
}
|
||||
loadChapter(loader!!, chapter)
|
||||
Result.success(true)
|
||||
} else {
|
||||
// Unlikely but okay
|
||||
@ -611,12 +631,11 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||
chapter.bookmark = bookmarked
|
||||
|
||||
viewModelScope.launchNonCancellable {
|
||||
updateChapter.await(
|
||||
ChapterUpdate(
|
||||
id = chapter.id!!.toLong(),
|
||||
bookmark = bookmarked,
|
||||
),
|
||||
)
|
||||
if (bookmarked) {
|
||||
setBookmark.await(chapter.manga_id!!.toLong(), chapter.id!!.toLong(), null, null)
|
||||
} else {
|
||||
deleteBookmark.await(chapter.manga_id!!.toLong(), chapter.id!!.toLong(), null)
|
||||
}
|
||||
}
|
||||
|
||||
mutableState.update {
|
||||
@ -626,6 +645,40 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds or updates a page bookmark for a page selected in page-actions dialog.
|
||||
*/
|
||||
fun updateCurrentPageBookmark(bookmarkNote: String) {
|
||||
val manga = manga ?: return
|
||||
val page = (state.value.dialog as? Dialog.PageActions)?.page ?: return
|
||||
|
||||
viewModelScope.launchNonCancellable {
|
||||
setBookmark.await(manga.id, chapterId, page.index, bookmarkNote)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the bookmark for the currently selected page.
|
||||
*/
|
||||
fun deleteCurrentPageBookmark() {
|
||||
val manga = manga ?: return
|
||||
val page = (state.value.dialog as? Dialog.PageActions)?.page ?: return
|
||||
|
||||
viewModelScope.launchNonCancellable {
|
||||
deleteBookmark.await(manga.id, chapterId, page.index)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to retrieve the bookmark for the currently selected page.
|
||||
* @return The bookmark for the current page, or null if it doesn't exist.
|
||||
*/
|
||||
fun getPageBookmark(): Bookmark? {
|
||||
val manga = manga ?: return null
|
||||
val page = (state.value.dialog as? Dialog.PageActions)?.page ?: return null
|
||||
return runBlocking { getBookmark.await(manga.id, chapterId, page.index) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the viewer position used by this manga or the default one.
|
||||
*/
|
||||
|
@ -35,9 +35,11 @@ import logcat.LogPriority
|
||||
import tachiyomi.core.util.lang.launchIO
|
||||
import tachiyomi.core.util.lang.launchNonCancellable
|
||||
import tachiyomi.core.util.system.logcat
|
||||
import tachiyomi.domain.bookmark.interactor.DeleteBookmark
|
||||
import tachiyomi.domain.bookmark.interactor.SetBookmark
|
||||
import tachiyomi.domain.bookmark.model.Bookmark
|
||||
import tachiyomi.domain.bookmark.model.BookmarkDelete
|
||||
import tachiyomi.domain.chapter.interactor.GetChapter
|
||||
import tachiyomi.domain.chapter.interactor.UpdateChapter
|
||||
import tachiyomi.domain.chapter.model.ChapterUpdate
|
||||
import tachiyomi.domain.library.service.LibraryPreferences
|
||||
import tachiyomi.domain.manga.interactor.GetManga
|
||||
import tachiyomi.domain.source.service.SourceManager
|
||||
@ -52,12 +54,13 @@ class UpdatesScreenModel(
|
||||
private val sourceManager: SourceManager = Injekt.get(),
|
||||
private val downloadManager: DownloadManager = Injekt.get(),
|
||||
private val downloadCache: DownloadCache = Injekt.get(),
|
||||
private val updateChapter: UpdateChapter = Injekt.get(),
|
||||
private val setReadStatus: SetReadStatus = Injekt.get(),
|
||||
private val getUpdates: GetUpdates = Injekt.get(),
|
||||
private val getManga: GetManga = Injekt.get(),
|
||||
private val getChapter: GetChapter = Injekt.get(),
|
||||
private val libraryPreferences: LibraryPreferences = Injekt.get(),
|
||||
private val setBookmark: SetBookmark = Injekt.get(),
|
||||
private val deleteBookmark: DeleteBookmark = Injekt.get(),
|
||||
val snackbarHostState: SnackbarHostState = SnackbarHostState(),
|
||||
) : StateScreenModel<UpdatesScreenModel.State>(State()) {
|
||||
|
||||
@ -213,11 +216,21 @@ class UpdatesScreenModel(
|
||||
* @param updates the list of chapters to bookmark.
|
||||
*/
|
||||
fun bookmarkUpdates(updates: List<UpdatesItem>, bookmark: Boolean) {
|
||||
val toUpdate = updates.filterNot { it.update.bookmark == bookmark }
|
||||
screenModelScope.launchIO {
|
||||
updates
|
||||
.filterNot { it.update.bookmark == bookmark }
|
||||
.map { ChapterUpdate(id = it.update.chapterId, bookmark = bookmark) }
|
||||
.let { updateChapter.awaitAll(it) }
|
||||
if (bookmark) {
|
||||
toUpdate
|
||||
.map {
|
||||
Bookmark.create().copy(mangaId = it.update.mangaId, chapterId = it.update.chapterId)
|
||||
}
|
||||
.let { setBookmark.awaitAll(it) }
|
||||
} else {
|
||||
toUpdate
|
||||
.map {
|
||||
BookmarkDelete(mangaId = it.update.mangaId, chapterId = it.update.chapterId)
|
||||
}
|
||||
.let { deleteBookmark.awaitAll(it) }
|
||||
}
|
||||
}
|
||||
toggleAllSelection(false)
|
||||
}
|
||||
|
94
data/src/main/java/tachiyomi/data/bookmark/BookmarkMapper.kt
Normal file
94
data/src/main/java/tachiyomi/data/bookmark/BookmarkMapper.kt
Normal file
@ -0,0 +1,94 @@
|
||||
package tachiyomi.data.bookmark
|
||||
|
||||
import tachiyomi.domain.bookmark.model.Bookmark
|
||||
import tachiyomi.domain.bookmark.model.BookmarkWithChapterNumber
|
||||
import tachiyomi.domain.bookmark.model.BookmarkedPage
|
||||
import tachiyomi.domain.bookmark.model.MangaWithBookmarks
|
||||
import tachiyomi.domain.manga.model.MangaCover
|
||||
|
||||
object BookmarkMapper {
|
||||
fun mapBookmark(
|
||||
id: Long,
|
||||
mangaId: Long,
|
||||
chapterId: Long,
|
||||
pageIndex: Long?,
|
||||
note: String?,
|
||||
lastModifiedAt: Long,
|
||||
): Bookmark = Bookmark(
|
||||
id = id,
|
||||
mangaId = mangaId,
|
||||
chapterId = chapterId,
|
||||
pageIndex = pageIndex?.toInt(),
|
||||
note = note,
|
||||
lastModifiedAt = lastModifiedAt * 1000L,
|
||||
)
|
||||
|
||||
fun mapMangaWithBookmarks(
|
||||
mangaId: Long,
|
||||
mangaTitle: String,
|
||||
mangaThumbnailUrl: String?,
|
||||
mangaSource: Long,
|
||||
isMangaFavorite: Boolean,
|
||||
mangaCoverLastModified: Long,
|
||||
numberOfBookmarks: Long,
|
||||
bookmarkLastModified: Long?,
|
||||
): MangaWithBookmarks = MangaWithBookmarks(
|
||||
mangaId = mangaId,
|
||||
mangaTitle = mangaTitle,
|
||||
numberOfBookmarks = numberOfBookmarks,
|
||||
bookmarkLastModified = (bookmarkLastModified ?: 0L) * 1000L,
|
||||
coverData = MangaCover(
|
||||
mangaId = mangaId,
|
||||
sourceId = mangaSource,
|
||||
isMangaFavorite = isMangaFavorite,
|
||||
url = mangaThumbnailUrl,
|
||||
lastModified = mangaCoverLastModified,
|
||||
),
|
||||
)
|
||||
|
||||
fun mapBookmarkedPage(
|
||||
bookmarkId: Long,
|
||||
mangaId: Long,
|
||||
chapterId: Long,
|
||||
pageIndex: Long?,
|
||||
mangaTitle: String,
|
||||
mangaThumbnailUrl: String?,
|
||||
mangaSource: Long,
|
||||
isMangaFavorite: Boolean,
|
||||
mangaCoverLastModified: Long,
|
||||
chapterNumber: Double,
|
||||
chapterName: String,
|
||||
note: String?,
|
||||
lastModifiedAt: Long,
|
||||
): BookmarkedPage = BookmarkedPage(
|
||||
bookmarkId = bookmarkId,
|
||||
mangaId = mangaId,
|
||||
chapterId = chapterId,
|
||||
pageIndex = pageIndex?.toInt(),
|
||||
mangaTitle = mangaTitle,
|
||||
chapterNumber = chapterNumber,
|
||||
chapterName = chapterName,
|
||||
note = note,
|
||||
lastModifiedAt = lastModifiedAt * 1000L,
|
||||
coverData = MangaCover(
|
||||
mangaId = mangaId,
|
||||
sourceId = mangaSource,
|
||||
isMangaFavorite = isMangaFavorite,
|
||||
url = mangaThumbnailUrl,
|
||||
lastModified = mangaCoverLastModified,
|
||||
),
|
||||
)
|
||||
|
||||
fun mapBookmarkWithChapterNumber(
|
||||
@Suppress("UNUSED_PARAMETER") chapterUrl: String,
|
||||
chapterNumber: Double,
|
||||
pageIndex: Long?,
|
||||
note: String?,
|
||||
lastModifiedAt: Long,
|
||||
): BookmarkWithChapterNumber = BookmarkWithChapterNumber(
|
||||
chapterNumber = chapterNumber,
|
||||
pageIndex = pageIndex?.toInt(),
|
||||
note = note,
|
||||
lastModifiedAt = lastModifiedAt * 1000L,
|
||||
)
|
||||
}
|
@ -0,0 +1,123 @@
|
||||
package tachiyomi.data.bookmark
|
||||
|
||||
import tachiyomi.data.Database
|
||||
import tachiyomi.data.DatabaseHandler
|
||||
import tachiyomi.domain.bookmark.model.Bookmark
|
||||
import tachiyomi.domain.bookmark.model.BookmarkDelete
|
||||
import tachiyomi.domain.bookmark.model.BookmarkUpdate
|
||||
import tachiyomi.domain.bookmark.model.BookmarkWithChapterNumber
|
||||
import tachiyomi.domain.bookmark.model.BookmarkedPage
|
||||
import tachiyomi.domain.bookmark.model.MangaWithBookmarks
|
||||
import tachiyomi.domain.bookmark.repository.BookmarkRepository
|
||||
|
||||
class BookmarkRepositoryImpl(
|
||||
private val handler: DatabaseHandler,
|
||||
) : BookmarkRepository {
|
||||
override suspend fun get(id: Long): Bookmark? {
|
||||
return handler.awaitOneOrNull { bookmarksQueries.getBookmarkById(id, BookmarkMapper::mapBookmark) }
|
||||
}
|
||||
|
||||
override suspend fun get(mangaId: Long, chapterId: Long, pageIndex: Int?): Bookmark? {
|
||||
return handler.awaitOneOrNull {
|
||||
bookmarksQueries.getBookmarkByMangaAndChapterPage(
|
||||
mangaId,
|
||||
chapterId,
|
||||
pageIndex?.toLong(),
|
||||
BookmarkMapper::mapBookmark,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getAllByMangaId(mangaId: Long): List<Bookmark> {
|
||||
return handler.awaitList {
|
||||
bookmarksQueries.getAllByMangaId(mangaId, BookmarkMapper::mapBookmark)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getMangaWithBookmarks(): List<MangaWithBookmarks> {
|
||||
return handler.awaitList {
|
||||
mangaWithBookmarksViewQueries.mangaWithBookmarks(BookmarkMapper::mapMangaWithBookmarks)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getBookmarkedPagesByMangaId(mangaId: Long): List<BookmarkedPage> {
|
||||
return handler.awaitList {
|
||||
bookmarksViewQueries.getBookmarksByManga(mangaId, BookmarkMapper::mapBookmarkedPage)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getWithChapterNumberByMangaId(mangaId: Long): List<BookmarkWithChapterNumber> {
|
||||
return handler.awaitList {
|
||||
bookmarksQueries.getWithChapterInfoByMangaId(mangaId, BookmarkMapper::mapBookmarkWithChapterNumber)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun insert(bookmark: Bookmark) {
|
||||
handler.await {
|
||||
bookmarksQueries.insert(
|
||||
mangaId = bookmark.mangaId,
|
||||
chapterId = bookmark.chapterId,
|
||||
pageIndex = bookmark.pageIndex?.toLong(),
|
||||
note = bookmark.note,
|
||||
// Seconds in DB.
|
||||
lastModifiedAt = bookmark.lastModifiedAt / 1000,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun updatePartial(update: BookmarkUpdate) {
|
||||
handler.await {
|
||||
bookmarksQueries.update(
|
||||
bookmarkId = update.id,
|
||||
note = update.note,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun insertOrReplaceAll(
|
||||
idsToDelete: List<Long>,
|
||||
bookmarksToAdd: List<Bookmark>,
|
||||
) {
|
||||
handler.await(inTransaction = true) {
|
||||
idsToDelete.forEach { bookmarkId -> bookmarksQueries.delete(bookmarkId) }
|
||||
|
||||
bookmarksToAdd.forEach { bookmark ->
|
||||
bookmarksQueries.insert(
|
||||
mangaId = bookmark.mangaId,
|
||||
chapterId = bookmark.chapterId,
|
||||
pageIndex = bookmark.pageIndex?.toLong(),
|
||||
note = bookmark.note,
|
||||
lastModifiedAt = bookmark.lastModifiedAt / 1000,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun delete(bookmarkId: Long) {
|
||||
handler.await { bookmarksQueries.delete(bookmarkId = bookmarkId) }
|
||||
}
|
||||
|
||||
override suspend fun delete(delete: BookmarkDelete) {
|
||||
handler.await { deleteBlocking(delete) }
|
||||
}
|
||||
|
||||
override suspend fun deleteAll(delete: List<BookmarkDelete>) {
|
||||
handler.await(inTransaction = true) {
|
||||
for (bookmark in delete) {
|
||||
deleteBlocking(bookmark)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun deleteAllByMangaId(mangaId: Long) {
|
||||
handler.await { bookmarksQueries.deleteAllByMangaId(mangaId = mangaId) }
|
||||
}
|
||||
|
||||
private fun Database.deleteBlocking(delete: BookmarkDelete) {
|
||||
bookmarksQueries.deleteByMangaAndChapterPage(
|
||||
mangaId = delete.mangaId,
|
||||
chapterId = delete.chapterId,
|
||||
pageIndex = delete.pageIndex?.toLong(),
|
||||
)
|
||||
}
|
||||
}
|
77
data/src/main/sqldelight/tachiyomi/data/bookmarks.sq
Normal file
77
data/src/main/sqldelight/tachiyomi/data/bookmarks.sq
Normal file
@ -0,0 +1,77 @@
|
||||
CREATE TABLE bookmarks(
|
||||
_id INTEGER NOT NULL PRIMARY KEY,
|
||||
manga_id INTEGER NOT NULL,
|
||||
chapter_id INTEGER NOT NULL,
|
||||
page_index INTEGER,
|
||||
note TEXT,
|
||||
last_modified_at INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
FOREIGN KEY(manga_id) REFERENCES mangas (_id)
|
||||
ON DELETE CASCADE,
|
||||
|
||||
FOREIGN KEY(chapter_id) REFERENCES chapters (_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Only single bookmark per page is allowed.
|
||||
CREATE UNIQUE INDEX bookmark_manga_id_chapter_id_page_index
|
||||
ON bookmarks(manga_id, chapter_id, page_index);
|
||||
|
||||
-- For chapters FK with DELETE CASCADE.
|
||||
CREATE INDEX bookmark_chapter_id ON bookmarks(chapter_id);
|
||||
|
||||
CREATE TRIGGER update_last_modified_at_bookmarks
|
||||
AFTER UPDATE ON bookmarks
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
UPDATE bookmarks
|
||||
SET last_modified_at = strftime('%s', 'now')
|
||||
WHERE _id = new._id;
|
||||
END;
|
||||
|
||||
-- Methods
|
||||
getBookmarkById:
|
||||
SELECT *
|
||||
FROM bookmarks
|
||||
WHERE _id = :id;
|
||||
|
||||
getAllByMangaId:
|
||||
SELECT *
|
||||
FROM bookmarks
|
||||
WHERE manga_id = :mangaId;
|
||||
|
||||
getWithChapterInfoByMangaId:
|
||||
SELECT
|
||||
chapters.url,
|
||||
chapters.chapter_number,
|
||||
bookmarks.page_index,
|
||||
bookmarks.note,
|
||||
bookmarks.last_modified_at
|
||||
FROM bookmarks JOIN chapters ON chapters._id = bookmarks.chapter_id
|
||||
WHERE bookmarks.manga_id = :mangaId;
|
||||
|
||||
getBookmarkByMangaAndChapterPage:
|
||||
SELECT *
|
||||
FROM bookmarks
|
||||
WHERE manga_id = :mangaId AND chapter_id = :chapterId AND page_index = :pageIndex;
|
||||
|
||||
insert:
|
||||
INSERT INTO bookmarks(manga_id, chapter_id, page_index, note, last_modified_at)
|
||||
VALUES (:mangaId, :chapterId, :pageIndex, :note, :lastModifiedAt);
|
||||
|
||||
update:
|
||||
UPDATE bookmarks SET
|
||||
note = coalesce(:note, note)
|
||||
WHERE _id = :bookmarkId;
|
||||
|
||||
delete:
|
||||
DELETE FROM bookmarks
|
||||
WHERE _id = :bookmarkId;
|
||||
|
||||
deleteByMangaAndChapterPage:
|
||||
DELETE FROM bookmarks
|
||||
WHERE manga_id = :mangaId AND chapter_id = :chapterId AND page_index = :pageIndex;
|
||||
|
||||
deleteAllByMangaId:
|
||||
DELETE FROM bookmarks
|
||||
WHERE manga_id = :mangaId;
|
80
data/src/main/sqldelight/tachiyomi/migrations/28.sqm
Normal file
80
data/src/main/sqldelight/tachiyomi/migrations/28.sqm
Normal file
@ -0,0 +1,80 @@
|
||||
-- Tables and views for page bookmars with notes.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bookmarks(
|
||||
_id INTEGER NOT NULL PRIMARY KEY,
|
||||
manga_id INTEGER NOT NULL,
|
||||
chapter_id INTEGER NOT NULL,
|
||||
page_index INTEGER,
|
||||
note TEXT,
|
||||
last_modified_at INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
FOREIGN KEY(manga_id) REFERENCES mangas (_id)
|
||||
ON DELETE CASCADE,
|
||||
|
||||
FOREIGN KEY(chapter_id) REFERENCES chapters (_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Only single bookmark per page is allowed.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS bookmark_manga_id_chapter_id_page_index
|
||||
ON bookmarks(manga_id, chapter_id, page_index);
|
||||
|
||||
-- For chapters FK with DELETE CASCADE.
|
||||
CREATE INDEX IF NOT EXISTS bookmark_chapter_id ON bookmarks(chapter_id);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS update_last_modified_at_bookmarks
|
||||
AFTER UPDATE ON bookmarks
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
UPDATE bookmarks
|
||||
SET last_modified_at = strftime('%s', 'now')
|
||||
WHERE _id = new._id;
|
||||
END;
|
||||
|
||||
|
||||
DROP VIEW IF EXISTS bookmarksView;
|
||||
|
||||
CREATE VIEW bookmarksView AS
|
||||
SELECT
|
||||
bookmarks._id AS bookmarkId,
|
||||
mangas._id AS mangaId,
|
||||
chapters._id AS chapterId,
|
||||
bookmarks.page_index AS pageIndex,
|
||||
mangas.title AS mangaTitle,
|
||||
mangas.thumbnail_url AS mangaThumbnailUrl,
|
||||
mangas.source AS mangaSource,
|
||||
mangas.favorite AS isMangaFavorite,
|
||||
mangas.cover_last_modified AS mangaCoverLastModified,
|
||||
chapters.chapter_number AS chapterNumber,
|
||||
chapters.name AS chapterName,
|
||||
bookmarks.note,
|
||||
bookmarks.last_modified_at AS lastModifiedAt
|
||||
FROM bookmarks
|
||||
JOIN mangas ON mangas._id = bookmarks.manga_id
|
||||
JOIN chapters ON chapters._id = bookmarks.chapter_id
|
||||
ORDER BY mangaTitle, chapterNumber ASC, pageIndex ASC;
|
||||
|
||||
|
||||
DROP VIEW IF EXISTS mangaWithBookmarksView;
|
||||
|
||||
CREATE VIEW mangaWithBookmarksView AS
|
||||
SELECT
|
||||
mangas._id AS mangaId,
|
||||
mangas.title AS mangaTitle,
|
||||
mangas.thumbnail_url AS mangaThumbnailUrl,
|
||||
mangas.source AS mangaSource,
|
||||
mangas.favorite AS isMangaFavorite,
|
||||
mangas.cover_last_modified AS mangaCoverLastModified,
|
||||
COUNT(*) AS numberOfBookmarks,
|
||||
MAX(bookmarks.last_modified_at) AS bookmarkLastModified
|
||||
FROM bookmarks
|
||||
JOIN mangas ON mangas._id = bookmarks.manga_id
|
||||
WHERE mangas.favorite = 1
|
||||
GROUP BY mangas._id
|
||||
ORDER BY numberOfBookmarks DESC;
|
||||
|
||||
-- One-time insert new records for all bookmarked chapters.
|
||||
INSERT INTO bookmarks (manga_id, chapter_id, page_index, note, last_modified_at)
|
||||
SELECT manga_id, _id, NULL, NULL, last_modified_at
|
||||
FROM chapters
|
||||
WHERE bookmark = 1 AND NOT EXISTS (SELECT * FROM bookmarks WHERE bookmarks.page_index IS NULL);
|
29
data/src/main/sqldelight/tachiyomi/view/bookmarksView.sq
Normal file
29
data/src/main/sqldelight/tachiyomi/view/bookmarksView.sq
Normal file
@ -0,0 +1,29 @@
|
||||
CREATE VIEW bookmarksView AS
|
||||
SELECT
|
||||
bookmarks._id AS bookmarkId,
|
||||
mangas._id AS mangaId,
|
||||
chapters._id AS chapterId,
|
||||
bookmarks.page_index AS pageIndex,
|
||||
mangas.title AS mangaTitle,
|
||||
mangas.thumbnail_url AS mangaThumbnailUrl,
|
||||
mangas.source AS mangaSource,
|
||||
mangas.favorite AS isMangaFavorite,
|
||||
mangas.cover_last_modified AS mangaCoverLastModified,
|
||||
chapters.chapter_number AS chapterNumber,
|
||||
chapters.name AS chapterName,
|
||||
bookmarks.note,
|
||||
bookmarks.last_modified_at AS lastModifiedAt
|
||||
FROM bookmarks
|
||||
JOIN mangas ON mangas._id = bookmarks.manga_id
|
||||
JOIN chapters ON chapters._id = bookmarks.chapter_id
|
||||
ORDER BY mangaTitle, chapterNumber ASC, pageIndex ASC;
|
||||
|
||||
getBookmarksByManga:
|
||||
SELECT *
|
||||
FROM bookmarksView
|
||||
WHERE mangaId = :mangaId;
|
||||
|
||||
getBookmarksByNotePattern:
|
||||
SELECT *
|
||||
FROM bookmarksView
|
||||
WHERE note LIKE :notePattern;
|
@ -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;
|
@ -0,0 +1,89 @@
|
||||
package tachiyomi.domain.bookmark.interactor
|
||||
|
||||
import logcat.LogPriority
|
||||
import tachiyomi.core.util.lang.withNonCancellableContext
|
||||
import tachiyomi.core.util.system.logcat
|
||||
import tachiyomi.domain.bookmark.model.BookmarkDelete
|
||||
import tachiyomi.domain.bookmark.repository.BookmarkRepository
|
||||
import tachiyomi.domain.chapter.model.Chapter
|
||||
import tachiyomi.domain.chapter.model.ChapterUpdate
|
||||
import tachiyomi.domain.chapter.repository.ChapterRepository
|
||||
|
||||
class DeleteBookmark(
|
||||
private val bookmarkRepository: BookmarkRepository,
|
||||
private val chapterRepository: ChapterRepository,
|
||||
) {
|
||||
suspend fun await(mangaId: Long, chapterId: Long, pageIndex: Int?) = withNonCancellableContext {
|
||||
try {
|
||||
if (pageIndex == null) {
|
||||
chapterRepository.update(ChapterUpdate.bookmarkUpdate(chapterId, false))
|
||||
}
|
||||
bookmarkRepository.delete(
|
||||
BookmarkDelete(
|
||||
mangaId = mangaId,
|
||||
chapterId = chapterId,
|
||||
pageIndex = pageIndex,
|
||||
),
|
||||
)
|
||||
Result.Success
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.ERROR, e)
|
||||
return@withNonCancellableContext Result.InternalError(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun awaitAll(delete: List<BookmarkDelete>) = withNonCancellableContext {
|
||||
try {
|
||||
// Not in transaction, but chapters first
|
||||
// to not to affect existing chapter-level bookmarks.
|
||||
val chapters = delete
|
||||
.mapNotNull {
|
||||
when (it.pageIndex) {
|
||||
null -> ChapterUpdate.bookmarkUpdate(it.chapterId, false)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
if (chapters.isNotEmpty()) {
|
||||
chapterRepository.updateAll(chapters)
|
||||
}
|
||||
|
||||
bookmarkRepository.deleteAll(delete)
|
||||
|
||||
Result.Success
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.ERROR, e)
|
||||
return@withNonCancellableContext Result.InternalError(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun awaitByChapters(chaptersToUnbookmark: List<Chapter>) {
|
||||
return chaptersToUnbookmark
|
||||
.map { BookmarkDelete(mangaId = it.mangaId, chapterId = it.id) }
|
||||
.let { awaitAll(it) }
|
||||
}
|
||||
|
||||
suspend fun awaitAllByMangaId(mangaId: Long, updateChapters: Boolean = true) =
|
||||
withNonCancellableContext {
|
||||
try {
|
||||
if (updateChapters) {
|
||||
val unbookmarkChapters =
|
||||
chapterRepository.getBookmarkedChaptersByMangaId(mangaId)
|
||||
.map { ChapterUpdate.bookmarkUpdate(id = it.id, bookmark = false) }
|
||||
if (unbookmarkChapters.isNotEmpty()) {
|
||||
chapterRepository.updateAll(unbookmarkChapters)
|
||||
}
|
||||
}
|
||||
|
||||
bookmarkRepository.deleteAllByMangaId(mangaId)
|
||||
Result.Success
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.ERROR, e)
|
||||
return@withNonCancellableContext Result.InternalError(e)
|
||||
}
|
||||
}
|
||||
|
||||
sealed class Result {
|
||||
object Success : Result()
|
||||
data class InternalError(val error: Throwable) : Result()
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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() }
|
||||
}
|
@ -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) }
|
||||
}
|
@ -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) }
|
||||
}
|
@ -0,0 +1,131 @@
|
||||
package tachiyomi.domain.bookmark.interactor
|
||||
|
||||
import logcat.LogPriority
|
||||
import tachiyomi.core.util.lang.withNonCancellableContext
|
||||
import tachiyomi.core.util.system.logcat
|
||||
import tachiyomi.domain.bookmark.model.Bookmark
|
||||
import tachiyomi.domain.bookmark.model.BookmarkUpdate
|
||||
import tachiyomi.domain.bookmark.repository.BookmarkRepository
|
||||
import tachiyomi.domain.chapter.model.Chapter
|
||||
import tachiyomi.domain.chapter.model.ChapterUpdate
|
||||
import tachiyomi.domain.chapter.repository.ChapterRepository
|
||||
import java.util.Date
|
||||
|
||||
class SetBookmark(
|
||||
private val bookmarkRepository: BookmarkRepository,
|
||||
private val chapterRepository: ChapterRepository,
|
||||
) {
|
||||
/**
|
||||
* Inserts a new bookmark or updates the note of an existing bookmark if one already exists
|
||||
* for the given manga, chapter, and page index.
|
||||
* Ensures that at most one bookmark per page exists.
|
||||
* Updates correspondent chapter bookmark field.
|
||||
*
|
||||
* @param pageIndex when null, bookmark is considered as chapter bookmark.
|
||||
* @param note Optional text note to be associated with the bookmark.
|
||||
*/
|
||||
suspend fun await(
|
||||
mangaId: Long,
|
||||
chapterId: Long,
|
||||
pageIndex: Int?,
|
||||
note: String?,
|
||||
lastModifiedAt: Long? = null,
|
||||
): Result =
|
||||
withNonCancellableContext {
|
||||
try {
|
||||
if (pageIndex == null) {
|
||||
chapterRepository.update(ChapterUpdate.bookmarkUpdate(chapterId, true))
|
||||
}
|
||||
val existingBookmark = bookmarkRepository.get(mangaId, chapterId, pageIndex)
|
||||
if (existingBookmark != null) {
|
||||
bookmarkRepository.updatePartial(
|
||||
BookmarkUpdate(
|
||||
id = existingBookmark.id,
|
||||
note = note,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
val newBookmark =
|
||||
Bookmark.create()
|
||||
.copy(
|
||||
mangaId = mangaId,
|
||||
chapterId = chapterId,
|
||||
pageIndex = pageIndex,
|
||||
note = note,
|
||||
lastModifiedAt = lastModifiedAt ?: Date().time,
|
||||
)
|
||||
bookmarkRepository.insert(newBookmark)
|
||||
}
|
||||
Result.Success
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.ERROR, e)
|
||||
Result.InternalError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates or inserts new bookmarks
|
||||
* By default updates correspondent chapter bookmark field.
|
||||
*/
|
||||
suspend fun awaitAll(addedBookmarks: List<Bookmark>, updateChapters: Boolean = true): Result {
|
||||
return try {
|
||||
if (updateChapters) {
|
||||
val chapters = addedBookmarks
|
||||
.mapNotNull {
|
||||
when (it.pageIndex) {
|
||||
null -> ChapterUpdate.bookmarkUpdate(it.chapterId, true)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
if (chapters.isNotEmpty()) {
|
||||
chapterRepository.updateAll(chapters)
|
||||
}
|
||||
}
|
||||
|
||||
// Check what should be removed to avoid duplication and when update can be skipped.
|
||||
val oldBookmarks = addedBookmarks
|
||||
.map { it.mangaId }
|
||||
.distinct()
|
||||
.flatMap { mangaId ->
|
||||
bookmarkRepository.getAllByMangaId(mangaId)
|
||||
}
|
||||
.associate { Triple(it.mangaId, it.chapterId, it.pageIndex) to it.id }
|
||||
|
||||
val idsToDelete = mutableListOf<Long>()
|
||||
val toAdd = mutableListOf<Bookmark>()
|
||||
|
||||
addedBookmarks.forEach { bookmark ->
|
||||
val oldBookmarkId =
|
||||
oldBookmarks[Triple(bookmark.mangaId, bookmark.chapterId, bookmark.pageIndex)]
|
||||
// Don't delete & insert if there's already an old bookmark and new has no note set.
|
||||
// However, this prevents merging or backup restores to remove existing notes.
|
||||
// If needed, then add `bookmark.pageIndex == null` to only check for chapter bookmarks.
|
||||
val isUpdateNeeded = oldBookmarkId == null || bookmark.note?.isNotBlank() ?: false
|
||||
|
||||
if (isUpdateNeeded) {
|
||||
oldBookmarkId?.let { idsToDelete.add(it) }
|
||||
toAdd.add(bookmark)
|
||||
}
|
||||
}
|
||||
|
||||
if (toAdd.isNotEmpty()) {
|
||||
bookmarkRepository.insertOrReplaceAll(idsToDelete, toAdd)
|
||||
}
|
||||
Result.Success
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.ERROR, e)
|
||||
Result.InternalError(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun awaitByChapters(chaptersToBookmark: List<Chapter>): Result {
|
||||
return chaptersToBookmark
|
||||
.map { Bookmark.create().copy(mangaId = it.mangaId, chapterId = it.id) }
|
||||
.let { awaitAll(it) }
|
||||
}
|
||||
|
||||
sealed class Result {
|
||||
object Success : Result()
|
||||
data class InternalError(val error: Throwable) : Result()
|
||||
}
|
||||
}
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
package tachiyomi.domain.bookmark.model
|
||||
|
||||
data class BookmarkDelete(
|
||||
val mangaId: Long,
|
||||
val chapterId: Long,
|
||||
val pageIndex: Int? = null,
|
||||
)
|
@ -0,0 +1,6 @@
|
||||
package tachiyomi.domain.bookmark.model
|
||||
|
||||
data class BookmarkUpdate(
|
||||
val id: Long,
|
||||
val note: String? = null,
|
||||
)
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
@ -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,
|
||||
)
|
@ -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,
|
||||
)
|
@ -0,0 +1,27 @@
|
||||
package tachiyomi.domain.bookmark.repository
|
||||
|
||||
import tachiyomi.domain.bookmark.model.Bookmark
|
||||
import tachiyomi.domain.bookmark.model.BookmarkDelete
|
||||
import tachiyomi.domain.bookmark.model.BookmarkUpdate
|
||||
import tachiyomi.domain.bookmark.model.BookmarkWithChapterNumber
|
||||
import tachiyomi.domain.bookmark.model.BookmarkedPage
|
||||
import tachiyomi.domain.bookmark.model.MangaWithBookmarks
|
||||
|
||||
interface BookmarkRepository {
|
||||
suspend fun get(id: Long): Bookmark?
|
||||
suspend fun get(mangaId: Long, chapterId: Long, pageIndex: Int?): Bookmark?
|
||||
suspend fun getAllByMangaId(mangaId: Long): List<Bookmark>
|
||||
|
||||
suspend fun getMangaWithBookmarks(): List<MangaWithBookmarks>
|
||||
suspend fun getBookmarkedPagesByMangaId(mangaId: Long): List<BookmarkedPage>
|
||||
suspend fun getWithChapterNumberByMangaId(mangaId: Long): List<BookmarkWithChapterNumber>
|
||||
|
||||
suspend fun insert(bookmark: Bookmark)
|
||||
suspend fun updatePartial(update: BookmarkUpdate)
|
||||
suspend fun insertOrReplaceAll(idsToDelete: List<Long>, bookmarksToAdd: List<Bookmark>)
|
||||
|
||||
suspend fun delete(bookmarkId: Long)
|
||||
suspend fun delete(delete: BookmarkDelete)
|
||||
suspend fun deleteAll(delete: List<BookmarkDelete>)
|
||||
suspend fun deleteAllByMangaId(mangaId: Long)
|
||||
}
|
@ -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(
|
||||
|
@ -31,6 +31,7 @@
|
||||
<string name="label_backup">Backup and restore</string>
|
||||
<string name="label_data_storage">Data and storage</string>
|
||||
<string name="label_stats">Statistics</string>
|
||||
<string name="label_bookmarks">Bookmarks</string>
|
||||
<string name="label_migration">Migrate</string>
|
||||
<string name="label_extensions">Extensions</string>
|
||||
<string name="label_extension_info">Extension info</string>
|
||||
@ -84,6 +85,11 @@
|
||||
<string name="action_download">Download</string>
|
||||
<string name="action_bookmark">Bookmark chapter</string>
|
||||
<string name="action_remove_bookmark">Unbookmark chapter</string>
|
||||
<string name="action_add_page_bookmark">Add page bookmark</string>
|
||||
<string name="action_update_page_bookmark">Update page bookmark</string>
|
||||
<string name="action_update_bookmark">Update bookmark</string>
|
||||
<string name="action_delete_bookmark">Delete bookmark</string>
|
||||
<string name="action_delete_all_bookmarks">Delete all bookmarks</string>
|
||||
<string name="action_delete">Delete</string>
|
||||
<string name="action_update_library">Update library</string>
|
||||
<string name="action_enable_all">Enable all</string>
|
||||
@ -747,6 +753,7 @@
|
||||
<!-- Reader activity -->
|
||||
<string name="custom_filter">Custom filter</string>
|
||||
<string name="set_as_cover">Set as cover</string>
|
||||
<string name="page_bookmark">Bookmark page</string>
|
||||
<string name="cover_updated">Cover updated</string>
|
||||
<string name="share_page_info">%1$s: %2$s, page %3$d</string>
|
||||
<string name="chapter_progress">Page: %1$d</string>
|
||||
@ -866,6 +873,7 @@
|
||||
<!-- Information Text -->
|
||||
<string name="information_no_downloads">No downloads</string>
|
||||
<string name="information_no_recent">No recent updates</string>
|
||||
<string name="information_no_bookmarks">No bookmarks</string>
|
||||
<string name="information_no_recent_manga">Nothing read recently</string>
|
||||
<string name="information_empty_library">Your library is empty</string>
|
||||
<string name="information_no_manga_category">Category is empty</string>
|
||||
@ -916,4 +924,13 @@
|
||||
<string name="exception_http">HTTP %d, check website in WebView</string>
|
||||
<string name="exception_offline">No Internet connection</string>
|
||||
<string name="exception_unknown_host">Couldn\'t reach %s</string>
|
||||
|
||||
<!-- Bookmarks view and editing. -->
|
||||
<string name="bookmark_note_placeholder">Provide an optional bookmark note</string>
|
||||
<string name="bookmark_total_in_manga">Total bookmarks: %1$d</string>
|
||||
<string name="bookmark_last_updated_in_manga">Last bookmark update: %1$s</string>
|
||||
<string name="delete_bookmark_confirmation">Do you wish to delete the bookmark?</string>
|
||||
<string name="bookmark_page_number">Page: %1$d</string>
|
||||
<string name="bookmark_chapter">Chapter bookmark</string>
|
||||
<string name="bookmark_delete_manga_confirmation">Are you sure? Bookmarks will be lost.</string>
|
||||
</resources>
|
||||
|
Loading…
Reference in New Issue
Block a user