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() }