Rename
This commit is contained in:
1 parent
b86d90021c
commit
6c0757c91b
972 files changed
+6885
-7321
No files matched your search
@@ -0,0 +1,16 @@
|
||||
package io.legato.kazusa.help.book
|
||||
|
||||
import io.legato.kazusa.data.entities.ReplaceRule
|
||||
|
||||
data class BookContent(
|
||||
val sameTitleRemoved: Boolean,
|
||||
val textList: List<String>,
|
||||
//起效的替换规则
|
||||
val effectiveReplaceRules: List<ReplaceRule>?
|
||||
) {
|
||||
|
||||
override fun toString(): String {
|
||||
return textList.joinToString("\n")
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
@file:Suppress("unused")
|
||||
|
||||
package io.legato.kazusa.help.book
|
||||
|
||||
import android.net.Uri
|
||||
import com.script.buildScriptBindings
|
||||
import com.script.rhino.RhinoScriptEngine
|
||||
import io.legato.kazusa.constant.AppLog
|
||||
import io.legato.kazusa.constant.AppPattern
|
||||
import io.legato.kazusa.constant.BookSourceType
|
||||
import io.legato.kazusa.constant.BookType
|
||||
import io.legato.kazusa.data.appDb
|
||||
import io.legato.kazusa.data.entities.BaseBook
|
||||
import io.legato.kazusa.data.entities.Book
|
||||
import io.legato.kazusa.data.entities.BookSource
|
||||
import io.legato.kazusa.exception.NoStackTraceException
|
||||
import io.legato.kazusa.help.RuleBigDataHelp
|
||||
import io.legato.kazusa.help.config.AppConfig
|
||||
import io.legato.kazusa.model.localBook.LocalBook
|
||||
import io.legato.kazusa.utils.FileDoc
|
||||
import io.legato.kazusa.utils.GSON
|
||||
import io.legato.kazusa.utils.MD5Utils
|
||||
import io.legato.kazusa.utils.exists
|
||||
import io.legato.kazusa.utils.find
|
||||
import io.legato.kazusa.utils.inputStream
|
||||
import io.legato.kazusa.utils.isUri
|
||||
import io.legato.kazusa.utils.toastOnUi
|
||||
import splitties.init.appCtx
|
||||
import java.io.File
|
||||
import java.time.LocalDate
|
||||
import java.time.Period.between
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
|
||||
val Book.isAudio: Boolean
|
||||
get() = isType(BookType.audio)
|
||||
|
||||
val Book.isImage: Boolean
|
||||
get() = isType(BookType.image)
|
||||
|
||||
val Book.isLocal: Boolean
|
||||
get() {
|
||||
if (type == 0) {
|
||||
return origin == BookType.localTag || origin.startsWith(BookType.webDavTag)
|
||||
}
|
||||
return isType(BookType.local)
|
||||
}
|
||||
|
||||
val Book.isLocalTxt: Boolean
|
||||
get() = isLocal && originName.endsWith(".txt", true)
|
||||
|
||||
val Book.isEpub: Boolean
|
||||
get() = isLocal && originName.endsWith(".epub", true)
|
||||
|
||||
val Book.isUmd: Boolean
|
||||
get() = isLocal && originName.endsWith(".umd", true)
|
||||
|
||||
val Book.isPdf: Boolean
|
||||
get() = isLocal && originName.endsWith(".pdf", true)
|
||||
|
||||
val Book.isMobi: Boolean
|
||||
get() = isLocal && (originName.endsWith(".mobi", true) ||
|
||||
originName.endsWith(".azw3", true) ||
|
||||
originName.endsWith(".azw", true))
|
||||
|
||||
val Book.isOnLineTxt: Boolean
|
||||
get() = !isLocal && isType(BookType.text)
|
||||
|
||||
val Book.isWebFile: Boolean
|
||||
get() = isType(BookType.webFile)
|
||||
|
||||
val Book.isUpError: Boolean
|
||||
get() = isType(BookType.updateError)
|
||||
|
||||
val Book.isArchive: Boolean
|
||||
get() = isType(BookType.archive)
|
||||
|
||||
val Book.isNotShelf: Boolean
|
||||
get() = isType(BookType.notShelf)
|
||||
|
||||
val Book.archiveName: String
|
||||
get() {
|
||||
if (!isArchive) throw NoStackTraceException("Book is not deCompressed from archive")
|
||||
// local_book::archive.rar
|
||||
// webDav::https://...../archive.rar
|
||||
return origin.substringAfter("::").substringAfterLast("/")
|
||||
}
|
||||
|
||||
fun Book.contains(word: String?): Boolean {
|
||||
if (word.isNullOrEmpty()) {
|
||||
return true
|
||||
}
|
||||
return name.contains(word)
|
||||
|| author.contains(word)
|
||||
|| originName.contains(word)
|
||||
|| origin.contains(word)
|
||||
|| kind?.contains(word) == true
|
||||
|| intro?.contains(word) == true
|
||||
}
|
||||
|
||||
private val localUriCache by lazy {
|
||||
ConcurrentHashMap<String, Uri>()
|
||||
}
|
||||
|
||||
fun Book.getLocalUri(): Uri {
|
||||
if (!isLocal) {
|
||||
throw NoStackTraceException("不是本地书籍")
|
||||
}
|
||||
var uri = localUriCache[bookUrl]
|
||||
if (uri != null) {
|
||||
return uri
|
||||
}
|
||||
uri = if (bookUrl.isUri()) {
|
||||
Uri.parse(bookUrl)
|
||||
} else {
|
||||
Uri.fromFile(File(bookUrl))
|
||||
}
|
||||
//先检测uri是否有效,这个比较快
|
||||
uri.inputStream(appCtx).getOrNull()?.use {
|
||||
localUriCache[bookUrl] = uri
|
||||
}?.let {
|
||||
return uri
|
||||
}
|
||||
//不同的设备书籍保存路径可能不一样, uri无效时尝试寻找当前保存路径下的文件
|
||||
val defaultBookDir = AppConfig.defaultBookTreeUri
|
||||
val importBookDir = AppConfig.importBookPath
|
||||
|
||||
// 查找书籍保存目录
|
||||
if (!defaultBookDir.isNullOrBlank()) {
|
||||
val treeUri = Uri.parse(defaultBookDir)
|
||||
val treeFileDoc = FileDoc.fromUri(treeUri, true)
|
||||
if (!treeFileDoc.exists()) {
|
||||
appCtx.toastOnUi("书籍保存目录失效,请重新设置!")
|
||||
} else {
|
||||
val fileDoc = treeFileDoc.find(originName, 5, 100)
|
||||
if (fileDoc != null) {
|
||||
localUriCache[bookUrl] = fileDoc.uri
|
||||
//更新bookUrl 重启不用再找一遍
|
||||
bookUrl = fileDoc.toString()
|
||||
save()
|
||||
return fileDoc.uri
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 查找添加本地选择的目录
|
||||
if (!importBookDir.isNullOrBlank() && defaultBookDir != importBookDir) {
|
||||
val treeUri = if (importBookDir.isUri()) {
|
||||
Uri.parse(importBookDir)
|
||||
} else {
|
||||
Uri.fromFile(File(importBookDir))
|
||||
}
|
||||
val treeFileDoc = FileDoc.fromUri(treeUri, true)
|
||||
val fileDoc = treeFileDoc.find(originName, 5, 100)
|
||||
if (fileDoc != null) {
|
||||
localUriCache[bookUrl] = fileDoc.uri
|
||||
bookUrl = fileDoc.toString()
|
||||
save()
|
||||
return fileDoc.uri
|
||||
}
|
||||
}
|
||||
|
||||
localUriCache[bookUrl] = uri
|
||||
return uri
|
||||
}
|
||||
|
||||
|
||||
fun Book.getArchiveUri(): Uri? {
|
||||
val defaultBookDir = AppConfig.defaultBookTreeUri
|
||||
return if (isArchive && !defaultBookDir.isNullOrBlank()) {
|
||||
FileDoc.fromUri(Uri.parse(defaultBookDir), true)
|
||||
.find(archiveName)?.uri
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun Book.cacheLocalUri(uri: Uri) {
|
||||
localUriCache[bookUrl] = uri
|
||||
}
|
||||
|
||||
fun Book.removeLocalUriCache() {
|
||||
localUriCache.remove(bookUrl)
|
||||
}
|
||||
|
||||
fun Book.getRemoteUrl(): String? {
|
||||
if (origin.startsWith(BookType.webDavTag)) {
|
||||
return origin.substring(BookType.webDavTag.length)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun Book.setType(@BookType.Type vararg types: Int) {
|
||||
type = 0
|
||||
addType(*types)
|
||||
}
|
||||
|
||||
fun Book.addType(@BookType.Type vararg types: Int) {
|
||||
types.forEach {
|
||||
type = type or it
|
||||
}
|
||||
}
|
||||
|
||||
fun Book.removeType(@BookType.Type vararg types: Int) {
|
||||
types.forEach {
|
||||
type = type and it.inv()
|
||||
}
|
||||
}
|
||||
|
||||
fun Book.removeAllBookType() {
|
||||
removeType(BookType.allBookType)
|
||||
}
|
||||
|
||||
fun Book.clearType() {
|
||||
type = 0
|
||||
}
|
||||
|
||||
fun Book.isType(@BookType.Type bookType: Int): Boolean = type and bookType > 0
|
||||
|
||||
fun Book.upType() {
|
||||
if (type < 8) {
|
||||
type = when (type) {
|
||||
BookSourceType.image -> BookType.image
|
||||
BookSourceType.audio -> BookType.audio
|
||||
BookSourceType.file -> BookType.webFile
|
||||
else -> BookType.text
|
||||
}
|
||||
if (origin == BookType.localTag || origin.startsWith(BookType.webDavTag)) {
|
||||
type = type or BookType.local
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Book.sync(oldBook: Book) {
|
||||
val curBook = appDb.bookDao.getBook(oldBook.bookUrl)!!
|
||||
durChapterTime = curBook.durChapterTime
|
||||
durChapterPos = curBook.durChapterPos
|
||||
if (durChapterIndex != curBook.durChapterIndex) {
|
||||
durChapterIndex = curBook.durChapterIndex
|
||||
val replaceRules = ContentProcessor.get(this).getTitleReplaceRules()
|
||||
appDb.bookChapterDao.getChapter(bookUrl, durChapterIndex)?.let {
|
||||
durChapterTitle = it.getDisplayTitle(replaceRules, getUseReplaceRule())
|
||||
}
|
||||
}
|
||||
canUpdate = curBook.canUpdate
|
||||
readConfig = curBook.readConfig
|
||||
}
|
||||
|
||||
fun Book.update() {
|
||||
appDb.bookDao.update(this)
|
||||
}
|
||||
|
||||
fun Book.primaryStr(): String {
|
||||
return origin + bookUrl
|
||||
}
|
||||
|
||||
fun Book.updateTo(newBook: Book): Book {
|
||||
newBook.durChapterIndex = durChapterIndex
|
||||
newBook.durChapterTitle = durChapterTitle
|
||||
newBook.durChapterPos = durChapterPos
|
||||
newBook.durChapterTime = durChapterTime
|
||||
newBook.group = group
|
||||
newBook.order = order
|
||||
newBook.customCoverUrl = customCoverUrl
|
||||
newBook.customIntro = customIntro
|
||||
newBook.customTag = customTag
|
||||
newBook.canUpdate = canUpdate
|
||||
newBook.readConfig = readConfig
|
||||
val variableMap = variableMap.toMutableMap()
|
||||
variableMap.keys.removeIf {
|
||||
newBook.hasVariable(it)
|
||||
}
|
||||
newBook.variableMap.putAll(variableMap)
|
||||
newBook.variable = GSON.toJson(variableMap)
|
||||
return newBook
|
||||
}
|
||||
|
||||
fun Book.hasVariable(key: String): Boolean {
|
||||
return variableMap.contains(key) || RuleBigDataHelp.hasBookVariable(bookUrl, key)
|
||||
}
|
||||
|
||||
fun Book.getFolderNameNoCache(): String {
|
||||
return name.replace(AppPattern.fileNameRegex, "").let {
|
||||
it.substring(0, min(9, it.length)) + MD5Utils.md5Encode16(bookUrl)
|
||||
}
|
||||
}
|
||||
|
||||
fun Book.getBookSource(): BookSource? {
|
||||
return appDb.bookSourceDao.getBookSource(origin)
|
||||
}
|
||||
|
||||
fun Book.isLocalModified(): Boolean {
|
||||
return isLocal && LocalBook.getLastModified(this).getOrDefault(0L) > latestChapterTime
|
||||
}
|
||||
|
||||
fun Book.releaseHtmlData() {
|
||||
infoHtml = null
|
||||
tocHtml = null
|
||||
}
|
||||
|
||||
fun Book.isSameNameAuthor(other: Any?): Boolean {
|
||||
if (other is BaseBook) {
|
||||
return name == other.name && author == other.author
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun Book.getExportFileName(suffix: String): String {
|
||||
val jsStr = AppConfig.bookExportFileName
|
||||
if (jsStr.isNullOrBlank()) {
|
||||
return "$name 作者:${getRealAuthor()}.$suffix"
|
||||
}
|
||||
val bindings = buildScriptBindings { bindings ->
|
||||
bindings["epubIndex"] = ""// 兼容老版本,修复可能存在的错误
|
||||
bindings["name"] = name
|
||||
bindings["author"] = getRealAuthor()
|
||||
}
|
||||
return kotlin.runCatching {
|
||||
RhinoScriptEngine.eval(jsStr, bindings).toString() + "." + suffix
|
||||
}.onFailure {
|
||||
AppLog.put("导出书名规则错误,使用默认规则\n${it.localizedMessage}", it)
|
||||
}.getOrDefault("$name 作者:${getRealAuthor()}.$suffix")
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分割文件后的文件名
|
||||
*/
|
||||
fun Book.getExportFileName(
|
||||
suffix: String,
|
||||
epubIndex: Int,
|
||||
jsStr: String? = AppConfig.episodeExportFileName
|
||||
): String {
|
||||
// 默认规则
|
||||
val default = "$name 作者:${getRealAuthor()} [${epubIndex}].$suffix"
|
||||
if (jsStr.isNullOrBlank()) {
|
||||
return default
|
||||
}
|
||||
val bindings = buildScriptBindings { bindings ->
|
||||
bindings["name"] = name
|
||||
bindings["author"] = getRealAuthor()
|
||||
bindings["epubIndex"] = epubIndex
|
||||
}
|
||||
return kotlin.runCatching {
|
||||
RhinoScriptEngine.eval(jsStr, bindings).toString() + "." + suffix
|
||||
}.onFailure {
|
||||
AppLog.put("导出书名规则错误,使用默认规则\n${it.localizedMessage}", it)
|
||||
}.getOrDefault(default)
|
||||
}
|
||||
|
||||
// 根据当前日期计算章节总数
|
||||
fun Book.simulatedTotalChapterNum(): Int {
|
||||
return if (readSimulating()) {
|
||||
val currentDate = LocalDate.now()
|
||||
val daysPassed = between(config.startDate, currentDate).days + 1
|
||||
// 计算当前应该解锁到哪一章
|
||||
val chaptersToUnlock =
|
||||
max(0, (config.startChapter ?: 0) + (daysPassed * config.dailyChapters))
|
||||
min(totalChapterNum, chaptersToUnlock)
|
||||
} else {
|
||||
totalChapterNum
|
||||
}
|
||||
}
|
||||
|
||||
fun Book.readSimulating(): Boolean {
|
||||
return config.readSimulating
|
||||
}
|
||||
|
||||
fun tryParesExportFileName(jsStr: String): Boolean {
|
||||
val bindings = buildScriptBindings { bindings ->
|
||||
bindings["name"] = "name"
|
||||
bindings["author"] = "author"
|
||||
bindings["epubIndex"] = "epubIndex"
|
||||
}
|
||||
return runCatching {
|
||||
RhinoScriptEngine.eval(jsStr, bindings)
|
||||
true
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
@@ -0,0 +1,607 @@
|
||||
package io.legato.kazusa.help.book
|
||||
|
||||
import android.graphics.BitmapFactory
|
||||
import android.os.ParcelFileDescriptor
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import io.legato.kazusa.constant.AppLog
|
||||
import io.legato.kazusa.constant.AppPattern
|
||||
import io.legato.kazusa.constant.EventBus
|
||||
import io.legato.kazusa.data.appDb
|
||||
import io.legato.kazusa.data.entities.Book
|
||||
import io.legato.kazusa.data.entities.BookChapter
|
||||
import io.legato.kazusa.data.entities.BookSource
|
||||
import io.legato.kazusa.help.config.AppConfig
|
||||
import io.legato.kazusa.model.analyzeRule.AnalyzeUrl
|
||||
import io.legato.kazusa.model.localBook.LocalBook
|
||||
import io.legato.kazusa.utils.ArchiveUtils
|
||||
import io.legato.kazusa.utils.FileUtils
|
||||
import io.legato.kazusa.utils.ImageUtils
|
||||
import io.legato.kazusa.utils.MD5Utils
|
||||
import io.legato.kazusa.utils.NetworkUtils
|
||||
import io.legato.kazusa.utils.StringUtils
|
||||
import io.legato.kazusa.utils.SvgUtils
|
||||
import io.legato.kazusa.utils.UrlUtil
|
||||
import io.legato.kazusa.utils.createFileIfNotExist
|
||||
import io.legato.kazusa.utils.exists
|
||||
import io.legato.kazusa.utils.externalFiles
|
||||
import io.legato.kazusa.utils.getFile
|
||||
import io.legato.kazusa.utils.isContentScheme
|
||||
import io.legato.kazusa.utils.onEachParallel
|
||||
import io.legato.kazusa.utils.postEvent
|
||||
import com.script.rhino.runScriptWithContext
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.apache.commons.text.similarity.JaccardSimilarity
|
||||
import splitties.init.appCtx
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.regex.Pattern
|
||||
import java.util.zip.ZipFile
|
||||
import kotlin.coroutines.coroutineContext
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
@Suppress("unused", "ConstPropertyName")
|
||||
object BookHelp {
|
||||
private val downloadDir: File = appCtx.externalFiles
|
||||
private const val cacheFolderName = "book_cache"
|
||||
private const val cacheImageFolderName = "images"
|
||||
private const val cacheEpubFolderName = "epub"
|
||||
private val downloadImages = ConcurrentHashMap<String, Mutex>()
|
||||
|
||||
val cachePath = FileUtils.getPath(downloadDir, cacheFolderName)
|
||||
|
||||
fun clearCache() {
|
||||
FileUtils.delete(
|
||||
FileUtils.getPath(downloadDir, cacheFolderName)
|
||||
)
|
||||
}
|
||||
|
||||
fun clearCache(book: Book) {
|
||||
val filePath = FileUtils.getPath(downloadDir, cacheFolderName, book.getFolderName())
|
||||
FileUtils.delete(filePath)
|
||||
}
|
||||
|
||||
fun updateCacheFolder(oldBook: Book, newBook: Book) {
|
||||
val oldFolderName = oldBook.getFolderNameNoCache()
|
||||
val newFolderName = newBook.getFolderNameNoCache()
|
||||
if (oldFolderName == newFolderName) return
|
||||
val oldFolderPath = FileUtils.getPath(
|
||||
downloadDir,
|
||||
cacheFolderName,
|
||||
oldFolderName
|
||||
)
|
||||
val newFolderPath = FileUtils.getPath(
|
||||
downloadDir,
|
||||
cacheFolderName,
|
||||
newFolderName
|
||||
)
|
||||
FileUtils.move(oldFolderPath, newFolderPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除已删除书的缓存 解压缓存
|
||||
*/
|
||||
suspend fun clearInvalidCache() {
|
||||
withContext(IO) {
|
||||
val bookFolderNames = hashSetOf<String>()
|
||||
val originNames = hashSetOf<String>()
|
||||
appDb.bookDao.all.forEach {
|
||||
clearComicCache(it)
|
||||
bookFolderNames.add(it.getFolderName())
|
||||
if (it.isEpub) originNames.add(it.originName)
|
||||
}
|
||||
downloadDir.getFile(cacheFolderName)
|
||||
.listFiles()?.forEach { bookFile ->
|
||||
if (!bookFolderNames.contains(bookFile.name)) {
|
||||
FileUtils.delete(bookFile.absolutePath)
|
||||
}
|
||||
}
|
||||
downloadDir.getFile(cacheEpubFolderName)
|
||||
.listFiles()?.forEach { epubFile ->
|
||||
if (!originNames.contains(epubFile.name)) {
|
||||
FileUtils.delete(epubFile.absolutePath)
|
||||
}
|
||||
}
|
||||
FileUtils.delete(ArchiveUtils.TEMP_PATH)
|
||||
val filesDir = appCtx.filesDir
|
||||
FileUtils.delete("$filesDir/shareBookSource.json")
|
||||
FileUtils.delete("$filesDir/shareRssSource.json")
|
||||
FileUtils.delete("$filesDir/books.json")
|
||||
}
|
||||
}
|
||||
|
||||
//清除已经看过的漫画数据
|
||||
private fun clearComicCache(book: Book) {
|
||||
//只处理漫画
|
||||
//为0的时候,不清除已缓存数据
|
||||
if (!book.isImage || AppConfig.imageRetainNum == 0) {
|
||||
return
|
||||
}
|
||||
//向前保留设定数量,向后保留预下载数量
|
||||
val startIndex = book.durChapterIndex - AppConfig.imageRetainNum
|
||||
val endIndex = book.durChapterIndex + AppConfig.preDownloadNum
|
||||
val chapterList = appDb.bookChapterDao.getChapterList(book.bookUrl, startIndex, endIndex)
|
||||
val imgNames = hashSetOf<String>()
|
||||
//获取需要保留章节的图片信息
|
||||
chapterList.forEach {
|
||||
val content = getContent(book, it)
|
||||
if (content != null) {
|
||||
val matcher = AppPattern.imgPattern.matcher(content)
|
||||
while (matcher.find()) {
|
||||
val src = matcher.group(1) ?: continue
|
||||
val mSrc = NetworkUtils.getAbsoluteURL(it.url, src)
|
||||
imgNames.add("${MD5Utils.md5Encode16(mSrc)}.${getImageSuffix(mSrc)}")
|
||||
}
|
||||
}
|
||||
}
|
||||
downloadDir.getFile(
|
||||
cacheFolderName,
|
||||
book.getFolderName(),
|
||||
cacheImageFolderName
|
||||
).listFiles()?.forEach { imgFile ->
|
||||
if (!imgNames.contains(imgFile.name)) {
|
||||
imgFile.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveContent(
|
||||
bookSource: BookSource,
|
||||
book: Book,
|
||||
bookChapter: BookChapter,
|
||||
content: String
|
||||
) {
|
||||
try {
|
||||
saveText(book, bookChapter, content)
|
||||
//saveImages(bookSource, book, bookChapter, content)
|
||||
postEvent(EventBus.SAVE_CONTENT, Pair(book, bookChapter))
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
AppLog.put("保存正文失败 ${book.name} ${bookChapter.title}", e)
|
||||
}
|
||||
}
|
||||
|
||||
fun saveText(
|
||||
book: Book,
|
||||
bookChapter: BookChapter,
|
||||
content: String
|
||||
) {
|
||||
if (content.isEmpty()) return
|
||||
//保存文本
|
||||
FileUtils.createFileIfNotExist(
|
||||
downloadDir,
|
||||
cacheFolderName,
|
||||
book.getFolderName(),
|
||||
bookChapter.getFileName(),
|
||||
).writeText(content)
|
||||
if (book.isOnLineTxt && AppConfig.tocCountWords) {
|
||||
val wordCount = StringUtils.wordCountFormat(content.length)
|
||||
bookChapter.wordCount = wordCount
|
||||
appDb.bookChapterDao.upWordCount(bookChapter.bookUrl, bookChapter.url, wordCount)
|
||||
}
|
||||
}
|
||||
|
||||
fun flowImages(bookChapter: BookChapter, content: String): Flow<String> {
|
||||
return flow {
|
||||
val matcher = AppPattern.imgPattern.matcher(content)
|
||||
while (matcher.find()) {
|
||||
val src = matcher.group(1) ?: continue
|
||||
val mSrc = NetworkUtils.getAbsoluteURL(bookChapter.url, src)
|
||||
emit(mSrc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveImages(
|
||||
bookSource: BookSource,
|
||||
book: Book,
|
||||
bookChapter: BookChapter,
|
||||
content: String,
|
||||
concurrency: Int = AppConfig.threadCount
|
||||
) = coroutineScope {
|
||||
flowImages(bookChapter, content).onEachParallel(concurrency) { mSrc ->
|
||||
saveImage(bookSource, book, mSrc, bookChapter)
|
||||
}.collect()
|
||||
}
|
||||
|
||||
suspend fun saveImage(
|
||||
bookSource: BookSource?,
|
||||
book: Book,
|
||||
src: String,
|
||||
chapter: BookChapter? = null
|
||||
) {
|
||||
if (isImageExist(book, src)) {
|
||||
return
|
||||
}
|
||||
val mutex = synchronized(this) {
|
||||
downloadImages.getOrPut(src) { Mutex() }
|
||||
}
|
||||
mutex.lock()
|
||||
try {
|
||||
if (isImageExist(book, src)) {
|
||||
return
|
||||
}
|
||||
val analyzeUrl = AnalyzeUrl(
|
||||
src, source = bookSource, coroutineContext = coroutineContext
|
||||
)
|
||||
val bytes = analyzeUrl.getByteArrayAwait()
|
||||
//某些图片被加密,需要进一步解密
|
||||
runScriptWithContext {
|
||||
ImageUtils.decode(
|
||||
src, bytes, isCover = false, bookSource, book
|
||||
)
|
||||
}?.let {
|
||||
if (!checkImage(it)) {
|
||||
// 如果部分图片失效,每次进入正文都会花很长时间再次获取图片数据
|
||||
// 所以无论如何都要将数据写入到文件里
|
||||
// throw NoStackTraceException("数据异常")
|
||||
AppLog.put("${book.name} ${chapter?.title} 图片 $src 下载错误 数据异常")
|
||||
}
|
||||
writeImage(book, src, it)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
coroutineContext.ensureActive()
|
||||
val msg = "${book.name} ${chapter?.title} 图片 $src 下载失败\n${e.localizedMessage}"
|
||||
AppLog.put(msg, e)
|
||||
} finally {
|
||||
downloadImages.remove(src)
|
||||
mutex.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
fun getImage(book: Book, src: String): File {
|
||||
return downloadDir.getFile(
|
||||
cacheFolderName,
|
||||
book.getFolderName(),
|
||||
cacheImageFolderName,
|
||||
"${MD5Utils.md5Encode16(src)}.${getImageSuffix(src)}"
|
||||
)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun writeImage(book: Book, src: String, bytes: ByteArray) {
|
||||
getImage(book, src).createFileIfNotExist().writeBytes(bytes)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun isImageExist(book: Book, src: String): Boolean {
|
||||
return getImage(book, src).exists()
|
||||
}
|
||||
|
||||
fun getImageSuffix(src: String): String {
|
||||
return UrlUtil.getSuffix(src, "jpg")
|
||||
}
|
||||
|
||||
@Throws(IOException::class, FileNotFoundException::class)
|
||||
fun getEpubFile(book: Book): ZipFile {
|
||||
val uri = book.getLocalUri()
|
||||
if (uri.isContentScheme()) {
|
||||
FileUtils.createFolderIfNotExist(downloadDir, cacheEpubFolderName)
|
||||
val path = FileUtils.getPath(downloadDir, cacheEpubFolderName, book.originName)
|
||||
val file = File(path)
|
||||
val doc = DocumentFile.fromSingleUri(appCtx, uri)
|
||||
?: throw IOException("文件不存在")
|
||||
if (!file.exists() || doc.lastModified() > book.latestChapterTime) {
|
||||
LocalBook.getBookInputStream(book).use { inputStream ->
|
||||
FileOutputStream(file).use { outputStream ->
|
||||
inputStream.copyTo(outputStream)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ZipFile(file)
|
||||
}
|
||||
return ZipFile(uri.path)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取本地书籍文件的ParcelFileDescriptor
|
||||
*
|
||||
* @param book
|
||||
* @return
|
||||
*/
|
||||
@Throws(IOException::class, FileNotFoundException::class)
|
||||
fun getBookPFD(book: Book): ParcelFileDescriptor? {
|
||||
val uri = book.getLocalUri()
|
||||
return if (uri.isContentScheme()) {
|
||||
appCtx.contentResolver.openFileDescriptor(uri, "r")
|
||||
} else {
|
||||
ParcelFileDescriptor.open(File(uri.path!!), ParcelFileDescriptor.MODE_READ_ONLY)
|
||||
}
|
||||
}
|
||||
|
||||
fun getChapterFiles(book: Book): HashSet<String> {
|
||||
val fileNames = hashSetOf<String>()
|
||||
if (book.isLocalTxt) {
|
||||
return fileNames
|
||||
}
|
||||
FileUtils.createFolderIfNotExist(
|
||||
downloadDir,
|
||||
subDirs = arrayOf(cacheFolderName, book.getFolderName())
|
||||
).list()?.let {
|
||||
fileNames.addAll(it)
|
||||
}
|
||||
return fileNames
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测该章节是否下载
|
||||
*/
|
||||
fun hasContent(book: Book, bookChapter: BookChapter): Boolean {
|
||||
return if (book.isLocalTxt ||
|
||||
(bookChapter.isVolume && bookChapter.url.startsWith(bookChapter.title))
|
||||
) {
|
||||
true
|
||||
} else {
|
||||
downloadDir.exists(
|
||||
cacheFolderName,
|
||||
book.getFolderName(),
|
||||
bookChapter.getFileName()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测图片是否下载
|
||||
*/
|
||||
fun hasImageContent(book: Book, bookChapter: BookChapter): Boolean {
|
||||
if (!hasContent(book, bookChapter)) {
|
||||
return false
|
||||
}
|
||||
var ret = true
|
||||
val op = BitmapFactory.Options()
|
||||
op.inJustDecodeBounds = true
|
||||
getContent(book, bookChapter)?.let {
|
||||
val matcher = AppPattern.imgPattern.matcher(it)
|
||||
while (matcher.find()) {
|
||||
val src = matcher.group(1)!!
|
||||
val image = getImage(book, src)
|
||||
if (!image.exists()) {
|
||||
ret = false
|
||||
continue
|
||||
}
|
||||
BitmapFactory.decodeFile(image.absolutePath, op)
|
||||
if (op.outWidth < 1 && op.outHeight < 1) {
|
||||
if (SvgUtils.getSize(image.absolutePath) != null) {
|
||||
continue
|
||||
}
|
||||
ret = false
|
||||
image.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
private fun checkImage(bytes: ByteArray): Boolean {
|
||||
val op = BitmapFactory.Options()
|
||||
op.inJustDecodeBounds = true
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, op)
|
||||
if (op.outWidth < 1 && op.outHeight < 1) {
|
||||
return SvgUtils.getSize(ByteArrayInputStream(bytes)) != null
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取章节内容
|
||||
*/
|
||||
fun getContent(book: Book, bookChapter: BookChapter): String? {
|
||||
val file = downloadDir.getFile(
|
||||
cacheFolderName,
|
||||
book.getFolderName(),
|
||||
bookChapter.getFileName()
|
||||
)
|
||||
if (file.exists()) {
|
||||
return file.readText()
|
||||
}
|
||||
if (book.isLocal) {
|
||||
val string = LocalBook.getContent(book, bookChapter)
|
||||
if (string != null && book.isEpub) {
|
||||
saveText(book, bookChapter, string)
|
||||
}
|
||||
return string
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除章节内容
|
||||
*/
|
||||
fun delContent(book: Book, bookChapter: BookChapter) {
|
||||
FileUtils.createFileIfNotExist(
|
||||
downloadDir,
|
||||
cacheFolderName,
|
||||
book.getFolderName(),
|
||||
bookChapter.getFileName()
|
||||
).delete()
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置是否禁用正文的去除重复标题,针对单个章节
|
||||
*/
|
||||
fun setRemoveSameTitle(book: Book, bookChapter: BookChapter, removeSameTitle: Boolean) {
|
||||
val fileName = bookChapter.getFileName("nr")
|
||||
val contentProcessor = ContentProcessor.get(book)
|
||||
if (removeSameTitle) {
|
||||
val path = FileUtils.getPath(
|
||||
downloadDir,
|
||||
cacheFolderName,
|
||||
book.getFolderName(),
|
||||
fileName
|
||||
)
|
||||
contentProcessor.removeSameTitleCache.remove(fileName)
|
||||
File(path).delete()
|
||||
} else {
|
||||
FileUtils.createFileIfNotExist(
|
||||
downloadDir,
|
||||
cacheFolderName,
|
||||
book.getFolderName(),
|
||||
fileName
|
||||
)
|
||||
contentProcessor.removeSameTitleCache.add(fileName)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取是否去除重复标题
|
||||
*/
|
||||
fun removeSameTitle(book: Book, bookChapter: BookChapter): Boolean {
|
||||
val path = FileUtils.getPath(
|
||||
downloadDir,
|
||||
cacheFolderName,
|
||||
book.getFolderName(),
|
||||
bookChapter.getFileName("nr")
|
||||
)
|
||||
return !File(path).exists()
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化书名
|
||||
*/
|
||||
fun formatBookName(name: String): String {
|
||||
return name
|
||||
.replace(AppPattern.nameRegex, "")
|
||||
.trim { it <= ' ' }
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化作者
|
||||
*/
|
||||
fun formatBookAuthor(author: String): String {
|
||||
return author
|
||||
.replace(AppPattern.authorRegex, "")
|
||||
.trim { it <= ' ' }
|
||||
}
|
||||
|
||||
private val jaccardSimilarity by lazy {
|
||||
JaccardSimilarity()
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据目录名获取当前章节
|
||||
*/
|
||||
fun getDurChapter(
|
||||
oldDurChapterIndex: Int,
|
||||
oldDurChapterName: String?,
|
||||
newChapterList: List<BookChapter>,
|
||||
oldChapterListSize: Int = 0
|
||||
): Int {
|
||||
if (oldDurChapterIndex <= 0) return 0
|
||||
if (newChapterList.isEmpty()) return oldDurChapterIndex
|
||||
val oldChapterNum = getChapterNum(oldDurChapterName)
|
||||
val oldName = getPureChapterName(oldDurChapterName)
|
||||
val newChapterSize = newChapterList.size
|
||||
val durIndex =
|
||||
if (oldChapterListSize == 0) oldDurChapterIndex
|
||||
else oldDurChapterIndex * oldChapterListSize / newChapterSize
|
||||
val min = max(0, min(oldDurChapterIndex, durIndex) - 10)
|
||||
val max = min(newChapterSize - 1, max(oldDurChapterIndex, durIndex) + 10)
|
||||
var nameSim = 0.0
|
||||
var newIndex = 0
|
||||
var newNum = 0
|
||||
if (oldName.isNotEmpty()) {
|
||||
for (i in min..max) {
|
||||
val newName = getPureChapterName(newChapterList[i].title)
|
||||
val temp = jaccardSimilarity.apply(oldName, newName)
|
||||
if (temp > nameSim) {
|
||||
nameSim = temp
|
||||
newIndex = i
|
||||
}
|
||||
}
|
||||
}
|
||||
if (nameSim < 0.96 && oldChapterNum > 0) {
|
||||
for (i in min..max) {
|
||||
val temp = getChapterNum(newChapterList[i].title)
|
||||
if (temp == oldChapterNum) {
|
||||
newNum = temp
|
||||
newIndex = i
|
||||
break
|
||||
} else if (abs(temp - oldChapterNum) < abs(newNum - oldChapterNum)) {
|
||||
newNum = temp
|
||||
newIndex = i
|
||||
}
|
||||
}
|
||||
}
|
||||
return if (nameSim > 0.96 || abs(newNum - oldChapterNum) < 1) {
|
||||
newIndex
|
||||
} else {
|
||||
min(max(0, newChapterList.size - 1), oldDurChapterIndex)
|
||||
}
|
||||
}
|
||||
|
||||
fun getDurChapter(
|
||||
oldBook: Book,
|
||||
newChapterList: List<BookChapter>
|
||||
): Int {
|
||||
return oldBook.run {
|
||||
getDurChapter(durChapterIndex, durChapterTitle, newChapterList, totalChapterNum)
|
||||
}
|
||||
}
|
||||
|
||||
private val chapterNamePattern1 by lazy {
|
||||
Pattern.compile(
|
||||
".*?第([\\d零〇一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+)[章节篇回集话]"
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("RegExpSimplifiable")
|
||||
private val chapterNamePattern2 by lazy {
|
||||
Pattern.compile(
|
||||
"^(?:[\\d零〇一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+[,:、])*([\\d零〇一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+)(?:[,:、]|\\.[^\\d])"
|
||||
)
|
||||
}
|
||||
|
||||
private val regexA by lazy {
|
||||
return@lazy "\\s".toRegex()
|
||||
}
|
||||
|
||||
private fun getChapterNum(chapterName: String?): Int {
|
||||
chapterName ?: return -1
|
||||
val chapterName1 = StringUtils.fullToHalf(chapterName).replace(regexA, "")
|
||||
return StringUtils.stringToInt(
|
||||
(
|
||||
chapterNamePattern1.matcher(chapterName1).takeIf { it.find() }
|
||||
?: chapterNamePattern2.matcher(chapterName1).takeIf { it.find() }
|
||||
)?.group(1)
|
||||
?: "-1"
|
||||
)
|
||||
}
|
||||
|
||||
private val regexOther by lazy {
|
||||
// 所有非字母数字中日韩文字 CJK区+扩展A-F区
|
||||
@Suppress("RegExpDuplicateCharacterInClass")
|
||||
return@lazy "[^\\w\\u4E00-\\u9FEF〇\\u3400-\\u4DBF\\u20000-\\u2A6DF\\u2A700-\\u2EBEF]".toRegex()
|
||||
}
|
||||
|
||||
@Suppress("RegExpUnnecessaryNonCapturingGroup", "RegExpSimplifiable")
|
||||
private val regexB by lazy {
|
||||
//章节序号,排除处于结尾的状况,避免将章节名替换为空字串
|
||||
return@lazy "^.*?第(?:[\\d零〇一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+)[章节篇回集话](?!$)|^(?:[\\d零〇一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+[,:、])*(?:[\\d零〇一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+)(?:[,:、](?!$)|\\.(?=[^\\d]))".toRegex()
|
||||
}
|
||||
|
||||
private val regexC by lazy {
|
||||
//前后附加内容,整个章节名都在括号中时只剔除首尾括号,避免将章节名替换为空字串
|
||||
return@lazy "(?!^)(?:[〖【《〔\\[{(][^〖【《〔\\[{()〕》】〗\\]}]+)?[)〕》】〗\\]}]$|^[〖【《〔\\[{(](?:[^〖【《〔\\[{()〕》】〗\\]}]+[〕》】〗\\]})])?(?!$)".toRegex()
|
||||
}
|
||||
|
||||
private fun getPureChapterName(chapterName: String?): String {
|
||||
return if (chapterName == null) "" else StringUtils.fullToHalf(chapterName)
|
||||
.replace(regexA, "")
|
||||
.replace(regexB, "")
|
||||
.replace(regexC, "")
|
||||
.replace(regexOther, "")
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,620 @@
|
||||
package io.legato.kazusa.help.book
|
||||
|
||||
import java.util.regex.Pattern
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
@Suppress("SameParameterValue", "RegExpRedundantEscape")
|
||||
object ContentHelp {
|
||||
|
||||
/**
|
||||
* 段落重排算法入口。把整篇内容输入,连接错误的分段,再把每个段落调用其他方法重新切分
|
||||
*
|
||||
* @param content 正文
|
||||
* @param chapterName 标题
|
||||
* @return
|
||||
*/
|
||||
fun reSegment(content: String, chapterName: String): String {
|
||||
var content1 = content
|
||||
val dict = makeDict(content1)
|
||||
var p = content1
|
||||
.replace(""".toRegex(), "“")
|
||||
.replace("[::]['\"‘”“]+".toRegex(), ":“")
|
||||
.replace("[\"”“]+[\\s]*[\"”“][\\s\"”“]*".toRegex(), "”\n“")
|
||||
.split("\n(\\s*)".toRegex()).toTypedArray()
|
||||
|
||||
//初始化StringBuilder的长度,在原content的长度基础上做冗余
|
||||
var buffer = StringBuilder((content1.length * 1.15).toInt())
|
||||
// 章节的文本格式为章节标题-空行-首段,所以处理段落时需要略过第一行文本。
|
||||
buffer.append(" ")
|
||||
if (chapterName.trim { it <= ' ' } != p[0].trim { it <= ' ' }) {
|
||||
// 去除段落内空格。unicode 3000 象形字间隔(中日韩符号和标点),不包含在\s内
|
||||
buffer.append(p[0].replace("[\u3000\\s]+".toRegex(), ""))
|
||||
}
|
||||
|
||||
//如果原文存在分段错误,需要把段落重新黏合
|
||||
for (i in 1 until p.size) {
|
||||
if (match(MARK_SENTENCES_END, buffer[buffer.length - 1])) buffer.append("\n")
|
||||
// 段落开头以外的地方不应该有空格
|
||||
// 去除段落内空格。unicode 3000 象形字间隔(中日韩符号和标点),不包含在\s内
|
||||
buffer.append(p[i].replace("[\u3000\\s]".toRegex(), ""))
|
||||
}
|
||||
// 预分段预处理
|
||||
// ”“处理为”\n“。
|
||||
// ”。“处理为”。\n“。不考虑“?” “!”的情况。
|
||||
// ”。xxx处理为 ”。\n xxx
|
||||
p = buffer.toString()
|
||||
.replace("[\"”“]+[\\s]*[\"”“]+".toRegex(), "”\n“")
|
||||
.replace("[\"”“]+(?。!?!~)[\"”“]+".toRegex(), "”$1\n“")
|
||||
.replace("[\"”“]+(?。!?!~)([^\"”“])".toRegex(), "”$1\n$2")
|
||||
.replace(
|
||||
"([问说喊唱叫骂道着答])[\\.。]".toRegex(),
|
||||
"$1。\n"
|
||||
)
|
||||
.split("\n".toRegex()).toTypedArray()
|
||||
buffer = StringBuilder((content1.length * 1.15).toInt())
|
||||
for (s in p) {
|
||||
buffer.append("\n")
|
||||
buffer.append(findNewLines(s, dict))
|
||||
}
|
||||
buffer = reduceLength(buffer)
|
||||
content1 = (buffer.toString() // 处理章节头部空格和换行
|
||||
.replaceFirst("^\\s+".toRegex(), "")
|
||||
.replace("\\s*[\"”“]+[\\s]*[\"”“][\\s\"”“]*".toRegex(), "”\n“")
|
||||
.replace("[::][”“\"\\s]+".toRegex(), ":“")
|
||||
.replace("\n[\"“”]([^\n\"“”]+)([,:,:][\"”“])([^\n\"“”]+)".toRegex(), "\n$1:“$3")
|
||||
.replace("\n(\\s*)".toRegex(), "\n"))
|
||||
return content1
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制切分,减少段落内的句子
|
||||
* 如果连续2对引号的段落没有提示语,进入对话模式。最后一对引号后强制切分段落
|
||||
* 如果引号内的内容长于5句,可能引号状态有误,随机分段
|
||||
* 如果引号外的内容长于3句,随机分段
|
||||
*
|
||||
* @param str
|
||||
* @return
|
||||
*/
|
||||
private fun reduceLength(str: StringBuilder): StringBuilder {
|
||||
val p = str.toString().split("\n".toRegex()).toTypedArray()
|
||||
val l = p.size
|
||||
val b = BooleanArray(l)
|
||||
for (i in 0 until l) {
|
||||
b[i] = p[i].matches(PARAGRAPH_DIAGLOG)
|
||||
}
|
||||
var dialogue = 0
|
||||
for (i in 0 until l) {
|
||||
if (b[i]) {
|
||||
if (dialogue < 0) dialogue = 1 else if (dialogue < 2) dialogue++
|
||||
} else {
|
||||
if (dialogue > 1) {
|
||||
p[i] = splitQuote(p[i])
|
||||
dialogue--
|
||||
} else if (dialogue > 0 && i < l - 2) {
|
||||
if (b[i + 1]) p[i] = splitQuote(p[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
val string = StringBuilder()
|
||||
for (i in 0 until l) {
|
||||
string.append('\n')
|
||||
string.append(p[i])
|
||||
//System.out.print(" "+b[i]);
|
||||
}
|
||||
//System.out.println(" " + str);
|
||||
return string
|
||||
}
|
||||
|
||||
// 强制切分进入对话模式后,未构成 “xxx” 形式的段落
|
||||
private fun splitQuote(str: String): String {
|
||||
val length = str.length
|
||||
if (length < 3) return str
|
||||
if (match(MARK_QUOTATION, str[0])) {
|
||||
val i = seekIndex(str, MARK_QUOTATION, 1, length - 2, true) + 1
|
||||
if (i > 1) if (!match(MARK_QUOTATION_BEFORE, str[i - 1])) {
|
||||
return "${str.substring(0, i)}\n${str.substring(i)}"
|
||||
}
|
||||
} else if (match(MARK_QUOTATION, str[length - 1])) {
|
||||
val i = length - 1 - seekIndex(str, MARK_QUOTATION, 1, length - 2, false)
|
||||
if (i > 1) {
|
||||
if (!match(MARK_QUOTATION_BEFORE, str[i - 1])) {
|
||||
return "${str.substring(0, i)}\n${str.substring(i)}"
|
||||
}
|
||||
}
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算随机插入换行符的位置。
|
||||
* @param str 字符串
|
||||
* @param offset 传回的结果需要叠加的偏移量
|
||||
* @param min 最低几个句子,随机插入换行
|
||||
* @param gain 倍率。每个句子插入换行的数学期望 = 1 / gain , gain越大越不容易插入换行
|
||||
* @return
|
||||
*/
|
||||
private fun forceSplit(
|
||||
str: String,
|
||||
offset: Int,
|
||||
min: Int,
|
||||
gain: Int,
|
||||
tigger: Int
|
||||
): ArrayList<Int> {
|
||||
val result = ArrayList<Int>()
|
||||
val arrayEnd = seekIndexs(str, MARK_SENTENCES_END_P, 0, str.length - 2, true)
|
||||
val arrayMid = seekIndexs(str, MARK_SENTENCES_MID, 0, str.length - 2, true)
|
||||
if (arrayEnd.size < tigger && arrayMid.size < tigger * 3) return result
|
||||
var j = 0
|
||||
var i = min
|
||||
while (i < arrayEnd.size) {
|
||||
var k = 0
|
||||
while (j < arrayMid.size) {
|
||||
if (arrayMid[j] < arrayEnd[i]) k++
|
||||
j++
|
||||
}
|
||||
if (Math.random() * gain < 0.8 + k / 2.5) {
|
||||
result.add(arrayEnd[i] + offset)
|
||||
i = max(i + min, i)
|
||||
}
|
||||
i++
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// 对内容重新划分段落.输入参数str已经使用换行符预分割
|
||||
private fun findNewLines(str: String, dict: List<String>): String {
|
||||
val string = StringBuilder(str)
|
||||
// 标记string中每个引号的位置.特别的,用引号进行列举时视为只有一对引号。 如:“锅”、“碗”视为“锅、碗”,从而避免误断句。
|
||||
val arrayQuote: MutableList<Int> = ArrayList()
|
||||
// 标记插入换行符的位置,int为插入位置(str的char下标)
|
||||
var insN = ArrayList<Int>()
|
||||
|
||||
//mod[i]标记str的每一段处于引号内还是引号外。范围: str.substring( array_quote.get(i), array_quote.get(i+1) )的状态。
|
||||
//长度:array_quote.size(),但是初始化时未预估占用的长度,用空间换时间
|
||||
//0未知,正数引号内,负数引号外。
|
||||
//如果相邻的两个标记都为+1,那么需要增加1个引号。
|
||||
//引号内不进行断句
|
||||
val mod = IntArray(str.length)
|
||||
var waitClose = false
|
||||
for (i in str.indices) {
|
||||
val c = str[i]
|
||||
if (match(MARK_QUOTATION, c)) {
|
||||
val size = arrayQuote.size
|
||||
|
||||
// 把“xxx”、“yy”合并为“xxx_yy”进行处理
|
||||
if (size > 0) {
|
||||
val quotePre = arrayQuote[size - 1]
|
||||
if (i - quotePre == 2) {
|
||||
var remove = false
|
||||
if (waitClose) {
|
||||
if (match(",,、/", str[i - 1])) {
|
||||
// 考虑出现“和”这种特殊情况
|
||||
remove = true
|
||||
}
|
||||
} else if (match(",,、/和与或", str[i - 1])) {
|
||||
remove = true
|
||||
}
|
||||
if (remove) {
|
||||
string.setCharAt(i, '“')
|
||||
string.setCharAt(i - 2, '”')
|
||||
arrayQuote.removeAt(size - 1)
|
||||
mod[size - 1] = 1
|
||||
mod[size] = -1
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
arrayQuote.add(i)
|
||||
|
||||
// 为xxx:“xxx”做标记
|
||||
if (i > 1) {
|
||||
// 当前发言的正引号的前一个字符
|
||||
val charB1 = str[i - 1]
|
||||
// 上次发言的正引号的前一个字符
|
||||
var charB2 = 0.toChar()
|
||||
if (match(MARK_QUOTATION_BEFORE, charB1)) {
|
||||
// 如果不是第一处引号,寻找上一处断句,进行分段
|
||||
if (arrayQuote.size > 1) {
|
||||
val lastQuote = arrayQuote[arrayQuote.size - 2]
|
||||
var p = 0
|
||||
if (charB1 == ',' || charB1 == ',') {
|
||||
if (arrayQuote.size > 2) {
|
||||
p = arrayQuote[arrayQuote.size - 3]
|
||||
if (p > 0) {
|
||||
charB2 = str[p - 1]
|
||||
}
|
||||
}
|
||||
}
|
||||
//if(char_b2=='.' || char_b2=='。')
|
||||
if (match(MARK_SENTENCES_END_P, charB2)) {
|
||||
insN.add(p - 1)
|
||||
} else if (!match("的", charB2)) {
|
||||
val lastEnd = seekLast(str, MARK_SENTENCES_END, i, lastQuote)
|
||||
if (lastEnd > 0) insN.add(lastEnd) else insN.add(lastQuote)
|
||||
}
|
||||
}
|
||||
waitClose = true
|
||||
mod[size] = 1
|
||||
if (size > 0) {
|
||||
mod[size - 1] = -1
|
||||
if (size > 1) {
|
||||
mod[size - 2] = 1
|
||||
}
|
||||
}
|
||||
} else if (waitClose) {
|
||||
run {
|
||||
waitClose = false
|
||||
insN.add(i)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val size = arrayQuote.size
|
||||
|
||||
|
||||
//标记循环状态,此位置前的引号是否已经配对
|
||||
var opend = false
|
||||
if (size > 0) {
|
||||
//第1次遍历array_quote,令其元素的值不为0
|
||||
for (i in 0 until size) {
|
||||
if (mod[i] > 0) {
|
||||
opend = true
|
||||
} else if (mod[i] < 0) {
|
||||
//连续2个反引号表明存在冲突,强制把前一个设为正引号
|
||||
if (!opend) {
|
||||
if (i > 0) mod[i] = 3
|
||||
}
|
||||
opend = false
|
||||
} else {
|
||||
opend = !opend
|
||||
if (opend) mod[i] = 2 else mod[i] = -2
|
||||
}
|
||||
}
|
||||
// 修正,断尾必须封闭引号
|
||||
if (opend) {
|
||||
if (arrayQuote[size - 1] - string.length > -3) {
|
||||
//if((match(MARK_QUOTATION,string.charAt(string.length()-1)) || match(MARK_QUOTATION,string.charAt(string.length()-2)))){
|
||||
if (size > 1) mod[size - 2] = 4
|
||||
// 0<=i<size,故无需判断size>=1
|
||||
mod[size - 1] = -4
|
||||
} else if (!match(MARK_SENTENCES_SAY, string[string.length - 2])) string.append(
|
||||
"”"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
//第2次循环,mod[i]由负变正时,前1字符如果是句末,需要插入换行
|
||||
var loop2Mod1 = -1 //上一个引号跟随内容的状态
|
||||
var loop2Mod2: Int //当前引号跟随内容的状态
|
||||
var i = 0
|
||||
var j = arrayQuote[0] - 1 //当前引号前一字符的序号
|
||||
if (j < 0) {
|
||||
i = 1
|
||||
loop2Mod1 = 0
|
||||
}
|
||||
while (i < size) {
|
||||
j = arrayQuote[i] - 1
|
||||
loop2Mod2 = mod[i]
|
||||
if (loop2Mod1 < 0 && loop2Mod2 > 0) {
|
||||
if (match(MARK_SENTENCES_END, string[j])) insN.add(j)
|
||||
}
|
||||
loop2Mod1 = loop2Mod2
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
//第3次循环,匹配并插入换行。
|
||||
//"xxxx" xxxx。\n xxx“xxxx”
|
||||
//未实现
|
||||
|
||||
// 使用字典验证ins_n , 避免插入不必要的换行。
|
||||
// 由于目前没有插入、的列表,无法解决 “xx”、“xx”“xx” 被插入换行的问题
|
||||
val insN1 = ArrayList<Int>()
|
||||
for (i in insN) {
|
||||
if (match("\"'”“", string[i])) {
|
||||
val start: Int = seekLast(
|
||||
str,
|
||||
"\"'”“",
|
||||
i - 1,
|
||||
i - WORD_MAX_LENGTH
|
||||
)
|
||||
if (start > 0) {
|
||||
val word = str.substring(start + 1, i)
|
||||
if (dict.contains(word)) {
|
||||
//System.out.println("使用字典验证 跳过\tins_n=" + i + " word=" + word);
|
||||
//引号内如果是字典词条,后方不插入换行符(前方不需要优化)
|
||||
continue
|
||||
} else {
|
||||
//System.out.println("使用字典验证 插入\tins_n=" + i + " word=" + word);
|
||||
if (match("的地得", str[start])) {
|
||||
//xx的“xx”,后方不插入换行符(前方不需要优化)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
insN1.add(i)
|
||||
}
|
||||
insN = insN1
|
||||
|
||||
// 随机在句末插入换行符
|
||||
insN = ArrayList(HashSet(insN))
|
||||
insN.sort()
|
||||
run {
|
||||
var subs: String
|
||||
var j = 0
|
||||
var progress = 0
|
||||
var nextLine = -1
|
||||
if (insN.size > 0) nextLine = insN[j]
|
||||
var gain = 3
|
||||
var min = 0
|
||||
var trigger = 2
|
||||
for (i in arrayQuote.indices) {
|
||||
val qutoe = arrayQuote[i]
|
||||
if (qutoe > 0) {
|
||||
gain = 4
|
||||
min = 2
|
||||
trigger = 4
|
||||
} else {
|
||||
gain = 3
|
||||
min = 0
|
||||
trigger = 2
|
||||
}
|
||||
|
||||
// 把引号前的换行符与内容相间插入
|
||||
while (j < insN.size) {
|
||||
|
||||
// 如果下一个换行符在当前引号前,那么需要此次处理.如果紧挨当前引号,需要考虑插入引号的情况
|
||||
if (nextLine >= qutoe) break
|
||||
nextLine = insN[j]
|
||||
if (progress < nextLine) {
|
||||
subs = string.substring(progress, nextLine)
|
||||
insN.addAll(forceSplit(subs, progress, min, gain, trigger))
|
||||
progress = nextLine + 1
|
||||
}
|
||||
j++
|
||||
}
|
||||
if (progress < qutoe) {
|
||||
subs = string.substring(progress, qutoe + 1)
|
||||
insN.addAll(forceSplit(subs, progress, min, gain, trigger))
|
||||
progress = qutoe + 1
|
||||
}
|
||||
}
|
||||
while (j < insN.size) {
|
||||
nextLine = insN[j]
|
||||
if (progress < nextLine) {
|
||||
subs = string.substring(progress, nextLine)
|
||||
insN.addAll(forceSplit(subs, progress, min, gain, trigger))
|
||||
progress = nextLine + 1
|
||||
}
|
||||
j++
|
||||
}
|
||||
if (progress < string.length) {
|
||||
subs = string.substring(progress, string.length)
|
||||
insN.addAll(forceSplit(subs, progress, min, gain, trigger))
|
||||
}
|
||||
}
|
||||
|
||||
// 根据段落状态修正引号方向、计算需要插入引号的位置
|
||||
// ins_quote跟随array_quote ins_quote[i]!=0,则array_quote.get(i)的引号前需要前插入'”'
|
||||
val insQuote = BooleanArray(size)
|
||||
opend = false
|
||||
for (i in 0 until size) {
|
||||
val p = arrayQuote[i]
|
||||
if (mod[i] > 0) {
|
||||
string.setCharAt(p, '“')
|
||||
if (opend) insQuote[i] = true
|
||||
opend = true
|
||||
} else if (mod[i] < 0) {
|
||||
string.setCharAt(p, '”')
|
||||
opend = false
|
||||
} else {
|
||||
opend = !opend
|
||||
if (opend) string.setCharAt(p, '“') else string.setCharAt(p, '”')
|
||||
}
|
||||
}
|
||||
insN = ArrayList(HashSet(insN))
|
||||
insN.sort()
|
||||
|
||||
// 完成字符串拼接(从string复制、插入引号和换行
|
||||
// ins_quote 在引号前插入一个引号。 ins_quote[i]!=0,则array_quote.get(i)的引号前需要前插入'”'
|
||||
// ins_n 插入换行。数组的值表示插入换行符的位置
|
||||
val buffer = StringBuilder((str.length * 1.15).toInt())
|
||||
var j = 0
|
||||
var progress = 0
|
||||
var nextLine = -1
|
||||
if (insN.size > 0) nextLine = insN[j]
|
||||
for (i in arrayQuote.indices) {
|
||||
val qutoe = arrayQuote[i]
|
||||
|
||||
// 把引号前的换行符与内容相间插入
|
||||
while (j < insN.size) {
|
||||
|
||||
// 如果下一个换行符在当前引号前,那么需要此次处理.如果紧挨当前引号,需要考虑插入引号的情况
|
||||
if (nextLine >= qutoe) break
|
||||
nextLine = insN[j]
|
||||
buffer.append(string, progress, nextLine + 1)
|
||||
buffer.append('\n')
|
||||
progress = nextLine + 1
|
||||
j++
|
||||
}
|
||||
if (progress < qutoe) {
|
||||
buffer.append(string, progress, qutoe + 1)
|
||||
progress = qutoe + 1
|
||||
}
|
||||
if (insQuote[i] && buffer.length > 2) {
|
||||
if (buffer[buffer.length - 1] == '\n') buffer.append('“') else buffer.insert(
|
||||
buffer.length - 1,
|
||||
"”\n"
|
||||
)
|
||||
}
|
||||
}
|
||||
while (j < insN.size) {
|
||||
nextLine = insN[j]
|
||||
if (progress <= nextLine) {
|
||||
buffer.append(string, progress, nextLine + 1)
|
||||
buffer.append('\n')
|
||||
progress = nextLine + 1
|
||||
}
|
||||
j++
|
||||
}
|
||||
if (progress < string.length) {
|
||||
buffer.append(string, progress, string.length)
|
||||
}
|
||||
return buffer.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* 从字符串提取引号包围,且不止出现一次的内容为字典
|
||||
*
|
||||
* @param str
|
||||
* @return 词条列表
|
||||
*/
|
||||
private fun makeDict(str: String): List<String> {
|
||||
|
||||
// 引号中间不包含任何标点
|
||||
val patten = Pattern.compile(
|
||||
"""
|
||||
(?<=["'”“])([^
|
||||
\p{P}]{1,$WORD_MAX_LENGTH})(?=["'”“])
|
||||
""".trimIndent()
|
||||
)
|
||||
//Pattern patten = Pattern.compile("(?<=[\"'”“])([^\n\"'”“]{1,16})(?=[\"'”“])");
|
||||
val matcher = patten.matcher(str)
|
||||
val cache: MutableList<String> = ArrayList()
|
||||
val dict: MutableList<String> = ArrayList()
|
||||
while (matcher.find()) {
|
||||
val word = matcher.group()
|
||||
if (cache.contains(word)) {
|
||||
if (!dict.contains(word)) dict.add(word)
|
||||
} else cache.add(word)
|
||||
}
|
||||
return dict
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算匹配到字典的每个字符的位置
|
||||
*
|
||||
* @param str 待匹配的字符串
|
||||
* @param key 字典
|
||||
* @param from 从字符串的第几个字符开始匹配
|
||||
* @param to 匹配到第几个字符结束
|
||||
* @param inOrder 是否按照从前向后的顺序匹配
|
||||
* @return 返回距离构成的ArrayList<Int>
|
||||
*/
|
||||
private fun seekIndexs(
|
||||
str: String,
|
||||
key: String,
|
||||
from: Int,
|
||||
to: Int,
|
||||
inOrder: Boolean
|
||||
): ArrayList<Int> {
|
||||
val list = ArrayList<Int>()
|
||||
if (str.length - from < 1) return list
|
||||
var i = 0
|
||||
if (from > i) i = from
|
||||
var t = str.length
|
||||
if (to > 0) t = min(t, to)
|
||||
var c: Char
|
||||
while (i < t) {
|
||||
c = if (inOrder) str[i] else str[str.length - i - 1]
|
||||
if (key.indexOf(c) != -1) {
|
||||
list.add(i)
|
||||
}
|
||||
i++
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算字符串最后出现与字典中字符匹配的位置
|
||||
*
|
||||
* @param str 数据字符串
|
||||
* @param key 字典字符串
|
||||
* @param from 从哪个字符开始匹配,默认最末位
|
||||
* @param to 匹配到哪个字符(不包含此字符)默认0
|
||||
* @return 位置(正向计算)
|
||||
*/
|
||||
private fun seekLast(str: String, key: String, from: Int, to: Int): Int {
|
||||
if (str.length - from < 1) return -1
|
||||
var i = str.lastIndex
|
||||
if (from < i && i > 0) i = from
|
||||
var t = 0
|
||||
if (to > 0) t = to
|
||||
var c: Char
|
||||
while (i > t) {
|
||||
c = str[i]
|
||||
if (key.indexOf(c) != -1) {
|
||||
return i
|
||||
}
|
||||
i--
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算字符串与字典中字符的最短距离
|
||||
*
|
||||
* @param str 数据字符串
|
||||
* @param key 字典字符串
|
||||
* @param from 从哪个字符开始匹配,默认0
|
||||
* @param to 匹配到哪个字符(不包含此字符)默认匹配到最末位
|
||||
* @param inOrder 是否从正向开始匹配
|
||||
* @return 返回最短距离, 注意不是str的char的下标
|
||||
*/
|
||||
private fun seekIndex(str: String, key: String, from: Int, to: Int, inOrder: Boolean): Int {
|
||||
if (str.length - from < 1) return -1
|
||||
var i = 0
|
||||
if (from > i) i = from
|
||||
var t = str.length
|
||||
if (to > 0) t = min(t, to)
|
||||
var c: Char
|
||||
while (i < t) {
|
||||
c = if (inOrder) str[i] else str[str.length - i - 1]
|
||||
if (key.indexOf(c) != -1) {
|
||||
return i
|
||||
}
|
||||
i++
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
/* 搜寻引号并进行分段。处理了一、二、五三类常见情况
|
||||
参照百科词条[引号#应用示例](https://baike.baidu.com/item/%E5%BC%95%E5%8F%B7/998963?#5)对引号内容进行矫正并分句。
|
||||
一、完整引用说话内容,在反引号内侧有断句标点。例如:
|
||||
1) 丫姑折断几枝扔下来,边叫我的小名儿边说:“先喂饱你!”
|
||||
2)“哎呀,真是美极了!”皇帝说,“我十分满意!”
|
||||
3)“怕什么!海的美就在这里!”我说道。
|
||||
二、部分引用,在反引号外侧有断句标点:
|
||||
4)适当地改善自己的生活,岂但“你管得着吗”,而且是顺乎天理,合乎人情的。
|
||||
5)现代画家徐悲鸿笔下的马,正如有的评论家所说的那样,“形神兼备,充满生机”。
|
||||
6)唐朝的张嘉贞说它“制造奇特,人不知其所为”。
|
||||
三、一段接着一段地直接引用时,中间段落只在段首用起引号,该段段尾却不用引回号。但是正统文学不在考虑范围内。
|
||||
四、引号里面又要用引号时,外面一层用双引号,里面一层用单引号。暂时不需要考虑
|
||||
五、反语和强调,周围没有断句符号。
|
||||
*/
|
||||
|
||||
// 句子结尾的标点。因为引号可能存在误判,不包含引号。
|
||||
private const val MARK_SENTENCES_END = "?。!?!~"
|
||||
private const val MARK_SENTENCES_END_P = ".?。!?!~"
|
||||
|
||||
// 句中标点,由于某些网站常把“,”写为".",故英文句点按照句中标点判断
|
||||
private const val MARK_SENTENCES_MID = ".,、,—…"
|
||||
private const val MARK_SENTENCES_SAY = "问说喊唱叫骂道着答"
|
||||
|
||||
// XXX说:“”的冒号
|
||||
private const val MARK_QUOTATION_BEFORE = ",:,:"
|
||||
|
||||
// 引号
|
||||
private const val MARK_QUOTATION = "\"“”"
|
||||
private val PARAGRAPH_DIAGLOG = "^[\"”“][^\"”“]+[\"”“]$".toRegex()
|
||||
|
||||
// 限制字典的长度
|
||||
private const val WORD_MAX_LENGTH = 16
|
||||
|
||||
private fun match(rule: String, chr: Char): Boolean {
|
||||
return rule.indexOf(chr) != -1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package io.legato.kazusa.help.book
|
||||
|
||||
import android.os.Build
|
||||
import io.legato.kazusa.constant.AppLog
|
||||
import io.legato.kazusa.constant.AppPattern.spaceRegex
|
||||
import io.legato.kazusa.data.appDb
|
||||
import io.legato.kazusa.data.entities.Book
|
||||
import io.legato.kazusa.data.entities.BookChapter
|
||||
import io.legato.kazusa.data.entities.ReplaceRule
|
||||
import io.legato.kazusa.exception.RegexTimeoutException
|
||||
import io.legato.kazusa.help.config.AppConfig
|
||||
import io.legato.kazusa.help.config.ReadBookConfig
|
||||
import io.legato.kazusa.utils.ChineseUtils
|
||||
import io.legato.kazusa.utils.escapeRegex
|
||||
import io.legato.kazusa.utils.replace
|
||||
import io.legato.kazusa.utils.stackTraceStr
|
||||
import io.legato.kazusa.utils.toastOnUi
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import splitties.init.appCtx
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import java.util.regex.Pattern
|
||||
|
||||
class ContentProcessor private constructor(
|
||||
private val bookName: String,
|
||||
private val bookOrigin: String
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private val processors = hashMapOf<String, WeakReference<ContentProcessor>>()
|
||||
private val isAndroid8 = Build.VERSION.SDK_INT in 26..27
|
||||
|
||||
fun get(book: Book) = get(book.name, book.origin)
|
||||
|
||||
fun get(bookName: String, bookOrigin: String): ContentProcessor {
|
||||
val processorWr = processors[bookName + bookOrigin]
|
||||
var processor: ContentProcessor? = processorWr?.get()
|
||||
if (processor == null) {
|
||||
processor = ContentProcessor(bookName, bookOrigin)
|
||||
processors[bookName + bookOrigin] = WeakReference(processor)
|
||||
}
|
||||
return processor
|
||||
}
|
||||
|
||||
fun upReplaceRules() {
|
||||
processors.forEach {
|
||||
it.value.get()?.upReplaceRules()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private val titleReplaceRules = CopyOnWriteArrayList<ReplaceRule>()
|
||||
private val contentReplaceRules = CopyOnWriteArrayList<ReplaceRule>()
|
||||
val removeSameTitleCache = hashSetOf<String>()
|
||||
|
||||
init {
|
||||
upReplaceRules()
|
||||
upRemoveSameTitle()
|
||||
}
|
||||
|
||||
fun upReplaceRules() {
|
||||
titleReplaceRules.run {
|
||||
clear()
|
||||
addAll(appDb.replaceRuleDao.findEnabledByTitleScope(bookName, bookOrigin))
|
||||
}
|
||||
contentReplaceRules.run {
|
||||
clear()
|
||||
addAll(appDb.replaceRuleDao.findEnabledByContentScope(bookName, bookOrigin))
|
||||
}
|
||||
}
|
||||
|
||||
private fun upRemoveSameTitle() {
|
||||
val book = appDb.bookDao.getBookByOrigin(bookName, bookOrigin) ?: return
|
||||
removeSameTitleCache.clear()
|
||||
val files = BookHelp.getChapterFiles(book).filter {
|
||||
it.endsWith("nr")
|
||||
}
|
||||
removeSameTitleCache.addAll(files)
|
||||
}
|
||||
|
||||
fun getTitleReplaceRules(): List<ReplaceRule> {
|
||||
return titleReplaceRules
|
||||
}
|
||||
|
||||
@Suppress("MemberVisibilityCanBePrivate")
|
||||
fun getContentReplaceRules(): List<ReplaceRule> {
|
||||
return contentReplaceRules
|
||||
}
|
||||
|
||||
fun getContent(
|
||||
book: Book,
|
||||
chapter: BookChapter,
|
||||
content: String,
|
||||
includeTitle: Boolean = true,
|
||||
useReplace: Boolean = true,
|
||||
chineseConvert: Boolean = true,
|
||||
reSegment: Boolean = true
|
||||
): BookContent {
|
||||
var mContent = content
|
||||
var sameTitleRemoved = false
|
||||
var effectiveReplaceRules: ArrayList<ReplaceRule>? = null
|
||||
if (content != "null") {
|
||||
//去除重复标题
|
||||
val fileName = chapter.getFileName("nr")
|
||||
if (!removeSameTitleCache.contains(fileName)) try {
|
||||
val name = Pattern.quote(book.name)
|
||||
var title = chapter.title.escapeRegex().replace(spaceRegex, "\\\\s*")
|
||||
var matcher = Pattern.compile("^(\\s|\\p{P}|${name})*${title}(\\s)*")
|
||||
.matcher(mContent)
|
||||
if (matcher.find()) {
|
||||
mContent = mContent.substring(matcher.end())
|
||||
sameTitleRemoved = true
|
||||
} else if (useReplace && book.getUseReplaceRule()) {
|
||||
title = Pattern.quote(
|
||||
chapter.getDisplayTitle(
|
||||
contentReplaceRules,
|
||||
chineseConvert = false
|
||||
)
|
||||
)
|
||||
matcher = Pattern.compile("^(\\s|\\p{P}|${name})*${title}(\\s)*")
|
||||
.matcher(mContent)
|
||||
if (matcher.find()) {
|
||||
mContent = mContent.substring(matcher.end())
|
||||
sameTitleRemoved = true
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
AppLog.put("去除重复标题出错\n${e.localizedMessage}", e)
|
||||
}
|
||||
if (reSegment && book.getReSegment()) {
|
||||
//重新分段
|
||||
mContent = ContentHelp.reSegment(mContent, chapter.title)
|
||||
}
|
||||
if (chineseConvert) {
|
||||
//简繁转换
|
||||
try {
|
||||
when (AppConfig.chineseConverterType) {
|
||||
1 -> mContent = ChineseUtils.t2s(mContent)
|
||||
2 -> mContent = ChineseUtils.s2t(mContent)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
appCtx.toastOnUi("简繁转换出错")
|
||||
}
|
||||
}
|
||||
if (useReplace && book.getUseReplaceRule()) {
|
||||
//替换
|
||||
effectiveReplaceRules = arrayListOf()
|
||||
mContent = mContent.lines().joinToString("\n") { it.trim() }
|
||||
getContentReplaceRules().forEach { item ->
|
||||
if (item.pattern.isEmpty()) {
|
||||
return@forEach
|
||||
}
|
||||
try {
|
||||
val tmp = if (item.isRegex) {
|
||||
mContent.replace(
|
||||
item.regex,
|
||||
item.replacement,
|
||||
item.getValidTimeoutMillisecond()
|
||||
)
|
||||
} else {
|
||||
mContent.replace(item.pattern, item.replacement)
|
||||
}
|
||||
if (mContent != tmp) {
|
||||
effectiveReplaceRules.add(item)
|
||||
mContent = tmp
|
||||
}
|
||||
} catch (e: RegexTimeoutException) {
|
||||
item.isEnabled = false
|
||||
appDb.replaceRuleDao.update(item)
|
||||
mContent = item.name + e.stackTraceStr
|
||||
} catch (_: CancellationException) {
|
||||
} catch (e: Exception) {
|
||||
AppLog.put("替换净化: 规则 ${item.name}替换出错.\n${mContent}", e)
|
||||
appCtx.toastOnUi("替换净化: 规则 ${item.name}替换出错")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (includeTitle) {
|
||||
//重新添加标题
|
||||
mContent = chapter.getDisplayTitle(
|
||||
getTitleReplaceRules(),
|
||||
useReplace = useReplace && book.getUseReplaceRule()
|
||||
) + "\n" + mContent
|
||||
}
|
||||
if (isAndroid8) {
|
||||
mContent = mContent.replace('\u00A0', ' ')
|
||||
}
|
||||
val contents = arrayListOf<String>()
|
||||
mContent.split("\n").forEach { str ->
|
||||
val paragraph = str.trim {
|
||||
it.code <= 0x20 || it == ' '
|
||||
}
|
||||
if (paragraph.isNotEmpty()) {
|
||||
if (contents.isEmpty() && includeTitle) {
|
||||
contents.add(paragraph)
|
||||
} else {
|
||||
contents.add("${ReadBookConfig.paragraphIndent}$paragraph")
|
||||
}
|
||||
}
|
||||
}
|
||||
return BookContent(sameTitleRemoved, contents, effectiveReplaceRules)
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user