Merge branch 'master' into master

This commit is contained in:
Xwite
2022-04-05 19:15:54 +08:00
committed by GitHub
92 changed files with 1243 additions and 1156 deletions
+2
View File
@@ -14,6 +14,7 @@ import io.legado.app.constant.AppConst.channelIdReadAloud
import io.legado.app.constant.AppConst.channelIdWeb
import io.legado.app.constant.PreferKey
import io.legado.app.data.appDb
import io.legado.app.help.BookHelp
import io.legado.app.help.CrashHandler
import io.legado.app.help.LifecycleHelp
import io.legado.app.help.RuleBigDataHelp
@@ -52,6 +53,7 @@ class App : MultiDexApplication() {
appDb.searchBookDao.clearExpired(clearTime)
}
RuleBigDataHelp.clearInvalid()
BookHelp.clearInvalidCache()
//初始化简繁转换引擎
when (AppConfig.chineseConverterType) {
1 -> ChineseUtils.t2s("初始化")
@@ -7,7 +7,6 @@ import io.legado.app.data.appDb
import io.legado.app.data.entities.BookSource
import io.legado.app.utils.GSON
import io.legado.app.utils.fromJsonArray
import io.legado.app.utils.msg
object BookSourceController {
@@ -23,20 +22,16 @@ object BookSourceController {
fun saveSource(postData: String?): ReturnData {
val returnData = ReturnData()
postData ?: return returnData.setErrorMsg("数据不能为空")
kotlin.runCatching {
val bookSource = BookSource.fromJson(postData)
if (bookSource != null) {
if (TextUtils.isEmpty(bookSource.bookSourceName) || TextUtils.isEmpty(bookSource.bookSourceUrl)) {
returnData.setErrorMsg("源名称和URL不能为空")
} else {
appDb.bookSourceDao.insert(bookSource)
returnData.setData("")
}
val bookSource = BookSource.fromJson(postData).getOrNull()
if (bookSource != null) {
if (TextUtils.isEmpty(bookSource.bookSourceName) || TextUtils.isEmpty(bookSource.bookSourceUrl)) {
returnData.setErrorMsg("源名称和URL不能为空")
} else {
returnData.setErrorMsg("转换源失败")
appDb.bookSourceDao.insert(bookSource)
returnData.setData("")
}
}.onFailure {
returnData.setErrorMsg(it.msg)
} else {
returnData.setErrorMsg("转换源失败")
}
return returnData
}
@@ -44,19 +39,18 @@ object BookSourceController {
fun saveSources(postData: String?): ReturnData {
postData ?: return ReturnData().setErrorMsg("数据为空")
val okSources = arrayListOf<BookSource>()
val bookSources = BookSource.fromJsonArray(postData)
if (bookSources.isNotEmpty()) {
bookSources.forEach { bookSource ->
if (bookSource.bookSourceName.isNotBlank()
&& bookSource.bookSourceUrl.isNotBlank()
) {
appDb.bookSourceDao.insert(bookSource)
okSources.add(bookSource)
}
}
} else {
val bookSources = BookSource.fromJsonArray(postData).getOrNull()
if (bookSources.isNullOrEmpty()) {
return ReturnData().setErrorMsg("转换源失败")
}
bookSources.forEach { bookSource ->
if (bookSource.bookSourceName.isNotBlank()
&& bookSource.bookSourceUrl.isNotBlank()
) {
appDb.bookSourceDao.insert(bookSource)
okSources.add(bookSource)
}
}
return ReturnData().setData(okSources)
}
@@ -5,7 +5,6 @@ import android.text.TextUtils
import io.legado.app.api.ReturnData
import io.legado.app.data.appDb
import io.legado.app.data.entities.RssSource
import io.legado.app.utils.msg
object RssSourceController {
@@ -21,20 +20,15 @@ object RssSourceController {
fun saveSource(postData: String?): ReturnData {
val returnData = ReturnData()
postData ?: return returnData.setErrorMsg("数据不能为空")
kotlin.runCatching {
val source = RssSource.fromJson(postData)
if (source != null) {
if (TextUtils.isEmpty(source.sourceName) || TextUtils.isEmpty(source.sourceUrl)) {
returnData.setErrorMsg("源名称和URL不能为空")
} else {
appDb.rssSourceDao.insert(source)
returnData.setData("")
}
RssSource.fromJson(postData).onFailure {
returnData.setErrorMsg("转换源失败${it.localizedMessage}")
}.onSuccess { source ->
if (TextUtils.isEmpty(source.sourceName) || TextUtils.isEmpty(source.sourceUrl)) {
returnData.setErrorMsg("源名称和URL不能为空")
} else {
returnData.setErrorMsg("转换源失败")
appDb.rssSourceDao.insert(source)
returnData.setData("")
}
}.onFailure {
returnData.setErrorMsg(it.msg)
}
return returnData
}
@@ -42,18 +36,17 @@ object RssSourceController {
fun saveSources(postData: String?): ReturnData {
postData ?: return ReturnData().setErrorMsg("数据不能为空")
val okSources = arrayListOf<RssSource>()
val source = RssSource.fromJsonArray(postData)
if (source.isNotEmpty()) {
for (rssSource in source) {
if (rssSource.sourceName.isBlank() || rssSource.sourceUrl.isBlank()) {
continue
}
appDb.rssSourceDao.insert(rssSource)
okSources.add(rssSource)
}
} else {
val source = RssSource.fromJsonArray(postData).getOrNull()
if (source.isNullOrEmpty()) {
return ReturnData().setErrorMsg("转换源失败")
}
for (rssSource in source) {
if (rssSource.sourceName.isBlank() || rssSource.sourceUrl.isBlank()) {
continue
}
appDb.rssSourceDao.insert(rssSource)
okSources.add(rssSource)
}
return ReturnData().setData(okSources)
}
@@ -70,11 +63,11 @@ object RssSourceController {
fun deleteSources(postData: String?): ReturnData {
postData ?: return ReturnData().setErrorMsg("没有传递数据")
kotlin.runCatching {
RssSource.fromJsonArray(postData).let {
it.forEach { source ->
appDb.rssSourceDao.delete(source)
}
RssSource.fromJsonArray(postData).onFailure {
return ReturnData().setErrorMsg("格式不对")
}.onSuccess {
it.forEach { source ->
appDb.rssSourceDao.delete(source)
}
}
return ReturnData().setData("已执行"/*okSources*/)
@@ -22,7 +22,7 @@ object AppPattern {
val debugMessageSymbolRegex = Regex("[⇒◇┌└≡]")
//本地书籍支持类型
val bookFileRegex = Regex("(?i).*\\.(txt|epub|umd)")
val bookFileRegex = Regex(".*\\.(txt|epub|umd)", RegexOption.IGNORE_CASE)
/**
* 所有标点
@@ -12,15 +12,26 @@ interface BookSourceDao {
@Query(
"""select * from book_sources
where bookSourceName like :searchKey
or bookSourceGroup like :searchKey
or bookSourceUrl like :searchKey
or bookSourceComment like :searchKey
where bookSourceName like '%' || :searchKey || '%'
or bookSourceGroup like '%' || :searchKey || '%'
or bookSourceUrl like '%' || :searchKey || '%'
or bookSourceComment like '%' || :searchKey || '%'
order by customOrder asc"""
)
fun flowSearch(searchKey: String): Flow<List<BookSource>>
@Query("select * from book_sources where bookSourceGroup like :searchKey order by customOrder asc")
@Query(
"""select * from book_sources
where enabled = 1 and
(bookSourceName like '%' || :searchKey || '%'
or bookSourceGroup like '%' || :searchKey || '%'
or bookSourceUrl like '%' || :searchKey || '%'
or bookSourceComment like '%' || :searchKey || '%')
order by customOrder asc"""
)
fun flowSearchEnabled(searchKey: String): Flow<List<BookSource>>
@Query("select * from book_sources where bookSourceGroup like '%' || :searchKey || '%' order by customOrder asc")
fun flowGroupSearch(searchKey: String): Flow<List<BookSource>>
@Query("select * from book_sources where enabled = 1 order by customOrder asc")
@@ -39,7 +50,7 @@ interface BookSourceDao {
"""select * from book_sources
where enabledExplore = 1
and trim(exploreUrl) <> ''
and (bookSourceGroup like :key or bookSourceName like :key)
and (bookSourceGroup like '%' || :key || '%' or bookSourceName like '%' || :key || '%')
order by customOrder asc"""
)
fun flowExplore(key: String): Flow<List<BookSource>>
@@ -48,7 +59,7 @@ interface BookSourceDao {
"""select * from book_sources
where enabledExplore = 1
and trim(exploreUrl) <> ''
and (bookSourceGroup like :key)
and (bookSourceGroup like '%' || :key || '%')
order by customOrder asc"""
)
fun flowGroupExplore(key: String): Flow<List<BookSource>>
@@ -74,6 +85,9 @@ interface BookSourceDao {
@Query("select * from book_sources where enabled = 1 and bookSourceGroup like '%' || :group || '%'")
fun getEnabledByGroup(group: String): List<BookSource>
@Query("select * from book_sources where enabled = 1 and bookSourceType = :type")
fun getEnabledByType(type: Int): List<BookSource>
@get:Query("select * from book_sources where trim(bookUrlPattern) <> '' order by enabled desc, customOrder")
val hasBookUrlPattern: List<BookSource>
@@ -7,7 +7,11 @@ import io.legado.app.data.entities.Bookmark
@Dao
interface BookmarkDao {
@get:Query("select * from bookmarks")
@get:Query(
"""
select * from bookmarks order by bookName collate localized, bookAuthor collate localized, chapterIndex, chapterPos
"""
)
val all: List<Bookmark>
@Query(
@@ -6,6 +6,8 @@ import io.legado.app.constant.AppPattern
import io.legado.app.constant.BookType
import io.legado.app.constant.PageAnim
import io.legado.app.data.appDb
import io.legado.app.help.BookHelp
import io.legado.app.help.ContentProcessor
import io.legado.app.help.config.AppConfig
import io.legado.app.help.config.ReadBookConfig
import io.legado.app.model.ReadBook
@@ -271,7 +273,13 @@ data class Book(
this.tocHtml = this@Book.tocHtml
}
fun changeTo(newBook: Book) {
fun changeTo(newBook: Book, toc: List<BookChapter>): Book {
newBook.durChapterIndex = BookHelp
.getDurChapter(durChapterIndex, durChapterTitle, toc, totalChapterNum)
newBook.durChapterTitle = toc[newBook.durChapterIndex].getDisplayTitle(
ContentProcessor.get(newBook.name, newBook.origin).getTitleReplaceRules()
)
newBook.durChapterPos = durChapterPos
newBook.group = group
newBook.order = order
newBook.customCoverUrl = customCoverUrl
@@ -279,23 +287,11 @@ data class Book(
newBook.customTag = customTag
newBook.canUpdate = canUpdate
newBook.readConfig = readConfig
delete(this)
appDb.bookDao.insert(newBook)
}
fun upInfoFromOld(oldBook: Book?) {
oldBook?.let {
group = oldBook.group
durChapterIndex = oldBook.durChapterIndex
durChapterPos = oldBook.durChapterPos
durChapterTitle = oldBook.durChapterTitle
customCoverUrl = oldBook.customCoverUrl
customIntro = oldBook.customIntro
order = oldBook.order
if (coverUrl.isNullOrEmpty()) {
coverUrl = oldBook.getDisplayCover()
}
if (appDb.bookDao.has(bookUrl) == true) {
delete()
appDb.bookDao.insert(newBook)
}
return newBook
}
fun createBookMark(): Bookmark {
@@ -313,20 +309,19 @@ data class Book(
}
}
fun delete() {
if (ReadBook.book?.bookUrl == bookUrl) {
ReadBook.book = null
}
appDb.bookDao.delete(this)
}
companion object {
const val hTag = 2L
const val rubyTag = 4L
const val imgStyleDefault = "DEFAULT"
const val imgStyleFull = "FULL"
const val imgStyleText = "TEXT"
fun delete(book: Book?) {
book ?: return
if (ReadBook.book?.bookUrl == book.bookUrl) {
ReadBook.book = null
}
appDb.bookDao.delete(book)
}
}
@Parcelize
@@ -11,6 +11,7 @@ import io.legado.app.utils.*
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import splitties.init.appCtx
import java.io.InputStream
@Parcelize
@TypeConverters(BookSource.Converters::class)
@@ -203,13 +204,17 @@ data class BookSource(
companion object {
fun fromJson(json: String): BookSource? {
fun fromJson(json: String): Result<BookSource> {
return SourceAnalyzer.jsonToBookSource(json)
}
fun fromJsonArray(json: String): List<BookSource> {
fun fromJsonArray(json: String): Result<MutableList<BookSource>> {
return SourceAnalyzer.jsonToBookSources(json)
}
fun fromJsonArray(inputStream: InputStream): Result<MutableList<BookSource>> {
return SourceAnalyzer.jsonToBookSources(inputStream)
}
}
class Converters {
@@ -121,7 +121,7 @@ data class RssSource(
@Suppress("MemberVisibilityCanBePrivate")
companion object {
fun fromJsonDoc(doc: DocumentContext): RssSource? {
fun fromJsonDoc(doc: DocumentContext): Result<RssSource> {
return kotlin.runCatching {
val loginUi = doc.read<Any>("$.loginUi")
RssSource(
@@ -152,23 +152,25 @@ data class RssSource(
loadWithBaseUrl = doc.readBool("$.loadWithBaseUrl") ?: true,
customOrder = doc.readInt("$.customOrder") ?: 0
)
}.getOrNull()
}
}
fun fromJson(json: String): RssSource? {
fun fromJson(json: String): Result<RssSource> {
return fromJsonDoc(jsonPath.parse(json))
}
fun fromJsonArray(jsonArray: String): ArrayList<RssSource> {
val sources = arrayListOf<RssSource>()
val doc = jsonPath.parse(jsonArray).read<List<*>>("$")
doc.forEach {
val jsonItem = jsonPath.parse(it)
fromJsonDoc(jsonItem)?.let { source ->
sources.add(source)
fun fromJsonArray(jsonArray: String): Result<ArrayList<RssSource>> {
return kotlin.runCatching {
val sources = arrayListOf<RssSource>()
val doc = jsonPath.parse(jsonArray).read<List<*>>("$")
doc.forEach {
val jsonItem = jsonPath.parse(it)
fromJsonDoc(jsonItem).getOrThrow().let { source ->
sources.add(source)
}
}
sources
}
return sources
}
}
@@ -6,14 +6,11 @@ import io.legado.app.data.appDb
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter
import io.legado.app.data.entities.BookSource
import io.legado.app.help.coroutine.Coroutine
import io.legado.app.model.analyzeRule.AnalyzeUrl
import io.legado.app.model.localBook.LocalBook
import io.legado.app.utils.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers.IO
import org.apache.commons.text.similarity.JaccardSimilarity
import splitties.init.appCtx
import java.io.File
@@ -44,11 +41,10 @@ object BookHelp {
/**
* 清除已删除书的缓存
*/
fun clearRemovedCache() {
Coroutine.async {
val bookFolderNames = arrayListOf<String>()
appDb.bookDao.all.forEach {
bookFolderNames.add(it.getFolderName())
suspend fun clearInvalidCache() {
withContext(IO) {
val bookFolderNames = appDb.bookDao.all.map {
it.getFolderName()
}
val file = downloadDir.getFile(cacheFolderName)
file.listFiles()?.forEach { bookFile ->
@@ -54,13 +54,11 @@ object DefaultData {
}
val rssSources: List<RssSource> by lazy {
kotlin.runCatching {
val json = String(
appCtx.assets.open("defaultData${File.separator}rssSources.json")
.readBytes()
)
RssSource.fromJsonArray(json)
}.getOrDefault(emptyList())
val json = String(
appCtx.assets.open("defaultData${File.separator}rssSources.json")
.readBytes()
)
RssSource.fromJsonArray(json).getOrDefault(emptyList())
}
val coverRuleConfig: BookCover.CoverRuleConfig by lazy {
@@ -139,7 +139,7 @@ interface JsExtensions {
path.startsWith("/storage") -> FileUtils.readText(path)
else -> readTxtFile(path)
}
if (result.isBlank()) throw NoStackTraceException("${path} 内容获取失败或者为空")
if (result.isBlank()) throw NoStackTraceException("$path 内容获取失败或者为空")
return result
}
@@ -7,7 +7,9 @@ import io.legado.app.constant.AppLog
import io.legado.app.constant.BookType
import io.legado.app.data.entities.BookSource
import io.legado.app.data.entities.rule.*
import io.legado.app.exception.NoStackTraceException
import io.legado.app.utils.*
import java.io.InputStream
import java.util.regex.Pattern
@@ -16,31 +18,66 @@ object SourceAnalyzer {
private val headerPattern = Pattern.compile("@Header:\\{.+?\\}", Pattern.CASE_INSENSITIVE)
private val jsPattern = Pattern.compile("\\{\\{.+?\\}\\}", Pattern.CASE_INSENSITIVE)
fun jsonToBookSources(json: String): List<BookSource> {
val bookSources = mutableListOf<BookSource>()
if (json.isJsonArray()) {
val items: List<Map<String, Any>> = jsonPath.parse(json).read("$")
for (item in items) {
fun jsonToBookSources(json: String): Result<MutableList<BookSource>> {
return kotlin.runCatching {
val bookSources = mutableListOf<BookSource>()
when {
json.isJsonArray() -> {
val items: List<Map<String, Any>> = jsonPath.parse(json).read("$")
for (item in items) {
val jsonItem = jsonPath.parse(item)
jsonToBookSource(jsonItem.jsonString()).getOrThrow().let {
bookSources.add(it)
}
}
}
json.isJsonObject() -> {
jsonToBookSource(json).getOrThrow().let {
bookSources.add(it)
}
}
else -> {
throw NoStackTraceException("格式不对")
}
}
bookSources
}
}
fun jsonToBookSources(inputStream: InputStream): Result<MutableList<BookSource>> {
return kotlin.runCatching {
val bookSources = mutableListOf<BookSource>()
kotlin.runCatching {
val items: List<Map<String, Any>> = jsonPath.parse(inputStream).read("$")
for (item in items) {
val jsonItem = jsonPath.parse(item)
jsonToBookSource(jsonItem.jsonString()).getOrThrow().let {
bookSources.add(it)
}
}
}.onFailure {
val item: Map<String, Any> = jsonPath.parse(inputStream).read("$")
val jsonItem = jsonPath.parse(item)
jsonToBookSource(jsonItem.jsonString())?.let {
jsonToBookSource(jsonItem.jsonString()).getOrThrow().let {
bookSources.add(it)
}
}
bookSources
}
return bookSources
}
fun jsonToBookSource(json: String): BookSource? {
fun jsonToBookSource(json: String): Result<BookSource> {
val source = BookSource()
val sourceAny = GSON.fromJsonObject<BookSourceAny>(json.trim())
.onFailure {
AppLog.put("转化书源出错", it)
}.getOrNull()
try {
return kotlin.runCatching {
if (sourceAny?.ruleToc == null) {
source.apply {
val jsonItem = jsonPath.parse(json.trim())
bookSourceUrl = jsonItem.readString("bookSourceUrl") ?: return null
bookSourceUrl = jsonItem.readString("bookSourceUrl")
?: throw NoStackTraceException("格式不对")
bookSourceName = jsonItem.readString("bookSourceName") ?: ""
bookSourceGroup = jsonItem.readString("bookSourceGroup")
loginUrl = jsonItem.readString("loginUrl")
@@ -168,10 +205,8 @@ object SourceAnalyzer {
.getOrNull()
}
}
} catch (e: Exception) {
e.printOnDebug()
source
}
return source
}
@Keep
@@ -301,6 +301,12 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
val doublePageHorizontal: Boolean
get() = appCtx.getPrefBoolean(PreferKey.doublePageHorizontal, true)
var searchGroup: String
get() = appCtx.getPrefString("searchGroup") ?: ""
set(value) {
appCtx.putPrefString("searchGroup", value)
}
private fun getPrefUserAgent(): String {
val ua = appCtx.getPrefString(PreferKey.userAgent)
if (ua.isNullOrBlank()) {
@@ -91,9 +91,10 @@ object ImportOldData {
}
fun importOldSource(json: String): Int {
val bookSources = BookSource.fromJsonArray(json)
appDb.bookSourceDao.insert(*bookSources.toTypedArray())
return bookSources.size
val count = BookSource.fromJsonArray(json).onSuccess {
appDb.bookSourceDao.insert(*it.toTypedArray())
}.getOrNull()?.size
return count ?: 0
}
private fun importOldReplaceRule(json: String): Int {
@@ -22,6 +22,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import splitties.init.appCtx
import java.io.File
import java.io.FileInputStream
object Restore {
@@ -192,8 +193,9 @@ object Restore {
private inline fun <reified T> fileToListT(path: String, fileName: String): List<T>? {
try {
val file = FileUtils.createFileIfNotExist(path + File.separator + fileName)
val json = file.readText()
return GSON.fromJsonArray<T>(json).getOrThrow()
FileInputStream(file).use {
return GSON.fromJsonArray<T>(it).getOrThrow()
}
} catch (e: Exception) {
AppLog.put("$fileName\n读取解析出错\n${e.localizedMessage}", e)
appCtx.toastOnUi("$fileName\n读取文件出错\n${e.localizedMessage}")
@@ -48,9 +48,9 @@ fun Context.alert(
inline fun Fragment.alert(
titleResource: Int? = null,
message: Int? = null,
messageResource: Int? = null,
noinline init: (AlertBuilder<DialogInterface>.() -> Unit)? = null
) = requireActivity().alert(titleResource, message, init)
) = requireActivity().alert(titleResource, messageResource, init)
fun Context.alert(init: AlertBuilder<AlertDialog>.() -> Unit): AlertDialog =
AndroidAlertBuilder(this).apply {
@@ -1,5 +1,6 @@
package io.legado.app.lib.webdav
import io.legado.app.constant.AppLog
import io.legado.app.help.http.newCallResponseBody
import io.legado.app.help.http.okHttpClient
import io.legado.app.help.http.text
@@ -168,6 +169,8 @@ class WebDav(urlStr: String) {
addHeader("Authorization", Credentials.basic(auth.user, auth.pass))
}.close()
}
}.onFailure {
AppLog.put(it.localizedMessage)
}.isSuccess
}
return false
@@ -22,6 +22,7 @@ import javax.script.SimpleBindings
object LocalBook {
private val nameAuthorPatterns = arrayOf(
Pattern.compile("(.*?)《([^《》]+)》.*?作者:(.*)"),
Pattern.compile("(.*?)《([^《》]+)》(.*)"),
Pattern.compile("(^)(.+) 作者:(.+)$"),
Pattern.compile("(^)(.+) by (.+)$")
@@ -8,7 +8,6 @@ import io.legado.app.data.entities.SearchBook
import io.legado.app.help.config.AppConfig
import io.legado.app.help.coroutine.CompositeCoroutine
import io.legado.app.utils.getPrefBoolean
import io.legado.app.utils.getPrefString
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExecutorCoroutineDispatcher
import kotlinx.coroutines.asCoroutineDispatcher
@@ -60,7 +59,7 @@ class SearchModel(private val scope: CoroutineScope) {
initSearchPool()
mSearchId = searchId
searchPage = 1
val searchGroup = appCtx.getPrefString("searchGroup") ?: ""
val searchGroup = AppConfig.searchGroup
bookSourceList.clear()
searchBooks.clear()
callBack?.onSearchSuccess(searchBooks)
@@ -69,6 +68,7 @@ class SearchModel(private val scope: CoroutineScope) {
} else {
val sources = appDb.bookSourceDao.getEnabledByGroup(searchGroup)
if (sources.isEmpty()) {
AppConfig.searchGroup = ""
bookSourceList.addAll(appDb.bookSourceDao.allEnabled)
} else {
bookSourceList.addAll(sources)
@@ -262,7 +262,7 @@ object WebBook {
Debug.log(bookSource.bookSourceUrl, "⇒正文规则为空,使用章节链接:${bookChapter.url}")
return bookChapter.url
}
if(bookChapter.isVolume && bookChapter.url.startsWith(bookChapter.title)) {
if (bookChapter.isVolume && bookChapter.url.startsWith(bookChapter.title)) {
Debug.log(bookSource.bookSourceUrl, "⇒一级目录正文不解析规则")
return bookChapter.tag ?: ""
}
@@ -320,35 +320,38 @@ object WebBook {
name: String,
author: String,
context: CoroutineContext = Dispatchers.IO,
): Coroutine<Pair<BookSource, Book>> {
): Coroutine<Pair<Book, BookSource>> {
return Coroutine.async(scope, context) {
preciseSearchAwait(scope, bookSources, name, author)
?: throw NoStackTraceException("没有搜索到<$name>$author")
for (source in bookSources) {
val book = preciseSearchAwait(scope, source, name, author).getOrNull()
if (book != null) {
return@async Pair(book, source)
}
}
throw NoStackTraceException("没有搜索到<$name>$author")
}
}
suspend fun preciseSearchAwait(
scope: CoroutineScope,
bookSources: List<BookSource>,
bookSource: BookSource,
name: String,
author: String
): Pair<BookSource, Book>? {
bookSources.forEach { source ->
kotlin.runCatching {
if (!scope.isActive) return null
searchBookAwait(scope, source, name).firstOrNull {
it.name == name && it.author == author
}?.let { searchBook ->
if (!scope.isActive) return null
var book = searchBook.toBook()
if (book.tocUrl.isBlank()) {
book = getBookInfoAwait(scope, source, book)
}
return Pair(source, book)
author: String,
): Result<Book?> {
return kotlin.runCatching {
if (!scope.isActive) return@runCatching null
searchBookAwait(scope, bookSource, name).firstOrNull {
it.name == name && it.author == author
}?.let { searchBook ->
if (!scope.isActive) return@runCatching null
var book = searchBook.toBook()
if (book.tocUrl.isBlank()) {
book = getBookInfoAwait(scope, bookSource, book)
}
return@runCatching book
}
return@runCatching null
}
return null
}
}
@@ -31,9 +31,8 @@ class FileAssociationViewModel(application: Application) : BaseAssociationViewMo
} else {
DocumentFile.fromSingleUri(context, uri)?.readText(context)
} ?: throw NoStackTraceException("文件不存在")
if (content.isJson()) {
//暂时根据文件内容判断属于什么
when {
when {
content.isJson() -> when {
content.contains("bookSourceUrl") ->
importBookSourceLive.postValue(content)
content.contains("sourceUrl") ->
@@ -48,10 +47,12 @@ class FileAssociationViewModel(application: Application) : BaseAssociationViewMo
importHttpTTS(content, finally)
else -> errorLiveData.postValue("格式不对")
}
} else if (uri.toString().matches(bookFileRegex)) {
importBookLiveData.postValue(uri)
} else {
throw NoStackTraceException("暂未支持的本地书籍格式(TXT/UMD/EPUB)")
(uri.path ?: uri.toString()).matches(bookFileRegex) -> {
importBookLiveData.postValue(uri)
}
else -> {
throw NoStackTraceException("暂未支持的本地书籍格式(TXT/UMD/EPUB)")
}
}
} else {
onLineImportLive.postValue(uri)
@@ -189,7 +189,7 @@ class ImportBookSourceDialog() : BaseDialogFragment(R.layout.dialog_recycler_vie
override fun onCodeSave(code: String, requestId: String?) {
requestId?.toInt()?.let {
BookSource.fromJson(code)?.let { source ->
BookSource.fromJson(code).getOrNull()?.let { source ->
viewModel.allSources[it] = source
adapter.setItem(it, source)
}
@@ -14,7 +14,6 @@ import io.legado.app.help.SourceHelp
import io.legado.app.help.config.AppConfig
import io.legado.app.help.http.newCallResponseBody
import io.legado.app.help.http.okHttpClient
import io.legado.app.help.http.text
import io.legado.app.utils.*
@@ -98,13 +97,12 @@ class ImportBookSourceViewModel(app: Application) : BaseViewModel(app) {
importSourceUrl(it)
}
} else {
BookSource.fromJson(mText)?.let {
BookSource.fromJson(mText).getOrThrow().let {
allSources.add(it)
}
}
}
mText.isJsonArray() -> {
val items = BookSource.fromJsonArray(mText)
mText.isJsonArray() -> BookSource.fromJsonArray(mText).getOrThrow().let { items ->
allSources.addAll(items)
}
mText.isAbsUrl() -> {
@@ -123,26 +121,8 @@ class ImportBookSourceViewModel(app: Application) : BaseViewModel(app) {
private suspend fun importSourceUrl(url: String) {
okHttpClient.newCallResponseBody {
url(url)
}.text("utf-8").let { body ->
when {
body.isJsonArray() -> {
val items: List<Map<String, Any>> = jsonPath.parse(body).read("$")
for (item in items) {
val jsonItem = jsonPath.parse(item)
BookSource.fromJson(jsonItem.jsonString())?.let { source ->
allSources.add(source)
}
}
}
body.isJsonObject() -> {
BookSource.fromJson(body)?.let {
allSources.add(it)
}
}
else -> {
throw NoStackTraceException(context.getString(R.string.wrong_format))
}
}
}.byteStream().let {
allSources.addAll(BookSource.fromJsonArray(it).getOrThrow())
}
}
@@ -92,14 +92,14 @@ class ImportRssSourceDialog() : BaseDialogFragment(R.layout.dialog_recycler_view
adapter.notifyDataSetChanged()
upSelectText()
}
viewModel.errorLiveData.observe(this, {
viewModel.errorLiveData.observe(this) {
binding.rotateLoading.hide()
binding.tvMsg.apply {
text = it
visible()
}
})
viewModel.successLiveData.observe(this, {
}
viewModel.successLiveData.observe(this) {
binding.rotateLoading.hide()
if (it > 0) {
adapter.setItems(viewModel.allSources)
@@ -110,7 +110,7 @@ class ImportRssSourceDialog() : BaseDialogFragment(R.layout.dialog_recycler_view
visible()
}
}
})
}
val source = arguments?.getString("source")
if (source.isNullOrEmpty()) {
dismiss()
@@ -188,7 +188,7 @@ class ImportRssSourceDialog() : BaseDialogFragment(R.layout.dialog_recycler_view
override fun onCodeSave(code: String, requestId: String?) {
requestId?.toInt()?.let {
RssSource.fromJson(code)?.let { source ->
RssSource.fromJson(code).getOrNull()?.let { source ->
viewModel.allSources[it] = source
adapter.setItem(it, source)
}
@@ -13,7 +13,6 @@ import io.legado.app.help.SourceHelp
import io.legado.app.help.config.AppConfig
import io.legado.app.help.http.newCallResponseBody
import io.legado.app.help.http.okHttpClient
import io.legado.app.help.http.text
import io.legado.app.utils.*
class ImportRssSourceViewModel(app: Application) : BaseViewModel(app) {
@@ -95,7 +94,7 @@ class ImportRssSourceViewModel(app: Application) : BaseViewModel(app) {
importSourceUrl(it)
}
} else {
RssSource.fromJsonArray(mText).let {
RssSource.fromJsonArray(mText).getOrThrow().let {
allSources.addAll(it)
}
}
@@ -104,7 +103,7 @@ class ImportRssSourceViewModel(app: Application) : BaseViewModel(app) {
val items: List<Map<String, Any>> = jsonPath.parse(mText).read("$")
for (item in items) {
val jsonItem = jsonPath.parse(item)
RssSource.fromJsonDoc(jsonItem)?.let {
RssSource.fromJsonDoc(jsonItem).getOrThrow().let {
allSources.add(it)
}
}
@@ -124,11 +123,11 @@ class ImportRssSourceViewModel(app: Application) : BaseViewModel(app) {
private suspend fun importSourceUrl(url: String) {
okHttpClient.newCallResponseBody {
url(url)
}.text("utf-8").let { body ->
}.byteStream().let { body ->
val items: List<Map<String, Any>> = jsonPath.parse(body).read("$")
for (item in items) {
val jsonItem = jsonPath.parse(item)
RssSource.fromJson(jsonItem.jsonString())?.let { source ->
RssSource.fromJson(jsonItem.jsonString()).getOrThrow().let { source ->
allSources.add(source)
}
}
@@ -15,11 +15,13 @@ import io.legado.app.constant.PreferKey
import io.legado.app.data.appDb
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookGroup
import io.legado.app.data.entities.BookSource
import io.legado.app.databinding.ActivityArrangeBookBinding
import io.legado.app.lib.dialogs.alert
import io.legado.app.lib.theme.primaryColor
import io.legado.app.ui.book.group.GroupManageDialog
import io.legado.app.ui.book.group.GroupSelectDialog
import io.legado.app.ui.theme.AppTheme
import io.legado.app.ui.widget.SelectActionBar
import io.legado.app.ui.widget.recycler.DragSelectTouchHelper
import io.legado.app.ui.widget.recycler.ItemTouchCallback
@@ -41,6 +43,7 @@ class ArrangeBookActivity : VMBaseActivity<ActivityArrangeBookBinding, ArrangeBo
PopupMenu.OnMenuItemClickListener,
SelectActionBar.CallBack,
ArrangeBookAdapter.CallBack,
SourcePickerDialog.Callback,
GroupSelectDialog.CallBack {
override val binding by viewBinding(ActivityArrangeBookBinding::inflate)
@@ -107,6 +110,17 @@ class ArrangeBookActivity : VMBaseActivity<ActivityArrangeBookBinding, ArrangeBo
binding.selectActionBar.inflateMenu(R.menu.arrange_book_sel)
binding.selectActionBar.setOnMenuItemClickListener(this)
binding.selectActionBar.setCallBack(this)
binding.composeView.setContent {
AppTheme {
BatchChangeSourceDialog(
state = viewModel.batchChangeSourceState,
size = viewModel.batchChangeSourceSize,
position = viewModel.batchChangeSourcePosition
) {
viewModel.batchChangeSourceCoroutine?.cancel()
}
}
}
}
@SuppressLint("NotifyDataSetChanged")
@@ -169,6 +183,7 @@ class ArrangeBookActivity : VMBaseActivity<ActivityArrangeBookBinding, ArrangeBo
R.id.menu_update_disable ->
viewModel.upCanUpdate(adapter.selectedBooks(), false)
R.id.menu_add_to_group -> selectGroup(addToGroupRequestCode, 0)
R.id.menu_change_source -> showDialogFragment<SourcePickerDialog>()
}
return false
}
@@ -228,4 +243,9 @@ class ArrangeBookActivity : VMBaseActivity<ActivityArrangeBookBinding, ArrangeBo
}
}
override fun sourceOnClick(source: BookSource) {
viewModel.changeSource(adapter.selectedBooks(), source)
viewModel.batchChangeSourceState.value = true
}
}
@@ -1,13 +1,22 @@
package io.legado.app.ui.book.arrange
import android.app.Application
import androidx.compose.runtime.mutableStateOf
import io.legado.app.base.BaseViewModel
import io.legado.app.data.appDb
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookSource
import io.legado.app.help.coroutine.Coroutine
import io.legado.app.model.webBook.WebBook
class ArrangeBookViewModel(application: Application) : BaseViewModel(application) {
val batchChangeSourceState = mutableStateOf(false)
val batchChangeSourceSize = mutableStateOf(0)
val batchChangeSourcePosition = mutableStateOf(0)
var batchChangeSourceCoroutine: Coroutine<Unit>? = null
fun upCanUpdate(books: Array<Book>, canUpdate: Boolean) {
execute {
books.forEach {
@@ -29,4 +38,24 @@ class ArrangeBookViewModel(application: Application) : BaseViewModel(application
}
}
fun changeSource(books: Array<Book>, source: BookSource) {
batchChangeSourceCoroutine?.cancel()
batchChangeSourceCoroutine = execute {
batchChangeSourceSize.value = books.size
books.forEachIndexed { index, book ->
batchChangeSourcePosition.value = index + 1
if (book.isLocalBook()) return@forEachIndexed
if (book.origin == source.bookSourceUrl) return@forEachIndexed
WebBook.preciseSearchAwait(this, source, book.name, book.author)
.getOrNull()?.let { newBook ->
val toc = WebBook.getChapterListAwait(this, source, newBook)
book.changeTo(newBook, toc)
appDb.bookChapterDao.insert(*toc.toTypedArray())
}
}
}.onFinally {
batchChangeSourceState.value = false
}
}
}
@@ -0,0 +1,45 @@
package io.legado.app.ui.book.arrange
import androidx.compose.foundation.layout.Column
import androidx.compose.material.AlertDialog
import androidx.compose.material.LinearProgressIndicator
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Alignment
import io.legado.app.R
import splitties.init.appCtx
@Composable
fun BatchChangeSourceDialog(
state: MutableState<Boolean>,
size: MutableState<Int>,
position: MutableState<Int>,
cancel: () -> Unit
) {
if (state.value) {
AlertDialog(
onDismissRequest = { },
confirmButton = {
TextButton(onClick = {
cancel.invoke()
state.value = false
}, content = {
Text(text = "取消")
})
},
title = {
Text(text = appCtx.getString(R.string.change_source_batch))
},
text = {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = "${position.value}/${size.value}")
LinearProgressIndicator(
progress = position.value / size.value.toFloat()
)
}
}
)
}
}
@@ -0,0 +1,121 @@
package io.legado.app.ui.book.arrange
import android.content.Context
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.SearchView
import androidx.core.view.setPadding
import androidx.recyclerview.widget.LinearLayoutManager
import io.legado.app.R
import io.legado.app.base.BaseDialogFragment
import io.legado.app.base.adapter.ItemViewHolder
import io.legado.app.base.adapter.RecyclerAdapter
import io.legado.app.data.appDb
import io.legado.app.data.entities.BookSource
import io.legado.app.databinding.DialogSourcePickerBinding
import io.legado.app.databinding.Item1lineTextBinding
import io.legado.app.lib.theme.primaryColor
import io.legado.app.lib.theme.primaryTextColor
import io.legado.app.utils.applyTint
import io.legado.app.utils.dpToPx
import io.legado.app.utils.setLayout
import io.legado.app.utils.viewbindingdelegate.viewBinding
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import splitties.views.onClick
class SourcePickerDialog : BaseDialogFragment(R.layout.dialog_source_picker) {
private val binding by viewBinding(DialogSourcePickerBinding::bind)
private val searchView: SearchView by lazy {
binding.toolBar.findViewById(R.id.search_view)
}
private val adapter by lazy {
SourceAdapter(requireContext())
}
private var sourceFlowJob: Job? = null
override fun onStart() {
super.onStart()
setLayout(1f, ViewGroup.LayoutParams.MATCH_PARENT)
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
initView()
initData()
}
private fun initView() {
binding.toolBar.setBackgroundColor(primaryColor)
binding.toolBar.title = "选择书源"
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter
searchView.applyTint(primaryTextColor)
searchView.onActionViewExpanded()
searchView.isSubmitButtonEnabled = true
searchView.queryHint = getString(R.string.search_book_source)
searchView.clearFocus()
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean {
return false
}
override fun onQueryTextChange(newText: String?): Boolean {
initData(newText)
return false
}
})
}
private fun initData(searchKey: String? = null) {
sourceFlowJob?.cancel()
sourceFlowJob = launch {
when {
searchKey.isNullOrEmpty() -> appDb.bookSourceDao.flowEnabled()
else -> appDb.bookSourceDao.flowSearchEnabled(searchKey)
}.collect {
adapter.setItems(it)
}
}
}
inner class SourceAdapter(context: Context) :
RecyclerAdapter<BookSource, Item1lineTextBinding>(context) {
override fun getViewBinding(parent: ViewGroup): Item1lineTextBinding {
return Item1lineTextBinding.inflate(inflater, parent, false).apply {
root.setPadding(16.dpToPx())
}
}
override fun convert(
holder: ItemViewHolder,
binding: Item1lineTextBinding,
item: BookSource,
payloads: MutableList<Any>
) {
binding.textView.text = item.getDisPlayNameGroup()
}
override fun registerListener(holder: ItemViewHolder, binding: Item1lineTextBinding) {
binding.root.onClick {
getItemByLayoutPosition(holder.layoutPosition)?.let {
callback?.sourceOnClick(it)
dismissAllowingStateLoss()
}
}
}
}
private val callback: Callback?
get() {
return (parentFragment as? Callback) ?: activity as? Callback
}
interface Callback {
fun sourceOnClick(source: BookSource)
}
}
@@ -12,10 +12,12 @@ import androidx.activity.viewModels
import androidx.compose.runtime.mutableStateOf
import io.legado.app.R
import io.legado.app.base.VMBaseActivity
import io.legado.app.constant.BookType
import io.legado.app.constant.EventBus
import io.legado.app.constant.Status
import io.legado.app.constant.Theme
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter
import io.legado.app.data.entities.BookSource
import io.legado.app.databinding.ActivityAudioPlayBinding
import io.legado.app.lib.dialogs.alert
@@ -24,6 +26,7 @@ import io.legado.app.model.BookCover
import io.legado.app.service.AudioPlayService
import io.legado.app.ui.about.AppLogDialog
import io.legado.app.ui.book.changesource.ChangeBookSourceDialog
import io.legado.app.ui.book.read.ReadBookActivity
import io.legado.app.ui.book.source.edit.BookSourceEditActivity
import io.legado.app.ui.book.toc.TocActivityResult
import io.legado.app.ui.login.SourceLoginActivity
@@ -31,6 +34,9 @@ import io.legado.app.ui.theme.AppTheme
import io.legado.app.ui.widget.seekbar.SeekBarChangeListener
import io.legado.app.utils.*
import io.legado.app.utils.viewbindingdelegate.viewBinding
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import splitties.views.onLongClick
import java.util.*
@@ -198,8 +204,21 @@ class AudioPlayActivity :
override val oldBook: Book?
get() = AudioPlay.book
override fun changeTo(source: BookSource, book: Book) {
viewModel.changeTo(source, book)
override fun changeTo(source: BookSource, book: Book, toc: List<BookChapter>) {
if (book.type == BookType.audio) {
viewModel.changeTo(source, book, toc)
} else {
AudioPlay.stop(this)
launch {
withContext(IO) {
AudioPlay.book?.changeTo(book, toc)
}
startActivity<ReadBookActivity> {
putExtra("bookUrl", book.bookUrl)
}
finish()
}
}
}
override fun finish() {
@@ -9,8 +9,6 @@ import io.legado.app.data.appDb
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter
import io.legado.app.data.entities.BookSource
import io.legado.app.help.BookHelp
import io.legado.app.help.ContentProcessor
import io.legado.app.model.AudioPlay
import io.legado.app.model.webBook.WebBook
import io.legado.app.utils.postEvent
@@ -45,33 +43,23 @@ class AudioPlayViewModel(application: Application) : BaseViewModel(application)
}
}
private fun loadBookInfo(
book: Book,
changeDruChapterIndex: ((chapters: List<BookChapter>) -> Unit)? = null
) {
private fun loadBookInfo(book: Book) {
execute {
AudioPlay.bookSource?.let {
WebBook.getBookInfo(this, it, book)
.onSuccess {
loadChapterList(book, changeDruChapterIndex)
loadChapterList(book)
}
}
}
}
private fun loadChapterList(
book: Book,
changeDruChapterIndex: ((chapters: List<BookChapter>) -> Unit)? = null
) {
private fun loadChapterList(book: Book) {
execute {
AudioPlay.bookSource?.let {
WebBook.getChapterList(this, it, book)
.onSuccess(Dispatchers.IO) { cList ->
if (changeDruChapterIndex == null) {
appDb.bookChapterDao.insert(*cList.toTypedArray())
} else {
changeDruChapterIndex(cList)
}
appDb.bookChapterDao.insert(*cList.toTypedArray())
AudioPlay.upDurChapter(book)
}.onError {
context.toastOnUi(R.string.error_load_toc)
@@ -88,47 +76,17 @@ class AudioPlayViewModel(application: Application) : BaseViewModel(application)
}
}
fun changeTo(source: BookSource, book: Book) {
fun changeTo(source: BookSource, book: Book, toc: List<BookChapter>) {
execute {
var oldTocSize: Int = book.totalChapterNum
AudioPlay.book?.let {
oldTocSize = it.totalChapterNum
book.order = it.order
appDb.bookDao.delete(it)
}
appDb.bookDao.insert(book)
AudioPlay.book = book
AudioPlay.book = AudioPlay.book!!.changeTo(book, toc)
AudioPlay.bookSource = source
if (book.tocUrl.isEmpty()) {
loadBookInfo(book) { upChangeDurChapterIndex(book, oldTocSize, it) }
} else {
loadChapterList(book) { upChangeDurChapterIndex(book, oldTocSize, it) }
}
appDb.bookChapterDao.insert(*toc.toTypedArray())
AudioPlay.upDurChapter(book)
}.onFinally {
postEvent(EventBus.SOURCE_CHANGED, book.bookUrl)
}
}
private fun upChangeDurChapterIndex(
book: Book,
oldTocSize: Int,
chapters: List<BookChapter>
) {
execute {
book.durChapterIndex = BookHelp.getDurChapter(
book.durChapterIndex,
book.durChapterTitle,
chapters,
oldTocSize
)
book.durChapterTitle = chapters[book.durChapterIndex].getDisplayTitle(
ContentProcessor.get(book.name, book.origin).getTitleReplaceRules()
)
appDb.bookDao.update(book)
appDb.bookChapterDao.insert(*chapters.toTypedArray())
}
}
fun removeFromBookshelf(success: (() -> Unit)?) {
execute {
AudioPlay.book?.let {
@@ -32,7 +32,7 @@ fun TimerDialog(state: MutableState<Boolean>, parent: View) {
timeMinute.value = it.toInt()
AudioPlay.setTimer(it.toInt())
},
valueRange = 0f..180f
valueRange = 0f..180f,
)
}
}
@@ -0,0 +1,47 @@
package io.legado.app.ui.book.bookmark
import android.os.Bundle
import androidx.activity.viewModels
import io.legado.app.base.VMBaseActivity
import io.legado.app.data.entities.Bookmark
import io.legado.app.databinding.ActivityAllBookmarkBinding
import io.legado.app.utils.showDialogFragment
import io.legado.app.utils.viewbindingdelegate.viewBinding
class AllBookmarkActivity : VMBaseActivity<ActivityAllBookmarkBinding, AllBookmarkViewModel>(),
BookmarkAdapter.Callback,
BookmarkDialog.Callback {
override val viewModel by viewModels<AllBookmarkViewModel>()
override val binding by viewBinding(ActivityAllBookmarkBinding::inflate)
private val adapter by lazy {
BookmarkAdapter(this, this)
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
initView()
viewModel.initData {
adapter.setItems(it)
}
}
private fun initView() {
binding.recyclerView.addItemDecoration(BookmarkDecoration(adapter))
binding.recyclerView.adapter = adapter
}
override fun onItemClick(bookmark: Bookmark, position: Int) {
showDialogFragment(BookmarkDialog(bookmark, position))
}
override fun upBookmark(pos: Int, bookmark: Bookmark) {
adapter.setItem(pos, bookmark)
}
override fun deleteBookmark(pos: Int) {
adapter.getItem(pos)?.let {
viewModel.deleteBookmark(it)
}
adapter.removeItem(pos)
}
}
@@ -0,0 +1,25 @@
package io.legado.app.ui.book.bookmark
import android.app.Application
import io.legado.app.base.BaseViewModel
import io.legado.app.data.appDb
import io.legado.app.data.entities.Bookmark
class AllBookmarkViewModel(application: Application) : BaseViewModel(application) {
fun initData(onSuccess: (bookmarks: List<Bookmark>) -> Unit) {
execute {
appDb.bookmarkDao.all
}.onSuccess {
onSuccess.invoke(it)
}
}
fun deleteBookmark(bookmark: Bookmark) {
execute {
appDb.bookmarkDao.delete(bookmark)
}
}
}
@@ -0,0 +1,60 @@
package io.legado.app.ui.book.bookmark
import android.content.Context
import android.view.ViewGroup
import io.legado.app.base.adapter.ItemViewHolder
import io.legado.app.base.adapter.RecyclerAdapter
import io.legado.app.data.entities.Bookmark
import io.legado.app.databinding.ItemBookmarkBinding
import io.legado.app.utils.gone
import splitties.views.onClick
class BookmarkAdapter(context: Context, val callback: Callback) :
RecyclerAdapter<Bookmark, ItemBookmarkBinding>(context) {
override fun getViewBinding(parent: ViewGroup): ItemBookmarkBinding {
return ItemBookmarkBinding.inflate(inflater, parent, false)
}
override fun convert(
holder: ItemViewHolder,
binding: ItemBookmarkBinding,
item: Bookmark,
payloads: MutableList<Any>
) {
binding.tvChapterName.text = item.chapterName
binding.tvBookText.gone(item.bookText.isEmpty())
binding.tvBookText.text = item.bookText
binding.tvContent.gone(item.content.isEmpty())
binding.tvContent.text = item.content
}
override fun registerListener(holder: ItemViewHolder, binding: ItemBookmarkBinding) {
binding.root.onClick {
getItemByLayoutPosition(holder.layoutPosition)?.let {
callback.onItemClick(it, holder.layoutPosition)
}
}
}
fun getHeaderText(position: Int): String {
return with(getItem(position)) {
"${this?.bookName ?: ""}(${this?.bookAuthor ?: ""})"
}
}
fun isItemHeader(position: Int): Boolean {
if (position == 0) return true
val lastItem = getItem(position - 1)
val curItem = getItem(position)
return !(lastItem?.bookName == curItem?.bookName
&& lastItem?.bookAuthor == curItem?.bookAuthor)
}
interface Callback {
fun onItemClick(bookmark: Bookmark, position: Int)
}
}
@@ -0,0 +1,110 @@
package io.legado.app.ui.book.bookmark
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Rect
import android.text.TextPaint
import android.view.View
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import io.legado.app.lib.theme.accentColor
import io.legado.app.lib.theme.backgroundColor
import io.legado.app.utils.dpToPx
import io.legado.app.utils.spToPx
import splitties.init.appCtx
import kotlin.math.min
class BookmarkDecoration(val adapter: BookmarkAdapter) : RecyclerView.ItemDecoration() {
private val headerLeft = 16f.dpToPx()
private val headerHeight = 32f.dpToPx()
private val headerPaint = Paint().apply {
color = appCtx.backgroundColor
}
private val textPaint = TextPaint().apply {
textSize = 16f.spToPx()
color = appCtx.accentColor
}
private val textRect = Rect()
override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
val count = parent.childCount
for (i in 0 until count) {
val view = parent.getChildAt(i)
val position = parent.getChildLayoutPosition(view)
val isHeader = adapter.isItemHeader(position)
if (isHeader) {
c.drawRect(
0f,
view.top - headerHeight,
parent.width.toFloat(),
view.top.toFloat(),
headerPaint
)
val headerText = adapter.getHeaderText(position)
textPaint.getTextBounds(headerText, 0, headerText.length, textRect)
c.drawText(
headerText,
headerLeft,
(view.top - headerHeight) + headerHeight / 2 + textRect.height() / 2,
textPaint
)
}
}
}
override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
val position = (parent.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition()
val view = parent.findViewHolderForAdapterPosition(position)?.itemView ?: return
val isHeader = adapter.isItemHeader(position + 1)
val headerText = adapter.getHeaderText(position)
if (isHeader) {
val bottom = min(headerHeight.toInt(), view.bottom)
c.drawRect(
0f,
view.top - headerHeight,
parent.width.toFloat(),
bottom.toFloat(),
headerPaint
)
textPaint.getTextBounds(headerText, 0, headerText.length, textRect)
c.drawText(
headerText,
headerLeft,
headerHeight / 2 + textRect.height() / 2 - (headerHeight - bottom),
textPaint
)
} else {
c.drawRect(
0f,
0f,
parent.width.toFloat(),
headerHeight,
headerPaint
)
textPaint.getTextBounds(headerText, 0, headerText.length, textRect)
c.drawText(
headerText,
headerLeft,
headerHeight / 2 + textRect.height() / 2,
textPaint
)
}
c.save()
}
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
val position = parent.getChildLayoutPosition(view)
val isHeader = adapter.isItemHeader(position)
if (isHeader) {
outRect.top = headerHeight.toInt()
}
}
}
@@ -1,4 +1,4 @@
package io.legado.app.ui.book.toc
package io.legado.app.ui.book.bookmark
import android.os.Bundle
import android.view.View
@@ -75,8 +75,9 @@ class BookmarkDialog() : BaseDialogFragment(R.layout.dialog_bookmark) {
}
}
fun getCallback(): Callback? {
return parentFragment as? Callback
private fun getCallback(): Callback? {
return (parentFragment as? Callback)
?: activity as? Callback
}
interface Callback {
@@ -74,7 +74,9 @@ class ChangeBookSourceAdapter(
override fun registerListener(holder: ItemViewHolder, binding: ItemChangeSourceBinding) {
holder.itemView.setOnClickListener {
getItem(holder.layoutPosition)?.let {
callBack.changeTo(it)
if (it.bookUrl != callBack.bookUrl) {
callBack.changeTo(it)
}
}
}
holder.itemView.onLongClick {
@@ -19,6 +19,7 @@ import io.legado.app.constant.EventBus
import io.legado.app.constant.PreferKey
import io.legado.app.data.appDb
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter
import io.legado.app.data.entities.BookSource
import io.legado.app.data.entities.SearchBook
import io.legado.app.databinding.DialogBookChangeSourceBinding
@@ -27,6 +28,7 @@ import io.legado.app.lib.dialogs.alert
import io.legado.app.lib.theme.primaryColor
import io.legado.app.ui.book.source.edit.BookSourceEditActivity
import io.legado.app.ui.book.source.manage.BookSourceActivity
import io.legado.app.ui.widget.dialog.WaitDialog
import io.legado.app.ui.widget.recycler.VerticalDivider
import io.legado.app.utils.*
import io.legado.app.utils.viewbindingdelegate.viewBinding
@@ -50,6 +52,7 @@ class ChangeBookSourceDialog() : BaseDialogFragment(R.layout.dialog_book_change_
private val groups = linkedSetOf<String>()
private val callBack: CallBack? get() = activity as? CallBack
private val viewModel: ChangeBookSourceViewModel by viewModels()
private val waitDialog by lazy { WaitDialog(requireContext()) }
private val adapter by lazy { ChangeBookSourceAdapter(requireContext(), viewModel, this) }
private val editSourceResult =
registerForActivityResult(StartActivityContract(BookSourceEditActivity::class.java)) {
@@ -57,14 +60,14 @@ class ChangeBookSourceDialog() : BaseDialogFragment(R.layout.dialog_book_change_
}
private val searchFinishCallback: (isEmpty: Boolean) -> Unit = {
if (it) {
val searchGroup = getPrefString("searchGroup")
if (!searchGroup.isNullOrEmpty()) {
val searchGroup = AppConfig.searchGroup
if (searchGroup.isNotEmpty()) {
launch {
alert("搜索结果为空") {
setMessage("${searchGroup}分组搜索结果为空,是否切换到全部分组")
cancelButton()
okButton {
putPrefString("searchGroup", "")
AppConfig.searchGroup = ""
viewModel.startSearch()
}
}
@@ -218,9 +221,9 @@ class ChangeBookSourceDialog() : BaseDialogFragment(R.layout.dialog_book_change_
if (!item.isChecked) {
item.isChecked = true
if (item.title.toString() == getString(R.string.all_source)) {
putPrefString("searchGroup", "")
AppConfig.searchGroup = ""
} else {
putPrefString("searchGroup", item.title.toString())
AppConfig.searchGroup = item.title.toString()
}
viewModel.startOrStopSearch()
viewModel.refresh()
@@ -241,8 +244,23 @@ class ChangeBookSourceDialog() : BaseDialogFragment(R.layout.dialog_book_change_
}
override fun changeTo(searchBook: SearchBook) {
changeSource(searchBook)
dismissAllowingStateLoss()
if (searchBook.type == callBack?.oldBook?.type) {
changeSource(searchBook) {
dismissAllowingStateLoss()
}
} else {
alert(
titleResource = R.string.book_type_different,
messageResource = R.string.soure_change_source
) {
okButton {
changeSource(searchBook) {
dismissAllowingStateLoss()
}
}
cancelButton()
}
}
}
override val bookUrl: String?
@@ -269,22 +287,23 @@ class ChangeBookSourceDialog() : BaseDialogFragment(R.layout.dialog_book_change_
override fun deleteSource(searchBook: SearchBook) {
viewModel.del(searchBook)
if (bookUrl == searchBook.bookUrl) {
viewModel.firstSourceOrNull(searchBook)?.let {
changeSource(it)
viewModel.autoChangeSource(callBack?.oldBook?.type) { book, toc, source ->
callBack?.changeTo(source, book, toc)
}
}
}
private fun changeSource(searchBook: SearchBook) {
try {
val book = searchBook.toBook()
book.upInfoFromOld(callBack?.oldBook)
val source = appDb.bookSourceDao.getBookSource(book.origin)
callBack?.changeTo(source!!, book)
searchBook.time = System.currentTimeMillis()
viewModel.updateSource(searchBook)
} catch (e: Exception) {
toastOnUi("换源失败\n${e.localizedMessage}")
private fun changeSource(searchBook: SearchBook, onSuccess: (() -> Unit)? = null) {
waitDialog.setText(R.string.load_toc)
waitDialog.show()
val book = searchBook.toBook()
viewModel.getToc(book, {
waitDialog.dismiss()
toastOnUi(it)
}) { toc, source ->
waitDialog.dismiss()
callBack?.changeTo(source, book, toc)
onSuccess?.invoke()
}
}
@@ -293,7 +312,7 @@ class ChangeBookSourceDialog() : BaseDialogFragment(R.layout.dialog_book_change_
*/
private fun upGroupMenu() {
val menu: Menu = binding.toolBar.menu
val selectedGroup = getPrefString("searchGroup")
val selectedGroup = AppConfig.searchGroup
menu.removeGroup(R.id.source_group)
val allItem = menu.add(R.id.source_group, Menu.NONE, Menu.NONE, R.string.all_source)
var hasSelectedGroup = false
@@ -325,7 +344,7 @@ class ChangeBookSourceDialog() : BaseDialogFragment(R.layout.dialog_book_change_
interface CallBack {
val oldBook: Book?
fun changeTo(source: BookSource, book: Book)
fun changeTo(source: BookSource, book: Book, toc: List<BookChapter>)
}
}
@@ -11,14 +11,16 @@ import io.legado.app.constant.AppPattern
import io.legado.app.constant.PreferKey
import io.legado.app.data.appDb
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter
import io.legado.app.data.entities.BookSource
import io.legado.app.data.entities.SearchBook
import io.legado.app.exception.NoStackTraceException
import io.legado.app.help.config.AppConfig
import io.legado.app.help.coroutine.CompositeCoroutine
import io.legado.app.help.coroutine.Coroutine
import io.legado.app.model.webBook.WebBook
import io.legado.app.utils.getPrefBoolean
import io.legado.app.utils.getPrefString
import io.legado.app.utils.toastOnUi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.ExecutorCoroutineDispatcher
@@ -27,8 +29,9 @@ import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import splitties.init.appCtx
import kotlinx.coroutines.withContext
import java.util.*
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
import kotlin.math.min
@@ -44,7 +47,7 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
private var screenKey: String = ""
private var bookSourceList = arrayListOf<BookSource>()
private val searchBooks = Collections.synchronizedList(arrayListOf<SearchBook>())
private val searchGroup get() = appCtx.getPrefString("searchGroup") ?: ""
private val tocMap = ConcurrentHashMap<String, List<BookChapter>>()
private var searchCallback: SourceCallback? = null
val searchDataFlow = callbackFlow {
@@ -86,6 +89,11 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
@Volatile
private var searchIndex = -1
override fun onCleared() {
super.onCleared()
searchPool?.close()
}
@CallSuper
open fun initData(arguments: Bundle?) {
arguments?.let { bundle ->
@@ -118,11 +126,13 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
appDb.searchBookDao.clear(name, author)
searchBooks.clear()
bookSourceList.clear()
val searchGroup = AppConfig.searchGroup
if (searchGroup.isBlank()) {
bookSourceList.addAll(appDb.bookSourceDao.allEnabled)
} else {
val sources = appDb.bookSourceDao.getEnabledByGroup(searchGroup)
if (sources.isEmpty()) {
AppConfig.searchGroup = ""
bookSourceList.addAll(appDb.bookSourceDao.allEnabled)
} else {
bookSourceList.addAll(sources)
@@ -187,6 +197,7 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
private suspend fun loadBookToc(scope: CoroutineScope, source: BookSource, book: Book) {
val chapters = WebBook.getChapterListAwait(scope, source, book)
tocMap[book.bookUrl] = chapters
book.latestChapterTitle = chapters.last().title
val searchBook: SearchBook = book.toSearchBook()
searchCallback?.searchSuccess(searchBook)
@@ -212,15 +223,23 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
private fun getDbSearchBooks(): List<SearchBook> {
return if (screenKey.isEmpty()) {
if (AppConfig.changeSourceCheckAuthor) {
appDb.searchBookDao.getChangeSourceSearch(name, author, searchGroup)
appDb.searchBookDao.getChangeSourceSearch(
name, author, AppConfig.searchGroup
)
} else {
appDb.searchBookDao.getChangeSourceSearch(name, "", searchGroup)
appDb.searchBookDao.getChangeSourceSearch(
name, "", AppConfig.searchGroup
)
}
} else {
if (AppConfig.changeSourceCheckAuthor) {
appDb.searchBookDao.getChangeSourceSearch(name, author, screenKey, searchGroup)
appDb.searchBookDao.getChangeSourceSearch(
name, author, screenKey, AppConfig.searchGroup
)
} else {
appDb.searchBookDao.getChangeSourceSearch(name, "", screenKey, searchGroup)
appDb.searchBookDao.getChangeSourceSearch(
name, "", screenKey, AppConfig.searchGroup
)
}
}
}
@@ -253,9 +272,39 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
searchStateData.postValue(false)
}
override fun onCleared() {
super.onCleared()
searchPool?.close()
fun getToc(
book: Book,
onError: (msg: String) -> Unit,
onSuccess: (toc: List<BookChapter>, source: BookSource) -> Unit
) {
execute {
val toc = tocMap[book.bookUrl]
if (toc != null) {
val source = appDb.bookSourceDao.getBookSource(book.origin)
return@execute Pair(toc, source!!)
}
val result = getToc(book).getOrThrow()
tocMap[book.bookUrl] = result.first
return@execute result
}.onSuccess {
onSuccess.invoke(it.first, it.second)
}.onError {
onError.invoke(it.localizedMessage ?: "获取目录出错")
}
}
suspend fun getToc(book: Book): Result<Pair<List<BookChapter>, BookSource>> {
return kotlin.runCatching {
withContext(IO) {
val source = appDb.bookSourceDao.getBookSource(book.origin)
?: throw NoStackTraceException("书源不存在")
if (book.tocUrl.isEmpty()) {
WebBook.getBookInfoAwait(this, source, book)
}
val toc = WebBook.getChapterListAwait(this, source, book)
Pair(toc, source)
}
}
}
fun disableSource(searchBook: SearchBook) {
@@ -310,8 +359,26 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
searchCallback?.upAdapter()
}
fun firstSourceOrNull(searchBook: SearchBook): SearchBook? {
return searchBooks.firstOrNull { it.bookUrl != searchBook.bookUrl }
fun autoChangeSource(
bookType: Int?,
onSuccess: (book: Book, toc: List<BookChapter>, source: BookSource) -> Unit
) {
execute {
searchBooks.forEach {
if (it.type == bookType) {
val book = it.toBook()
val result = getToc(book).getOrNull()
if (result != null) {
return@execute Triple(book, result.first, result.second)
}
}
}
throw NoStackTraceException("没有有效源")
}.onSuccess {
onSuccess.invoke(it.first, it.second, it.third)
}.onError {
context.toastOnUi("自动换源失败\n${it.localizedMessage}")
}
}
interface SourceCallback {
@@ -67,13 +67,6 @@ class ChangeChapterSourceDialog() : BaseDialogFragment(R.layout.dialog_chapter_c
private val tocAdapter by lazy {
ChangeChapterTocAdapter(requireContext(), this)
}
private val tocSuccess: (toc: List<BookChapter>) -> Unit = {
tocAdapter.durChapterIndex =
BookHelp.getDurChapter(viewModel.chapterIndex, viewModel.chapterTitle, it)
binding.loadingToc.hide()
tocAdapter.setItems(it)
binding.recyclerViewToc.scrollToPosition(tocAdapter.durChapterIndex - 5)
}
private val contentSuccess: (content: String) -> Unit = {
binding.loadingToc.hide()
callBack?.replaceContent(it)
@@ -82,14 +75,14 @@ class ChangeChapterSourceDialog() : BaseDialogFragment(R.layout.dialog_chapter_c
private var searchBook: SearchBook? = null
private val searchFinishCallback: (isEmpty: Boolean) -> Unit = {
if (it) {
val searchGroup = getPrefString("searchGroup")
if (!searchGroup.isNullOrEmpty()) {
val searchGroup = AppConfig.searchGroup
if (searchGroup.isNotEmpty()) {
launch {
alert("搜索结果为空") {
setMessage("${searchGroup}分组搜索结果为空,是否切换到全部分组")
cancelButton()
okButton {
putPrefString("searchGroup", "")
AppConfig.searchGroup = ""
viewModel.startSearch()
}
}
@@ -252,9 +245,9 @@ class ChangeChapterSourceDialog() : BaseDialogFragment(R.layout.dialog_chapter_c
if (!item.isChecked) {
item.isChecked = true
if (item.title.toString() == getString(R.string.all_source)) {
putPrefString("searchGroup", "")
AppConfig.searchGroup = ""
} else {
putPrefString("searchGroup", item.title.toString())
AppConfig.searchGroup = item.title.toString()
}
viewModel.startOrStopSearch()
viewModel.refresh()
@@ -279,9 +272,16 @@ class ChangeChapterSourceDialog() : BaseDialogFragment(R.layout.dialog_chapter_c
tocAdapter.setItems(null)
binding.clToc.visible()
binding.loadingToc.show()
viewModel.getToc(searchBook, tocSuccess) {
val book = searchBook.toBook()
viewModel.getToc(book, {
binding.clToc.gone()
toastOnUi(it)
}) { toc: List<BookChapter>, _: BookSource ->
tocAdapter.durChapterIndex =
BookHelp.getDurChapter(viewModel.chapterIndex, viewModel.chapterTitle, toc)
binding.loadingToc.hide()
tocAdapter.setItems(toc)
binding.recyclerViewToc.scrollToPosition(tocAdapter.durChapterIndex - 5)
}
}
@@ -309,8 +309,8 @@ class ChangeChapterSourceDialog() : BaseDialogFragment(R.layout.dialog_chapter_c
override fun deleteSource(searchBook: SearchBook) {
viewModel.del(searchBook)
if (bookUrl == searchBook.bookUrl) {
viewModel.firstSourceOrNull(searchBook)?.let {
changeSource(it)
viewModel.autoChangeSource(callBack?.oldBook?.type) { book, toc, source ->
callBack?.changeTo(source, book, toc)
}
}
}
@@ -326,25 +326,12 @@ class ChangeChapterSourceDialog() : BaseDialogFragment(R.layout.dialog_chapter_c
}
}
private fun changeSource(searchBook: SearchBook) {
try {
val book = searchBook.toBook()
book.upInfoFromOld(callBack?.oldBook)
val source = appDb.bookSourceDao.getBookSource(book.origin)
callBack?.changeTo(source!!, book)
searchBook.time = System.currentTimeMillis()
viewModel.updateSource(searchBook)
} catch (e: Exception) {
toastOnUi("换源失败\n${e.localizedMessage}")
}
}
/**
* 更新分组菜单
*/
private fun upGroupMenu() {
val menu: Menu = binding.toolBar.menu
val selectedGroup = getPrefString("searchGroup")
val selectedGroup = AppConfig.searchGroup
menu.removeGroup(R.id.source_group)
val allItem = menu.add(R.id.source_group, Menu.NONE, Menu.NONE, R.string.all_source)
var hasSelectedGroup = false
@@ -388,7 +375,7 @@ class ChangeChapterSourceDialog() : BaseDialogFragment(R.layout.dialog_chapter_c
interface CallBack {
val oldBook: Book?
fun changeTo(source: BookSource, book: Book)
fun changeTo(source: BookSource, book: Book, toc: List<BookChapter>)
fun replaceContent(content: String)
}
@@ -5,10 +5,8 @@ import android.os.Bundle
import io.legado.app.data.appDb
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter
import io.legado.app.data.entities.SearchBook
import io.legado.app.exception.NoStackTraceException
import io.legado.app.model.webBook.WebBook
import java.util.concurrent.ConcurrentHashMap
@Suppress("MemberVisibilityCanBePrivate")
class ChangeChapterSourceViewModel(application: Application) :
@@ -17,8 +15,6 @@ class ChangeChapterSourceViewModel(application: Application) :
var chapterIndex: Int = 0
var chapterTitle: String = ""
private val tocMap = ConcurrentHashMap<String, List<BookChapter>>()
override fun initData(arguments: Bundle?) {
super.initData(arguments)
arguments?.let { bundle ->
@@ -29,31 +25,6 @@ class ChangeChapterSourceViewModel(application: Application) :
}
}
fun getToc(
searchBook: SearchBook,
success: (toc: List<BookChapter>) -> Unit,
error: (msg: String) -> Unit
) {
execute {
return@execute tocMap[searchBook.bookUrl]
?: let {
val book = searchBook.toBook()
val source = appDb.bookSourceDao.getBookSource(book.origin)
?: throw NoStackTraceException("书源不存在")
if (book.tocUrl.isEmpty()) {
WebBook.getBookInfoAwait(this, source, book)
}
val toc = WebBook.getChapterListAwait(this, source, book)
tocMap[book.bookUrl] = toc
toc
}
}.onSuccess {
success(it)
}.onError {
error(it.localizedMessage ?: "获取目录出错")
}
}
fun getContent(
book: Book,
chapter: BookChapter,
@@ -457,9 +457,8 @@ class BookInfoActivity :
override val oldBook: Book?
get() = viewModel.bookData.value
override fun changeTo(source: BookSource, book: Book) {
upLoading(true)
viewModel.changeTo(source, book)
override fun changeTo(source: BookSource, book: Book, toc: List<BookChapter>) {
viewModel.changeTo(source, book, toc)
}
override fun coverChangeTo(coverUrl: String) {
@@ -14,7 +14,6 @@ import io.legado.app.data.entities.BookChapter
import io.legado.app.data.entities.BookSource
import io.legado.app.exception.NoStackTraceException
import io.legado.app.help.BookHelp
import io.legado.app.help.ContentProcessor
import io.legado.app.help.coroutine.Coroutine
import io.legado.app.model.BookCover
import io.legado.app.model.ReadBook
@@ -24,7 +23,6 @@ import io.legado.app.utils.postEvent
import io.legado.app.utils.toastOnUi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.ensureActive
class BookInfoViewModel(application: Application) : BaseViewModel(application) {
val bookData = MutableLiveData<Book>()
@@ -107,12 +105,11 @@ class BookInfoViewModel(application: Application) : BaseViewModel(application) {
fun loadBookInfo(
book: Book,
canReName: Boolean = true,
scope: CoroutineScope = viewModelScope,
changeDruChapterIndex: ((chapters: List<BookChapter>) -> Unit)? = null,
scope: CoroutineScope = viewModelScope
) {
execute(scope) {
if (book.isLocalBook()) {
loadChapter(book, scope, changeDruChapterIndex)
loadChapter(book, scope)
} else {
bookSource?.let { bookSource ->
WebBook.getBookInfo(this, bookSource, book, canReName = canReName)
@@ -121,7 +118,7 @@ class BookInfoViewModel(application: Application) : BaseViewModel(application) {
if (inBookshelf) {
appDb.bookDao.update(book)
}
loadChapter(it, scope, changeDruChapterIndex)
loadChapter(it, scope)
}.onError {
AppLog.put("获取数据信息失败\n${it.localizedMessage}", it)
context.toastOnUi(R.string.error_get_book_info)
@@ -136,8 +133,7 @@ class BookInfoViewModel(application: Application) : BaseViewModel(application) {
private fun loadChapter(
book: Book,
scope: CoroutineScope = viewModelScope,
changeDruChapterIndex: ((chapters: List<BookChapter>) -> Unit)? = null,
scope: CoroutineScope = viewModelScope
) {
execute(scope) {
if (book.isLocalBook()) {
@@ -156,11 +152,7 @@ class BookInfoViewModel(application: Application) : BaseViewModel(application) {
appDb.bookChapterDao.delByBook(book.bookUrl)
appDb.bookChapterDao.insert(*it.toTypedArray())
}
if (changeDruChapterIndex == null) {
chapterListData.postValue(it)
} else {
changeDruChapterIndex(it)
}
chapterListData.postValue(it)
}.onError {
chapterListData.postValue(emptyList())
AppLog.put("获取目录失败\n${it.localizedMessage}", it)
@@ -184,58 +176,18 @@ class BookInfoViewModel(application: Application) : BaseViewModel(application) {
}
}
fun changeTo(source: BookSource, newBook: Book) {
fun changeTo(source: BookSource, newBook: Book, toc: List<BookChapter>) {
changeSourceCoroutine?.cancel()
changeSourceCoroutine = execute {
var oldTocSize: Int = newBook.totalChapterNum
if (inBookshelf) {
bookData.value?.let {
oldTocSize = it.totalChapterNum
it.changeTo(newBook)
}
}
bookData.postValue(newBook)
bookSource = source
if (newBook.tocUrl.isEmpty()) {
loadBookInfo(newBook, false, this) {
ensureActive()
upChangeDurChapterIndex(newBook, oldTocSize, it)
}
} else {
loadChapter(newBook, this) {
ensureActive()
upChangeDurChapterIndex(newBook, oldTocSize, it)
}
}
bookData.value!!.changeTo(newBook, toc)
bookData.postValue(newBook)
chapterListData.postValue(toc)
}.onFinally {
postEvent(EventBus.SOURCE_CHANGED, newBook.bookUrl)
}
}
private fun upChangeDurChapterIndex(
book: Book,
oldTocSize: Int,
chapters: List<BookChapter>
) {
execute {
book.durChapterIndex = BookHelp.getDurChapter(
book.durChapterIndex,
book.durChapterTitle,
chapters,
oldTocSize
)
book.durChapterTitle = chapters[book.durChapterIndex].getDisplayTitle(
ContentProcessor.get(book.name, book.origin).getTitleReplaceRules()
)
if (inBookshelf) {
appDb.bookDao.update(book)
appDb.bookChapterDao.insert(*chapters.toTypedArray())
}
bookData.postValue(book)
chapterListData.postValue(chapters)
}
}
fun topBook() {
execute {
bookData.value?.let { book ->
@@ -300,7 +252,7 @@ class BookInfoViewModel(application: Application) : BaseViewModel(application) {
fun delBook(deleteOriginal: Boolean = false, success: (() -> Unit)? = null) {
execute {
bookData.value?.let {
Book.delete(it)
it.delete()
inBookshelf = false
if (it.isLocalBook()) {
LocalBook.deleteBook(it, deleteOriginal)
@@ -14,6 +14,7 @@ import androidx.core.view.size
import com.jaredrummler.android.colorpicker.ColorPickerDialogListener
import io.legado.app.BuildConfig
import io.legado.app.R
import io.legado.app.constant.BookType
import io.legado.app.constant.EventBus
import io.legado.app.constant.PreferKey
import io.legado.app.constant.Status
@@ -38,6 +39,8 @@ import io.legado.app.model.ReadBook
import io.legado.app.receiver.TimeBatteryReceiver
import io.legado.app.service.BaseReadAloudService
import io.legado.app.ui.about.AppLogDialog
import io.legado.app.ui.book.audio.AudioPlayActivity
import io.legado.app.ui.book.bookmark.BookmarkDialog
import io.legado.app.ui.book.changesource.ChangeBookSourceDialog
import io.legado.app.ui.book.changesource.ChangeChapterSourceDialog
import io.legado.app.ui.book.read.config.*
@@ -51,7 +54,6 @@ import io.legado.app.ui.book.read.page.provider.TextPageFactory
import io.legado.app.ui.book.searchContent.SearchContentActivity
import io.legado.app.ui.book.searchContent.SearchResult
import io.legado.app.ui.book.source.edit.BookSourceEditActivity
import io.legado.app.ui.book.toc.BookmarkDialog
import io.legado.app.ui.book.toc.TocActivityResult
import io.legado.app.ui.browser.WebViewActivity
import io.legado.app.ui.dict.DictDialog
@@ -715,8 +717,19 @@ class ReadBookActivity : BaseReadBookActivity(),
override val oldBook: Book?
get() = ReadBook.book
override fun changeTo(source: BookSource, book: Book) {
viewModel.changeTo(source, book)
override fun changeTo(source: BookSource, book: Book, toc: List<BookChapter>) {
if (book.type != BookType.audio) {
viewModel.changeTo(source, book, toc)
} else {
ReadAloud.stop(this)
launch {
ReadBook.book?.changeTo(book, toc)
}
startActivity<AudioPlayActivity> {
putExtra("bookUrl", book.bookUrl)
}
finish()
}
}
override fun replaceContent(content: String) {
@@ -10,6 +10,7 @@ import io.legado.app.constant.AppLog
import io.legado.app.constant.EventBus
import io.legado.app.data.appDb
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter
import io.legado.app.data.entities.BookProgress
import io.legado.app.data.entities.BookSource
import io.legado.app.exception.NoStackTraceException
@@ -30,7 +31,6 @@ import io.legado.app.utils.postEvent
import io.legado.app.utils.toStringArray
import io.legado.app.utils.toastOnUi
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.ensureActive
class ReadBookViewModel(application: Application) : BaseViewModel(application) {
val permissionDenialLiveData = MutableLiveData<Int>()
@@ -191,40 +191,22 @@ class ReadBookViewModel(application: Application) : BaseViewModel(application) {
/**
* 换源
*/
fun changeTo(source: BookSource, book: Book) {
fun changeTo(source: BookSource, book: Book, toc: List<BookChapter>) {
changeSourceCoroutine?.cancel()
changeSourceCoroutine = execute {
ReadBook.upMsg(context.getString(R.string.loading))
if (book.tocUrl.isEmpty()) {
WebBook.getBookInfoAwait(this, source, book)
}
ensureActive()
val chapters = WebBook.getChapterListAwait(this, source, book)
ensureActive()
val oldBook = ReadBook.book!!
book.durChapterIndex = BookHelp.getDurChapter(
oldBook.durChapterIndex,
oldBook.durChapterTitle,
chapters,
oldBook.totalChapterNum
)
book.durChapterTitle = chapters[book.durChapterIndex].getDisplayTitle(
ContentProcessor.get(book.name, book.origin).getTitleReplaceRules()
)
ensureActive()
val nextChapter = chapters.getOrElse(book.durChapterIndex) {
chapters.first()
ReadBook.book!!.changeTo(book, toc)
val nextChapter = toc.getOrElse(book.durChapterIndex) {
toc.first()
}
WebBook.getContentAwait(
this,
bookSource = source,
book = book,
bookChapter = chapters[book.durChapterIndex],
bookChapter = toc[book.durChapterIndex],
nextChapterUrl = nextChapter.url
)
ensureActive()
oldBook.changeTo(book)
appDb.bookChapterDao.insert(*chapters.toTypedArray())
appDb.bookChapterDao.insert(*toc.toTypedArray())
ReadBook.resetData(book)
ReadBook.upMsg(null)
ReadBook.loadContent(resetPageOffset = true)
@@ -244,10 +226,17 @@ class ReadBookViewModel(application: Application) : BaseViewModel(application) {
if (!AppConfig.autoChangeSource) return
execute {
val sources = appDb.bookSourceDao.allTextEnabled
WebBook.preciseSearchAwait(this, sources, name, author)?.let {
it.second.upInfoFromOld(ReadBook.book)
changeTo(it.first, it.second)
} ?: throw NoStackTraceException("自动换源失败")
sources.forEach { source ->
WebBook.preciseSearchAwait(this, source, name, author).getOrNull()?.let { book ->
if (book.tocUrl.isEmpty()) {
WebBook.getBookInfoAwait(this, source, book)
}
val toc = WebBook.getChapterListAwait(this, source, book)
changeTo(source, book, toc)
return@execute
}
}
throw NoStackTraceException("自动换源失败")
}.onStart {
ReadBook.upMsg(context.getString(R.string.source_auto_changing))
}.onError {
@@ -272,7 +261,7 @@ class ReadBookViewModel(application: Application) : BaseViewModel(application) {
fun removeFromBookshelf(success: (() -> Unit)?) {
execute {
Book.delete(ReadBook.book)
ReadBook.book?.delete()
}.onSuccess {
success?.invoke()
}
@@ -22,6 +22,7 @@ import io.legado.app.data.appDb
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.SearchKeyword
import io.legado.app.databinding.ActivityBookSearchBinding
import io.legado.app.help.config.AppConfig
import io.legado.app.lib.dialogs.alert
import io.legado.app.lib.theme.*
import io.legado.app.ui.book.info.BookInfoActivity
@@ -66,14 +67,14 @@ class SearchActivity : VMBaseActivity<ActivityBookSearchBinding, SearchViewModel
private var groups = linkedSetOf<String>()
private val searchFinishCallback: (isEmpty: Boolean) -> Unit = {
if (it) {
val searchGroup = getPrefString("searchGroup")
if (!searchGroup.isNullOrEmpty()) {
val searchGroup = AppConfig.searchGroup
if (searchGroup.isNotEmpty()) {
launch {
alert("搜索结果为空") {
setMessage("${searchGroup}分组搜索结果为空,是否切换到全部分组")
cancelButton()
okButton {
putPrefString("searchGroup", "")
AppConfig.searchGroup = ""
viewModel.searchKey = ""
viewModel.search(searchView.query.toString())
}
@@ -123,9 +124,9 @@ class SearchActivity : VMBaseActivity<ActivityBookSearchBinding, SearchViewModel
else -> if (item.groupId == R.id.source_group) {
item.isChecked = true
if (item.title.toString() == getString(R.string.all_source)) {
putPrefString("searchGroup", "")
AppConfig.searchGroup = ""
} else {
putPrefString("searchGroup", item.title.toString())
AppConfig.searchGroup = item.title.toString()
}
searchView.query?.toString()?.trim()?.let {
searchView.setQuery(it, true)
@@ -280,7 +281,7 @@ class SearchActivity : VMBaseActivity<ActivityBookSearchBinding, SearchViewModel
* 更新分组菜单
*/
private fun upGroupMenu() = menu?.let { menu ->
val selectedGroup = getPrefString("searchGroup")
val selectedGroup = AppConfig.searchGroup
menu.removeGroup(R.id.source_group)
val allItem = menu.add(R.id.source_group, Menu.NONE, Menu.NONE, R.string.all_source)
var hasSelectedGroup = false
@@ -86,14 +86,12 @@ class BookSourceEditViewModel(application: Application) : BaseViewModel(applicat
text.isJsonArray() -> {
val items: List<Map<String, Any>> = jsonPath.parse(text).read("$")
val jsonItem = jsonPath.parse(items[0])
BookSource.fromJson(jsonItem.jsonString())
BookSource.fromJson(jsonItem.jsonString()).getOrThrow()
}
text.isJsonObject() -> {
BookSource.fromJson(text)
}
else -> {
null
BookSource.fromJson(text).getOrThrow()
}
else -> throw NoStackTraceException("格式不对")
}
}
@@ -230,10 +230,10 @@ class BookSourceActivity : VMBaseActivity<ActivityBookSourceBinding, BookSourceV
}
searchKey.startsWith("group:") -> {
val key = searchKey.substringAfter("group:")
appDb.bookSourceDao.flowGroupSearch("%$key%")
appDb.bookSourceDao.flowGroupSearch(key)
}
else -> {
appDb.bookSourceDao.flowSearch("%$searchKey%")
appDb.bookSourceDao.flowSearch(searchKey)
}
}.conflate().map { data ->
if (sortAscending) when (sort) {
@@ -6,6 +6,7 @@ import io.legado.app.base.adapter.ItemViewHolder
import io.legado.app.base.adapter.RecyclerAdapter
import io.legado.app.data.entities.Bookmark
import io.legado.app.databinding.ItemBookmarkBinding
import io.legado.app.utils.gone
import splitties.views.onLongClick
class BookmarkAdapter(context: Context, val callback: Callback) :
@@ -22,7 +23,9 @@ class BookmarkAdapter(context: Context, val callback: Callback) :
payloads: MutableList<Any>
) {
binding.tvChapterName.text = item.chapterName
binding.tvBookText.gone(item.bookText.isEmpty())
binding.tvBookText.text = item.bookText
binding.tvContent.gone(item.content.isEmpty())
binding.tvContent.text = item.content
}
@@ -11,6 +11,7 @@ import io.legado.app.data.appDb
import io.legado.app.data.entities.Bookmark
import io.legado.app.databinding.FragmentBookmarkBinding
import io.legado.app.lib.theme.primaryColor
import io.legado.app.ui.book.bookmark.BookmarkDialog
import io.legado.app.ui.widget.recycler.UpLinearLayoutManager
import io.legado.app.ui.widget.recycler.VerticalDivider
import io.legado.app.utils.setEdgeEffectColor
@@ -22,6 +22,7 @@ import io.legado.app.databinding.ActivityMainBinding
import io.legado.app.help.BookHelp
import io.legado.app.help.config.AppConfig
import io.legado.app.help.config.LocalConfig
import io.legado.app.help.coroutine.Coroutine
import io.legado.app.help.storage.Backup
import io.legado.app.lib.theme.elevation
import io.legado.app.lib.theme.primaryColor
@@ -175,7 +176,9 @@ class MainActivity : VMBaseActivity<ActivityMainBinding, MainViewModel>(),
override fun onDestroy() {
super.onDestroy()
BookHelp.clearRemovedCache()
Coroutine.async {
BookHelp.clearInvalidCache()
}
}
override fun observeLiveBus() {
@@ -132,7 +132,7 @@ class BookshelfViewModel(application: Application) : BaseViewModel(application)
if (name.isNotEmpty() && appDb.bookDao.getBook(name, author) == null) {
WebBook.preciseSearch(this, bookSources, name, author)
.onSuccess {
val book = it.second
val book = it.first
if (groupId > 0) {
book.group = groupId
}
@@ -123,10 +123,10 @@ class ExploreFragment : VMBaseFragment<ExploreViewModel>(R.layout.fragment_explo
}
searchKey.startsWith("group:") -> {
val key = searchKey.substringAfter("group:")
appDb.bookSourceDao.flowGroupExplore("%$key%")
appDb.bookSourceDao.flowGroupExplore(key)
}
else -> {
appDb.bookSourceDao.flowExplore("%$searchKey%")
appDb.bookSourceDao.flowExplore(searchKey)
}
}.catch {
AppLog.put("发现界面更新数据出错", it)
@@ -20,6 +20,7 @@ import io.legado.app.service.WebService
import io.legado.app.ui.about.AboutActivity
import io.legado.app.ui.about.DonateActivity
import io.legado.app.ui.about.ReadRecordActivity
import io.legado.app.ui.book.bookmark.AllBookmarkActivity
import io.legado.app.ui.book.source.manage.BookSourceActivity
import io.legado.app.ui.config.ConfigActivity
import io.legado.app.ui.config.ConfigTag
@@ -135,6 +136,7 @@ class MyFragment : BaseFragment(R.layout.fragment_my_config) {
when (preference.key) {
"bookSourceManage" -> startActivity<BookSourceActivity>()
"replaceManage" -> startActivity<ReplaceRuleActivity>()
"bookmark" -> startActivity<AllBookmarkActivity>()
"setting" -> startActivity<ConfigActivity> {
putExtra("configTag", ConfigTag.OTHER_CONFIG)
}
@@ -52,7 +52,7 @@ class RssSourceEditViewModel(application: Application) : BaseViewModel(applicati
execute(context = Dispatchers.Main) {
var source: RssSource? = null
context.getClipText()?.let { json ->
source = RssSource.fromJson(json)
source = RssSource.fromJson(json).getOrThrow()
}
source
}.onError {
@@ -69,7 +69,7 @@ class RssSourceEditViewModel(application: Application) : BaseViewModel(applicati
fun importSource(text: String, finally: (source: RssSource) -> Unit) {
execute {
val text1 = text.trim()
RssSource.fromJson(text1)?.let {
RssSource.fromJson(text1).getOrThrow().let {
finally.invoke(it)
}
}.onError {
@@ -6,23 +6,36 @@ import androidx.compose.material.lightColors
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import io.legado.app.help.config.ThemeConfig
import io.legado.app.lib.theme.accentColor
import io.legado.app.lib.theme.primaryColor
import io.legado.app.utils.ColorUtils
import splitties.init.appCtx
object AppTheme {
val colors
get() = if (ThemeConfig.isDarkTheme()) {
darkColors(
primary = Color(appCtx.accentColor),
primaryVariant = Color(ColorUtils.darkenColor(appCtx.accentColor)),
secondary = Color(appCtx.primaryColor),
secondaryVariant = Color(appCtx.primaryColor)
)
} else {
lightColors(
primary = Color(appCtx.accentColor),
primaryVariant = Color(ColorUtils.darkenColor(appCtx.accentColor)),
secondary = Color(appCtx.primaryColor),
secondaryVariant = Color(appCtx.primaryColor)
)
}
}
@Composable
fun AppTheme(content: @Composable () -> Unit) {
val colors = if (ThemeConfig.isDarkTheme()) {
darkColors(
primary = Color(appCtx.primaryColor),
)
} else {
lightColors(
primary = Color(appCtx.primaryColor),
)
}
MaterialTheme(
colors = colors,
colors = AppTheme.colors,
content = content
)
}
@@ -98,6 +98,11 @@ class SelectActionBar @JvmOverloads constructor(
btnRevertSelection.isClickable = isClickable
btnSelectActionMain.isEnabled = isClickable
btnSelectActionMain.isClickable = isClickable
if (isClickable) {
ivMenuMore.setColorFilter(context.primaryTextColor)
} else {
ivMenuMore.setColorFilter(context.secondaryTextColor)
}
ivMenuMore.isEnabled = isClickable
ivMenuMore.isClickable = isClickable
}
@@ -4,6 +4,8 @@ import com.google.gson.*
import com.google.gson.internal.LinkedTreeMap
import com.google.gson.reflect.TypeToken
import com.google.gson.stream.JsonWriter
import java.io.InputStream
import java.io.InputStreamReader
import java.io.OutputStream
import java.io.OutputStreamWriter
import java.lang.reflect.ParameterizedType
@@ -37,6 +39,20 @@ inline fun <reified T> Gson.fromJsonArray(json: String?): Result<List<T>?> {
}
}
inline fun <reified T> Gson.fromJsonObject(inputStream: InputStream?): Result<T?> {
return kotlin.runCatching {
val reader = InputStreamReader(inputStream)
fromJson(reader, genericType<T>()) as? T
}
}
inline fun <reified T> Gson.fromJsonArray(inputStream: InputStream?): Result<List<T>?> {
return kotlin.runCatching {
val reader = InputStreamReader(inputStream)
fromJson(reader, ParameterizedTypeImpl(T::class.java)) as? List<T>
}
}
fun Gson.writeToOutputStream(out: OutputStream, any: Any) {
val writer = JsonWriter(OutputStreamWriter(out, "UTF-8"))
writer.setIndent(" ")
@@ -98,6 +98,14 @@ fun View.gone() {
}
}
fun View.gone(gone: Boolean) {
if (gone) {
gone()
} else {
visibility = VISIBLE
}
}
fun View.invisible() {
if (visibility != INVISIBLE) {
visibility = INVISIBLE