Merge pull request #478 from gedoor/master

update
pull/481/head
Antecer 4 years ago committed by GitHub
commit c0349b43c3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      app/src/main/assets/updateLog.md
  2. 9
      app/src/main/java/io/legado/app/data/dao/ReplaceRuleDao.kt
  3. 8
      app/src/main/java/io/legado/app/data/entities/BookChapter.kt
  4. 91
      app/src/main/java/io/legado/app/help/BookHelp.kt
  5. 79
      app/src/main/java/io/legado/app/help/ContentProcessor.kt
  6. 2
      app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeUrl.kt
  7. 7
      app/src/main/java/io/legado/app/model/webBook/WebBook.kt
  8. 11
      app/src/main/java/io/legado/app/service/help/ReadBook.kt
  9. 96
      app/src/main/java/io/legado/app/ui/book/cache/CacheViewModel.kt
  10. 2
      app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt
  11. 4
      app/src/main/java/io/legado/app/ui/book/read/page/ContentTextView.kt
  12. 4
      app/src/main/java/io/legado/app/ui/book/read/page/ContentView.kt
  13. 65
      app/src/main/java/io/legado/app/ui/book/read/page/PageView.kt
  14. 40
      app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentActivity.kt
  15. 5
      app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentViewModel.kt
  16. 69
      app/src/main/java/io/legado/app/ui/main/MainViewModel.kt
  17. 4
      app/src/main/java/io/legado/app/ui/replace/ReplaceRuleActivity.kt
  18. 2
      app/src/main/java/io/legado/app/ui/widget/image/CircleImageView.kt
  19. 2
      app/src/main/java/io/legado/app/utils/NetworkUtils.kt
  20. 3
      app/src/main/java/io/legado/app/utils/StringExtensions.kt
  21. 34
      app/src/main/res/layout/view_read_menu.xml
  22. 2
      build.gradle

@ -3,13 +3,15 @@
* 关注合作公众号 **[小说拾遗]()** 获取好看的小说。 * 关注合作公众号 **[小说拾遗]()** 获取好看的小说。
* 旧版数据导入教程:先在旧版阅读(2.x)中进行备份,然后在新版阅读(3.x)【我的】->【备份与恢复】,选择【导入旧版本数据】。 * 旧版数据导入教程:先在旧版阅读(2.x)中进行备份,然后在新版阅读(3.x)【我的】->【备份与恢复】,选择【导入旧版本数据】。
**2020/11/13** **2020/11/15**
* 正文规则添加字体规则,返回ByteArray * 正文规则添加字体规则,返回ByteArray
* js添加方法: * js添加方法:
``` ```
base64DecodeToByteArray(str: String?): ByteArray? base64DecodeToByteArray(str: String?): ByteArray?
base64DecodeToByteArray(str: String?, flags: Int): ByteArray? base64DecodeToByteArray(str: String?, flags: Int): ByteArray?
``` ```
* 导出添加替换净化
* 修复正文内容TalkBack不对的bug,优化视障使用体验
**2020/11/08** **2020/11/08**
* 优化书源,订阅源导入,添加保持原名选项 * 优化书源,订阅源导入,添加保持原名选项

