diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 3e76368fb..bb3f60f1c 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -417,6 +417,7 @@
+
removeDownload(intent.getStringExtra("bookUrl"))
IntentAction.stop -> stopSelf()
}
diff --git a/app/src/main/java/io/legado/app/service/ExportBookService.kt b/app/src/main/java/io/legado/app/service/ExportBookService.kt
new file mode 100644
index 000000000..ed8c016a2
--- /dev/null
+++ b/app/src/main/java/io/legado/app/service/ExportBookService.kt
@@ -0,0 +1,930 @@
+package io.legado.app.service
+
+import android.content.Intent
+import android.graphics.Bitmap
+import android.graphics.drawable.Drawable
+import android.net.Uri
+import android.util.ArraySet
+import androidx.core.app.NotificationCompat
+import androidx.documentfile.provider.DocumentFile
+import com.bumptech.glide.Glide
+import com.bumptech.glide.request.target.CustomTarget
+import com.bumptech.glide.request.transition.Transition
+import io.legado.app.R
+import io.legado.app.base.BaseService
+import io.legado.app.constant.AppConst
+import io.legado.app.constant.AppLog
+import io.legado.app.constant.AppPattern
+import io.legado.app.constant.EventBus
+import io.legado.app.constant.IntentAction
+import io.legado.app.data.appDb
+import io.legado.app.data.entities.Book
+import io.legado.app.data.entities.BookChapter
+import io.legado.app.exception.NoStackTraceException
+import io.legado.app.help.AppWebDav
+import io.legado.app.help.book.BookHelp
+import io.legado.app.help.book.ContentProcessor
+import io.legado.app.help.book.getExportFileName
+import io.legado.app.help.config.AppConfig
+import io.legado.app.help.coroutine.OrderCoroutine
+import io.legado.app.ui.book.cache.CacheActivity
+import io.legado.app.utils.DocumentUtils
+import io.legado.app.utils.FileUtils
+import io.legado.app.utils.HtmlFormatter
+import io.legado.app.utils.MD5Utils
+import io.legado.app.utils.NetworkUtils
+import io.legado.app.utils.activityPendingIntent
+import io.legado.app.utils.cnCompare
+import io.legado.app.utils.createFolderIfNotExist
+import io.legado.app.utils.isContentScheme
+import io.legado.app.utils.postEvent
+import io.legado.app.utils.readBytes
+import io.legado.app.utils.readText
+import io.legado.app.utils.servicePendingIntent
+import io.legado.app.utils.toastOnUi
+import io.legado.app.utils.writeBytes
+import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.Dispatchers.IO
+import kotlinx.coroutines.Dispatchers.Main
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.async
+import kotlinx.coroutines.ensureActive
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import me.ag2s.epublib.domain.Author
+import me.ag2s.epublib.domain.Date
+import me.ag2s.epublib.domain.EpubBook
+import me.ag2s.epublib.domain.FileResourceProvider
+import me.ag2s.epublib.domain.LazyResource
+import me.ag2s.epublib.domain.Metadata
+import me.ag2s.epublib.domain.Resource
+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
+import java.nio.charset.Charset
+import java.util.concurrent.ConcurrentHashMap
+
+/**
+ * 导出书籍服务
+ */
+class ExportBookService : BaseService() {
+
+ companion object {
+ val exportProgress = ConcurrentHashMap()
+ val exportMsg = ConcurrentHashMap()
+ }
+
+ data class ExportConfig(
+ val path: String,
+ val type: String,
+ val epubSize: Int = 1,
+ val epubScope: String? = null
+ )
+
+ private val waitExportBooks = linkedMapOf()
+ private var exportJob: Job? = null
+
+ private var notificationContent = appCtx.getString(R.string.service_starting)
+
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ when (intent?.action) {
+ IntentAction.start -> kotlin.runCatching {
+ val bookUrl = intent.getStringExtra("bookUrl")!!
+ if (!exportProgress.contains(bookUrl)) {
+ val exportConfig = ExportConfig(
+ path = intent.getStringExtra("exportPath")!!,
+ type = intent.getStringExtra("exportType")!!,
+ epubSize = intent.getIntExtra("epubSize", 1),
+ epubScope = intent.getStringExtra("epubScope")
+ )
+ waitExportBooks[bookUrl] = exportConfig
+ export()
+ }
+ }.onFailure {
+ toastOnUi(it.localizedMessage)
+ }
+
+ IntentAction.stop -> stopSelf()
+ }
+ return super.onStartCommand(intent, flags, startId)
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ exportProgress.clear()
+ exportMsg.clear()
+ }
+
+ override fun upNotification() {
+ val notification = NotificationCompat.Builder(this, AppConst.channelIdDownload)
+ .setSmallIcon(R.drawable.ic_export)
+ .setOngoing(true)
+ .setContentTitle(getString(R.string.export))
+ .setContentIntent(activityPendingIntent("cacheActivity"))
+ notification.addAction(
+ R.drawable.ic_stop_black_24dp,
+ getString(R.string.cancel),
+ servicePendingIntent(IntentAction.stop)
+ )
+ notification.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
+ notification.setContentText(notificationContent)
+ startForeground(AppConst.notificationIdCache, notification.build())
+ }
+
+ private fun export() {
+ if (exportJob?.isActive == true) {
+ return
+ }
+ val entry = waitExportBooks.firstNotNullOfOrNull { it }
+ if (entry == null) {
+ notificationContent = "导出完成"
+ upNotification()
+ return
+ }
+ val bookUrl = entry.key
+ val exportConfig = entry.value
+ if (exportProgress.contains(bookUrl)) return
+ exportProgress[bookUrl] = 0
+ waitExportBooks.remove(bookUrl)
+ exportJob = launch(IO) {
+ val book = appDb.bookDao.getBook(bookUrl)
+ try {
+ book ?: throw NoStackTraceException("获取${bookUrl}书籍出错")
+ notificationContent = "正在导出(${book.name}),还有${waitExportBooks.size}本待导出"
+ if (exportConfig.type == "epub") {
+ if (exportConfig.epubScope.isNullOrBlank()) {
+ exportEPUB(exportConfig.path, book)
+ } else {
+ CustomExporter(paresScope(exportConfig.epubScope), exportConfig.epubSize)
+ .export(exportConfig.path, book)
+ }
+ } else {
+ export(exportConfig.path, book)
+ }
+ exportMsg[book.bookUrl] = getString(R.string.export_success)
+ } catch (e: Throwable) {
+ exportMsg[bookUrl] = e.localizedMessage ?: "ERROR"
+ AppLog.put("导出书籍<${book?.name ?: bookUrl}>出错", e)
+ } finally {
+ exportProgress.remove(bookUrl)
+ postEvent(EventBus.EXPORT_BOOK, bookUrl)
+ }
+ withContext(Main) {
+ export()
+ }
+ }
+ }
+
+ private suspend fun export(path: String, book: Book) {
+ exportMsg.remove(book.bookUrl)
+ postEvent(EventBus.EXPORT_BOOK, book.bookUrl)
+ if (path.isContentScheme()) {
+ val uri = Uri.parse(path)
+ val doc = DocumentFile.fromTreeUri(this@ExportBookService, uri)
+ ?: throw NoStackTraceException("获取导出文档失败")
+ export(doc, book)
+ } else {
+ export(File(path).createFolderIfNotExist(), book)
+ }
+ }
+
+ private suspend fun export(doc: DocumentFile, book: Book) {
+ val filename = book.getExportFileName("txt")
+ DocumentUtils.delete(doc, filename)
+ val bookDoc = DocumentUtils.createFileIfNotExist(doc, filename)
+ ?: throw NoStackTraceException("创建文档失败,请尝试重新设置导出文件夹")
+ contentResolver.openOutputStream(bookDoc.uri, "wa")?.use { bookOs ->
+ getAllContents(book) { text, srcList ->
+ bookOs.write(text.toByteArray(Charset.forName(AppConfig.exportCharset)))
+ srcList?.forEach {
+ val vFile = BookHelp.getImage(book, it.third)
+ if (vFile.exists()) {
+ DocumentUtils.createFileIfNotExist(
+ doc,
+ "${it.second}-${MD5Utils.md5Encode16(it.third)}.jpg",
+ subDirs = arrayOf("${book.name}_${book.author}", "images", it.first)
+ )?.writeBytes(this, vFile.readBytes())
+ }
+ }
+ }
+ }
+ if (AppConfig.exportToWebDav) {
+ // 导出到webdav
+ AppWebDav.exportWebDav(bookDoc.uri, filename)
+ }
+ }
+
+ private suspend fun export(file: File, book: Book) {
+ val filename = book.getExportFileName("txt")
+ val bookPath = FileUtils.getPath(file, filename)
+ val bookFile = FileUtils.createFileWithReplace(bookPath)
+ getAllContents(book) { text, srcList ->
+ bookFile.appendText(text, Charset.forName(AppConfig.exportCharset))
+ srcList?.forEach {
+ val vFile = BookHelp.getImage(book, it.third)
+ if (vFile.exists()) {
+ FileUtils.createFileIfNotExist(
+ file,
+ "${book.name}_${book.author}",
+ "images",
+ it.first,
+ "${it.second}-${MD5Utils.md5Encode16(it.third)}.jpg"
+ ).writeBytes(vFile.readBytes())
+ }
+ }
+ }
+ if (AppConfig.exportToWebDav) {
+ AppWebDav.exportWebDav(Uri.fromFile(bookFile), filename) // 导出到webdav
+ }
+ }
+
+ private suspend fun getAllContents(
+ book: Book,
+ append: (text: String, srcList: ArrayList>?) -> Unit
+ ) {
+ val useReplace = AppConfig.exportUseReplace && book.getUseReplaceRule()
+ val contentProcessor = ContentProcessor.get(book.name, book.origin)
+ val qy = "${book.name}\n${
+ getString(R.string.author_show, book.getRealAuthor())
+ }\n${
+ getString(
+ R.string.intro_show,
+ "\n" + HtmlFormatter.format(book.getDisplayIntro())
+ )
+ }"
+ append(qy, null)
+ if (AppConfig.parallelExportBook) {
+ val oc =
+ OrderCoroutine>?>>(AppConfig.threadCount)
+ appDb.bookChapterDao.getChapterList(book.bookUrl).forEach { chapter ->
+ oc.submit { getExportData(book, chapter, contentProcessor, useReplace) }
+ }
+ oc.collect { index, result ->
+ postEvent(EventBus.EXPORT_BOOK, book.bookUrl)
+ exportProgress[book.bookUrl] = index
+ append.invoke(result.first, result.second)
+ }
+ } else {
+ appDb.bookChapterDao.getChapterList(book.bookUrl).forEachIndexed { index, chapter ->
+ kotlin.coroutines.coroutineContext.ensureActive()
+ postEvent(EventBus.EXPORT_BOOK, book.bookUrl)
+ exportProgress[book.bookUrl] = index
+ val result = getExportData(book, chapter, contentProcessor, useReplace)
+ append.invoke(result.first, result.second)
+ }
+ }
+
+ }
+
+ private fun getExportData(
+ book: Book,
+ chapter: BookChapter,
+ contentProcessor: ContentProcessor,
+ useReplace: Boolean
+ ): Pair>?> {
+ BookHelp.getContent(book, chapter).let { content ->
+ val content1 = contentProcessor
+ .getContent(
+ book,
+ // 不导出vip标识
+ chapter.apply { isVip = false },
+ content ?: if (chapter.isVolume) "" else "null",
+ includeTitle = !AppConfig.exportNoChapterName,
+ useReplace = useReplace,
+ chineseConvert = false,
+ reSegment = false
+ ).toString()
+ if (AppConfig.exportPictureFile) {
+ //txt导出图片文件
+ val srcList = arrayListOf>()
+ content?.split("\n")?.forEachIndexed { index, text ->
+ val matcher = AppPattern.imgPattern.matcher(text)
+ while (matcher.find()) {
+ matcher.group(1)?.let {
+ val src = NetworkUtils.getAbsoluteURL(chapter.url, it)
+ srcList.add(Triple(chapter.title, index, src))
+ }
+ }
+ }
+ return Pair("\n\n$content1", srcList)
+ } else {
+ return Pair("\n\n$content1", null)
+ }
+ }
+ }
+
+ /**
+ * 解析范围字符串
+ *
+ * @param scope 范围字符串
+ * @return 范围
+ *
+ * @since 2023/5/22
+ * @author Discut
+ */
+ private fun paresScope(scope: String): IntArray {
+ val split = scope.split(",")
+ val result = ArraySet()
+ for (s in split) {
+ val v = s.split("-")
+ if (v.size != 2) {
+ result.add(s.toInt() - 1)
+ continue
+ }
+ val left = v[0].toInt()
+ val right = v[1].toInt()
+ if (left > right) {
+ AppLog.put("Error expression : $s; left > right")
+ continue
+ }
+ for (i in left..right)
+ result.add(i - 1)
+ }
+ return result.toIntArray()
+ }
+
+ /**
+ * 导出Epub
+ */
+ private suspend fun exportEPUB(path: String, book: Book) {
+ exportMsg.remove(book.bookUrl)
+ postEvent(EventBus.EXPORT_BOOK, book.bookUrl)
+ if (path.isContentScheme()) {
+ val uri = Uri.parse(path)
+ val doc = DocumentFile.fromTreeUri(this@ExportBookService, uri)
+ ?: throw NoStackTraceException("获取导出文档失败")
+ exportEpub(doc, book)
+ } else {
+ exportEpub(File(path).createFolderIfNotExist(), book)
+ }
+ }
+
+ private suspend fun exportEpub(doc: DocumentFile, book: Book) {
+ val filename = book.getExportFileName("epub")
+ DocumentUtils.delete(doc, filename)
+ val epubBook = EpubBook()
+ epubBook.version = "2.0"
+ //set metadata
+ setEpubMetadata(book, epubBook)
+ //set cover
+ setCover(book, epubBook)
+ //set css
+ val contentModel = setAssets(doc, book, epubBook)
+
+ //设置正文
+ setEpubContent(contentModel, book, epubBook)
+ DocumentUtils.createFileIfNotExist(doc, filename)?.let { bookDoc ->
+ contentResolver.openOutputStream(bookDoc.uri, "wa")?.use { bookOs ->
+ EpubWriter().write(epubBook, BufferedOutputStream(bookOs))
+ }
+ if (AppConfig.exportToWebDav) {
+ // 导出到webdav
+ AppWebDav.exportWebDav(bookDoc.uri, filename)
+ }
+ }
+ }
+
+
+ private suspend fun exportEpub(file: File, book: Book) {
+ val filename = book.getExportFileName("epub")
+ val epubBook = EpubBook()
+ epubBook.version = "2.0"
+ //set metadata
+ setEpubMetadata(book, epubBook)
+ //set cover
+ setCover(book, epubBook)
+ //set css
+ val contentModel = setAssets(book, epubBook)
+
+ val bookPath = FileUtils.getPath(file, filename)
+ val bookFile = FileUtils.createFileWithReplace(bookPath)
+ //设置正文
+ setEpubContent(contentModel, book, epubBook)
+ @Suppress("BlockingMethodInNonBlockingContext")
+ EpubWriter().write(epubBook, BufferedOutputStream(FileOutputStream(bookFile)))
+ if (AppConfig.exportToWebDav) {
+ // 导出到webdav
+ AppWebDav.exportWebDav(Uri.fromFile(bookFile), filename)
+ }
+ }
+
+ private fun setAssets(doc: DocumentFile, book: Book, epubBook: EpubBook): String {
+ var contentModel = ""
+ DocumentUtils.getDirDocument(doc, "Asset").let { customPath ->
+ if (customPath == null) {//使用内置模板
+ contentModel = setAssets(book, epubBook)
+ } else {//外部模板
+ customPath.listFiles().forEach { folder ->
+ if (folder.isDirectory && folder.name == "Text") {
+ folder.listFiles().sortedWith { o1, o2 ->
+ val name1 = o1.name ?: ""
+ val name2 = o2.name ?: ""
+ name1.cnCompare(name2)
+ }.forEach { file ->
+ if (file.isFile) {
+ when {
+ //正文模板
+ file.name.equals("chapter.html", true)
+ || file.name.equals("chapter.xhtml", true) -> {
+ contentModel = file.readText(this)
+ }
+ //封面等其他模板
+ true == file.name?.endsWith("html", true) -> {
+ epubBook.addSection(
+ FileUtils.getNameExcludeExtension(
+ file.name ?: "Cover.html"
+ ),
+ ResourceUtil.createPublicResource(
+ book.name,
+ book.getRealAuthor(),
+ book.getDisplayIntro(),
+ book.kind,
+ book.wordCount,
+ file.readText(this),
+ "${folder.name}/${file.name}"
+ )
+ )
+ }
+
+ else -> {
+ //其他格式文件当做资源文件
+ folder.listFiles().forEach {
+ if (it.isFile)
+ epubBook.resources.add(
+ Resource(
+ it.readBytes(this),
+ "${folder.name}/${it.name}"
+ )
+ )
+ }
+ }
+ }
+ }
+ }
+ } else if (folder.isDirectory) {
+ //资源文件
+ folder.listFiles().forEach {
+ if (it.isFile)
+ epubBook.resources.add(
+ Resource(
+ it.readBytes(this),
+ "${folder.name}/${it.name}"
+ )
+ )
+ }
+ } else {//Asset下面的资源文件
+ epubBook.resources.add(
+ Resource(
+ folder.readBytes(this),
+ "${folder.name}"
+ )
+ )
+ }
+ }
+ }
+ }
+
+ return contentModel
+ }
+
+ private fun setAssets(book: Book, epubBook: EpubBook): String {
+ epubBook.resources.add(
+ Resource(
+ appCtx.assets.open("epub/fonts.css").readBytes(),
+ "Styles/fonts.css"
+ )
+ )
+ epubBook.resources.add(
+ Resource(
+ appCtx.assets.open("epub/main.css").readBytes(),
+ "Styles/main.css"
+ )
+ )
+ epubBook.resources.add(
+ Resource(
+ appCtx.assets.open("epub/logo.png").readBytes(),
+ "Images/logo.png"
+ )
+ )
+ epubBook.addSection(
+ getString(R.string.img_cover),
+ ResourceUtil.createPublicResource(
+ book.name,
+ book.getRealAuthor(),
+ book.getDisplayIntro(),
+ book.kind,
+ book.wordCount,
+ String(appCtx.assets.open("epub/cover.html").readBytes()),
+ "Text/cover.html"
+ )
+ )
+ epubBook.addSection(
+ getString(R.string.book_intro),
+ ResourceUtil.createPublicResource(
+ book.name,
+ book.getRealAuthor(),
+ book.getDisplayIntro(),
+ book.kind,
+ book.wordCount,
+ String(appCtx.assets.open("epub/intro.html").readBytes()),
+ "Text/intro.html"
+ )
+ )
+ return String(appCtx.assets.open("epub/chapter.html").readBytes())
+ }
+
+ private fun setCover(book: Book, epubBook: EpubBook) {
+ Glide.with(this)
+ .asBitmap()
+ .load(book.getDisplayCover())
+ .into(object : CustomTarget() {
+ override fun onResourceReady(
+ resource: Bitmap,
+ transition: Transition?
+ ) {
+ val stream = ByteArrayOutputStream()
+ resource.compress(Bitmap.CompressFormat.JPEG, 100, stream)
+ val byteArray: ByteArray = stream.toByteArray()
+ stream.close()
+ epubBook.coverImage = Resource(byteArray, "Images/cover.jpg")
+ }
+
+ override fun onLoadCleared(placeholder: Drawable?) {
+ }
+ })
+ }
+
+ private suspend fun setEpubContent(
+ contentModel: String,
+ book: Book,
+ epubBook: EpubBook
+ ) {
+ //正文
+ val useReplace = AppConfig.exportUseReplace && book.getUseReplaceRule()
+ val contentProcessor = ContentProcessor.get(book.name, book.origin)
+ appDb.bookChapterDao.getChapterList(book.bookUrl).forEachIndexed { index, chapter ->
+ kotlin.coroutines.coroutineContext.ensureActive()
+ postEvent(EventBus.EXPORT_BOOK, book.bookUrl)
+ exportProgress[book.bookUrl] = index
+ BookHelp.getContent(book, chapter).let { content ->
+ var content1 = fixPic(
+ epubBook,
+ book,
+ content ?: if (chapter.isVolume) "" else "null",
+ chapter
+ )
+ content1 = contentProcessor
+ .getContent(
+ book,
+ chapter,
+ content1,
+ includeTitle = false,
+ useReplace = useReplace,
+ chineseConvert = false,
+ reSegment = false
+ ).toString()
+ val title = chapter.run {
+ // 不导出vip标识
+ isVip = false
+ getDisplayTitle(
+ contentProcessor.getTitleReplaceRules(),
+ useReplace = useReplace
+ )
+ }
+ epubBook.addSection(
+ title,
+ ResourceUtil.createChapterResource(
+ title.replace("\uD83D\uDD12", ""),
+ content1,
+ contentModel,
+ "Text/chapter_${index}.html"
+ )
+ )
+ }
+ }
+ }
+
+ private fun fixPic(
+ epubBook: EpubBook,
+ book: Book,
+ content: String,
+ chapter: BookChapter
+ ): String {
+ val data = StringBuilder("")
+ content.split("\n").forEach { text ->
+ var text1 = text
+ val matcher = AppPattern.imgPattern.matcher(text)
+ while (matcher.find()) {
+ matcher.group(1)?.let {
+ val src = NetworkUtils.getAbsoluteURL(chapter.url, it)
+ val originalHref =
+ "${MD5Utils.md5Encode16(src)}.${BookHelp.getImageSuffix(src)}"
+ val href =
+ "Images/${MD5Utils.md5Encode16(src)}.${BookHelp.getImageSuffix(src)}"
+ val vFile = BookHelp.getImage(book, src)
+ val fp = FileResourceProvider(vFile.parent)
+ if (vFile.exists()) {
+ val img = LazyResource(fp, href, originalHref)
+ epubBook.resources.add(img)
+ }
+ text1 = text1.replace(src, "../${href}")
+ }
+ }
+ data.append(text1).append("\n")
+ }
+ return data.toString()
+ }
+
+ private fun setEpubMetadata(book: Book, epubBook: EpubBook) {
+ val metadata = Metadata()
+ metadata.titles.add(book.name)//书籍的名称
+ metadata.authors.add(Author(book.getRealAuthor()))//书籍的作者
+ metadata.language = "zh"//数据的语言
+ metadata.dates.add(Date())//数据的创建日期
+ metadata.publishers.add("Legado")//数据的创建者
+ metadata.descriptions.add(book.getDisplayIntro())//书籍的简介
+ //metadata.subjects.add("")//书籍的主题,在静读天下里面有使用这个分类书籍
+ epubBook.metadata = metadata
+ }
+
+ //////end of EPUB
+
+ //////start of custom exporter
+ /**
+ * 自定义Exporter
+ * @param scope 导出范围
+ * @param size epub 文件包含最大章节数
+ */
+ inner class CustomExporter(private val scope: IntArray, private val size: Int) {
+
+ /**
+ * 导出Epub
+ * @param path 导出的路径
+ * @param book 书籍
+ */
+ suspend fun export(
+ path: String,
+ book: Book
+ ) {
+ exportProgress[book.bookUrl] = 0
+ exportMsg.remove(book.bookUrl)
+ postEvent(EventBus.EXPORT_BOOK, book.bookUrl)
+ val currentTimeMillis = System.currentTimeMillis()
+ when (path.isContentScheme()) {
+ true -> {
+ val uri = Uri.parse(path)
+ val doc = DocumentFile.fromTreeUri(this@ExportBookService, uri)
+ ?: throw NoStackTraceException("获取导出文档失败")
+ val (contentModel, epubList) = createEpubs(book, doc)
+ val asyncBlocks = ArrayList>(epubList.size)
+ var progressBar = 0.0
+ epubList.forEachIndexed { index, ep ->
+ val (filename, epubBook) = ep
+ val asyncBlock = async {
+ //设置正文
+ setEpubContent(
+ contentModel,
+ book,
+ epubBook,
+ index
+ ) { _, _ ->
+ // 将章节写入内存时更新进度条
+ postEvent(EventBus.EXPORT_BOOK, book.bookUrl)
+ progressBar += book.totalChapterNum.toDouble() / scope.size / 2
+ exportProgress[book.bookUrl] = progressBar.toInt()
+ }
+ save2Drive(filename, epubBook, doc) { total, progress ->
+ //写入硬盘时更新进度条
+ progressBar += book.totalChapterNum.toDouble() / epubList.size / total / 2
+ postEvent(EventBus.EXPORT_BOOK, book.bookUrl)
+ 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>(epubList.size)
+ var progressBar = 0.0
+ epubList.forEachIndexed { index, ep ->
+ val (filename, epubBook) = ep
+ val asyncBlock = async {
+ //设置正文
+ setEpubContent(
+ contentModel,
+ book,
+ epubBook,
+ index
+ ) { _, _ ->
+ postEvent(EventBus.EXPORT_BOOK, book.bookUrl)
+ exportProgress[book.bookUrl] =
+ exportProgress[book.bookUrl]?.plus(book.totalChapterNum / scope.size)
+ ?: 1
+ }
+ save2Drive(filename, epubBook, file) { total, progress ->
+ //设置进度
+ progressBar += book.totalChapterNum.toDouble() / epubList.size / total / 2
+ postEvent(EventBus.EXPORT_BOOK, book.bookUrl)
+ exportProgress[book.bookUrl] = progressBar.toInt()
+ }
+ }
+ asyncBlocks.add(asyncBlock)
+ }
+ asyncBlocks.forEach { it.await() }
+ }
+ }
+ AppLog.put("分割导出书籍 ${book.name} 一共耗时 ${System.currentTimeMillis() - currentTimeMillis}")
+ }
+
+
+ /**
+ * 设置epub正文
+ *
+ * @param contentModel 正文模板
+ * @param book 书籍
+ * @param epubBook 分割后的epub
+ * @param epubBookIndex 分割后的epub序号
+ */
+ private fun setEpubContent(
+ contentModel: String,
+ book: Book,
+ epubBook: EpubBook,
+ epubBookIndex: Int,
+ updateProgress: (chapterList: MutableList, index: Int) -> Unit
+ ) {
+ //正文
+ val useReplace = AppConfig.exportUseReplace && book.getUseReplaceRule()
+ val contentProcessor = ContentProcessor.get(book.name, book.origin)
+ var chapterList: MutableList = ArrayList()
+ appDb.bookChapterDao.getChapterList(book.bookUrl).forEachIndexed { index, chapter ->
+ if (scope.indexOf(index) >= 0) {
+ chapterList.add(chapter)
+ }
+ if (scope.size == chapterList.size) {
+ return@forEachIndexed
+ }
+ }
+ // val totalChapterNum = book.totalChapterNum / scope.size
+ if (chapterList.size == 0) {
+ throw RuntimeException("书籍<${book.name}>(${epubBookIndex + 1})未找到章节信息")
+ }
+ chapterList = chapterList.subList(
+ epubBookIndex * size,
+ if ((epubBookIndex + 1) * size > scope.size) scope.size else (epubBookIndex + 1) * size
+ )
+ chapterList.forEachIndexed { index, chapter ->
+ coroutineContext.ensureActive()
+ updateProgress(chapterList, index)
+ BookHelp.getContent(book, chapter).let { content ->
+ var content1 = fixPic(
+ epubBook,
+ book,
+ content ?: if (chapter.isVolume) "" else "null",
+ chapter
+ )
+ content1 = contentProcessor
+ .getContent(
+ book,
+ chapter,
+ content1,
+ includeTitle = false,
+ useReplace = useReplace,
+ chineseConvert = false,
+ reSegment = false
+ ).toString()
+ val title = chapter.run {
+ // 不导出vip标识
+ isVip = false
+ getDisplayTitle(
+ contentProcessor.getTitleReplaceRules(),
+ useReplace = useReplace
+ )
+ }
+ epubBook.addSection(
+ title,
+ ResourceUtil.createChapterResource(
+ title.replace("\uD83D\uDD12", ""),
+ content1,
+ contentModel,
+ "Text/chapter_${index}.html"
+ )
+ )
+ }
+ }
+ }
+
+ /**
+ * 创建多个epub 对象
+ *
+ * 分割epub时,一个书籍需要创建多个epub对象
+ * @param doc 导出文档
+ * @param book 书籍
+ *
+ * @return <内容模板字符串, >
+ */
+ private fun createEpubs(
+ book: Book,
+ doc: DocumentFile?,
+ ): Pair>> {
+ val paresNumOfEpub = paresNumOfEpub(scope.size, size)
+ val result: MutableList> = 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
+ setEpubMetadata(book, epubBook)
+ //set cover
+ setCover(book, epubBook)
+ //set css
+ contentModel = doc?.let {
+ setAssets(it, book, epubBook)
+ } ?: setAssets(book, epubBook)
+
+ // add epubBook
+ result.add(Pair(filename, epubBook))
+ }
+ return Pair(contentModel, result)
+ }
+
+ /**
+ * 保存文件到 设备
+ */
+ private suspend fun save2Drive(
+ filename: String,
+ epubBook: EpubBook,
+ doc: DocumentFile,
+ callback: (total: Int, progress: Int) -> Unit
+ ) {
+ DocumentUtils.createFileIfNotExist(doc, filename)?.let { bookDoc ->
+ contentResolver.openOutputStream(bookDoc.uri, "wa")?.use { bookOs ->
+ EpubWriter()
+ .setCallback(object : EpubWriterProcessor.Callback {
+ override fun onProgressing(total: Int, progress: Int) {
+ callback(total, progress)
+ }
+ })
+ .write(epubBook, BufferedOutputStream(bookOs))
+ }
+ if (AppConfig.exportToWebDav) {
+ // 导出到webdav
+ AppWebDav.exportWebDav(bookDoc.uri, filename)
+ }
+ }
+ }
+
+ /**
+ * 保存文件到 设备
+ */
+ 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()
+ .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)
+ }
+ }
+
+ /**
+ * 解析 分割epub后的数量
+ *
+ * @param total 章节总数
+ * @param size 每个epub文件包含多少章节
+ */
+ private fun paresNumOfEpub(total: Int, size: Int): Int {
+ val i = total % size
+ var result = total / size
+ if (i > 0) {
+ result++
+ }
+ return result
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/io/legado/app/ui/book/cache/CacheActivity.kt b/app/src/main/java/io/legado/app/ui/book/cache/CacheActivity.kt
index 993c0f311..84dbfe95f 100644
--- a/app/src/main/java/io/legado/app/ui/book/cache/CacheActivity.kt
+++ b/app/src/main/java/io/legado/app/ui/book/cache/CacheActivity.kt
@@ -15,6 +15,7 @@ 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.constant.IntentAction
import io.legado.app.data.appDb
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter
@@ -30,6 +31,7 @@ import io.legado.app.lib.dialogs.SelectItem
import io.legado.app.lib.dialogs.alert
import io.legado.app.lib.dialogs.selector
import io.legado.app.model.CacheBook
+import io.legado.app.service.ExportBookService
import io.legado.app.ui.about.AppLogDialog
import io.legado.app.ui.file.HandleFileContract
import io.legado.app.utils.*
@@ -239,6 +241,9 @@ class CacheActivity : VMBaseActivity()
viewModel.upAdapterLiveData.observe(this) {
notifyItemChanged(it)
}
+ observeEvent(EventBus.EXPORT_BOOK) {
+ notifyItemChanged(it)
+ }
observeEvent(EventBus.UP_DOWNLOAD) {
if (!CacheBook.isRun) {
menu?.findItem(R.id.menu_download)?.let { item ->
@@ -291,7 +296,6 @@ class CacheActivity : VMBaseActivity()
*/
private fun configExportSection(path: String, position: Int) {
-
val alertBinding = DialogSelectSectionExportBinding.inflate(layoutInflater)
.apply {
fun verifyExportFileNameJsStr(js: String): Boolean {
@@ -391,16 +395,22 @@ class CacheActivity : VMBaseActivity()
}
etInputScope.error = null
val toInt = etEpubSize.text.toString().toInt()
- startExport(path, position, toInt, text.toString())
+ adapter.getItem(position)?.let { book ->
+ startService {
+ action = IntentAction.start
+ putExtra("bookUrl", book.bookUrl)
+ putExtra("exportType", "epub")
+ putExtra("exportPath", path)
+ putExtra("epubSize", toInt)
+ putExtra("epubScope", text.toString())
+ }
+ }
alertDialog.hide()
-
}
}
}
-
-
private fun selectExportFolder(exportPosition: Int) {
val default = arrayListOf>()
val path = ACache.get().getAsString(exportBookPathKey)
@@ -413,25 +423,19 @@ class CacheActivity : VMBaseActivity()
}
}
- private fun startExport(path: String, exportPosition: Int, size: Int, scope: String) {
- if (exportPosition >= 0) {
- adapter.getItem(exportPosition)?.let { book ->
- when (AppConfig.exportType) {
- 1 -> viewModel.exportEPUBs(path, book, size, scope)
- // 目前仅支持 epub
- //else -> viewModel.export(path, book)
- }
- }
- }
- }
-
private fun startExport(path: String, exportPosition: Int) {
+ val exportType = when (AppConfig.exportType) {
+ 1 -> "epub"
+ else -> "txt"
+ }
if (exportPosition == -10) {
if (adapter.getItems().isNotEmpty()) {
adapter.getItems().forEach { book ->
- when (AppConfig.exportType) {
- 1 -> viewModel.exportEPUB(path, book)
- else -> viewModel.export(path, book)
+ startService {
+ action = IntentAction.start
+ putExtra("bookUrl", book.bookUrl)
+ putExtra("exportType", exportType)
+ putExtra("exportPath", path)
}
}
} else {
@@ -439,9 +443,11 @@ class CacheActivity : VMBaseActivity()
}
} else if (exportPosition >= 0) {
adapter.getItem(exportPosition)?.let { book ->
- when (AppConfig.exportType) {
- 1 -> viewModel.exportEPUB(path, book)
- else -> viewModel.export(path, book)
+ startService {
+ action = IntentAction.start
+ putExtra("bookUrl", book.bookUrl)
+ putExtra("exportType", exportType)
+ putExtra("exportPath", path)
}
}
}
@@ -450,11 +456,7 @@ class CacheActivity : VMBaseActivity()
@SuppressLint("SetTextI18n")
private fun alertExportFileName() {
alert(R.string.export_file_name) {
- val message =
- "Variable: name, author."
-// if (AppConfig.bookExportFileName.isNullOrBlank()) {
-// message += "\n例如:\nname+\"-\"+author+(epubIndex?\"(\"+epubIndex+\")\":\"\")"
-// }
+ val message = "Variable: name, author."
setMessage(message)
val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
editView.hint = "file name js"
@@ -499,11 +501,11 @@ class CacheActivity : VMBaseActivity()
get() = viewModel.cacheChapters
override fun exportProgress(bookUrl: String): Int? {
- return viewModel.exportProgress[bookUrl]
+ return ExportBookService.exportProgress[bookUrl]
}
override fun exportMsg(bookUrl: String): String? {
- return viewModel.exportMsg[bookUrl]
+ return ExportBookService.exportMsg[bookUrl]
}
}
\ No newline at end of file
diff --git a/app/src/main/java/io/legado/app/ui/book/cache/CacheViewModel.kt b/app/src/main/java/io/legado/app/ui/book/cache/CacheViewModel.kt
index 97c719c2f..25ae5abf9 100644
--- a/app/src/main/java/io/legado/app/ui/book/cache/CacheViewModel.kt
+++ b/app/src/main/java/io/legado/app/ui/book/cache/CacheViewModel.kt
@@ -1,62 +1,30 @@
package io.legado.app.ui.book.cache
import android.app.Application
-import android.graphics.Bitmap
-import android.graphics.drawable.Drawable
-import android.net.Uri
-import android.util.ArraySet
-import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.MutableLiveData
-import com.bumptech.glide.Glide
-import com.bumptech.glide.request.target.CustomTarget
-import com.bumptech.glide.request.transition.Transition
-import io.legado.app.R
import io.legado.app.base.BaseViewModel
-import io.legado.app.constant.AppLog
-import io.legado.app.constant.AppPattern
import io.legado.app.data.appDb
import io.legado.app.data.entities.Book
-import io.legado.app.data.entities.BookChapter
-import io.legado.app.exception.NoStackTraceException
-import io.legado.app.help.AppWebDav
import io.legado.app.help.book.BookHelp
-import io.legado.app.help.book.ContentProcessor
-import io.legado.app.help.book.getExportFileName
import io.legado.app.help.book.isLocal
-import io.legado.app.help.config.AppConfig
import io.legado.app.help.coroutine.Coroutine
-import io.legado.app.help.coroutine.OrderCoroutine
-import io.legado.app.utils.*
-import kotlinx.coroutines.*
-import kotlinx.coroutines.sync.Mutex
-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
-import java.nio.charset.Charset
-import java.nio.file.*
-import java.util.*
-import java.util.concurrent.ConcurrentHashMap
-import kotlin.coroutines.coroutineContext
+import io.legado.app.utils.sendValue
+import kotlinx.coroutines.ensureActive
+import kotlin.collections.HashSet
+import kotlin.collections.List
+import kotlin.collections.contains
+import kotlin.collections.forEach
+import kotlin.collections.hashMapOf
+import kotlin.collections.hashSetOf
+import kotlin.collections.isNotEmpty
+import kotlin.collections.set
class CacheViewModel(application: Application) : BaseViewModel(application) {
val upAdapterLiveData = MutableLiveData()
- val exportProgress = ConcurrentHashMap()
- val exportMsg = ConcurrentHashMap()
- private val mutex = Mutex()
- val cacheChapters = hashMapOf>()
- private var loadChapterCoroutine: Coroutine? = null
- @Volatile
- private var exportNumber = 0
+ private var loadChapterCoroutine: Coroutine? = null
+ val cacheChapters = hashMapOf>()
fun loadCacheFiles(books: List) {
loadChapterCoroutine?.cancel()
@@ -80,855 +48,4 @@ class CacheViewModel(application: Application) : BaseViewModel(application) {
}
}
- fun exportFileExist(path: String, book: Book): Boolean {
- val fileName = book.getExportFileName("txt")
- return if (path.isContentScheme()) {
- val uri = Uri.parse(path)
- val doc = DocumentFile.fromTreeUri(context, uri) ?: return false
- doc.findFile(fileName) ?: return false
- return true
- } else {
- File(path).exists(fileName)
- }
- }
-
- fun export(path: String, book: Book) {
- if (exportProgress.contains(book.bookUrl)) return
- exportProgress[book.bookUrl] = 0
- exportMsg.remove(book.bookUrl)
- upAdapterLiveData.sendValue(book.bookUrl)
- execute {
- mutex.withLock {
- while (exportNumber > 0) {
- delay(1000)
- }
- exportNumber++
- }
- if (path.isContentScheme()) {
- val uri = Uri.parse(path)
- val doc = DocumentFile.fromTreeUri(context, uri)
- ?: throw NoStackTraceException("获取导出文档失败")
- export(doc, book)
- } else {
- export(File(path).createFolderIfNotExist(), book)
- }
- }.onError {
- exportProgress.remove(book.bookUrl)
- exportMsg[book.bookUrl] = it.localizedMessage ?: "ERROR"
- upAdapterLiveData.postValue(book.bookUrl)
- AppLog.put("导出书籍<${book.name}>出错", it)
- }.onSuccess {
- exportProgress.remove(book.bookUrl)
- exportMsg[book.bookUrl] = context.getString(R.string.export_success)
- upAdapterLiveData.postValue(book.bookUrl)
- }.onFinally {
- exportNumber--
- }
- }
-
- private suspend fun export(doc: DocumentFile, book: Book) {
- val filename = book.getExportFileName("txt")
- DocumentUtils.delete(doc, filename)
- val bookDoc = DocumentUtils.createFileIfNotExist(doc, filename)
- ?: throw NoStackTraceException("创建文档失败,请尝试重新设置导出文件夹")
- context.contentResolver.openOutputStream(bookDoc.uri, "wa")?.use { bookOs ->
- getAllContents(book) { text, srcList ->
- bookOs.write(text.toByteArray(Charset.forName(AppConfig.exportCharset)))
- srcList?.forEach {
- val vFile = BookHelp.getImage(book, it.third)
- if (vFile.exists()) {
- DocumentUtils.createFileIfNotExist(
- doc,
- "${it.second}-${MD5Utils.md5Encode16(it.third)}.jpg",
- subDirs = arrayOf("${book.name}_${book.author}", "images", it.first)
- )?.writeBytes(context, vFile.readBytes())
- }
- }
- }
- }
- if (AppConfig.exportToWebDav) {
- // 导出到webdav
- AppWebDav.exportWebDav(bookDoc.uri, filename)
- }
- }
-
- private suspend fun export(file: File, book: Book) {
- val filename = book.getExportFileName("txt")
- val bookPath = FileUtils.getPath(file, filename)
- val bookFile = FileUtils.createFileWithReplace(bookPath)
- getAllContents(book) { text, srcList ->
- bookFile.appendText(text, Charset.forName(AppConfig.exportCharset))
- srcList?.forEach {
- val vFile = BookHelp.getImage(book, it.third)
- if (vFile.exists()) {
- FileUtils.createFileIfNotExist(
- file,
- "${book.name}_${book.author}",
- "images",
- it.first,
- "${it.second}-${MD5Utils.md5Encode16(it.third)}.jpg"
- ).writeBytes(vFile.readBytes())
- }
- }
- }
- if (AppConfig.exportToWebDav) {
- AppWebDav.exportWebDav(Uri.fromFile(bookFile), filename) // 导出到webdav
- }
- }
-
- private suspend fun getAllContents(
- book: Book,
- append: (text: String, srcList: ArrayList>?) -> Unit
- ) {
- val useReplace = AppConfig.exportUseReplace && book.getUseReplaceRule()
- val contentProcessor = ContentProcessor.get(book.name, book.origin)
- val qy = "${book.name}\n${
- context.getString(R.string.author_show, book.getRealAuthor())
- }\n${
- context.getString(
- R.string.intro_show,
- "\n" + HtmlFormatter.format(book.getDisplayIntro())
- )
- }"
- append(qy, null)
- if (AppConfig.parallelExportBook) {
- val oc =
- OrderCoroutine>?>>(AppConfig.threadCount)
- appDb.bookChapterDao.getChapterList(book.bookUrl).forEach { chapter ->
- oc.submit { getExportData(book, chapter, contentProcessor, useReplace) }
- }
- oc.collect { index, result ->
- upAdapterLiveData.postValue(book.bookUrl)
- exportProgress[book.bookUrl] = index
- append.invoke(result.first, result.second)
- }
- } else {
- appDb.bookChapterDao.getChapterList(book.bookUrl).forEachIndexed { index, chapter ->
- coroutineContext.ensureActive()
- upAdapterLiveData.postValue(book.bookUrl)
- exportProgress[book.bookUrl] = index
- val result = getExportData(book, chapter, contentProcessor, useReplace)
- append.invoke(result.first, result.second)
- }
- }
-
- }
-
- private suspend fun getExportData(
- book: Book,
- chapter: BookChapter,
- contentProcessor: ContentProcessor,
- useReplace: Boolean
- ): Pair>?> {
- BookHelp.getContent(book, chapter).let { content ->
- val content1 = contentProcessor
- .getContent(
- book,
- // 不导出vip标识
- chapter.apply { isVip = false },
- content ?: if (chapter.isVolume) "" else "null",
- includeTitle = !AppConfig.exportNoChapterName,
- useReplace = useReplace,
- chineseConvert = false,
- reSegment = false
- ).toString()
- if (AppConfig.exportPictureFile) {
- //txt导出图片文件
- val srcList = arrayListOf>()
- content?.split("\n")?.forEachIndexed { index, text ->
- val matcher = AppPattern.imgPattern.matcher(text)
- while (matcher.find()) {
- matcher.group(1)?.let {
- val src = NetworkUtils.getAbsoluteURL(chapter.url, it)
- srcList.add(Triple(chapter.title, index, src))
- }
- }
- }
- return Pair("\n\n$content1", srcList)
- } else {
- return Pair("\n\n$content1", null)
- }
- }
- }
-
- /**
- * 解析范围字符串
- *
- * @param scope 范围字符串
- * @return 范围
- *
- * @since 2023/5/22
- * @author Discut
- */
- private fun paresScope(scope: String): IntArray {
- val split = scope.split(",")
- val result = ArraySet()
- for (s in split) {
- val v = s.split("-")
- if (v.size != 2) {
- result.add(s.toInt() - 1)
- continue
- }
- val left = v[0].toInt()
- val right = v[1].toInt()
- if (left > right) {
- AppLog.put("Error expression : $s; left > right")
- continue
- }
- for (i in left..right)
- result.add(i - 1)
- }
- return result.toIntArray()
- }
-
-
- //////////////////Start EPUB
- /**
- * 导出Epub 根据自定义导出范围
- *
- * @param path 导出路径
- * @param book 书籍
- * @param size 每本Epub包含的章节
- * @param scope 导出范围
- * @since 2023/5/22
- */
- fun exportEPUBs(path: String, book: Book, size: Int = 1, scope: String) {
- if (exportProgress.contains(book.bookUrl)) return
- CustomExporter(this).let {
- it.scope = paresScope(scope)
- it.size = size
- it.export(path, book)
- }
- }
-
- /**
- * 导出Epub
- */
- fun exportEPUB(path: String, book: Book) {
- if (exportProgress.contains(book.bookUrl)) return
- exportProgress[book.bookUrl] = 0
- exportMsg.remove(book.bookUrl)
- upAdapterLiveData.sendValue(book.bookUrl)
- execute {
- mutex.withLock {
- while (exportNumber > 0) {
- delay(1000)
- }
- exportNumber++
- }
- if (path.isContentScheme()) {
- val uri = Uri.parse(path)
- val doc = DocumentFile.fromTreeUri(context, uri)
- ?: throw NoStackTraceException("获取导出文档失败")
- exportEpub(doc, book)
- } else {
- exportEpub(File(path).createFolderIfNotExist(), book)
- }
- }.onError {
- exportProgress.remove(book.bookUrl)
- exportMsg[book.bookUrl] = it.localizedMessage ?: "ERROR"
- upAdapterLiveData.postValue(book.bookUrl)
- it.printStackTrace()
- AppLog.put("导出epub书籍<${book.name}>出错\n${it.localizedMessage}", it)
- }.onSuccess {
- exportProgress.remove(book.bookUrl)
- exportMsg[book.bookUrl] = context.getString(R.string.export_success)
- upAdapterLiveData.postValue(book.bookUrl)
- }.onFinally {
- exportNumber--
- }
- }
-
- private suspend fun exportEpub(doc: DocumentFile, book: Book) {
- val filename = book.getExportFileName("epub")
- DocumentUtils.delete(doc, filename)
- val epubBook = EpubBook()
- epubBook.version = "2.0"
- //set metadata
- setEpubMetadata(book, epubBook)
- //set cover
- setCover(book, epubBook)
- //set css
- val contentModel = setAssets(doc, book, epubBook)
-
- //设置正文
- setEpubContent(contentModel, book, epubBook)
- DocumentUtils.createFileIfNotExist(doc, filename)?.let { bookDoc ->
- context.contentResolver.openOutputStream(bookDoc.uri, "wa")?.use { bookOs ->
- EpubWriter().write(epubBook, BufferedOutputStream(bookOs))
- }
- if (AppConfig.exportToWebDav) {
- // 导出到webdav
- AppWebDav.exportWebDav(bookDoc.uri, filename)
- }
- }
- }
-
-
- private suspend fun exportEpub(file: File, book: Book) {
- val filename = book.getExportFileName("epub")
- val epubBook = EpubBook()
- epubBook.version = "2.0"
- //set metadata
- setEpubMetadata(book, epubBook)
- //set cover
- setCover(book, epubBook)
- //set css
- val contentModel = setAssets(book, epubBook)
-
- val bookPath = FileUtils.getPath(file, filename)
- val bookFile = FileUtils.createFileWithReplace(bookPath)
- //设置正文
- setEpubContent(contentModel, book, epubBook)
- @Suppress("BlockingMethodInNonBlockingContext")
- EpubWriter().write(epubBook, BufferedOutputStream(FileOutputStream(bookFile)))
- if (AppConfig.exportToWebDav) {
- // 导出到webdav
- AppWebDav.exportWebDav(Uri.fromFile(bookFile), filename)
- }
- }
-
- private fun setAssets(doc: DocumentFile, book: Book, epubBook: EpubBook): String {
- var contentModel = ""
- DocumentUtils.getDirDocument(doc, "Asset").let { customPath ->
- if (customPath == null) {//使用内置模板
- contentModel = setAssets(book, epubBook)
- } else {//外部模板
- customPath.listFiles().forEach { folder ->
- if (folder.isDirectory && folder.name == "Text") {
- folder.listFiles().sortedWith { o1, o2 ->
- val name1 = o1.name ?: ""
- val name2 = o2.name ?: ""
- name1.cnCompare(name2)
- }.forEach { file ->
- if (file.isFile) {
- when {
- //正文模板
- file.name.equals("chapter.html", true)
- || file.name.equals("chapter.xhtml", true) -> {
- contentModel = file.readText(context)
- }
- //封面等其他模板
- true == file.name?.endsWith("html", true) -> {
- epubBook.addSection(
- FileUtils.getNameExcludeExtension(
- file.name ?: "Cover.html"
- ),
- ResourceUtil.createPublicResource(
- book.name,
- book.getRealAuthor(),
- book.getDisplayIntro(),
- book.kind,
- book.wordCount,
- file.readText(context),
- "${folder.name}/${file.name}"
- )
- )
- }
-
- else -> {
- //其他格式文件当做资源文件
- folder.listFiles().forEach {
- if (it.isFile)
- epubBook.resources.add(
- Resource(
- it.readBytes(context),
- "${folder.name}/${it.name}"
- )
- )
- }
- }
- }
- }
- }
- } else if (folder.isDirectory) {
- //资源文件
- folder.listFiles().forEach {
- if (it.isFile)
- epubBook.resources.add(
- Resource(
- it.readBytes(context),
- "${folder.name}/${it.name}"
- )
- )
- }
- } else {//Asset下面的资源文件
- epubBook.resources.add(
- Resource(
- folder.readBytes(context),
- "${folder.name}"
- )
- )
- }
- }
- }
- }
-
- return contentModel
- }
-
- private fun setAssets(book: Book, epubBook: EpubBook): String {
- epubBook.resources.add(
- Resource(
- appCtx.assets.open("epub/fonts.css").readBytes(),
- "Styles/fonts.css"
- )
- )
- epubBook.resources.add(
- Resource(
- appCtx.assets.open("epub/main.css").readBytes(),
- "Styles/main.css"
- )
- )
- epubBook.resources.add(
- Resource(
- appCtx.assets.open("epub/logo.png").readBytes(),
- "Images/logo.png"
- )
- )
- epubBook.addSection(
- context.getString(R.string.img_cover),
- ResourceUtil.createPublicResource(
- book.name,
- book.getRealAuthor(),
- book.getDisplayIntro(),
- book.kind,
- book.wordCount,
- String(appCtx.assets.open("epub/cover.html").readBytes()),
- "Text/cover.html"
- )
- )
- epubBook.addSection(
- context.getString(R.string.book_intro),
- ResourceUtil.createPublicResource(
- book.name,
- book.getRealAuthor(),
- book.getDisplayIntro(),
- book.kind,
- book.wordCount,
- String(appCtx.assets.open("epub/intro.html").readBytes()),
- "Text/intro.html"
- )
- )
- return String(appCtx.assets.open("epub/chapter.html").readBytes())
- }
-
- private fun setCover(book: Book, epubBook: EpubBook) {
- Glide.with(context)
- .asBitmap()
- .load(book.getDisplayCover())
- .into(object : CustomTarget() {
- override fun onResourceReady(
- resource: Bitmap,
- transition: Transition?
- ) {
- val stream = ByteArrayOutputStream()
- resource.compress(Bitmap.CompressFormat.JPEG, 100, stream)
- val byteArray: ByteArray = stream.toByteArray()
- stream.close()
- epubBook.coverImage = Resource(byteArray, "Images/cover.jpg")
- }
-
- override fun onLoadCleared(placeholder: Drawable?) {
- }
- })
- }
-
- private suspend fun setEpubContent(
- contentModel: String,
- book: Book,
- epubBook: EpubBook
- ) {
- //正文
- val useReplace = AppConfig.exportUseReplace && book.getUseReplaceRule()
- val contentProcessor = ContentProcessor.get(book.name, book.origin)
- appDb.bookChapterDao.getChapterList(book.bookUrl).forEachIndexed { index, chapter ->
- coroutineContext.ensureActive()
- upAdapterLiveData.postValue(book.bookUrl)
- exportProgress[book.bookUrl] = index
- BookHelp.getContent(book, chapter).let { content ->
- var content1 = fixPic(
- epubBook,
- book,
- content ?: if (chapter.isVolume) "" else "null",
- chapter
- )
- content1 = contentProcessor
- .getContent(
- book,
- chapter,
- content1,
- includeTitle = false,
- useReplace = useReplace,
- chineseConvert = false,
- reSegment = false
- ).toString()
- val title = chapter.run {
- // 不导出vip标识
- isVip = false
- getDisplayTitle(
- contentProcessor.getTitleReplaceRules(),
- useReplace = useReplace
- )
- }
- epubBook.addSection(
- title,
- ResourceUtil.createChapterResource(
- title.replace("\uD83D\uDD12", ""),
- content1,
- contentModel,
- "Text/chapter_${index}.html"
- )
- )
- }
- }
- }
-
- private fun fixPic(
- epubBook: EpubBook,
- book: Book,
- content: String,
- chapter: BookChapter
- ): String {
- val data = StringBuilder("")
- content.split("\n").forEach { text ->
- var text1 = text
- val matcher = AppPattern.imgPattern.matcher(text)
- while (matcher.find()) {
- matcher.group(1)?.let {
- val src = NetworkUtils.getAbsoluteURL(chapter.url, it)
- val originalHref =
- "${MD5Utils.md5Encode16(src)}.${BookHelp.getImageSuffix(src)}"
- val href =
- "Images/${MD5Utils.md5Encode16(src)}.${BookHelp.getImageSuffix(src)}"
- val vFile = BookHelp.getImage(book, src)
- val fp = FileResourceProvider(vFile.parent)
- if (vFile.exists()) {
- val img = LazyResource(fp, href, originalHref)
- epubBook.resources.add(img)
- }
- text1 = text1.replace(src, "../${href}")
- }
- }
- data.append(text1).append("\n")
- }
- return data.toString()
- }
-
- private fun setEpubMetadata(book: Book, epubBook: EpubBook) {
- val metadata = Metadata()
- metadata.titles.add(book.name)//书籍的名称
- metadata.authors.add(Author(book.getRealAuthor()))//书籍的作者
- metadata.language = "zh"//数据的语言
- metadata.dates.add(Date())//数据的创建日期
- metadata.publishers.add("Legado")//数据的创建者
- metadata.descriptions.add(book.getDisplayIntro())//书籍的简介
- //metadata.subjects.add("")//书籍的主题,在静读天下里面有使用这个分类书籍
- epubBook.metadata = metadata
- }
-
- //////end of EPUB
-
- //////start of custom exporter
- /**
- * 自定义Exporter
- *
- * @since 2023/5/23
- */
- class CustomExporter(private val context: CacheViewModel) {
- var scope: IntArray = IntArray(0)
-
- /**
- * epub 文件包含最大章节数
- */
- var size: Int = 1
-
- /**
- * 导出Epub
- *
- * from [io.legado.app.ui.book.cache.CacheViewModel.exportEPUB]
- * @param path 导出的路径
- * @param book 书籍
- */
- fun export(
- path: String,
- book: Book
- ) {
- context.exportProgress[book.bookUrl] = 0
- context.exportMsg.remove(book.bookUrl)
- context.upAdapterLiveData.sendValue(book.bookUrl)
- context.execute {
- context.mutex.withLock {
- while (context.exportNumber > 0) {
- delay(1000)
- }
- context.exportNumber++
- }
- 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>(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>(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"
- context.upAdapterLiveData.postValue(book.bookUrl)
- it.printStackTrace()
- AppLog.put("导出epub书籍<${book.name}>出错\n${it.localizedMessage}", it)
- }.onSuccess {
- context.exportProgress.remove(book.bookUrl)
- context.exportMsg[book.bookUrl] = context.context.getString(R.string.export_success)
- context.upAdapterLiveData.postValue(book.bookUrl)
- }.onFinally {
- context.exportNumber--
- }
- }
-
-
- /**
- * 设置epub正文
- *
- * from [io.legado.app.ui.book.cache.CacheViewModel.setEpubContent]
- *
- * @param contentModel 正文模板
- * @param book 书籍
- * @param epubBook 分割后的epub
- * @param epubBookIndex 分割后的epub序号
- */
- private suspend fun setEpubContent(
- contentModel: String,
- book: Book,
- epubBook: EpubBook,
- epubBookIndex: Int,
- updateProgress: (chapterList: MutableList, index: Int) -> Unit
- ) {
- //正文
- val useReplace = AppConfig.exportUseReplace && book.getUseReplaceRule()
- val contentProcessor = ContentProcessor.get(book.name, book.origin)
- var chapterList: MutableList = ArrayList()
- appDb.bookChapterDao.getChapterList(book.bookUrl).forEachIndexed { index, chapter ->
- if (scope.indexOf(index) >= 0) {
- chapterList.add(chapter)
- }
- if (scope.size == chapterList.size) {
- return@forEachIndexed
- }
- }
- // val totalChapterNum = book.totalChapterNum / scope.size
- if (chapterList.size == 0) {
- throw RuntimeException("书籍<${book.name}>(${epubBookIndex + 1})未找到章节信息")
- }
- chapterList = chapterList.subList(
- epubBookIndex * size,
- if ((epubBookIndex + 1) * size > scope.size) scope.size else (epubBookIndex + 1) * size
- )
- chapterList.forEachIndexed { index, chapter ->
- coroutineContext.ensureActive()
- updateProgress(chapterList, index)
- BookHelp.getContent(book, chapter).let { content ->
- var content1 = context.fixPic(
- epubBook,
- book,
- content ?: if (chapter.isVolume) "" else "null",
- chapter
- )
- content1 = contentProcessor
- .getContent(
- book,
- chapter,
- content1,
- includeTitle = false,
- useReplace = useReplace,
- chineseConvert = false,
- reSegment = false
- ).toString()
- val title = chapter.run {
- // 不导出vip标识
- isVip = false
- getDisplayTitle(
- contentProcessor.getTitleReplaceRules(),
- useReplace = useReplace
- )
- }
- epubBook.addSection(
- title,
- ResourceUtil.createChapterResource(
- title.replace("\uD83D\uDD12", ""),
- content1,
- contentModel,
- "Text/chapter_${index}.html"
- )
- )
- }
- }
- }
-
- /**
- * 创建多个epub 对象
- *
- * 分割epub时,一个书籍需要创建多个epub对象
- * @param doc 导出文档
- * @param book 书籍
- *
- * @return <内容模板字符串, >
- */
- private fun createEpubs(
- book: Book,
- doc: DocumentFile?,
- ): Pair>> {
- val paresNumOfEpub = paresNumOfEpub(scope.size, size)
- val result: MutableList> = 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
- context.setEpubMetadata(book, epubBook)
- //set cover
- context.setCover(book, epubBook)
- //set css
- contentModel = doc?.let {
- context.setAssets(it, book, epubBook)
- } ?: context.setAssets(book, epubBook)
-
- // add epubBook
- result.add(Pair(filename, epubBook))
- }
- return Pair(contentModel, result)
- }
-
- /**
- * 保存文件到 设备
- */
- 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()
- .setCallback(object : EpubWriterProcessor.Callback {
- override fun onProgressing(total: Int, progress: Int) {
- callback(total, progress)
- }
- })
- .write(epubBook, BufferedOutputStream(bookOs))
- }
- if (AppConfig.exportToWebDav) {
- // 导出到webdav
- AppWebDav.exportWebDav(bookDoc.uri, filename)
- }
- }
- }
-
- /**
- * 保存文件到 设备
- */
- 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()
- .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)
- }
- }
-
- /**
- * 解析 分割epub后的数量
- *
- * @param total 章节总数
- * @param size 每个epub文件包含多少章节
- */
- private fun paresNumOfEpub(total: Int, size: Int): Int {
- val i = total % size
- var result = total / size
- if (i > 0) {
- result++
- }
- return result
- }
- }
}
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_export.xml b/app/src/main/res/drawable/ic_export.xml
index 78d6a76dd..2c34ad3b0 100644
--- a/app/src/main/res/drawable/ic_export.xml
+++ b/app/src/main/res/drawable/ic_export.xml
@@ -6,9 +6,9 @@
android:viewportHeight="1024">
\ No newline at end of file