General improvements (#316)
* Added support for right text alignment in epub reader * Added tabs management to PDF navigation drawer * Implemented unified selection menu placement logic * Added reset functionality for toolbar customization * Improved highlight filtering in epub pagination * Migrated hardcoded UI strings to string resources * Extracted desktop application logic from Main.kt into modular files * Refactored Main.kt by extracting PDF and EPUB logic into specialized files * Added Spanish language support * Added system default option to app language selection
This commit is contained in:
parent
759d4b73a0
commit
056485a140
77 changed files with 9184 additions and 5947 deletions
|
|
@ -0,0 +1,335 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import com.aryan.reader.shared.ReaderLocator
|
||||
import com.aryan.reader.shared.ui.SharedNativeReaderLinkClick
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import java.awt.event.KeyEvent as AwtKeyEvent
|
||||
import java.net.URLDecoder
|
||||
|
||||
internal data class DesktopReaderPosition(
|
||||
val pageIndex: Int,
|
||||
val locator: ReaderLocator?
|
||||
)
|
||||
|
||||
internal data class DesktopReaderHighlightClick(
|
||||
val highlightId: String
|
||||
)
|
||||
|
||||
internal data class DesktopEpubLinkClick(
|
||||
val href: String,
|
||||
val chapterIndex: Int?,
|
||||
val text: String? = null,
|
||||
val chapterId: String? = null,
|
||||
val chapterHref: String? = null,
|
||||
val source: String = "bridge"
|
||||
)
|
||||
|
||||
internal fun SharedNativeReaderLinkClick.toDesktopEpubLinkClick(): DesktopEpubLinkClick {
|
||||
return DesktopEpubLinkClick(
|
||||
href = href,
|
||||
chapterIndex = chapterIndex,
|
||||
text = text,
|
||||
source = "native"
|
||||
)
|
||||
}
|
||||
|
||||
internal data class DesktopEpubHandledLink(
|
||||
val href: String,
|
||||
val handledAtMs: Long
|
||||
)
|
||||
|
||||
internal enum class DesktopReaderSelectionAction {
|
||||
DEFINE,
|
||||
SPEAK,
|
||||
SEARCH
|
||||
}
|
||||
|
||||
internal enum class DesktopReaderKeyNavigation {
|
||||
NEXT,
|
||||
PREVIOUS,
|
||||
FIRST,
|
||||
LAST,
|
||||
SEARCH,
|
||||
NEXT_SEARCH,
|
||||
EXIT_FULLSCREEN
|
||||
}
|
||||
|
||||
internal fun AwtKeyEvent.desktopReaderKeyNavigationOrNull(fullscreen: Boolean): DesktopReaderKeyNavigation? {
|
||||
if (id != AwtKeyEvent.KEY_PRESSED) return null
|
||||
if (fullscreen && keyCode == AwtKeyEvent.VK_ESCAPE) {
|
||||
return DesktopReaderKeyNavigation.EXIT_FULLSCREEN
|
||||
}
|
||||
if (isControlDown && keyCode == AwtKeyEvent.VK_F) {
|
||||
return DesktopReaderKeyNavigation.SEARCH
|
||||
}
|
||||
if (isControlDown && keyCode == AwtKeyEvent.VK_G) {
|
||||
return DesktopReaderKeyNavigation.NEXT_SEARCH
|
||||
}
|
||||
return when (keyCode) {
|
||||
AwtKeyEvent.VK_RIGHT,
|
||||
AwtKeyEvent.VK_PAGE_DOWN -> DesktopReaderKeyNavigation.NEXT
|
||||
AwtKeyEvent.VK_LEFT,
|
||||
AwtKeyEvent.VK_PAGE_UP -> DesktopReaderKeyNavigation.PREVIOUS
|
||||
AwtKeyEvent.VK_HOME -> DesktopReaderKeyNavigation.FIRST
|
||||
AwtKeyEvent.VK_END -> DesktopReaderKeyNavigation.LAST
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
internal data class DesktopReaderSelectionActionPayload(
|
||||
val action: DesktopReaderSelectionAction,
|
||||
val text: String
|
||||
)
|
||||
|
||||
internal fun String.readerHighlightClickOrNull(): DesktopReaderHighlightClick? {
|
||||
fun parse(rawJson: String): DesktopReaderHighlightClick? = runCatching {
|
||||
val obj = Json.parseToJsonElement(rawJson).jsonObject
|
||||
val highlightId = obj["id"]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.jsonPrimitive
|
||||
?.contentOrNull
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: obj["highlightId"]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.jsonPrimitive
|
||||
?.contentOrNull
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: return@runCatching null
|
||||
DesktopReaderHighlightClick(highlightId)
|
||||
}.getOrNull()
|
||||
|
||||
parse(this)?.let { return it }
|
||||
return runCatching {
|
||||
Json.parseToJsonElement(this).jsonPrimitive.contentOrNull
|
||||
}.getOrNull()?.let { parse(it) }
|
||||
}
|
||||
|
||||
internal fun String.readerSelectionActionOrNull(): DesktopReaderSelectionActionPayload? {
|
||||
fun parse(rawJson: String): DesktopReaderSelectionActionPayload? = runCatching {
|
||||
val obj = Json.parseToJsonElement(rawJson).jsonObject
|
||||
val text = obj["text"]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.jsonPrimitive
|
||||
?.contentOrNull
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: return@runCatching null
|
||||
val action = when (
|
||||
obj["action"]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.jsonPrimitive
|
||||
?.contentOrNull
|
||||
?.lowercase()
|
||||
) {
|
||||
"define" -> DesktopReaderSelectionAction.DEFINE
|
||||
"speak" -> DesktopReaderSelectionAction.SPEAK
|
||||
"web-search", "search" -> DesktopReaderSelectionAction.SEARCH
|
||||
else -> return@runCatching null
|
||||
}
|
||||
DesktopReaderSelectionActionPayload(action, text)
|
||||
}.getOrNull()
|
||||
|
||||
parse(this)?.let { return it }
|
||||
return runCatching {
|
||||
Json.parseToJsonElement(this).jsonPrimitive.contentOrNull
|
||||
}.getOrNull()?.let { parse(it) }
|
||||
}
|
||||
|
||||
internal fun String.readerSelectionDebugMessageOrNull(): String? {
|
||||
fun parse(rawJson: String): String? = runCatching {
|
||||
Json.parseToJsonElement(rawJson)
|
||||
.jsonObject["message"]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.jsonPrimitive
|
||||
?.contentOrNull
|
||||
?.takeIf { it.isNotBlank() }
|
||||
}.getOrNull()
|
||||
|
||||
parse(this)?.let { return it }
|
||||
return runCatching {
|
||||
Json.parseToJsonElement(this).jsonPrimitive.contentOrNull
|
||||
}.getOrNull()?.let { parse(it) }
|
||||
}
|
||||
|
||||
internal fun String.readerPaginationLogMessageOrNull(): String? {
|
||||
fun parse(rawJson: String): String? = runCatching {
|
||||
Json.parseToJsonElement(rawJson)
|
||||
.jsonObject["message"]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.jsonPrimitive
|
||||
?.contentOrNull
|
||||
?.takeIf { it.isNotBlank() }
|
||||
}.getOrNull()
|
||||
|
||||
parse(this)?.let { return it }
|
||||
return runCatching {
|
||||
Json.parseToJsonElement(this).jsonPrimitive.contentOrNull
|
||||
}.getOrNull()?.let { parse(it) }
|
||||
}
|
||||
|
||||
internal fun String.readerPositionOrNull(): DesktopReaderPosition? {
|
||||
fun parse(rawJson: String): DesktopReaderPosition? = runCatching {
|
||||
val obj = Json.parseToJsonElement(rawJson).jsonObject
|
||||
val pageIndex = obj["pageIndex"]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.jsonPrimitive
|
||||
?.intOrNull
|
||||
?: return@runCatching null
|
||||
val locator = ReaderLocator(
|
||||
chapterIndex = obj["chapterIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
|
||||
pageIndex = pageIndex,
|
||||
startOffset = obj["startOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
|
||||
endOffset = obj["endOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
|
||||
textQuote = obj["textQuote"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull,
|
||||
cfi = obj["cfi"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull
|
||||
)
|
||||
DesktopReaderPosition(pageIndex, locator)
|
||||
}.getOrNull()
|
||||
|
||||
parse(this)?.let { return it }
|
||||
return runCatching {
|
||||
Json.parseToJsonElement(this).jsonPrimitive.contentOrNull
|
||||
}.getOrNull()?.let { parse(it) }
|
||||
}
|
||||
|
||||
internal fun String.readerKeyNavigationOrNull(): DesktopReaderKeyNavigation? {
|
||||
fun parse(rawJson: String): DesktopReaderKeyNavigation? = runCatching {
|
||||
val action = Json.parseToJsonElement(rawJson)
|
||||
.jsonObject["action"]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.jsonPrimitive
|
||||
?.contentOrNull
|
||||
?: return@runCatching null
|
||||
when (action) {
|
||||
"next" -> DesktopReaderKeyNavigation.NEXT
|
||||
"previous" -> DesktopReaderKeyNavigation.PREVIOUS
|
||||
"first" -> DesktopReaderKeyNavigation.FIRST
|
||||
"last" -> DesktopReaderKeyNavigation.LAST
|
||||
"search" -> DesktopReaderKeyNavigation.SEARCH
|
||||
"nextSearch" -> DesktopReaderKeyNavigation.NEXT_SEARCH
|
||||
"exitFullscreen" -> DesktopReaderKeyNavigation.EXIT_FULLSCREEN
|
||||
else -> null
|
||||
}
|
||||
}.getOrNull()
|
||||
|
||||
parse(this)?.let { return it }
|
||||
return runCatching {
|
||||
Json.parseToJsonElement(this).jsonPrimitive.contentOrNull
|
||||
}.getOrNull()?.let { parse(it) }
|
||||
}
|
||||
|
||||
internal fun String.readerLinkClickOrNull(): DesktopEpubLinkClick? {
|
||||
fun parse(rawJson: String): DesktopEpubLinkClick? = runCatching {
|
||||
val obj = Json.parseToJsonElement(rawJson).jsonObject
|
||||
val href = obj["href"]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.jsonPrimitive
|
||||
?.contentOrNull
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: return@runCatching null
|
||||
DesktopEpubLinkClick(
|
||||
href = href,
|
||||
chapterIndex = obj["chapterIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
|
||||
text = obj["text"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull,
|
||||
chapterId = obj["chapterId"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull,
|
||||
chapterHref = obj["chapterHref"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull
|
||||
)
|
||||
}.getOrNull()
|
||||
|
||||
parse(this)?.let { return it }
|
||||
return runCatching {
|
||||
Json.parseToJsonElement(this).jsonPrimitive.contentOrNull
|
||||
}.getOrNull()?.let { parse(it) }
|
||||
}
|
||||
|
||||
internal fun String.readerLinkClickFromIntercept(): DesktopEpubLinkClick? {
|
||||
val trimmed = trim()
|
||||
if (trimmed.startsWith("readerlink:", ignoreCase = true)) {
|
||||
logEpubLink("request_intercept_readerlink raw=\"${trimmed.logPreview()}\"")
|
||||
val payload = trimmed.substringAfter("?", missingDelimiterValue = "")
|
||||
.split('&')
|
||||
.firstOrNull { it.substringBefore("=").equals("payload", ignoreCase = true) }
|
||||
?.substringAfter("=", missingDelimiterValue = "")
|
||||
?.takeIf { it.isNotBlank() }
|
||||
if (payload == null) {
|
||||
logEpubLink("request_intercept_readerlink_ignored reason=missing_payload")
|
||||
return null
|
||||
}
|
||||
val decoded = runCatching {
|
||||
URLDecoder.decode(payload, Charsets.UTF_8.name())
|
||||
}.getOrElse {
|
||||
logEpubLink("request_intercept_payload_decode_failed error=\"${it.message.orEmpty().logPreview()}\"")
|
||||
return null
|
||||
}
|
||||
val link = decoded.readerLinkClickOrNull()?.copy(source = "request")
|
||||
if (link == null) {
|
||||
logEpubLink("request_intercept_readerlink_ignored reason=parse_failed payload=\"${decoded.logPreview()}\"")
|
||||
}
|
||||
return link
|
||||
}
|
||||
return readerHrefFromIntercept()?.let { href ->
|
||||
DesktopEpubLinkClick(
|
||||
href = href,
|
||||
chapterIndex = null,
|
||||
source = "request"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.readerHrefFromIntercept(): String? {
|
||||
val trimmed = trim()
|
||||
if (trimmed.isBlank()) return null
|
||||
if (trimmed.equals("about:blank", ignoreCase = true)) return null
|
||||
if (trimmed.startsWith("file:///kcefbrowser/", ignoreCase = true)) return null
|
||||
if (trimmed.startsWith("file:/kcefbrowser/", ignoreCase = true)) return null
|
||||
if (trimmed.startsWith("file://", ignoreCase = true)) return null
|
||||
if (trimmed.startsWith("about:blank#", ignoreCase = true)) return "#${trimmed.substringAfter('#')}"
|
||||
if (trimmed.startsWith("data:", ignoreCase = true)) return null
|
||||
if (trimmed.startsWith("blob:", ignoreCase = true)) return null
|
||||
return trimmed
|
||||
}
|
||||
|
||||
internal fun ReaderLocator.toReaderLocatorJson(): String {
|
||||
return buildString {
|
||||
append("{")
|
||||
val values = buildList {
|
||||
chapterIndex?.let { add("\"chapterIndex\":$it") }
|
||||
pageIndex?.let { add("\"pageIndex\":$it") }
|
||||
startOffset?.let { add("\"startOffset\":$it") }
|
||||
endOffset?.let { add("\"endOffset\":$it") }
|
||||
cfi?.let { add("\"cfi\":${it.toJsonStringLiteral()}") }
|
||||
textQuote?.let { add("\"textQuote\":${it.toJsonStringLiteral()}") }
|
||||
}
|
||||
append(values.joinToString(","))
|
||||
append("}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.toJsonStringLiteral(): String {
|
||||
val builder = StringBuilder("\"")
|
||||
forEach { char ->
|
||||
when (char) {
|
||||
'\\' -> builder.append("\\\\")
|
||||
'"' -> builder.append("\\\"")
|
||||
'\n' -> builder.append("\\n")
|
||||
'\r' -> builder.append("\\r")
|
||||
'\t' -> builder.append("\\t")
|
||||
'\b' -> builder.append("\\b")
|
||||
'\u000C' -> builder.append("\\f")
|
||||
else -> {
|
||||
if (char.code < 0x20) {
|
||||
builder.append("\\u")
|
||||
builder.append(char.code.toString(16).padStart(4, '0'))
|
||||
} else {
|
||||
builder.append(char)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
builder.append('"')
|
||||
return builder.toString()
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue