library UI + reader mode
This commit is contained in:
commit
8f8b466e89
81 files changed
+7502
No files matched your search
@@ -0,0 +1,311 @@
|
||||
package net.sergeych.toread
|
||||
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||
import net.sergeych.toread.fb2.Fb2Binary
|
||||
import net.sergeych.toread.fb2.Fb2Format
|
||||
import net.sergeych.toread.storage.ContentAnchor
|
||||
import net.sergeych.toread.storage.ReadingStateRecord
|
||||
import net.sergeych.toread.storage.jdbc.H2LibraryDatabase
|
||||
import net.sergeych.toread.storage.jdbc.LibraryScanner
|
||||
import org.jetbrains.skia.Image
|
||||
import java.io.File
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.swing.JFileChooser
|
||||
import kotlin.concurrent.thread
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
actual fun loadDefaultBookBytes(): ByteArray? {
|
||||
var current = File(System.getProperty("user.dir")).absoluteFile
|
||||
while (true) {
|
||||
val candidate = File(current, "test_books/$DefaultBookFileName")
|
||||
if (candidate.isFile) return candidate.readBytes()
|
||||
current = current.parentFile ?: break
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
actual fun decodeBookImage(binary: Fb2Binary): ImageBitmap? =
|
||||
runCatching { Image.makeFromEncoded(binary.imageBytes()).toComposeImageBitmap() }.getOrNull()
|
||||
|
||||
actual fun decodeImageBytes(bytes: ByteArray): ImageBitmap? =
|
||||
runCatching { Image.makeFromEncoded(bytes).toComposeImageBitmap() }.getOrNull()
|
||||
|
||||
actual fun defaultLibraryScanPath(): String? = findProjectRoot()?.let { File(it, "test_books").absolutePath }
|
||||
|
||||
actual suspend fun loadPlatformOpenBookRequest(): PlatformOpenBookRequest? = null
|
||||
|
||||
actual suspend fun chooseLibraryScanDirectory(): String? = withContext(Dispatchers.IO) {
|
||||
val chooser = JFileChooser(defaultLibraryScanPath())
|
||||
chooser.fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
|
||||
chooser.dialogTitle = "Choose library folder"
|
||||
if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) {
|
||||
chooser.selectedFile.absolutePath
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
actual suspend fun loadLibraryItems(): List<LibraryItem> = withContext(Dispatchers.IO) {
|
||||
appendLibraryLog("load library items")
|
||||
openLibraryDatabase().useLibrary { db ->
|
||||
val items = db.files.list().map { file ->
|
||||
val book = file.bookId?.let(db.books::get)
|
||||
val parsed = file.storageUri?.let { uri ->
|
||||
runCatching {
|
||||
File(uri).takeIf { it.isFile }?.readBytes()?.let { Fb2Format.parse(it, uri) }
|
||||
}.getOrNull()
|
||||
}
|
||||
LibraryItem(
|
||||
fileId = file.id,
|
||||
bookId = file.bookId,
|
||||
title = parsed?.title ?: book?.title ?: file.originalFilename ?: file.id,
|
||||
authors = parsed?.authors?.mapNotNull { it.displayName.takeIf(String::isNotBlank) }.orEmpty(),
|
||||
language = parsed?.language ?: book?.language,
|
||||
date = parsed?.date,
|
||||
format = file.format,
|
||||
sizeBytes = file.sizeBytes,
|
||||
storageUri = file.storageUri,
|
||||
lastSeenAt = file.lastSeenAt,
|
||||
coverImage = book?.coverImage ?: parsed?.libraryCoverBinary()?.imageBytes(),
|
||||
coverImageMimeType = book?.coverImageMimeType ?: parsed?.libraryCoverBinary()?.contentType,
|
||||
)
|
||||
}
|
||||
appendLibraryLog("loaded library items count=${items.size}")
|
||||
items
|
||||
}
|
||||
}
|
||||
|
||||
actual suspend fun scanLibrarySubtree(
|
||||
path: String,
|
||||
onProgress: (LibraryScanProgress) -> Unit,
|
||||
): LibraryScanReport = withContext(Dispatchers.IO) {
|
||||
appendLibraryLog("scan requested path=$path")
|
||||
openLibraryDatabase().useLibrary { db ->
|
||||
val summary = LibraryScanner(db, ::appendLibraryLog).scanSubtree(File(path)) {
|
||||
onProgress(
|
||||
LibraryScanProgress(
|
||||
scannedFiles = it.scannedFiles,
|
||||
importedFiles = it.importedFiles,
|
||||
skippedFiles = it.skippedFiles,
|
||||
failedFiles = it.failedFiles,
|
||||
currentFile = it.currentFile,
|
||||
)
|
||||
)
|
||||
}
|
||||
LibraryScanReport(
|
||||
scannedFiles = summary.scannedFiles,
|
||||
importedFiles = summary.importedFiles,
|
||||
skippedFiles = summary.skippedFiles,
|
||||
failedFiles = summary.failedFiles,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
actual suspend fun openLibraryBook(fileId: String): ByteArray? = withContext(Dispatchers.IO) {
|
||||
appendLibraryLog("open book fileId=$fileId")
|
||||
openLibraryDatabase().useLibrary { db ->
|
||||
val file = db.files.get(fileId) ?: return@useLibrary null
|
||||
val bytes = file.storageUri?.let { File(it) }?.takeIf { it.isFile }?.readBytes()
|
||||
appendLibraryLog("open book fileId=$fileId bytes=${bytes?.size ?: 0} uri=${file.storageUri}")
|
||||
bytes
|
||||
}
|
||||
}
|
||||
|
||||
actual suspend fun deleteLibraryItem(fileId: String): Boolean = withContext(Dispatchers.IO) {
|
||||
appendLibraryLog("delete fileId=$fileId")
|
||||
openLibraryDatabase().useLibrary { db ->
|
||||
val deleted = db.files.delete(fileId)
|
||||
appendLibraryLog("delete fileId=$fileId deleted=$deleted")
|
||||
deleted
|
||||
}
|
||||
}
|
||||
|
||||
actual suspend fun loadLibraryReadingPosition(fileId: String): ReadingPosition? = withContext(Dispatchers.IO) {
|
||||
openLibraryDatabase().useLibrary { db ->
|
||||
val file = db.files.get(fileId) ?: return@useLibrary null
|
||||
val clusterId = file.bodyClusterId ?: return@useLibrary null
|
||||
db.readingStates.getForBodyCluster(clusterId)?.anchor?.formatHintsJson?.toReadingPosition()
|
||||
}
|
||||
}
|
||||
|
||||
actual suspend fun saveLibraryReadingPosition(fileId: String, position: ReadingPosition) = withContext(Dispatchers.IO) {
|
||||
openLibraryDatabase().useLibrary { db ->
|
||||
val file = db.files.get(fileId) ?: return@useLibrary
|
||||
val clusterId = file.bodyClusterId ?: return@useLibrary
|
||||
db.readingStates.upsert(
|
||||
ReadingStateRecord(
|
||||
id = "state-$clusterId",
|
||||
bodyClusterId = clusterId,
|
||||
bodyId = file.bodyId,
|
||||
anchor = ContentAnchor(
|
||||
progress = position.itemIndex.toDouble(),
|
||||
formatHintsJson = position.toFormatHintsJson(),
|
||||
),
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
actual suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras = withContext(Dispatchers.IO) {
|
||||
openLibraryDatabase().useLibrary { db ->
|
||||
val file = db.files.get(fileId) ?: return@useLibrary BookInfoExtras()
|
||||
val clusterId = file.bodyClusterId ?: return@useLibrary BookInfoExtras()
|
||||
val readingPosition = db.readingStates.getForBodyCluster(clusterId)?.anchor?.formatHintsJson?.toReadingPosition()
|
||||
BookInfoExtras(
|
||||
bookmarks = db.bookmarks.listForBodyCluster(clusterId).map {
|
||||
BookmarkInfo(
|
||||
title = it.title,
|
||||
selectedText = it.selectedTextSnapshot,
|
||||
progress = it.anchor.progress,
|
||||
updatedAt = it.updatedAt,
|
||||
)
|
||||
},
|
||||
notes = db.notes.listForBodyCluster(clusterId).map {
|
||||
NoteInfo(
|
||||
text = it.text,
|
||||
progress = it.anchor.progress,
|
||||
updatedAt = it.updatedAt,
|
||||
)
|
||||
},
|
||||
lastReadingPosition = readingPosition,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
actual suspend fun loadActiveReadingFileId(): String? = withContext(Dispatchers.IO) {
|
||||
openLibraryDatabase().useLibrary { db ->
|
||||
db.getAppFlag(ActiveReadingFileIdFlag)?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
}
|
||||
|
||||
actual suspend fun saveActiveReadingFileId(fileId: String?) = withContext(Dispatchers.IO) {
|
||||
openLibraryDatabase().useLibrary { db ->
|
||||
db.setAppFlag(ActiveReadingFileIdFlag, fileId?.takeIf { it.isNotBlank() })
|
||||
}
|
||||
}
|
||||
|
||||
actual suspend fun loadThemeMode(): ThemeMode = withContext(Dispatchers.IO) {
|
||||
openLibraryDatabase().useLibrary { db ->
|
||||
db.getAppFlag(ThemeModeFlag)?.let { value ->
|
||||
runCatching { ThemeMode.valueOf(value) }.getOrNull()
|
||||
} ?: ThemeMode.SYSTEM
|
||||
}
|
||||
}
|
||||
|
||||
actual suspend fun saveThemeMode(mode: ThemeMode) = withContext(Dispatchers.IO) {
|
||||
openLibraryDatabase().useLibrary { db ->
|
||||
db.setAppFlag(ThemeModeFlag, mode.name)
|
||||
}
|
||||
}
|
||||
|
||||
actual fun isPlatformDarkTheme(): Boolean {
|
||||
val osName = System.getProperty("os.name").lowercase()
|
||||
if (!osName.contains("linux")) return false
|
||||
|
||||
val gtkTheme = System.getenv("GTK_THEME").orEmpty()
|
||||
if (gtkTheme.contains("dark", ignoreCase = true)) return true
|
||||
|
||||
val colorScheme = runCommand("gsettings", "get", "org.gnome.desktop.interface", "color-scheme")
|
||||
if (colorScheme?.contains("prefer-dark", ignoreCase = true) == true) return true
|
||||
|
||||
val themeName = runCommand("gsettings", "get", "org.gnome.desktop.interface", "gtk-theme")
|
||||
if (themeName?.contains("dark", ignoreCase = true) == true) return true
|
||||
|
||||
val kdeGlobals = File(System.getProperty("user.home"), ".config/kdeglobals")
|
||||
return runCatching {
|
||||
kdeGlobals.takeIf { it.isFile }
|
||||
?.readText()
|
||||
?.lineSequence()
|
||||
?.any { line ->
|
||||
line.startsWith("ColorScheme=", ignoreCase = true) &&
|
||||
line.contains("dark", ignoreCase = true)
|
||||
} == true
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
actual fun watchPlatformDarkTheme(onChange: (Boolean) -> Unit): () -> Unit {
|
||||
val osName = System.getProperty("os.name").lowercase()
|
||||
if (!osName.contains("linux")) return {}
|
||||
|
||||
val running = AtomicBoolean(true)
|
||||
val worker = thread(name = "toread-theme-watch", isDaemon = true) {
|
||||
var last = isPlatformDarkTheme()
|
||||
while (running.get()) {
|
||||
try {
|
||||
Thread.sleep(1500)
|
||||
val current = isPlatformDarkTheme()
|
||||
if (current != last) {
|
||||
last = current
|
||||
onChange(current)
|
||||
}
|
||||
} catch (_: InterruptedException) {
|
||||
return@thread
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
running.set(false)
|
||||
worker.interrupt()
|
||||
}
|
||||
}
|
||||
|
||||
actual fun libraryLogPath(): String? = libraryLogFile().absolutePath
|
||||
|
||||
private fun openLibraryDatabase(): H2LibraryDatabase {
|
||||
val libraryDir = File(System.getProperty("user.home"), ".toread/library")
|
||||
val dbDir = File(libraryDir, "db")
|
||||
dbDir.mkdirs()
|
||||
return H2LibraryDatabase.openFile(File(dbDir, "library").absolutePath)
|
||||
}
|
||||
|
||||
private fun <T> H2LibraryDatabase.useLibrary(block: (H2LibraryDatabase) -> T): T =
|
||||
try {
|
||||
block(this)
|
||||
} finally {
|
||||
close()
|
||||
}
|
||||
|
||||
private fun findProjectRoot(): File? {
|
||||
var current = File(System.getProperty("user.dir")).absoluteFile
|
||||
while (true) {
|
||||
if (File(current, "test_books").isDirectory) return current
|
||||
current = current.parentFile ?: break
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun appendLibraryLog(message: String) {
|
||||
val file = libraryLogFile()
|
||||
file.parentFile?.mkdirs()
|
||||
file.appendText("${System.currentTimeMillis()} $message\n")
|
||||
}
|
||||
|
||||
private fun libraryLogFile(): File =
|
||||
File(System.getProperty("user.home"), ".toread/toread.log")
|
||||
|
||||
private fun ReadingPosition.toFormatHintsJson(): String =
|
||||
"""{"firstVisibleItemIndex":$itemIndex,"firstVisibleItemScrollOffset":$scrollOffset}"""
|
||||
|
||||
private fun String.toReadingPosition(): ReadingPosition? {
|
||||
val index = Regex(""""firstVisibleItemIndex"\s*:\s*(\d+)""").find(this)?.groupValues?.getOrNull(1)?.toIntOrNull()
|
||||
val offset = Regex(""""firstVisibleItemScrollOffset"\s*:\s*(\d+)""").find(this)?.groupValues?.getOrNull(1)?.toIntOrNull()
|
||||
return if (index != null && offset != null) ReadingPosition(index, offset) else null
|
||||
}
|
||||
|
||||
private fun runCommand(vararg command: String): String? =
|
||||
runCatching {
|
||||
val process = ProcessBuilder(*command).redirectErrorStream(true).start()
|
||||
if (!process.waitFor(500, TimeUnit.MILLISECONDS)) {
|
||||
process.destroy()
|
||||
null
|
||||
} else {
|
||||
process.inputStream.bufferedReader().readText().trim()
|
||||
}
|
||||
}.getOrNull()
|
||||
|
||||
private const val ActiveReadingFileIdFlag = "active_reading_file_id"
|
||||
private const val ThemeModeFlag = "theme_mode"
|
||||
@@ -0,0 +1,13 @@
|
||||
package net.sergeych.toread
|
||||
|
||||
import androidx.compose.ui.window.Window
|
||||
import androidx.compose.ui.window.application
|
||||
|
||||
fun main() = application {
|
||||
Window(
|
||||
onCloseRequest = ::exitApplication,
|
||||
title = "toread",
|
||||
) {
|
||||
App()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user