Fix CustomExport (#2)

* perf(CacheActivity):  提升性能

* feat(strings): 💄 新增 result_analyzed 字段

* feat(PreferKey):  新增 episodeExportFileName 字段

+ 用于保存 自定义导出文件名 的js脚本

* feat(ExportCustomEpisode):  将导出文件名格式输入框移至导出对话框

+ 新增 测试导出文件名格式按钮
+ 默认选择为 导出所有
+ 优化了保存变量逻辑

* fix(ui): cant show R.string.result_analyzed

* perf(ExportCustomEpisode):  使用协程优化导出

* perf(ExportCustomEpisode): 优化log

* fix(AppConfig): 修复episodeExportFileName值可能为null的问题

* fix(AppConfig): 修复enableCustomExport值可能为null的问题

* fix(AppConfig): 修复第一次选择导出路径不能正常使用自定义导出的问题

* perf(ExportCustomEpisode):  优化进度条显示、优化输出速度

* style(ExportCustomEpisode): 🎨 清理代码
This commit is contained in:
Discut 2023-07-15 17:43:25 +08:00 committed by GitHub
parent b29f65bac1
commit 4d6864051d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 374 additions and 127 deletions

View File

@ -39,6 +39,7 @@ object PreferKey {
const val bookshelfSort = "bookshelfSort" const val bookshelfSort = "bookshelfSort"
const val bookExportFileName = "bookExportFileName" const val bookExportFileName = "bookExportFileName"
const val bookImportFileName = "bookImportFileName" const val bookImportFileName = "bookImportFileName"
const val episodeExportFileName = "episodeExportFileName"
const val recordLog = "recordLog" const val recordLog = "recordLog"
const val processText = "process_text" const val processText = "process_text"
const val cleanCache = "cleanCache" const val cleanCache = "cleanCache"

View File

@ -236,6 +236,7 @@ fun Book.getExportFileName(suffix: String): String {
return "$name 作者:${getRealAuthor()}.$suffix" return "$name 作者:${getRealAuthor()}.$suffix"
} }
val bindings = SimpleBindings() val bindings = SimpleBindings()
bindings["epubIndex"] = ""// 兼容老版本,修复可能存在的错误
bindings["name"] = name bindings["name"] = name
bindings["author"] = getRealAuthor() bindings["author"] = getRealAuthor()
return kotlin.runCatching { return kotlin.runCatching {
@ -248,8 +249,11 @@ fun Book.getExportFileName(suffix: String): String {
/** /**
* 获取分割文件后的文件名 * 获取分割文件后的文件名
*/ */
fun Book.getExportFileName(suffix: String, epubIndex: Int): String { fun Book.getExportFileName(
val jsStr = AppConfig.bookExportFileName suffix: String,
epubIndex: Int,
jsStr: String? = AppConfig.episodeExportFileName
): String {
// 默认规则 // 默认规则
val default = "$name 作者:${getRealAuthor()} [${epubIndex}].$suffix" val default = "$name 作者:${getRealAuthor()} [${epubIndex}].$suffix"
if (jsStr.isNullOrBlank()) { if (jsStr.isNullOrBlank()) {
@ -265,3 +269,14 @@ fun Book.getExportFileName(suffix: String, epubIndex: Int): String {
AppLog.put("导出书名规则错误,使用默认规则\n${it.localizedMessage}", it) AppLog.put("导出书名规则错误,使用默认规则\n${it.localizedMessage}", it)
}.getOrDefault(default) }.getOrDefault(default)
} }
fun tryParesExportFileName(jsStr: String): Boolean {
val bindings = SimpleBindings()
bindings["name"] = "name"
bindings["author"] = "author"
bindings["epubIndex"] = "epubIndex"
return runCatching {
RhinoScriptEngine.eval(jsStr, bindings)
true
}.getOrDefault(false)
}

View File

@ -31,26 +31,37 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
PreferKey.themeMode -> isEInkMode = appCtx.getPrefString(PreferKey.themeMode) == "3" PreferKey.themeMode -> isEInkMode = appCtx.getPrefString(PreferKey.themeMode) == "3"
PreferKey.clickActionTL -> clickActionTL = PreferKey.clickActionTL -> clickActionTL =
appCtx.getPrefInt(PreferKey.clickActionTL, 2) appCtx.getPrefInt(PreferKey.clickActionTL, 2)
PreferKey.clickActionTC -> clickActionTC = PreferKey.clickActionTC -> clickActionTC =
appCtx.getPrefInt(PreferKey.clickActionTC, 2) appCtx.getPrefInt(PreferKey.clickActionTC, 2)
PreferKey.clickActionTR -> clickActionTR = PreferKey.clickActionTR -> clickActionTR =
appCtx.getPrefInt(PreferKey.clickActionTR, 1) appCtx.getPrefInt(PreferKey.clickActionTR, 1)
PreferKey.clickActionML -> clickActionML = PreferKey.clickActionML -> clickActionML =
appCtx.getPrefInt(PreferKey.clickActionML, 2) appCtx.getPrefInt(PreferKey.clickActionML, 2)
PreferKey.clickActionMC -> clickActionMC = PreferKey.clickActionMC -> clickActionMC =
appCtx.getPrefInt(PreferKey.clickActionMC, 0) appCtx.getPrefInt(PreferKey.clickActionMC, 0)
PreferKey.clickActionMR -> clickActionMR = PreferKey.clickActionMR -> clickActionMR =
appCtx.getPrefInt(PreferKey.clickActionMR, 1) appCtx.getPrefInt(PreferKey.clickActionMR, 1)
PreferKey.clickActionBL -> clickActionBL = PreferKey.clickActionBL -> clickActionBL =
appCtx.getPrefInt(PreferKey.clickActionBL, 2) appCtx.getPrefInt(PreferKey.clickActionBL, 2)
PreferKey.clickActionBC -> clickActionBC = PreferKey.clickActionBC -> clickActionBC =
appCtx.getPrefInt(PreferKey.clickActionBC, 1) appCtx.getPrefInt(PreferKey.clickActionBC, 1)
PreferKey.clickActionBR -> clickActionBR = PreferKey.clickActionBR -> clickActionBR =
appCtx.getPrefInt(PreferKey.clickActionBR, 1) appCtx.getPrefInt(PreferKey.clickActionBR, 1)
PreferKey.readBodyToLh -> ReadBookConfig.readBodyToLh = PreferKey.readBodyToLh -> ReadBookConfig.readBodyToLh =
appCtx.getPrefBoolean(PreferKey.readBodyToLh, true) appCtx.getPrefBoolean(PreferKey.readBodyToLh, true)
PreferKey.useZhLayout -> ReadBookConfig.useZhLayout = PreferKey.useZhLayout -> ReadBookConfig.useZhLayout =
appCtx.getPrefBoolean(PreferKey.useZhLayout) appCtx.getPrefBoolean(PreferKey.useZhLayout)
PreferKey.userAgent -> userAgent = getPrefUserAgent() PreferKey.userAgent -> userAgent = getPrefUserAgent()
} }
} }
@ -132,6 +143,13 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
appCtx.putPrefString(PreferKey.bookExportFileName, value) appCtx.putPrefString(PreferKey.bookExportFileName, value)
} }
// 保存 自定义导出章节模式 文件名js表达式
var episodeExportFileName: String?
get() = appCtx.getPrefString(PreferKey.episodeExportFileName, "")
set(value) {
appCtx.putPrefString(PreferKey.episodeExportFileName, value)
}
var bookImportFileName: String? var bookImportFileName: String?
get() = appCtx.getPrefString(PreferKey.bookImportFileName) get() = appCtx.getPrefString(PreferKey.bookImportFileName)
set(value) { set(value) {
@ -274,8 +292,10 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
set(value) { set(value) {
appCtx.putPrefBoolean(PreferKey.exportNoChapterName, value) appCtx.putPrefBoolean(PreferKey.exportNoChapterName, value)
} }
// 是否启用自定义导出 default->false
var enableCustomExport: Boolean var enableCustomExport: Boolean
get() = appCtx.getPrefBoolean(PreferKey.enableCustomExport) get() = appCtx.getPrefBoolean(PreferKey.enableCustomExport, false)
set(value) { set(value) {
appCtx.putPrefBoolean(PreferKey.enableCustomExport, value) appCtx.putPrefBoolean(PreferKey.enableCustomExport, value)
} }

View File

@ -8,10 +8,12 @@ import android.view.View
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.textfield.TextInputLayout
import io.legado.app.R import io.legado.app.R
import io.legado.app.base.VMBaseActivity import io.legado.app.base.VMBaseActivity
import io.legado.app.constant.AppConst import io.legado.app.constant.AppConst
import io.legado.app.constant.AppConst.charsets import io.legado.app.constant.AppConst.charsets
import io.legado.app.constant.AppLog
import io.legado.app.constant.EventBus import io.legado.app.constant.EventBus
import io.legado.app.data.appDb import io.legado.app.data.appDb
import io.legado.app.data.entities.Book import io.legado.app.data.entities.Book
@ -20,7 +22,9 @@ import io.legado.app.data.entities.BookGroup
import io.legado.app.databinding.ActivityCacheBookBinding import io.legado.app.databinding.ActivityCacheBookBinding
import io.legado.app.databinding.DialogEditTextBinding import io.legado.app.databinding.DialogEditTextBinding
import io.legado.app.databinding.DialogSelectSectionExportBinding import io.legado.app.databinding.DialogSelectSectionExportBinding
import io.legado.app.help.book.getExportFileName
import io.legado.app.help.book.isAudio import io.legado.app.help.book.isAudio
import io.legado.app.help.book.tryParesExportFileName
import io.legado.app.help.config.AppConfig import io.legado.app.help.config.AppConfig
import io.legado.app.lib.dialogs.SelectItem import io.legado.app.lib.dialogs.SelectItem
import io.legado.app.lib.dialogs.alert import io.legado.app.lib.dialogs.alert
@ -55,17 +59,29 @@ class CacheActivity : VMBaseActivity<ActivityCacheBookBinding, CacheViewModel>()
private var groupId: Long = -1 private var groupId: Long = -1
private val exportDir = registerForActivityResult(HandleFileContract()) { result -> private val exportDir = registerForActivityResult(HandleFileContract()) { result ->
var isReadyPath = false
var dirPath = ""
result.uri?.let { uri -> result.uri?.let { uri ->
if (uri.isContentScheme()) { if (uri.isContentScheme()) {
ACache.get().put(exportBookPathKey, uri.toString()) ACache.get().put(exportBookPathKey, uri.toString())
startExport(uri.toString(), result.requestCode) dirPath = uri.toString()
isReadyPath = true
} else { } else {
uri.path?.let { path -> uri.path?.let { path ->
ACache.get().put(exportBookPathKey, path) ACache.get().put(exportBookPathKey, path)
startExport(path, result.requestCode) dirPath = path
isReadyPath = true
} }
} }
} }
if (!isReadyPath) {
return@registerForActivityResult
}
if (enableCustomExport()) {// 启用自定义导出 and 导出类型为Epub
configExportSection(dirPath, result.requestCode)
} else {
startExport(dirPath, result.requestCode)
}
} }
override fun onActivityCreated(savedInstanceState: Bundle?) { override fun onActivityCreated(savedInstanceState: Bundle?) {
@ -249,7 +265,7 @@ class CacheActivity : VMBaseActivity<ActivityCacheBookBinding, CacheViewModel>()
val path = ACache.get().getAsString(exportBookPathKey) val path = ACache.get().getAsString(exportBookPathKey)
if (path.isNullOrEmpty()) { if (path.isNullOrEmpty()) {
selectExportFolder(position) selectExportFolder(position)
} else if (AppConfig.enableCustomExport && AppConfig.exportType == 1) {// 启用自定义导出 and 导出类型为Epub } else if (enableCustomExport()) {// 启用自定义导出 and 导出类型为Epub
configExportSection(path, position) configExportSection(path, position)
} else { } else {
startExport(path, position) startExport(path, position)
@ -274,9 +290,50 @@ class CacheActivity : VMBaseActivity<ActivityCacheBookBinding, CacheViewModel>()
* @since 1.0.0 * @since 1.0.0
*/ */
private fun configExportSection(path: String, position: Int) { private fun configExportSection(path: String, position: Int) {
val alertBinding = DialogSelectSectionExportBinding.inflate(layoutInflater) val alertBinding = DialogSelectSectionExportBinding.inflate(layoutInflater)
.apply { .apply {
fun verifyExportFileNameJsStr(js: String): Boolean {
return tryParesExportFileName(js) && etEpubFilename.text.toString()
.isNotEmpty()
}
fun enableLyEtEpubFilenameIcon() {
lyEtEpubFilename.endIconMode = TextInputLayout.END_ICON_CUSTOM
lyEtEpubFilename.setEndIconOnClickListener {
adapter.getItem(position)?.run {
lyEtEpubFilename.helperText =
if (verifyExportFileNameJsStr(etEpubFilename.text.toString()))
"${resources.getString(R.string.result_analyzed)}: ${
getExportFileName(
"epub",
1,
etEpubFilename.text.toString()
)
}"
else "Error"
} ?: run {
lyEtEpubFilename.helperText = "Error"
AppLog.put("未找到书籍position is $position")
}
}
}
etEpubSize.setText("1") etEpubSize.setText("1")
// lyEtEpubFilename.endIconMode = TextInputLayout.END_ICON_NONE
etEpubFilename.text?.append(AppConfig.episodeExportFileName)
// 存储解析文件名的jsStr
etEpubFilename.let {
it.setOnFocusChangeListener { _, hasFocus ->
if (hasFocus)
return@setOnFocusChangeListener
it.text?.run {
if (verifyExportFileNameJsStr(toString())) {
AppConfig.episodeExportFileName = toString()
}
}
}
}
tvAllExport.setOnClickListener { tvAllExport.setOnClickListener {
cbAllExport.callOnClick() cbAllExport.callOnClick()
} }
@ -287,6 +344,8 @@ class CacheActivity : VMBaseActivity<ActivityCacheBookBinding, CacheViewModel>()
if (isChecked) { if (isChecked) {
etEpubSize.isEnabled = true etEpubSize.isEnabled = true
etInputScope.isEnabled = true etInputScope.isEnabled = true
etEpubFilename.isEnabled = true
enableLyEtEpubFilenameIcon()
cbAllExport.isChecked = false cbAllExport.isChecked = false
} }
} }
@ -294,6 +353,8 @@ class CacheActivity : VMBaseActivity<ActivityCacheBookBinding, CacheViewModel>()
if (isChecked) { if (isChecked) {
etEpubSize.isEnabled = false etEpubSize.isEnabled = false
etInputScope.isEnabled = false etInputScope.isEnabled = false
etEpubFilename.isEnabled = false
lyEtEpubFilename.endIconMode = TextInputLayout.END_ICON_NONE
cbSelectExport.isChecked = false cbSelectExport.isChecked = false
} }
} }
@ -306,7 +367,9 @@ class CacheActivity : VMBaseActivity<ActivityCacheBookBinding, CacheViewModel>()
etInputScope.hint = "" etInputScope.hint = ""
} }
} }
cbAllExport.callOnClick()
// 默认选择自定义导出
cbSelectExport.callOnClick()
} }
val alertDialog = alert(titleResource = R.string.select_section_export) { val alertDialog = alert(titleResource = R.string.select_section_export) {
customView { alertBinding.root } customView { alertBinding.root }
@ -336,17 +399,7 @@ class CacheActivity : VMBaseActivity<ActivityCacheBookBinding, CacheViewModel>()
} }
} }
/**
* 验证 输入的范围 是否正确
*
* @since 1.0.0
* @author Discut
* @param text 输入的范围 字符串
* @return 是否正确
*/
private fun verificationField(text: String): Boolean {
return text.matches(Regex("\\d+(-\\d+)?(,\\d+(-\\d+)?)*"))
}
private fun selectExportFolder(exportPosition: Int) { private fun selectExportFolder(exportPosition: Int) {
val default = arrayListOf<SelectItem<Int>>() val default = arrayListOf<SelectItem<Int>>()
@ -397,11 +450,11 @@ class CacheActivity : VMBaseActivity<ActivityCacheBookBinding, CacheViewModel>()
@SuppressLint("SetTextI18n") @SuppressLint("SetTextI18n")
private fun alertExportFileName() { private fun alertExportFileName() {
alert(R.string.export_file_name) { alert(R.string.export_file_name) {
var message = val message =
"js内有name和author变量,返回书名\n启用自定义epub导出章节时包含额外变量[epubIndex]" "Variable: name, author."
if (AppConfig.bookExportFileName.isNullOrBlank()) { // if (AppConfig.bookExportFileName.isNullOrBlank()) {
message += "\n例如:\nname+\"-\"+author+(epubIndex?\"(\"+epubIndex+\")\":\"\")" // message += "\n例如\nname+\"-\"+author+(epubIndex?\"(\"+epubIndex+\")\":\"\")"
} // }
setMessage(message) setMessage(message)
val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
editView.hint = "file name js" editView.hint = "file name js"

View File

@ -33,8 +33,10 @@ import kotlinx.coroutines.sync.withLock
import me.ag2s.epublib.domain.* import me.ag2s.epublib.domain.*
import me.ag2s.epublib.domain.Date import me.ag2s.epublib.domain.Date
import me.ag2s.epublib.epub.EpubWriter import me.ag2s.epublib.epub.EpubWriter
import me.ag2s.epublib.epub.EpubWriterProcessor
import me.ag2s.epublib.util.ResourceUtil import me.ag2s.epublib.util.ResourceUtil
import splitties.init.appCtx import splitties.init.appCtx
import java.io.BufferedOutputStream
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
@ -42,7 +44,6 @@ import java.nio.charset.Charset
import java.nio.file.* import java.nio.file.*
import java.util.* import java.util.*
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import kotlin.collections.ArrayList
import kotlin.coroutines.coroutineContext import kotlin.coroutines.coroutineContext
@ -270,7 +271,7 @@ class CacheViewModel(application: Application) : BaseViewModel(application) {
} }
val left = v[0].toInt() val left = v[0].toInt()
val right = v[1].toInt() val right = v[1].toInt()
if (left > right){ if (left > right) {
AppLog.put("Error expression : $s; left > right") AppLog.put("Error expression : $s; left > right")
continue continue
} }
@ -354,7 +355,7 @@ class CacheViewModel(application: Application) : BaseViewModel(application) {
setEpubContent(contentModel, book, epubBook) setEpubContent(contentModel, book, epubBook)
DocumentUtils.createFileIfNotExist(doc, filename)?.let { bookDoc -> DocumentUtils.createFileIfNotExist(doc, filename)?.let { bookDoc ->
context.contentResolver.openOutputStream(bookDoc.uri, "wa")?.use { bookOs -> context.contentResolver.openOutputStream(bookDoc.uri, "wa")?.use { bookOs ->
EpubWriter().write(epubBook, bookOs) EpubWriter().write(epubBook, BufferedOutputStream(bookOs))
} }
if (AppConfig.exportToWebDav) { if (AppConfig.exportToWebDav) {
// 导出到webdav // 导出到webdav
@ -380,7 +381,7 @@ class CacheViewModel(application: Application) : BaseViewModel(application) {
//设置正文 //设置正文
setEpubContent(contentModel, book, epubBook) setEpubContent(contentModel, book, epubBook)
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
EpubWriter().write(epubBook, FileOutputStream(bookFile)) EpubWriter().write(epubBook, BufferedOutputStream(FileOutputStream(bookFile)))
if (AppConfig.exportToWebDav) { if (AppConfig.exportToWebDav) {
// 导出到webdav // 导出到webdav
AppWebDav.exportWebDav(Uri.fromFile(bookFile), filename) AppWebDav.exportWebDav(Uri.fromFile(bookFile), filename)
@ -636,6 +637,10 @@ class CacheViewModel(application: Application) : BaseViewModel(application) {
*/ */
class CustomExporter(private val context: CacheViewModel) { class CustomExporter(private val context: CacheViewModel) {
var scope: IntArray = IntArray(0) var scope: IntArray = IntArray(0)
/**
* epub 文件包含最大章节数
*/
var size: Int = 1 var size: Int = 1
/** /**
@ -659,14 +664,75 @@ class CacheViewModel(application: Application) : BaseViewModel(application) {
} }
context.exportNumber++ context.exportNumber++
} }
if (path.isContentScheme()) { val currentTimeMillis = System.currentTimeMillis()
val uri = Uri.parse(path) when (path.isContentScheme()) {
val doc = DocumentFile.fromTreeUri(context.context, uri) true -> {
?: throw NoStackTraceException("获取导出文档失败") val uri = Uri.parse(path)
exportEpub(doc, book) val doc = DocumentFile.fromTreeUri(context.context, uri)
} else { ?: throw NoStackTraceException("获取导出文档失败")
exportEpub(File(path).createFolderIfNotExist(), book) val (contentModel, epubList) = createEpubs(book, doc)
val asyncBlocks = ArrayList<Deferred<Unit>>(epubList.size)
var progressBar = 0.0
epubList.forEachIndexed { index, ep ->
val (filename, epubBook) = ep
val asyncBlock = async {
//设置正文
setEpubContent(
contentModel,
book,
epubBook,
index
) { _, _ ->
// 将章节写入内存时更新进度条
context.upAdapterLiveData.postValue(book.bookUrl)
progressBar += book.totalChapterNum.toDouble() / scope.size / 2
context.exportProgress[book.bookUrl] = progressBar.toInt()
}
save2Drive(filename, epubBook, doc) { total, progress ->
//写入硬盘时更新进度条
progressBar += book.totalChapterNum.toDouble() / epubList.size / total / 2
context.upAdapterLiveData.postValue(book.bookUrl)
context.exportProgress[book.bookUrl] = progressBar.toInt()
}
}
asyncBlocks.add(asyncBlock)
}
asyncBlocks.forEach { it.await() }
}
false -> {
val file = File(path).createFolderIfNotExist()
val (contentModel, epubList) = createEpubs(book, null)
val asyncBlocks = ArrayList<Deferred<Unit>>(epubList.size)
var progressBar = 0.0
epubList.forEachIndexed { index, ep ->
val (filename, epubBook) = ep
val asyncBlock = async {
//设置正文
setEpubContent(
contentModel,
book,
epubBook,
index
) { _, _ ->
context.upAdapterLiveData.postValue(book.bookUrl)
context.exportProgress[book.bookUrl] =
context.exportProgress[book.bookUrl]?.plus(book.totalChapterNum / scope.size)
?: 1
}
save2Drive(filename, epubBook, file) { total, progress ->
//设置进度
progressBar += book.totalChapterNum.toDouble() / epubList.size / total / 2
context.upAdapterLiveData.postValue(book.bookUrl)
context.exportProgress[book.bookUrl] = progressBar.toInt()
}
}
asyncBlocks.add(asyncBlock)
}
asyncBlocks.forEach { it.await() }
}
} }
AppLog.put("分割导出书籍 ${book.name} 一共耗时 ${System.currentTimeMillis() - currentTimeMillis}")
}.onError { }.onError {
context.exportProgress.remove(book.bookUrl) context.exportProgress.remove(book.bookUrl)
context.exportMsg[book.bookUrl] = it.localizedMessage ?: "ERROR" context.exportMsg[book.bookUrl] = it.localizedMessage ?: "ERROR"
@ -682,38 +748,6 @@ class CacheViewModel(application: Application) : BaseViewModel(application) {
} }
} }
/**
* 导出 epub
*
* from [io.legado.app.ui.book.cache.CacheViewModel.exportEpub]
*/
private suspend fun exportEpub(file: File, book: Book) {
val (contentModel, epubList) = createEpubs(book)
epubList.forEachIndexed { index, ep ->
val (filename, epubBook) = ep
//设置正文
this.setEpubContent(contentModel, book, epubBook, index)
save2Drive(filename, epubBook, file)
}
}
/**
* 导出 epub
*
* from [io.legado.app.ui.book.cache.CacheViewModel.exportEpub]
*/
private suspend fun exportEpub(doc: DocumentFile, book: Book) {
val (contentModel, epubList) = createEpubs(doc, book)
epubList.forEachIndexed { index, ep ->
val (filename, epubBook) = ep
//设置正文
this.setEpubContent(contentModel, book, epubBook, index)
save2Drive(filename, epubBook, doc)
}
}
/** /**
* 设置epub正文 * 设置epub正文
@ -729,7 +763,8 @@ class CacheViewModel(application: Application) : BaseViewModel(application) {
contentModel: String, contentModel: String,
book: Book, book: Book,
epubBook: EpubBook, epubBook: EpubBook,
epubBookIndex: Int epubBookIndex: Int,
updateProgress: (chapterList: MutableList<BookChapter>, index: Int) -> Unit
) { ) {
//正文 //正文
val useReplace = AppConfig.exportUseReplace && book.getUseReplaceRule() val useReplace = AppConfig.exportUseReplace && book.getUseReplaceRule()
@ -743,7 +778,7 @@ class CacheViewModel(application: Application) : BaseViewModel(application) {
return@forEachIndexed return@forEachIndexed
} }
} }
val totalChapterNum = book.totalChapterNum / scope.size // val totalChapterNum = book.totalChapterNum / scope.size
if (chapterList.size == 0) { if (chapterList.size == 0) {
throw RuntimeException("书籍<${book.name}>(${epubBookIndex + 1})未找到章节信息") throw RuntimeException("书籍<${book.name}>(${epubBookIndex + 1})未找到章节信息")
} }
@ -753,9 +788,7 @@ class CacheViewModel(application: Application) : BaseViewModel(application) {
) )
chapterList.forEachIndexed { index, chapter -> chapterList.forEachIndexed { index, chapter ->
coroutineContext.ensureActive() coroutineContext.ensureActive()
context.upAdapterLiveData.postValue(book.bookUrl) updateProgress(chapterList, index)
context.exportProgress[book.bookUrl] =
totalChapterNum * (epubBookIndex * size + index)
BookHelp.getContent(book, chapter).let { content -> BookHelp.getContent(book, chapter).let { content ->
var content1 = context.fixPic( var content1 = context.fixPic(
epubBook, epubBook,
@ -798,22 +831,23 @@ class CacheViewModel(application: Application) : BaseViewModel(application) {
* 创建多个epub 对象 * 创建多个epub 对象
* *
* 分割epub时一个书籍需要创建多个epub对象 * 分割epub时一个书籍需要创建多个epub对象
*
* @param doc 导出文档 * @param doc 导出文档
* @param book 书籍 * @param book 书籍
* *
* @return <内容模板字符串, <epub文件名, epub对象>> * @return <内容模板字符串, <epub文件名, epub对象>>
*/ */
private fun createEpubs( private fun createEpubs(
doc: DocumentFile, book: Book,
book: Book doc: DocumentFile?,
): Pair<String, List<Pair<String, EpubBook>>> { ): Pair<String, List<Pair<String, EpubBook>>> {
val paresNumOfEpub = paresNumOfEpub(scope.size, size) val paresNumOfEpub = paresNumOfEpub(scope.size, size)
val result: MutableList<Pair<String, EpubBook>> = ArrayList(paresNumOfEpub) val result: MutableList<Pair<String, EpubBook>> = ArrayList(paresNumOfEpub)
var contentModel = "" var contentModel = ""
for (i in 1..paresNumOfEpub) { for (i in 1..paresNumOfEpub) {
val filename = book.getExportFileName("epub", i) val filename = book.getExportFileName("epub", i)
DocumentUtils.delete(doc, filename) doc?.let {
DocumentUtils.delete(it, filename)
}
val epubBook = EpubBook() val epubBook = EpubBook()
epubBook.version = "2.0" epubBook.version = "2.0"
//set metadata //set metadata
@ -821,39 +855,9 @@ class CacheViewModel(application: Application) : BaseViewModel(application) {
//set cover //set cover
context.setCover(book, epubBook) context.setCover(book, epubBook)
//set css //set css
contentModel = context.setAssets(doc, book, epubBook) contentModel = doc?.let {
context.setAssets(it, book, epubBook)
// add epubBook } ?: context.setAssets(book, epubBook)
result.add(Pair(filename, epubBook))
}
return Pair(contentModel, result)
}
/**
* 创建多个epub 对象
*
* 分割epub时一个书籍需要创建多个epub对象
*
* @param book 书籍
*
* @return <内容模板字符串, <epub文件名, epub对象>>
*/
private fun createEpubs(
book: Book
): Pair<String, List<Pair<String, EpubBook>>> {
val paresNumOfEpub = paresNumOfEpub(scope.size, size)
val result: MutableList<Pair<String, EpubBook>> = ArrayList(paresNumOfEpub)
var contentModel = ""
for (i in 1..paresNumOfEpub) {
val filename = book.getExportFileName("epub", i)
val epubBook = EpubBook()
epubBook.version = "2.0"
//set metadata
context.setEpubMetadata(book, epubBook)
//set cover
context.setCover(book, epubBook)
//set css
contentModel = context.setAssets(book, epubBook)
// add epubBook // add epubBook
result.add(Pair(filename, epubBook)) result.add(Pair(filename, epubBook))
@ -864,10 +868,21 @@ class CacheViewModel(application: Application) : BaseViewModel(application) {
/** /**
* 保存文件到 设备 * 保存文件到 设备
*/ */
private suspend fun save2Drive(filename: String, epubBook: EpubBook, doc: DocumentFile) { private suspend fun save2Drive(
filename: String,
epubBook: EpubBook,
doc: DocumentFile,
callback: (total: Int, progress: Int) -> Unit
) {
DocumentUtils.createFileIfNotExist(doc, filename)?.let { bookDoc -> DocumentUtils.createFileIfNotExist(doc, filename)?.let { bookDoc ->
context.context.contentResolver.openOutputStream(bookDoc.uri, "wa")?.use { bookOs -> context.context.contentResolver.openOutputStream(bookDoc.uri, "wa")?.use { bookOs ->
EpubWriter().write(epubBook, bookOs) EpubWriter()
.setCallback(object : EpubWriterProcessor.Callback {
override fun onProgressing(total: Int, progress: Int) {
callback(total, progress)
}
})
.write(epubBook, BufferedOutputStream(bookOs))
} }
if (AppConfig.exportToWebDav) { if (AppConfig.exportToWebDav) {
// 导出到webdav // 导出到webdav
@ -879,11 +894,22 @@ class CacheViewModel(application: Application) : BaseViewModel(application) {
/** /**
* 保存文件到 设备 * 保存文件到 设备
*/ */
private suspend fun save2Drive(filename: String, epubBook: EpubBook, file: File) { private suspend fun save2Drive(
filename: String,
epubBook: EpubBook,
file: File,
callback: (total: Int, progress: Int) -> Unit
) {
val bookPath = FileUtils.getPath(file, filename) val bookPath = FileUtils.getPath(file, filename)
val bookFile = FileUtils.createFileWithReplace(bookPath) val bookFile = FileUtils.createFileWithReplace(bookPath)
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
EpubWriter().write(epubBook, FileOutputStream(bookFile)) EpubWriter()
.setCallback(object : EpubWriterProcessor.Callback {
override fun onProgressing(total: Int, progress: Int) {
callback(total, progress)
}
})
.write(epubBook, BufferedOutputStream(FileOutputStream(bookFile)))
if (AppConfig.exportToWebDav) { if (AppConfig.exportToWebDav) {
// 导出到webdav // 导出到webdav
AppWebDav.exportWebDav(Uri.fromFile(bookFile), filename) AppWebDav.exportWebDav(Uri.fromFile(bookFile), filename)

View File

@ -0,0 +1,27 @@
package io.legado.app.utils
import io.legado.app.help.config.AppConfig
// 匹配待“输入的章节”字符串
private val regexEpisode = Regex("\\d+(-\\d+)?(,\\d+(-\\d+)?)*")
/**
* 是否启用自定义导出
*
* @author Discut
*/
fun enableCustomExport(): Boolean {
return AppConfig.enableCustomExport && AppConfig.exportType == 1
}
/**
* 验证 输入的范围 是否正确
*
* @since 1.0.0
* @author Discut
* @param text 输入的范围 字符串
* @return 是否正确
*/
fun verificationField(text: String): Boolean {
return text.matches(regexEpisode)
}

View File

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" <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" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@ -54,6 +55,29 @@
</LinearLayout> </LinearLayout>
<io.legado.app.ui.widget.text.TextInputLayout
android:id="@+id/ly_et_epub_filename"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="@string/export_file_name"
app:endIconCheckable="true"
app:endIconContentDescription="Execute script"
app:endIconDrawable="@drawable/ic_play_24dp"
app:endIconMode="custom"
app:helperText="Variable: name, author, epubIndex">
<io.legado.app.lib.theme.view.ThemeEditText
android:id="@+id/et_epub_filename"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text"
android:enabled="false"
tools:ignore="SpeakableTextPresentCheck,TouchTargetSizeCheck" />
</io.legado.app.ui.widget.text.TextInputLayout>
<io.legado.app.ui.widget.text.TextInputLayout <io.legado.app.ui.widget.text.TextInputLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@ -64,8 +88,8 @@
android:id="@+id/et_epub_size" android:id="@+id/et_epub_size"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:maxLength="6"
android:inputType="number" android:inputType="number"
android:maxLength="6"
tools:ignore="SpeakableTextPresentCheck,TouchTargetSizeCheck" /> tools:ignore="SpeakableTextPresentCheck,TouchTargetSizeCheck" />
</io.legado.app.ui.widget.text.TextInputLayout> </io.legado.app.ui.widget.text.TextInputLayout>

View File

@ -1119,4 +1119,5 @@
<string name="exit_app">退出软件</string> <string name="exit_app">退出软件</string>
<string name="add_all_to_bookshelf">全部加入书架</string> <string name="add_all_to_bookshelf">全部加入书架</string>
<string name="page_to">页至</string> <string name="page_to">页至</string>
<string name="result_analyzed">Analyzed</string>
</resources> </resources>

View File

@ -1122,4 +1122,5 @@
<string name="exit_app">退出软件</string> <string name="exit_app">退出软件</string>
<string name="add_all_to_bookshelf">全部加入书架</string> <string name="add_all_to_bookshelf">全部加入书架</string>
<string name="page_to">页至</string> <string name="page_to">页至</string>
<string name="result_analyzed">Analyzed</string>
</resources> </resources>

View File

@ -1122,4 +1122,5 @@
<string name="exit_app">退出软件</string> <string name="exit_app">退出软件</string>
<string name="add_all_to_bookshelf">全部加入书架</string> <string name="add_all_to_bookshelf">全部加入书架</string>
<string name="page_to">页至</string> <string name="page_to">页至</string>
<string name="result_analyzed">Analyzed</string>
</resources> </resources>

View File

@ -1118,4 +1118,5 @@ Còn </string>
<string name="exit_app">Thoát khỏi phần mềm</string> <string name="exit_app">Thoát khỏi phần mềm</string>
<string name="add_all_to_bookshelf">Thêm tất cả vào giá sách</string> <string name="add_all_to_bookshelf">Thêm tất cả vào giá sách</string>
<string name="page_to">Trang tới</string> <string name="page_to">Trang tới</string>
<string name="result_analyzed">Analyzed</string>
</resources> </resources>

View File

@ -1119,4 +1119,5 @@
<string name="exit_app">退出软件</string> <string name="exit_app">退出软件</string>
<string name="add_all_to_bookshelf">全部加入书架</string> <string name="add_all_to_bookshelf">全部加入书架</string>
<string name="page_to">页至</string> <string name="page_to">页至</string>
<string name="result_analyzed">解析示例</string>
</resources> </resources>

View File

@ -1121,4 +1121,5 @@
<string name="exit_app">退出软件</string> <string name="exit_app">退出软件</string>
<string name="add_all_to_bookshelf">全部加入书架</string> <string name="add_all_to_bookshelf">全部加入书架</string>
<string name="page_to">页至</string> <string name="page_to">页至</string>
<string name="result_analyzed">解析示例</string>
</resources> </resources>

View File

@ -1121,4 +1121,5 @@
<string name="exit_app">退出软件</string> <string name="exit_app">退出软件</string>
<string name="add_all_to_bookshelf">全部加入书架</string> <string name="add_all_to_bookshelf">全部加入书架</string>
<string name="page_to">页至</string> <string name="page_to">页至</string>
<string name="result_analyzed">解析示例</string>
</resources> </resources>

View File

@ -1122,4 +1122,5 @@
<string name="exit_app">退出软件</string> <string name="exit_app">退出软件</string>
<string name="add_all_to_bookshelf">全部加入书架</string> <string name="add_all_to_bookshelf">全部加入书架</string>
<string name="page_to">页至</string> <string name="page_to">页至</string>
<string name="result_analyzed">Analyzed</string>
</resources> </resources>

View File

@ -9,6 +9,7 @@ import java.io.InputStream;
import java.io.OutputStream; import java.io.OutputStream;
import java.io.OutputStreamWriter; import java.io.OutputStreamWriter;
import java.io.Writer; import java.io.Writer;
import java.util.Objects;
import java.util.zip.CRC32; import java.util.zip.CRC32;
import java.util.zip.ZipEntry; import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream; import java.util.zip.ZipOutputStream;
@ -25,15 +26,20 @@ import me.ag2s.epublib.util.IOUtil;
*/ */
public class EpubWriter { public class EpubWriter {
private static final String TAG = EpubWriter.class.getName();
// package // package
static final String EMPTY_NAMESPACE_PREFIX = ""; static final String EMPTY_NAMESPACE_PREFIX = "";
private static final String TAG = EpubWriter.class.getName();
private BookProcessor bookProcessor; private BookProcessor bookProcessor;
private EpubWriterProcessor epubWriterProcessor;
public EpubWriter() { public EpubWriter() {
this(BookProcessor.IDENTITY_BOOKPROCESSOR); this(BookProcessor.IDENTITY_BOOKPROCESSOR);
this.epubWriterProcessor = new EpubWriterProcessor();
// 写入MimeTypeContainer初始化TOCResource整体为1
// 写入PackageDocument 为1
// 关闭流 为1
this.epubWriterProcessor.setTotalProgress(3);
} }
@ -41,16 +47,31 @@ public class EpubWriter {
this.bookProcessor = bookProcessor; this.bookProcessor = bookProcessor;
} }
public EpubWriter setCallback(EpubWriterProcessor.Callback callback) {
epubWriterProcessor.setCallback(callback);
return this;
}
public void write(EpubBook book, OutputStream out) throws IOException { public void write(EpubBook book, OutputStream out) throws IOException {
if (Objects.nonNull(this.epubWriterProcessor.getCallback())) {
epubWriterProcessor.getCallback().onStart(book);
}
epubWriterProcessor.setTotalProgress(epubWriterProcessor.getTotalProgress() + book.getResources().size());
book = processBook(book); book = processBook(book);
ZipOutputStream resultStream = new ZipOutputStream(out); ZipOutputStream resultStream = new ZipOutputStream(out);
resultStream.setLevel(ZipOutputStream.STORED);
writeMimeType(resultStream); writeMimeType(resultStream);
writeContainer(resultStream); writeContainer(resultStream);
initTOCResource(book); initTOCResource(book);
epubWriterProcessor.updateCurrentProgress(1);
writeResources(book, resultStream); writeResources(book, resultStream);
writePackageDocument(book, resultStream); writePackageDocument(book, resultStream);
epubWriterProcessor.updateCurrentProgress(epubWriterProcessor.getCurrentProgress() + 1);
resultStream.close(); resultStream.close();
epubWriterProcessor.updateCurrentProgress(epubWriterProcessor.getCurrentProgress() + 1);
if (Objects.nonNull(epubWriterProcessor.getCallback())) {
epubWriterProcessor.getCallback().onEnd(book);
}
} }
private EpubBook processBook(EpubBook book) { private EpubBook processBook(EpubBook book) {
@ -76,9 +97,7 @@ public class EpubWriter {
book.getSpine().setTocResource(tocResource); book.getSpine().setTocResource(tocResource);
book.getResources().add(tocResource); book.getResources().add(tocResource);
} catch (Exception ex) { } catch (Exception ex) {
Log.e(TAG, Log.e(TAG, "Error writing table of contents: " + ex.getClass().getName() + ": " + ex.getMessage(), ex);
"Error writing table of contents: "
+ ex.getClass().getName() + ": " + ex.getMessage(), ex);
} }
} }
@ -86,6 +105,7 @@ public class EpubWriter {
private void writeResources(EpubBook book, ZipOutputStream resultStream) { private void writeResources(EpubBook book, ZipOutputStream resultStream) {
for (Resource resource : book.getResources().getAll()) { for (Resource resource : book.getResources().getAll()) {
writeResource(resource, resultStream); writeResource(resource, resultStream);
epubWriterProcessor.updateCurrentProgress(epubWriterProcessor.getCurrentProgress() + 1);
} }
} }
@ -111,11 +131,9 @@ public class EpubWriter {
} }
private void writePackageDocument(EpubBook book, ZipOutputStream resultStream) private void writePackageDocument(EpubBook book, ZipOutputStream resultStream) throws IOException {
throws IOException {
resultStream.putNextEntry(new ZipEntry("OEBPS/content.opf")); resultStream.putNextEntry(new ZipEntry("OEBPS/content.opf"));
XmlSerializer xmlSerializer = EpubProcessorSupport XmlSerializer xmlSerializer = EpubProcessorSupport.createXmlSerializer(resultStream);
.createXmlSerializer(resultStream);
PackageDocumentWriter.write(this, xmlSerializer, book); PackageDocumentWriter.write(this, xmlSerializer, book);
xmlSerializer.flush(); xmlSerializer.flush();
// String resultAsString = result.toString(); // String resultAsString = result.toString();
@ -132,11 +150,9 @@ public class EpubWriter {
resultStream.putNextEntry(new ZipEntry("META-INF/container.xml")); resultStream.putNextEntry(new ZipEntry("META-INF/container.xml"));
Writer out = new OutputStreamWriter(resultStream); Writer out = new OutputStreamWriter(resultStream);
out.write("<?xml version=\"1.0\"?>\n"); out.write("<?xml version=\"1.0\"?>\n");
out.write( out.write("<container version=\"1.0\" xmlns=\"urn:oasis:names:tc:opendocument:xmlns:container\">\n");
"<container version=\"1.0\" xmlns=\"urn:oasis:names:tc:opendocument:xmlns:container\">\n");
out.write("\t<rootfiles>\n"); out.write("\t<rootfiles>\n");
out.write( out.write("\t\t<rootfile full-path=\"OEBPS/content.opf\" media-type=\"application/oebps-package+xml\"/>\n");
"\t\t<rootfile full-path=\"OEBPS/content.opf\" media-type=\"application/oebps-package+xml\"/>\n");
out.write("\t</rootfiles>\n"); out.write("\t</rootfiles>\n");
out.write("</container>"); out.write("</container>");
out.flush(); out.flush();

View File

@ -0,0 +1,57 @@
package me.ag2s.epublib.epub;
import java.util.Objects;
import me.ag2s.epublib.domain.EpubBook;
/**
* epub导出进度管理器
*
* @author Discut
*/
public class EpubWriterProcessor {
private int totalProgress = 0;
private int currentProgress = 0;
private Callback callback;
public int getCurrentProgress() {
return currentProgress;
}
public int getTotalProgress() {
return totalProgress;
}
public void setTotalProgress(int totalProgress) {
this.totalProgress = totalProgress;
}
public void setCallback(Callback callback) {
this.callback = callback;
}
protected void updateCurrentProgress(int current) {
this.currentProgress = Math.min(current, totalProgress);
if (Objects.isNull(callback)) {
return;
}
callback.onProgressing(totalProgress, this.currentProgress);
}
protected Callback getCallback() {
return callback;
}
@SuppressWarnings("unused")
public interface Callback {
default void onStart(EpubBook epubBook) {
}
default void onProgressing(int total, int progress) {
}
default void onEnd(EpubBook epubBook) {
}
}
}