[优化] 阅读本地TXT文本时,编辑可选择覆盖源文件

This commit is contained in:
HapeLee committed 2026-03-05 01:23:47 +08:00
1 parent 16b8164943
commit c528ed59a5
4 files changed
+96 -46

No files matched your search

@@ -47,6 +47,9 @@ interface BookChapterDao {
@Query("update chapters set wordCount = :wordCount where bookUrl = :bookUrl and url = :url") @Query("update chapters set wordCount = :wordCount where bookUrl = :bookUrl and url = :url")
fun upWordCount(bookUrl: String, url: String, wordCount: String) fun upWordCount(bookUrl: String, url: String, wordCount: String)
@Query("update chapters set start = start + :diff, end = end + :diff where bookUrl = :bookUrl and `index` > :index")
fun updateOffsets(bookUrl: String, index: Int, diff: Long)
/** /**
* 根据书籍的唯一标识 bookUrl 和章节索引 index 查找章节标题。 * 根据书籍的唯一标识 bookUrl 和章节索引 index 查找章节标题。
*/ */
@@ -2,6 +2,7 @@ package io.legado.app.help.book
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import android.system.Os
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import com.script.rhino.runScriptWithContext import com.script.rhino.runScriptWithContext
import io.legado.app.constant.AppLog import io.legado.app.constant.AppLog
@@ -14,6 +15,7 @@ import io.legado.app.data.entities.BookSource
import io.legado.app.help.config.AppConfig import io.legado.app.help.config.AppConfig
import io.legado.app.model.analyzeRule.AnalyzeUrl import io.legado.app.model.analyzeRule.AnalyzeUrl
import io.legado.app.model.localBook.LocalBook import io.legado.app.model.localBook.LocalBook
import io.legado.app.model.localBook.TextFile
import io.legado.app.utils.ArchiveUtils import io.legado.app.utils.ArchiveUtils
import io.legado.app.utils.FileUtils import io.legado.app.utils.FileUtils
import io.legado.app.utils.ImageUtils import io.legado.app.utils.ImageUtils
@@ -176,10 +178,19 @@ object BookHelp {
fun saveText( fun saveText(
book: Book, book: Book,
bookChapter: BookChapter, bookChapter: BookChapter,
content: String content: String,
saveToSource: Boolean = false
) { ) {
if (content.isEmpty()) return if (content.isEmpty()) return
//保存文本 if (book.isLocalTxt && saveToSource) {
try {
saveToLocalTxt(book, bookChapter, content)
TextFile.clear()
} catch (e: Exception) {
AppLog.put("修改本地TXT失败: ${e.localizedMessage}", e)
}
}
// 保存阅读缓存文本(.nb)
FileUtils.createFileIfNotExist( FileUtils.createFileIfNotExist(
downloadDir, downloadDir,
cacheFolderName, cacheFolderName,
@@ -193,6 +204,60 @@ object BookHelp {
} }
} }
private fun saveToLocalTxt(book: Book, bookChapter: BookChapter, content: String) {
val start = bookChapter.start ?: return
val end = bookChapter.end ?: return
val uri = book.getLocalUri()
val charset = book.fileCharset()
val oldTitle = bookChapter.title
val newContent = oldTitle + "\n" + content
val newBytes = newContent.toByteArray(charset)
val oldLength = end - start
val diff = newBytes.size - oldLength
val pfd = if (uri.isContentScheme()) {
appCtx.contentResolver.openFileDescriptor(uri, "rw")
} else {
ParcelFileDescriptor.open(File(uri.path!!), ParcelFileDescriptor.MODE_READ_WRITE)
}
pfd?.use {
val fd = it.fileDescriptor
try {
val totalLength = Os.fstat(fd).st_size
if (diff != 0L) {
val remaining = totalLength - end
if (remaining > 0) {
val buffer = ByteArray(1024 * 1024)
var pos = totalLength
while (pos > end) {
val readSize =
if (pos - end > buffer.size) buffer.size else (pos - end).toInt()
Os.pread(fd, buffer, 0, readSize, pos - readSize)
Os.pwrite(fd, buffer, 0, readSize, pos - readSize + diff)
pos -= readSize
}
}
if (diff < 0) {
Os.ftruncate(fd, totalLength + diff)
}
}
Os.pwrite(fd, newBytes, 0, newBytes.size, start)
// 更新数据库中的偏移量
if (diff != 0L) {
appDb.bookChapterDao.updateOffsets(book.bookUrl, bookChapter.index, diff)
}
bookChapter.end = start + newBytes.size
appDb.bookChapterDao.update(bookChapter)
} catch (e: Exception) {
throw e
}
}
}
fun flowImages(bookChapter: BookChapter, content: String): Flow<String> { fun flowImages(bookChapter: BookChapter, content: String): Flow<String> {
return flow { return flow {
val matcher = AppPattern.imgPattern.matcher(content) val matcher = AppPattern.imgPattern.matcher(content)
@@ -597,7 +662,7 @@ object BookHelp {
private val regexC by lazy { private val regexC by lazy {
//前后附加内容,整个章节名都在括号中时只剔除首尾括号,避免将章节名替换为空字串 //前后附加内容,整个章节名都在括号中时只剔除首尾括号,避免将章节名替换为空字串
return@lazy "(?!^)(?:[〖【《〔\\[{(][^〖【《〔\\[{()〕》】〗\\]}]+)?[)〕》】〗\\]}]$|^[〖【《〔\\[{(](?:[^〖【《〔\\[{()〕》】〗\\]}]+[〕》】〗\\]})])?(?!$)".toRegex() return@lazy "(?!^)(?:[〖【《〔\\[{(][^〖【《〔\\[{()〕》》】〗\\]}]+)?[)〕》》】〗\\]}]$|^[〖【《〔\\[{(](?:[^〖【《〔\\[{()〕》》】〗\\]}]+[〕》》】〗\\]})])?(?!$)".toRegex()
} }
private fun getPureChapterName(chapterName: String?): String { private fun getPureChapterName(chapterName: String?): String {
@@ -3,9 +3,6 @@ package io.legado.app.ui.book.read
import android.app.Application import android.app.Application
import android.content.DialogInterface import android.content.DialogInterface
import android.os.Bundle import android.os.Bundle
import android.text.Spannable
import android.text.Spanned
import android.text.style.BackgroundColorSpan
import android.view.View import android.view.View
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
@@ -20,13 +17,13 @@ import io.legado.app.databinding.DialogEditTextBinding
import io.legado.app.help.book.BookHelp import io.legado.app.help.book.BookHelp
import io.legado.app.help.book.ContentProcessor import io.legado.app.help.book.ContentProcessor
import io.legado.app.help.book.isLocal import io.legado.app.help.book.isLocal
import io.legado.app.help.book.isLocalTxt
import io.legado.app.help.coroutine.Coroutine import io.legado.app.help.coroutine.Coroutine
import io.legado.app.lib.dialogs.alert import io.legado.app.lib.dialogs.alert
import io.legado.app.model.ReadBook import io.legado.app.model.ReadBook
import io.legado.app.model.webBook.WebBook import io.legado.app.model.webBook.WebBook
import io.legado.app.utils.gone import io.legado.app.utils.gone
import io.legado.app.utils.sendToClip import io.legado.app.utils.sendToClip
import io.legado.app.utils.themeColor
import io.legado.app.utils.viewbindingdelegate.viewBinding import io.legado.app.utils.viewbindingdelegate.viewBinding
import io.legado.app.utils.visible import io.legado.app.utils.visible
import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.IO
@@ -47,12 +44,16 @@ class ContentEditDialog : BaseBottomSheetDialogFragment(R.layout.dialog_content_
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
binding.toolBar.title = ReadBook.curTextChapter?.title binding.toolBar.title = ReadBook.curTextChapter?.title
val book = ReadBook.book
if (book?.isLocalTxt == true) {
binding.cbSaveToSource.visible()
}
initMenu() initMenu()
binding.toolBar.setOnClickListener { binding.toolBar.setOnClickListener {
lifecycleScope.launch { lifecycleScope.launch {
val book = ReadBook.book ?: return@launch val book1 = ReadBook.book ?: return@launch
val chapter = withContext(IO) { val chapter = withContext(IO) {
appDb.bookChapterDao.getChapter(book.bookUrl, ReadBook.durChapterIndex) appDb.bookChapterDao.getChapter(book1.bookUrl, ReadBook.durChapterIndex)
} ?: return@launch } ?: return@launch
editTitle(chapter) editTitle(chapter)
} }
@@ -119,37 +120,6 @@ class ContentEditDialog : BaseBottomSheetDialogFragment(R.layout.dialog_content_
} }
} }
private fun highlightSelectedTextTwice() {
val editText = binding.contentView
val content = editText.text ?: return
val start = arguments?.getInt("start_position", -1) ?: -1
val selectedText = arguments?.getString("selected_text") ?: ""
if (start == -1 || selectedText.isEmpty()) return
val end = (start + selectedText.length).coerceAtMost(content.length)
val spannable = editText.text as Spannable
val span =
BackgroundColorSpan(requireContext().themeColor(com.google.android.material.R.attr.colorSecondaryContainer))
fun flash(times: Int) {
if (times <= 0) return
spannable.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
editText.postDelayed({
spannable.removeSpan(span)
editText.postDelayed({
flash(times - 1)
}, 200)
}, 300)
}
flash(3)
}
override fun onCancel(dialog: DialogInterface) { override fun onCancel(dialog: DialogInterface) {
super.onCancel(dialog) super.onCancel(dialog)
save() save()
@@ -157,12 +127,13 @@ class ContentEditDialog : BaseBottomSheetDialogFragment(R.layout.dialog_content_
private fun save() { private fun save() {
val content = binding.contentView.text?.toString() ?: return val content = binding.contentView.text?.toString() ?: return
val saveToSource = binding.cbSaveToSource.isChecked
Coroutine.async { Coroutine.async {
val book = ReadBook.book ?: return@async val book = ReadBook.book ?: return@async
val chapter = appDb.bookChapterDao val chapter = appDb.bookChapterDao
.getChapter(book.bookUrl, ReadBook.durChapterIndex) .getChapter(book.bookUrl, ReadBook.durChapterIndex)
?: return@async ?: return@async
BookHelp.saveText(book, chapter, content) BookHelp.saveText(book, chapter, content, saveToSource)
ReadBook.loadContent(ReadBook.durChapterIndex, resetPageOffset = false) ReadBook.loadContent(ReadBook.durChapterIndex, resetPageOffset = false)
} }
} }
@@ -10,21 +10,32 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:theme="?attr/actionBarStyle" android:theme="?attr/actionBarStyle"
app:title="edit" app:layout_constraintTop_toTopOf="parent"
app:layout_constraintTop_toTopOf="parent" /> app:title="edit" />
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/cb_save_to_source"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:text="保存到源文件,保存后重置功能将无效"
android:visibility="visible"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toBottomOf="@id/tool_bar" />
<androidx.core.widget.NestedScrollView <androidx.core.widget.NestedScrollView
android:id="@+id/scrollView" android:id="@+id/scrollView"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:paddingHorizontal="16dp" android:paddingHorizontal="16dp"
app:layout_constraintTop_toBottomOf="@id/tool_bar"> app:layout_constraintTop_toBottomOf="@id/cb_save_to_source">
<io.legado.app.ui.widget.text.TextInputLayout <io.legado.app.ui.widget.text.TextInputLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="80dp" android:layout_marginBottom="80dp"
app:layout_constraintTop_toBottomOf="@+id/tool_bar"> app:layout_constraintTop_toBottomOf="@+id/tool_bar">
<com.google.android.material.textfield.TextInputEditText <com.google.android.material.textfield.TextInputEditText
android:id="@+id/content_view" android:id="@+id/content_view"
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -39,9 +50,9 @@
android:layout_width="60dp" android:layout_width="60dp"
android:layout_height="60dp" android:layout_height="60dp"
android:visibility="invisible" android:visibility="invisible"
app:layout_constraintTop_toBottomOf="@+id/tool_bar"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent" /> app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@+id/tool_bar" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>