瀏覽代碼

Merge pull request #3228 from Discut/master

Fix CustomExport
kunfei 3 年之前
父節點
當前提交
114f64af29

+ 1 - 0
app/src/main/java/io/legado/app/constant/PreferKey.kt

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

+ 17 - 2
app/src/main/java/io/legado/app/help/book/BookExtensions.kt

@@ -236,6 +236,7 @@ fun Book.getExportFileName(suffix: String): String {
         return "$name 作者:${getRealAuthor()}.$suffix"
     }
     val bindings = SimpleBindings()
+    bindings["epubIndex"] = ""// 兼容老版本,修复可能存在的错误
     bindings["name"] = name
     bindings["author"] = getRealAuthor()
     return kotlin.runCatching {
@@ -248,8 +249,11 @@ fun Book.getExportFileName(suffix: String): String {
 /**
  * 获取分割文件后的文件名
  */
-fun Book.getExportFileName(suffix: String, epubIndex: Int): String {
-    val jsStr = AppConfig.bookExportFileName
+fun Book.getExportFileName(
+    suffix: String,
+    epubIndex: Int,
+    jsStr: String? = AppConfig.episodeExportFileName
+): String {
     // 默认规则
     val default = "$name 作者:${getRealAuthor()} [${epubIndex}].$suffix"
     if (jsStr.isNullOrBlank()) {
@@ -264,4 +268,15 @@ fun Book.getExportFileName(suffix: String, epubIndex: Int): String {
     }.onFailure {
         AppLog.put("导出书名规则错误,使用默认规则\n${it.localizedMessage}", it)
     }.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)
 }

+ 21 - 1
app/src/main/java/io/legado/app/help/config/AppConfig.kt

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

+ 73 - 20
app/src/main/java/io/legado/app/ui/book/cache/CacheActivity.kt

@@ -8,10 +8,12 @@ import android.view.View
 import androidx.activity.viewModels
 import androidx.appcompat.app.AlertDialog
 import androidx.recyclerview.widget.LinearLayoutManager
+import com.google.android.material.textfield.TextInputLayout
 import io.legado.app.R
 import io.legado.app.base.VMBaseActivity
 import io.legado.app.constant.AppConst
 import io.legado.app.constant.AppConst.charsets
+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
@@ -20,7 +22,9 @@ import io.legado.app.data.entities.BookGroup
 import io.legado.app.databinding.ActivityCacheBookBinding
 import io.legado.app.databinding.DialogEditTextBinding
 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.tryParesExportFileName
 import io.legado.app.help.config.AppConfig
 import io.legado.app.lib.dialogs.SelectItem
 import io.legado.app.lib.dialogs.alert
@@ -55,17 +59,29 @@ class CacheActivity : VMBaseActivity<ActivityCacheBookBinding, CacheViewModel>()
     private var groupId: Long = -1
 
     private val exportDir = registerForActivityResult(HandleFileContract()) { result ->
+        var isReadyPath = false
+        var dirPath = ""
         result.uri?.let { uri ->
             if (uri.isContentScheme()) {
                 ACache.get().put(exportBookPathKey, uri.toString())
-                startExport(uri.toString(), result.requestCode)
+                dirPath = uri.toString()
+                isReadyPath = true
             } else {
                 uri.path?.let { 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?) {
@@ -249,7 +265,7 @@ class CacheActivity : VMBaseActivity<ActivityCacheBookBinding, CacheViewModel>()
         val path = ACache.get().getAsString(exportBookPathKey)
         if (path.isNullOrEmpty()) {
             selectExportFolder(position)
-        } else if (AppConfig.enableCustomExport && AppConfig.exportType == 1) {// 启用自定义导出 and 导出类型为Epub
+        } else if (enableCustomExport()) {// 启用自定义导出 and 导出类型为Epub
             configExportSection(path, position)
         } else {
             startExport(path, position)
@@ -274,9 +290,50 @@ class CacheActivity : VMBaseActivity<ActivityCacheBookBinding, CacheViewModel>()
      * @since 1.0.0
      */
     private fun configExportSection(path: String, position: Int) {
+
+
         val alertBinding = DialogSelectSectionExportBinding.inflate(layoutInflater)
             .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")
+                // 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 {
                     cbAllExport.callOnClick()
                 }
@@ -287,6 +344,8 @@ class CacheActivity : VMBaseActivity<ActivityCacheBookBinding, CacheViewModel>()
                     if (isChecked) {
                         etEpubSize.isEnabled = true
                         etInputScope.isEnabled = true
+                        etEpubFilename.isEnabled = true
+                        enableLyEtEpubFilenameIcon()
                         cbAllExport.isChecked = false
                     }
                 }
@@ -294,6 +353,8 @@ class CacheActivity : VMBaseActivity<ActivityCacheBookBinding, CacheViewModel>()
                     if (isChecked) {
                         etEpubSize.isEnabled = false
                         etInputScope.isEnabled = false
+                        etEpubFilename.isEnabled = false
+                        lyEtEpubFilename.endIconMode = TextInputLayout.END_ICON_NONE
                         cbSelectExport.isChecked = false
                     }
                 }
@@ -306,7 +367,9 @@ class CacheActivity : VMBaseActivity<ActivityCacheBookBinding, CacheViewModel>()
                             etInputScope.hint = ""
                         }
                     }
-                cbAllExport.callOnClick()
+
+                // 默认选择自定义导出
+                cbSelectExport.callOnClick()
             }
         val alertDialog = alert(titleResource = R.string.select_section_export) {
             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) {
         val default = arrayListOf<SelectItem<Int>>()
@@ -397,11 +450,11 @@ class CacheActivity : VMBaseActivity<ActivityCacheBookBinding, CacheViewModel>()
     @SuppressLint("SetTextI18n")
     private fun alertExportFileName() {
         alert(R.string.export_file_name) {
-            var message =
-                "js内有name和author变量,返回书名\n启用自定义epub导出章节时包含额外变量[epubIndex]"
-            if (AppConfig.bookExportFileName.isNullOrBlank()) {
-                message += "\n例如:\nname+\"-\"+author+(epubIndex?\"(\"+epubIndex+\")\":\"\")"
-            }
+            val message =
+                "Variable: name, author."
+//            if (AppConfig.bookExportFileName.isNullOrBlank()) {
+//                message += "\n例如:\nname+\"-\"+author+(epubIndex?\"(\"+epubIndex+\")\":\"\")"
+//            }
             setMessage(message)
             val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
                 editView.hint = "file name js"

+ 115 - 89
app/src/main/java/io/legado/app/ui/book/cache/CacheViewModel.kt

@@ -33,8 +33,10 @@ import kotlinx.coroutines.sync.withLock
 import me.ag2s.epublib.domain.*
 import me.ag2s.epublib.domain.Date
 import me.ag2s.epublib.epub.EpubWriter
+import me.ag2s.epublib.epub.EpubWriterProcessor
 import me.ag2s.epublib.util.ResourceUtil
 import splitties.init.appCtx
+import java.io.BufferedOutputStream
 import java.io.ByteArrayOutputStream
 import java.io.File
 import java.io.FileOutputStream
@@ -42,7 +44,6 @@ import java.nio.charset.Charset
 import java.nio.file.*
 import java.util.*
 import java.util.concurrent.ConcurrentHashMap
-import kotlin.collections.ArrayList
 import kotlin.coroutines.coroutineContext
 
 
@@ -270,7 +271,7 @@ class CacheViewModel(application: Application) : BaseViewModel(application) {
             }
             val left = v[0].toInt()
             val right = v[1].toInt()
-            if (left > right){
+            if (left > right) {
                 AppLog.put("Error expression : $s; left > right")
                 continue
             }
@@ -354,7 +355,7 @@ class CacheViewModel(application: Application) : BaseViewModel(application) {
         setEpubContent(contentModel, book, epubBook)
         DocumentUtils.createFileIfNotExist(doc, filename)?.let { bookDoc ->
             context.contentResolver.openOutputStream(bookDoc.uri, "wa")?.use { bookOs ->
-                EpubWriter().write(epubBook, bookOs)
+                EpubWriter().write(epubBook, BufferedOutputStream(bookOs))
             }
             if (AppConfig.exportToWebDav) {
                 // 导出到webdav
@@ -380,7 +381,7 @@ class CacheViewModel(application: Application) : BaseViewModel(application) {
         //设置正文
         setEpubContent(contentModel, book, epubBook)
         @Suppress("BlockingMethodInNonBlockingContext")
-        EpubWriter().write(epubBook, FileOutputStream(bookFile))
+        EpubWriter().write(epubBook, BufferedOutputStream(FileOutputStream(bookFile)))
         if (AppConfig.exportToWebDav) {
             // 导出到webdav
             AppWebDav.exportWebDav(Uri.fromFile(bookFile), filename)
@@ -636,6 +637,10 @@ class CacheViewModel(application: Application) : BaseViewModel(application) {
      */
     class CustomExporter(private val context: CacheViewModel) {
         var scope: IntArray = IntArray(0)
+
+        /**
+         *  epub 文件包含最大章节数
+         */
         var size: Int = 1
 
         /**
@@ -659,14 +664,75 @@ class CacheViewModel(application: Application) : BaseViewModel(application) {
                     }
                     context.exportNumber++
                 }
-                if (path.isContentScheme()) {
-                    val uri = Uri.parse(path)
-                    val doc = DocumentFile.fromTreeUri(context.context, uri)
-                        ?: throw NoStackTraceException("获取导出文档失败")
-                    exportEpub(doc, book)
-                } else {
-                    exportEpub(File(path).createFolderIfNotExist(), book)
+                val currentTimeMillis = System.currentTimeMillis()
+                when (path.isContentScheme()) {
+                    true -> {
+                        val uri = Uri.parse(path)
+                        val doc = DocumentFile.fromTreeUri(context.context, uri)
+                            ?: throw NoStackTraceException("获取导出文档失败")
+                        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 {
                 context.exportProgress.remove(book.bookUrl)
                 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正文
@@ -729,7 +763,8 @@ class CacheViewModel(application: Application) : BaseViewModel(application) {
             contentModel: String,
             book: Book,
             epubBook: EpubBook,
-            epubBookIndex: Int
+            epubBookIndex: Int,
+            updateProgress: (chapterList: MutableList<BookChapter>, index: Int) -> Unit
         ) {
             //正文
             val useReplace = AppConfig.exportUseReplace && book.getUseReplaceRule()
@@ -743,7 +778,7 @@ class CacheViewModel(application: Application) : BaseViewModel(application) {
                     return@forEachIndexed
                 }
             }
-            val totalChapterNum = book.totalChapterNum / scope.size
+            // val totalChapterNum = book.totalChapterNum / scope.size
             if (chapterList.size == 0) {
                 throw RuntimeException("书籍<${book.name}>(${epubBookIndex + 1})未找到章节信息")
             }
@@ -753,9 +788,7 @@ class CacheViewModel(application: Application) : BaseViewModel(application) {
             )
             chapterList.forEachIndexed { index, chapter ->
                 coroutineContext.ensureActive()
-                context.upAdapterLiveData.postValue(book.bookUrl)
-                context.exportProgress[book.bookUrl] =
-                    totalChapterNum * (epubBookIndex * size + index)
+                updateProgress(chapterList, index)
                 BookHelp.getContent(book, chapter).let { content ->
                     var content1 = context.fixPic(
                         epubBook,
@@ -798,54 +831,23 @@ class CacheViewModel(application: Application) : BaseViewModel(application) {
          * 创建多个epub 对象
          *
          * 分割epub时,一个书籍需要创建多个epub对象
-         *
          * @param doc 导出文档
          * @param book 书籍
          *
          * @return <内容模板字符串, <epub文件名, epub对象>>
          */
         private fun createEpubs(
-            doc: DocumentFile,
-            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)
-                DocumentUtils.delete(doc, filename)
-                val epubBook = EpubBook()
-                epubBook.version = "2.0"
-                //set metadata
-                context.setEpubMetadata(book, epubBook)
-                //set cover
-                context.setCover(book, epubBook)
-                //set css
-                contentModel = context.setAssets(doc, book, epubBook)
-
-                // add epubBook
-                result.add(Pair(filename, epubBook))
-            }
-            return Pair(contentModel, result)
-        }
-
-        /**
-         * 创建多个epub 对象
-         *
-         * 分割epub时,一个书籍需要创建多个epub对象
-         *
-         * @param book 书籍
-         *
-         * @return <内容模板字符串, <epub文件名, epub对象>>
-         */
-        private fun createEpubs(
-            book: Book
+            book: Book,
+            doc: DocumentFile?,
         ): 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)
+                doc?.let {
+                    DocumentUtils.delete(it, filename)
+                }
                 val epubBook = EpubBook()
                 epubBook.version = "2.0"
                 //set metadata
@@ -853,7 +855,9 @@ class CacheViewModel(application: Application) : BaseViewModel(application) {
                 //set cover
                 context.setCover(book, epubBook)
                 //set css
-                contentModel = context.setAssets(book, epubBook)
+                contentModel = doc?.let {
+                    context.setAssets(it, book, epubBook)
+                } ?: context.setAssets(book, epubBook)
 
                 // add 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 ->
                 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) {
                     // 导出到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 bookFile = FileUtils.createFileWithReplace(bookPath)
             @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) {
                 // 导出到webdav
                 AppWebDav.exportWebDav(Uri.fromFile(bookFile), filename)

+ 27 - 0
app/src/main/java/io/legado/app/utils/CustomExportUtils.kt

@@ -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)
+}

+ 25 - 1
app/src/main/res/layout/dialog_select_section_export.xml

@@ -1,5 +1,6 @@
 <?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"
@@ -54,6 +55,29 @@
 
         </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
             android:layout_width="match_parent"
             android:layout_height="wrap_content"
@@ -64,8 +88,8 @@
                 android:id="@+id/et_epub_size"
                 android:layout_width="match_parent"
                 android:layout_height="wrap_content"
-                android:maxLength="6"
                 android:inputType="number"
+                android:maxLength="6"
                 tools:ignore="SpeakableTextPresentCheck,TouchTargetSizeCheck" />
 
         </io.legado.app.ui.widget.text.TextInputLayout>

+ 1 - 0
app/src/main/res/values-es-rES/strings.xml

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

+ 1 - 0
app/src/main/res/values-ja-rJP/strings.xml

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

+ 1 - 0
app/src/main/res/values-pt-rBR/strings.xml

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

+ 1 - 0
app/src/main/res/values-vi/strings.xml

@@ -1118,4 +1118,5 @@ Còn </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="page_to">Trang tới</string>
+    <string name="result_analyzed">Analyzed</string>
 </resources>

+ 1 - 0
app/src/main/res/values-zh-rHK/strings.xml

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

+ 1 - 0
app/src/main/res/values-zh-rTW/strings.xml

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

+ 1 - 0
app/src/main/res/values-zh/strings.xml

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

+ 1 - 0
app/src/main/res/values/strings.xml

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

+ 30 - 14
modules/book/src/main/java/me/ag2s/epublib/epub/EpubWriter.java

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

+ 57 - 0
modules/book/src/main/java/me/ag2s/epublib/epub/EpubWriterProcessor.java

@@ -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) {
+        }
+
+    }
+}