Windows (#291)
* Centralize library management logic and introduce support for plain text and HTML formats * Centralize library management logic and introduce support for plain text and HTML formats * Expand unit test coverage for library state management, UI models, and MainViewModel features. * Add comprehensive unit tests for PDF reader core logic, preferences, and data persistence * Add unit tests for EPUB parsing, content loading, search functionality, and reader JavaScript bridges. * Add unit tests for OPDS parsing and Smart Collection engine, and integrate Kover plugin * Add comprehensive unit tests * Centralize library snapshot serialization in the `shared` module and improve filtering and sorting logic. * Implement text selection, highlighting, and reading state persistence for PDF and EPUB engines in desktop version * Folder import support for desktop app * Introduce Smart Shelves with rule-based filtering in desktop version * Implement shared EPUB annotation serialization and highlight rendering * Centralize file type capabilities and platform-specific support logic * Refactor reader state management to use a central reducer * Implement customizable reader toolbar and advanced formatting settings in shared * Implement locator-based navigation and customizable highlight palette for desktop app * Enhance reader customization and expand search functionality in desktop app * Redesign reader settings and tools into a tabbed control panel in desktop app * Enhance reader navigation and highlight precision in desktop app * Implement bidirectional position synchronization and dynamic highlights in the desktop reader * Implement shared state management and enhanced search for the PDF reader in desktop app * Add vertical scroll support to the desktop PDF reader * Implement ink, text, and eraser annotation support in desktop PDF viewer * Implement PDF bookmarks, Table of Contents, and annotation editing in desktop app * Implement link handling and navigation for PDF and EPUB readers in desktop app * Implement PDF jump history for navigation in desktop app * Enhance PDF ink rendering and annotation capabilities in desktop app * Implement advanced PDF text annotations with inline editing and rich styling in desktop app * Add move handle and movement logic for PDF text annotations in desktop app * Implement local folder synchronization and metadata sidecar support in desktop app * Implement book metadata extraction and drag-and-drop import for Desktop * Implement dynamic and custom app theme management for desktop * Introduce canonical PDF annotation codec and support for multi-segment highlights * Implement rich text editing and pagination support for the PDF reader in desktop app * Improve PDF rich text pagination, synchronization, and observability in desktop * Hide trailing structural page breaks in rich text editor * Implement a unified JVM book loader and expand supported formats on Desktop * Add comic archive support for Desktop and enhance MOBI parsing * Implement shared OPDS catalog support and UI for Android and Desktop * Improve native WebView lifecycle and surface transition management on Desktop * Enable Compose Swing interop blending and simplify Desktop WebView management * Integrate BYOK AI features and Cloud TTS for desktop * Enhance Desktop TTS with streaming audio and improved secure storage for AI key * Implement scoped Cloud TTS with synchronized highlighting for EPUB and PDF in desktop app * Implement custom font management and utility screens in desktop app * Implement PDFium-based PDF annotation export * Remove PdfBox dependency and standardize PDF export via Pdfium * Implement local audio caching and playback controls for Gemini Cloud TTS in desktop app * Implement reader themes and custom texture support in desktop app * Redesign non-reader UI with responsive navigation and enhanced library management in desktop app * Introduce ReaderWorkspaceShell to unify EPUB and PDF reader layouts in desktop app * Exclude manual-only files from automated sync and import * Implement customizable Text-to-Speech (TTS) word replacements * Optimize reader performance with persistent layout caching and decoupled theme rendering * Improve position restoration during reader reconfiguration in epub pagination * Use independent thickness for eraser tool and stylus override
|
|
@ -21,6 +21,14 @@ kotlin {
|
|||
implementation("io.github.kevinnzou:compose-webview-multiplatform:2.0.3")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
||||
implementation("net.java.dev.jna:jna:5.17.0")
|
||||
implementation("org.apache.commons:commons-compress:1.28.0")
|
||||
implementation("org.tukaani:xz:1.10")
|
||||
implementation("com.twelvemonkeys.imageio:imageio-webp:3.13.1")
|
||||
}
|
||||
}
|
||||
val desktopTest by getting {
|
||||
dependencies {
|
||||
implementation(kotlin("test"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,527 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import com.aryan.reader.shared.DEFAULT_CLOUD_TTS_SPEAKER_ID
|
||||
import com.aryan.reader.shared.GEMINI_CLOUD_TTS_MODEL_ID
|
||||
import com.aryan.reader.shared.ReaderAiByokSettings
|
||||
import com.sun.jna.Memory
|
||||
import com.sun.jna.Native
|
||||
import com.sun.jna.Pointer
|
||||
import com.sun.jna.Structure
|
||||
import com.sun.jna.WString
|
||||
import com.sun.jna.ptr.PointerByReference
|
||||
import com.sun.jna.win32.StdCallLibrary
|
||||
import java.io.File
|
||||
import java.util.Base64
|
||||
import java.util.Properties
|
||||
|
||||
private const val WINDOWS_CRED_TYPE_GENERIC = 1
|
||||
private const val WINDOWS_CRED_PERSIST_LOCAL_MACHINE = 2
|
||||
private const val WINDOWS_ERROR_NOT_FOUND = 1168
|
||||
|
||||
internal class DesktopAiByokStore(
|
||||
private val settingsFile: File = defaultSettingsFile(),
|
||||
private val secretCodec: DesktopSecretCodec = DesktopSecretCodec.platform()
|
||||
) {
|
||||
val isSecureStorageAvailable: Boolean
|
||||
get() = secretCodec.isAvailable.also { available ->
|
||||
logDesktopTts("settings_secure_available codec=${secretCodec.name} available=$available")
|
||||
}
|
||||
|
||||
fun load(): ReaderAiByokSettings {
|
||||
logDesktopTts(
|
||||
"settings_load_start file=\"${settingsFile.absolutePath.desktopTtsPreview(220)}\" " +
|
||||
"exists=${settingsFile.exists()} secureStorage=${secretCodec.isAvailable}"
|
||||
)
|
||||
if (!settingsFile.exists()) {
|
||||
logDesktopTts("settings_load_empty reason=file_missing")
|
||||
return ReaderAiByokSettings()
|
||||
}
|
||||
val properties = Properties()
|
||||
return runCatching {
|
||||
settingsFile.inputStream().use(properties::load)
|
||||
val legacyGeminiKey = properties.getProperty(LegacyGeminiKey, "")
|
||||
val legacyGroqKey = properties.getProperty(LegacyGroqKey, "")
|
||||
val loadedSettings = ReaderAiByokSettings(
|
||||
geminiKey = loadSecret(properties, GeminiKey, legacyGeminiKey),
|
||||
groqKey = loadSecret(properties, GroqKey, legacyGroqKey),
|
||||
useOneModel = properties.getProperty("useOneModel", "true").toBooleanStrictOrNull() ?: true,
|
||||
modelForAll = properties.getProperty("modelForAll", ""),
|
||||
defineModel = properties.getProperty("defineModel", ""),
|
||||
summarizeModel = properties.getProperty("summarizeModel", ""),
|
||||
recapModel = properties.getProperty("recapModel", ""),
|
||||
ttsModel = properties.getProperty("ttsModel", ""),
|
||||
hideReaderAiFeatures = properties.getProperty("hideReaderAiFeatures", "false").toBooleanStrictOrNull() ?: false,
|
||||
ttsSpeakerId = properties.getProperty("ttsSpeakerId", DEFAULT_CLOUD_TTS_SPEAKER_ID)
|
||||
).sanitized()
|
||||
val settings = if (loadedSettings.geminiKey.isNotBlank() && loadedSettings.ttsModel.isBlank()) {
|
||||
loadedSettings.copy(ttsModel = GEMINI_CLOUD_TTS_MODEL_ID)
|
||||
} else {
|
||||
loadedSettings
|
||||
}
|
||||
if (secretCodec.isAvailable &&
|
||||
(legacyGeminiKey.isNotBlank() || legacyGroqKey.isNotBlank() || settings != loadedSettings)
|
||||
) {
|
||||
logDesktopTts(
|
||||
"settings_load_migrate legacyGemini=${legacyGeminiKey.isNotBlank()} " +
|
||||
"legacyGroq=${legacyGroqKey.isNotBlank()} autoTtsModel=${settings != loadedSettings}"
|
||||
)
|
||||
runCatching { save(settings) }
|
||||
}
|
||||
logDesktopTts(
|
||||
"settings_load_complete geminiKey=${settings.geminiKey.isNotBlank()} groqKey=${settings.groqKey.isNotBlank()} " +
|
||||
"ttsModel=\"${settings.ttsModel.desktopTtsPreview()}\" cloudAvailable=${settings.isCloudTtsAvailable}"
|
||||
)
|
||||
settings
|
||||
}.getOrElse { error ->
|
||||
logDesktopTts("settings_load_failed error=\"${error.desktopTtsSummary()}\"")
|
||||
ReaderAiByokSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun save(settings: ReaderAiByokSettings) {
|
||||
val sanitized = settings.sanitized()
|
||||
logDesktopTts(
|
||||
"settings_save_start file=\"${settingsFile.absolutePath.desktopTtsPreview(220)}\" " +
|
||||
"secureStorage=${secretCodec.isAvailable} geminiKey=${sanitized.geminiKey.isNotBlank()} " +
|
||||
"groqKey=${sanitized.groqKey.isNotBlank()} ttsModel=\"${sanitized.ttsModel.desktopTtsPreview()}\""
|
||||
)
|
||||
val properties = Properties().apply {
|
||||
setProtectedSecret(GeminiKey, sanitized.geminiKey)
|
||||
setProtectedSecret(GroqKey, sanitized.groqKey)
|
||||
setProperty("useOneModel", sanitized.useOneModel.toString())
|
||||
setProperty("modelForAll", sanitized.modelForAll)
|
||||
setProperty("defineModel", sanitized.defineModel)
|
||||
setProperty("summarizeModel", sanitized.summarizeModel)
|
||||
setProperty("recapModel", sanitized.recapModel)
|
||||
setProperty("ttsModel", sanitized.ttsModel)
|
||||
setProperty("hideReaderAiFeatures", sanitized.hideReaderAiFeatures.toString())
|
||||
setProperty("ttsSpeakerId", sanitized.ttsSpeakerId)
|
||||
}
|
||||
settingsFile.parentFile?.mkdirs()
|
||||
settingsFile.outputStream().use { output ->
|
||||
properties.store(output, "Episteme desktop AI keys and models")
|
||||
}
|
||||
logDesktopTts(
|
||||
"settings_save_complete geminiProtected=${properties.getProperty(GeminiKey, "").isNotBlank()} " +
|
||||
"groqProtected=${properties.getProperty(GroqKey, "").isNotBlank()}"
|
||||
)
|
||||
}
|
||||
|
||||
private fun loadSecret(properties: Properties, key: String, legacyPlaintext: String): String {
|
||||
val protectedValue = properties.getProperty(key, "")
|
||||
val decrypted = protectedValue
|
||||
.takeIf { it.isNotBlank() }
|
||||
?.let {
|
||||
runCatching { secretCodec.unprotect(key, it) }
|
||||
.onFailure { error -> logDesktopTts("settings_secret_unprotect_failed key=$key error=\"${error.desktopTtsSummary()}\"") }
|
||||
.getOrDefault("")
|
||||
}
|
||||
.orEmpty()
|
||||
if (decrypted.isNotBlank()) return decrypted
|
||||
return legacyPlaintext.takeIf { secretCodec.isAvailable }.orEmpty()
|
||||
}
|
||||
|
||||
private fun Properties.setProtectedSecret(key: String, value: String) {
|
||||
val trimmed = value.trim()
|
||||
if (trimmed.isBlank()) {
|
||||
secretCodec.delete(key)
|
||||
return
|
||||
}
|
||||
runCatching { secretCodec.protect(key, trimmed) }
|
||||
.onSuccess { protectedValue ->
|
||||
if (protectedValue.isBlank()) {
|
||||
logDesktopTts("settings_secret_protect_empty key=$key codec=${secretCodec.name}")
|
||||
} else {
|
||||
setProperty(key, protectedValue)
|
||||
logDesktopTts("settings_secret_protect_success key=$key codec=${secretCodec.name} prefix=\"${protectedValue.substringBefore(':', protectedValue)}\"")
|
||||
}
|
||||
}
|
||||
.onFailure { error ->
|
||||
logDesktopTts("settings_secret_protect_failed key=$key codec=${secretCodec.name} error=\"${error.desktopTtsSummary()}\"")
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val GeminiKey = "geminiKeyProtected"
|
||||
private const val GroqKey = "groqKeyProtected"
|
||||
private const val LegacyGeminiKey = "geminiKey"
|
||||
private const val LegacyGroqKey = "groqKey"
|
||||
|
||||
fun defaultSettingsFile(): File {
|
||||
val baseDir = System.getenv("APPDATA")?.takeIf { it.isNotBlank() }
|
||||
?: File(System.getProperty("user.home"), "AppData/Roaming").absolutePath
|
||||
return File(baseDir, "Episteme/ai-byok.properties")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal interface DesktopSecretCodec {
|
||||
val name: String get() = this::class.java.simpleName.ifBlank { "DesktopSecretCodec" }
|
||||
val isAvailable: Boolean
|
||||
fun protect(value: String): String
|
||||
fun unprotect(value: String): String
|
||||
fun protect(keyName: String, value: String): String = protect(value)
|
||||
fun unprotect(keyName: String, value: String): String = unprotect(value)
|
||||
fun delete(keyName: String) = Unit
|
||||
|
||||
companion object {
|
||||
fun platform(): DesktopSecretCodec {
|
||||
val osName = System.getProperty("os.name").orEmpty()
|
||||
val codec = if (osName.startsWith("Windows", ignoreCase = true)) {
|
||||
WindowsSecretCodec
|
||||
} else {
|
||||
UnavailableDesktopSecretCodec
|
||||
}
|
||||
logDesktopTts("settings_platform os=\"${osName.desktopTtsPreview()}\" codec=${codec.name}")
|
||||
return codec
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private object UnavailableDesktopSecretCodec : DesktopSecretCodec {
|
||||
override val name: String = "unavailable"
|
||||
override val isAvailable: Boolean = false
|
||||
override fun protect(value: String): String {
|
||||
throw IllegalStateException("Secure key storage is unavailable on this operating system.")
|
||||
}
|
||||
override fun unprotect(value: String): String = ""
|
||||
}
|
||||
|
||||
private object WindowsSecretCodec : DesktopSecretCodec {
|
||||
override val name: String = "windows"
|
||||
|
||||
override val isAvailable: Boolean
|
||||
get() {
|
||||
val wincred = WindowsCredentialSecretCodec.isAvailable
|
||||
val dpapi = WindowsDpapiSecretCodec.isAvailable
|
||||
logDesktopTts("settings_windows_available wincred=$wincred dpapi=$dpapi")
|
||||
return wincred || dpapi
|
||||
}
|
||||
|
||||
override fun protect(value: String): String {
|
||||
return protect("secret", value)
|
||||
}
|
||||
|
||||
override fun unprotect(value: String): String {
|
||||
return unprotect("secret", value)
|
||||
}
|
||||
|
||||
override fun protect(keyName: String, value: String): String {
|
||||
val wincredFailure = runCatching { return WindowsCredentialSecretCodec.protect(keyName, value) }
|
||||
.exceptionOrNull()
|
||||
?.also { error -> logDesktopTts("settings_wincred_protect_failed key=$keyName error=\"${error.desktopTtsSummary()}\"") }
|
||||
val dpapiFailure = runCatching { return WindowsDpapiSecretCodec.protect(value) }
|
||||
.exceptionOrNull()
|
||||
?.also { error -> logDesktopTts("settings_dpapi_protect_failed key=$keyName error=\"${error.desktopTtsSummary()}\"") }
|
||||
throw IllegalStateException(
|
||||
"No Windows secure key store write succeeded. " +
|
||||
"Credential Manager: ${wincredFailure?.desktopTtsSummary() ?: "not attempted"}; " +
|
||||
"DPAPI: ${dpapiFailure?.desktopTtsSummary() ?: "not attempted"}"
|
||||
)
|
||||
}
|
||||
|
||||
override fun unprotect(keyName: String, value: String): String {
|
||||
return when {
|
||||
value.startsWith(WindowsCredentialSecretCodec.Prefix) -> WindowsCredentialSecretCodec.unprotect(keyName, value)
|
||||
value.startsWith(WindowsDpapiSecretCodec.Prefix) -> WindowsDpapiSecretCodec.unprotect(value)
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
|
||||
override fun delete(keyName: String) {
|
||||
if (WindowsCredentialSecretCodec.isAvailable) WindowsCredentialSecretCodec.delete(keyName)
|
||||
}
|
||||
}
|
||||
|
||||
private object WindowsCredentialSecretCodec : DesktopSecretCodec {
|
||||
override val name: String = "wincred"
|
||||
|
||||
const val Prefix = "wincred:"
|
||||
|
||||
override val isAvailable: Boolean by lazy {
|
||||
val probeKey = "probe"
|
||||
val probe = "episteme-wincred-probe"
|
||||
logDesktopTts("settings_wincred_probe_start")
|
||||
runCatching {
|
||||
val reference = protect(probeKey, probe)
|
||||
val restored = unprotect(probeKey, reference)
|
||||
val matches = restored == probe
|
||||
logDesktopTts(
|
||||
"settings_wincred_probe_result matches=$matches referencePrefix=\"${reference.substringBefore(':', reference)}\" " +
|
||||
"restoredChars=${restored.length}"
|
||||
)
|
||||
matches
|
||||
}.onFailure { error ->
|
||||
logDesktopTts("settings_wincred_unavailable error=\"${error.desktopTtsSummary()}\"")
|
||||
}.also {
|
||||
runCatching { delete(probeKey) }
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
override fun protect(value: String): String {
|
||||
return protect("secret", value)
|
||||
}
|
||||
|
||||
override fun unprotect(value: String): String {
|
||||
return unprotect("secret", value)
|
||||
}
|
||||
|
||||
override fun protect(keyName: String, value: String): String {
|
||||
val target = credentialTarget(keyName)
|
||||
logDesktopTts("settings_wincred_write_start key=$keyName target=\"$target\" valueChars=${value.length}")
|
||||
val credential = WindowsCredential(target, value)
|
||||
credential.write()
|
||||
val ok = Advapi32.INSTANCE.CredWriteW(credential, 0)
|
||||
val errorCode = Native.getLastError()
|
||||
logDesktopTts("settings_wincred_write_result key=$keyName ok=$ok error=$errorCode")
|
||||
if (!ok) throw IllegalStateException("Windows Credential Manager write failed: $errorCode")
|
||||
return Prefix + target
|
||||
}
|
||||
|
||||
override fun unprotect(keyName: String, value: String): String {
|
||||
if (!value.startsWith(Prefix)) return ""
|
||||
val target = value.removePrefix(Prefix).ifBlank { credentialTarget(keyName) }
|
||||
logDesktopTts("settings_wincred_read_start key=$keyName target=\"$target\"")
|
||||
val credentialPointer = PointerByReference()
|
||||
val ok = Advapi32.INSTANCE.CredReadW(WString(target), WINDOWS_CRED_TYPE_GENERIC, 0, credentialPointer)
|
||||
val errorCode = Native.getLastError()
|
||||
logDesktopTts("settings_wincred_read_result key=$keyName ok=$ok error=$errorCode hasPointer=${credentialPointer.value != null}")
|
||||
if (!ok) throw IllegalStateException("Windows Credential Manager read failed: $errorCode")
|
||||
val pointer = credentialPointer.value ?: return ""
|
||||
return try {
|
||||
val credential = WindowsCredential(pointer)
|
||||
val blobPointer = credential.CredentialBlob ?: return ""
|
||||
logDesktopTts("settings_wincred_read_blob key=$keyName bytes=${credential.CredentialBlobSize}")
|
||||
String(blobPointer.getByteArray(0, credential.CredentialBlobSize), Charsets.UTF_8)
|
||||
} finally {
|
||||
Advapi32.INSTANCE.CredFree(pointer)
|
||||
}
|
||||
}
|
||||
|
||||
override fun delete(keyName: String) {
|
||||
val ok = Advapi32.INSTANCE.CredDeleteW(WString(credentialTarget(keyName)), WINDOWS_CRED_TYPE_GENERIC, 0)
|
||||
val errorCode = Native.getLastError()
|
||||
logDesktopTts("settings_wincred_delete_result key=$keyName ok=$ok error=$errorCode")
|
||||
if (!ok && errorCode != WINDOWS_ERROR_NOT_FOUND) {
|
||||
logDesktopTts("settings_wincred_delete_failed key=$keyName error=$errorCode")
|
||||
}
|
||||
}
|
||||
|
||||
private fun credentialTarget(keyName: String): String {
|
||||
return "Episteme.Reader.AI.$keyName"
|
||||
}
|
||||
|
||||
private interface Advapi32 : StdCallLibrary {
|
||||
fun CredWriteW(credential: WindowsCredential, flags: Int): Boolean
|
||||
fun CredReadW(targetName: WString, type: Int, flags: Int, credential: PointerByReference): Boolean
|
||||
fun CredDeleteW(targetName: WString, type: Int, flags: Int): Boolean
|
||||
fun CredFree(buffer: Pointer?)
|
||||
|
||||
companion object {
|
||||
val INSTANCE: Advapi32 by lazy {
|
||||
Native.load("Advapi32", Advapi32::class.java) as Advapi32
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private object WindowsDpapiSecretCodec : DesktopSecretCodec {
|
||||
override val name: String = "dpapi"
|
||||
|
||||
const val Prefix = "dpapi:"
|
||||
|
||||
override val isAvailable: Boolean by lazy {
|
||||
logDesktopTts("settings_dpapi_probe_start")
|
||||
runCatching {
|
||||
Crypt32.INSTANCE
|
||||
Kernel32.INSTANCE
|
||||
val probe = "episteme-dpapi-probe"
|
||||
val encrypted = protect(probe)
|
||||
val restored = unprotect(encrypted)
|
||||
val matches = restored == probe
|
||||
logDesktopTts("settings_dpapi_probe_result matches=$matches encryptedChars=${encrypted.length} restoredChars=${restored.length}")
|
||||
matches
|
||||
}.onFailure { error ->
|
||||
logDesktopTts("settings_dpapi_unavailable error=\"${error.desktopTtsSummary()}\"")
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
override fun protect(value: String): String {
|
||||
val input = DataBlob(value.toByteArray(Charsets.UTF_8))
|
||||
val output = DataBlob()
|
||||
logDesktopTts("settings_dpapi_protect_start bytes=${value.toByteArray(Charsets.UTF_8).size}")
|
||||
val ok = Crypt32.INSTANCE.CryptProtectData(input, null, null, null, null, 0, output)
|
||||
val errorCode = Native.getLastError()
|
||||
logDesktopTts("settings_dpapi_protect_result ok=$ok error=$errorCode")
|
||||
if (!ok) throw IllegalStateException("Windows DPAPI protect failed: $errorCode")
|
||||
return try {
|
||||
Prefix + Base64.getEncoder().encodeToString(output.toByteArray())
|
||||
} finally {
|
||||
output.free()
|
||||
}
|
||||
}
|
||||
|
||||
override fun unprotect(value: String): String {
|
||||
if (!value.startsWith(Prefix)) return ""
|
||||
val encrypted = Base64.getDecoder().decode(value.removePrefix(Prefix))
|
||||
val input = DataBlob(encrypted)
|
||||
val output = DataBlob()
|
||||
logDesktopTts("settings_dpapi_unprotect_start bytes=${encrypted.size}")
|
||||
val ok = Crypt32.INSTANCE.CryptUnprotectData(input, null, null, null, null, 0, output)
|
||||
val errorCode = Native.getLastError()
|
||||
logDesktopTts("settings_dpapi_unprotect_result ok=$ok error=$errorCode")
|
||||
if (!ok) throw IllegalStateException("Windows DPAPI unprotect failed: $errorCode")
|
||||
return try {
|
||||
String(output.toByteArray(), Charsets.UTF_8)
|
||||
} finally {
|
||||
output.free()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Structure.FieldOrder("dwLowDateTime", "dwHighDateTime")
|
||||
internal class WindowsFileTime : Structure() {
|
||||
@JvmField
|
||||
var dwLowDateTime: Int = 0
|
||||
|
||||
@JvmField
|
||||
var dwHighDateTime: Int = 0
|
||||
}
|
||||
|
||||
@Structure.FieldOrder(
|
||||
"Flags",
|
||||
"Type",
|
||||
"TargetName",
|
||||
"Comment",
|
||||
"LastWritten",
|
||||
"CredentialBlobSize",
|
||||
"CredentialBlob",
|
||||
"Persist",
|
||||
"AttributeCount",
|
||||
"Attributes",
|
||||
"TargetAlias",
|
||||
"UserName"
|
||||
)
|
||||
internal open class WindowsCredential : Structure {
|
||||
@JvmField
|
||||
var Flags: Int = 0
|
||||
|
||||
@JvmField
|
||||
var Type: Int = WINDOWS_CRED_TYPE_GENERIC
|
||||
|
||||
@JvmField
|
||||
var TargetName: WString? = null
|
||||
|
||||
@JvmField
|
||||
var Comment: WString? = null
|
||||
|
||||
@JvmField
|
||||
var LastWritten: WindowsFileTime = WindowsFileTime()
|
||||
|
||||
@JvmField
|
||||
var CredentialBlobSize: Int = 0
|
||||
|
||||
@JvmField
|
||||
var CredentialBlob: Pointer? = null
|
||||
|
||||
@JvmField
|
||||
var Persist: Int = WINDOWS_CRED_PERSIST_LOCAL_MACHINE
|
||||
|
||||
@JvmField
|
||||
var AttributeCount: Int = 0
|
||||
|
||||
@JvmField
|
||||
var Attributes: Pointer? = null
|
||||
|
||||
@JvmField
|
||||
var TargetAlias: WString? = null
|
||||
|
||||
@JvmField
|
||||
var UserName: WString? = WString("Episteme")
|
||||
|
||||
private var blobMemory: Memory? = null
|
||||
|
||||
constructor() : super()
|
||||
|
||||
constructor(pointer: Pointer) : super(pointer) {
|
||||
read()
|
||||
}
|
||||
|
||||
constructor(target: String, secret: String) : super() {
|
||||
val bytes = secret.toByteArray(Charsets.UTF_8)
|
||||
TargetName = WString(target)
|
||||
CredentialBlobSize = bytes.size
|
||||
blobMemory = Memory(bytes.size.toLong()).also { memory ->
|
||||
memory.write(0, bytes, 0, bytes.size)
|
||||
CredentialBlob = memory
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Structure.FieldOrder("cbData", "pbData")
|
||||
internal open class DataBlob() : Structure() {
|
||||
@JvmField
|
||||
var cbData: Int = 0
|
||||
|
||||
@JvmField
|
||||
var pbData: Pointer? = null
|
||||
|
||||
private var memory: Memory? = null
|
||||
|
||||
constructor(bytes: ByteArray) : this() {
|
||||
cbData = bytes.size
|
||||
memory = Memory(bytes.size.toLong()).also { allocated ->
|
||||
allocated.write(0, bytes, 0, bytes.size)
|
||||
pbData = allocated
|
||||
}
|
||||
}
|
||||
|
||||
fun toByteArray(): ByteArray {
|
||||
read()
|
||||
return pbData?.getByteArray(0, cbData) ?: ByteArray(0)
|
||||
}
|
||||
|
||||
fun free() {
|
||||
pbData?.let { Kernel32.INSTANCE.LocalFree(it) }
|
||||
pbData = null
|
||||
cbData = 0
|
||||
}
|
||||
}
|
||||
|
||||
private interface Crypt32 : StdCallLibrary {
|
||||
fun CryptProtectData(
|
||||
pDataIn: DataBlob,
|
||||
szDataDescr: String?,
|
||||
pOptionalEntropy: DataBlob?,
|
||||
pvReserved: Pointer?,
|
||||
pPromptStruct: Pointer?,
|
||||
dwFlags: Int,
|
||||
pDataOut: DataBlob
|
||||
): Boolean
|
||||
|
||||
fun CryptUnprotectData(
|
||||
pDataIn: DataBlob,
|
||||
ppszDataDescr: Pointer?,
|
||||
pOptionalEntropy: DataBlob?,
|
||||
pvReserved: Pointer?,
|
||||
pPromptStruct: Pointer?,
|
||||
dwFlags: Int,
|
||||
pDataOut: DataBlob
|
||||
): Boolean
|
||||
|
||||
companion object {
|
||||
val INSTANCE: Crypt32 by lazy {
|
||||
Native.load("Crypt32", Crypt32::class.java) as Crypt32
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private interface Kernel32 : StdCallLibrary {
|
||||
fun LocalFree(hMem: Pointer?): Pointer?
|
||||
|
||||
companion object {
|
||||
val INSTANCE: Kernel32 by lazy {
|
||||
Native.load("Kernel32", Kernel32::class.java) as Kernel32
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,310 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import com.aryan.reader.shared.AiAdapter
|
||||
import com.aryan.reader.shared.AiDefinitionResult
|
||||
import com.aryan.reader.shared.ReaderAiByokSettings
|
||||
import com.aryan.reader.shared.ReaderAiFeature
|
||||
import com.aryan.reader.shared.ReaderByokTextRequest
|
||||
import com.aryan.reader.shared.ReaderByokTextRequestResult
|
||||
import com.aryan.reader.shared.ReaderByokTextRequests
|
||||
import com.aryan.reader.shared.RecapResult
|
||||
import com.aryan.reader.shared.SummarizationResult
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
class DesktopByokAiAdapter(
|
||||
private val settingsProvider: () -> ReaderAiByokSettings
|
||||
) : AiAdapter {
|
||||
override val isAvailable: Boolean
|
||||
get() = settingsProvider().sanitized().areReaderAiFeaturesAvailable
|
||||
|
||||
override suspend fun define(text: String, context: String?): AiDefinitionResult {
|
||||
val result = callTextAi(ReaderAiFeature.DEFINE, text, context)
|
||||
return AiDefinitionResult(definition = result.getOrNull(), error = result.exceptionOrNull()?.message)
|
||||
}
|
||||
|
||||
override suspend fun summarize(text: String): SummarizationResult {
|
||||
val result = callTextAi(ReaderAiFeature.SUMMARIZE, text)
|
||||
return SummarizationResult(summary = result.getOrNull(), error = result.exceptionOrNull()?.message)
|
||||
}
|
||||
|
||||
override suspend fun recap(textBeforeCurrentLocation: String): RecapResult {
|
||||
val result = callTextAi(ReaderAiFeature.RECAP, textBeforeCurrentLocation)
|
||||
return RecapResult(recap = result.getOrNull(), error = result.exceptionOrNull()?.message)
|
||||
}
|
||||
|
||||
suspend fun callTextAi(
|
||||
feature: ReaderAiFeature,
|
||||
text: String,
|
||||
context: String? = null
|
||||
): Result<String> = withContext(Dispatchers.IO) {
|
||||
if (text.isBlank()) return@withContext Result.failure(IllegalArgumentException("There is no text to send."))
|
||||
when (val requestResult = ReaderByokTextRequests.build(settingsProvider(), feature, text, context)) {
|
||||
ReaderByokTextRequestResult.Hidden -> Result.failure(IllegalStateException("Reader AI features are hidden."))
|
||||
is ReaderByokTextRequestResult.MissingKey -> {
|
||||
Result.failure(IllegalStateException("Add a ${requestResult.provider.replaceFirstChar { it.uppercaseChar() }} API key in AI keys and models."))
|
||||
}
|
||||
is ReaderByokTextRequestResult.MissingModel -> {
|
||||
Result.failure(IllegalStateException("Choose a model for ${requestResult.featureName} in AI keys and models."))
|
||||
}
|
||||
is ReaderByokTextRequestResult.Ready -> runCatching {
|
||||
requestResult.request.execute()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ReaderByokTextRequest.execute(): String {
|
||||
var connection: HttpURLConnection? = null
|
||||
try {
|
||||
val url = if (model.provider == "groq") {
|
||||
URL("https://api.groq.com/openai/v1/chat/completions")
|
||||
} else {
|
||||
URL("https://generativelanguage.googleapis.com/v1beta/models/${model.name}:streamGenerateContent?key=$apiKey")
|
||||
}
|
||||
connection = (url.openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = "POST"
|
||||
setRequestProperty("Content-Type", "application/json; charset=UTF-8")
|
||||
setRequestProperty("Accept", "application/json")
|
||||
if (model.provider == "groq") {
|
||||
setRequestProperty("Authorization", "Bearer $apiKey")
|
||||
}
|
||||
connectTimeout = 15_000
|
||||
readTimeout = 120_000
|
||||
doOutput = true
|
||||
doInput = true
|
||||
}
|
||||
val payload = if (model.provider == "groq") buildGroqPayload(this) else buildGeminiPayload(this)
|
||||
connection.outputStream.use { output ->
|
||||
output.write(payload.toByteArray(Charsets.UTF_8))
|
||||
}
|
||||
val responseCode = connection.responseCode
|
||||
if (responseCode != HttpURLConnection.HTTP_OK) {
|
||||
val errorBody = runCatching {
|
||||
connection.errorStream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }
|
||||
}.getOrNull()
|
||||
throw IllegalStateException("AI provider error: $responseCode. ${errorBody.orEmpty().take(300)}")
|
||||
}
|
||||
val text = if (model.provider == "groq") {
|
||||
streamGroqResponse(connection)
|
||||
} else {
|
||||
streamGeminiResponse(connection)
|
||||
}.trim()
|
||||
if (text.isBlank()) throw IllegalStateException("The AI provider returned an empty response.")
|
||||
return text
|
||||
} finally {
|
||||
connection?.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildGroqPayload(request: ReaderByokTextRequest): String {
|
||||
return buildJsonObject {
|
||||
put("model", JsonPrimitive(request.model.name))
|
||||
put(
|
||||
"messages",
|
||||
buildJsonArray {
|
||||
add(buildJsonObject {
|
||||
put("role", JsonPrimitive("system"))
|
||||
put("content", JsonPrimitive(request.systemInstruction))
|
||||
})
|
||||
add(buildJsonObject {
|
||||
put("role", JsonPrimitive("user"))
|
||||
put("content", JsonPrimitive(request.userPrompt))
|
||||
})
|
||||
}
|
||||
)
|
||||
put("temperature", JsonPrimitive(request.temperature))
|
||||
put("top_p", JsonPrimitive(0.95))
|
||||
put("max_tokens", JsonPrimitive(request.maxTokens))
|
||||
put("stream", JsonPrimitive(true))
|
||||
if (request.model.name.contains("qwen")) put("reasoning_effort", JsonPrimitive("none"))
|
||||
}.toString()
|
||||
}
|
||||
|
||||
private fun buildGeminiPayload(request: ReaderByokTextRequest): String {
|
||||
return buildJsonObject {
|
||||
put(
|
||||
"contents",
|
||||
buildJsonArray {
|
||||
add(buildJsonObject {
|
||||
put("parts", buildJsonArray {
|
||||
add(buildJsonObject { put("text", JsonPrimitive(request.userPrompt)) })
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
put(
|
||||
"systemInstruction",
|
||||
buildJsonObject {
|
||||
put("parts", buildJsonArray {
|
||||
add(buildJsonObject { put("text", JsonPrimitive(request.systemInstruction)) })
|
||||
})
|
||||
}
|
||||
)
|
||||
put(
|
||||
"generationConfig",
|
||||
buildJsonObject {
|
||||
put("temperature", JsonPrimitive(request.temperature))
|
||||
put("topP", JsonPrimitive(0.95))
|
||||
put("topK", JsonPrimitive(40))
|
||||
put("maxOutputTokens", JsonPrimitive(request.maxTokens))
|
||||
put("response_mime_type", JsonPrimitive("text/plain"))
|
||||
if (request.model.name.startsWith("gemini")) {
|
||||
put(
|
||||
"thinkingConfig",
|
||||
buildJsonObject { put("thinkingBudget", JsonPrimitive(0)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}.toString()
|
||||
}
|
||||
|
||||
private fun streamGeminiResponse(connection: HttpURLConnection): String {
|
||||
val output = StringBuilder()
|
||||
connection.inputStream.bufferedReader(Charsets.UTF_8).use { reader ->
|
||||
var buffer = ""
|
||||
var line: String?
|
||||
while (reader.readLine().also { line = it } != null) {
|
||||
buffer += line
|
||||
while (true) {
|
||||
val start = buffer.indexOf('{')
|
||||
if (start == -1) {
|
||||
buffer = ""
|
||||
break
|
||||
}
|
||||
var depth = 0
|
||||
var end = -1
|
||||
scan@ for (index in start until buffer.length) {
|
||||
when (buffer[index]) {
|
||||
'{' -> depth++
|
||||
'}' -> {
|
||||
depth--
|
||||
if (depth == 0) {
|
||||
end = index
|
||||
break@scan
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (end == -1) break
|
||||
val jsonObject = buffer.substring(start, end + 1)
|
||||
buffer = buffer.substring(end + 1)
|
||||
val parsed = runCatching { DesktopAiJson.parseToJsonElement(jsonObject).jsonObject }.getOrNull()
|
||||
output.append(parsed.geminiTextChunk())
|
||||
if (parsed.geminiFinishReason() == "SAFETY") {
|
||||
throw IllegalStateException("Blocked for safety reasons.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return output.toString()
|
||||
}
|
||||
|
||||
private fun streamGroqResponse(connection: HttpURLConnection): String {
|
||||
val output = StringBuilder()
|
||||
var inThink = false
|
||||
var thinkBuffer = ""
|
||||
|
||||
fun cleanChunk(text: String): String {
|
||||
thinkBuffer += text
|
||||
val cleaned = StringBuilder()
|
||||
while (true) {
|
||||
if (inThink) {
|
||||
val end = thinkBuffer.indexOf("</think>")
|
||||
if (end == -1) {
|
||||
if (thinkBuffer.length > 7) thinkBuffer = thinkBuffer.takeLast(7)
|
||||
break
|
||||
}
|
||||
inThink = false
|
||||
thinkBuffer = thinkBuffer.substring(end + 8)
|
||||
} else {
|
||||
val start = thinkBuffer.indexOf("<think>")
|
||||
if (start == -1) {
|
||||
if (thinkBuffer.length > 6) {
|
||||
cleaned.append(thinkBuffer.dropLast(6))
|
||||
thinkBuffer = thinkBuffer.takeLast(6)
|
||||
}
|
||||
break
|
||||
}
|
||||
cleaned.append(thinkBuffer.substring(0, start))
|
||||
inThink = true
|
||||
thinkBuffer = thinkBuffer.substring(start + 7)
|
||||
}
|
||||
}
|
||||
return cleaned.toString()
|
||||
}
|
||||
|
||||
connection.inputStream.bufferedReader(Charsets.UTF_8).use { reader ->
|
||||
var line: String?
|
||||
while (reader.readLine().also { line = it } != null) {
|
||||
val trimmed = line!!.trim()
|
||||
if (!trimmed.startsWith("data: ")) continue
|
||||
val data = trimmed.removePrefix("data: ").trim()
|
||||
if (data == "[DONE]") continue
|
||||
val chunk = runCatching {
|
||||
DesktopAiJson.parseToJsonElement(data).jsonObject["choices"]
|
||||
?.jsonArray
|
||||
?.firstOrNull()
|
||||
?.jsonObject
|
||||
?.get("delta")
|
||||
?.jsonObject
|
||||
?.get("content")
|
||||
?.jsonPrimitive
|
||||
?.contentOrNull
|
||||
}.getOrNull().orEmpty()
|
||||
output.append(cleanChunk(chunk))
|
||||
}
|
||||
}
|
||||
if (!inThink && thinkBuffer.isNotBlank()) output.append(thinkBuffer)
|
||||
return output.toString()
|
||||
}
|
||||
}
|
||||
|
||||
private val DesktopAiJson = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private fun JsonObject?.geminiTextChunk(): String {
|
||||
if (this == null) return ""
|
||||
return this["candidates"]
|
||||
?.jsonArrayOrNull()
|
||||
?.firstOrNull()
|
||||
?.jsonObjectOrNull()
|
||||
?.get("content")
|
||||
?.jsonObjectOrNull()
|
||||
?.get("parts")
|
||||
?.jsonArrayOrNull()
|
||||
?.firstOrNull()
|
||||
?.jsonObjectOrNull()
|
||||
?.get("text")
|
||||
?.jsonPrimitiveOrNull()
|
||||
?.contentOrNull
|
||||
.orEmpty()
|
||||
}
|
||||
|
||||
private fun JsonObject?.geminiFinishReason(): String? {
|
||||
if (this == null) return null
|
||||
return this["candidates"]
|
||||
?.jsonArrayOrNull()
|
||||
?.firstOrNull()
|
||||
?.jsonObjectOrNull()
|
||||
?.get("finishReason")
|
||||
?.jsonPrimitiveOrNull()
|
||||
?.contentOrNull
|
||||
}
|
||||
|
||||
private fun JsonElement.jsonObjectOrNull(): JsonObject? = this as? JsonObject
|
||||
private fun JsonElement.jsonArrayOrNull(): JsonArray? = this as? JsonArray
|
||||
private fun JsonElement.jsonPrimitiveOrNull(): JsonPrimitive? = this as? JsonPrimitive
|
||||
|
|
@ -0,0 +1,586 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import com.aryan.reader.shared.FileType
|
||||
import com.aryan.reader.shared.opds.OpdsCatalog
|
||||
import com.aryan.reader.shared.opds.OpdsStreamReference
|
||||
import com.sun.jna.Library
|
||||
import com.sun.jna.Native
|
||||
import com.sun.jna.Pointer
|
||||
import com.sun.jna.ptr.PointerByReference
|
||||
import org.apache.commons.compress.archivers.sevenz.SevenZFile
|
||||
import java.awt.Font
|
||||
import java.awt.Color
|
||||
import java.awt.RenderingHints
|
||||
import java.awt.image.BufferedImage
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.io.OutputStream
|
||||
import java.net.URL
|
||||
import java.nio.file.Files
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.zip.ZipFile
|
||||
import javax.imageio.ImageIO
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
internal object DesktopComicArchive {
|
||||
private val comicTypes = setOf(FileType.CBZ, FileType.CBR, FileType.CB7)
|
||||
private val imageExtensions = setOf("jpg", "jpeg", "png", "webp", "bmp", "gif")
|
||||
|
||||
fun canLoad(type: FileType): Boolean = type in comicTypes
|
||||
|
||||
fun load(file: File, type: FileType): DesktopComicDocument {
|
||||
require(file.isFile) { "Missing comic archive: ${file.absolutePath}" }
|
||||
require(canLoad(type)) { "${type.name} is not a comic archive type." }
|
||||
return when (type) {
|
||||
FileType.CBZ -> loadZip(file)
|
||||
FileType.CBR -> loadRar(file)
|
||||
FileType.CB7 -> loadSevenZ(file)
|
||||
else -> error("${type.name} is not a comic archive type.")
|
||||
}
|
||||
}
|
||||
|
||||
fun loadOpdsStream(
|
||||
path: String,
|
||||
title: String,
|
||||
reference: OpdsStreamReference,
|
||||
catalog: OpdsCatalog?
|
||||
): DesktopComicDocument {
|
||||
val cacheDir = File(
|
||||
DesktopLibraryDatabase.defaultDatabaseFile().parentFile,
|
||||
"opds_stream_cache/${reference.id.hashCode()}"
|
||||
).apply { mkdirs() }
|
||||
val pages = (0 until reference.count).map { pageIndex ->
|
||||
DesktopComicPage(
|
||||
name = "opds_${pageIndex + 1}.jpg",
|
||||
width = 800,
|
||||
height = 1200,
|
||||
source = OpdsStreamComicPageSource(
|
||||
pageIndex = pageIndex,
|
||||
urlTemplate = reference.urlTemplate,
|
||||
catalog = catalog,
|
||||
cacheDir = cacheDir
|
||||
)
|
||||
)
|
||||
}
|
||||
return DesktopComicDocument(
|
||||
path = path,
|
||||
title = title,
|
||||
pages = pages,
|
||||
closeAction = {}
|
||||
)
|
||||
}
|
||||
|
||||
private fun loadZip(file: File): DesktopComicDocument {
|
||||
val zip = ZipFile(file)
|
||||
return try {
|
||||
val pages = zip.entries()
|
||||
.asSequence()
|
||||
.filter { entry -> !entry.isDirectory && entry.name.isComicImageName() }
|
||||
.sortedBy { entry -> entry.name.comicSortKey() }
|
||||
.mapNotNull { entry ->
|
||||
val bytes = zip.getInputStream(entry).use { it.readBytes() }
|
||||
val size = decodeImageSize(bytes) ?: return@mapNotNull null
|
||||
DesktopComicPage(
|
||||
name = entry.name,
|
||||
width = size.first,
|
||||
height = size.second,
|
||||
source = ZipComicPageSource(zip, entry.name)
|
||||
)
|
||||
}
|
||||
.toList()
|
||||
require(pages.isNotEmpty()) { "No readable image pages were found in ${file.name}." }
|
||||
DesktopComicDocument(
|
||||
path = file.absolutePath,
|
||||
title = file.nameWithoutExtension,
|
||||
pages = pages,
|
||||
closeAction = { zip.close() }
|
||||
)
|
||||
} catch (throwable: Throwable) {
|
||||
runCatching { zip.close() }
|
||||
throw throwable
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadRar(file: File): DesktopComicDocument {
|
||||
val nativeResult = runCatching { loadRarWithNativeLibarchive(file) }
|
||||
if (nativeResult.isSuccess) return nativeResult.getOrThrow()
|
||||
|
||||
return runCatching { loadWithArchiveCommand(file) }
|
||||
.getOrElse { commandError ->
|
||||
error(
|
||||
"Could not open CBR with libarchive. " +
|
||||
"Bundle archive.dll/libarchive for desktop, or keep Windows tar/bsdtar available. " +
|
||||
"Native: ${nativeResult.exceptionOrNull()?.shortMessage().orEmpty()} " +
|
||||
"Command: ${commandError.shortMessage()}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadRarWithNativeLibarchive(file: File): DesktopComicDocument {
|
||||
val tempDir = Files.createTempDirectory("reader-comic-").toFile()
|
||||
return try {
|
||||
val extracted = DesktopLibarchive.extractImagePages(file, tempDir, imageExtensions)
|
||||
documentFromExtracted(file, extracted, tempDir)
|
||||
} catch (throwable: Throwable) {
|
||||
runCatching { tempDir.deleteRecursively() }
|
||||
throw throwable
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadSevenZ(file: File): DesktopComicDocument {
|
||||
val tempDir = Files.createTempDirectory("reader-comic-").toFile()
|
||||
val commonsResult = runCatching {
|
||||
val extracted = mutableListOf<ExtractedComicPage>()
|
||||
@Suppress("DEPRECATION")
|
||||
SevenZFile(file).use { archive ->
|
||||
var entry = archive.nextEntry
|
||||
while (entry != null) {
|
||||
val name = entry.name.orEmpty()
|
||||
if (!entry.isDirectory && name.isComicImageName()) {
|
||||
val target = File(tempDir, "page_${extracted.size}.${name.imageExtension()}")
|
||||
target.outputStream().use { output ->
|
||||
archive.copyCurrentEntryTo(output)
|
||||
}
|
||||
extracted += ExtractedComicPage(name = name, file = target)
|
||||
}
|
||||
entry = archive.nextEntry
|
||||
}
|
||||
}
|
||||
documentFromExtracted(file, extracted, tempDir)
|
||||
}
|
||||
if (commonsResult.isSuccess) return commonsResult.getOrThrow()
|
||||
|
||||
runCatching { tempDir.deleteRecursively() }
|
||||
return runCatching { loadWithArchiveCommand(file) }
|
||||
.getOrElse { commandError ->
|
||||
error(
|
||||
"Could not open CB7 with Commons Compress or system tar/bsdtar. " +
|
||||
"Commons: ${commonsResult.exceptionOrNull()?.shortMessage().orEmpty()} " +
|
||||
"Command: ${commandError.shortMessage()}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadWithArchiveCommand(file: File): DesktopComicDocument {
|
||||
val tempDir = Files.createTempDirectory("reader-comic-").toFile()
|
||||
return try {
|
||||
val extracted = DesktopArchiveCommand.extractImagePages(file, tempDir, imageExtensions)
|
||||
documentFromExtracted(file, extracted, tempDir)
|
||||
} catch (throwable: Throwable) {
|
||||
runCatching { tempDir.deleteRecursively() }
|
||||
throw throwable
|
||||
}
|
||||
}
|
||||
|
||||
private fun documentFromExtracted(
|
||||
file: File,
|
||||
extracted: List<ExtractedComicPage>,
|
||||
tempDir: File
|
||||
): DesktopComicDocument {
|
||||
val pages = extracted
|
||||
.sortedBy { page -> page.name.comicSortKey() }
|
||||
.mapNotNull { page ->
|
||||
val bytes = page.file.readBytes()
|
||||
val size = decodeImageSize(bytes) ?: return@mapNotNull null
|
||||
DesktopComicPage(
|
||||
name = page.name,
|
||||
width = size.first,
|
||||
height = size.second,
|
||||
source = FileComicPageSource(page.file)
|
||||
)
|
||||
}
|
||||
require(pages.isNotEmpty()) { "No readable image pages were found in ${file.name}." }
|
||||
return DesktopComicDocument(
|
||||
path = file.absolutePath,
|
||||
title = file.nameWithoutExtension,
|
||||
pages = pages,
|
||||
closeAction = { tempDir.deleteRecursively() }
|
||||
)
|
||||
}
|
||||
|
||||
private fun SevenZFile.copyCurrentEntryTo(output: OutputStream) {
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
while (true) {
|
||||
val read = read(buffer, 0, buffer.size)
|
||||
if (read < 0) break
|
||||
if (read > 0) output.write(buffer, 0, read)
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeImageSize(bytes: ByteArray): Pair<Int, Int>? {
|
||||
val image = ByteArrayInputStream(bytes).use { input ->
|
||||
ImageIO.read(input)
|
||||
} ?: return null
|
||||
return image.width.coerceAtLeast(1) to image.height.coerceAtLeast(1)
|
||||
}
|
||||
|
||||
internal fun String.isComicImageName(): Boolean {
|
||||
return imageExtension() in imageExtensions
|
||||
}
|
||||
|
||||
internal fun String.imageExtension(): String {
|
||||
return substringBefore('?')
|
||||
.substringBefore('#')
|
||||
.substringAfterLast('.', missingDelimiterValue = "img")
|
||||
.lowercase()
|
||||
.takeIf { it in imageExtensions }
|
||||
?: "img"
|
||||
}
|
||||
|
||||
internal fun String.comicSortKey(): String {
|
||||
return replace('\\', '/').lowercase()
|
||||
}
|
||||
|
||||
internal fun Throwable.shortMessage(): String {
|
||||
return message
|
||||
?.replace(Regex("\\s+"), " ")
|
||||
?.take(240)
|
||||
?.ifBlank { null }
|
||||
?: javaClass.simpleName
|
||||
}
|
||||
|
||||
internal data class ExtractedComicPage(
|
||||
val name: String,
|
||||
val file: File
|
||||
)
|
||||
}
|
||||
|
||||
internal class DesktopComicDocument(
|
||||
val path: String,
|
||||
val title: String,
|
||||
pages: List<DesktopComicPage>,
|
||||
private val closeAction: () -> Unit
|
||||
) {
|
||||
private val pages = pages.toList()
|
||||
|
||||
val pageCount: Int = pages.size
|
||||
val pageSizes: List<DesktopPdfPageSize> = pages.map { page ->
|
||||
DesktopPdfPageSize(page.width.toFloat(), page.height.toFloat())
|
||||
}
|
||||
|
||||
fun renderPageBufferedImage(pageIndex: Int, scale: Float): BufferedImage {
|
||||
val page = pages.getOrNull(pageIndex) ?: error("Invalid comic page index $pageIndex.")
|
||||
val sourceImage = ByteArrayInputStream(page.source.readBytes()).use { input ->
|
||||
ImageIO.read(input)
|
||||
} ?: error("Could not decode comic page ${pageIndex + 1}.")
|
||||
val safeScale = scale.takeIf { it.isFinite() && it > 0f } ?: 1f
|
||||
val sourceWidth = sourceImage.width.coerceAtLeast(1)
|
||||
val sourceHeight = sourceImage.height.coerceAtLeast(1)
|
||||
val width = (sourceWidth * safeScale).roundToInt().coerceAtLeast(1)
|
||||
val height = (sourceHeight * safeScale).roundToInt().coerceAtLeast(1)
|
||||
val image = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB)
|
||||
val graphics = image.createGraphics()
|
||||
try {
|
||||
graphics.color = Color.WHITE
|
||||
graphics.fillRect(0, 0, width, height)
|
||||
graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR)
|
||||
graphics.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY)
|
||||
graphics.drawImage(sourceImage, 0, 0, width, height, null)
|
||||
} finally {
|
||||
graphics.dispose()
|
||||
sourceImage.flush()
|
||||
}
|
||||
return image
|
||||
}
|
||||
|
||||
fun close() {
|
||||
closeAction()
|
||||
}
|
||||
}
|
||||
|
||||
internal data class DesktopComicPage(
|
||||
val name: String,
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
val source: ComicPageSource
|
||||
)
|
||||
|
||||
internal interface ComicPageSource {
|
||||
fun readBytes(): ByteArray
|
||||
}
|
||||
|
||||
private class ZipComicPageSource(
|
||||
private val zip: ZipFile,
|
||||
private val entryName: String
|
||||
) : ComicPageSource {
|
||||
override fun readBytes(): ByteArray {
|
||||
val entry = zip.getEntry(entryName) ?: error("Missing comic page entry: $entryName")
|
||||
return zip.getInputStream(entry).use { it.readBytes() }
|
||||
}
|
||||
}
|
||||
|
||||
private class FileComicPageSource(
|
||||
private val file: File
|
||||
) : ComicPageSource {
|
||||
override fun readBytes(): ByteArray = file.readBytes()
|
||||
}
|
||||
|
||||
private class OpdsStreamComicPageSource(
|
||||
private val pageIndex: Int,
|
||||
private val urlTemplate: String,
|
||||
private val catalog: OpdsCatalog?,
|
||||
private val cacheDir: File
|
||||
) : ComicPageSource {
|
||||
override fun readBytes(): ByteArray {
|
||||
val cachedFile = File(cacheDir, "page_$pageIndex.jpg")
|
||||
if (cachedFile.isFile && cachedFile.length() > 0L) {
|
||||
return cachedFile.readBytes()
|
||||
}
|
||||
|
||||
return runCatching {
|
||||
val bytes = DesktopOpdsHttp.fetchBytes(streamPageUrl(), catalog)
|
||||
if (bytes.isNotEmpty()) {
|
||||
cachedFile.writeBytes(bytes)
|
||||
bytes
|
||||
} else {
|
||||
error("Empty OPDS stream page response.")
|
||||
}
|
||||
}.getOrElse {
|
||||
errorPageBytes()
|
||||
}
|
||||
}
|
||||
|
||||
private fun streamPageUrl(): String {
|
||||
return effectiveTemplate()
|
||||
.replace("{pageNumber}", pageIndex.toString())
|
||||
.replace("{page}", pageIndex.toString())
|
||||
.replace("{maxWidth}", "1600")
|
||||
.replace("{maxHeight}", "2400")
|
||||
}
|
||||
|
||||
private fun effectiveTemplate(): String {
|
||||
val catalogUrl = catalog?.url ?: return urlTemplate
|
||||
if (!urlTemplate.startsWith("http", ignoreCase = true)) return urlTemplate
|
||||
return runCatching {
|
||||
val oldUrl = URL(urlTemplate)
|
||||
val newUrl = URL(catalogUrl)
|
||||
val oldBase = "${oldUrl.protocol}://${oldUrl.authority}"
|
||||
val newBase = "${newUrl.protocol}://${newUrl.authority}"
|
||||
urlTemplate.replace(oldBase, newBase)
|
||||
}.getOrDefault(urlTemplate)
|
||||
}
|
||||
|
||||
private fun errorPageBytes(): ByteArray {
|
||||
val image = BufferedImage(800, 1200, BufferedImage.TYPE_INT_RGB)
|
||||
val graphics = image.createGraphics()
|
||||
try {
|
||||
graphics.color = Color.DARK_GRAY
|
||||
graphics.fillRect(0, 0, image.width, image.height)
|
||||
graphics.color = Color.WHITE
|
||||
graphics.font = Font(Font.SANS_SERIF, Font.BOLD, 36)
|
||||
val text = "Page unavailable"
|
||||
val metrics = graphics.fontMetrics
|
||||
graphics.drawString(text, (image.width - metrics.stringWidth(text)) / 2, image.height / 2)
|
||||
} finally {
|
||||
graphics.dispose()
|
||||
}
|
||||
return ByteArrayOutputStream().use { output ->
|
||||
ImageIO.write(image, "jpg", output)
|
||||
output.toByteArray()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private object DesktopArchiveCommand {
|
||||
private const val EXTRACT_TIMEOUT_SECONDS = 60L
|
||||
|
||||
fun extractImagePages(
|
||||
file: File,
|
||||
tempDir: File,
|
||||
imageExtensions: Set<String>
|
||||
): List<DesktopComicArchive.ExtractedComicPage> {
|
||||
val command = resolveCommand()
|
||||
?: error("No tar/bsdtar command was found on PATH.")
|
||||
val names = listArchiveEntries(command, file)
|
||||
.filter { name ->
|
||||
val extension = name.substringBefore('?')
|
||||
.substringBefore('#')
|
||||
.substringAfterLast('.', missingDelimiterValue = "")
|
||||
.lowercase()
|
||||
extension in imageExtensions
|
||||
}
|
||||
.sortedBy { name -> with(DesktopComicArchive) { name.comicSortKey() } }
|
||||
require(names.isNotEmpty()) { "No readable image pages were found in ${file.name}." }
|
||||
return names.mapIndexed { index, name ->
|
||||
val extension = name.substringBefore('?')
|
||||
.substringBefore('#')
|
||||
.substringAfterLast('.', missingDelimiterValue = "img")
|
||||
.lowercase()
|
||||
.ifBlank { "img" }
|
||||
val target = File(tempDir, "page_$index.$extension")
|
||||
extractEntry(command, file, name, target)
|
||||
DesktopComicArchive.ExtractedComicPage(name = name, file = target)
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveCommand(): String? {
|
||||
val override = System.getProperty("reader.archive.command")
|
||||
?: System.getenv("READER_ARCHIVE_COMMAND")
|
||||
return listOfNotNull(override?.takeIf { it.isNotBlank() }, "bsdtar", "tar")
|
||||
.firstOrNull(::isCommandAvailable)
|
||||
}
|
||||
|
||||
private fun isCommandAvailable(command: String): Boolean {
|
||||
return runCatching {
|
||||
val process = ProcessBuilder(command, "--version")
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
process.inputStream.use { it.readBytes() }
|
||||
process.waitFor(5, TimeUnit.SECONDS) && process.exitValue() == 0
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
private fun listArchiveEntries(command: String, file: File): List<String> {
|
||||
val process = ProcessBuilder(command, "-tf", file.absolutePath)
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
val output = process.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() }
|
||||
val finished = process.waitFor(EXTRACT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
if (!finished) {
|
||||
process.destroyForcibly()
|
||||
error("$command timed out while listing ${file.name}.")
|
||||
}
|
||||
if (process.exitValue() != 0) {
|
||||
error(output.ifBlank { "$command could not list ${file.name}." })
|
||||
}
|
||||
return output
|
||||
.lineSequence()
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotBlank() }
|
||||
.toList()
|
||||
}
|
||||
|
||||
private fun extractEntry(command: String, file: File, entryName: String, target: File) {
|
||||
val process = ProcessBuilder(command, "-xOf", file.absolutePath, entryName)
|
||||
.start()
|
||||
val errorText = StringBuilder()
|
||||
val errorThread = Thread {
|
||||
process.errorStream.bufferedReader(Charsets.UTF_8).use { reader ->
|
||||
errorText.append(reader.readText())
|
||||
}
|
||||
}.apply {
|
||||
isDaemon = true
|
||||
start()
|
||||
}
|
||||
process.inputStream.use { input ->
|
||||
target.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
val finished = process.waitFor(EXTRACT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
if (!finished) {
|
||||
process.destroyForcibly()
|
||||
error("$command timed out while extracting $entryName.")
|
||||
}
|
||||
errorThread.join(1000)
|
||||
if (process.exitValue() != 0) {
|
||||
error(errorText.toString().ifBlank { "$command could not extract $entryName." })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private object DesktopLibarchive {
|
||||
private const val ARCHIVE_OK = 0
|
||||
private const val ARCHIVE_EOF = 1
|
||||
private const val ARCHIVE_ENTRY_DIRECTORY = 0x4000
|
||||
private const val BUFFER_SIZE = 128 * 1024
|
||||
|
||||
fun extractImagePages(
|
||||
file: File,
|
||||
tempDir: File,
|
||||
imageExtensions: Set<String>
|
||||
): List<DesktopComicArchive.ExtractedComicPage> {
|
||||
val archive = api.archive_read_new()
|
||||
?: error("libarchive could not allocate a reader.")
|
||||
try {
|
||||
checkArchive(api.archive_read_support_filter_all(archive), archive, "enable archive filters")
|
||||
checkArchive(api.archive_read_support_format_all(archive), archive, "enable archive formats")
|
||||
checkArchive(
|
||||
api.archive_read_open_filename(archive, file.absolutePath, BUFFER_SIZE.toLong()),
|
||||
archive,
|
||||
"open ${file.name}"
|
||||
)
|
||||
|
||||
val pages = mutableListOf<DesktopComicArchive.ExtractedComicPage>()
|
||||
val entryRef = PointerByReference()
|
||||
while (true) {
|
||||
when (val status = api.archive_read_next_header(archive, entryRef)) {
|
||||
ARCHIVE_OK -> Unit
|
||||
ARCHIVE_EOF -> break
|
||||
else -> checkArchive(status, archive, "read archive header")
|
||||
}
|
||||
|
||||
val entry = entryRef.value ?: continue
|
||||
val name = api.archive_entry_pathname_utf8(entry)
|
||||
?: api.archive_entry_pathname(entry)
|
||||
?: continue
|
||||
val extension = name.substringBefore('?')
|
||||
.substringBefore('#')
|
||||
.substringAfterLast('.', missingDelimiterValue = "")
|
||||
.lowercase()
|
||||
if (api.archive_entry_filetype(entry) == ARCHIVE_ENTRY_DIRECTORY || extension !in imageExtensions) {
|
||||
api.archive_read_data_skip(archive)
|
||||
continue
|
||||
}
|
||||
|
||||
val target = File(tempDir, "page_${pages.size}.${extension.ifBlank { "img" }}")
|
||||
target.outputStream().use { output ->
|
||||
val buffer = ByteArray(BUFFER_SIZE)
|
||||
while (true) {
|
||||
val read = api.archive_read_data(archive, buffer, buffer.size.toLong())
|
||||
when {
|
||||
read > 0 -> output.write(buffer, 0, read.toInt())
|
||||
read == 0L -> break
|
||||
else -> checkArchive(read.toInt(), archive, "extract $name")
|
||||
}
|
||||
}
|
||||
}
|
||||
pages += DesktopComicArchive.ExtractedComicPage(name = name, file = target)
|
||||
}
|
||||
return pages.sortedBy { page -> with(DesktopComicArchive) { page.name.comicSortKey() } }
|
||||
} finally {
|
||||
api.archive_read_free(archive)
|
||||
}
|
||||
}
|
||||
|
||||
private val api: LibarchiveLibrary by lazy {
|
||||
val overridePath = System.getProperty("reader.libarchive.path")
|
||||
?: System.getenv("READER_LIBARCHIVE_PATH")
|
||||
val candidates = if (overridePath.isNullOrBlank()) {
|
||||
listOf("archive", "libarchive", "libarchive-13", "libarchive-14")
|
||||
} else {
|
||||
listOf(File(overridePath).absolutePath, overridePath)
|
||||
}
|
||||
|
||||
candidates.firstNotNullOfOrNull { candidate ->
|
||||
runCatching { Native.load(candidate, LibarchiveLibrary::class.java) }
|
||||
.getOrNull()
|
||||
} ?: error(
|
||||
"Native libarchive was not found. Set READER_LIBARCHIVE_PATH/reader.libarchive.path " +
|
||||
"or bundle archive.dll/libarchive for this platform."
|
||||
)
|
||||
}
|
||||
|
||||
private fun checkArchive(status: Int, archive: Pointer, action: String) {
|
||||
if (status >= ARCHIVE_OK) return
|
||||
val message = api.archive_error_string(archive).orEmpty()
|
||||
error("libarchive could not $action: ${message.ifBlank { "error code $status" }}")
|
||||
}
|
||||
|
||||
@Suppress("FunctionName")
|
||||
private interface LibarchiveLibrary : Library {
|
||||
fun archive_read_new(): Pointer?
|
||||
fun archive_read_support_filter_all(archive: Pointer): Int
|
||||
fun archive_read_support_format_all(archive: Pointer): Int
|
||||
fun archive_read_open_filename(archive: Pointer, fileName: String, blockSize: Long): Int
|
||||
fun archive_read_next_header(archive: Pointer, entry: PointerByReference): Int
|
||||
fun archive_read_data(archive: Pointer, buffer: ByteArray, size: Long): Long
|
||||
fun archive_read_data_skip(archive: Pointer): Int
|
||||
fun archive_read_free(archive: Pointer): Int
|
||||
fun archive_error_string(archive: Pointer): String?
|
||||
fun archive_entry_pathname_utf8(entry: Pointer): String?
|
||||
fun archive_entry_pathname(entry: Pointer): String?
|
||||
fun archive_entry_filetype(entry: Pointer): Int
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import com.aryan.reader.shared.CustomFontItem
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import java.io.File
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URLEncoder
|
||||
import java.net.URL
|
||||
import java.util.UUID
|
||||
|
||||
class DesktopCustomFontStore(
|
||||
private val fontsDir: File = defaultFontsDir()
|
||||
) {
|
||||
private var googleFontsCache: List<String>? = null
|
||||
|
||||
fun importFont(source: File, displayNameOverride: String? = null): Result<CustomFontItem> {
|
||||
if (!source.isFile) {
|
||||
return Result.failure(IllegalArgumentException("Choose a font file."))
|
||||
}
|
||||
val extension = source.extension.lowercase()
|
||||
if (extension !in SupportedFontExtensions) {
|
||||
return Result.failure(IllegalArgumentException("Unsupported font format. Use TTF, OTF, or WOFF2."))
|
||||
}
|
||||
|
||||
return runCatching {
|
||||
fontsDir.mkdirs()
|
||||
val fontId = UUID.randomUUID().toString()
|
||||
val fileName = "font_$fontId.$extension"
|
||||
val destination = File(fontsDir, fileName)
|
||||
source.inputStream().use { input ->
|
||||
destination.outputStream().use { output -> input.copyTo(output) }
|
||||
}
|
||||
CustomFontItem(
|
||||
id = fontId,
|
||||
displayName = displayNameOverride?.takeIf { it.isNotBlank() }
|
||||
?: source.nameWithoutExtension.ifBlank { "Imported font" },
|
||||
fileName = fileName,
|
||||
fileExtension = extension,
|
||||
path = destination.absolutePath,
|
||||
timestamp = System.currentTimeMillis()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteFont(font: CustomFontItem): Boolean {
|
||||
val target = runCatching { File(font.path).canonicalFile }.getOrNull() ?: return false
|
||||
val root = runCatching { fontsDir.canonicalFile }.getOrNull() ?: return false
|
||||
val insideFontStore = generateSequence(target) { it.parentFile }.any { it == root }
|
||||
if (!insideFontStore) return false
|
||||
return !target.exists() || target.delete()
|
||||
}
|
||||
|
||||
fun loadGoogleFontsList(): List<String> {
|
||||
googleFontsCache?.let { return it }
|
||||
val loaded = runCatching {
|
||||
val stream = Thread.currentThread().contextClassLoader?.getResourceAsStream(GoogleFontsResource)
|
||||
?: DesktopCustomFontStore::class.java.classLoader?.getResourceAsStream(GoogleFontsResource)
|
||||
?: return@runCatching emptyList()
|
||||
stream.bufferedReader(Charsets.UTF_8).use { reader ->
|
||||
googleFontsFromJson(reader.readText())
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
googleFontsCache = loaded
|
||||
return loaded
|
||||
}
|
||||
|
||||
fun downloadGoogleFont(fontName: String): Result<CustomFontItem> {
|
||||
val normalizedFontName = fontName.trim()
|
||||
if (normalizedFontName.isBlank()) {
|
||||
return Result.failure(IllegalArgumentException("Choose a Google Font."))
|
||||
}
|
||||
|
||||
return runCatching {
|
||||
val encodedName = URLEncoder.encode(normalizedFontName, Charsets.UTF_8.name())
|
||||
val cssConnection = URL("https://fonts.googleapis.com/css?family=$encodedName")
|
||||
.openConnection() as HttpURLConnection
|
||||
cssConnection.setRequestProperty("User-Agent", GoogleFontsSafariUserAgent)
|
||||
cssConnection.connectTimeout = 15_000
|
||||
cssConnection.readTimeout = 15_000
|
||||
|
||||
if (cssConnection.responseCode != HttpURLConnection.HTTP_OK) {
|
||||
throw IllegalStateException("Font '$normalizedFontName' was not found on Google Fonts.")
|
||||
}
|
||||
|
||||
val css = cssConnection.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() }
|
||||
val fontUrl = googleFontDownloadUrlFromCss(css)
|
||||
?: throw IllegalStateException("Could not parse a download link for $normalizedFontName.")
|
||||
val extension = googleFontFileExtension(fontUrl)
|
||||
if (extension !in SupportedFontExtensions) {
|
||||
throw IllegalStateException("Unsupported format ($extension) returned for $normalizedFontName.")
|
||||
}
|
||||
|
||||
val tempFile = File.createTempFile("episteme_google_font_", ".$extension")
|
||||
try {
|
||||
val fontConnection = URL(fontUrl).openConnection() as HttpURLConnection
|
||||
fontConnection.connectTimeout = 15_000
|
||||
fontConnection.readTimeout = 30_000
|
||||
fontConnection.inputStream.use { input ->
|
||||
tempFile.outputStream().use { output -> input.copyTo(output) }
|
||||
}
|
||||
importFont(tempFile, displayNameOverride = normalizedFontName).getOrThrow()
|
||||
} finally {
|
||||
tempFile.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val SupportedFontExtensions = setOf("ttf", "otf", "woff2")
|
||||
private const val GoogleFontsResource = "google_fonts.json"
|
||||
private const val GoogleFontsSafariUserAgent =
|
||||
"Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_6_8; en-us) AppleWebKit/533.21.1 (KHTML, like Gecko) Version/5.0.5 Safari/533.21.1"
|
||||
|
||||
fun defaultFontsDir(): File {
|
||||
val baseDir = System.getenv("APPDATA")?.takeIf { it.isNotBlank() }
|
||||
?: File(System.getProperty("user.home"), "AppData/Roaming").absolutePath
|
||||
return File(baseDir, "Episteme/custom_fonts")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun googleFontDownloadUrlFromCss(css: String): String? {
|
||||
return Regex("""url\((https://[^)]+)\)""")
|
||||
.find(css)
|
||||
?.groupValues
|
||||
?.getOrNull(1)
|
||||
}
|
||||
|
||||
internal fun googleFontFileExtension(fontUrl: String): String {
|
||||
return fontUrl.substringBefore('?')
|
||||
.substringAfterLast('.', "ttf")
|
||||
.lowercase()
|
||||
}
|
||||
|
||||
internal fun googleFontsFromJson(rawJson: String): List<String> {
|
||||
return Json.parseToJsonElement(rawJson)
|
||||
.jsonArray
|
||||
.mapNotNull { element ->
|
||||
runCatching { element.jsonPrimitive.content.trim().takeIf { it.isNotBlank() } }.getOrNull()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,282 +1,12 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.aryan.reader.paginatedreader.CssParser
|
||||
import com.aryan.reader.paginatedreader.OptimizedCssRules
|
||||
import com.aryan.reader.paginatedreader.UserAgentStylesheet
|
||||
import com.aryan.reader.paginatedreader.htmlToSemanticBlocks
|
||||
import com.aryan.reader.shared.FileType
|
||||
import com.aryan.reader.shared.reader.SharedEpubBook
|
||||
import com.aryan.reader.shared.reader.SharedEpubChapter
|
||||
import com.aryan.reader.shared.reader.SharedJvmBookLoader
|
||||
import java.io.File
|
||||
import java.util.Base64
|
||||
import java.util.UUID
|
||||
import java.util.zip.ZipFile
|
||||
|
||||
object DesktopEpubLoader {
|
||||
fun load(file: File): SharedEpubBook {
|
||||
ZipFile(file).use { zip ->
|
||||
val container = zip.readText("META-INF/container.xml")
|
||||
val opfPath = container
|
||||
.substringAfter("full-path=\"", missingDelimiterValue = "")
|
||||
.substringBefore("\"")
|
||||
.ifBlank { error("EPUB container does not point to an OPF package.") }
|
||||
val opf = zip.readText(opfPath)
|
||||
val basePath = opfPath.substringBeforeLast('/', missingDelimiterValue = "")
|
||||
.let { if (it.isBlank()) "" else "$it/" }
|
||||
|
||||
val title = opf.tagText("title").ifBlank { file.nameWithoutExtension }
|
||||
val author = opf.tagText("creator").ifBlank { null }
|
||||
val manifest = parseManifest(opf)
|
||||
val cssByPath = loadCss(zip, manifest, basePath)
|
||||
val cssRules = parseCssRules(cssByPath)
|
||||
val spine = Regex("<itemref[^>]*idref=[\"']([^\"']+)[\"'][^>]*/?>")
|
||||
.findAll(opf)
|
||||
.mapNotNull { match -> manifest[match.groupValues[1]] }
|
||||
.toList()
|
||||
|
||||
val chapterPaths = spine.ifEmpty {
|
||||
manifest.values.filter { it.endsWith(".xhtml", ignoreCase = true) || it.endsWith(".html", ignoreCase = true) }
|
||||
}
|
||||
|
||||
val chapters = chapterPaths.mapIndexedNotNull { index, href ->
|
||||
val path = normalizeZipPath(basePath + href)
|
||||
val html = zip.readTextOrNull(path) ?: return@mapIndexedNotNull null
|
||||
val resourceReadyHtml = html.sanitizeReaderHtml().withEmbeddedResources(zip, path)
|
||||
val text = htmlToText(html)
|
||||
val semanticBlocks = runCatching {
|
||||
htmlToSemanticBlocks(
|
||||
html = resourceReadyHtml,
|
||||
cssRules = cssRules,
|
||||
textStyle = TextStyle(fontSize = 18.sp),
|
||||
chapterAbsPath = path,
|
||||
extractionBasePath = "",
|
||||
density = Density(1f),
|
||||
fontFamilyMap = emptyMap(),
|
||||
constraints = Constraints(maxWidth = 980, maxHeight = 720)
|
||||
)
|
||||
}.getOrElse { emptyList() }
|
||||
if (text.isBlank()) {
|
||||
null
|
||||
} else {
|
||||
SharedEpubChapter(
|
||||
id = "chapter_$index",
|
||||
title = html.tagText("h1")
|
||||
.ifBlank { html.tagText("h2") }
|
||||
.ifBlank { html.tagText("title") }
|
||||
.ifBlank { "Chapter ${index + 1}" },
|
||||
plainText = text,
|
||||
semanticBlocks = semanticBlocks,
|
||||
htmlContent = resourceReadyHtml.extractBodyOrSelf(),
|
||||
baseHref = path.substringBeforeLast('/', missingDelimiterValue = "")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return SharedEpubBook(
|
||||
id = file.absolutePath,
|
||||
fileName = file.name,
|
||||
title = title,
|
||||
author = author,
|
||||
css = cssByPath,
|
||||
chapters = chapters.ifEmpty {
|
||||
listOf(
|
||||
SharedEpubChapter(
|
||||
id = UUID.randomUUID().toString(),
|
||||
title = title,
|
||||
plainText = "This EPUB opened, but no readable spine text was found by the lightweight desktop loader."
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseManifest(opf: String): Map<String, String> {
|
||||
return Regex("<item\\s+[^>]*>").findAll(opf).mapNotNull { match ->
|
||||
val item = match.value
|
||||
val id = item.attr("id")
|
||||
val href = item.attr("href")
|
||||
if (id.isBlank() || href.isBlank()) null else id to href
|
||||
}.toMap()
|
||||
}
|
||||
|
||||
private fun loadCss(zip: ZipFile, manifest: Map<String, String>, basePath: String): Map<String, String> {
|
||||
return manifest.values
|
||||
.filter { it.endsWith(".css", ignoreCase = true) }
|
||||
.mapNotNull { href ->
|
||||
val path = normalizeZipPath(basePath + href)
|
||||
val css = zip.readTextOrNull(path)?.withEmbeddedCssResources(zip, path).orEmpty()
|
||||
if (css.isBlank()) null else path to css
|
||||
}
|
||||
.toMap()
|
||||
}
|
||||
|
||||
private fun parseCssRules(cssByPath: Map<String, String>): OptimizedCssRules {
|
||||
val constraints = Constraints(maxWidth = 980, maxHeight = 720)
|
||||
val baseRules = CssParser.parse(
|
||||
cssContent = UserAgentStylesheet.default,
|
||||
cssPath = null,
|
||||
baseFontSizeSp = 18f,
|
||||
density = 1f,
|
||||
constraints = constraints,
|
||||
isDarkTheme = false
|
||||
).rules
|
||||
|
||||
return cssByPath.entries
|
||||
.fold(baseRules) { rules, (path, css) ->
|
||||
if (css.isBlank()) {
|
||||
rules
|
||||
} else {
|
||||
rules.merge(
|
||||
CssParser.parse(
|
||||
cssContent = css,
|
||||
cssPath = path,
|
||||
baseFontSizeSp = 18f,
|
||||
density = 1f,
|
||||
constraints = constraints,
|
||||
isDarkTheme = false
|
||||
).rules
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ZipFile.readText(path: String): String {
|
||||
val entry = getEntry(path) ?: error("Missing EPUB entry: $path")
|
||||
return getInputStream(entry).bufferedReader().use { it.readText() }
|
||||
}
|
||||
|
||||
private fun ZipFile.readTextOrNull(path: String): String? {
|
||||
val entry = getEntry(path) ?: return null
|
||||
return getInputStream(entry).bufferedReader().use { it.readText() }
|
||||
}
|
||||
|
||||
private fun String.attr(name: String): String {
|
||||
return Regex("""\b$name=["']([^"']+)["']""").find(this)?.groupValues?.get(1).orEmpty()
|
||||
}
|
||||
|
||||
private fun String.tagText(tag: String): String {
|
||||
return Regex("<(?:[^:>]+:)?$tag\\b[^>]*>(.*?)</(?:[^:>]+:)?$tag>", RegexOption.IGNORE_CASE)
|
||||
.find(this)
|
||||
?.groupValues
|
||||
?.get(1)
|
||||
?.let(::htmlToText)
|
||||
.orEmpty()
|
||||
}
|
||||
|
||||
private fun normalizeZipPath(path: String): String {
|
||||
val parts = ArrayDeque<String>()
|
||||
path.split('/').forEach { part ->
|
||||
when (part) {
|
||||
"", "." -> Unit
|
||||
".." -> if (parts.isNotEmpty()) parts.removeLast()
|
||||
else -> parts.addLast(part)
|
||||
}
|
||||
}
|
||||
return parts.joinToString("/")
|
||||
}
|
||||
|
||||
private fun String.withEmbeddedResources(zip: ZipFile, chapterPath: String): String {
|
||||
return replace(Regex("""(?i)\b(src|href)=["']([^"']+)["']""")) { match ->
|
||||
val attr = match.groupValues[1]
|
||||
val raw = match.groupValues[2]
|
||||
if (attr.equals("href", ignoreCase = true) && !raw.looksLikeEmbeddableResource()) {
|
||||
return@replace match.value
|
||||
}
|
||||
val dataUri = zip.toDataUri(raw, chapterPath)
|
||||
if (dataUri != null) "$attr=\"$dataUri\"" else match.value
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.looksLikeEmbeddableResource(): Boolean {
|
||||
return substringBefore('#')
|
||||
.substringBefore('?')
|
||||
.substringAfterLast('.', "")
|
||||
.lowercase() in setOf("css", "jpg", "jpeg", "png", "gif", "svg", "webp", "ttf", "otf", "woff", "woff2")
|
||||
}
|
||||
|
||||
private fun String.sanitizeReaderHtml(): String {
|
||||
return replace(Regex("(?is)<script\\b.*?</script>"), "")
|
||||
.replace(Regex("(?is)<object\\b.*?</object>"), "")
|
||||
.replace(Regex("(?is)<embed\\b[^>]*>"), "")
|
||||
.replace(Regex("""(?i)\s+on[a-z]+\s*=\s*(['"]).*?\1"""), "")
|
||||
}
|
||||
|
||||
private fun String.withEmbeddedCssResources(zip: ZipFile, cssPath: String): String {
|
||||
return replace(Regex("""url\((['"]?)([^)'"]+)\1\)""", RegexOption.IGNORE_CASE)) { match ->
|
||||
val raw = match.groupValues[2].trim()
|
||||
val dataUri = zip.toDataUri(raw, cssPath)
|
||||
if (dataUri != null) "url('$dataUri')" else match.value
|
||||
}
|
||||
}
|
||||
|
||||
private fun ZipFile.toDataUri(rawRef: String, ownerPath: String): String? {
|
||||
val ref = rawRef.substringBefore('#').trim()
|
||||
if (ref.isBlank() || ref.startsWith("data:", ignoreCase = true)) return null
|
||||
if (ref.startsWith("http://", ignoreCase = true) || ref.startsWith("https://", ignoreCase = true)) return null
|
||||
val base = ownerPath.substringBeforeLast('/', missingDelimiterValue = "")
|
||||
val path = normalizeZipPath(if (base.isBlank()) ref else "$base/$ref")
|
||||
val entry = getEntry(path) ?: return null
|
||||
val bytes = getInputStream(entry).use { it.readBytes() }
|
||||
return "data:${mimeType(path)};base64,${Base64.getEncoder().encodeToString(bytes)}"
|
||||
}
|
||||
|
||||
private fun mimeType(path: String): String {
|
||||
return when (path.substringAfterLast('.', "").lowercase()) {
|
||||
"jpg", "jpeg" -> "image/jpeg"
|
||||
"png" -> "image/png"
|
||||
"gif" -> "image/gif"
|
||||
"svg" -> "image/svg+xml"
|
||||
"webp" -> "image/webp"
|
||||
"ttf" -> "font/ttf"
|
||||
"otf" -> "font/otf"
|
||||
"woff" -> "font/woff"
|
||||
"woff2" -> "font/woff2"
|
||||
"css" -> "text/css"
|
||||
"js" -> "text/javascript"
|
||||
else -> "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.extractBodyOrSelf(): String {
|
||||
return Regex("(?is)<body\\b[^>]*>(.*?)</body>")
|
||||
.find(this)
|
||||
?.groupValues
|
||||
?.get(1)
|
||||
?.trim()
|
||||
?: this
|
||||
}
|
||||
|
||||
private fun htmlToText(html: String): String {
|
||||
return html
|
||||
.replace(Regex("(?is)<script.*?</script>"), "")
|
||||
.replace(Regex("(?is)<style.*?</style>"), "")
|
||||
.replace(Regex("(?i)<br\\s*/?>"), "\n")
|
||||
.replace(Regex("(?i)</p\\s*>"), "\n\n")
|
||||
.replace(Regex("(?i)</h[1-6]\\s*>"), "\n\n")
|
||||
.replace(Regex("<[^>]+>"), " ")
|
||||
.decodeEntities()
|
||||
.replace(Regex("[ \\t\\x0B\\f\\r]+"), " ")
|
||||
.replace(Regex(" *\\n *"), "\n")
|
||||
.replace(Regex("\\n{3,}"), "\n\n")
|
||||
.trim()
|
||||
}
|
||||
|
||||
private fun String.decodeEntities(): String {
|
||||
return replace(" ", " ")
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace(Regex("&#x([0-9a-fA-F]+);")) { match ->
|
||||
match.groupValues[1].toIntOrNull(16)?.toChar()?.toString().orEmpty()
|
||||
}
|
||||
.replace(Regex("&#(\\d+);")) { match ->
|
||||
match.groupValues[1].toIntOrNull()?.toChar()?.toString().orEmpty()
|
||||
}
|
||||
return SharedJvmBookLoader.load(file, FileType.EPUB)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,530 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import com.aryan.reader.shared.BookItem
|
||||
import com.aryan.reader.shared.FileType
|
||||
import com.aryan.reader.shared.ReaderPlatform
|
||||
import com.aryan.reader.shared.SharedFileCapabilities
|
||||
import com.aryan.reader.shared.reader.SharedJvmBookLoader
|
||||
import java.awt.Color
|
||||
import java.awt.Font
|
||||
import java.awt.GradientPaint
|
||||
import java.awt.RenderingHints
|
||||
import java.awt.image.BufferedImage
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
import java.util.zip.ZipFile
|
||||
import javax.imageio.ImageIO
|
||||
import kotlin.math.max
|
||||
|
||||
data class DesktopFolderMetadataExtractionResult(
|
||||
val books: List<BookItem>,
|
||||
val stats: DesktopFolderMetadataExtractionStats = DesktopFolderMetadataExtractionStats()
|
||||
)
|
||||
|
||||
data class DesktopFolderMetadataExtractionStats(
|
||||
val processedBooks: Int = 0,
|
||||
val updatedBooks: Int = 0,
|
||||
val coversUpdated: Int = 0,
|
||||
val failedBooks: Int = 0
|
||||
) {
|
||||
operator fun plus(other: DesktopFolderMetadataExtractionStats): DesktopFolderMetadataExtractionStats {
|
||||
return DesktopFolderMetadataExtractionStats(
|
||||
processedBooks = processedBooks + other.processedBooks,
|
||||
updatedBooks = updatedBooks + other.updatedBooks,
|
||||
coversUpdated = coversUpdated + other.coversUpdated,
|
||||
failedBooks = failedBooks + other.failedBooks
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
object DesktopFolderMetadataExtractor {
|
||||
private val textMetadataTypes = setOf(
|
||||
FileType.PDF,
|
||||
FileType.EPUB,
|
||||
FileType.HTML,
|
||||
FileType.MOBI,
|
||||
FileType.FB2,
|
||||
FileType.DOCX,
|
||||
FileType.ODT,
|
||||
FileType.FODT
|
||||
)
|
||||
private val generatedCoverTypes = SharedFileCapabilities.readableTypesFor(ReaderPlatform.DESKTOP)
|
||||
private val rasterCoverExtensions = setOf("jpg", "jpeg", "png", "gif", "webp", "bmp")
|
||||
|
||||
fun enrichFolderBooks(
|
||||
books: List<BookItem>,
|
||||
sourceFolder: String
|
||||
): DesktopFolderMetadataExtractionResult {
|
||||
return enrichBooks(books) { book -> book.sourceFolder == sourceFolder }
|
||||
}
|
||||
|
||||
fun enrichImportedBooks(
|
||||
books: List<BookItem>,
|
||||
importedBookIds: Set<String>
|
||||
): DesktopFolderMetadataExtractionResult {
|
||||
if (importedBookIds.isEmpty()) {
|
||||
return DesktopFolderMetadataExtractionResult(books)
|
||||
}
|
||||
return enrichBooks(books) { book -> book.id in importedBookIds }
|
||||
}
|
||||
|
||||
private fun enrichBooks(
|
||||
books: List<BookItem>,
|
||||
shouldConsider: (BookItem) -> Boolean
|
||||
): DesktopFolderMetadataExtractionResult {
|
||||
var stats = DesktopFolderMetadataExtractionStats()
|
||||
val updatedBooks = books.map { book ->
|
||||
if (!shouldConsider(book) || !book.needsFolderMetadataExtraction()) {
|
||||
return@map book
|
||||
}
|
||||
|
||||
stats = stats.copy(processedBooks = stats.processedBooks + 1)
|
||||
val updated = runCatching { enrichBook(book) }
|
||||
.onFailure { stats = stats.copy(failedBooks = stats.failedBooks + 1) }
|
||||
.getOrDefault(book)
|
||||
|
||||
if (updated != book) {
|
||||
stats = stats.copy(updatedBooks = stats.updatedBooks + 1)
|
||||
if (updated.coverImagePath != book.coverImagePath) {
|
||||
stats = stats.copy(coversUpdated = stats.coversUpdated + 1)
|
||||
}
|
||||
}
|
||||
updated
|
||||
}
|
||||
return DesktopFolderMetadataExtractionResult(updatedBooks, stats)
|
||||
}
|
||||
|
||||
private fun BookItem.needsFolderMetadataExtraction(): Boolean {
|
||||
val path = path?.takeIf { it.isNotBlank() } ?: return false
|
||||
val file = File(path)
|
||||
if (!file.isFile) return false
|
||||
val needsTextMetadata = type in textMetadataTypes && !folderTextMetadataParsed
|
||||
val needsCover = type in generatedCoverTypes && coverImagePath?.let { File(it).isFile } != true
|
||||
return needsTextMetadata || needsCover
|
||||
}
|
||||
|
||||
private fun enrichBook(book: BookItem): BookItem {
|
||||
val file = File(book.path.orEmpty())
|
||||
val size = file.length().takeIf { it > 0L } ?: book.fileSize
|
||||
var title = book.title
|
||||
var author = book.author
|
||||
var textMetadataParsed = book.folderTextMetadataParsed
|
||||
var embeddedCover: EmbeddedCover? = null
|
||||
|
||||
when (book.type) {
|
||||
FileType.EPUB -> {
|
||||
val metadata = parseEpubMetadata(file)
|
||||
title = sanitizeTitle(metadata.title) ?: title
|
||||
author = sanitizeAuthor(metadata.author) ?: author
|
||||
embeddedCover = metadata.cover
|
||||
textMetadataParsed = true
|
||||
}
|
||||
FileType.PDF -> {
|
||||
val metadata = runCatching { DesktopPdfium.extractMetadata(file) }.getOrNull()
|
||||
title = sanitizeTitle(metadata?.title) ?: title
|
||||
author = sanitizeAuthor(metadata?.author) ?: author
|
||||
textMetadataParsed = true
|
||||
}
|
||||
FileType.HTML -> {
|
||||
title = sanitizeTitle(parseHtmlTitle(file)) ?: title
|
||||
textMetadataParsed = true
|
||||
}
|
||||
FileType.MOBI,
|
||||
FileType.FB2,
|
||||
FileType.DOCX,
|
||||
FileType.ODT,
|
||||
FileType.FODT -> {
|
||||
runCatching { SharedJvmBookLoader.load(file, book.type) }
|
||||
.onSuccess { loaded ->
|
||||
title = sanitizeTitle(loaded.title) ?: title
|
||||
author = sanitizeAuthor(loaded.author) ?: author
|
||||
textMetadataParsed = true
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
|
||||
val coverPath = book.coverImagePath?.takeIf { File(it).isFile }
|
||||
?: saveEmbeddedCover(book, embeddedCover)
|
||||
?: renderReaderSurfaceCover(book, file)
|
||||
?: saveGeneratedCover(book)
|
||||
|
||||
return book.copy(
|
||||
title = title ?: file.nameWithoutExtension,
|
||||
author = author,
|
||||
fileSize = size,
|
||||
coverImagePath = coverPath,
|
||||
folderTextMetadataParsed = textMetadataParsed
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseEpubMetadata(file: File): ExtractedBookMetadata {
|
||||
ZipFile(file).use { zip ->
|
||||
val containerXml = zip.readTextOrNull("META-INF/container.xml")
|
||||
val opfPath = containerXml
|
||||
?.let(::parseEpubRootfilePath)
|
||||
?: zip.entries().asSequence()
|
||||
.map { it.name }
|
||||
.firstOrNull { it.endsWith(".opf", ignoreCase = true) }
|
||||
?: return ExtractedBookMetadata()
|
||||
val opf = zip.readTextOrNull(opfPath) ?: return ExtractedBookMetadata()
|
||||
val basePath = opfPath.substringBeforeLast('/', missingDelimiterValue = "")
|
||||
.let { if (it.isBlank()) "" else "$it/" }
|
||||
val manifest = parseEpubManifest(opf)
|
||||
val cover = findEpubCover(opf, manifest)
|
||||
?.takeIf { it.isRasterCover }
|
||||
?.let { item ->
|
||||
val coverPath = normalizeZipPath(basePath + item.href)
|
||||
zip.readBytesOrNull(coverPath)?.let { bytes ->
|
||||
EmbeddedCover(bytes = bytes, extension = item.rasterExtension ?: "png")
|
||||
}
|
||||
}
|
||||
|
||||
return ExtractedBookMetadata(
|
||||
title = opf.tagText("title"),
|
||||
author = opf.tagText("creator"),
|
||||
cover = cover
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseEpubRootfilePath(containerXml: String): String? {
|
||||
return Regex("""<rootfile\b[^>]*\bfull-path=["']([^"']+)["'][^>]*>""", RegexOption.IGNORE_CASE)
|
||||
.find(containerXml)
|
||||
?.groupValues
|
||||
?.get(1)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
private fun parseEpubManifest(opf: String): List<EpubManifestItem> {
|
||||
return Regex("""<item\s+[^>]*>""", RegexOption.IGNORE_CASE)
|
||||
.findAll(opf)
|
||||
.mapNotNull { match ->
|
||||
val item = match.value
|
||||
val id = item.attr("id")
|
||||
val href = item.attr("href")
|
||||
if (id.isBlank() || href.isBlank()) {
|
||||
null
|
||||
} else {
|
||||
EpubManifestItem(
|
||||
id = id,
|
||||
href = href,
|
||||
mediaType = item.attr("media-type"),
|
||||
properties = item.attr("properties")
|
||||
)
|
||||
}
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
|
||||
private fun findEpubCover(opf: String, manifest: List<EpubManifestItem>): EpubManifestItem? {
|
||||
val coverId = Regex("""<meta\s+[^>]*>""", RegexOption.IGNORE_CASE)
|
||||
.findAll(opf)
|
||||
.firstOrNull { it.value.attr("name").equals("cover", ignoreCase = true) }
|
||||
?.value
|
||||
?.attr("content")
|
||||
?.takeIf { it.isNotBlank() }
|
||||
return manifest.firstOrNull { it.id == coverId }
|
||||
?: manifest.firstOrNull { it.properties.split(Regex("\\s+")).any { property -> property == "cover-image" } }
|
||||
?: manifest.firstOrNull { it.isRasterCover && it.href.contains("cover", ignoreCase = true) }
|
||||
?: manifest.firstOrNull { it.isRasterCover && it.href.contains("front", ignoreCase = true) }
|
||||
}
|
||||
|
||||
private fun parseHtmlTitle(file: File): String? {
|
||||
return runCatching {
|
||||
val head = file.inputStream().bufferedReader(Charsets.UTF_8).use { reader ->
|
||||
buildString {
|
||||
var remaining = 64 * 1024
|
||||
val buffer = CharArray(2048)
|
||||
while (remaining > 0) {
|
||||
val read = reader.read(buffer, 0, minOf(buffer.size, remaining))
|
||||
if (read <= 0) break
|
||||
append(buffer, 0, read)
|
||||
remaining -= read
|
||||
if (contains("</title>", ignoreCase = true)) break
|
||||
}
|
||||
}
|
||||
}
|
||||
head.tagText("title")
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun saveEmbeddedCover(book: BookItem, cover: EmbeddedCover?): String? {
|
||||
if (cover == null || cover.bytes.isEmpty()) return null
|
||||
val extension = cover.extension.takeIf { it in rasterCoverExtensions } ?: return null
|
||||
return runCatching {
|
||||
deleteExistingCoverFiles(book)
|
||||
val target = coverCacheFile(book, extension)
|
||||
target.parentFile?.mkdirs()
|
||||
val temp = File(target.parentFile, "${target.name}.tmp")
|
||||
temp.writeBytes(cover.bytes)
|
||||
Files.move(temp.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING)
|
||||
target.absolutePath
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun renderReaderSurfaceCover(book: BookItem, file: File): String? {
|
||||
if (book.type == FileType.PDF && !DesktopPdfium.isAvailable()) return null
|
||||
if (book.type != FileType.PDF && !DesktopComicArchive.canLoad(book.type)) return null
|
||||
return runCatching {
|
||||
val document = if (book.type == FileType.PDF) {
|
||||
DesktopPdfium.load(file)
|
||||
} else {
|
||||
DesktopPdfium.loadComic(file, book.type)
|
||||
}
|
||||
try {
|
||||
if (document.pageCount <= 0) {
|
||||
null
|
||||
} else {
|
||||
val firstPage = document.pageSizes.first()
|
||||
val scale = 800f / firstPage.height.coerceAtLeast(1f)
|
||||
val image = DesktopPdfium.renderPageBufferedImage(
|
||||
document = document,
|
||||
pageIndex = 0,
|
||||
scale = scale,
|
||||
renderAnnotations = false
|
||||
)
|
||||
saveCoverImage(book, image)
|
||||
}
|
||||
} finally {
|
||||
document.close()
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun saveGeneratedCover(book: BookItem): String? {
|
||||
if (book.type !in generatedCoverTypes) return null
|
||||
return saveCoverImage(book, generatedCoverImage(book))
|
||||
}
|
||||
|
||||
private fun saveCoverImage(book: BookItem, image: BufferedImage): String? {
|
||||
return runCatching {
|
||||
deleteExistingCoverFiles(book)
|
||||
val target = coverCacheFile(book, "png")
|
||||
target.parentFile?.mkdirs()
|
||||
val temp = File(target.parentFile, "${target.name}.tmp")
|
||||
ImageIO.write(image, "png", temp)
|
||||
Files.move(temp.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING)
|
||||
target.absolutePath
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun generatedCoverImage(book: BookItem): BufferedImage {
|
||||
val width = 480
|
||||
val height = 720
|
||||
val image = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB)
|
||||
val base = coverColor(book.type)
|
||||
val title = book.title?.takeIf { it.isNotBlank() }
|
||||
?: book.displayName.substringBeforeLast('.', missingDelimiterValue = book.displayName)
|
||||
val author = book.author?.takeIf { it.isNotBlank() }
|
||||
|
||||
val g = image.createGraphics()
|
||||
try {
|
||||
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
|
||||
g.paint = GradientPaint(0f, 0f, base.brighter(), 0f, height.toFloat(), base.darker())
|
||||
g.fillRect(0, 0, width, height)
|
||||
|
||||
g.color = Color(255, 255, 255, 36)
|
||||
g.fillRoundRect(42, 42, width - 84, height - 84, 36, 36)
|
||||
g.color = Color(255, 255, 255, 210)
|
||||
g.font = Font("SansSerif", Font.BOLD, 34)
|
||||
g.drawString(book.type.name, 64, 104)
|
||||
|
||||
g.font = Font("Serif", Font.BOLD, 48)
|
||||
val titleLines = wrapText(title, g.fontMetrics, width - 128, maxLines = 6)
|
||||
var y = 250
|
||||
titleLines.forEach { line ->
|
||||
g.drawString(line, 64, y)
|
||||
y += 58
|
||||
}
|
||||
|
||||
g.font = Font("SansSerif", Font.PLAIN, 28)
|
||||
val footer = author ?: book.displayName
|
||||
val footerLines = wrapText(footer, g.fontMetrics, width - 128, maxLines = 2)
|
||||
val footerStart = max(y + 40, height - 150)
|
||||
footerLines.forEachIndexed { index, line ->
|
||||
g.drawString(line, 64, footerStart + index * 34)
|
||||
}
|
||||
} finally {
|
||||
g.dispose()
|
||||
}
|
||||
return image
|
||||
}
|
||||
|
||||
private fun wrapText(text: String, metrics: java.awt.FontMetrics, maxWidth: Int, maxLines: Int): List<String> {
|
||||
val words = text.replace(Regex("\\s+"), " ").trim().split(' ').filter { it.isNotBlank() }
|
||||
if (words.isEmpty()) return listOf("Untitled")
|
||||
val lines = mutableListOf<String>()
|
||||
var current = ""
|
||||
|
||||
for (word in words) {
|
||||
val candidate = if (current.isBlank()) word else "$current $word"
|
||||
if (metrics.stringWidth(candidate) <= maxWidth) {
|
||||
current = candidate
|
||||
} else {
|
||||
if (current.isNotBlank()) lines += current
|
||||
current = trimToWidth(word, metrics, maxWidth)
|
||||
}
|
||||
if (lines.size == maxLines) break
|
||||
}
|
||||
if (lines.size < maxLines && current.isNotBlank()) lines += current
|
||||
return lines.take(maxLines)
|
||||
}
|
||||
|
||||
private fun trimToWidth(text: String, metrics: java.awt.FontMetrics, maxWidth: Int): String {
|
||||
if (metrics.stringWidth(text) <= maxWidth) return text
|
||||
var candidate = text
|
||||
while (candidate.length > 1 && metrics.stringWidth("$candidate...") > maxWidth) {
|
||||
candidate = candidate.dropLast(1)
|
||||
}
|
||||
return "$candidate..."
|
||||
}
|
||||
|
||||
private fun coverColor(type: FileType): Color {
|
||||
return when (type) {
|
||||
FileType.PDF -> Color(156, 65, 70)
|
||||
FileType.EPUB -> Color(0, 108, 76)
|
||||
FileType.CBZ, FileType.CBR, FileType.CB7 -> Color(112, 93, 73)
|
||||
FileType.MD -> Color(83, 101, 120)
|
||||
FileType.HTML -> Color(122, 87, 42)
|
||||
FileType.TXT -> Color(74, 92, 112)
|
||||
else -> Color(93, 107, 130)
|
||||
}
|
||||
}
|
||||
|
||||
private fun coverCacheFile(book: BookItem, extension: String): File {
|
||||
val key = book.path?.takeIf { it.isNotBlank() } ?: book.id
|
||||
val hash = Integer.toUnsignedString(key.hashCode())
|
||||
return File(coverCacheDir(), "cover_$hash.$extension")
|
||||
}
|
||||
|
||||
private fun deleteExistingCoverFiles(book: BookItem) {
|
||||
val key = book.path?.takeIf { it.isNotBlank() } ?: book.id
|
||||
val hash = Integer.toUnsignedString(key.hashCode())
|
||||
coverCacheDir().listFiles()
|
||||
?.filter { it.isFile && it.name.startsWith("cover_$hash.") }
|
||||
?.forEach { runCatching { it.delete() } }
|
||||
}
|
||||
|
||||
private fun coverCacheDir(): File {
|
||||
val overridePath = System.getProperty("reader.cover.cache.dir")
|
||||
?: System.getenv("READER_COVER_CACHE_DIR")
|
||||
if (!overridePath.isNullOrBlank()) {
|
||||
return File(overridePath).apply { mkdirs() }
|
||||
}
|
||||
val root = DesktopLibraryDatabase.defaultDatabaseFile().parentFile
|
||||
?: File(System.getProperty("user.home"), "AppData/Roaming/Episteme")
|
||||
return File(root, "cover_cache").apply { mkdirs() }
|
||||
}
|
||||
|
||||
private fun ZipFile.readTextOrNull(path: String): String? {
|
||||
val entry = getEntry(path) ?: return null
|
||||
return getInputStream(entry).bufferedReader(Charsets.UTF_8).use { it.readText() }
|
||||
}
|
||||
|
||||
private fun ZipFile.readBytesOrNull(path: String): ByteArray? {
|
||||
val entry = getEntry(path) ?: return null
|
||||
return getInputStream(entry).use { it.readBytes() }
|
||||
}
|
||||
|
||||
private fun String.attr(name: String): String {
|
||||
return Regex("""\b$name=["']([^"']+)["']""", RegexOption.IGNORE_CASE)
|
||||
.find(this)
|
||||
?.groupValues
|
||||
?.get(1)
|
||||
.orEmpty()
|
||||
}
|
||||
|
||||
private fun String.tagText(tag: String): String {
|
||||
return Regex(
|
||||
"<(?:[^:>]+:)?$tag\\b[^>]*>(.*?)</(?:[^:>]+:)?$tag>",
|
||||
setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)
|
||||
)
|
||||
.find(this)
|
||||
?.groupValues
|
||||
?.get(1)
|
||||
?.replace(Regex("<[^>]+>"), " ")
|
||||
?.decodeEntities()
|
||||
?.replace(Regex("\\s+"), " ")
|
||||
?.trim()
|
||||
.orEmpty()
|
||||
}
|
||||
|
||||
private fun String.decodeEntities(): String {
|
||||
return replace(" ", " ")
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace(Regex("&#x([0-9a-fA-F]+);")) { match ->
|
||||
match.groupValues[1].toIntOrNull(16)?.toChar()?.toString().orEmpty()
|
||||
}
|
||||
.replace(Regex("&#(\\d+);")) { match ->
|
||||
match.groupValues[1].toIntOrNull()?.toChar()?.toString().orEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalizeZipPath(path: String): String {
|
||||
val parts = ArrayDeque<String>()
|
||||
path.split('/').forEach { part ->
|
||||
when (part) {
|
||||
"", "." -> Unit
|
||||
".." -> if (parts.isNotEmpty()) parts.removeLast()
|
||||
else -> parts.addLast(part)
|
||||
}
|
||||
}
|
||||
return parts.joinToString("/")
|
||||
}
|
||||
|
||||
private fun sanitizeTitle(value: String?): String? {
|
||||
return value
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() && !it.equals("content", ignoreCase = true) }
|
||||
}
|
||||
|
||||
private fun sanitizeAuthor(value: String?): String? {
|
||||
return value
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
|
||||
}
|
||||
|
||||
private val EpubManifestItem.isRasterCover: Boolean
|
||||
get() = rasterExtension != null
|
||||
|
||||
private val EpubManifestItem.rasterExtension: String?
|
||||
get() {
|
||||
val extension = href.substringBefore('?')
|
||||
.substringBefore('#')
|
||||
.substringAfterLast('.', missingDelimiterValue = "")
|
||||
.lowercase()
|
||||
if (extension in rasterCoverExtensions) return extension
|
||||
return when {
|
||||
mediaType.equals("image/jpeg", ignoreCase = true) -> "jpg"
|
||||
mediaType.equals("image/png", ignoreCase = true) -> "png"
|
||||
mediaType.equals("image/gif", ignoreCase = true) -> "gif"
|
||||
mediaType.equals("image/webp", ignoreCase = true) -> "webp"
|
||||
mediaType.equals("image/bmp", ignoreCase = true) -> "bmp"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private data class ExtractedBookMetadata(
|
||||
val title: String? = null,
|
||||
val author: String? = null,
|
||||
val cover: EmbeddedCover? = null
|
||||
)
|
||||
|
||||
private data class EmbeddedCover(
|
||||
val bytes: ByteArray,
|
||||
val extension: String
|
||||
)
|
||||
|
||||
private data class EpubManifestItem(
|
||||
val id: String,
|
||||
val href: String,
|
||||
val mediaType: String,
|
||||
val properties: String
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,732 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import com.aryan.reader.shared.GEMINI_CLOUD_TTS_MODEL
|
||||
import com.aryan.reader.shared.ReaderAiByokSettings
|
||||
import com.aryan.reader.shared.ReaderTtsCacheSummary
|
||||
import com.aryan.reader.shared.ReaderTtsChunk
|
||||
import com.aryan.reader.shared.ReaderTtsFileCacheManager
|
||||
import com.aryan.reader.shared.ReaderTtsReadScope
|
||||
import com.aryan.reader.shared.TtsAdapter
|
||||
import com.aryan.reader.shared.createReaderTtsWavHeaderUnknownLength
|
||||
import com.aryan.reader.shared.patchReaderTtsWavHeader
|
||||
import com.aryan.reader.shared.splitReaderTextIntoTtsChunks
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.selects.select
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.net.URI
|
||||
import java.net.URLEncoder
|
||||
import java.net.http.HttpClient
|
||||
import java.net.http.WebSocket
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.Base64
|
||||
import java.util.concurrent.CompletableFuture
|
||||
import java.util.concurrent.CompletionStage
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import javax.sound.sampled.AudioFormat
|
||||
import javax.sound.sampled.AudioSystem
|
||||
import javax.sound.sampled.SourceDataLine
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
private data class DesktopTtsSequenceChunk(
|
||||
val text: String,
|
||||
val chapterTitle: String?
|
||||
)
|
||||
|
||||
class DesktopGeminiCloudTtsAdapter(
|
||||
private val settingsProvider: () -> ReaderAiByokSettings,
|
||||
private val httpClient: HttpClient = HttpClient.newHttpClient(),
|
||||
private val cacheManager: ReaderTtsFileCacheManager = ReaderTtsFileCacheManager(defaultDesktopTtsCacheRoot())
|
||||
) : TtsAdapter {
|
||||
@Volatile
|
||||
private var activeLine: SourceDataLine? = null
|
||||
|
||||
@Volatile
|
||||
private var activeWebSocket: WebSocket? = null
|
||||
|
||||
@Volatile
|
||||
private var activePlayer: DesktopStreamingPcmPlayer? = null
|
||||
|
||||
override val isAvailable: Boolean
|
||||
get() = settingsProvider().sanitized().isCloudTtsAvailable
|
||||
|
||||
override suspend fun speak(text: String) {
|
||||
val trimmed = text.trim()
|
||||
logDesktopTts("speak_start textChars=${trimmed.length}")
|
||||
if (trimmed.isBlank()) return
|
||||
speakSequence(splitReaderTextIntoTtsChunks(trimmed).ifEmpty { listOf(trimmed.take(5_000)) })
|
||||
logDesktopTts("speak_finished")
|
||||
}
|
||||
|
||||
suspend fun speakSequence(
|
||||
texts: List<String>,
|
||||
onChunkStart: suspend (Int) -> Unit = {}
|
||||
) {
|
||||
val normalizedChunks = texts
|
||||
.flatMap { text -> splitReaderTextIntoTtsChunks(text).ifEmpty { listOf(text.trim()) } }
|
||||
.map { text -> DesktopTtsSequenceChunk(text = text.trim().take(5_000), chapterTitle = null) }
|
||||
.filter { it.text.isNotBlank() }
|
||||
logDesktopTts(
|
||||
"sequence_speak_start chunks=${normalizedChunks.size} totalTextChars=${normalizedChunks.sumOf { it.text.length }}"
|
||||
)
|
||||
if (normalizedChunks.isEmpty()) return
|
||||
val callbackContext = coroutineContext
|
||||
stop()
|
||||
streamSequence("Desktop selection", normalizedChunks, callbackContext, onChunkStart)
|
||||
logDesktopTts("sequence_speak_finished chunks=${normalizedChunks.size}")
|
||||
}
|
||||
|
||||
suspend fun speakChunks(
|
||||
bookTitle: String,
|
||||
readScope: ReaderTtsReadScope,
|
||||
chunks: List<ReaderTtsChunk>,
|
||||
onChunkStart: suspend (Int) -> Unit = {}
|
||||
) {
|
||||
val sequenceChunks = chunks
|
||||
.map { chunk ->
|
||||
DesktopTtsSequenceChunk(
|
||||
text = chunk.spokenText.trim().ifBlank { chunk.text.trim() }.take(5_000),
|
||||
chapterTitle = chunk.chapterTitle.ifBlank { readScope.label }
|
||||
)
|
||||
}
|
||||
.filter { it.text.isNotBlank() }
|
||||
logDesktopTts(
|
||||
"chunk_sequence_speak_start book=\"${bookTitle.desktopTtsPreview()}\" scope=${readScope.name} " +
|
||||
"chunks=${sequenceChunks.size} totalTextChars=${sequenceChunks.sumOf { it.text.length }}"
|
||||
)
|
||||
if (sequenceChunks.isEmpty()) return
|
||||
val callbackContext = coroutineContext
|
||||
stop()
|
||||
streamSequence(bookTitle.ifBlank { "Untitled" }, sequenceChunks, callbackContext, onChunkStart)
|
||||
logDesktopTts("chunk_sequence_speak_finished chunks=${sequenceChunks.size}")
|
||||
}
|
||||
|
||||
override suspend fun pause() {
|
||||
withContext(Dispatchers.IO) {
|
||||
activePlayer?.pause()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun resume() {
|
||||
withContext(Dispatchers.IO) {
|
||||
activePlayer?.resume()
|
||||
}
|
||||
}
|
||||
|
||||
fun cacheSummary(bookTitle: String, speakerId: String? = settingsProvider().sanitized().ttsSpeakerId): ReaderTtsCacheSummary {
|
||||
return cacheManager.getCacheSummary(bookTitle.ifBlank { "Untitled" }, speakerId)
|
||||
}
|
||||
|
||||
fun clearBookCacheForSpeaker(bookTitle: String, speakerId: String = settingsProvider().sanitized().ttsSpeakerId) {
|
||||
cacheManager.clearBookCacheForSpeaker(bookTitle.ifBlank { "Untitled" }, speakerId)
|
||||
}
|
||||
|
||||
fun clearBookCache(bookTitle: String) {
|
||||
cacheManager.clearBookCache(bookTitle.ifBlank { "Untitled" })
|
||||
}
|
||||
|
||||
override suspend fun stop() {
|
||||
withContext(Dispatchers.IO) {
|
||||
logDesktopTts("stop_requested hasWebSocket=${activeWebSocket != null} hasLine=${activeLine != null}")
|
||||
runCatching { activeWebSocket?.abort() }
|
||||
activeWebSocket = null
|
||||
runCatching { activePlayer?.closeNow() }
|
||||
activePlayer = null
|
||||
runCatching { activeLine?.stop() }
|
||||
runCatching { activeLine?.flush() }
|
||||
runCatching { activeLine?.close() }
|
||||
activeLine = null
|
||||
logDesktopTts("stop_complete")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun streamSequence(
|
||||
bookTitle: String,
|
||||
chunks: List<DesktopTtsSequenceChunk>,
|
||||
callbackContext: CoroutineContext,
|
||||
onChunkStart: suspend (Int) -> Unit
|
||||
) = withContext(Dispatchers.IO) {
|
||||
val settings = settingsProvider().sanitized()
|
||||
val totalTextChars = chunks.sumOf { it.text.length }
|
||||
logDesktopTts(
|
||||
"stream_start book=\"${bookTitle.desktopTtsPreview()}\" chunks=${chunks.size} totalTextChars=$totalTextChars keyPresent=${settings.geminiKey.isNotBlank()} " +
|
||||
"ttsModel=\"${settings.ttsModel.desktopTtsPreview()}\" speaker=\"${settings.ttsSpeakerId.desktopTtsPreview()}\" " +
|
||||
"available=${settings.isCloudTtsAvailable}"
|
||||
)
|
||||
if (!settings.isCloudTtsAvailable) {
|
||||
logDesktopTts("stream_blocked reason=not_available")
|
||||
throw IllegalStateException("Cloud TTS needs a saved Gemini key and the Gemini cloud TTS model selected.")
|
||||
}
|
||||
|
||||
val audioBytesReceived = AtomicLong(0)
|
||||
val currentTurnAudioBytesReceived = AtomicLong(0)
|
||||
val player = DesktopStreamingPcmPlayer { activeLine = it }
|
||||
activePlayer = player
|
||||
val setupComplete = CompletableDeferred<Unit>()
|
||||
val currentTurnComplete = AtomicReference<CompletableDeferred<Unit>?>(null)
|
||||
val activeCacheOutput = AtomicReference<FileOutputStream?>(null)
|
||||
val failure = CompletableDeferred<Throwable>()
|
||||
val messageBuffer = StringBuilder()
|
||||
var webSocket: WebSocket? = null
|
||||
var activeTempCacheFile: File? = null
|
||||
|
||||
fun handleMessage(message: String) {
|
||||
handleGeminiTtsMessage(
|
||||
message = message,
|
||||
setupComplete = setupComplete,
|
||||
turnComplete = currentTurnComplete.get(),
|
||||
failure = failure,
|
||||
onAudioPart = { bytes ->
|
||||
audioBytesReceived.addAndGet(bytes.size.toLong())
|
||||
currentTurnAudioBytesReceived.addAndGet(bytes.size.toLong())
|
||||
activeCacheOutput.get()?.let { output ->
|
||||
runCatching { output.write(bytes) }
|
||||
.onFailure { error ->
|
||||
logDesktopTts("cache_write_failed error=\"${error.desktopTtsSummary()}\"")
|
||||
failure.complete(error)
|
||||
}
|
||||
}
|
||||
runCatching { player.write(bytes) }
|
||||
.onFailure { error ->
|
||||
logDesktopTts("stream_audio_write_failed error=\"${error.desktopTtsSummary()}\"")
|
||||
failure.complete(error)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
val listener = object : WebSocket.Listener {
|
||||
override fun onOpen(webSocket: WebSocket) {
|
||||
activeWebSocket = webSocket
|
||||
webSocket.request(1)
|
||||
logDesktopTts("ws_open send_setup model=\"$GEMINI_CLOUD_TTS_MODEL\" speaker=\"${settings.ttsSpeakerId.desktopTtsPreview()}\"")
|
||||
webSocket.sendText(buildGeminiTtsSetup(settings.ttsSpeakerId), true)
|
||||
.whenComplete { _, error ->
|
||||
if (error != null) {
|
||||
logDesktopTts("ws_setup_send_failed error=\"${error.desktopTtsSummary()}\"")
|
||||
failure.complete(error)
|
||||
} else {
|
||||
logDesktopTts("ws_setup_send_complete")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onText(webSocket: WebSocket, data: CharSequence, last: Boolean): CompletionStage<*> {
|
||||
messageBuffer.append(data)
|
||||
logDesktopTts("ws_message_text chunkChars=${data.length} last=$last bufferChars=${messageBuffer.length}")
|
||||
if (last) {
|
||||
val message = messageBuffer.toString()
|
||||
messageBuffer.clear()
|
||||
handleMessage(message)
|
||||
}
|
||||
webSocket.request(1)
|
||||
return CompletableFuture.completedFuture(null)
|
||||
}
|
||||
|
||||
override fun onBinary(webSocket: WebSocket, data: ByteBuffer, last: Boolean): CompletionStage<*> {
|
||||
val bytes = ByteArray(data.remaining())
|
||||
data.get(bytes)
|
||||
messageBuffer.append(bytes.decodeToString())
|
||||
logDesktopTts("ws_message_binary chunkBytes=${bytes.size} last=$last bufferChars=${messageBuffer.length}")
|
||||
if (last) {
|
||||
val message = messageBuffer.toString()
|
||||
messageBuffer.clear()
|
||||
handleMessage(message)
|
||||
}
|
||||
webSocket.request(1)
|
||||
return CompletableFuture.completedFuture(null)
|
||||
}
|
||||
|
||||
override fun onError(webSocket: WebSocket, error: Throwable) {
|
||||
logDesktopTts("ws_error error=\"${error.desktopTtsSummary()}\"")
|
||||
failure.complete(error)
|
||||
}
|
||||
|
||||
override fun onClose(webSocket: WebSocket, statusCode: Int, reason: String): CompletionStage<*> {
|
||||
val activeTurn = currentTurnComplete.get()
|
||||
logDesktopTts(
|
||||
"ws_close status=$statusCode reason=\"${reason.desktopTtsPreview()}\" " +
|
||||
"setupComplete=${setupComplete.isCompleted} turnComplete=${activeTurn?.isCompleted}"
|
||||
)
|
||||
if (!setupComplete.isCompleted && !failure.isCompleted) {
|
||||
failure.complete(IllegalStateException("Cloud TTS connection closed before setup: $reason"))
|
||||
} else if (activeTurn != null && !activeTurn.isCompleted && !failure.isCompleted) {
|
||||
failure.complete(IllegalStateException("Cloud TTS connection closed: $reason"))
|
||||
}
|
||||
return CompletableFuture.completedFuture(null)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun ensureWebSocket(): WebSocket {
|
||||
webSocket?.let { return it }
|
||||
val encodedKey = URLEncoder.encode(settings.geminiKey, Charsets.UTF_8.name())
|
||||
val uri = URI("wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent?key=$encodedKey")
|
||||
logDesktopTts("ws_connect_start endpoint=GeminiLive keyChars=${settings.geminiKey.length}")
|
||||
val connectedWebSocket = runCatching {
|
||||
httpClient.newWebSocketBuilder()
|
||||
.buildAsync(uri, listener)
|
||||
.get(15, TimeUnit.SECONDS)
|
||||
}.getOrElse { error ->
|
||||
logDesktopTts("ws_connect_failed error=\"${error.desktopTtsSummary()}\"")
|
||||
throw error
|
||||
}
|
||||
activeWebSocket = connectedWebSocket
|
||||
webSocket = connectedWebSocket
|
||||
logDesktopTts("ws_connect_complete")
|
||||
|
||||
logDesktopTts("setup_wait_start timeoutMs=15000")
|
||||
withTimeout(15_000) {
|
||||
select<Unit> {
|
||||
setupComplete.onAwait { }
|
||||
failure.onAwait { throw it }
|
||||
}
|
||||
}
|
||||
logDesktopTts("setup_wait_complete")
|
||||
return connectedWebSocket
|
||||
}
|
||||
|
||||
try {
|
||||
val totalChunksByChapter = chunks.groupingBy { it.chapterTitle }.eachCount()
|
||||
chunks.forEach { chunk ->
|
||||
cacheManager.saveTotalChunks(
|
||||
bookTitle = bookTitle,
|
||||
chapterTitle = chunk.chapterTitle,
|
||||
totalChunks = totalChunksByChapter[chunk.chapterTitle] ?: chunks.size
|
||||
)
|
||||
}
|
||||
chunks.forEachIndexed { index, chunk ->
|
||||
val text = chunk.text
|
||||
val turnComplete = CompletableDeferred<Unit>()
|
||||
currentTurnAudioBytesReceived.set(0)
|
||||
currentTurnComplete.set(turnComplete)
|
||||
logDesktopTts("sequence_turn_start index=${index + 1}/${chunks.size} textChars=${text.length}")
|
||||
withContext(callbackContext) {
|
||||
onChunkStart(index)
|
||||
}
|
||||
|
||||
val cacheFile = cacheManager.getCacheFile(bookTitle, chunk.chapterTitle, text, settings.ttsSpeakerId)
|
||||
if (cacheFile.exists() && cacheFile.length() > 44) {
|
||||
logDesktopTts(
|
||||
"cache_hit index=${index + 1}/${chunks.size} bytes=${cacheFile.length()} " +
|
||||
"file=\"${cacheFile.absolutePath.desktopTtsPreview(220)}\""
|
||||
)
|
||||
val cachedBytes = playCachedWav(cacheFile, player)
|
||||
currentTurnAudioBytesReceived.set(cachedBytes)
|
||||
audioBytesReceived.addAndGet(cachedBytes)
|
||||
logDesktopTts("cache_play_complete index=${index + 1}/${chunks.size} audioBytes=$cachedBytes")
|
||||
currentTurnComplete.compareAndSet(turnComplete, null)
|
||||
return@forEachIndexed
|
||||
}
|
||||
|
||||
val socket = ensureWebSocket()
|
||||
val tempCacheFile = File(cacheFile.absolutePath + ".tmp")
|
||||
activeTempCacheFile = tempCacheFile
|
||||
runCatching {
|
||||
tempCacheFile.parentFile?.mkdirs()
|
||||
FileOutputStream(tempCacheFile).also { output ->
|
||||
output.write(createReaderTtsWavHeaderUnknownLength(24_000))
|
||||
activeCacheOutput.set(output)
|
||||
}
|
||||
}.onFailure { error ->
|
||||
activeCacheOutput.set(null)
|
||||
tempCacheFile.delete()
|
||||
logDesktopTts("cache_prepare_failed index=${index + 1}/${chunks.size} error=\"${error.desktopTtsSummary()}\"")
|
||||
}
|
||||
|
||||
try {
|
||||
logDesktopTts("text_send_start index=${index + 1}/${chunks.size} textChars=${text.length}")
|
||||
runCatching { socket.sendText(buildGeminiTtsTextInput(text), true).join() }
|
||||
.onFailure { error ->
|
||||
logDesktopTts("text_send_failed index=${index + 1}/${chunks.size} error=\"${error.desktopTtsSummary()}\"")
|
||||
throw error
|
||||
}
|
||||
logDesktopTts("text_send_complete index=${index + 1}/${chunks.size}")
|
||||
|
||||
val turnTimeoutMs = (30_000L + text.length * 80L).coerceIn(60_000L, 600_000L)
|
||||
logDesktopTts("turn_wait_start index=${index + 1}/${chunks.size} timeoutMs=$turnTimeoutMs")
|
||||
withTimeout(turnTimeoutMs) {
|
||||
select<Unit> {
|
||||
turnComplete.onAwait { }
|
||||
failure.onAwait { throw it }
|
||||
}
|
||||
}
|
||||
val turnAudioBytes = currentTurnAudioBytesReceived.get()
|
||||
logDesktopTts(
|
||||
"turn_wait_complete index=${index + 1}/${chunks.size} " +
|
||||
"turnAudioBytes=$turnAudioBytes totalAudioBytes=${audioBytesReceived.get()}"
|
||||
)
|
||||
if (turnAudioBytes == 0L) {
|
||||
logDesktopTts("stream_failed reason=empty_turn_audio index=${index + 1}/${chunks.size}")
|
||||
throw IllegalStateException("Cloud TTS returned no audio for a text chunk.")
|
||||
}
|
||||
activeCacheOutput.getAndSet(null)?.close()
|
||||
runCatching {
|
||||
patchReaderTtsWavHeader(tempCacheFile, turnAudioBytes.toInt())
|
||||
if (cacheFile.exists()) cacheFile.delete()
|
||||
if (!tempCacheFile.renameTo(cacheFile)) {
|
||||
throw IllegalStateException("Could not move temp cache file into place.")
|
||||
}
|
||||
}.onSuccess {
|
||||
logDesktopTts(
|
||||
"cache_store_complete index=${index + 1}/${chunks.size} bytes=${cacheFile.length()} " +
|
||||
"file=\"${cacheFile.absolutePath.desktopTtsPreview(220)}\""
|
||||
)
|
||||
}.onFailure { error ->
|
||||
tempCacheFile.delete()
|
||||
logDesktopTts("cache_store_failed index=${index + 1}/${chunks.size} error=\"${error.desktopTtsSummary()}\"")
|
||||
}
|
||||
activeTempCacheFile = null
|
||||
} finally {
|
||||
activeCacheOutput.getAndSet(null)?.let { output ->
|
||||
runCatching { output.close() }
|
||||
}
|
||||
}
|
||||
currentTurnComplete.compareAndSet(turnComplete, null)
|
||||
}
|
||||
|
||||
if (audioBytesReceived.get() == 0L) {
|
||||
logDesktopTts("stream_failed reason=empty_audio")
|
||||
throw IllegalStateException("Cloud TTS returned no audio.")
|
||||
}
|
||||
player.drainAndClose()
|
||||
webSocket?.let { socket -> runCatching { socket.sendClose(WebSocket.NORMAL_CLOSURE, "done").join() } }
|
||||
activeWebSocket = null
|
||||
activePlayer = null
|
||||
logDesktopTts("stream_complete chunks=${chunks.size} audioBytes=${audioBytesReceived.get()}")
|
||||
} catch (error: Throwable) {
|
||||
currentTurnComplete.set(null)
|
||||
activeCacheOutput.getAndSet(null)?.let { output -> runCatching { output.close() } }
|
||||
activeTempCacheFile?.delete()
|
||||
activeTempCacheFile = null
|
||||
runCatching { webSocket?.abort() }
|
||||
activeWebSocket = null
|
||||
activePlayer = null
|
||||
player.closeNow()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun playCachedWav(file: File, player: DesktopStreamingPcmPlayer): Long {
|
||||
var totalBytes = 0L
|
||||
file.inputStream().use { input ->
|
||||
var skipped = 0L
|
||||
while (skipped < 44L) {
|
||||
val next = input.skip(44L - skipped)
|
||||
if (next <= 0L) break
|
||||
skipped += next
|
||||
}
|
||||
val buffer = ByteArray(8192)
|
||||
while (true) {
|
||||
coroutineContext.ensureActive()
|
||||
val read = input.read(buffer)
|
||||
if (read <= 0) break
|
||||
player.write(buffer.copyOf(read))
|
||||
totalBytes += read
|
||||
}
|
||||
}
|
||||
return totalBytes
|
||||
}
|
||||
|
||||
private fun defaultDesktopTtsCacheRoot(): File {
|
||||
val baseDir = System.getenv("APPDATA")?.takeIf { it.isNotBlank() }
|
||||
?: File(System.getProperty("user.home"), "AppData/Roaming").absolutePath
|
||||
return File(baseDir, "Episteme/TTS_Cache")
|
||||
}
|
||||
|
||||
private fun buildGeminiTtsSetup(speakerId: String): String {
|
||||
val systemPrompt = """
|
||||
You are a professional audiobook narrator.
|
||||
Read the exact text provided, word for word, with neutral emotion and good pacing.
|
||||
Do not add conversational filler, acknowledgments, extra words, summaries, or commentary.
|
||||
Skip non-verbal symbols or formatting noise that cannot be read naturally.
|
||||
""".trimIndent()
|
||||
return buildJsonObject {
|
||||
put(
|
||||
"setup",
|
||||
buildJsonObject {
|
||||
put("model", JsonPrimitive("models/$GEMINI_CLOUD_TTS_MODEL"))
|
||||
put(
|
||||
"systemInstruction",
|
||||
buildJsonObject {
|
||||
put("parts", buildJsonArray {
|
||||
add(buildJsonObject { put("text", JsonPrimitive(systemPrompt)) })
|
||||
})
|
||||
}
|
||||
)
|
||||
put(
|
||||
"generationConfig",
|
||||
buildJsonObject {
|
||||
put("responseModalities", buildJsonArray { add(JsonPrimitive("AUDIO")) })
|
||||
put(
|
||||
"speechConfig",
|
||||
buildJsonObject {
|
||||
put(
|
||||
"voiceConfig",
|
||||
buildJsonObject {
|
||||
put(
|
||||
"prebuiltVoiceConfig",
|
||||
buildJsonObject { put("voiceName", JsonPrimitive(speakerId)) }
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}.toString()
|
||||
}
|
||||
|
||||
private fun buildGeminiTtsTextInput(text: String): String {
|
||||
return buildJsonObject {
|
||||
put(
|
||||
"realtimeInput",
|
||||
buildJsonObject {
|
||||
put("text", JsonPrimitive(text))
|
||||
}
|
||||
)
|
||||
}.toString()
|
||||
}
|
||||
|
||||
private fun handleGeminiTtsMessage(
|
||||
message: String,
|
||||
setupComplete: CompletableDeferred<Unit>,
|
||||
turnComplete: CompletableDeferred<Unit>?,
|
||||
failure: CompletableDeferred<Throwable>,
|
||||
onAudioPart: (ByteArray) -> Unit
|
||||
) {
|
||||
logDesktopTts("message_handle chars=${message.length} preview=\"${message.desktopTtsPreview()}\"")
|
||||
val json = runCatching { DesktopGeminiTtsJson.parseToJsonElement(message).jsonObject }.getOrElse { error ->
|
||||
logDesktopTts("message_parse_failed error=\"${error.desktopTtsSummary()}\"")
|
||||
return
|
||||
}
|
||||
json["error"]?.let { error ->
|
||||
logDesktopTts("message_provider_error body=\"${error.toString().desktopTtsPreview(300)}\"")
|
||||
failure.complete(IllegalStateException(error.toString()))
|
||||
return
|
||||
}
|
||||
if (json.containsKey("setupComplete") || json.containsKey("setup_complete")) {
|
||||
logDesktopTts("message_setup_complete")
|
||||
setupComplete.complete(Unit)
|
||||
}
|
||||
|
||||
val serverContent = json.jsonObjectValue("serverContent", "server_content") ?: return
|
||||
val modelTurn = serverContent.jsonObjectValue("modelTurn", "model_turn")
|
||||
val parts = modelTurn?.get("parts")?.jsonArray
|
||||
parts?.forEach { part ->
|
||||
val inlineData = part.jsonObjectOrNull()?.jsonObjectValue("inlineData", "inline_data")
|
||||
val encoded = inlineData?.get("data")?.jsonPrimitive?.contentOrNull
|
||||
if (!encoded.isNullOrBlank()) {
|
||||
val decoded = Base64.getMimeDecoder().decode(encoded)
|
||||
onAudioPart(decoded)
|
||||
logDesktopTts("message_audio_part bytes=${decoded.size}")
|
||||
}
|
||||
}
|
||||
if (serverContent.booleanValue("turnComplete", "turn_complete")) {
|
||||
logDesktopTts("message_turn_complete")
|
||||
turnComplete?.complete(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
private val DesktopGeminiTtsJson = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private fun JsonObject.jsonObjectValue(vararg keys: String): JsonObject? {
|
||||
return keys.firstNotNullOfOrNull { key -> get(key) as? JsonObject }
|
||||
}
|
||||
|
||||
private fun JsonObject.booleanValue(vararg keys: String): Boolean {
|
||||
return keys.any { key -> get(key)?.jsonPrimitive?.booleanOrNull == true }
|
||||
}
|
||||
|
||||
private fun JsonElement.jsonObjectOrNull(): JsonObject? {
|
||||
return this as? JsonObject
|
||||
}
|
||||
|
||||
private fun ByteArray.upsample16BitMonoLe2x(): ByteArray {
|
||||
if (size < 2) return this
|
||||
val sampleCount = size / 2
|
||||
val output = ByteArray(sampleCount * 4)
|
||||
var outputIndex = 0
|
||||
fun sampleAt(index: Int): Int {
|
||||
val byteIndex = index * 2
|
||||
val lo = this[byteIndex].toInt() and 0xFF
|
||||
val hi = this[byteIndex + 1].toInt()
|
||||
return (hi shl 8) or lo
|
||||
}
|
||||
fun writeSample(sample: Int) {
|
||||
output[outputIndex] = (sample and 0xFF).toByte()
|
||||
output[outputIndex + 1] = ((sample shr 8) and 0xFF).toByte()
|
||||
outputIndex += 2
|
||||
}
|
||||
for (index in 0 until sampleCount) {
|
||||
val current = sampleAt(index)
|
||||
val next = sampleAt((index + 1).coerceAtMost(sampleCount - 1))
|
||||
writeSample(current)
|
||||
writeSample(((current + next) / 2).coerceIn(Short.MIN_VALUE.toInt(), Short.MAX_VALUE.toInt()))
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
private class DesktopStreamingPcmPlayer(
|
||||
private val onLineChanged: (SourceDataLine?) -> Unit
|
||||
) {
|
||||
@Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN")
|
||||
private val stateLock = java.lang.Object()
|
||||
private var line: SourceDataLine? = null
|
||||
private var fallbackTo48Khz = true
|
||||
@Volatile
|
||||
private var closed = false
|
||||
@Volatile
|
||||
private var paused = false
|
||||
private var bytesWritten = 0L
|
||||
|
||||
init {
|
||||
logDesktopTts("play_stream_start mixers=\"${availableAudioMixers().desktopTtsPreview(260)}\"")
|
||||
}
|
||||
|
||||
fun pause() {
|
||||
synchronized(stateLock) {
|
||||
if (closed || paused) return
|
||||
paused = true
|
||||
runCatching { line?.stop() }
|
||||
logDesktopTts("play_stream_paused totalWritten=$bytesWritten")
|
||||
}
|
||||
}
|
||||
|
||||
fun resume() {
|
||||
synchronized(stateLock) {
|
||||
if (closed || !paused) return
|
||||
paused = false
|
||||
runCatching { line?.start() }
|
||||
stateLock.notifyAll()
|
||||
logDesktopTts("play_stream_resumed totalWritten=$bytesWritten")
|
||||
}
|
||||
}
|
||||
|
||||
fun write(pcm24Khz: ByteArray) {
|
||||
if (closed || pcm24Khz.isEmpty()) return
|
||||
waitIfPaused()
|
||||
val activeLine = synchronized(stateLock) {
|
||||
if (closed) return
|
||||
line ?: openBestLine()
|
||||
}
|
||||
val bytes = if (fallbackTo48Khz) pcm24Khz.upsample16BitMonoLe2x() else pcm24Khz
|
||||
var offset = 0
|
||||
var lineStarted = activeLine.isRunning
|
||||
val primeTargetBytes = (activeLine.bufferSize / 2).coerceAtLeast(8192)
|
||||
while (offset < bytes.size && !closed) {
|
||||
waitIfPaused()
|
||||
val maxWrite = if (lineStarted) 8192 else primeTargetBytes
|
||||
val written = activeLine.write(bytes, offset, (bytes.size - offset).coerceAtMost(maxWrite))
|
||||
if (written <= 0) break
|
||||
offset += written
|
||||
bytesWritten += written
|
||||
if (!lineStarted && (offset >= bytes.size || offset >= primeTargetBytes)) {
|
||||
activeLine.start()
|
||||
lineStarted = true
|
||||
logDesktopTts("play_line_started_after_prime primeBytes=$offset")
|
||||
}
|
||||
}
|
||||
if (!lineStarted && !closed) {
|
||||
activeLine.start()
|
||||
logDesktopTts("play_line_started_after_prime primeBytes=$offset")
|
||||
}
|
||||
logDesktopTts("play_stream_write inputBytes=${pcm24Khz.size} writtenBytes=$offset totalWritten=$bytesWritten")
|
||||
}
|
||||
|
||||
fun drainAndClose() {
|
||||
val activeLine = line
|
||||
if (activeLine != null && !closed) {
|
||||
logDesktopTts("play_stream_drain totalWritten=$bytesWritten")
|
||||
runCatching { activeLine.drain() }
|
||||
.onFailure { error -> logDesktopTts("play_stream_drain_failed error=\"${error.desktopTtsSummary()}\"") }
|
||||
}
|
||||
closeNow()
|
||||
}
|
||||
|
||||
fun closeNow() {
|
||||
val activeLine = synchronized(stateLock) {
|
||||
if (closed) return
|
||||
closed = true
|
||||
paused = false
|
||||
stateLock.notifyAll()
|
||||
line.also { line = null }
|
||||
}
|
||||
activeLine?.let {
|
||||
runCatching { it.stop() }
|
||||
runCatching { it.flush() }
|
||||
runCatching { it.close() }
|
||||
}
|
||||
onLineChanged(null)
|
||||
logDesktopTts("play_stream_closed totalWritten=$bytesWritten")
|
||||
}
|
||||
|
||||
private fun waitIfPaused() {
|
||||
synchronized(stateLock) {
|
||||
while (paused && !closed) {
|
||||
stateLock.wait(100)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun openBestLine(): SourceDataLine {
|
||||
fallbackTo48Khz = true
|
||||
return runCatching {
|
||||
openLine(48_000f)
|
||||
}.getOrElse { firstError ->
|
||||
logDesktopTts("play_primary_failed sampleRate=48000 error=\"${firstError.desktopTtsSummary()}\"")
|
||||
fallbackTo48Khz = false
|
||||
runCatching {
|
||||
openLine(24_000f)
|
||||
}.onFailure { secondError ->
|
||||
logDesktopTts("play_fallback_failed sampleRate=24000 error=\"${secondError.desktopTtsSummary()}\"")
|
||||
secondError.printStackTrace()
|
||||
}.getOrElse {
|
||||
throw firstError
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun openLine(sampleRate: Float): SourceDataLine {
|
||||
val format = AudioFormat(sampleRate, 16, 1, true, false)
|
||||
val bufferBytes = sampleRate.toInt().coerceAtLeast(16_384)
|
||||
logDesktopTts("play_line_request sampleRate=${sampleRate.toInt()} bufferBytes=$bufferBytes")
|
||||
val openedLine = AudioSystem.getSourceDataLine(format)
|
||||
openedLine.open(format, bufferBytes)
|
||||
line = openedLine
|
||||
onLineChanged(openedLine)
|
||||
logDesktopTts(
|
||||
"play_line_opened sampleRate=${sampleRate.toInt()} output48Khz=$fallbackTo48Khz " +
|
||||
"line=\"${openedLine.lineInfo.toString().desktopTtsPreview(160)}\""
|
||||
)
|
||||
return openedLine
|
||||
}
|
||||
}
|
||||
|
||||
private fun availableAudioMixers(): String {
|
||||
return runCatching {
|
||||
AudioSystem.getMixerInfo()
|
||||
.joinToString(limit = 8, truncated = "...") { "${it.name}/${it.description}" }
|
||||
.ifBlank { "none" }
|
||||
}.getOrDefault("unavailable")
|
||||
}
|
||||
|
|
@ -1,66 +1,20 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import com.aryan.reader.shared.BookItem
|
||||
import com.aryan.reader.shared.BookShelfRef
|
||||
import com.aryan.reader.shared.FileType
|
||||
import com.aryan.reader.shared.ShelfRecord
|
||||
import com.aryan.reader.shared.Tag
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.doubleOrNull
|
||||
import kotlinx.serialization.json.floatOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
import com.aryan.reader.shared.SharedLibrarySnapshot
|
||||
import com.aryan.reader.shared.SharedLibrarySnapshotJson
|
||||
import java.io.File
|
||||
|
||||
data class DesktopLibrarySnapshot(
|
||||
val books: List<BookItem> = emptyList(),
|
||||
val shelfRecords: List<ShelfRecord> = emptyList(),
|
||||
val shelfRefs: List<BookShelfRef> = emptyList(),
|
||||
val tags: List<Tag> = emptyList()
|
||||
)
|
||||
|
||||
class DesktopLibraryDatabase(
|
||||
private val databaseFile: File = defaultDatabaseFile()
|
||||
) {
|
||||
private val json = Json {
|
||||
prettyPrint = true
|
||||
ignoreUnknownKeys = true
|
||||
fun load(): SharedLibrarySnapshot {
|
||||
if (!databaseFile.exists()) return SharedLibrarySnapshot()
|
||||
return SharedLibrarySnapshotJson.decodeOrEmpty(databaseFile.readText())
|
||||
}
|
||||
|
||||
fun load(): DesktopLibrarySnapshot {
|
||||
if (!databaseFile.exists()) return DesktopLibrarySnapshot()
|
||||
val root = runCatching {
|
||||
json.parseToJsonElement(databaseFile.readText()).jsonObject
|
||||
}.getOrNull() ?: return DesktopLibrarySnapshot()
|
||||
|
||||
return DesktopLibrarySnapshot(
|
||||
books = root.array("books").mapNotNull { it.asBookItemOrNull() },
|
||||
shelfRecords = root.array("shelves").mapNotNull { it.asShelfRecordOrNull() },
|
||||
shelfRefs = root.array("bookShelfRefs").mapNotNull { it.asBookShelfRefOrNull() },
|
||||
tags = root.array("tags").mapNotNull { it.asTagOrNull() }
|
||||
)
|
||||
}
|
||||
|
||||
fun save(snapshot: DesktopLibrarySnapshot) {
|
||||
fun save(snapshot: SharedLibrarySnapshot) {
|
||||
databaseFile.parentFile?.mkdirs()
|
||||
val root = JsonObject(
|
||||
mapOf(
|
||||
"schemaVersion" to JsonPrimitive(1),
|
||||
"books" to JsonArray(snapshot.books.map { it.toJsonObject() }),
|
||||
"shelves" to JsonArray(snapshot.shelfRecords.map { it.toJsonObject() }),
|
||||
"bookShelfRefs" to JsonArray(snapshot.shelfRefs.map { it.toJsonObject() }),
|
||||
"tags" to JsonArray(snapshot.tags.map { it.toJsonObject() })
|
||||
)
|
||||
)
|
||||
databaseFile.writeText(root.toString())
|
||||
databaseFile.writeText(SharedLibrarySnapshotJson.encode(snapshot))
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
@ -71,135 +25,3 @@ class DesktopLibraryDatabase(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonObject.array(name: String): List<JsonElement> {
|
||||
return runCatching { this[name]?.jsonArray?.toList().orEmpty() }.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
private fun JsonObject.string(name: String): String? {
|
||||
return this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.content
|
||||
}
|
||||
|
||||
private fun JsonObject.long(name: String, fallback: Long = 0L): Long {
|
||||
return this[name]?.jsonPrimitive?.longOrNull ?: fallback
|
||||
}
|
||||
|
||||
private fun JsonObject.float(name: String): Float? {
|
||||
return this[name]?.jsonPrimitive?.floatOrNull
|
||||
}
|
||||
|
||||
private fun JsonObject.double(name: String): Double? {
|
||||
return this[name]?.jsonPrimitive?.doubleOrNull
|
||||
}
|
||||
|
||||
private fun JsonObject.boolean(name: String, fallback: Boolean): Boolean {
|
||||
return this[name]?.jsonPrimitive?.booleanOrNull ?: fallback
|
||||
}
|
||||
|
||||
private fun JsonElement.asBookItemOrNull(): BookItem? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
val id = obj.string("id") ?: return null
|
||||
val displayName = obj.string("displayName") ?: return null
|
||||
val type = obj.string("type")?.let { runCatching { FileType.valueOf(it) }.getOrNull() } ?: FileType.UNKNOWN
|
||||
return BookItem(
|
||||
id = id,
|
||||
path = obj.string("path"),
|
||||
type = type,
|
||||
displayName = displayName,
|
||||
timestamp = obj.long("timestamp"),
|
||||
title = obj.string("title"),
|
||||
author = obj.string("author"),
|
||||
progressPercentage = obj.float("progressPercentage"),
|
||||
isRecent = obj.boolean("isRecent", true),
|
||||
fileSize = obj.long("fileSize"),
|
||||
sourceFolder = obj.string("sourceFolder"),
|
||||
seriesName = obj.string("seriesName"),
|
||||
seriesIndex = obj.double("seriesIndex"),
|
||||
tags = obj.array("tags").mapNotNull { it.asTagOrNull() }
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonElement.asShelfRecordOrNull(): ShelfRecord? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
return ShelfRecord(
|
||||
id = obj.string("id") ?: return null,
|
||||
name = obj.string("name") ?: return null,
|
||||
isSmart = obj.boolean("isSmart", false),
|
||||
smartRulesJson = obj.string("smartRulesJson")
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonElement.asBookShelfRefOrNull(): BookShelfRef? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
return BookShelfRef(
|
||||
bookId = obj.string("bookId") ?: return null,
|
||||
shelfId = obj.string("shelfId") ?: return null,
|
||||
addedAt = obj.long("addedAt")
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonElement.asTagOrNull(): Tag? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
return Tag(
|
||||
id = obj.string("id") ?: return null,
|
||||
name = obj.string("name") ?: return null,
|
||||
color = obj["color"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.content?.toIntOrNull()
|
||||
)
|
||||
}
|
||||
|
||||
private fun BookItem.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"id" to JsonPrimitive(id),
|
||||
"path" to path.asJson(),
|
||||
"type" to JsonPrimitive(type.name),
|
||||
"displayName" to JsonPrimitive(displayName),
|
||||
"timestamp" to JsonPrimitive(timestamp),
|
||||
"title" to title.asJson(),
|
||||
"author" to author.asJson(),
|
||||
"progressPercentage" to progressPercentage.asJson(),
|
||||
"isRecent" to JsonPrimitive(isRecent),
|
||||
"fileSize" to JsonPrimitive(fileSize),
|
||||
"sourceFolder" to sourceFolder.asJson(),
|
||||
"seriesName" to seriesName.asJson(),
|
||||
"seriesIndex" to seriesIndex.asJson(),
|
||||
"tags" to JsonArray(tags.map { it.toJsonObject() })
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun ShelfRecord.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"id" to JsonPrimitive(id),
|
||||
"name" to JsonPrimitive(name),
|
||||
"isSmart" to JsonPrimitive(isSmart),
|
||||
"smartRulesJson" to smartRulesJson.asJson()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun BookShelfRef.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"bookId" to JsonPrimitive(bookId),
|
||||
"shelfId" to JsonPrimitive(shelfId),
|
||||
"addedAt" to JsonPrimitive(addedAt)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun Tag.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"id" to JsonPrimitive(id),
|
||||
"name" to JsonPrimitive(name),
|
||||
"color" to color.asJson()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun String?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull
|
||||
private fun Float?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull
|
||||
private fun Double?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull
|
||||
private fun Int?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull
|
||||
|
|
|
|||
|
|
@ -0,0 +1,573 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import com.aryan.reader.shared.BookItem
|
||||
import com.aryan.reader.shared.BookShelfRef
|
||||
import com.aryan.reader.shared.FileType
|
||||
import com.aryan.reader.shared.LOCAL_FOLDER_ANNOTATION_SUFFIX
|
||||
import com.aryan.reader.shared.LOCAL_FOLDER_SYNC_DATA_DIR
|
||||
import com.aryan.reader.shared.LocalFolderSyncEngine
|
||||
import com.aryan.reader.shared.LocalFolderSyncStats
|
||||
import com.aryan.reader.shared.ReaderPlatform
|
||||
import com.aryan.reader.shared.SharedFileCapabilities
|
||||
import com.aryan.reader.shared.SharedFolderBookMetadata
|
||||
import com.aryan.reader.shared.SharedFolderScannedFile
|
||||
import com.aryan.reader.shared.SharedReaderScreenState
|
||||
import com.aryan.reader.shared.SyncedFolder
|
||||
import com.aryan.reader.shared.pdf.SharedPdfAnnotationSerializer
|
||||
import com.aryan.reader.shared.pdf.SharedPdfAnnotationSidecarCodec
|
||||
import com.aryan.reader.shared.pdf.SharedPdfRichTextLog
|
||||
import com.aryan.reader.shared.pdf.SharedPdfRichTextSerializer
|
||||
import com.aryan.reader.shared.toSharedFolderBookMetadata
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
import java.io.File
|
||||
import java.nio.file.AtomicMoveNotSupportedException
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
|
||||
data class DesktopLocalFolderSyncResult(
|
||||
val state: SharedReaderScreenState,
|
||||
val shelfRefs: List<BookShelfRef>,
|
||||
val stats: LocalFolderSyncStats,
|
||||
val metadataStats: DesktopFolderMetadataExtractionStats = DesktopFolderMetadataExtractionStats(),
|
||||
val idMigrations: Map<String, String> = emptyMap(),
|
||||
val removedBookIds: Set<String> = emptySet(),
|
||||
val failedFolders: List<String> = emptyList()
|
||||
)
|
||||
|
||||
object DesktopLocalFolderSync {
|
||||
private val desktopSyncableTypes = SharedFileCapabilities.syncableTypesFor(ReaderPlatform.DESKTOP)
|
||||
|
||||
fun hasSupportedFiles(folder: File): Boolean {
|
||||
if (!folder.isDirectory) return false
|
||||
return folder.walkTopDown()
|
||||
.onEnter { it == folder || it.shouldEnterSyncedFolder() }
|
||||
.any { file ->
|
||||
file.isFile &&
|
||||
file.shouldSyncBookFile() &&
|
||||
SharedFileCapabilities.fileTypeForName(file.name) in desktopSyncableTypes
|
||||
}
|
||||
}
|
||||
|
||||
fun sync(
|
||||
state: SharedReaderScreenState,
|
||||
shelfRefs: List<BookShelfRef>,
|
||||
targetFolder: File? = null,
|
||||
nowMillis: Long = System.currentTimeMillis()
|
||||
): DesktopLocalFolderSyncResult {
|
||||
val requestedFolders = foldersToSync(state, targetFolder, nowMillis)
|
||||
var nextState = state
|
||||
var nextShelfRefs = shelfRefs
|
||||
var totalStats = LocalFolderSyncStats()
|
||||
var totalMetadataStats = DesktopFolderMetadataExtractionStats()
|
||||
val allMigrations = linkedMapOf<String, String>()
|
||||
val allRemovedBookIds = linkedSetOf<String>()
|
||||
val failedFolders = mutableListOf<String>()
|
||||
|
||||
requestedFolders.forEach { folder ->
|
||||
val root = File(folder.uriString)
|
||||
if (!root.isDirectory) {
|
||||
failedFolders += folder.name
|
||||
return@forEach
|
||||
}
|
||||
|
||||
val scannedFiles = scanFolder(root = root, sourceFolder = folder.uriString)
|
||||
val remoteMetadata = readAllMetadata(root)
|
||||
val syncResult = LocalFolderSyncEngine.syncFolder(
|
||||
state = nextState,
|
||||
folder = folder,
|
||||
files = scannedFiles,
|
||||
remoteMetadata = remoteMetadata,
|
||||
nowMillis = nowMillis
|
||||
)
|
||||
nextState = syncResult.state
|
||||
nextShelfRefs = LocalFolderSyncEngine.applyIdMigrationsToShelfRefs(
|
||||
nextShelfRefs,
|
||||
syncResult.idMigrations
|
||||
).filterNot { it.bookId in syncResult.removedBookIds }
|
||||
allMigrations += syncResult.idMigrations
|
||||
allRemovedBookIds += syncResult.removedBookIds
|
||||
totalStats += syncResult.stats
|
||||
|
||||
var syncedBooks = nextState.rawLibraryBooks.filter { it.sourceFolder == folder.uriString }
|
||||
importAnnotationSidecars(root, syncedBooks)
|
||||
val metadataResult = DesktopFolderMetadataExtractor.enrichFolderBooks(
|
||||
books = nextState.rawLibraryBooks,
|
||||
sourceFolder = folder.uriString
|
||||
)
|
||||
if (metadataResult.stats.updatedBooks > 0) {
|
||||
nextState = nextState.copy(rawLibraryBooks = metadataResult.books)
|
||||
syncedBooks = nextState.rawLibraryBooks.filter { it.sourceFolder == folder.uriString }
|
||||
}
|
||||
totalMetadataStats += metadataResult.stats
|
||||
syncedBooks.forEach { book ->
|
||||
saveBookMetadata(book)
|
||||
savePdfAnnotationSidecar(book)
|
||||
}
|
||||
}
|
||||
|
||||
return DesktopLocalFolderSyncResult(
|
||||
state = nextState,
|
||||
shelfRefs = nextShelfRefs,
|
||||
stats = totalStats,
|
||||
metadataStats = totalMetadataStats,
|
||||
idMigrations = allMigrations,
|
||||
removedBookIds = allRemovedBookIds,
|
||||
failedFolders = failedFolders
|
||||
)
|
||||
}
|
||||
|
||||
fun saveBookSidecars(book: BookItem) {
|
||||
saveBookMetadata(book)
|
||||
savePdfAnnotationSidecar(book)
|
||||
}
|
||||
|
||||
fun saveBookMetadata(book: BookItem) {
|
||||
val metadata = book.toSharedFolderBookMetadata() ?: return
|
||||
val root = book.sourceFolder?.let(::File)?.takeIf { it.isDirectory } ?: return
|
||||
saveMetadataToFolder(root, metadata)
|
||||
}
|
||||
|
||||
fun savePdfAnnotationSidecar(book: BookItem) {
|
||||
val path = book.path?.takeIf { it.isNotBlank() } ?: return
|
||||
if (book.type != FileType.PDF) return
|
||||
val root = book.sourceFolder?.let(::File)?.takeIf { it.isDirectory } ?: return
|
||||
val annotationFile = desktopPdfAnnotationFile(path)
|
||||
val bookmarkFile = desktopPdfBookmarkFile(path)
|
||||
val richTextFile = desktopPdfRichTextFile(path)
|
||||
val data = buildMap {
|
||||
if (annotationFile.isFile) {
|
||||
val annotationJson = annotationFile.readText().trim()
|
||||
val annotations = SharedPdfAnnotationSerializer.decode(annotationJson)
|
||||
put(
|
||||
SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS,
|
||||
SharedPdfAnnotationSidecarCodec.encodeAnnotationsElement(annotations)
|
||||
)
|
||||
}
|
||||
if (bookmarkFile.isFile) {
|
||||
val bookmarksJson = bookmarkFile.readText().trim()
|
||||
desktopFolderSyncJson.parseElementOrNull(bookmarksJson)?.let { put("bookmarks", it) }
|
||||
}
|
||||
if (richTextFile.isFile) {
|
||||
val richTextJson = richTextFile.readText().trim()
|
||||
val richTextElement = desktopFolderSyncJson.parseElementOrNull(richTextJson)
|
||||
if (richTextElement == null) {
|
||||
SharedPdfRichTextLog.d(
|
||||
"desktop.sync.exportRichTextParseFailed book=${book.id} " +
|
||||
"file=\"${richTextFile.absolutePath.richSyncPreview()}\" rawLen=${richTextJson.length}"
|
||||
)
|
||||
} else {
|
||||
val richTextDocument = SharedPdfRichTextSerializer.decodeElement(richTextElement)
|
||||
SharedPdfRichTextLog.d(
|
||||
"desktop.sync.exportRichText book=${book.id} " +
|
||||
"file=\"${richTextFile.absolutePath.richSyncPreview()}\" rawLen=${richTextJson.length} " +
|
||||
"textLen=${richTextDocument.text.length} spans=${richTextDocument.spans.size}"
|
||||
)
|
||||
put("text", SharedPdfRichTextSerializer.encodeElement(richTextDocument))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (data.isEmpty()) {
|
||||
SharedPdfRichTextLog.d("desktop.sync.exportSkipNoSidecarData book=${book.id} pdfPath=\"${path.richSyncPreview()}\"")
|
||||
return
|
||||
}
|
||||
val timestamp = maxOf(
|
||||
annotationFile.lastModifiedIfFile(),
|
||||
bookmarkFile.lastModifiedIfFile(),
|
||||
richTextFile.lastModifiedIfFile(),
|
||||
System.currentTimeMillis()
|
||||
)
|
||||
val dataJson = desktopFolderSyncJson.encodeToString(
|
||||
JsonElement.serializer(),
|
||||
JsonObject(data)
|
||||
)
|
||||
if (data.containsKey("text")) {
|
||||
SharedPdfRichTextLog.d(
|
||||
"desktop.sync.exportSidecar book=${book.id} timestamp=$timestamp " +
|
||||
"keys=${data.keys.sorted()} root=\"${root.absolutePath.richSyncPreview()}\""
|
||||
)
|
||||
}
|
||||
saveAnnotationSidecar(
|
||||
root = root,
|
||||
bookId = book.id,
|
||||
jsonPayload = dataJson,
|
||||
timestamp = timestamp
|
||||
)
|
||||
}
|
||||
|
||||
private fun foldersToSync(
|
||||
state: SharedReaderScreenState,
|
||||
targetFolder: File?,
|
||||
nowMillis: Long
|
||||
): List<SyncedFolder> {
|
||||
if (targetFolder == null) return state.syncedFolders
|
||||
val root = targetFolder.canonicalOrAbsolute()
|
||||
val rootPath = root.absolutePath
|
||||
val existing = state.syncedFolders.firstOrNull { File(it.uriString).canonicalOrAbsolute() == root }
|
||||
return listOf(
|
||||
existing ?: SyncedFolder(
|
||||
uriString = rootPath,
|
||||
name = root.name.takeIf { it.isNotBlank() } ?: rootPath,
|
||||
lastScanTime = nowMillis,
|
||||
allowedFileTypes = desktopSyncableTypes
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun scanFolder(root: File, sourceFolder: String): List<SharedFolderScannedFile> {
|
||||
val rootPath = root.toPath().toAbsolutePath().normalize()
|
||||
return root.walkTopDown()
|
||||
.onEnter { it == root || it.shouldEnterSyncedFolder() }
|
||||
.filter { it.isFile && it.shouldSyncBookFile() }
|
||||
.mapNotNull { file ->
|
||||
val type = SharedFileCapabilities.fileTypeForName(file.name)
|
||||
.takeIf { it in desktopSyncableTypes }
|
||||
?: return@mapNotNull null
|
||||
val relativePath = runCatching {
|
||||
rootPath.relativize(file.toPath().toAbsolutePath().normalize())
|
||||
.joinToString("/")
|
||||
}.getOrNull() ?: file.name
|
||||
SharedFolderScannedFile(
|
||||
name = file.name,
|
||||
path = file.absolutePath,
|
||||
sourceFolder = sourceFolder,
|
||||
relativePath = relativePath,
|
||||
type = type,
|
||||
size = file.length(),
|
||||
lastModified = file.lastModified()
|
||||
)
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
|
||||
private fun readAllMetadata(root: File): Map<String, SharedFolderBookMetadata> {
|
||||
val syncDir = File(root, LOCAL_FOLDER_SYNC_DATA_DIR)
|
||||
if (!syncDir.isDirectory) return emptyMap()
|
||||
return syncDir.listFiles().orEmpty()
|
||||
.asSequence()
|
||||
.filter { it.isFile }
|
||||
.mapNotNull { file -> file.metadataBookIdOrNull()?.let { it to file } }
|
||||
.groupBy({ it.first }, { it.second })
|
||||
.mapNotNull { (bookId, files) ->
|
||||
val best = files
|
||||
.mapNotNull { file ->
|
||||
runCatching { SharedFolderBookMetadata.fromJsonString(file.readText()) }.getOrNull()
|
||||
}
|
||||
.filter { it.bookId == bookId }
|
||||
.maxByOrNull { it.lastModifiedTimestamp }
|
||||
best?.let { bookId to it }
|
||||
}
|
||||
.toMap()
|
||||
}
|
||||
|
||||
private fun saveMetadataToFolder(root: File, metadata: SharedFolderBookMetadata) {
|
||||
val syncDir = File(root, LOCAL_FOLDER_SYNC_DATA_DIR).apply { mkdirs() }
|
||||
val existing = resolveMetadataConflicts(syncDir, metadata.bookId, cleanup = true)
|
||||
if (existing != null && existing.lastModifiedTimestamp > metadata.lastModifiedTimestamp) return
|
||||
|
||||
val target = File(syncDir, ".${metadata.bookId}.json")
|
||||
val temp = File(syncDir, ".${metadata.bookId}.tmp")
|
||||
runCatching {
|
||||
temp.writeText(metadata.toJsonString())
|
||||
moveReplacing(temp, target)
|
||||
}.onFailure {
|
||||
runCatching { temp.delete() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveMetadataConflicts(
|
||||
syncDir: File,
|
||||
bookId: String,
|
||||
cleanup: Boolean
|
||||
): SharedFolderBookMetadata? {
|
||||
val candidates = syncDir.listFiles().orEmpty().filter { file ->
|
||||
val normalized = file.name.removePrefix(".")
|
||||
file.isFile && (
|
||||
normalized == "$bookId.json" ||
|
||||
normalized.startsWith("$bookId.sync-conflict") ||
|
||||
normalized.startsWith("$bookId.json.sync-conflict")
|
||||
)
|
||||
}
|
||||
if (candidates.isEmpty()) return null
|
||||
|
||||
val parsed = candidates.mapNotNull { file ->
|
||||
val metadata = runCatching { SharedFolderBookMetadata.fromJsonString(file.readText()) }.getOrNull()
|
||||
metadata?.takeIf { it.bookId == bookId }?.let { file to it }
|
||||
}
|
||||
val winner = parsed.maxByOrNull { it.second.lastModifiedTimestamp } ?: return null
|
||||
|
||||
if (cleanup) {
|
||||
candidates
|
||||
.filterNot { it == winner.first }
|
||||
.forEach { runCatching { it.delete() } }
|
||||
val correctName = ".${bookId}.json"
|
||||
if (winner.first.name != correctName) {
|
||||
runCatching { moveReplacing(winner.first, File(syncDir, correctName)) }
|
||||
}
|
||||
}
|
||||
|
||||
return winner.second
|
||||
}
|
||||
|
||||
private fun preloadAnnotationSidecars(root: File): Map<String, AnnotationSidecar> {
|
||||
val syncDir = File(root, LOCAL_FOLDER_SYNC_DATA_DIR)
|
||||
if (!syncDir.isDirectory) return emptyMap()
|
||||
return syncDir.listFiles().orEmpty()
|
||||
.asSequence()
|
||||
.filter { it.isFile }
|
||||
.mapNotNull { file -> file.annotationBookIdOrNull()?.let { it to file } }
|
||||
.groupBy({ it.first }, { it.second })
|
||||
.mapNotNull { (bookId, files) ->
|
||||
val best = files
|
||||
.mapNotNull { it.readAnnotationSidecarOrNull() }
|
||||
.maxByOrNull { it.timestamp }
|
||||
best?.let { bookId to it }
|
||||
}
|
||||
.toMap()
|
||||
}
|
||||
|
||||
private fun importAnnotationSidecars(root: File, books: List<BookItem>) {
|
||||
if (books.isEmpty()) return
|
||||
val sidecars = preloadAnnotationSidecars(root)
|
||||
if (sidecars.isEmpty()) return
|
||||
|
||||
books.forEach { book ->
|
||||
val path = book.path?.takeIf { it.isNotBlank() } ?: return@forEach
|
||||
if (book.type != FileType.PDF) return@forEach
|
||||
val sidecar = sidecars[book.id] ?: return@forEach
|
||||
val annotationFile = desktopPdfAnnotationFile(path)
|
||||
val bookmarkFile = desktopPdfBookmarkFile(path)
|
||||
val richTextFile = desktopPdfRichTextFile(path)
|
||||
val localTimestamp = maxOf(
|
||||
annotationFile.lastModifiedIfFile(),
|
||||
bookmarkFile.lastModifiedIfFile(),
|
||||
richTextFile.lastModifiedIfFile()
|
||||
)
|
||||
if (sidecar.timestamp <= localTimestamp + 1000L) {
|
||||
if (sidecar.data.containsKey("text") || richTextFile.isFile) {
|
||||
SharedPdfRichTextLog.d(
|
||||
"desktop.sync.importSkipOlder book=${book.id} sidecarTs=${sidecar.timestamp} " +
|
||||
"localTs=$localTimestamp hasSidecarText=${sidecar.data.containsKey("text")} " +
|
||||
"richFile=\"${richTextFile.absolutePath.richSyncPreview()}\""
|
||||
)
|
||||
}
|
||||
return@forEach
|
||||
}
|
||||
if (sidecar.data.hasPdfAnnotationPayload()) {
|
||||
val annotations = SharedPdfAnnotationSidecarCodec.annotationsFromData(sidecar.data)
|
||||
annotationFile.parentFile?.mkdirs()
|
||||
annotationFile.writeText(SharedPdfAnnotationSerializer.encode(annotations))
|
||||
annotationFile.setLastModified(sidecar.timestamp)
|
||||
}
|
||||
sidecar.data["bookmarks"]?.let { bookmarks ->
|
||||
bookmarkFile.parentFile?.mkdirs()
|
||||
bookmarkFile.writeText(desktopFolderSyncJson.encodeToString(JsonElement.serializer(), bookmarks))
|
||||
bookmarkFile.setLastModified(sidecar.timestamp)
|
||||
}
|
||||
sidecar.data["text"]?.let { richText ->
|
||||
val richDocument = SharedPdfRichTextSerializer.decodeElement(richText)
|
||||
SharedPdfRichTextLog.d(
|
||||
"desktop.sync.importRichText book=${book.id} timestamp=${sidecar.timestamp} " +
|
||||
"textLen=${richDocument.text.length} spans=${richDocument.spans.size} " +
|
||||
"file=\"${richTextFile.absolutePath.richSyncPreview()}\""
|
||||
)
|
||||
richTextFile.parentFile?.mkdirs()
|
||||
richTextFile.writeText(SharedPdfRichTextSerializer.encode(richDocument))
|
||||
richTextFile.setLastModified(sidecar.timestamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveAnnotationSidecar(
|
||||
root: File,
|
||||
bookId: String,
|
||||
jsonPayload: String,
|
||||
timestamp: Long
|
||||
) {
|
||||
val syncDir = File(root, LOCAL_FOLDER_SYNC_DATA_DIR).apply { mkdirs() }
|
||||
val data = desktopFolderSyncJson.parseElementOrNull(jsonPayload)?.jsonObjectOrNull() ?: return
|
||||
val existing = resolveAnnotationConflicts(syncDir, bookId, cleanup = true)
|
||||
if (existing != null && existing.timestamp >= timestamp) {
|
||||
if (data.containsKey("text")) {
|
||||
SharedPdfRichTextLog.d(
|
||||
"desktop.sync.saveSidecarSkipExisting book=$bookId existingTs=${existing.timestamp} " +
|
||||
"candidateTs=$timestamp targetRoot=\"${root.absolutePath.richSyncPreview()}\""
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val wrapper = JsonObject(
|
||||
mapOf(
|
||||
"version" to JsonPrimitive(1),
|
||||
"timestamp" to JsonPrimitive(timestamp),
|
||||
"data" to data
|
||||
)
|
||||
)
|
||||
val target = File(syncDir, ".${bookId}${LOCAL_FOLDER_ANNOTATION_SUFFIX}.json")
|
||||
val temp = File(syncDir, ".${bookId}${LOCAL_FOLDER_ANNOTATION_SUFFIX}.tmp")
|
||||
runCatching {
|
||||
temp.writeText(desktopFolderSyncJson.encodeToString(JsonElement.serializer(), wrapper))
|
||||
moveReplacing(temp, target)
|
||||
if (data.containsKey("text")) {
|
||||
SharedPdfRichTextLog.d(
|
||||
"desktop.sync.saveSidecar book=$bookId timestamp=$timestamp " +
|
||||
"target=\"${target.absolutePath.richSyncPreview()}\""
|
||||
)
|
||||
}
|
||||
}.onFailure {
|
||||
if (data.containsKey("text")) {
|
||||
SharedPdfRichTextLog.d(
|
||||
"desktop.sync.saveSidecarFailed book=$bookId timestamp=$timestamp " +
|
||||
"target=\"${target.absolutePath.richSyncPreview()}\" error=${it.message}"
|
||||
)
|
||||
}
|
||||
runCatching { temp.delete() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveAnnotationConflicts(
|
||||
syncDir: File,
|
||||
bookId: String,
|
||||
cleanup: Boolean
|
||||
): AnnotationSidecar? {
|
||||
val candidates = syncDir.listFiles().orEmpty().filter { file ->
|
||||
file.isFile && file.annotationBookIdOrNull() == bookId
|
||||
}
|
||||
if (candidates.isEmpty()) return null
|
||||
val parsed = candidates.mapNotNull { file ->
|
||||
file.readAnnotationSidecarOrNull()?.let { file to it }
|
||||
}
|
||||
val winner = parsed.maxByOrNull { it.second.timestamp } ?: return null
|
||||
|
||||
if (cleanup) {
|
||||
candidates
|
||||
.filterNot { it == winner.first }
|
||||
.forEach { runCatching { it.delete() } }
|
||||
val correctName = ".${bookId}${LOCAL_FOLDER_ANNOTATION_SUFFIX}.json"
|
||||
if (winner.first.name != correctName) {
|
||||
runCatching { moveReplacing(winner.first, File(syncDir, correctName)) }
|
||||
}
|
||||
}
|
||||
|
||||
return winner.second
|
||||
}
|
||||
}
|
||||
|
||||
private data class AnnotationSidecar(
|
||||
val timestamp: Long,
|
||||
val data: JsonObject
|
||||
)
|
||||
|
||||
private val desktopFolderSyncJson = Json {
|
||||
ignoreUnknownKeys = true
|
||||
prettyPrint = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
private fun File.shouldEnterSyncedFolder(): Boolean {
|
||||
if (!isDirectory) return false
|
||||
if (name == LOCAL_FOLDER_SYNC_DATA_DIR) return false
|
||||
if (name.startsWith(".")) return false
|
||||
return runCatching { !isHidden }.getOrDefault(true)
|
||||
}
|
||||
|
||||
private fun File.shouldSyncBookFile(): Boolean {
|
||||
if (name.startsWith(".")) return false
|
||||
if (extension.equals("json", ignoreCase = true)) return false
|
||||
return parentFile?.name != LOCAL_FOLDER_SYNC_DATA_DIR
|
||||
}
|
||||
|
||||
private fun File.metadataBookIdOrNull(): String? {
|
||||
val fileName = name
|
||||
if (fileName.contains(LOCAL_FOLDER_ANNOTATION_SUFFIX)) return null
|
||||
if (fileName.endsWith(".tmp") || fileName.contains(".syncthing.")) return null
|
||||
if (!fileName.endsWith(".json") && !fileName.contains(".sync-conflict")) return null
|
||||
val normalized = fileName.removePrefix(".")
|
||||
val base = if (normalized.contains(".sync-conflict")) {
|
||||
normalized.substringBefore(".sync-conflict")
|
||||
} else {
|
||||
normalized.substringBeforeLast(".json")
|
||||
}
|
||||
return base.removeSuffix(".json").takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
private fun File.annotationBookIdOrNull(): String? {
|
||||
var candidate = name
|
||||
if (!candidate.contains(LOCAL_FOLDER_ANNOTATION_SUFFIX)) return null
|
||||
if (!candidate.endsWith(".json") || candidate.endsWith(".tmp")) return null
|
||||
if (candidate.contains(".syncthing.")) return null
|
||||
if (candidate.contains(".sync-conflict")) {
|
||||
candidate = candidate.substringBefore(".sync-conflict")
|
||||
}
|
||||
candidate = candidate.substringBeforeLast(".json")
|
||||
if (candidate.endsWith(LOCAL_FOLDER_ANNOTATION_SUFFIX)) {
|
||||
candidate = candidate.substring(0, candidate.length - LOCAL_FOLDER_ANNOTATION_SUFFIX.length)
|
||||
}
|
||||
return candidate.removePrefix(".").takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
private fun File.readAnnotationSidecarOrNull(): AnnotationSidecar? {
|
||||
return runCatching {
|
||||
val root = desktopFolderSyncJson.parseToJsonElement(readText()).jsonObject
|
||||
val timestamp = root["timestamp"]?.jsonPrimitive?.longOrNull ?: 0L
|
||||
val data = root["data"]?.jsonObjectOrNull() ?: error("Missing annotation sidecar data")
|
||||
AnnotationSidecar(timestamp = timestamp, data = data)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun Json.parseElementOrNull(raw: String): JsonElement? {
|
||||
return runCatching { parseToJsonElement(raw) }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonElement.jsonObjectOrNull(): JsonObject? {
|
||||
if (this is JsonNull) return null
|
||||
return runCatching { jsonObject }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.hasPdfAnnotationPayload(): Boolean {
|
||||
return containsKey(SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS) ||
|
||||
containsKey(SharedPdfAnnotationSidecarCodec.KEY_LEGACY_INK) ||
|
||||
containsKey(SharedPdfAnnotationSidecarCodec.KEY_LEGACY_TEXT_BOXES) ||
|
||||
containsKey(SharedPdfAnnotationSidecarCodec.KEY_LEGACY_HIGHLIGHTS)
|
||||
}
|
||||
|
||||
private fun File.canonicalOrAbsolute(): File {
|
||||
return runCatching { canonicalFile }.getOrElse { absoluteFile }
|
||||
}
|
||||
|
||||
private fun File.lastModifiedIfFile(): Long {
|
||||
return if (isFile) lastModified() else 0L
|
||||
}
|
||||
|
||||
private fun String.richSyncPreview(maxLength: Int = 160): String {
|
||||
return replace(Regex("\\s+"), " ")
|
||||
.trim()
|
||||
.let { if (it.length <= maxLength) it else it.take(maxLength) + "..." }
|
||||
.replace("\"", "\\\"")
|
||||
}
|
||||
|
||||
private fun moveReplacing(source: File, target: File) {
|
||||
target.parentFile?.mkdirs()
|
||||
try {
|
||||
Files.move(
|
||||
source.toPath(),
|
||||
target.toPath(),
|
||||
StandardCopyOption.REPLACE_EXISTING,
|
||||
StandardCopyOption.ATOMIC_MOVE
|
||||
)
|
||||
} catch (_: AtomicMoveNotSupportedException) {
|
||||
Files.move(
|
||||
source.toPath(),
|
||||
target.toPath(),
|
||||
StandardCopyOption.REPLACE_EXISTING
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,190 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import com.aryan.reader.shared.opds.OpdsAcquisition
|
||||
import com.aryan.reader.shared.opds.OpdsCatalog
|
||||
import com.aryan.reader.shared.opds.OpdsEntry
|
||||
import com.aryan.reader.shared.opds.OpdsFeed
|
||||
import com.aryan.reader.shared.opds.SharedOpdsCatalogs
|
||||
import com.aryan.reader.shared.opds.SharedOpdsDownloadNamer
|
||||
import com.aryan.reader.shared.opds.SharedOpdsParser
|
||||
import com.aryan.reader.shared.opds.SharedOpdsRepository
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.net.Authenticator
|
||||
import java.net.PasswordAuthentication
|
||||
import java.net.URI
|
||||
import java.net.http.HttpClient
|
||||
import java.net.http.HttpRequest
|
||||
import java.net.http.HttpResponse
|
||||
import java.time.Duration
|
||||
import java.util.UUID
|
||||
|
||||
internal class DesktopOpdsRepository(
|
||||
private val catalogFile: File = defaultCatalogFile(),
|
||||
private val idFactory: () -> String = { UUID.randomUUID().toString() }
|
||||
) : SharedOpdsRepository {
|
||||
private val parser = SharedOpdsParser()
|
||||
|
||||
override fun loadCatalogs(): List<OpdsCatalog> {
|
||||
val rawJson = catalogFile.takeIf { it.exists() }?.readText()
|
||||
val decodedCatalogs = SharedOpdsCatalogs.decode(rawJson)
|
||||
val catalogs = decodedCatalogs.ifEmpty { SharedOpdsCatalogs.defaultCatalogs(idFactory) }
|
||||
if (decodedCatalogs.isEmpty()) saveCatalogs(catalogs)
|
||||
return catalogs
|
||||
}
|
||||
|
||||
override fun saveCatalogs(catalogs: List<OpdsCatalog>) {
|
||||
catalogFile.parentFile?.mkdirs()
|
||||
catalogFile.writeText(SharedOpdsCatalogs.encode(catalogs))
|
||||
}
|
||||
|
||||
override suspend fun fetchFeed(url: String, username: String?, password: String?): Result<OpdsFeed> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val response = DesktopOpdsHttp.fetchString(url, username, password)
|
||||
if (response.statusCode !in 200..299) {
|
||||
error("HTTP ${response.statusCode}")
|
||||
}
|
||||
if (response.body.isBlank()) error("Empty response body")
|
||||
parser.parse(response.body, url)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getSearchTemplate(openSearchUrl: String, username: String?, password: String?): String? = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val response = DesktopOpdsHttp.fetchString(openSearchUrl, username, password)
|
||||
if (response.statusCode !in 200..299) return@withContext null
|
||||
parser.extractOpenSearchTemplate(response.body, openSearchUrl)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
suspend fun downloadBook(
|
||||
entry: OpdsEntry,
|
||||
acquisition: OpdsAcquisition,
|
||||
catalog: OpdsCatalog?,
|
||||
onProgress: (Float?) -> Unit
|
||||
): File = withContext(Dispatchers.IO) {
|
||||
val response = DesktopOpdsHttp.fetchStream(acquisition.url, catalog?.username, catalog?.password)
|
||||
if (response.statusCode !in 200..299) {
|
||||
response.body.close()
|
||||
error("HTTP ${response.statusCode}")
|
||||
}
|
||||
|
||||
val contentLength = response.headers.firstValueAsLong("content-length").orElse(-1L)
|
||||
val contentDisposition = response.headers.firstValue("content-disposition").orElse(null)
|
||||
val urlName = runCatching {
|
||||
URI(acquisition.url).path.substringAfterLast('/').takeIf { it.isNotBlank() }
|
||||
}.getOrNull()
|
||||
val extension = SharedOpdsDownloadNamer.resolveExtension(acquisition, contentDisposition, urlName)
|
||||
val target = uniqueDownloadFile(SharedOpdsDownloadNamer.safeFileStem(entry.title), extension)
|
||||
|
||||
response.body.use { input ->
|
||||
target.outputStream().use { output ->
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
var totalRead = 0L
|
||||
var lastProgressAt = 0L
|
||||
while (true) {
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
if (read > 0) {
|
||||
output.write(buffer, 0, read)
|
||||
totalRead += read
|
||||
if (contentLength > 0) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastProgressAt >= 200L) {
|
||||
onProgress((totalRead.toFloat() / contentLength.toFloat()).coerceIn(0f, 1f))
|
||||
lastProgressAt = now
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
onProgress(1f)
|
||||
target
|
||||
}
|
||||
|
||||
fun catalogById(id: String?): OpdsCatalog? {
|
||||
if (id.isNullOrBlank()) return null
|
||||
return loadCatalogs().firstOrNull { it.id == id }
|
||||
}
|
||||
|
||||
private fun uniqueDownloadFile(stem: String, extension: String): File {
|
||||
val dir = opdsDownloadsDir().apply { mkdirs() }
|
||||
var candidate = File(dir, "$stem$extension")
|
||||
var index = 1
|
||||
while (candidate.exists()) {
|
||||
candidate = File(dir, "${stem}_$index$extension")
|
||||
index += 1
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun defaultCatalogFile(): File {
|
||||
return File(DesktopLibraryDatabase.defaultDatabaseFile().parentFile, "opds_catalogs.json")
|
||||
}
|
||||
|
||||
fun opdsDownloadsDir(): File {
|
||||
return File(DesktopLibraryDatabase.defaultDatabaseFile().parentFile, "opds_downloads")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal data class DesktopOpdsTextResponse(
|
||||
val statusCode: Int,
|
||||
val body: String
|
||||
)
|
||||
|
||||
internal data class DesktopOpdsStreamResponse(
|
||||
val statusCode: Int,
|
||||
val headers: java.net.http.HttpHeaders,
|
||||
val body: java.io.InputStream
|
||||
)
|
||||
|
||||
internal object DesktopOpdsHttp {
|
||||
fun fetchString(url: String, username: String?, password: String?): DesktopOpdsTextResponse {
|
||||
val request = request(url).build()
|
||||
val response = client(username, password).send(request, HttpResponse.BodyHandlers.ofString())
|
||||
return DesktopOpdsTextResponse(response.statusCode(), response.body().orEmpty())
|
||||
}
|
||||
|
||||
fun fetchStream(url: String, username: String?, password: String?): DesktopOpdsStreamResponse {
|
||||
val request = request(url).build()
|
||||
val response = client(username, password).send(request, HttpResponse.BodyHandlers.ofInputStream())
|
||||
return DesktopOpdsStreamResponse(response.statusCode(), response.headers(), response.body())
|
||||
}
|
||||
|
||||
fun fetchBytes(url: String, catalog: OpdsCatalog?): ByteArray {
|
||||
val request = request(url).build()
|
||||
val response = client(catalog?.username, catalog?.password).send(request, HttpResponse.BodyHandlers.ofByteArray())
|
||||
if (response.statusCode() !in 200..299) {
|
||||
error("HTTP ${response.statusCode()}")
|
||||
}
|
||||
return response.body()
|
||||
}
|
||||
|
||||
private fun request(url: String): HttpRequest.Builder {
|
||||
return HttpRequest.newBuilder(URI(url.trim()))
|
||||
.timeout(Duration.ofSeconds(45))
|
||||
.header("User-Agent", "EpistemeReader/1.0 (Desktop)")
|
||||
}
|
||||
|
||||
private fun client(username: String?, password: String?): HttpClient {
|
||||
val builder = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(20))
|
||||
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||
|
||||
if (!username.isNullOrBlank() && !password.isNullOrBlank()) {
|
||||
builder.authenticator(
|
||||
object : Authenticator() {
|
||||
override fun getPasswordAuthentication(): PasswordAuthentication {
|
||||
return PasswordAuthentication(username, password.toCharArray())
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
private const val DesktopTtsLogTag = "EpistemeDesktopTts"
|
||||
|
||||
internal fun logDesktopTts(message: String) {
|
||||
println("$DesktopTtsLogTag $message")
|
||||
}
|
||||
|
||||
internal fun Throwable.desktopTtsSummary(): String {
|
||||
val type = this::class.java.simpleName.ifBlank { "Throwable" }
|
||||
return "$type: ${message.orEmpty().desktopTtsPreview(220)}"
|
||||
}
|
||||
|
||||
internal fun String.desktopTtsPreview(maxLength: Int = 120): String {
|
||||
return replace(Regex("\\s+"), " ")
|
||||
.trim()
|
||||
.let { if (it.length <= maxLength) it else it.take(maxLength) + "..." }
|
||||
.replace("\"", "\\\"")
|
||||
}
|
||||
2083
desktopApp/src/desktopMain/resources/google_fonts.json
Normal file
BIN
desktopApp/src/desktopMain/resources/textures/classy_fabric.webp
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
BIN
desktopApp/src/desktopMain/resources/textures/light-veneer.webp
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
desktopApp/src/desktopMain/resources/textures/retina_wood.webp
Normal file
|
After Width: | Height: | Size: 9 KiB |
BIN
desktopApp/src/desktopMain/resources/textures/retro_intro.webp
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
desktopApp/src/desktopMain/resources/textures/texture_canvas.png
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
desktopApp/src/desktopMain/resources/textures/texture_eink.webp
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
desktopApp/src/desktopMain/resources/textures/texture_paper.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
desktopApp/src/desktopMain/resources/textures/texture_slate.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
|
|
@ -0,0 +1,106 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import com.aryan.reader.shared.GEMINI_CLOUD_TTS_MODEL_ID
|
||||
import com.aryan.reader.shared.ReaderAiByokSettings
|
||||
import java.nio.file.Files
|
||||
import kotlin.io.path.readText
|
||||
import kotlin.io.path.writeText
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DesktopAiByokStoreTest {
|
||||
@Test
|
||||
fun `save keeps keys out of plaintext settings file`() {
|
||||
val settingsFile = Files.createTempDirectory("reader-ai-store").resolve("ai-byok.properties")
|
||||
val store = DesktopAiByokStore(settingsFile.toFile(), ReversibleSecretCodec)
|
||||
|
||||
store.save(
|
||||
ReaderAiByokSettings(
|
||||
geminiKey = "gemini_secret",
|
||||
groqKey = "groq_secret",
|
||||
modelForAll = "groq:qwen/qwen3-32b",
|
||||
ttsModel = GEMINI_CLOUD_TTS_MODEL_ID
|
||||
)
|
||||
)
|
||||
|
||||
val raw = settingsFile.readText()
|
||||
assertFalse(raw.contains("gemini_secret"))
|
||||
assertFalse(raw.contains("groq_secret"))
|
||||
assertTrue(raw.contains("geminiKeyProtected="))
|
||||
assertTrue(raw.contains("groqKeyProtected="))
|
||||
|
||||
val loaded = store.load()
|
||||
assertEquals("gemini_secret", loaded.geminiKey)
|
||||
assertEquals("groq_secret", loaded.groqKey)
|
||||
assertEquals("groq:qwen/qwen3-32b", loaded.modelForAll)
|
||||
assertEquals(GEMINI_CLOUD_TTS_MODEL_ID, loaded.ttsModel)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `load migrates legacy plaintext keys into protected entries`() {
|
||||
val settingsFile = Files.createTempDirectory("reader-ai-store-legacy").resolve("ai-byok.properties")
|
||||
settingsFile.writeText(
|
||||
"""
|
||||
geminiKey=old_gemini
|
||||
groqKey=old_groq
|
||||
modelForAll=groq:qwen/qwen3-32b
|
||||
useOneModel=true
|
||||
""".trimIndent()
|
||||
)
|
||||
val store = DesktopAiByokStore(settingsFile.toFile(), ReversibleSecretCodec)
|
||||
|
||||
val loaded = store.load()
|
||||
|
||||
assertEquals("old_gemini", loaded.geminiKey)
|
||||
assertEquals("old_groq", loaded.groqKey)
|
||||
assertEquals(GEMINI_CLOUD_TTS_MODEL_ID, loaded.ttsModel)
|
||||
val raw = settingsFile.readText()
|
||||
assertFalse(raw.contains("geminiKey=old_gemini"))
|
||||
assertFalse(raw.contains("groqKey=old_groq"))
|
||||
assertTrue(raw.contains("geminiKeyProtected="))
|
||||
assertTrue(raw.contains("groqKeyProtected="))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `model settings persist when secure key storage is unavailable`() {
|
||||
val settingsFile = Files.createTempDirectory("reader-ai-store-unavailable").resolve("ai-byok.properties")
|
||||
val store = DesktopAiByokStore(settingsFile.toFile(), UnavailableSecretCodec)
|
||||
|
||||
store.save(
|
||||
ReaderAiByokSettings(
|
||||
geminiKey = "session_only",
|
||||
modelForAll = "groq:qwen/qwen3-32b",
|
||||
ttsModel = GEMINI_CLOUD_TTS_MODEL_ID
|
||||
)
|
||||
)
|
||||
|
||||
val raw = settingsFile.readText()
|
||||
assertFalse(raw.contains("session_only"))
|
||||
assertFalse(raw.contains("geminiKeyProtected="))
|
||||
|
||||
val loaded = store.load()
|
||||
assertEquals("", loaded.geminiKey)
|
||||
assertEquals("groq:qwen/qwen3-32b", loaded.modelForAll)
|
||||
assertEquals(GEMINI_CLOUD_TTS_MODEL_ID, loaded.ttsModel)
|
||||
}
|
||||
|
||||
private object ReversibleSecretCodec : DesktopSecretCodec {
|
||||
override val isAvailable: Boolean = true
|
||||
|
||||
override fun protect(value: String): String {
|
||||
return "test:" + value.reversed()
|
||||
}
|
||||
|
||||
override fun unprotect(value: String): String {
|
||||
return value.removePrefix("test:").reversed()
|
||||
}
|
||||
}
|
||||
|
||||
private object UnavailableSecretCodec : DesktopSecretCodec {
|
||||
override val isAvailable: Boolean = false
|
||||
override fun protect(value: String): String = ""
|
||||
override fun unprotect(value: String): String = ""
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import com.aryan.reader.shared.FileType
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.util.Base64
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DesktopComicArchiveTest {
|
||||
@Test
|
||||
fun `cbz archive loads image pages for pdf reader surface`() = withTempDir { dir ->
|
||||
val cbz = File(dir, "comic.cbz")
|
||||
ZipOutputStream(cbz.outputStream()).use { zip ->
|
||||
zip.putNextEntry(ZipEntry("pages/001.png"))
|
||||
zip.write(onePixelPngBytes())
|
||||
zip.closeEntry()
|
||||
}
|
||||
|
||||
val document = DesktopPdfium.loadComic(cbz, FileType.CBZ)
|
||||
try {
|
||||
assertEquals(1, document.pageCount)
|
||||
assertEquals(1f, document.pageSizes.single().width)
|
||||
assertEquals(1f, document.pageSizes.single().height)
|
||||
|
||||
val image = DesktopPdfium.renderPageBufferedImage(document, pageIndex = 0, scale = 8f)
|
||||
|
||||
assertEquals(8, image.width)
|
||||
assertEquals(8, image.height)
|
||||
} finally {
|
||||
document.close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop comic types are routed through shared reader capability map`() {
|
||||
assertTrue(DesktopComicArchive.canLoad(FileType.CBZ))
|
||||
assertTrue(DesktopComicArchive.canLoad(FileType.CBR))
|
||||
assertTrue(DesktopComicArchive.canLoad(FileType.CB7))
|
||||
}
|
||||
|
||||
private fun withTempDir(block: (File) -> Unit) {
|
||||
val dir = Files.createTempDirectory("reader-desktop-comic").toFile()
|
||||
try {
|
||||
block(dir)
|
||||
} finally {
|
||||
dir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
private fun onePixelPngBytes(): ByteArray {
|
||||
return Base64.getDecoder().decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII="
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class DesktopComposeInteropTest {
|
||||
@Test
|
||||
fun `desktop enables Compose interop blending before app startup`() {
|
||||
withSystemProperty(ComposeInteropBlendingProperty, null) {
|
||||
configureComposeSwingInterop()
|
||||
|
||||
assertEquals(ComposeInteropBlendingEnabled, System.getProperty(ComposeInteropBlendingProperty))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop treats blank Compose interop blending value as unset`() {
|
||||
withSystemProperty(ComposeInteropBlendingProperty, " ") {
|
||||
configureComposeSwingInterop()
|
||||
|
||||
assertEquals(ComposeInteropBlendingEnabled, System.getProperty(ComposeInteropBlendingProperty))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop preserves explicit Compose interop blending override`() {
|
||||
withSystemProperty(ComposeInteropBlendingProperty, "false") {
|
||||
configureComposeSwingInterop()
|
||||
|
||||
assertEquals("false", System.getProperty(ComposeInteropBlendingProperty))
|
||||
}
|
||||
}
|
||||
|
||||
private fun withSystemProperty(
|
||||
key: String,
|
||||
value: String?,
|
||||
block: () -> Unit
|
||||
) {
|
||||
val previous = System.getProperty(key)
|
||||
try {
|
||||
if (value == null) {
|
||||
System.clearProperty(key)
|
||||
} else {
|
||||
System.setProperty(key, value)
|
||||
}
|
||||
block()
|
||||
} finally {
|
||||
if (previous == null) {
|
||||
System.clearProperty(key)
|
||||
} else {
|
||||
System.setProperty(key, previous)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import com.aryan.reader.shared.CustomFontItem
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DesktopCustomFontStoreTest {
|
||||
@Test
|
||||
fun `import font copies supported file into desktop font store`() {
|
||||
val tempRoot = Files.createTempDirectory("episteme-font-store-test").toFile()
|
||||
try {
|
||||
val source = File(tempRoot, "Literata.ttf").apply { writeText("font-bytes") }
|
||||
val store = DesktopCustomFontStore(File(tempRoot, "store"))
|
||||
|
||||
val font = store.importFont(source).getOrThrow()
|
||||
|
||||
assertEquals("Literata", font.displayName)
|
||||
assertEquals("ttf", font.fileExtension)
|
||||
assertTrue(File(font.path).isFile)
|
||||
assertEquals("font-bytes", File(font.path).readText())
|
||||
} finally {
|
||||
tempRoot.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `import font rejects unsupported extension`() {
|
||||
val tempRoot = Files.createTempDirectory("episteme-font-store-test").toFile()
|
||||
try {
|
||||
val source = File(tempRoot, "not-a-font.txt").apply { writeText("nope") }
|
||||
val store = DesktopCustomFontStore(File(tempRoot, "store"))
|
||||
|
||||
assertTrue(store.importFont(source).isFailure)
|
||||
} finally {
|
||||
tempRoot.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `delete font only removes files inside desktop font store`() {
|
||||
val tempRoot = Files.createTempDirectory("episteme-font-store-test").toFile()
|
||||
try {
|
||||
val storeDir = File(tempRoot, "store").apply { mkdirs() }
|
||||
val stored = File(storeDir, "font_a.ttf").apply { writeText("stored") }
|
||||
val outside = File(tempRoot, "outside.ttf").apply { writeText("outside") }
|
||||
val store = DesktopCustomFontStore(storeDir)
|
||||
|
||||
assertTrue(store.deleteFont(stored.toFontItem()))
|
||||
assertFalse(stored.exists())
|
||||
assertFalse(store.deleteFont(outside.toFontItem()))
|
||||
assertTrue(outside.exists())
|
||||
} finally {
|
||||
tempRoot.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `google font css parser extracts first https font url`() {
|
||||
val css = """
|
||||
@font-face {
|
||||
font-family: 'Literata';
|
||||
src: url(https://fonts.gstatic.com/s/literata/v35/font.ttf) format('truetype');
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
assertEquals("https://fonts.gstatic.com/s/literata/v35/font.ttf", googleFontDownloadUrlFromCss(css))
|
||||
assertEquals("ttf", googleFontFileExtension("https://fonts.gstatic.com/s/literata/v35/font.ttf?foo=bar"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `google fonts json parser ignores blank names`() {
|
||||
assertEquals(listOf("Inter", "Literata"), googleFontsFromJson("""["Inter", "", " Literata "]"""))
|
||||
}
|
||||
|
||||
private fun File.toFontItem(): CustomFontItem {
|
||||
return CustomFontItem(
|
||||
id = nameWithoutExtension,
|
||||
displayName = nameWithoutExtension,
|
||||
fileName = name,
|
||||
fileExtension = extension,
|
||||
path = absolutePath,
|
||||
timestamp = 1L
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import com.aryan.reader.shared.BookItem
|
||||
import com.aryan.reader.shared.FileType
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.util.Base64
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DesktopFolderMetadataExtractorTest {
|
||||
@Test
|
||||
fun `direct imported epub gets text metadata and embedded cover`() = withCoverCacheDir { tempDir ->
|
||||
val epub = File(tempDir, "direct.epub")
|
||||
writeEpub(
|
||||
target = epub,
|
||||
opf = """
|
||||
<package xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<metadata>
|
||||
<dc:title>Direct EPUB</dc:title>
|
||||
<dc:creator>Ada Lovelace</dc:creator>
|
||||
<meta name="cover" content="cover-image" />
|
||||
</metadata>
|
||||
<manifest>
|
||||
<item id="cover-image" href="images/cover.png" media-type="image/png" />
|
||||
</manifest>
|
||||
</package>
|
||||
""".trimIndent()
|
||||
)
|
||||
val book = bookFor(epub, FileType.EPUB)
|
||||
|
||||
val result = DesktopFolderMetadataExtractor.enrichImportedBooks(
|
||||
books = listOf(book),
|
||||
importedBookIds = setOf(book.id)
|
||||
)
|
||||
|
||||
val enriched = result.books.single()
|
||||
assertEquals("Direct EPUB", enriched.title)
|
||||
assertEquals("Ada Lovelace", enriched.author)
|
||||
assertTrue(enriched.folderTextMetadataParsed)
|
||||
assertTrue(File(assertNotNull(enriched.coverImagePath)).isFile)
|
||||
assertEquals(1, result.stats.updatedBooks)
|
||||
assertEquals(1, result.stats.coversUpdated)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `direct imported text file gets generated cover`() = withCoverCacheDir { tempDir ->
|
||||
val textFile = File(tempDir, "notes.txt").apply { writeText("Notes") }
|
||||
val book = bookFor(textFile, FileType.TXT, title = "Notes")
|
||||
|
||||
val result = DesktopFolderMetadataExtractor.enrichImportedBooks(
|
||||
books = listOf(book),
|
||||
importedBookIds = setOf(book.id)
|
||||
)
|
||||
|
||||
val enriched = result.books.single()
|
||||
assertEquals("Notes", enriched.title)
|
||||
assertFalse(enriched.folderTextMetadataParsed)
|
||||
assertTrue(File(assertNotNull(enriched.coverImagePath)).isFile)
|
||||
assertEquals(1, result.stats.updatedBooks)
|
||||
assertEquals(1, result.stats.coversUpdated)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `direct imported docx gets text metadata and generated cover`() = withCoverCacheDir { tempDir ->
|
||||
val docx = File(tempDir, "direct.docx")
|
||||
writeDocx(
|
||||
target = docx,
|
||||
title = "Direct DOCX",
|
||||
author = "Grace Hopper",
|
||||
bodyText = "Portable desktop document text."
|
||||
)
|
||||
val book = bookFor(docx, FileType.DOCX, title = null)
|
||||
|
||||
val result = DesktopFolderMetadataExtractor.enrichImportedBooks(
|
||||
books = listOf(book),
|
||||
importedBookIds = setOf(book.id)
|
||||
)
|
||||
|
||||
val enriched = result.books.single()
|
||||
assertEquals("Direct DOCX", enriched.title)
|
||||
assertEquals("Grace Hopper", enriched.author)
|
||||
assertTrue(enriched.folderTextMetadataParsed)
|
||||
assertTrue(File(assertNotNull(enriched.coverImagePath)).isFile)
|
||||
assertEquals(1, result.stats.updatedBooks)
|
||||
assertEquals(1, result.stats.coversUpdated)
|
||||
}
|
||||
|
||||
private fun withCoverCacheDir(block: (File) -> Unit) {
|
||||
val tempDir = Files.createTempDirectory("reader-desktop-covers").toFile()
|
||||
val oldCacheDir = System.getProperty("reader.cover.cache.dir")
|
||||
System.setProperty("reader.cover.cache.dir", File(tempDir, "covers").absolutePath)
|
||||
try {
|
||||
block(tempDir)
|
||||
} finally {
|
||||
if (oldCacheDir == null) {
|
||||
System.clearProperty("reader.cover.cache.dir")
|
||||
} else {
|
||||
System.setProperty("reader.cover.cache.dir", oldCacheDir)
|
||||
}
|
||||
tempDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
private fun bookFor(
|
||||
file: File,
|
||||
type: FileType,
|
||||
title: String? = file.nameWithoutExtension
|
||||
): BookItem {
|
||||
return BookItem(
|
||||
id = file.absolutePath,
|
||||
path = file.absolutePath,
|
||||
type = type,
|
||||
displayName = file.name,
|
||||
timestamp = 1L,
|
||||
title = title,
|
||||
fileSize = file.length(),
|
||||
isRecent = false
|
||||
)
|
||||
}
|
||||
|
||||
private fun writeEpub(target: File, opf: String) {
|
||||
ZipOutputStream(target.outputStream()).use { zip ->
|
||||
zip.putText(
|
||||
"META-INF/container.xml",
|
||||
"""
|
||||
<container>
|
||||
<rootfiles>
|
||||
<rootfile full-path="OEBPS/content.opf" />
|
||||
</rootfiles>
|
||||
</container>
|
||||
""".trimIndent()
|
||||
)
|
||||
zip.putText("OEBPS/content.opf", opf)
|
||||
zip.putBytes("OEBPS/images/cover.png", onePixelPngBytes())
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeDocx(target: File, title: String, author: String, bodyText: String) {
|
||||
ZipOutputStream(target.outputStream()).use { zip ->
|
||||
zip.putText(
|
||||
"docProps/core.xml",
|
||||
"""
|
||||
<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<dc:title>$title</dc:title>
|
||||
<dc:creator>$author</dc:creator>
|
||||
</cp:coreProperties>
|
||||
""".trimIndent()
|
||||
)
|
||||
zip.putText(
|
||||
"word/document.xml",
|
||||
"""
|
||||
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:body>
|
||||
<w:p><w:r><w:t>$bodyText</w:t></w:r></w:p>
|
||||
</w:body>
|
||||
</w:document>
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ZipOutputStream.putText(name: String, value: String) {
|
||||
putBytes(name, value.toByteArray(Charsets.UTF_8))
|
||||
}
|
||||
|
||||
private fun ZipOutputStream.putBytes(name: String, value: ByteArray) {
|
||||
putNextEntry(ZipEntry(name))
|
||||
write(value)
|
||||
closeEntry()
|
||||
}
|
||||
|
||||
private fun onePixelPngBytes(): ByteArray {
|
||||
return Base64.getDecoder().decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII="
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DesktopOpdsRepositoryTest {
|
||||
@Test
|
||||
fun `desktop repository persists shared opds catalog rules`() = withTempDir { dir ->
|
||||
var nextId = 0
|
||||
val repository = DesktopOpdsRepository(
|
||||
catalogFile = File(dir, "opds_catalogs.json"),
|
||||
idFactory = { "catalog-${nextId++}" }
|
||||
)
|
||||
|
||||
val defaults = repository.loadCatalogs()
|
||||
assertEquals(2, defaults.size)
|
||||
assertTrue(defaults.all { it.isDefault })
|
||||
|
||||
repository.addCatalogForTest(" Custom ", " https://example.org/opds ", " user ", " pass ")
|
||||
val custom = repository.loadCatalogs().single { !it.isDefault }
|
||||
assertEquals("Custom", custom.title)
|
||||
assertEquals("https://example.org/opds", custom.url)
|
||||
assertEquals("user", custom.username)
|
||||
assertEquals("pass", custom.password)
|
||||
}
|
||||
|
||||
private fun DesktopOpdsRepository.addCatalogForTest(
|
||||
title: String,
|
||||
url: String,
|
||||
username: String?,
|
||||
password: String?
|
||||
) {
|
||||
saveCatalogs(
|
||||
com.aryan.reader.shared.opds.SharedOpdsCatalogs.addCatalog(
|
||||
catalogs = loadCatalogs(),
|
||||
title = title,
|
||||
url = url,
|
||||
username = username,
|
||||
password = password,
|
||||
idFactory = { "custom" }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun withTempDir(block: (File) -> Unit) {
|
||||
val dir = Files.createTempDirectory("reader-desktop-opds").toFile()
|
||||
try {
|
||||
block(dir)
|
||||
} finally {
|
||||
dir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||