Merge pull request #1884 from Xwite/master

在线文件导入对话框
This commit is contained in:
kunfei 2022-05-15 11:26:01 +08:00 committed by GitHub
commit aca8d21d0c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 366 additions and 53 deletions

View File

@ -10,6 +10,9 @@
@Json: json规则,直接写时以$.开头可省略@Json
: regex规则,不可省略,只可以用在书籍列表和目录列表
```
* 书源类型: 文件
> 对于类似知轩藏书提供文件整合下载的网站,可以'在书源详情的下载URL规则获取文件链接支持多个链接阅读会自动下载并导入
* CookieJar
> 启用后会自动保存每次返回头中的Set-Cookie中的值适用于验证码图片一类需要session的网站
* 登录UI

View File

@ -244,7 +244,7 @@ object BookController {
val fileData = parameters["fileData"]?.firstOrNull()
?: return returnData.setErrorMsg("fileData 不能为空")
kotlin.runCatching {
LocalBook.importFile(fileData, fileName)
LocalBook.importFileOnLine(fileData, fileName)
}.onFailure {
return when (it) {
is SecurityException -> returnData.setErrorMsg("需重新设置书籍保存位置!")

View File

@ -28,4 +28,5 @@ object EventBus {
const val TIP_COLOR = "tipColor"
const val SOURCE_CHANGED = "sourceChanged"
const val SEARCH_RESULT = "searchResult"
const val BOOK_URL_CHANGED = "bookUrlChanged"
}

View File

@ -154,6 +154,10 @@ data class Book(
@IgnoredOnParcel
override var tocHtml: String? = null
@Ignore
@IgnoredOnParcel
var downloadUrls: List<String>? = null
fun getRealAuthor() = author.replace(AppPattern.authorRegex, "")
fun getUnreadChapterNum() = max(totalChapterNum - durChapterIndex - 1, 0)

View File

@ -16,5 +16,6 @@ data class BookInfoRule(
var coverUrl: String? = null,
var tocUrl: String? = null,
var wordCount: String? = null,
var canReName: String? = null
var canReName: String? = null,
var downloadUrls: String? = null
) : Parcelable

View File

@ -2,6 +2,7 @@ package io.legado.app.model
import android.annotation.SuppressLint
import io.legado.app.constant.AppPattern
import io.legado.app.constant.BookType
import io.legado.app.data.entities.*
import io.legado.app.help.coroutine.CompositeCoroutine
import io.legado.app.model.rss.Rss
@ -238,7 +239,11 @@ object Debug {
.onSuccess {
log(debugSource, "︽详情页解析完成")
log(debugSource, showTime = false)
tocDebug(scope, bookSource, book)
if (book.type != BookType.file) {
tocDebug(scope, bookSource, book)
} else {
log(debugSource, "≡文件类书源跳过解析目录", state = 1000)
}
}
.onError {
log(debugSource, it.msg, state = -1)

View File

@ -24,6 +24,10 @@ import java.io.FileNotFoundException
import java.io.InputStream
import java.util.regex.Pattern
/**
* 书籍文件导入 目录正文解析
* 支持在线文件(txt epub umd 压缩文件需要用户解压) 本地文件
*/
object LocalBook {
private val nameAuthorPatterns = arrayOf(
@ -84,32 +88,23 @@ object LocalBook {
}
}
//导入在线的文件
fun importFile(
/**
* 下载在线的文件并自动导入到阅读txt umd epub)
* 压缩文件请先提示用户解压
*/
fun importFileOnLine(
str: String,
fileName: String,
source: BaseSource? = null,
onLineBook: Book? = null
): Book {
val bytes = when {
str.isAbsUrl() -> AnalyzeUrl(str, source = source).getByteArray()
str.isDataUrl() -> Base64.decode(str.substringAfter("base64,"), Base64.DEFAULT)
else -> throw NoStackTraceException("在线导入书籍支持http/https/DataURL")
}
val localBook = importFile(bytes, fileName)
return mergeBook(localBook, onLineBook)
}
fun importFile(
bytes: ByteArray,
fileName: String
): Book {
return saveBookFile(bytes, fileName).let {
return saveBookFile(str, fileName, source).let {
importFile(it)
}
}
//导入本地文件
/**
* 导入本地文件
*/
fun importFile(uri: Uri): Book {
val bookUrl: String
val updateTime: Long
@ -150,6 +145,9 @@ object LocalBook {
return book
}
/**
* 从文件分析书籍必要信息书名 作者等
*/
private fun analyzeNameAuthor(fileName: String): Pair<String, String> {
val tempFileName = fileName.substringBeforeLast(".")
var name: String
@ -203,6 +201,36 @@ object LocalBook {
}
}
/**
* 下载在线的文件
*/
fun saveBookFile(
str: String,
fileName: String,
source: BaseSource? = null,
): Uri {
val bytes = when {
str.isAbsUrl() -> AnalyzeUrl(str, source = source).getByteArray()
str.isDataUrl() -> Base64.decode(str.substringAfter("base64,"), Base64.DEFAULT)
else -> throw NoStackTraceException("在线导入书籍支持http/https/DataURL")
}
return saveBookFile(bytes, fileName)
}
/**
* 分析下载文件类书源的下载链接的文件后缀
* https://www.example.com/download/{fileName}.{type} 含有文件名和后缀
* https://www.example.com/download/?fileid=1234, {type: "txt"} 规则设置
*/
fun parseFileSuffix(url: String): String {
val analyzeUrl = AnalyzeUrl(url)
val urlNoOption = analyzeUrl.url
val lastPath = urlNoOption.substringAfterLast("/")
val fileType = lastPath.substringAfterLast(".")
val type = analyzeUrl.type
return type ?: fileType ?: "unknown"
}
private fun saveBookFile(
bytes: ByteArray,
fileName: String
@ -232,7 +260,7 @@ object LocalBook {
}
//文件类书源 合并在线书籍信息 在线 > 本地
private fun mergeBook(localBook: Book, onLineBook: Book?): Book {
fun mergeBook(localBook: Book, onLineBook: Book?): Book {
onLineBook ?: return localBook
val mergeBook = localBook
mergeBook.name = if (onLineBook.name.isBlank()) localBook.name else onLineBook.name

View File

@ -1,7 +1,7 @@
# 本地书籍解析
# 书籍文件导入解析
* BaseLocalBookParse.kt 本地书籍解析接口
* LocalBook.kt 总入口
* LocalBook.kt 导入解析总入口
* TextFile.kt 解析txt
* EpubFile.kt 解析epub
* UmdFile.kt 解析umd

View File

@ -1,6 +1,8 @@
package io.legado.app.model.webBook
import android.text.TextUtils
import io.legado.app.R
import io.legado.app.constant.BookType
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookSource
import io.legado.app.exception.NoStackTraceException
@ -137,14 +139,29 @@ object BookInfo {
Debug.log(bookSource.bookSourceUrl, "${e.localizedMessage}")
DebugLog.e("获取封面出错", e)
}
scope.ensureActive()
Debug.log(bookSource.bookSourceUrl, "┌获取目录链接")
book.tocUrl = analyzeRule.getString(infoRule.tocUrl, isUrl = true)
if (book.tocUrl.isEmpty()) book.tocUrl = baseUrl
if (book.tocUrl == baseUrl) {
book.tocHtml = body
if (book.type != BookType.file) {
scope.ensureActive()
Debug.log(bookSource.bookSourceUrl, "┌获取目录链接")
book.tocUrl = analyzeRule.getString(infoRule.tocUrl, isUrl = true)
if (book.tocUrl.isEmpty()) book.tocUrl = baseUrl
if (book.tocUrl == baseUrl) {
book.tocHtml = body
}
Debug.log(bookSource.bookSourceUrl, "${book.tocUrl}")
} else {
scope.ensureActive()
Debug.log(bookSource.bookSourceUrl, "┌获取文件下载链接")
book.downloadUrls = analyzeRule.getStringList(infoRule.downloadUrls, isUrl = true)
if (book.downloadUrls == null) {
Debug.log(bookSource.bookSourceUrl, "")
throw NoStackTraceException("下载链接为空")
} else {
Debug.log(
bookSource.bookSourceUrl,
"" + TextUtils.join("\n", book.downloadUrls!!)
)
}
}
Debug.log(bookSource.bookSourceUrl, "${book.tocUrl}")
}
}

View File

@ -6,6 +6,7 @@ import com.script.ScriptException
import io.legado.app.R
import io.legado.app.base.BaseService
import io.legado.app.constant.AppConst
import io.legado.app.constant.BookType
import io.legado.app.constant.EventBus
import io.legado.app.constant.IntentAction
import io.legado.app.data.appDb
@ -208,7 +209,9 @@ class CheckSourceService : BaseService() {
mBook = WebBook.getBookInfoAwait(this, source, mBook)
}
//校验目录
if (CheckSource.checkCategory) {
if (CheckSource.checkCategory &&
source.bookSourceType != BookType.file
) {
val toc = WebBook.getChapterListAwait(this, source, mBook).getOrThrow()
val nextChapterUrl = toc.getOrNull(1)?.url ?: toc.first().url
//校验正文

View File

@ -0,0 +1,132 @@
package io.legado.app.ui.association
import android.content.Context
import android.content.DialogInterface
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import android.net.Uri
import androidx.fragment.app.viewModels
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.databinding.DialogRecyclerViewBinding
import io.legado.app.databinding.ItemBookFileImportBinding
import io.legado.app.help.config.AppConfig
import io.legado.app.lib.dialogs.alert
import io.legado.app.lib.theme.primaryColor
import io.legado.app.ui.widget.dialog.WaitDialog
import io.legado.app.utils.*
import io.legado.app.utils.viewbindingdelegate.viewBinding
/**
* 导入在线书籍文件弹出窗口
*/
class ImportOnLineBookFileDialog() : BaseDialogFragment(R.layout.dialog_recycler_view) {
private val binding by viewBinding(DialogRecyclerViewBinding::bind)
private val viewModel by viewModels<ImportOnLineBookFileViewModel>()
private val adapter by lazy { BookFileAdapter(requireContext()) }
override fun onStart() {
super.onStart()
setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
val bookUrl = arguments?.getString("bookUrl")
viewModel.initData(bookUrl)
binding.toolBar.setBackgroundColor(primaryColor)
binding.toolBar.setTitle(R.string.download_and_import_file)
binding.rotateLoading.show()
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter
viewModel.errorLiveData.observe(this) {
binding.rotateLoading.hide()
binding.tvMsg.apply {
text = it
visible()
}
}
viewModel.successLiveData.observe(this) {
binding.rotateLoading.hide()
if (it > 0) {
adapter.setItems(viewModel.allBookFiles)
}
}
viewModel.savedFileUriData.observe(this) {
requireContext().openFileUri(it, "*/*")
}
}
private fun importFileAndUpdate(url: String, fileName: String) {
val waitDialog = WaitDialog(requireContext())
waitDialog.show()
viewModel.importOnLineBookFile(url, fileName) {
waitDialog.dismiss()
dismissAllowingStateLoss()
}
}
private fun downloadFile(url: String, fileName: String) {
val waitDialog = WaitDialog(requireContext())
waitDialog.show()
viewModel.downloadUrl(url, fileName) {
waitDialog.dismiss()
dismissAllowingStateLoss()
}
}
inner class BookFileAdapter(context: Context) :
RecyclerAdapter<Triple<String, String, Boolean>
, ItemBookFileImportBinding>(context) {
override fun getViewBinding(parent: ViewGroup): ItemBookFileImportBinding {
return ItemBookFileImportBinding.inflate(inflater, parent, false)
}
override fun convert(
holder: ItemViewHolder,
binding: ItemBookFileImportBinding,
item: Triple<String, String, Boolean>,
payloads: MutableList<Any>
) {
binding.apply {
cbFileName.text = item.second
}
}
override fun registerListener(
holder: ItemViewHolder,
binding: ItemBookFileImportBinding
) {
binding.apply {
cbFileName.setOnClickListener {
val selectFile = viewModel.allBookFiles[holder.layoutPosition]
if (selectFile.third) {
importFileAndUpdate(selectFile.first, selectFile.second)
} else {
alert(
title = getString(R.string.draw),
message = getString(R.string.file_not_supported, selectFile.second)
) {
okButton {
importFileAndUpdate(selectFile.first, selectFile.second)
}
neutralButton(R.string.open_fun) {
downloadFile(selectFile.first, selectFile.second)
}
cancelButton()
}
}
}
}
}
}
}

View File

@ -0,0 +1,80 @@
package io.legado.app.ui.association
import android.app.Application
import android.net.Uri
import androidx.lifecycle.MutableLiveData
import io.legado.app.R
import io.legado.app.base.BaseViewModel
import io.legado.app.constant.AppPattern
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.BookSource
import io.legado.app.exception.NoStackTraceException
import io.legado.app.model.analyzeRule.AnalyzeRule
import io.legado.app.model.analyzeRule.AnalyzeUrl
import io.legado.app.model.localBook.LocalBook
import io.legado.app.utils.*
class ImportOnLineBookFileViewModel(app: Application) : BaseViewModel(app) {
val allBookFiles = arrayListOf<Triple<String, String, Boolean>>()
val errorLiveData = MutableLiveData<String>()
val successLiveData = MutableLiveData<Int>()
val savedFileUriData = MutableLiveData<Uri>()
var bookSource: BookSource? = null
fun initData(bookUrl: String?) {
execute {
bookUrl ?: throw NoStackTraceException("书籍详情页链接为空")
val book = appDb.searchBookDao.getSearchBook(bookUrl)?.toBook()
?: throw NoStackTraceException("book is null")
bookSource = appDb.bookSourceDao.getBookSource(book.origin)
?: throw NoStackTraceException("bookSource is null")
val ruleDownloadUrls = bookSource?.getBookInfoRule()?.downloadUrls
val content = AnalyzeUrl(bookUrl, source = bookSource).getStrResponse().body
val analyzeRule = AnalyzeRule(book, bookSource)
analyzeRule.setContent(content).setBaseUrl(bookUrl)
val fileName = "${book.name} 作者:${book.author}"
analyzeRule.getStringList(ruleDownloadUrls, isUrl = true)?.let {
it.forEach { url ->
val mFileName = "${fileName}.${LocalBook.parseFileSuffix(url)}"
val isSupportedFile = AppPattern.bookFileRegex.matches(mFileName)
allBookFiles.add(Triple(url, mFileName, isSupportedFile))
}
} ?: throw NoStackTraceException("下载链接规则解析为空")
}.onSuccess {
successLiveData.postValue(allBookFiles.size)
}.onError {
errorLiveData.postValue(it.localizedMessage ?: "")
context.toastOnUi("获取书籍下载链接失败\n${it.localizedMessage}")
}
}
fun downloadUrl(url: String, fileName: String, success: () -> Unit) {
execute {
LocalBook.saveBookFile(url, fileName, bookSource).let {
savedFileUriData.postValue(it)
}
}.onSuccess {
success.invoke()
}.onError {
context.toastOnUi("下载书籍文件失败\n${it.localizedMessage}")
}
}
fun importOnLineBookFile(url: String, fileName: String, success: () -> Unit) {
execute {
LocalBook.importFileOnLine(url, fileName, bookSource).let {
postEvent(EventBus.BOOK_URL_CHANGED, it.bookUrl)
}
}.onSuccess {
success.invoke()
}.onError {
context.toastOnUi("下载书籍文件失败\n${it.localizedMessage}")
}
}
}

View File

@ -12,6 +12,7 @@ import androidx.activity.viewModels
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.Theme
import io.legado.app.data.appDb
import io.legado.app.data.entities.Book
@ -25,6 +26,7 @@ import io.legado.app.lib.theme.bottomBackground
import io.legado.app.lib.theme.getPrimaryTextColor
import io.legado.app.model.BookCover
import io.legado.app.ui.about.AppLogDialog
import io.legado.app.ui.association.ImportOnLineBookFileDialog
import io.legado.app.ui.book.audio.AudioPlayActivity
import io.legado.app.ui.book.changecover.ChangeCoverDialog
import io.legado.app.ui.book.changesource.ChangeBookSourceDialog
@ -278,11 +280,13 @@ class BookInfoActivity :
true
}
tvRead.setOnClickListener {
viewModel.bookData.value?.let {
viewModel.bookData.value?.let { book ->
if (viewModel.isImportBookOnLine) {
viewModel.importBookFileOnLine()
showDialogFragment<ImportOnLineBookFileDialog> {
putString("bookUrl", book.bookUrl)
}
} else {
readBook(it)
readBook(book)
}
} ?: toastOnUi("Book is null")
}
@ -486,4 +490,9 @@ class BookInfoActivity :
}
}
override fun observeLiveBus() {
observeEvent<String>(EventBus.BOOK_URL_CHANGED) {
viewModel.changeToLocalBook(it)
}
}
}

View File

@ -2,6 +2,7 @@ package io.legado.app.ui.book.info
import android.app.Application
import android.content.Intent
import android.net.Uri
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import io.legado.app.R
@ -118,6 +119,9 @@ class BookInfoViewModel(application: Application) : BaseViewModel(application) {
WebBook.getBookInfo(this, bookSource, book, canReName = canReName)
.onSuccess(IO) {
bookData.postValue(book)
if (isImportBookOnLine) {
appDb.searchBookDao.update(book.toSearchBook())
}
if (inBookshelf) {
appDb.bookDao.update(book)
}
@ -290,26 +294,14 @@ class BookInfoViewModel(application: Application) : BaseViewModel(application) {
}
}
fun importBookFileOnLine() {
execute {
//下载类书源的目录链接视为文件链接
val book = bookData.value!!
val fileUrl = book.tocUrl
//切下载链接获取文件名
var fileName = fileUrl.substringAfterLast("/")
if (fileName.isEmpty()) {
fileName = book.name
}
LocalBook.importFile(fileUrl, fileName, bookSource, book)
}.onSuccess {
bookData.postValue(it)
LocalBook.getChapterList(it).let { toc ->
chapterListData.postValue(toc)
}
fun changeToLocalBook(bookUrl: String) {
appDb.bookDao.getBook(bookUrl)?.let { localBook ->
isImportBookOnLine = false
inBookshelf = true
}.onError {
context.toastOnUi("自动导入出错\n${it.localizedMessage}")
LocalBook.mergeBook(localBook, bookData.value).let {
bookData.postValue(it)
loadChapter(it)
}
}
}

View File

@ -266,6 +266,7 @@ class BookSourceEditActivity :
add(EditEntity("coverUrl", ir?.coverUrl, R.string.rule_cover_url))
add(EditEntity("tocUrl", ir?.tocUrl, R.string.rule_toc_url))
add(EditEntity("canReName", ir?.canReName, R.string.rule_can_re_name))
add(EditEntity("downloadUrls", ir?.downloadUrls, R.string.download_url_rule))
}
//目录页
val tr = source?.getTocRule()
@ -396,6 +397,7 @@ class BookSourceEditActivity :
"tocUrl" -> bookInfoRule.tocUrl =
viewModel.ruleComplete(it.value, bookInfoRule.init, 2)
"canReName" -> bookInfoRule.canReName = it.value
"downloadUrls" -> bookInfoRule.downloadUrls = viewModel.ruleComplete(it.value, bookInfoRule.init)
}
}
tocEntities.forEach {

View File

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="8dp">
<TextView
android:id="@+id/cb_file_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/primaryText"
android:textSize="14sp"
app:layout_constrainedWidth="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:ignore="TouchTargetSizeCheck" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -981,5 +981,7 @@
<string name="auto_save_cookie">CookieJar</string>
<string name="click_read_button_load">点击阅读加载目录</string>
<string name="cookie">清除cookie</string>
<string name="download_and_import_file">导入在线书籍文件</string>
<!-- string end -->
</resources>

View File

@ -984,5 +984,7 @@
<string name="auto_save_cookie">CookieJar</string>
<string name="click_read_button_load">点击阅读加载目录</string>
<string name="cookie">清除cookie</string>
<string name="download_and_import_file">导入在线书籍文件</string>
<!-- string end -->
</resources>

View File

@ -984,5 +984,7 @@
<string name="auto_save_cookie">CookieJar</string>
<string name="click_read_button_load">点击阅读加载目录</string>
<string name="cookie">清除cookie</string>
<string name="download_and_import_file">导入在线书籍文件</string>
<!-- string end -->
</resources>

View File

@ -981,5 +981,7 @@
<string name="auto_save_cookie">CookieJar</string>
<string name="click_read_button_load">点击阅读加载目录</string>
<string name="cookie">清除cookie</string>
<string name="download_and_import_file">导入在线书籍文件</string>
<!-- string end -->
</resources>

View File

@ -983,5 +983,7 @@
<string name="auto_save_cookie">CookieJar</string>
<string name="click_read_button_load">点击阅读加载目录</string>
<string name="cookie">清除cookie</string>
<string name="download_and_import_file">导入在线书籍文件</string>
<!-- string end -->
</resources>

View File

@ -983,5 +983,6 @@
<string name="auto_save_cookie">CookieJar</string>
<string name="click_read_button_load">点击阅读加载目录</string>
<string name="cookie">清除cookie</string>
<string name="download_and_import_file">导入在线书籍文件</string>
<!-- string end -->
</resources>

View File

@ -984,5 +984,7 @@
<string name="auto_save_cookie">CookieJar</string>
<string name="click_read_button_load">点击阅读加载目录</string>
<string name="cookie">清除cookie</string>
<string name="download_and_import_file">导入在线书籍文件</string>
<!-- string end -->
</resources>