From d592122192f7380ff841ccd3bfad6263c2e1981e Mon Sep 17 00:00:00 2001 From: kunfei Date: Sat, 22 Jul 2023 17:59:03 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/AndroidManifest.xml | 1 + .../java/io/legado/app/constant/EventBus.kt | 1 + .../io/legado/app/service/CacheBookService.kt | 4 + .../legado/app/service/ExportBookService.kt | 930 ++++++++++++++++++ .../legado/app/ui/book/cache/CacheActivity.kt | 62 +- .../app/ui/book/cache/CacheViewModel.kt | 907 +---------------- app/src/main/res/drawable/ic_export.xml | 4 +- 7 files changed, 982 insertions(+), 927 deletions(-) create mode 100644 app/src/main/java/io/legado/app/service/ExportBookService.kt 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