@ -32,15 +32,6 @@ interface ReplaceRuleDao {
@Query("SELECT * FROM replace_rules WHERE id in (:ids)") @Query("SELECT * FROM replace_rules WHERE id in (:ids)")
fun findByIds(vararg ids: Long): List<ReplaceRule> fun findByIds(vararg ids: Long): List<ReplaceRule>
@Query(
"""
SELECT * FROM replace_rules WHERE isEnabled = 1
AND (scope LIKE '%' || :scope || '%' or scope is null or scope = '')
order by sortOrder
"""
)
fun findEnabledByScope(scope: String): List<ReplaceRule>
@Query( @Query(
""" """
SELECT * FROM replace_rules WHERE isEnabled = 1 SELECT * FROM replace_rules WHERE isEnabled = 1

@ -5,6 +5,7 @@ import androidx.room.Entity
import androidx.room.ForeignKey import androidx.room.ForeignKey
import androidx.room.Ignore import androidx.room.Ignore
import androidx.room.Index import androidx.room.Index
import io.legado.app.model.analyzeRule.AnalyzeUrl
import io.legado.app.utils.GSON import io.legado.app.utils.GSON
import io.legado.app.utils.MD5Utils import io.legado.app.utils.MD5Utils
import io.legado.app.utils.NetworkUtils import io.legado.app.utils.NetworkUtils
@ -61,7 +62,12 @@ data class BookChapter(
} }
fun getAbsoluteURL(): String { fun getAbsoluteURL(): String {
return NetworkUtils.getAbsoluteURL(baseUrl, url)!! val urlArray = url.split(AnalyzeUrl.splitUrlRegex)
var absoluteUrl = NetworkUtils.getAbsoluteURL(baseUrl, urlArray[0])!!
if (urlArray.size > 1) {
absoluteUrl += urlArray[1]
}
return absoluteUrl
} }
fun getFileName(): String = String.format("%05d-%s.nb", index, MD5Utils.md5Encode16(title)) fun getFileName(): String = String.format("%05d-%s.nb", index, MD5Utils.md5Encode16(title))

@ -1,22 +1,17 @@
package io.legado.app.help package io.legado.app.help
import com.hankcs.hanlp.HanLP
import io.legado.app.App import io.legado.app.App
import io.legado.app.constant.AppPattern import io.legado.app.constant.AppPattern
import io.legado.app.constant.EventBus import io.legado.app.constant.EventBus
import io.legado.app.data.entities.Book import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.BookChapter
import io.legado.app.data.entities.ReplaceRule
import io.legado.app.help.coroutine.Coroutine import io.legado.app.help.coroutine.Coroutine
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.utils.* import io.legado.app.utils.*
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import net.ricecode.similarity.JaroWinklerStrategy import net.ricecode.similarity.JaroWinklerStrategy
import net.ricecode.similarity.StringSimilarityServiceImpl import net.ricecode.similarity.StringSimilarityServiceImpl
import org.jetbrains.anko.toast
import java.io.File import java.io.File
import java.util.concurrent.CopyOnWriteArraySet import java.util.concurrent.CopyOnWriteArraySet
import java.util.regex.Matcher import java.util.regex.Matcher
@ -317,90 +312,4 @@ object BookHelp {
.replace(regexOther, "") .replace(regexOther, "")
} }
private var bookName: String? = null
private var bookOrigin: String? = null
private var replaceRules: List<ReplaceRule> = arrayListOf()
@Synchronized
fun upReplaceRules() {
val o = bookOrigin
bookName?.let {
replaceRules = if (o.isNullOrEmpty()) {
App.db.replaceRuleDao().findEnabledByScope(it)
} else {
App.db.replaceRuleDao().findEnabledByScope(it, o)
}
}
}
suspend fun disposeContent(
book: Book,
title: String,
content: String,
): List<String> {
var title1 = title
var content1 = content
if (book.getUseReplaceRule()) {
synchronized(this) {
if (bookName != book.name || bookOrigin != book.origin) {
bookName = book.name
bookOrigin = book.origin
replaceRules = if (bookOrigin.isNullOrEmpty()) {
App.db.replaceRuleDao().findEnabledByScope(bookName!!)
} else {
App.db.replaceRuleDao().findEnabledByScope(bookName!!, bookOrigin!!)
}
}
}
replaceRules.forEach { item ->
item.pattern.let {
if (it.isNotEmpty()) {
try {
content1 = if (item.isRegex) {
content1.replace(it.toRegex(), item.replacement)
} else {
content1.replace(it, item.replacement)
}
} catch (e: Exception) {
withContext(Main) {
App.INSTANCE.toast("${item.name}替换出错")
}
}
}
}
}
}
if (book.getReSegment()) {
content1 = ContentHelp.reSegment(content1, title1)
}
try {
when (AppConfig.chineseConverterType) {
1 -> {
title1 = HanLP.convertToSimplifiedChinese(title1)
content1 = HanLP.convertToSimplifiedChinese(content1)
}
2 -> {
title1 = HanLP.convertToTraditionalChinese(title1)
content1 = HanLP.convertToTraditionalChinese(content1)
}
}
} catch (e: Exception) {
withContext(Main) {
App.INSTANCE.toast("简繁转换出错")
}
}
val contents = arrayListOf<String>()
content1.split("\n").forEach {
val str = it.replace("^[\\n\\s\\r]+".toRegex(), "")
if (contents.isEmpty()) {
contents.add(title1)
if (str != title1 && str.isNotEmpty()) {
contents.add("${ReadBookConfig.paragraphIndent}$str")
}
} else if (str.isNotEmpty()) {
contents.add("${ReadBookConfig.paragraphIndent}$str")
}
}
return contents
}
} }

@ -0,0 +1,79 @@
package io.legado.app.help
import com.hankcs.hanlp.HanLP
import io.legado.app.App
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.ReplaceRule
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jetbrains.anko.toast
class ContentProcessor(private val bookName: String, private val bookOrigin: String) {
private var replaceRules = arrayListOf<ReplaceRule>()
init {
upReplaceRules()
}
@Synchronized
fun upReplaceRules() {
replaceRules.clear()
replaceRules.addAll(App.db.replaceRuleDao().findEnabledByScope(bookName, bookOrigin))
}
suspend fun getContent(
book: Book,
title: String, //已经经过简繁转换
content: String,
isRead: Boolean = true
): List<String> {
var content1 = content
if (book.getUseReplaceRule()) {
replaceRules.forEach { item ->
if (item.pattern.isNotEmpty()) {
try {
content1 = if (item.isRegex) {
content1.replace(item.pattern.toRegex(), item.replacement)
} else {
content1.replace(item.pattern, item.replacement)
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
App.INSTANCE.toast("${item.name}替换出错")
}
}
}
}
}
if (isRead) {
if (book.getReSegment()) {
content1 = ContentHelp.reSegment(content1, title)
}
try {
when (AppConfig.chineseConverterType) {
1 -> content1 = HanLP.convertToSimplifiedChinese(content1)
2 -> content1 = HanLP.convertToTraditionalChinese(content1)
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
App.INSTANCE.toast("简繁转换出错")
}
}
}
val contents = arrayListOf<String>()
content1.split("\n").forEach {
val str = it.replace("^[\\n\\s\\r]+".toRegex(), "")
if (contents.isEmpty()) {
contents.add(title)
if (str != title && str.isNotEmpty()) {
contents.add("${ReadBookConfig.paragraphIndent}$str")
}
} else if (str.isNotEmpty()) {
contents.add("${ReadBookConfig.paragraphIndent}$str")
}
}
return contents
}
}

@ -45,7 +45,7 @@ class AnalyzeUrl(
headerMapF: Map<String, String>? = null headerMapF: Map<String, String>? = null
) : JsExtensions { ) : JsExtensions {
companion object { companion object {
private val splitUrlRegex = Regex(",\\s*(?=\\{)") val splitUrlRegex = Regex(",\\s*(?=\\{)")
private val pagePattern = Pattern.compile("<(.*?)>") private val pagePattern = Pattern.compile("<(.*?)>")
private val jsonType = MediaType.parse("application/json; charset=utf-8") private val jsonType = MediaType.parse("application/json; charset=utf-8")
} }

@ -144,9 +144,10 @@ class WebBook(val bookSource: BookSource) {
).getResponseAwait(bookSource.bookSourceUrl) ).getResponseAwait(bookSource.bookSourceUrl)
BookChapterList.analyzeChapterList( BookChapterList.analyzeChapterList(
this, this,
book, res.body, book,
res.body,
bookSource, bookSource,
res.url book.tocUrl
) )
} }
@ -211,7 +212,7 @@ class WebBook(val bookSource: BookSource) {
book, book,
bookChapter, bookChapter,
bookSource, bookSource,
res.url, bookChapter.getAbsoluteURL(),
nextChapterUrl nextChapterUrl
) )
} }

