|
|
|
@@ -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<String, Int>()
|
|
|
|
|
val exportMsg = ConcurrentHashMap<String, String>()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
data class ExportConfig(
|
|
|
|
|
val path: String,
|
|
|
|
|
val type: String,
|
|
|
|
|
val epubSize: Int = 1,
|
|
|
|
|
val epubScope: String? = null
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
private val waitExportBooks = linkedMapOf<String, ExportConfig>()
|
|
|
|
|
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>("cacheActivity"))
|
|
|
|
|
notification.addAction(
|
|
|
|
|
R.drawable.ic_stop_black_24dp,
|
|
|
|
|
getString(R.string.cancel),
|
|
|
|
|
servicePendingIntent<ExportBookService>(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<Triple<String, Int, String>>?) -> 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<Pair<String, ArrayList<Triple<String, Int, String>>?>>(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<String, ArrayList<Triple<String, Int, String>>?> {
|
|
|
|
|
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<Triple<String, Int, String>>()
|
|
|
|
|
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<Int>()
|
|
|
|
|
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
|
|
|
|
|