@ -8,10 +8,7 @@ import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.BookChapter
import io.legado.app.data.entities.BookSource import io.legado.app.data.entities.BookSource
import io.legado.app.data.entities.ReadRecord import io.legado.app.data.entities.ReadRecord
import io.legado.app.help.AppConfig import io.legado.app.help.*
import io.legado.app.help.BookHelp
import io.legado.app.help.IntentDataHelp
import io.legado.app.help.ReadBookConfig
import io.legado.app.help.coroutine.Coroutine import io.legado.app.help.coroutine.Coroutine
import io.legado.app.model.webBook.WebBook import io.legado.app.model.webBook.WebBook
import io.legado.app.service.BaseReadAloudService import io.legado.app.service.BaseReadAloudService
@ -30,6 +27,7 @@ import org.jetbrains.anko.toast
object ReadBook { object ReadBook {
var titleDate = MutableLiveData<String>() var titleDate = MutableLiveData<String>()
var book: Book? = null var book: Book? = null
var contentProcessor: ContentProcessor? = null
var inBookshelf = false var inBookshelf = false
var chapterSize = 0 var chapterSize = 0
var durChapterIndex = 0 var durChapterIndex = 0
@ -48,6 +46,7 @@ object ReadBook {
fun resetData(book: Book) { fun resetData(book: Book) {
this.book = book this.book = book
contentProcessor = ContentProcessor(book.name, book.origin)
readRecord.bookName = book.name readRecord.bookName = book.name
readRecord.readTime = App.db.readRecordDao().getReadTime(book.name) ?: 0 readRecord.readTime = App.db.readRecordDao().getReadTime(book.name) ?: 0
durChapterIndex = book.durChapterIndex durChapterIndex = book.durChapterIndex
@ -372,13 +371,14 @@ object ReadBook {
resetPageOffset: Boolean resetPageOffset: Boolean
) { ) {
Coroutine.async { Coroutine.async {
ImageProvider.clearOut(durChapterIndex)
if (chapter.index in durChapterIndex - 1..durChapterIndex + 1) { if (chapter.index in durChapterIndex - 1..durChapterIndex + 1) {
chapter.title = when (AppConfig.chineseConverterType) { chapter.title = when (AppConfig.chineseConverterType) {
1 -> HanLP.convertToSimplifiedChinese(chapter.title) 1 -> HanLP.convertToSimplifiedChinese(chapter.title)
2 -> HanLP.convertToTraditionalChinese(chapter.title) 2 -> HanLP.convertToTraditionalChinese(chapter.title)
else -> chapter.title else -> chapter.title
} }
val contents = BookHelp.disposeContent(book, chapter.title, content) val contents = contentProcessor!!.getContent(book, chapter.title, content)
when (chapter.index) { when (chapter.index) {
durChapterIndex -> { durChapterIndex -> {
curTextChapter = curTextChapter =
@ -393,7 +393,6 @@ object ReadBook {
callBack?.upView() callBack?.upView()
curPageChanged() curPageChanged()
callBack?.contentLoadFinish() callBack?.contentLoadFinish()
ImageProvider.clearOut(durChapterIndex)
} }
durChapterIndex - 1 -> { durChapterIndex - 1 -> {
prevTextChapter = prevTextChapter =

@ -10,6 +10,7 @@ import io.legado.app.constant.AppPattern
import io.legado.app.constant.PreferKey import io.legado.app.constant.PreferKey
import io.legado.app.data.entities.Book import io.legado.app.data.entities.Book
import io.legado.app.help.BookHelp import io.legado.app.help.BookHelp
import io.legado.app.help.ContentProcessor
import io.legado.app.help.storage.WebDavHelp import io.legado.app.help.storage.WebDavHelp
import io.legado.app.utils.* import io.legado.app.utils.*
import java.io.File import java.io.File
@ -35,12 +36,12 @@ class CacheViewModel(application: Application) : BaseViewModel(application) {
} }
} }
private fun export(doc: DocumentFile, book: Book) { private suspend fun export(doc: DocumentFile, book: Book) {
val filename = "${book.name} by ${book.author}.txt" val filename = "${book.name} by ${book.author}.txt"
val content = getAllContents(book) val content = getAllContents(book)
DocumentUtils.createFileIfNotExist(doc, filename) DocumentUtils.createFileIfNotExist(doc, filename)
?.writeText(context, content) ?.writeText(context, content)
if(App.INSTANCE.getPrefBoolean(PreferKey.webDavCacheBackup,false)) { if (App.INSTANCE.getPrefBoolean(PreferKey.webDavCacheBackup, false)) {
FileUtils.createFileIfNotExist( FileUtils.createFileIfNotExist(
File(FileUtils.getCachePath()), File(FileUtils.getCachePath()),
filename filename
@ -50,74 +51,75 @@ class CacheViewModel(application: Application) : BaseViewModel(application) {
// 上传完删除cache文件 // 上传完删除cache文件
FileUtils.deleteFile("${FileUtils.getCachePath()}${File.separator}${filename}") FileUtils.deleteFile("${FileUtils.getCachePath()}${File.separator}${filename}")
} }
App.db.bookChapterDao().getChapterList(book.bookUrl).forEach { chapter -> getSrcList(book).forEach {
BookHelp.getContent(book, chapter).let { content -> val vFile = BookHelp.getImage(book, it.third)
content?.split("\n")?.forEachIndexed { index, text -> if (vFile.exists()) {
val matcher = AppPattern.imgPattern.matcher(text) DocumentUtils.createFileIfNotExist(
if (matcher.find()) { doc,
var src = matcher.group(1) "${it.second}-${MD5Utils.md5Encode16(it.third)}.jpg",
src = NetworkUtils.getAbsoluteURL(chapter.url, src) subDirs = arrayOf("${book.name}_${book.author}", "images", it.first)
src?.let { )?.writeBytes(context, vFile.readBytes())
val vFile = BookHelp.getImage(book, src)
if (vFile.exists()) {
DocumentUtils.createFileIfNotExist(doc,
"${index}-${MD5Utils.md5Encode16(src)}.jpg",
subDirs = arrayOf("${book.name}_${book.author}",
"images",
chapter.title))
?.writeBytes(context, vFile.readBytes())
}
}
}
}
} }
} }
} }
private fun export(file: File, book: Book) { private suspend fun export(file: File, book: Book) {
val filename = "${book.name} by ${book.author}.txt" val filename = "${book.name} by ${book.author}.txt"
FileUtils.createFileIfNotExist(file, filename) FileUtils.createFileIfNotExist(file, filename)
.writeText(getAllContents(book)) .writeText(getAllContents(book))
if(App.INSTANCE.getPrefBoolean(PreferKey.webDavCacheBackup,false)) { if (App.INSTANCE.getPrefBoolean(PreferKey.webDavCacheBackup, false)) {
WebDavHelp.exportWebDav(file.absolutePath, filename) // 导出到webdav WebDavHelp.exportWebDav(file.absolutePath, filename) // 导出到webdav
} }
App.db.bookChapterDao().getChapterList(book.bookUrl).forEach { chapter -> getSrcList(book).forEach {
BookHelp.getContent(book, chapter).let { content -> val vFile = BookHelp.getImage(book, it.third)
content?.split("\n")?.forEachIndexed { index, text -> if (vFile.exists()) {
val matcher = AppPattern.imgPattern.matcher(text) FileUtils.createFileIfNotExist(
if (matcher.find()) { file,
var src = matcher.group(1) "${book.name}_${book.author}",
src = NetworkUtils.getAbsoluteURL(chapter.url, src) "images",
src?.let { it.first,
val vFile = BookHelp.getImage(book, src) "${it.second}-${MD5Utils.md5Encode16(it.third)}.jpg"
if (vFile.exists()) { ).writeBytes(vFile.readBytes())
FileUtils.createFileIfNotExist(file,
"${book.name}_${book.author}",
"images",
chapter.title,
"${index}-${MD5Utils.md5Encode16(src)}.jpg")
.writeBytes(vFile.readBytes())
}
}
}
}
} }
} }
} }
private fun getAllContents(book: Book): String { private suspend fun getAllContents(book: Book): String {
val contentProcessor = ContentProcessor(book.name, book.origin)
val stringBuilder = StringBuilder() val stringBuilder = StringBuilder()
stringBuilder.append(book.name) stringBuilder.append(book.name)
.append("\n") .append("\n")
.append(context.getString(R.string.author_show, book.author)) .append(context.getString(R.string.author_show, book.author))
App.db.bookChapterDao().getChapterList(book.bookUrl).forEach { chapter -> App.db.bookChapterDao().getChapterList(book.bookUrl).forEach { chapter ->
BookHelp.getContent(book, chapter).let { BookHelp.getContent(book, chapter).let { content ->
val content1 = content?.let {
contentProcessor.getContent(book, chapter.title, it, false)
}
stringBuilder.append("\n\n") stringBuilder.append("\n\n")
.append(chapter.title) .append(chapter.title)
.append("\n") .append("\n")
.append(it) .append(content1)
} }
} }
return stringBuilder.toString() return stringBuilder.toString()
} }
private fun getSrcList(book: Book): ArrayList<Triple<String, Int, String>> {
val srcList = arrayListOf<Triple<String, Int, String>>()
App.db.bookChapterDao().getChapterList(book.bookUrl).forEach { chapter ->
BookHelp.getContent(book, chapter)?.let { content ->
content.split("\n").forEachIndexed { index, text ->
val matcher = AppPattern.imgPattern.matcher(text)
if (matcher.find()) {
var src = matcher.group(1)
src = NetworkUtils.getAbsoluteURL(chapter.url, src)
src?.let {
srcList.add(Triple(chapter.title, index, src))
}
}
}
}
}
return srcList
}
} }

@ -276,7 +276,7 @@ class ReadBookViewModel(application: Application) : BaseViewModel(application) {
*/ */
fun replaceRuleChanged() { fun replaceRuleChanged() {
execute { execute {
BookHelp.upReplaceRules() ReadBook.contentProcessor?.upReplaceRules()
ReadBook.loadContent(resetPageOffset = false) ReadBook.loadContent(resetPageOffset = false)
} }
} }

@ -47,13 +47,11 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at
init { init {
callBack = activity as CallBack callBack = activity as CallBack
contentDescription = textPage.text
} }
fun setContent(pageData: PageData) { fun setContent(pageData: PageData) {
this.textChapter = pageData.textChapter this.textChapter = pageData.textChapter
this.textPage = pageData.textPage this.textPage = pageData.textPage
contentDescription = textPage.text
invalidate() invalidate()
} }
@ -193,11 +191,13 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at
textPage = pageFactory.curData.textPage textPage = pageFactory.curData.textPage
pageOffset -= textPage.height.toInt() pageOffset -= textPage.height.toInt()
upView?.invoke(textPage) upView?.invoke(textPage)
contentDescription = textPage.text
} else if (pageOffset < -textPage.height) { } else if (pageOffset < -textPage.height) {
pageOffset += textPage.height.toInt() pageOffset += textPage.height.toInt()
pageFactory.moveToNext(false) pageFactory.moveToNext(false)
textPage = pageFactory.curData.textPage textPage = pageFactory.curData.textPage
upView?.invoke(textPage) upView?.invoke(textPage)
contentDescription = textPage.text
} }
invalidate() invalidate()
} }

@ -226,6 +226,10 @@ class ContentView(context: Context) : FrameLayout(context) {
content_text_view.setContent(pageData) content_text_view.setContent(pageData)
} }
fun setContentDescription(content: String) {
content_text_view.contentDescription = content
}
fun resetPageOffset() { fun resetPageOffset() {
content_text_view.resetPageOffset() content_text_view.resetPageOffset()
} }

@ -72,19 +72,18 @@ class PageView(context: Context, attrs: AttributeSet) :
private var firstCharIndex: Int = 0 private var firstCharIndex: Int = 0
val slopSquare by lazy { ViewConfiguration.get(context).scaledTouchSlop } val slopSquare by lazy { ViewConfiguration.get(context).scaledTouchSlop }
private val topLeftRectF = RectF(0F, 0F, width * 0.33f, height * 0.33f) private val tlRect = RectF(10F, 10F, width * 0.33f, height * 0.33f)
private val topCenterRectF = RectF(width * 0.33f, 0F, width * 0.66f, height * 0.33f) private val tcRect = RectF(width * 0.33f, 10F, width * 0.66f, height * 0.33f)
private val topRightRectF = RectF(width * 0.36f, 0F, width.toFloat(), height * 0.33f) private val trRect = RectF(width * 0.36f, 10F, width - 10f, height * 0.33f)
private val middleLeftRectF = RectF(0F, height * 0.33f, width * 0.33f, height * 0.66f) private val mlRect = RectF(10F, height * 0.33f, width * 0.33f, height * 0.66f)
private val centerRectF = RectF(width * 0.33f, height * 0.33f, width * 0.66f, height * 0.66f) private val mcRect = RectF(width * 0.33f, height * 0.33f, width * 0.66f, height * 0.66f)
private val middleRightRectF = private val mrRect = RectF(width * 0.66f, height * 0.33f, width - 10f, height * 0.66f)
RectF(width * 0.66f, height * 0.33f, width.toFloat(), height * 0.66f) private val blRect = RectF(10F, height * 0.66f, width * 0.33f, height - 10f)
private val bottomLeftRectF = RectF(0F, height * 0.66f, width * 0.33f, height.toFloat()) private val bcRect = RectF(width * 0.33f, height * 0.66f, width * 0.66f, height - 10f)
private val bottomCenterRectF = private val brRect = RectF(width * 0.66f, height * 0.66f, width - 10f, height - 10f)
RectF(width * 0.33f, height * 0.66f, width * 0.66f, height.toFloat()) private val autoPageRect by lazy {
private val bottomRightRectF = Rect()
RectF(width * 0.66f, height * 0.66f, width.toFloat(), height.toFloat()) }
private val autoPageRect by lazy { Rect() }
private val autoPagePint by lazy { private val autoPagePint by lazy {
Paint().apply { Paint().apply {
color = context.accentColor color = context.accentColor
@ -102,15 +101,15 @@ class PageView(context: Context, attrs: AttributeSet) :
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh) super.onSizeChanged(w, h, oldw, oldh)
topLeftRectF.set(0F, 0F, width * 0.33f, height * 0.33f) tlRect.set(10F, 10F, width * 0.33f, height * 0.33f)
topCenterRectF.set(width * 0.33f, 0F, width * 0.66f, height * 0.33f) tcRect.set(width * 0.33f, 10F, width * 0.66f, height * 0.33f)
topRightRectF.set(width * 0.36f, 0F, width.toFloat(), height * 0.33f) trRect.set(width * 0.36f, 10F, width - 10f, height * 0.33f)
middleLeftRectF.set(0F, height * 0.33f, width * 0.33f, height * 0.66f) mlRect.set(10F, height * 0.33f, width * 0.33f, height * 0.66f)
centerRectF.set(width * 0.33f, height * 0.33f, width * 0.66f, height * 0.66f) mcRect.set(width * 0.33f, height * 0.33f, width * 0.66f, height * 0.66f)
middleRightRectF.set(width * 0.66f, height * 0.33f, width.toFloat(), height * 0.66f) mrRect.set(width * 0.66f, height * 0.33f, width - 10f, height * 0.66f)
bottomLeftRectF.set(0F, height * 0.66f, width * 0.33f, height.toFloat()) blRect.set(10F, height * 0.66f, width * 0.33f, height - 10f)
bottomCenterRectF.set(width * 0.33f, height * 0.66f, width * 0.66f, height.toFloat()) bcRect.set(width * 0.33f, height * 0.66f, width * 0.66f, height - 10f)
bottomRightRectF.set(width * 0.66f, height * 0.66f, width.toFloat(), height.toFloat()) brRect.set(width * 0.66f, height * 0.66f, width - 10f, height - 10f)
prevPage.x = -w.toFloat() prevPage.x = -w.toFloat()
pageDelegate?.setViewSize(w, h) pageDelegate?.setViewSize(w, h)
if (oldw != 0 && oldh != 0) { if (oldw != 0 && oldh != 0) {
@ -258,38 +257,37 @@ class PageView(context: Context, attrs: AttributeSet) :
/** /**
* 单击 * 单击
*/ */
private fun onSingleTapUp(): Boolean { private fun onSingleTapUp() {
when { when {
isTextSelected -> isTextSelected = false isTextSelected -> isTextSelected = false
centerRectF.contains(startX, startY) -> if (!isAbortAnim) { mcRect.contains(startX, startY) -> if (!isAbortAnim) {
click(AppConfig.clickActionMiddleCenter) click(AppConfig.clickActionMiddleCenter)
} }
bottomCenterRectF.contains(startX, startY) -> { bcRect.contains(startX, startY) -> {
click(AppConfig.clickActionBottomCenter) click(AppConfig.clickActionBottomCenter)
} }
bottomLeftRectF.contains(startX, startY) -> { blRect.contains(startX, startY) -> {
click(AppConfig.clickActionBottomLeft) click(AppConfig.clickActionBottomLeft)
} }
bottomRightRectF.contains(startX, startY) -> { brRect.contains(startX, startY) -> {
click(AppConfig.clickActionBottomRight) click(AppConfig.clickActionBottomRight)
} }
middleLeftRectF.contains(startX, startY) -> { mlRect.contains(startX, startY) -> {
click(AppConfig.clickActionMiddleLeft) click(AppConfig.clickActionMiddleLeft)
} }
middleRightRectF.contains(startX, startY) -> { mrRect.contains(startX, startY) -> {
click(AppConfig.clickActionMiddleRight) click(AppConfig.clickActionMiddleRight)
} }
topLeftRectF.contains(startX, startY) -> { tlRect.contains(startX, startY) -> {
click(AppConfig.clickActionTopLeft) click(AppConfig.clickActionTopLeft)
} }
topCenterRectF.contains(startX, startY) -> { tcRect.contains(startX, startY) -> {
click(AppConfig.clickActionTopCenter) click(AppConfig.clickActionTopCenter)
} }
topRightRectF.contains(startX, startY) -> { trRect.contains(startX, startY) -> {
click(AppConfig.clickActionTopRight) click(AppConfig.clickActionTopRight)
} }
} }
return true
} }
private fun click(action: Int) { private fun click(action: Int) {
@ -375,6 +373,7 @@ class PageView(context: Context, attrs: AttributeSet) :
} }
override fun upContent(relativePosition: Int, resetPageOffset: Boolean) { override fun upContent(relativePosition: Int, resetPageOffset: Boolean) {
curPage.setContentDescription(pageFactory.curData.textPage.text)
if (isScroll && !callBack.isAutoPage) { if (isScroll && !callBack.isAutoPage) {
curPage.setContent(pageFactory.curData, resetPageOffset) curPage.setContent(pageFactory.curData, resetPageOffset)
} else { } else {

@ -24,7 +24,9 @@ import io.legado.app.utils.getViewModel
import io.legado.app.utils.observeEvent import io.legado.app.utils.observeEvent
import kotlinx.android.synthetic.main.activity_search_content.* import kotlinx.android.synthetic.main.activity_search_content.*
import kotlinx.android.synthetic.main.view_search.* import kotlinx.android.synthetic.main.view_search.*
import kotlinx.coroutines.* import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jetbrains.anko.sdk27.listeners.onClick import org.jetbrains.anko.sdk27.listeners.onClick
@ -34,7 +36,6 @@ class SearchContentActivity :
override val viewModel: SearchContentViewModel override val viewModel: SearchContentViewModel
get() = getViewModel(SearchContentViewModel::class.java) get() = getViewModel(SearchContentViewModel::class.java)
lateinit var adapter: SearchContentAdapter lateinit var adapter: SearchContentAdapter
private lateinit var mLayoutManager: UpLinearLayoutManager private lateinit var mLayoutManager: UpLinearLayoutManager
private var searchResultCounts = 0 private var searchResultCounts = 0
@ -97,14 +98,12 @@ class SearchContentActivity :
@SuppressLint("SetTextI18n") @SuppressLint("SetTextI18n")
private fun initBook() { private fun initBook() {
launch { tv_current_search_info.text = "搜索结果:$searchResultCounts"
tv_current_search_info.text = "搜索结果:$searchResultCounts" viewModel.book?.let {
viewModel.book?.let { initCacheFileNames(it)
initCacheFileNames(it) durChapterIndex = it.durChapterIndex
durChapterIndex = it.durChapterIndex intent.getStringExtra("searchWord")?.let { searchWord ->
intent.getStringExtra("searchWord")?.let { searchWord -> search_view.setQuery(searchWord, true)
search_view.setQuery(searchWord, true)
}
} }
} }
} }
@ -141,14 +140,13 @@ class SearchContentActivity :
var searchResults = listOf<SearchResult>() var searchResults = listOf<SearchResult>()
launch(Dispatchers.Main) { launch(Dispatchers.Main) {
App.db.bookChapterDao().getChapterList(viewModel.bookUrl).map { chapter -> App.db.bookChapterDao().getChapterList(viewModel.bookUrl).map { chapter ->
val job = async(Dispatchers.IO) { withContext(Dispatchers.IO) {
if (isLocalBook if (isLocalBook
|| adapter.cacheFileNames.contains(chapter.getFileName()) || adapter.cacheFileNames.contains(chapter.getFileName())
) { ) {
searchResults = searchChapter(newText, chapter) searchResults = searchChapter(newText, chapter)
} }
} }
job.await()
if (searchResults.isNotEmpty()) { if (searchResults.isNotEmpty()) {
searchResultList.addAll(searchResults) searchResultList.addAll(searchResults)
refresh_progress_bar.isAutoLoading = false refresh_progress_bar.isAutoLoading = false
@ -164,26 +162,27 @@ class SearchContentActivity :
private suspend fun searchChapter(query: String, chapter: BookChapter?): List<SearchResult> { private suspend fun searchChapter(query: String, chapter: BookChapter?): List<SearchResult> {
val searchResults: MutableList<SearchResult> = mutableListOf() val searchResults: MutableList<SearchResult> = mutableListOf()
var positions: List<Int> var positions: List<Int>
var replaceContents: List<String>? = null var replaceContents: List<String>?
var totalContents: String var totalContents: String
if (chapter != null) { if (chapter != null) {
viewModel.book?.let { book -> viewModel.book?.let { book ->
val bookContent = BookHelp.getContent(book, chapter) val bookContent = BookHelp.getContent(book, chapter)
if (bookContent != null) { if (bookContent != null) {
//搜索替换后的正文 //搜索替换后的正文
val job = async(Dispatchers.IO) { withContext(Dispatchers.IO) {
chapter.title = when (AppConfig.chineseConverterType) { chapter.title = when (AppConfig.chineseConverterType) {
1 -> HanLP.convertToSimplifiedChinese(chapter.title) 1 -> HanLP.convertToSimplifiedChinese(chapter.title)
2 -> HanLP.convertToTraditionalChinese(chapter.title) 2 -> HanLP.convertToTraditionalChinese(chapter.title)
else -> chapter.title else -> chapter.title
} }
replaceContents = BookHelp.disposeContent(book, chapter.title, bookContent) replaceContents =
viewModel.contentProcessor!!.getContent(
book,
chapter.title,
bookContent
)
} }
job.await() totalContents = replaceContents?.joinToString("") ?: bookContent
while (replaceContents == null) {
delay(100L)
}
totalContents = replaceContents!!.joinToString("")
positions = searchPosition(totalContents, query) positions = searchPosition(totalContents, query)
var count = 1 var count = 1
positions.map { positions.map {
@ -241,7 +240,6 @@ class SearchContentActivity :
get() = viewModel.book?.isLocalBook() == true get() = viewModel.book?.isLocalBook() == true
override fun openSearchResult(searchResult: SearchResult) { override fun openSearchResult(searchResult: SearchResult) {
val searchData = Intent() val searchData = Intent()
searchData.putExtra("index", searchResult.chapterIndex) searchData.putExtra("index", searchResult.chapterIndex)
searchData.putExtra("contentPosition", searchResult.contentPosition) searchData.putExtra("contentPosition", searchResult.contentPosition)

@ -5,16 +5,21 @@ import android.app.Application
import io.legado.app.App import io.legado.app.App
import io.legado.app.base.BaseViewModel import io.legado.app.base.BaseViewModel
import io.legado.app.data.entities.Book import io.legado.app.data.entities.Book
import io.legado.app.help.ContentProcessor
class SearchContentViewModel(application: Application) : BaseViewModel(application) { class SearchContentViewModel(application: Application) : BaseViewModel(application) {
var bookUrl: String = "" var bookUrl: String = ""
var book: Book? = null var book: Book? = null
var contentProcessor: ContentProcessor? = null
var lastQuery: String = "" var lastQuery: String = ""
fun initBook(bookUrl: String, success: () -> Unit) { fun initBook(bookUrl: String, success: () -> Unit) {
this.bookUrl = bookUrl this.bookUrl = bookUrl
execute { execute {
book = App.db.bookDao().getBook(bookUrl) book = App.db.bookDao().getBook(bookUrl)
book?.let {
contentProcessor = ContentProcessor(it.name, it.origin)
}
}.onSuccess { }.onSuccess {
success.invoke() success.invoke()
} }

@ -68,44 +68,43 @@ class MainViewModel(application: Application) : BaseViewModel(application) {
} }
} }
@Synchronized
private fun updateToc() { private fun updateToc() {
synchronized(this) { bookMap.forEach { bookEntry ->
bookMap.forEach { bookEntry -> if (!updateList.contains(bookEntry.key)) {
if (!updateList.contains(bookEntry.key)) { val book = bookEntry.value
val book = bookEntry.value synchronized(this) {
synchronized(this) { updateList.add(book.bookUrl)
updateList.add(book.bookUrl) postEvent(EventBus.UP_BOOK, book.bookUrl)
postEvent(EventBus.UP_BOOK, book.bookUrl) }
} App.db.bookSourceDao().getBookSource(book.origin)?.let { bookSource ->
App.db.bookSourceDao().getBookSource(book.origin)?.let { bookSource -> val webBook = WebBook(bookSource)
val webBook = WebBook(bookSource) webBook.getChapterList(book, context = upTocPool)
webBook.getChapterList(book, context = upTocPool) .timeout(300000)
.timeout(300000) .onSuccess(IO) {
.onSuccess(IO) { App.db.bookDao().update(book)
App.db.bookDao().update(book) App.db.bookChapterDao().delByBook(book.bookUrl)
App.db.bookChapterDao().delByBook(book.bookUrl) App.db.bookChapterDao().insert(*it.toTypedArray())
App.db.bookChapterDao().insert(*it.toTypedArray()) cacheBook(webBook, book)
cacheBook(webBook, book) }
} .onError {
.onError { it.printStackTrace()
it.printStackTrace() }
} .onFinally {
.onFinally { synchronized(this) {
synchronized(this) { bookMap.remove(bookEntry.key)
bookMap.remove(bookEntry.key) updateList.remove(book.bookUrl)
updateList.remove(book.bookUrl) postEvent(EventBus.UP_BOOK, book.bookUrl)
postEvent(EventBus.UP_BOOK, book.bookUrl) upNext()
upNext()
}
} }
} ?: synchronized(this) { }
bookMap.remove(bookEntry.key) } ?: synchronized(this) {
updateList.remove(book.bookUrl) bookMap.remove(bookEntry.key)
postEvent(EventBus.UP_BOOK, book.bookUrl) updateList.remove(book.bookUrl)
upNext() postEvent(EventBus.UP_BOOK, book.bookUrl)
} upNext()
return
} }
return
} }
} }
} }

@ -19,12 +19,12 @@ import io.legado.app.R
import io.legado.app.base.VMBaseActivity import io.legado.app.base.VMBaseActivity
import io.legado.app.constant.AppPattern import io.legado.app.constant.AppPattern
import io.legado.app.data.entities.ReplaceRule import io.legado.app.data.entities.ReplaceRule
import io.legado.app.help.BookHelp
import io.legado.app.help.IntentDataHelp import io.legado.app.help.IntentDataHelp
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.lib.theme.ATH import io.legado.app.lib.theme.ATH
import io.legado.app.lib.theme.primaryTextColor import io.legado.app.lib.theme.primaryTextColor
import io.legado.app.service.help.ReadBook
import io.legado.app.ui.association.ImportReplaceRuleActivity import io.legado.app.ui.association.ImportReplaceRuleActivity
import io.legado.app.ui.filepicker.FilePicker import io.legado.app.ui.filepicker.FilePicker
import io.legado.app.ui.filepicker.FilePickerDialog import io.legado.app.ui.filepicker.FilePickerDialog
@ -277,7 +277,7 @@ class ReplaceRuleActivity :
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
Coroutine.async { BookHelp.upReplaceRules() } Coroutine.async { ReadBook.contentProcessor?.upReplaceRules() }
} }
override fun upCountView() { override fun upCountView() {

@ -139,6 +139,7 @@ class CircleImageView @JvmOverloads constructor(
DEFAULT_CIRCLE_BACKGROUND_COLOR DEFAULT_CIRCLE_BACKGROUND_COLOR
) )
text = a.getString(R.styleable.CircleImageView_text) text = a.getString(R.styleable.CircleImageView_text)
contentDescription = text
if (a.hasValue(R.styleable.CircleImageView_textColor)) { if (a.hasValue(R.styleable.CircleImageView_textColor)) {
textColor = a.getColor( textColor = a.getColor(
R.styleable.CircleImageView_textColor, R.styleable.CircleImageView_textColor,
@ -226,6 +227,7 @@ class CircleImageView @JvmOverloads constructor(
fun setText(text: String?) { fun setText(text: String?) {
this.text = text this.text = text
contentDescription = text
invalidate() invalidate()
} }

@ -10,6 +10,7 @@ import java.util.regex.Pattern
@Suppress("unused", "MemberVisibilityCanBePrivate") @Suppress("unused", "MemberVisibilityCanBePrivate")
object NetworkUtils { object NetworkUtils {
fun getUrl(response: Response<*>): String { fun getUrl(response: Response<*>): String {
response.raw().networkResponse()?.let { response.raw().networkResponse()?.let {
return it.request().url().toString() return it.request().url().toString()
@ -79,6 +80,7 @@ object NetworkUtils {
fun getAbsoluteURL(baseURL: String?, relativePath: String?): String? { fun getAbsoluteURL(baseURL: String?, relativePath: String?): String? {
if (baseURL.isNullOrEmpty()) return relativePath if (baseURL.isNullOrEmpty()) return relativePath
if (relativePath.isNullOrEmpty()) return baseURL if (relativePath.isNullOrEmpty()) return baseURL
if (relativePath.isAbsUrl()) return baseURL
var relativeUrl = relativePath var relativeUrl = relativePath
try { try {
val absoluteUrl = URL(baseURL.substringBefore(",")) val absoluteUrl = URL(baseURL.substringBefore(","))

@ -23,8 +23,7 @@ fun String.parseToUri(): Uri {
fun String?.isAbsUrl() = fun String?.isAbsUrl() =
this?.let { this?.let {
it.startsWith("http://", true) it.startsWith("http://", true) || it.startsWith("https://", true)
|| it.startsWith("https://", true)
} ?: false } ?: false
fun String?.isJson(): Boolean = fun String?.isJson(): Boolean =

@ -10,6 +10,7 @@
android:id="@+id/vw_menu_bg" android:id="@+id/vw_menu_bg"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:contentDescription="@string/content"
tools:layout_editor_absoluteX="0dp" tools:layout_editor_absoluteX="0dp"
tools:layout_editor_absoluteY="0dp" /> tools:layout_editor_absoluteY="0dp" />
@ -172,19 +173,22 @@
<!--底部设置栏--> <!--底部设置栏-->
<FrameLayout <FrameLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="100dp"> android:layout_height="100dp"
android:importantForAccessibility="no">
<View <View
android:id="@+id/vw_bg" android:id="@+id/vw_bg"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" /> android:layout_height="match_parent"
android:importantForAccessibility="no" />
<LinearLayout <LinearLayout
android:id="@+id/ll_bottom_bg" android:id="@+id/ll_bottom_bg"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:background="@color/background_menu" android:background="@color/background_menu"
android:orientation="vertical"> android:orientation="vertical"
android:importantForAccessibility="no">
<!--章节设置--> <!--章节设置-->
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
@ -193,7 +197,8 @@
android:layout_marginTop="5dp" android:layout_marginTop="5dp"
android:layout_marginRight="20dp" android:layout_marginRight="20dp"
android:layout_marginBottom="5dp" android:layout_marginBottom="5dp"
android:orientation="horizontal"> android:orientation="horizontal"
android:importantForAccessibility="no">
<TextView <TextView
android:id="@+id/tv_pre" android:id="@+id/tv_pre"
@ -239,12 +244,14 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:baselineAligned="false" android:baselineAligned="false"
android:orientation="horizontal"> android:orientation="horizontal"
android:importantForAccessibility="no">
<View <View
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="match_parent" android:layout_height="match_parent"
android:layout_weight="1" /> android:layout_weight="1"
android:importantForAccessibility="no" />
<!--目录按钮--> <!--目录按钮-->
<LinearLayout <LinearLayout
@ -283,7 +290,8 @@
<View <View
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="match_parent" android:layout_height="match_parent"
android:layout_weight="2" /> android:layout_weight="2"
android:importantForAccessibility="no" />
<!--调节按钮--> <!--调节按钮-->
<LinearLayout <LinearLayout
@ -322,7 +330,9 @@
<View <View
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="match_parent" android:layout_height="match_parent"
android:layout_weight="2" /> android:layout_weight="2"
android:importantForAccessibility="no" />
<!--界面按钮--> <!--界面按钮-->
<LinearLayout <LinearLayout
android:id="@+id/ll_font" android:id="@+id/ll_font"
@ -360,7 +370,9 @@
<View <View
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="match_parent" android:layout_height="match_parent"
android:layout_weight="2" /> android:layout_weight="2"
android:importantForAccessibility="no" />
<!--设置按钮--> <!--设置按钮-->
<LinearLayout <LinearLayout
android:id="@+id/ll_setting" android:id="@+id/ll_setting"
@ -398,7 +410,9 @@
<View <View
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="match_parent" android:layout_height="match_parent"
android:layout_weight="1" /> android:layout_weight="1"
android:importantForAccessibility="no" />
</LinearLayout> </LinearLayout>
<View <View

@ -11,7 +11,7 @@ buildscript {
maven { url 'https://plugins.gradle.org/m2/' } maven { url 'https://plugins.gradle.org/m2/' }
} }
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:4.1.0' classpath 'com.android.tools.build:gradle:4.1.1'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath 'de.timfreiheit.resourceplaceholders:placeholders:0.3' classpath 'de.timfreiheit.resourceplaceholders:placeholders:0.3'
classpath 'com.google.gms:google-services:4.3.4' classpath 'com.google.gms:google-services:4.3.4'

Loading…
Cancel
Save