library UI + reader mode

This commit is contained in:
Sergey Chernov 2026-05-17 00:52:24 +03:00
commit 8f8b466e89
81 changed files with 7502 additions and 0 deletions

19
.gitignore vendored Normal file
View File

@ -0,0 +1,19 @@
*.iml
.kotlin
.gradle
**/build/
xcuserdata
!src/**/build/
local.properties
.idea
.DS_Store
captures
.externalNativeBuild
.cxx
*.xcodeproj/*
!*.xcodeproj/project.pbxproj
!*.xcodeproj/xcshareddata/
!*.xcodeproj/project.xcworkspace/
!*.xcworkspace/contents.xcworkspacedata
**/xcshareddata/WorkspaceSettings.xcsettings
node_modules/

92
README.md Normal file
View File

@ -0,0 +1,92 @@
This is a Kotlin Multiplatform project targeting Android, Web, Desktop (JVM), Server.
* [/composeApp](./composeApp/src) is for code that will be shared across your Compose Multiplatform applications.
It contains several subfolders:
- [commonMain](./composeApp/src/commonMain/kotlin) is for code that’s common for all targets.
- Other folders are for Kotlin code that will be compiled for only the platform indicated in the folder name.
For example, if you want to use Apple’s CoreCrypto for the iOS part of your Kotlin app,
the [iosMain](./composeApp/src/iosMain/kotlin) folder would be the right place for such calls.
Similarly, if you want to edit the Desktop (JVM) specific part, the [jvmMain](./composeApp/src/jvmMain/kotlin)
folder is the appropriate location.
* [/server](./server/src/main/kotlin) is for the Ktor server application.
* [/shared](./shared/src) is for the code that will be shared between all targets in the project.
The most important subfolder is [commonMain](./shared/src/commonMain/kotlin). If preferred, you
can add code to the platform-specific folders here too.
### Build and Run Android Application
To build and run the development version of the Android app, use the run configuration from the run widget
in your IDE’s toolbar or build it directly from the terminal:
- on macOS/Linux
```shell
./gradlew :composeApp:assembleDebug
```
- on Windows
```shell
.\gradlew.bat :composeApp:assembleDebug
```
### Build and Run Desktop (JVM) Application
To build and run the development version of the desktop app, use the run configuration from the run widget
in your IDE’s toolbar or run it directly from the terminal:
- on macOS/Linux
```shell
./gradlew :composeApp:run
```
- on Windows
```shell
.\gradlew.bat :composeApp:run
```
### Build and Run Server
To build and run the development version of the server, use the run configuration from the run widget
in your IDE’s toolbar or run it directly from the terminal:
- on macOS/Linux
```shell
./gradlew :server:run
```
- on Windows
```shell
.\gradlew.bat :server:run
```
### Build and Run Web Application
To build and run the development version of the web app, use the run configuration from the run widget
in your IDE's toolbar or run it directly from the terminal:
- for the Wasm target (faster, modern browsers):
- on macOS/Linux
```shell
./gradlew :composeApp:wasmJsBrowserDevelopmentRun
```
- on Windows
```shell
.\gradlew.bat :composeApp:wasmJsBrowserDevelopmentRun
```
- for the JS target (slower, supports older browsers):
- on macOS/Linux
```shell
./gradlew :composeApp:jsBrowserDevelopmentRun
```
- on Windows
```shell
.\gradlew.bat :composeApp:jsBrowserDevelopmentRun
```
---
Learn more about [Kotlin Multiplatform](https://www.jetbrains.com/help/kotlin-multiplatform-dev/get-started.html),
[Compose Multiplatform](https://github.com/JetBrains/compose-multiplatform/#compose-multiplatform),
[Kotlin/Wasm](https://kotl.in/wasm/)…
We would appreciate your feedback on Compose/Web and Kotlin/Wasm in the public Slack
channel [#compose-web](https://slack-chats.kotlinlang.org/c/compose-web).
If you face any issues, please report them on [YouTrack](https://youtrack.jetbrains.com/newIssue?project=CMP).

12
build.gradle.kts Normal file
View File

@ -0,0 +1,12 @@
plugins {
// this is necessary to avoid the plugins to be loaded multiple times
// in each subproject's classloader
alias(libs.plugins.androidApplication) apply false
alias(libs.plugins.androidLibrary) apply false
alias(libs.plugins.composeHotReload) apply false
alias(libs.plugins.composeMultiplatform) apply false
alias(libs.plugins.composeCompiler) apply false
alias(libs.plugins.kotlinJvm) apply false
alias(libs.plugins.kotlinMultiplatform) apply false
alias(libs.plugins.ktor) apply false
}

105
composeApp/build.gradle.kts Normal file
View File

@ -0,0 +1,105 @@
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidApplication)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
alias(libs.plugins.composeHotReload)
}
kotlin {
jvmToolchain(17)
androidTarget {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}
jvm()
js {
browser()
binaries.executable()
}
@OptIn(ExperimentalWasmDsl::class)
wasmJs {
browser()
binaries.executable()
}
sourceSets {
androidMain.dependencies {
implementation(libs.compose.uiToolingPreview)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.core.ktx)
}
commonMain.dependencies {
implementation(libs.compose.runtime)
implementation(libs.compose.foundation)
implementation(libs.compose.material.icons.extended)
implementation(libs.compose.material3)
implementation(libs.compose.ui)
implementation(libs.compose.components.resources)
implementation(libs.compose.uiToolingPreview)
implementation(libs.androidx.lifecycle.viewmodelCompose)
implementation(libs.androidx.lifecycle.runtimeCompose)
implementation(libs.kotlinx.coroutinesCore)
implementation(projects.shared)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
}
jvmMain.dependencies {
implementation(compose.desktop.currentOs)
implementation(libs.kotlinx.coroutinesSwing)
}
}
}
android {
namespace = "net.sergeych.toread"
compileSdk = libs.versions.android.compileSdk.get().toInt()
defaultConfig {
applicationId = "net.sergeych.toread"
minSdk = libs.versions.android.minSdk.get().toInt()
targetSdk = libs.versions.android.targetSdk.get().toInt()
versionCode = 1
versionName = "1.0"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
buildTypes {
getByName("release") {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
dependencies {
debugImplementation(libs.compose.uiTooling)
}
compose.desktop {
application {
mainClass = "net.sergeych.toread.MainKt"
nativeDistributions {
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
packageName = "net.sergeych.toread"
packageVersion = "1.0.0"
}
}
}

View File

@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32"/>
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity
android:exported="true"
android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="content"/>
<data android:scheme="file"/>
<data android:mimeType="application/x-fictionbook+xml"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="content" android:mimeType="*/*" android:pathPattern=".*\\.fb2"/>
<data android:scheme="content" android:mimeType="*/*" android:pathPattern=".*\\.fb2\\.zip"/>
<data android:scheme="file" android:mimeType="*/*" android:pathPattern=".*\\.fb2"/>
<data android:scheme="file" android:mimeType="*/*" android:pathPattern=".*\\.fb2\\.zip"/>
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -0,0 +1,442 @@
package net.sergeych.toread
import android.content.ContentResolver
import android.content.ComponentCallbacks
import android.content.Context
import android.content.Intent
import android.content.res.Configuration
import android.graphics.BitmapFactory
import android.net.Uri
import android.os.Environment
import android.provider.DocumentsContract
import android.provider.OpenableColumns
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
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 net.sergeych.toread.storage.jdbc.LibraryScanSummary
import java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
private lateinit var appContext: Context
private var directoryChooser: AndroidLibraryDirectoryChooser? = null
private var pendingOpenBookUri: Uri? = null
interface AndroidLibraryDirectoryChooser {
suspend fun chooseDirectory(): String?
suspend fun ensureExternalFileAccess(): Boolean
}
fun initToreadPlatform(context: Context, chooser: AndroidLibraryDirectoryChooser? = null) {
appContext = context.applicationContext
directoryChooser = chooser
}
actual fun loadDefaultBookBytes(): ByteArray? = null
actual fun decodeBookImage(binary: Fb2Binary): ImageBitmap? =
runCatching {
val bytes = binary.imageBytes()
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap()
}.getOrNull()
actual fun decodeImageBytes(bytes: ByteArray): ImageBitmap? =
runCatching {
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap()
}.getOrNull()
actual fun defaultLibraryScanPath(): String? =
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)?.absolutePath
?: appContext.getExternalFilesDir(null)?.absolutePath
?: appContext.filesDir.absolutePath
fun rememberPlatformOpenBookIntent(intent: Intent?) {
pendingOpenBookUri = intent
?.takeIf { it.action == Intent.ACTION_VIEW }
?.data
}
actual suspend fun loadPlatformOpenBookRequest(): PlatformOpenBookRequest? = withContext(Dispatchers.IO) {
val uri = pendingOpenBookUri ?: return@withContext null
pendingOpenBookUri = null
val bytes = readStorageUriBytes(uri.toString()) ?: return@withContext null
PlatformOpenBookRequest(
id = "platform:${uri}",
displayName = displayNameFor(uri),
bytes = bytes,
)
}
actual suspend fun chooseLibraryScanDirectory(): String? = directoryChooser?.chooseDirectory()
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 {
readStorageUriBytes(uri)?.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")
if (path.isContentUri()) {
scanLibraryContentTree(Uri.parse(path), onProgress)
} else {
if (path.requiresExternalFileAccess() && directoryChooser?.ensureExternalFileAccess() != true) {
error("All files access is required to scan $path.")
}
openLibraryDatabase().useLibrary { db ->
val summary = LibraryScanner(db, ::appendLibraryLog).scanSubtree(File(path)) {
onProgress(it.toLibraryScanProgress())
}
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(::readStorageUriBytes)
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 {
if (!::appContext.isInitialized) return false
val uiMode = appContext.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
return uiMode == Configuration.UI_MODE_NIGHT_YES
}
actual fun watchPlatformDarkTheme(onChange: (Boolean) -> Unit): () -> Unit {
if (!::appContext.isInitialized) return {}
val callback = object : ComponentCallbacks {
override fun onConfigurationChanged(newConfig: Configuration) {
val uiMode = newConfig.uiMode and Configuration.UI_MODE_NIGHT_MASK
onChange(uiMode == Configuration.UI_MODE_NIGHT_YES)
}
override fun onLowMemory() = Unit
}
appContext.registerComponentCallbacks(callback)
return { appContext.unregisterComponentCallbacks(callback) }
}
actual fun libraryLogPath(): String? = libraryLogFile().absolutePath
private data class AndroidLibraryDocument(
val uri: Uri,
val name: String,
val sizeBytes: Long?,
val lastModifiedMillis: Long?,
)
private fun scanLibraryContentTree(
rootUri: Uri,
onProgress: (LibraryScanProgress) -> Unit,
): LibraryScanReport =
openLibraryDatabase().useLibrary { db ->
val scanner = LibraryScanner(db, ::appendLibraryLog)
var scanned = 0
var imported = 0
var skipped = 0
var failed = 0
var visited = 0
walkContentTree(
rootUri = rootUri,
onVisited = { name ->
visited += 1
if (visited % 50 == 0) {
onProgress(LibraryScanProgress(scanned, imported, skipped, failed, name))
}
},
) { document ->
scanned += 1
appendLibraryLog("scan document uri=${document.uri} name=${document.name} size=${document.sizeBytes ?: 0}")
onProgress(LibraryScanProgress(scanned, imported, skipped, failed, document.name))
val result = runCatching {
val bytes = appContext.contentResolver.openInputStream(document.uri)?.use { it.readBytes() }
?: error("Could not read ${document.name}")
scanner.importExternalFile(
bytes = bytes,
displayName = document.name,
storageUri = document.uri.toString(),
sizeBytes = document.sizeBytes,
lastModifiedMillis = document.lastModifiedMillis,
)
}
if (result.isSuccess) {
if (result.getOrThrow()) {
imported += 1
appendLibraryLog("scan imported uri=${document.uri}")
} else {
skipped += 1
appendLibraryLog("scan skipped duplicate uri=${document.uri}")
}
} else {
failed += 1
appendLibraryLog("scan failed uri=${document.uri} error=${result.exceptionOrNull()?.message}")
}
onProgress(LibraryScanProgress(scanned, imported, skipped, failed, document.name))
}
LibraryScanReport(
scannedFiles = scanned,
importedFiles = imported,
skippedFiles = skipped,
failedFiles = failed,
)
}
private fun walkContentTree(
rootUri: Uri,
onVisited: (String) -> Unit,
onBookFile: (AndroidLibraryDocument) -> Unit,
) {
val rootDocumentId = DocumentsContract.getTreeDocumentId(rootUri)
val rootDocumentUri = DocumentsContract.buildDocumentUriUsingTree(rootUri, rootDocumentId)
walkContentDocument(rootUri, rootDocumentUri, onVisited, onBookFile)
}
private fun walkContentDocument(
treeUri: Uri,
documentUri: Uri,
onVisited: (String) -> Unit,
onBookFile: (AndroidLibraryDocument) -> Unit,
) {
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(
treeUri,
DocumentsContract.getDocumentId(documentUri),
)
val columns = arrayOf(
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_MIME_TYPE,
DocumentsContract.Document.COLUMN_SIZE,
DocumentsContract.Document.COLUMN_LAST_MODIFIED,
)
appContext.contentResolver.query(childrenUri, columns, null, null, null)?.use { cursor ->
val idIndex = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DOCUMENT_ID)
val nameIndex = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DISPLAY_NAME)
val mimeIndex = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_MIME_TYPE)
val sizeIndex = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_SIZE)
val modifiedIndex = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_LAST_MODIFIED)
while (cursor.moveToNext()) {
val documentId = cursor.getString(idIndex)
val name = cursor.getString(nameIndex).orEmpty()
val mimeType = cursor.getString(mimeIndex)
val childUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, documentId)
onVisited(name)
if (mimeType == DocumentsContract.Document.MIME_TYPE_DIR) {
walkContentDocument(treeUri, childUri, onVisited, onBookFile)
} else if (name.isSupportedBookFile()) {
onBookFile(
AndroidLibraryDocument(
uri = childUri,
name = name,
sizeBytes = cursor.getNullableLong(sizeIndex),
lastModifiedMillis = cursor.getNullableLong(modifiedIndex),
)
)
}
}
}
}
private fun readStorageUriBytes(storageUri: String): ByteArray? =
if (storageUri.isContentUri()) {
appContext.contentResolver.openInputStream(Uri.parse(storageUri))?.use { it.readBytes() }
} else {
File(storageUri).takeIf { it.isFile }?.readBytes()
}
private fun String.isContentUri(): Boolean =
runCatching { Uri.parse(this).scheme == ContentResolver.SCHEME_CONTENT }.getOrDefault(false)
private fun String.requiresExternalFileAccess(): Boolean {
val externalRoot = Environment.getExternalStorageDirectory()?.absolutePath ?: return false
val appExternalRoot = appContext.getExternalFilesDir(null)?.absolutePath
return startsWith(externalRoot) && (appExternalRoot == null || !startsWith(appExternalRoot))
}
private fun String.isSupportedBookFile(): Boolean =
endsWith(".fb2", ignoreCase = true) || endsWith(".fb2.zip", ignoreCase = true)
private fun displayNameFor(uri: Uri): String =
appContext.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) cursor.getString(0) else null
} ?: uri.lastPathSegment ?: uri.toString()
private fun LibraryScanSummary.toLibraryScanProgress(): LibraryScanProgress =
LibraryScanProgress(
scannedFiles = scannedFiles,
importedFiles = importedFiles,
skippedFiles = skippedFiles,
failedFiles = failedFiles,
currentFile = currentFile,
)
private fun android.database.Cursor.getNullableLong(index: Int): Long? =
if (isNull(index)) null else getLong(index)
private fun openLibraryDatabase(): H2LibraryDatabase {
val libraryDir = File(appContext.filesDir, "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 appendLibraryLog(message: String) {
val file = libraryLogFile()
file.parentFile?.mkdirs()
file.appendText("${System.currentTimeMillis()} $message\n")
}
private fun libraryLogFile(): File =
File(appContext.filesDir, "logs/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 const val ActiveReadingFileIdFlag = "active_reading_file_id"
private const val ThemeModeFlag = "theme_mode"

View File

@ -0,0 +1,141 @@
package net.sergeych.toread
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.provider.Settings
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.Modifier
import androidx.core.view.WindowCompat
import kotlinx.coroutines.CompletableDeferred
class MainActivity : ComponentActivity(), AndroidLibraryDirectoryChooser {
private lateinit var directoryLauncher: ActivityResultLauncher<Uri?>
private lateinit var allFilesAccessLauncher: ActivityResultLauncher<Intent>
private lateinit var readStoragePermissionLauncher: ActivityResultLauncher<String>
private var pendingDirectoryChoice: CompletableDeferred<String?>? = null
private var pendingExternalFileAccess: CompletableDeferred<Boolean>? = null
override fun onCreate(savedInstanceState: Bundle?) {
directoryLauncher = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
uri?.let {
runCatching {
contentResolver.takePersistableUriPermission(it, Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
}
pendingDirectoryChoice?.complete(uri?.toString())
pendingDirectoryChoice = null
}
allFilesAccessLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
pendingExternalFileAccess?.complete(hasBroadFileAccess())
pendingExternalFileAccess = null
}
readStoragePermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
pendingExternalFileAccess?.complete(granted)
pendingExternalFileAccess = null
}
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, true)
initToreadPlatform(this, this)
rememberPlatformOpenBookIntent(intent)
setContent {
AndroidAppWindow {
App()
}
}
}
override suspend fun chooseDirectory(): String? {
val result = CompletableDeferred<String?>()
runOnUiThread {
if (pendingDirectoryChoice != null) {
result.complete(null)
} else {
pendingDirectoryChoice = result
directoryLauncher.launch(null)
}
}
return result.await()
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
rememberPlatformOpenBookIntent(intent)
}
override suspend fun ensureExternalFileAccess(): Boolean {
if (hasBroadFileAccess()) return true
val result = CompletableDeferred<Boolean>()
runOnUiThread {
if (pendingExternalFileAccess != null) {
result.complete(false)
return@runOnUiThread
}
pendingExternalFileAccess = result
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val appSettings = Intent(
Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION,
Uri.parse("package:$packageName"),
)
val settingsIntent = if (appSettings.resolveActivity(packageManager) != null) {
appSettings
} else {
Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION)
}
allFilesAccessLauncher.launch(settingsIntent)
} else {
readStoragePermissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE)
}
}
return result.await()
}
override fun onDestroy() {
pendingDirectoryChoice?.complete(null)
pendingDirectoryChoice = null
pendingExternalFileAccess?.complete(false)
pendingExternalFileAccess = null
super.onDestroy()
}
private fun hasBroadFileAccess(): Boolean =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
Environment.isExternalStorageManager()
} else {
checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED
}
}
@Composable
private fun AndroidAppWindow(content: @Composable () -> Unit) {
androidx.compose.foundation.layout.Box(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Vertical)),
) {
content()
}
}
@Preview
@Composable
fun AppAndroidPreview() {
App()
}

View File

@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0"/>
<item
android:color="#00000000"
android:offset="1.0"/>
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000"/>
</vector>

View File

@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z"/>
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
</vector>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme" parent="@android:style/Theme.Material.NoActionBar">
<item name="android:windowActionBar">false</item>
<item name="android:windowNoTitle">true</item>
</style>
</resources>

View File

@ -0,0 +1,3 @@
<resources>
<string name="app_name">toread</string>
</resources>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme" parent="@android:style/Theme.Material.NoActionBar">
<item name="android:windowActionBar">false</item>
<item name="android:windowNoTitle">true</item>
</style>
</resources>

View File

@ -0,0 +1,44 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="450dp"
android:height="450dp"
android:viewportWidth="64"
android:viewportHeight="64">
<path
android:pathData="M56.25,18V46L32,60 7.75,46V18L32,4Z"
android:fillColor="#6075f2"/>
<path
android:pathData="m41.5,26.5v11L32,43V60L56.25,46V18Z"
android:fillColor="#6b57ff"/>
<path
android:pathData="m32,43 l-9.5,-5.5v-11L7.75,18V46L32,60Z">
<aapt:attr name="android:fillColor">
<gradient
android:centerX="23.131"
android:centerY="18.441"
android:gradientRadius="42.132"
android:type="radial">
<item android:offset="0" android:color="#FF5383EC"/>
<item android:offset="0.867" android:color="#FF7F52FF"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M22.5,26.5 L32,21 41.5,26.5 56.25,18 32,4 7.75,18Z">
<aapt:attr name="android:fillColor">
<gradient
android:startX="44.172"
android:startY="4.377"
android:endX="17.973"
android:endY="34.035"
android:type="linear">
<item android:offset="0" android:color="#FF33C3FF"/>
<item android:offset="0.878" android:color="#FF5383EC"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="m32,21 l9.526,5.5v11L32,43 22.474,37.5v-11z"
android:fillColor="#000000"/>
</vector>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,109 @@
package net.sergeych.toread
import net.sergeych.toread.fb2.Fb2Binary
import net.sergeych.toread.fb2.Fb2Book
data class LibraryItem(
val fileId: String,
val bookId: String?,
val title: String,
val authors: List<String> = emptyList(),
val language: String? = null,
val date: String? = null,
val format: String?,
val sizeBytes: Long?,
val storageUri: String?,
val lastSeenAt: Long?,
val coverImage: ByteArray? = null,
val coverImageMimeType: String? = null,
)
data class LibraryScanReport(
val scannedFiles: Int,
val importedFiles: Int,
val skippedFiles: Int,
val failedFiles: Int,
)
data class LibraryScanProgress(
val scannedFiles: Int,
val importedFiles: Int,
val skippedFiles: Int,
val failedFiles: Int,
val currentFile: String? = null,
)
data class PlatformOpenBookRequest(
val id: String,
val displayName: String,
val bytes: ByteArray,
)
data class ReadingPosition(
val itemIndex: Int,
val scrollOffset: Int,
)
data class BookInfoExtras(
val bookmarks: List<BookmarkInfo> = emptyList(),
val notes: List<NoteInfo> = emptyList(),
val lastReadingPosition: ReadingPosition? = null,
)
data class BookmarkInfo(
val title: String?,
val selectedText: String?,
val progress: Double?,
val updatedAt: Long,
)
data class NoteInfo(
val text: String,
val progress: Double?,
val updatedAt: Long,
)
enum class ThemeMode {
LIGHT,
DARK,
SYSTEM,
}
expect fun defaultLibraryScanPath(): String?
expect suspend fun loadPlatformOpenBookRequest(): PlatformOpenBookRequest?
expect suspend fun chooseLibraryScanDirectory(): String?
expect suspend fun loadLibraryItems(): List<LibraryItem>
expect suspend fun scanLibrarySubtree(path: String, onProgress: (LibraryScanProgress) -> Unit): LibraryScanReport
expect suspend fun openLibraryBook(fileId: String): ByteArray?
expect suspend fun deleteLibraryItem(fileId: String): Boolean
expect suspend fun loadLibraryReadingPosition(fileId: String): ReadingPosition?
expect suspend fun saveLibraryReadingPosition(fileId: String, position: ReadingPosition)
expect suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras
expect suspend fun loadActiveReadingFileId(): String?
expect suspend fun saveActiveReadingFileId(fileId: String?)
expect suspend fun loadThemeMode(): ThemeMode
expect suspend fun saveThemeMode(mode: ThemeMode)
expect fun isPlatformDarkTheme(): Boolean
expect fun watchPlatformDarkTheme(onChange: (Boolean) -> Unit): () -> Unit
expect fun libraryLogPath(): String?
internal fun Fb2Book.libraryCoverBinary(): Fb2Binary? {
val image = coverImages.firstOrNull() ?: bodyImages.firstOrNull()
return image?.let(::binaryFor)
}

View File

@ -0,0 +1,12 @@
package net.sergeych.toread
import kotlin.test.Test
import kotlin.test.assertEquals
class ComposeAppCommonTest {
@Test
fun example() {
assertEquals(3, 1 + 2)
}
}

View File

@ -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"

View File

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

View File

@ -0,0 +1,58 @@
package net.sergeych.toread
import androidx.compose.ui.graphics.ImageBitmap
import kotlinx.browser.window
import net.sergeych.toread.fb2.Fb2Binary
import org.w3c.dom.events.Event
actual fun loadDefaultBookBytes(): ByteArray? = null
actual fun decodeBookImage(binary: Fb2Binary): ImageBitmap? = null
actual fun decodeImageBytes(bytes: ByteArray): ImageBitmap? = null
actual fun defaultLibraryScanPath(): String? = null
actual suspend fun loadPlatformOpenBookRequest(): PlatformOpenBookRequest? = null
actual suspend fun chooseLibraryScanDirectory(): String? = null
actual suspend fun loadLibraryItems(): List<LibraryItem> = emptyList()
actual suspend fun scanLibrarySubtree(
path: String,
onProgress: (LibraryScanProgress) -> Unit,
): LibraryScanReport =
LibraryScanReport(scannedFiles = 0, importedFiles = 0, skippedFiles = 0, failedFiles = 1)
actual suspend fun openLibraryBook(fileId: String): ByteArray? = null
actual suspend fun deleteLibraryItem(fileId: String): Boolean = false
actual suspend fun loadLibraryReadingPosition(fileId: String): ReadingPosition? = null
actual suspend fun saveLibraryReadingPosition(fileId: String, position: ReadingPosition) = Unit
actual suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras = BookInfoExtras()
actual suspend fun loadActiveReadingFileId(): String? = null
actual suspend fun saveActiveReadingFileId(fileId: String?) = Unit
actual suspend fun loadThemeMode(): ThemeMode = ThemeMode.SYSTEM
actual suspend fun saveThemeMode(mode: ThemeMode) = Unit
actual fun isPlatformDarkTheme(): Boolean =
window.matchMedia("(prefers-color-scheme: dark)").matches
actual fun watchPlatformDarkTheme(onChange: (Boolean) -> Unit): () -> Unit {
val query = window.matchMedia("(prefers-color-scheme: dark)")
val listener: (Event) -> Unit = {
onChange(query.matches)
}
query.addEventListener("change", listener)
return { query.removeEventListener("change", listener) }
}
actual fun libraryLogPath(): String? = null

View File

@ -0,0 +1,11 @@
package net.sergeych.toread
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.window.ComposeViewport
@OptIn(ExperimentalComposeUiApi::class)
fun main() {
ComposeViewport {
App()
}
}

View File

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>toread</title>
<link type="text/css" rel="stylesheet" href="styles.css">
</head>
<body style="text-align: center; align-content: center">
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 50 50" role="presentation">
<circle cx="25" cy="25" r="20" stroke="#ccc" stroke-width="4" fill="none"/>
<circle cx="25" cy="25" r="20" stroke="#333" stroke-width="4" fill="none" stroke-linecap="round"
stroke-dasharray="90 125">
<animateTransform attributeName="transform" type="rotate" from="0 25 25" to="360 25 25" dur="1s"
repeatCount="indefinite"/>
</circle>
</svg>
<script type="application/javascript" src="composeApp.js"></script>
</body>
</html>

View File

@ -0,0 +1,7 @@
html, body {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}

64
docs/fb2-import-export.md Normal file
View File

@ -0,0 +1,64 @@
# FB2 Import/Export Specification
## Scope
Toread supports FictionBook 2.0 files as plain XML (`.fb2`) and as a standard ZIP archive containing one FB2 XML file (`.fb2.zip`).
The vendored schema files live in `shared/src/commonMain/resources/fb2/`:
- `FictionBook.xsd`
- `FictionBookGenres.xsd`
- `FictionBookLang.xsd`
- `FictionBookLinks.xsd`
These files were copied from `https://github.com/gribuser/fb2` so builds and validation references do not depend on the upstream repository remaining available.
## Import
The common API is `Fb2Format.parse(input: ByteArray, fileName: String? = null)`.
Import detection:
- A file is treated as ZIP when its bytes start with the ZIP local-file signature `PK\003\004` or the provided filename ends with `.zip`.
- Otherwise bytes are decoded as UTF-8 XML.
- In ZIP archives, the first entry ending with `.fb2` is used. If no such entry exists, the first non-directory entry is used.
ZIP support:
- Stored ZIP entries are supported on every multiplatform target.
- Deflated ZIP entries are supported on JVM and Android through `java.util.zip.Inflater`.
- Deflated ZIP entries currently fail with `Fb2ParseException` on JS and Wasm targets until a common/browser inflater is added.
- ZIP64 and encrypted archives are not supported.
XML mapping:
- `description/title-info/book-title` maps to `Fb2Book.title`.
- `description/title-info/author` maps to `Fb2Book.authors`.
- `genre`, `lang`, `keywords`, `date`, `annotation`, and `sequence` are imported from `title-info`.
- `src-lang`, `translator`, and `coverpage/image` are imported from `title-info`.
- `description/document-info` maps to `Fb2DocumentInfo`.
- The first non-notes `body` is imported as the readable body.
- Direct `body/image`, `body/title`, `section/title`, direct `section/image`, direct `section/p`, and nested `section` elements are preserved.
- `binary` elements are imported with `id`, `content-type`, and whitespace-normalized Base64 content.
- Image references keep their `href`; `Fb2ImageRef.binaryId` resolves `#cover.jpg` to `cover.jpg`, and `Fb2Book.binaryFor(image)` returns the corresponding embedded binary when present.
The importer is intentionally structural, not a full XSD validator.
## Export
The common API is:
- `Fb2Format.exportXml(book: Fb2Book)` for plain `.fb2` XML.
- `Fb2Format.exportZip(book: Fb2Book, entryName: String = "book.fb2")` for `.fb2.zip`.
Export behavior:
- XML is emitted as UTF-8 FictionBook 2.0 with the FB2 and XLink namespaces.
- Required FB2 description fields are emitted from the `Fb2Book` model.
- Missing `document-info` fields are filled with deterministic defaults: date `1970-01-01`, id `toread-generated`, version `1.0`.
- ZIP export uses a standard stored ZIP entry, so it is portable without requiring a common deflater.
Round-trip guarantees:
- Imported title, authors, language, source language, translators, genres, document info, cover images, body title/images, sections, paragraph text, and binaries are represented in the model.
- Formatting, comments, stylesheets, tables, inline style markup, cover references, and unknown FB2 extension elements are not preserved by the current model.

10
gradle.properties Normal file
View File

@ -0,0 +1,10 @@
#Kotlin
kotlin.code.style=official
kotlin.daemon.jvmargs=-Xmx3072M
#Gradle
org.gradle.jvmargs=-Xmx3072M -Dfile.encoding=UTF-8
org.gradle.configuration-cache=true
org.gradle.caching=true
#Android
android.nonTransitiveRClass=true
android.useAndroidX=true

58
gradle/libs.versions.toml Normal file
View File

@ -0,0 +1,58 @@
[versions]
agp = "8.11.2"
android-compileSdk = "36"
android-minSdk = "26"
android-targetSdk = "36"
androidx-activity = "1.13.0"
androidx-appcompat = "1.7.1"
androidx-core = "1.18.0"
androidx-espresso = "3.7.0"
androidx-lifecycle = "2.10.0"
androidx-testExt = "1.3.0"
composeHotReload = "1.1.0"
composeMaterialIcons = "1.7.3"
composeMultiplatform = "1.10.3"
junit = "4.13.2"
kotlin = "2.3.21"
kotlinx-coroutines = "1.10.2"
ktor = "3.4.3"
logback = "1.5.32"
material3 = "1.10.0-alpha05"
h2 = "2.4.240"
[libraries]
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
kotlin-testJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
junit = { module = "junit:junit", version.ref = "junit" }
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
androidx-testExt-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-testExt" }
androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-espresso" }
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" }
androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "composeMultiplatform" }
compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "composeMultiplatform" }
compose-material-icons-extended = { module = "org.jetbrains.compose.material:material-icons-extended", version.ref = "composeMaterialIcons" }
compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "material3" }
compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMultiplatform" }
compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeMultiplatform" }
compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" }
kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
kotlinx-coroutinesCore = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
h2 = { module = "com.h2database:h2", version.ref = "h2" }
logback = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
ktor-serverCore = { module = "io.ktor:ktor-server-core-jvm", version.ref = "ktor" }
ktor-serverNetty = { module = "io.ktor:ktor-server-netty-jvm", version.ref = "ktor" }
ktor-serverTestHost = { module = "io.ktor:ktor-server-test-host-jvm", version.ref = "ktor" }
[plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" }
androidLibrary = { id = "com.android.library", version.ref = "agp" }
composeHotReload = { id = "org.jetbrains.compose.hot-reload", version.ref = "composeHotReload" }
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" }
composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
ktor = { id = "io.ktor.plugin", version.ref = "ktor" }
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.0-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
gradlew vendored Executable file
View File

@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
gradlew.bat vendored Normal file
View File

@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

34
server/build.gradle.kts Normal file
View File

@ -0,0 +1,34 @@
plugins {
alias(libs.plugins.kotlinJvm)
alias(libs.plugins.ktor)
application
}
group = "net.sergeych.toread"
version = "1.0.0"
kotlin {
jvmToolchain(17)
}
java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(17))
}
}
application {
mainClass.set("net.sergeych.toread.ApplicationKt")
val isDevelopment: Boolean = project.ext.has("development")
applicationDefaultJvmArgs = listOf("-Dio.ktor.development=$isDevelopment")
}
dependencies {
implementation(projects.shared)
implementation(libs.logback)
implementation(libs.ktor.serverCore)
implementation(libs.ktor.serverNetty)
testImplementation(libs.ktor.serverTestHost)
testImplementation(libs.kotlin.testJunit)
}

View File

@ -0,0 +1,20 @@
package net.sergeych.toread
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
fun main() {
embeddedServer(Netty, port = SERVER_PORT, host = "0.0.0.0", module = Application::module)
.start(wait = true)
}
fun Application.module() {
routing {
get("/") {
call.respondText("Ktor: ${Greeting().greet()}")
}
}
}

View File

@ -0,0 +1,12 @@
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="trace">
<appender-ref ref="STDOUT"/>
</root>
<logger name="org.eclipse.jetty" level="INFO"/>
<logger name="io.netty" level="INFO"/>
</configuration>

View File

@ -0,0 +1,20 @@
package net.sergeych.toread
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.server.testing.*
import kotlin.test.*
class ApplicationTest {
@Test
fun testRoot() = testApplication {
application {
module()
}
val response = client.get("/")
assertEquals(HttpStatusCode.OK, response.status)
assertEquals("Ktor: ${Greeting().greet()}", response.bodyAsText())
}
}

37
settings.gradle.kts Normal file
View File

@ -0,0 +1,37 @@
rootProject.name = "toread"
enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
pluginManagement {
repositories {
google {
mavenContent {
includeGroupAndSubgroups("androidx")
includeGroupAndSubgroups("com.android")
includeGroupAndSubgroups("com.google")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositories {
google {
mavenContent {
includeGroupAndSubgroups("androidx")
includeGroupAndSubgroups("com.android")
includeGroupAndSubgroups("com.google")
}
}
mavenCentral()
}
}
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}
include(":composeApp")
include(":server")
include(":shared")

61
shared/build.gradle.kts Normal file
View File

@ -0,0 +1,61 @@
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidLibrary)
}
kotlin {
jvmToolchain(17)
androidTarget {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}
jvm()
js {
browser()
}
@OptIn(ExperimentalWasmDsl::class)
wasmJs {
browser()
}
sourceSets {
val commonMain by getting
val androidMain by getting
val jvmMain by getting
val jdbcMain by creating {
dependsOn(commonMain)
dependencies {
implementation(libs.h2)
}
}
androidMain.dependsOn(jdbcMain)
jvmMain.dependsOn(jdbcMain)
commonMain.dependencies {
// put your Multiplatform dependencies here
}
commonTest.dependencies {
implementation(libs.kotlin.test)
}
}
}
android {
namespace = "net.sergeych.toread.shared"
compileSdk = libs.versions.android.compileSdk.get().toInt()
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
defaultConfig {
minSdk = libs.versions.android.minSdk.get().toInt()
}
}

View File

@ -0,0 +1,9 @@
package net.sergeych.toread
import android.os.Build
class AndroidPlatform : Platform {
override val name: String = "Android ${Build.VERSION.SDK_INT}"
}
actual fun getPlatform(): Platform = AndroidPlatform()

View File

@ -0,0 +1,21 @@
package net.sergeych.toread.fb2
import java.util.zip.DataFormatException
import java.util.zip.Inflater
internal actual fun inflateRaw(input: ByteArray, expectedSize: Int): ByteArray {
val inflater = Inflater(true)
return try {
inflater.setInput(input)
val output = ByteArray(expectedSize)
val inflated = inflater.inflate(output)
if (!inflater.finished() || inflated != expectedSize) {
throw Fb2ParseException("Could not inflate ZIP entry: expected $expectedSize bytes, got $inflated")
}
output
} catch (cause: DataFormatException) {
throw Fb2ParseException("Could not inflate ZIP entry: ${cause.message ?: "invalid deflate stream"}")
} finally {
inflater.end()
}
}

View File

@ -0,0 +1,3 @@
package net.sergeych.toread
const val SERVER_PORT = 8080

View File

@ -0,0 +1,9 @@
package net.sergeych.toread
class Greeting {
private val platform = getPlatform()
fun greet(): String {
return "Hello, ${platform.name}!"
}
}

View File

@ -0,0 +1,7 @@
package net.sergeych.toread
interface Platform {
val name: String
}
expect fun getPlatform(): Platform

View File

@ -0,0 +1,97 @@
package net.sergeych.toread.fb2
data class Fb2Book(
val title: String,
val authors: List<Fb2Author> = emptyList(),
val language: String? = null,
val sourceLanguage: String? = null,
val genres: List<String> = emptyList(),
val annotation: String? = null,
val keywords: String? = null,
val date: String? = null,
val translators: List<Fb2Author> = emptyList(),
val sequences: List<Fb2Sequence> = emptyList(),
val coverImages: List<Fb2ImageRef> = emptyList(),
val documentInfo: Fb2DocumentInfo = Fb2DocumentInfo(),
val bodyTitle: List<String> = emptyList(),
val bodyImages: List<Fb2ImageRef> = emptyList(),
val sections: List<Fb2Section> = emptyList(),
val binaries: List<Fb2Binary> = emptyList(),
) {
fun binaryFor(image: Fb2ImageRef): Fb2Binary? =
binaries.firstOrNull { it.id == image.binaryId }
}
data class Fb2Author(
val firstName: String? = null,
val middleName: String? = null,
val lastName: String? = null,
val nickname: String? = null,
) {
val displayName: String
get() = listOfNotNull(firstName, middleName, lastName).joinToString(" ")
.ifBlank { nickname.orEmpty() }
}
data class Fb2Sequence(
val name: String,
val number: String? = null,
)
data class Fb2DocumentInfo(
val authors: List<Fb2Author> = emptyList(),
val date: String? = null,
val id: String? = null,
val version: String? = null,
)
data class Fb2Section(
val title: String? = null,
val paragraphs: List<String> = emptyList(),
val images: List<Fb2ImageRef> = emptyList(),
val sections: List<Fb2Section> = emptyList(),
val blocks: List<Fb2Block> = emptyList(),
)
data class Fb2ImageRef(
val href: String,
val alt: String? = null,
) {
val binaryId: String = href.removePrefix("#")
}
data class Fb2Binary(
val id: String,
val contentType: String,
val base64: String,
)
sealed interface Fb2Block {
data class Paragraph(val content: Fb2Text) : Fb2Block
data class Subtitle(val content: Fb2Text) : Fb2Block
data class Image(val image: Fb2ImageRef) : Fb2Block
data object EmptyLine : Fb2Block
}
data class Fb2Text(
val spans: List<Fb2TextSpan>,
) {
val plainText: String
get() = spans.joinToString(separator = "") { it.text }.trim()
}
data class Fb2TextSpan(
val text: String,
val styles: Set<Fb2TextStyle> = emptySet(),
)
enum class Fb2TextStyle {
Emphasis,
Strong,
Code,
Strikethrough,
Superscript,
Subscript,
}
class Fb2ParseException(message: String) : IllegalArgumentException(message)

View File

@ -0,0 +1,31 @@
package net.sergeych.toread.fb2
object Fb2Format {
const val Namespace = "http://www.gribuser.ru/xml/fictionbook/2.0"
const val XLinkNamespace = "http://www.w3.org/1999/xlink"
fun parseXml(xml: String): Fb2Book = Fb2XmlMapper.fromXml(xml)
fun parse(input: ByteArray, fileName: String? = null): Fb2Book {
val xml = if (looksLikeZip(input) || fileName?.endsWith(".zip", ignoreCase = true) == true) {
Fb2Zip.extractFb2Xml(input)
} else {
input.decodeToString()
}
return parseXml(xml)
}
fun exportXml(book: Fb2Book): String = Fb2XmlMapper.toXml(book)
fun exportZip(book: Fb2Book, entryName: String = "book.fb2"): ByteArray {
require(entryName.endsWith(".fb2", ignoreCase = true)) { "FB2 ZIP entry name should end with .fb2" }
return Fb2Zip.createStoredZip(entryName, exportXml(book).encodeToByteArray())
}
private fun looksLikeZip(input: ByteArray): Boolean =
input.size >= 4 &&
input[0] == 'P'.code.toByte() &&
input[1] == 'K'.code.toByte() &&
input[2] == 3.toByte() &&
input[3] == 4.toByte()
}

View File

@ -0,0 +1,256 @@
package net.sergeych.toread.fb2
internal object Fb2XmlMapper {
fun fromXml(xml: String): Fb2Book {
val root = SimpleXml.parse(xml)
if (root.localName != "FictionBook") {
throw Fb2ParseException("Expected FictionBook root element, got ${root.name}")
}
val description = root.first("description")
?: throw Fb2ParseException("FB2 document is missing description")
val titleInfo = description.first("title-info")
?: throw Fb2ParseException("FB2 document is missing description/title-info")
val documentInfo = description.first("document-info")
val body = root.children("body").firstOrNull { it.attributes["name"] != "notes" }
?: root.first("body")
return Fb2Book(
title = titleInfo.firstText("book-title")
?: throw Fb2ParseException("FB2 document is missing book-title"),
authors = titleInfo.children("author").map(::authorFrom),
language = titleInfo.firstText("lang"),
sourceLanguage = titleInfo.firstText("src-lang"),
genres = titleInfo.children("genre").mapNotNull { it.text().ifBlank { null } },
annotation = titleInfo.first("annotation")?.text()?.ifBlank { null },
keywords = titleInfo.firstText("keywords"),
date = titleInfo.first("date")?.text()?.ifBlank { null },
translators = titleInfo.children("translator").map(::authorFrom),
sequences = titleInfo.children("sequence").mapNotNull(::sequenceFrom),
coverImages = titleInfo.first("coverpage")?.children("image")?.mapNotNull(::imageRefFrom).orEmpty(),
documentInfo = documentInfoFrom(documentInfo),
bodyTitle = body?.first("title")?.children("p")?.mapNotNull { it.text().ifBlank { null } }.orEmpty(),
bodyImages = body?.children("image")?.mapNotNull(::imageRefFrom).orEmpty(),
sections = body?.children("section")?.map(::sectionFrom).orEmpty(),
binaries = root.children("binary").mapNotNull(::binaryFrom),
)
}
fun toXml(book: Fb2Book): String = buildString {
appendLine("""<?xml version="1.0" encoding="UTF-8"?>""")
append("""<FictionBook xmlns="${Fb2Format.Namespace}" xmlns:xlink="${Fb2Format.XLinkNamespace}">""")
append("<description><title-info>")
appendRepeated("genre", book.genres)
val titleAuthors = book.authors.ifEmpty { listOf(Fb2Author(nickname = "Unknown")) }
titleAuthors.forEach { appendAuthor(it) }
appendElement("book-title", book.title)
book.annotation?.takeIf { it.isNotBlank() }?.let { appendElement("annotation", it) }
book.keywords?.takeIf { it.isNotBlank() }?.let { appendElement("keywords", it) }
book.date?.takeIf { it.isNotBlank() }?.let { appendElement("date", it) }
if (book.coverImages.isNotEmpty()) {
append("<coverpage>")
book.coverImages.forEach { appendImage(it) }
append("</coverpage>")
}
book.language?.takeIf { it.isNotBlank() }?.let { appendElement("lang", it) }
book.sourceLanguage?.takeIf { it.isNotBlank() }?.let { appendElement("src-lang", it) }
book.translators.forEach { translator ->
append("<translator>")
appendAuthorFields(translator)
append("</translator>")
}
book.sequences.forEach { sequence ->
append("""<sequence name="${escapeAttribute(sequence.name)}"""")
sequence.number?.takeIf { it.isNotBlank() }?.let { append(""" number="${escapeAttribute(it)}"""") }
append("/>")
}
append("</title-info><document-info>")
val documentAuthors = book.documentInfo.authors.ifEmpty { titleAuthors }
documentAuthors.forEach { appendAuthor(it) }
appendElement("date", book.documentInfo.date?.takeIf { it.isNotBlank() } ?: "1970-01-01")
appendElement("id", book.documentInfo.id?.takeIf { it.isNotBlank() } ?: "toread-generated")
appendElement("version", book.documentInfo.version?.takeIf { it.isNotBlank() } ?: "1.0")
append("</document-info></description><body>")
book.bodyImages.forEach { appendImage(it) }
if (book.bodyTitle.isNotEmpty()) {
append("<title>")
book.bodyTitle.forEach { appendElement("p", it) }
append("</title>")
}
book.sections.forEach { appendSection(it) }
append("</body>")
book.binaries.forEach { binary ->
append("""<binary id="${escapeAttribute(binary.id)}" content-type="${escapeAttribute(binary.contentType)}">""")
append(binary.base64)
append("</binary>")
}
append("</FictionBook>")
}
private fun documentInfoFrom(element: XmlElement?): Fb2DocumentInfo = Fb2DocumentInfo(
authors = element?.children("author")?.map(::authorFrom).orEmpty(),
date = element?.first("date")?.text()?.ifBlank { null },
id = element?.firstText("id"),
version = element?.firstText("version"),
)
private fun authorFrom(element: XmlElement): Fb2Author = Fb2Author(
firstName = element.firstText("first-name"),
middleName = element.firstText("middle-name"),
lastName = element.firstText("last-name"),
nickname = element.firstText("nickname"),
)
private fun sequenceFrom(element: XmlElement): Fb2Sequence? {
val name = element.attributes["name"]?.takeIf { it.isNotBlank() } ?: return null
return Fb2Sequence(name = name, number = element.attributes["number"]?.takeIf { it.isNotBlank() })
}
private fun binaryFrom(element: XmlElement): Fb2Binary? {
val id = element.attributes["id"]?.takeIf { it.isNotBlank() } ?: return null
val contentType = element.attributes["content-type"]?.takeIf { it.isNotBlank() } ?: return null
return Fb2Binary(id = id, contentType = contentType, base64 = element.text().filterNot(Char::isWhitespace))
}
private fun imageRefFrom(element: XmlElement): Fb2ImageRef? {
val href = element.attributeLocal("href")?.takeIf { it.isNotBlank() } ?: return null
return Fb2ImageRef(href = href, alt = element.attributes["alt"]?.takeIf { it.isNotBlank() })
}
private fun sectionFrom(element: XmlElement): Fb2Section {
val blocks = element.nodes.mapNotNull { node ->
val child = (node as? XmlNode.ElementNode)?.element ?: return@mapNotNull null
when (child.localName) {
"p" -> Fb2Block.Paragraph(textFrom(child))
"subtitle" -> Fb2Block.Subtitle(textFrom(child))
"image" -> imageRefFrom(child)?.let(Fb2Block::Image)
"empty-line" -> Fb2Block.EmptyLine
else -> null
}
}
return Fb2Section(
title = element.first("title")?.text()?.ifBlank { null },
paragraphs = blocks.filterIsInstance<Fb2Block.Paragraph>()
.mapNotNull { it.content.plainText.ifBlank { null } },
images = blocks.filterIsInstance<Fb2Block.Image>().map { it.image },
sections = element.children("section").map(::sectionFrom),
blocks = blocks,
)
}
private fun textFrom(element: XmlElement): Fb2Text =
Fb2Text(spansFrom(element.nodes).mergeAdjacent())
private fun spansFrom(nodes: List<XmlNode>, styles: Set<Fb2TextStyle> = emptySet()): List<Fb2TextSpan> =
nodes.flatMap { node ->
when (node) {
is XmlNode.TextNode -> listOfNotNull(
node.text.takeIf { it.isNotEmpty() }?.let { Fb2TextSpan(it, styles) }
)
is XmlNode.ElementNode -> {
val element = node.element
val nestedStyles = when (element.localName) {
"emphasis" -> styles + Fb2TextStyle.Emphasis
"strong" -> styles + Fb2TextStyle.Strong
"code" -> styles + Fb2TextStyle.Code
"strikethrough" -> styles + Fb2TextStyle.Strikethrough
"sup" -> styles + Fb2TextStyle.Superscript
"sub" -> styles + Fb2TextStyle.Subscript
else -> styles
}
spansFrom(element.nodes, nestedStyles)
}
}
}
private fun List<Fb2TextSpan>.mergeAdjacent(): List<Fb2TextSpan> {
if (isEmpty()) return emptyList()
val merged = mutableListOf<Fb2TextSpan>()
forEach { span ->
val previous = merged.lastOrNull()
if (previous != null && previous.styles == span.styles) {
merged[merged.lastIndex] = previous.copy(text = previous.text + span.text)
} else {
merged += span
}
}
return merged
}
private fun StringBuilder.appendSection(section: Fb2Section) {
append("<section>")
section.title?.takeIf { it.isNotBlank() }?.let {
append("<title>")
appendElement("p", it)
append("</title>")
}
section.images.forEach { appendImage(it) }
section.paragraphs.forEach { appendElement("p", it) }
section.sections.forEach { appendSection(it) }
append("</section>")
}
private fun StringBuilder.appendAuthor(author: Fb2Author) {
append("<author>")
appendAuthorFields(author)
append("</author>")
}
private fun StringBuilder.appendAuthorFields(author: Fb2Author) {
author.firstName?.takeIf { it.isNotBlank() }?.let { appendElement("first-name", it) }
author.middleName?.takeIf { it.isNotBlank() }?.let { appendElement("middle-name", it) }
author.lastName?.takeIf { it.isNotBlank() }?.let { appendElement("last-name", it) }
author.nickname?.takeIf { it.isNotBlank() }?.let { appendElement("nickname", it) }
if (author.displayName.isBlank()) appendElement("nickname", "Unknown")
}
private fun StringBuilder.appendImage(image: Fb2ImageRef) {
append("""<image xlink:href="${escapeAttribute(image.href)}"""")
image.alt?.takeIf { it.isNotBlank() }?.let { append(""" alt="${escapeAttribute(it)}"""") }
append("/>")
}
private fun StringBuilder.appendRepeated(tag: String, values: List<String>) {
values.forEach { value ->
if (value.isNotBlank()) appendElement(tag, value)
}
}
private fun StringBuilder.appendElement(tag: String, text: String) {
append('<')
append(tag)
append('>')
append(escapeText(text))
append("</")
append(tag)
append('>')
}
private fun escapeText(value: String): String = buildString(value.length) {
value.forEach { char ->
when (char) {
'&' -> append("&amp;")
'<' -> append("&lt;")
'>' -> append("&gt;")
else -> append(char)
}
}
}
private fun escapeAttribute(value: String): String = buildString(value.length) {
value.forEach { char ->
when (char) {
'&' -> append("&amp;")
'<' -> append("&lt;")
'"' -> append("&quot;")
'\'' -> append("&apos;")
else -> append(char)
}
}
}
}
private fun XmlElement.attributeLocal(localName: String): String? =
attributes.entries.firstOrNull { it.key.substringAfter(':') == localName }?.value

View File

@ -0,0 +1,182 @@
package net.sergeych.toread.fb2
internal object Fb2Zip {
private const val LocalFileHeader = 0x04034b50
private const val CentralDirectoryHeader = 0x02014b50
private const val EndOfCentralDirectory = 0x06054b50
private const val Stored = 0
private const val Deflated = 8
fun extractFb2Xml(zip: ByteArray): String {
val entries = readCentralDirectory(zip)
val entry = entries.firstOrNull { it.name.endsWith(".fb2", ignoreCase = true) }
?: entries.firstOrNull { !it.name.endsWith("/") }
?: throw Fb2ParseException("ZIP archive does not contain an FB2 entry")
return readEntry(zip, entry).decodeToString()
}
fun createStoredZip(entryName: String, content: ByteArray): ByteArray {
val nameBytes = entryName.encodeToByteArray()
val crc = crc32(content)
val local = ByteWriter()
local.i32(LocalFileHeader)
local.i16(20)
local.i16(0)
local.i16(Stored)
local.i16(0)
local.i16(0)
local.i32(crc)
local.i32(content.size)
local.i32(content.size)
local.i16(nameBytes.size)
local.i16(0)
local.bytes(nameBytes)
local.bytes(content)
val centralOffset = local.size
val central = ByteWriter()
central.i32(CentralDirectoryHeader)
central.i16(20)
central.i16(20)
central.i16(0)
central.i16(Stored)
central.i16(0)
central.i16(0)
central.i32(crc)
central.i32(content.size)
central.i32(content.size)
central.i16(nameBytes.size)
central.i16(0)
central.i16(0)
central.i16(0)
central.i16(0)
central.i32(0)
central.i32(0)
central.bytes(nameBytes)
val end = ByteWriter()
end.i32(EndOfCentralDirectory)
end.i16(0)
end.i16(0)
end.i16(1)
end.i16(1)
end.i32(central.size)
end.i32(centralOffset)
end.i16(0)
return local.toByteArray() + central.toByteArray() + end.toByteArray()
}
private fun readEntry(zip: ByteArray, entry: ZipEntry): ByteArray {
if (zip.i32(entry.localHeaderOffset) != LocalFileHeader) {
throw Fb2ParseException("Invalid ZIP local header for ${entry.name}")
}
val nameSize = zip.i16(entry.localHeaderOffset + 26)
val extraSize = zip.i16(entry.localHeaderOffset + 28)
val dataOffset = entry.localHeaderOffset + 30 + nameSize + extraSize
if (dataOffset + entry.compressedSize > zip.size) {
throw Fb2ParseException("ZIP entry ${entry.name} is truncated")
}
val compressed = zip.copyOfRange(dataOffset, dataOffset + entry.compressedSize)
return when (entry.method) {
Stored -> compressed
Deflated -> inflateRaw(compressed, entry.uncompressedSize)
else -> throw Fb2ParseException("Unsupported ZIP compression method ${entry.method} for ${entry.name}")
}
}
private fun readCentralDirectory(zip: ByteArray): List<ZipEntry> {
val eocd = findEndOfCentralDirectory(zip)
val entryCount = zip.i16(eocd + 10)
val centralSize = zip.i32(eocd + 12)
val centralOffset = zip.i32(eocd + 16)
if (centralOffset < 0 || centralSize < 0 || centralOffset + centralSize > zip.size) {
throw Fb2ParseException("Invalid ZIP central directory")
}
val entries = mutableListOf<ZipEntry>()
var offset = centralOffset
repeat(entryCount) {
if (zip.i32(offset) != CentralDirectoryHeader) {
throw Fb2ParseException("Invalid ZIP central directory header")
}
val method = zip.i16(offset + 10)
val compressedSize = zip.i32(offset + 20)
val uncompressedSize = zip.i32(offset + 24)
val nameSize = zip.i16(offset + 28)
val extraSize = zip.i16(offset + 30)
val commentSize = zip.i16(offset + 32)
val localHeaderOffset = zip.i32(offset + 42)
val nameStart = offset + 46
val name = zip.copyOfRange(nameStart, nameStart + nameSize).decodeToString()
entries += ZipEntry(name, method, compressedSize, uncompressedSize, localHeaderOffset)
offset = nameStart + nameSize + extraSize + commentSize
}
return entries
}
private fun findEndOfCentralDirectory(zip: ByteArray): Int {
val min = maxOf(0, zip.size - 65_557)
var offset = zip.size - 22
while (offset >= min) {
if (zip.i32(offset) == EndOfCentralDirectory) return offset
offset--
}
throw Fb2ParseException("ZIP end of central directory was not found")
}
private data class ZipEntry(
val name: String,
val method: Int,
val compressedSize: Int,
val uncompressedSize: Int,
val localHeaderOffset: Int,
)
private class ByteWriter {
private val bytes = mutableListOf<Byte>()
val size: Int get() = bytes.size
fun i16(value: Int) {
bytes += (value and 0xff).toByte()
bytes += ((value ushr 8) and 0xff).toByte()
}
fun i32(value: Int) {
i16(value and 0xffff)
i16((value ushr 16) and 0xffff)
}
fun bytes(value: ByteArray) {
value.forEach { bytes += it }
}
fun toByteArray(): ByteArray = ByteArray(bytes.size) { bytes[it] }
}
}
internal expect fun inflateRaw(input: ByteArray, expectedSize: Int): ByteArray
private fun ByteArray.i16(offset: Int): Int {
if (offset + 2 > size) throw Fb2ParseException("Unexpected end of ZIP data")
return (this[offset].toInt() and 0xff) or ((this[offset + 1].toInt() and 0xff) shl 8)
}
private fun ByteArray.i32(offset: Int): Int {
if (offset + 4 > size) throw Fb2ParseException("Unexpected end of ZIP data")
return i16(offset) or (i16(offset + 2) shl 16)
}
private fun crc32(input: ByteArray): Int {
var crc = -1
input.forEach { byte ->
crc = crc xor (byte.toInt() and 0xff)
repeat(8) {
crc = if ((crc and 1) != 0) {
(crc ushr 1) xor 0xedb88320.toInt()
} else {
crc ushr 1
}
}
}
return crc.inv()
}

View File

@ -0,0 +1,233 @@
package net.sergeych.toread.fb2
internal data class XmlElement(
val name: String,
val attributes: Map<String, String>,
val nodes: List<XmlNode>,
) {
val localName: String = name.substringAfter(':')
fun children(localName: String): List<XmlElement> =
nodes.filterIsInstance<XmlNode.ElementNode>().map { it.element }.filter { it.localName == localName }
fun first(localName: String): XmlElement? = children(localName).firstOrNull()
fun firstText(localName: String): String? = first(localName)?.text()?.ifBlank { null }
fun text(): String = buildString {
nodes.forEach { node ->
when (node) {
is XmlNode.ElementNode -> append(node.element.text())
is XmlNode.TextNode -> append(node.text)
}
}
}.trim()
}
internal sealed interface XmlNode {
data class ElementNode(val element: XmlElement) : XmlNode
data class TextNode(val text: String) : XmlNode
}
internal object SimpleXml {
fun parse(xml: String): XmlElement = Parser(xml).parse()
private class Parser(private val xml: String) {
private var index = 0
fun parse(): XmlElement {
skipBom()
skipMisc()
val root = readElement()
skipMisc()
if (index < xml.length) {
throw error("Unexpected content after root element")
}
return root
}
private fun readElement(): XmlElement {
expect('<')
if (peek() == '/') throw error("Unexpected closing tag")
if (startsWith("!--")) return skipCommentAndReadNext()
if (startsWith("?")) {
skipProcessingInstruction()
skipMisc()
return readElement()
}
val name = readName()
val attributes = linkedMapOf<String, String>()
while (true) {
skipWhitespace()
when {
startsWith("/>") -> {
index += 2
return XmlElement(name, attributes, emptyList())
}
startsWith(">") -> {
index++
break
}
else -> {
val attributeName = readName()
skipWhitespace()
expect('=')
skipWhitespace()
attributes[attributeName] = readAttributeValue()
}
}
}
val nodes = mutableListOf<XmlNode>()
while (index < xml.length) {
when {
startsWith("</") -> {
index += 2
val closingName = readName()
if (closingName != name) throw error("Expected closing tag for $name, got $closingName")
skipWhitespace()
expect('>')
return XmlElement(name, attributes, nodes)
}
startsWith("<!--") -> skipComment()
startsWith("<![CDATA[") -> nodes += XmlNode.TextNode(readCData())
startsWith("<?") -> skipProcessingInstruction()
peek() == '<' -> nodes += XmlNode.ElementNode(readElement())
else -> nodes += XmlNode.TextNode(decodeEntities(readText()))
}
}
throw error("Unclosed tag $name")
}
private fun skipCommentAndReadNext(): XmlElement {
index--
skipComment()
skipMisc()
return readElement()
}
private fun readName(): String {
val start = index
while (index < xml.length) {
val char = xml[index]
if (char.isWhitespace() || char == '/' || char == '>' || char == '=') break
index++
}
if (start == index) throw error("Expected XML name")
return xml.substring(start, index)
}
private fun readAttributeValue(): String {
val quote = peek()
if (quote != '"' && quote != '\'') throw error("Expected quoted attribute value")
index++
val start = index
while (index < xml.length && xml[index] != quote) index++
if (index >= xml.length) throw error("Unclosed attribute value")
val value = xml.substring(start, index)
index++
return decodeEntities(value)
}
private fun readText(): String {
val start = index
while (index < xml.length && xml[index] != '<') index++
return xml.substring(start, index)
}
private fun readCData(): String {
index += "<![CDATA[".length
val end = xml.indexOf("]]>", index)
if (end < 0) throw error("Unclosed CDATA section")
val value = xml.substring(index, end)
index = end + 3
return value
}
private fun skipMisc() {
while (true) {
skipWhitespace()
when {
startsWith("<?") -> skipProcessingInstruction()
startsWith("<!--") -> skipComment()
else -> return
}
}
}
private fun skipProcessingInstruction() {
val end = xml.indexOf("?>", index)
if (end < 0) throw error("Unclosed processing instruction")
index = end + 2
}
private fun skipComment() {
val end = xml.indexOf("-->", index)
if (end < 0) throw error("Unclosed XML comment")
index = end + 3
}
private fun skipWhitespace() {
while (index < xml.length && xml[index].isWhitespace()) index++
}
private fun skipBom() {
if (xml.startsWith("\uFEFF")) index = 1
}
private fun startsWith(value: String): Boolean = xml.startsWith(value, index)
private fun peek(): Char {
if (index >= xml.length) throw error("Unexpected end of XML")
return xml[index]
}
private fun expect(char: Char) {
if (peek() != char) throw error("Expected '$char'")
index++
}
private fun error(message: String): Fb2ParseException =
Fb2ParseException("$message at offset $index")
}
}
private fun decodeEntities(value: String): String {
if ('&' !in value) return value
return buildString(value.length) {
var index = 0
while (index < value.length) {
val char = value[index]
if (char != '&') {
append(char)
index++
continue
}
val end = value.indexOf(';', index + 1)
if (end < 0) {
append(char)
index++
continue
}
val entity = value.substring(index + 1, end)
append(
when {
entity == "lt" -> '<'
entity == "gt" -> '>'
entity == "amp" -> '&'
entity == "quot" -> '"'
entity == "apos" -> '\''
entity.startsWith("#x") -> entity.drop(2).toIntOrNull(16)?.toChar() ?: '&'
entity.startsWith("#") -> entity.drop(1).toIntOrNull()?.toChar() ?: '&'
else -> '&'
}
)
if (entity.startsWith("#").not() && entity !in setOf("lt", "gt", "amp", "quot", "apos")) {
append(entity)
append(';')
}
index = end + 1
}
}
}

View File

@ -0,0 +1,196 @@
package net.sergeych.toread.storage
enum class BookFileStorageKind {
EXTERNAL_URI,
MANAGED_FILE,
DATABASE_BLOB,
INDEXEDDB_BLOB,
REMOTE_URL,
MISSING,
}
enum class BookImportPolicy {
LINK,
COPY,
STORE_BLOB,
}
data class BookRecord(
val id: String,
val title: String? = null,
val subtitle: String? = null,
val language: String? = null,
val description: String? = null,
val coverImage: ByteArray? = null,
val coverImageMimeType: String? = null,
val createdAt: Long,
val updatedAt: Long,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is BookRecord) return false
return id == other.id &&
title == other.title &&
subtitle == other.subtitle &&
language == other.language &&
description == other.description &&
coverImage.contentEquals(other.coverImage) &&
coverImageMimeType == other.coverImageMimeType &&
createdAt == other.createdAt &&
updatedAt == other.updatedAt
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + (title?.hashCode() ?: 0)
result = 31 * result + (subtitle?.hashCode() ?: 0)
result = 31 * result + (language?.hashCode() ?: 0)
result = 31 * result + (description?.hashCode() ?: 0)
result = 31 * result + (coverImage?.contentHashCode() ?: 0)
result = 31 * result + (coverImageMimeType?.hashCode() ?: 0)
result = 31 * result + createdAt.hashCode()
result = 31 * result + updatedAt.hashCode()
return result
}
}
data class BookBodyRecord(
val id: String,
val exactTextHash: String,
val canonicalizationVersion: Int,
val nearSignature: String? = null,
val wordCount: Int? = null,
val language: String? = null,
val createdAt: Long,
)
data class BodyClusterRecord(
val id: String,
val representativeBodyId: String,
val createdAt: Long,
)
data class BookFileRecord(
val id: String,
val bookId: String? = null,
val bodyId: String? = null,
val bodyClusterId: String? = null,
val rawSha256: String,
val format: String? = null,
val mimeType: String? = null,
val sizeBytes: Long? = null,
val originalFilename: String? = null,
val storageKind: BookFileStorageKind,
val storageUri: String? = null,
val contentObjectId: String? = null,
val lastModifiedMillis: Long? = null,
val lastSeenAt: Long? = null,
val createdAt: Long,
val updatedAt: Long,
)
data class ContentAnchor(
val version: Int = 1,
val canonicalCharOffset: Long? = null,
val canonicalTokenOffset: Long? = null,
val progress: Double? = null,
val exact: String? = null,
val prefix: String? = null,
val suffix: String? = null,
val fingerprintsJson: String? = null,
val formatHintsJson: String? = null,
)
data class ReadingStateRecord(
val id: String,
val bodyClusterId: String,
val bodyId: String? = null,
val anchor: ContentAnchor,
val updatedAt: Long,
)
data class BookmarkRecord(
val id: String,
val bodyClusterId: String,
val bodyId: String? = null,
val anchor: ContentAnchor,
val title: String? = null,
val selectedTextSnapshot: String? = null,
val color: String? = null,
val createdAt: Long,
val updatedAt: Long,
)
data class NoteRecord(
val id: String,
val bookmarkId: String? = null,
val bodyClusterId: String,
val bodyId: String? = null,
val anchor: ContentAnchor,
val text: String,
val createdAt: Long,
val updatedAt: Long,
)
interface BookRepository {
fun upsert(book: BookRecord)
fun get(id: String): BookRecord?
fun list(limit: Int = 100, offset: Int = 0): List<BookRecord>
fun delete(id: String): Boolean
}
interface BookBodyRepository {
fun upsert(body: BookBodyRecord)
fun get(id: String): BookBodyRecord?
fun findByExactTextHash(exactTextHash: String, canonicalizationVersion: Int): BookBodyRecord?
}
interface BodyClusterRepository {
fun upsert(cluster: BodyClusterRecord)
fun get(id: String): BodyClusterRecord?
fun findByRepresentativeBodyId(bodyId: String): BodyClusterRecord?
}
interface BookFileRepository {
fun upsert(file: BookFileRecord)
fun get(id: String): BookFileRecord?
fun findByRawSha256(rawSha256: String): List<BookFileRecord>
fun list(limit: Int = 500, offset: Int = 0): List<BookFileRecord>
fun listForBook(bookId: String): List<BookFileRecord>
fun delete(id: String): Boolean
}
interface ReadingStateRepository {
fun upsert(state: ReadingStateRecord)
fun get(id: String): ReadingStateRecord?
fun getForBodyCluster(bodyClusterId: String): ReadingStateRecord?
}
interface BookmarkRepository {
fun upsert(bookmark: BookmarkRecord)
fun get(id: String): BookmarkRecord?
fun listForBodyCluster(bodyClusterId: String): List<BookmarkRecord>
fun delete(id: String): Boolean
}
interface NoteRepository {
fun upsert(note: NoteRecord)
fun get(id: String): NoteRecord?
fun listForBookmark(bookmarkId: String): List<NoteRecord>
fun listForBodyCluster(bodyClusterId: String): List<NoteRecord>
fun delete(id: String): Boolean
}
interface LibraryDatabase {
val books: BookRepository
val bodies: BookBodyRepository
val clusters: BodyClusterRepository
val files: BookFileRepository
val readingStates: ReadingStateRepository
val bookmarks: BookmarkRepository
val notes: NoteRepository
fun <T> transaction(block: LibraryDatabase.() -> T): T
fun close()
}

View File

@ -0,0 +1,110 @@
package net.sergeych.toread.text
interface HyphenationPlugin {
val languageTags: Set<String>
fun hyphenateWord(word: String): String
}
class HyphenationRegistry(
plugins: List<HyphenationPlugin> = listOf(
EnglishHyphenationPlugin,
RussianHyphenationPlugin,
),
) {
private val byLanguage = plugins
.flatMap { plugin -> plugin.languageTags.map { it.lowercase() to plugin } }
.toMap()
fun pluginFor(languageTag: String?): HyphenationPlugin? {
val normalized = languageTag?.lowercase()?.takeIf { it.isNotBlank() } ?: return null
return byLanguage[normalized] ?: byLanguage[normalized.substringBefore('-')]
}
fun hyphenate(text: String, languageTag: String?): String {
val plugin = pluginFor(languageTag) ?: return text
return hyphenate(text, plugin)
}
private fun hyphenate(text: String, plugin: HyphenationPlugin): String = buildString(text.length + text.length / 12) {
var wordStart = -1
fun flushWord(end: Int) {
if (wordStart >= 0) {
append(plugin.hyphenateWord(text.substring(wordStart, end)))
wordStart = -1
}
}
text.forEachIndexed { index, char ->
if (char.isLetter()) {
if (wordStart < 0) wordStart = index
} else {
flushWord(index)
append(char)
}
}
flushWord(text.length)
}
}
object EnglishHyphenationPlugin : HyphenationPlugin {
override val languageTags: Set<String> = setOf("en", "eng")
override fun hyphenateWord(word: String): String {
if (word.length < 7 || SoftHyphen in word) return word
val breaks = mutableListOf<Int>()
for (index in 2 until word.lastIndex - 1) {
val prev = word[index - 1]
val current = word[index]
val next = word[index + 1]
if (prev.isVowel() && current.isConsonant() && next.isVowel()) breaks += index
if (current.isConsonant() && next.isConsonant() && word.getOrNull(index + 2)?.isVowel() == true) {
breaks += index + 1
}
}
return insertBreaks(word, breaks, minPrefix = 3, minSuffix = 3)
}
}
object RussianHyphenationPlugin : HyphenationPlugin {
override val languageTags: Set<String> = setOf("ru", "rus")
override fun hyphenateWord(word: String): String {
if (word.length < 6 || SoftHyphen in word) return word
val breaks = mutableListOf<Int>()
for (index in 2 until word.lastIndex) {
val prev = word[index - 1]
val current = word[index]
val next = word[index + 1]
if (current.isRussianVowel() && next.isRussianConsonant()) breaks += index + 1
if (prev.isRussianVowel() && current.isRussianConsonant() && next.isRussianVowel()) breaks += index
}
return insertBreaks(word, breaks, minPrefix = 2, minSuffix = 2)
}
}
const val SoftHyphen: Char = '\u00AD'
private fun insertBreaks(word: String, breaks: List<Int>, minPrefix: Int, minSuffix: Int): String {
val legalBreaks = breaks.distinct()
.filter { it >= minPrefix && word.length - it >= minSuffix }
.toSet()
if (legalBreaks.isEmpty()) return word
return buildString(word.length + legalBreaks.size) {
word.forEachIndexed { index, char ->
if (index in legalBreaks) append(SoftHyphen)
append(char)
}
}
}
private fun Char.isVowel(): Boolean = lowercaseChar() in "aeiouy"
private fun Char.isConsonant(): Boolean = isLetter() && !isVowel()
private fun Char.isRussianVowel(): Boolean = lowercaseChar() in "аеёиоуыэюя"
private fun Char.isRussianConsonant(): Boolean =
lowercaseChar() in "бвгджзйклмнпрстфхцчшщ"

View File

@ -0,0 +1,717 @@
<?xml version="1.0" encoding="UTF-8"?>
<xs:schema xmlns:genre="http://www.gribuser.ru/xml/fictionbook/2.0/genres" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns="http://www.gribuser.ru/xml/fictionbook/2.0" targetNamespace="http://www.gribuser.ru/xml/fictionbook/2.0" elementFormDefault="qualified" attributeFormDefault="unqualified">
<!--
Copyright (c) 2004, Dmitry Gribov
All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this list
of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice, this
list of conditions and the following disclaimer in the documentation and/or other
materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT
SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
SUCH DAMAGE.
-->
<xs:import namespace="http://www.w3.org/1999/xlink" schemaLocation="FictionBookLinks.xsd"/>
<xs:import namespace="http://www.gribuser.ru/xml/fictionbook/2.0/genres" schemaLocation="FictionBookGenres.xsd"/>
<xs:import namespace="http://www.w3.org/XML/1998/namespace" schemaLocation="FictionBookLang.xsd"/>
<xs:complexType name="bodyType">
<xs:annotation>
<xs:documentation>Main content of the book, multiple bodies are used for additional information, like footnotes, that do not appear in the main book flow (extended from this class). The first body is presented to the reader by default, and content in the other bodies should be accessible by hyperlinks.</xs:documentation>
</xs:annotation>
<xs:sequence>
<xs:element name="image" type="imageType" minOccurs="0">
<xs:annotation>
<xs:documentation>Image to be displayed at the top of this section</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="title" type="titleType" minOccurs="0">
<xs:annotation>
<xs:documentation>A fancy title for the entire book, should be used if the simple text version in &lt;description&gt; is not adequate, e.g. the book title has multiple paragraphs and/or character styles</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="epigraph" type="epigraphType" minOccurs="0" maxOccurs="unbounded">
<xs:annotation>
<xs:documentation>Epigraph(s) for the entire book, if any</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="section" type="sectionType" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute ref="xml:lang"/>
</xs:complexType>
<xs:complexType name="notesBodyType">
<xs:annotation>
<xs:documentation>Body for footnotes, content is mostly similar to base type and may (!) be rendered in the pure environment "as is". Advanced reader should treat section[2]/section as endnotes, all other stuff as footnotes</xs:documentation>
</xs:annotation>
<xs:complexContent>
<xs:extension base="bodyType">
<xs:attribute name="name" use="optional">
<xs:simpleType>
<xs:restriction base="xs:token">
<xs:pattern value="notes"/>
</xs:restriction>
</xs:simpleType>
</xs:attribute>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:element name="FictionBook">
<xs:annotation>
<xs:documentation>Root element</xs:documentation>
</xs:annotation>
<xs:complexType>
<xs:sequence>
<xs:element name="stylesheet" minOccurs="0" maxOccurs="unbounded">
<xs:annotation>
<xs:documentation>This element contains an arbitrary stylesheet that is intepreted by a some processing programs, e.g. text/css stylesheets can be used by XSLT stylesheets to generate better looking html</xs:documentation>
</xs:annotation>
<xs:complexType>
<xs:simpleContent>
<xs:extension base="xs:string">
<xs:attribute name="type" type="xs:string" use="required"/>
</xs:extension>
</xs:simpleContent>
</xs:complexType>
</xs:element>
<xs:element name="description">
<xs:complexType>
<xs:sequence>
<xs:element name="title-info" type="title-infoType">
<xs:annotation>
<xs:documentation>Generic information about the book</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="src-title-info" type="title-infoType" minOccurs="0">
<xs:annotation>
<xs:documentation>Generic information about the original book (for translations) </xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="document-info">
<xs:annotation>
<xs:documentation>Information about this particular (xml) document</xs:documentation>
</xs:annotation>
<xs:complexType>
<xs:sequence>
<xs:element name="author" type="authorType" maxOccurs="unbounded">
<xs:annotation>
<xs:documentation>Author(s) of this particular document</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="program-used" type="textFieldType" minOccurs="0">
<xs:annotation>
<xs:documentation>Any software used in preparation of this document, in free format</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="date" type="dateType">
<xs:annotation>
<xs:documentation>Date this document was created, same guidelines as in the &lt;title-info&gt; section apply</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="src-url" type="xs:string" minOccurs="0" maxOccurs="unbounded">
<xs:annotation>
<xs:documentation>Source URL if this document is a conversion of some other (online) document</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="src-ocr" type="textFieldType" minOccurs="0">
<xs:annotation>
<xs:documentation>Author of the original (online) document, if this is a conversion</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="id" type="xs:token">
<xs:annotation>
<xs:documentation>this is a unique identifier for a document. this must not change</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="version" type="xs:float">
<xs:annotation>
<xs:documentation>Document version, in free format, should be incremented if the document is changed and re-released to the public</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="history" type="annotationType" minOccurs="0">
<xs:annotation>
<xs:documentation>Short description for all changes made to this document, like "Added missing chapter 6", in free form.</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="publisher" type="authorType" minOccurs="0" maxOccurs="unbounded">
<xs:annotation>
<xs:documentation>Owner of the fb2 document copyrights</xs:documentation>
</xs:annotation>
</xs:element>
</xs:sequence>
</xs:complexType>
</xs:element>
<xs:element name="publish-info" minOccurs="0">
<xs:annotation>
<xs:documentation>Information about some paper/outher published document, that was used as a source of this xml document</xs:documentation>
</xs:annotation>
<xs:complexType>
<xs:sequence>
<xs:element name="book-name" type="textFieldType" minOccurs="0">
<xs:annotation>
<xs:documentation>Original (paper) book name</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="publisher" type="textFieldType" minOccurs="0">
<xs:annotation>
<xs:documentation>Original (paper) book publisher</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="city" type="textFieldType" minOccurs="0">
<xs:annotation>
<xs:documentation>City where the original (paper) book was published</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="year" type="xs:gYear" minOccurs="0">
<xs:annotation>
<xs:documentation>Year of the original (paper) publication</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="isbn" type="textFieldType" minOccurs="0"/>
<xs:element name="sequence" type="sequenceType" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
</xs:element>
<xs:element name="custom-info" minOccurs="0" maxOccurs="unbounded">
<xs:annotation>
<xs:documentation>Any other information about the book/document that didnt fit in the above groups</xs:documentation>
</xs:annotation>
<xs:complexType>
<xs:simpleContent>
<xs:extension base="textFieldType">
<xs:attribute name="info-type" type="xs:string" use="required"/>
</xs:extension>
</xs:simpleContent>
</xs:complexType>
</xs:element>
<xs:element name="output" type="shareInstructionType" minOccurs="0" maxOccurs="2">
<xs:annotation>
<xs:documentation>Describes, how the document should be presented to end-user, what parts are free, what parts should be sold and what price should be used</xs:documentation>
</xs:annotation>
</xs:element>
</xs:sequence>
</xs:complexType>
</xs:element>
<xs:element name="body" type="bodyType">
<xs:annotation>
<xs:documentation>Main content of the book, multiple bodies are used for additional information, like footnotes, that do not appear in the main book flow. The first body is presented to the reader by default, and content in the other bodies should be accessible by hyperlinks. Name attribute should describe the meaning of this body, this is optional for the main body.</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="body" type="notesBodyType" minOccurs="0">
<xs:annotation>
<xs:documentation>Main content of the book, multiple bodies are used for additional information, like footnotes, that do not appear in the main book flow. The first body is presented to the reader by default, and content in the other bodies should be accessible by hyperlinks. Name attribute should describe the meaning of this body, this is optional for the main body.</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="binary" minOccurs="0" maxOccurs="unbounded">
<xs:annotation>
<xs:documentation>Any binary data that is required for the presentation of this book in base64 format. Currently only images are used.</xs:documentation>
</xs:annotation>
<xs:complexType>
<xs:simpleContent>
<xs:extension base="xs:base64Binary">
<xs:attribute name="content-type" type="xs:string" use="required"/>
<xs:attribute name="id" type="xs:ID" use="required"/>
</xs:extension>
</xs:simpleContent>
</xs:complexType>
</xs:element>
</xs:sequence>
</xs:complexType>
</xs:element>
<xs:complexType name="authorType">
<xs:annotation>
<xs:documentation>Information about a single author</xs:documentation>
</xs:annotation>
<xs:choice>
<xs:sequence>
<xs:element name="first-name" type="textFieldType"/>
<xs:element name="middle-name" type="textFieldType" minOccurs="0"/>
<xs:element name="last-name" type="textFieldType"/>
<xs:element name="nickname" type="textFieldType" minOccurs="0"/>
<xs:element name="home-page" type="xs:string" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="email" type="xs:string" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="id" type="xs:token" minOccurs="0"/>
</xs:sequence>
<xs:sequence>
<xs:element name="nickname" type="textFieldType"/>
<xs:element name="home-page" type="xs:string" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="email" type="xs:string" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="id" type="xs:token" minOccurs="0"/>
</xs:sequence>
</xs:choice>
</xs:complexType>
<xs:complexType name="textFieldType">
<xs:simpleContent>
<xs:extension base="xs:string">
<xs:attribute ref="xml:lang"/>
</xs:extension>
</xs:simpleContent>
</xs:complexType>
<xs:complexType name="dateType">
<xs:annotation>
<xs:documentation>A human readable date, maybe not exact, with an optional computer readable variant</xs:documentation>
</xs:annotation>
<xs:simpleContent>
<xs:extension base="xs:string">
<xs:attribute name="value" type="xs:date" use="optional"/>
<xs:attribute ref="xml:lang"/>
</xs:extension>
</xs:simpleContent>
</xs:complexType>
<xs:complexType name="titleType">
<xs:annotation>
<xs:documentation>A title, used in sections, poems and body elements</xs:documentation>
</xs:annotation>
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element name="p" type="pType"/>
<xs:element name="empty-line"/>
</xs:choice>
<xs:attribute ref="xml:lang"/>
</xs:complexType>
<xs:complexType name="imageType">
<xs:annotation>
<xs:documentation>An empty element with an image name as an attribute</xs:documentation>
</xs:annotation>
<xs:attribute ref="xlink:type"/>
<xs:attribute ref="xlink:href"/>
<xs:attribute name="alt" type="xs:string" use="optional"/>
<xs:attribute name="title" type="xs:string" use="optional"/>
<xs:attribute name="id" type="xs:ID" use="optional"/>
</xs:complexType>
<xs:complexType name="pType" mixed="true">
<xs:annotation>
<xs:documentation>A basic paragraph, may include simple formatting inside</xs:documentation>
</xs:annotation>
<xs:complexContent mixed="true">
<xs:extension base="styleType">
<xs:attribute name="id" type="xs:ID" use="optional"/>
<xs:attribute name="style" type="xs:string" use="optional"/>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="citeType">
<xs:annotation>
<xs:documentation>A citation with an optional citation author at the end</xs:documentation>
</xs:annotation>
<xs:sequence>
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element name="p" type="pType"/>
<xs:element name="poem" type="poemType"/>
<xs:element name="empty-line"/>
<xs:element name="subtitle" type="pType"/>
<xs:element name="table" type="tableType"/>
</xs:choice>
<xs:element name="text-author" type="pType" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="id" type="xs:ID" use="optional"/>
<xs:attribute ref="xml:lang"/>
</xs:complexType>
<xs:complexType name="poemType">
<xs:annotation>
<xs:documentation>A poem</xs:documentation>
</xs:annotation>
<xs:sequence>
<xs:element name="title" type="titleType" minOccurs="0">
<xs:annotation>
<xs:documentation>Poem title</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="epigraph" type="epigraphType" minOccurs="0" maxOccurs="unbounded">
<xs:annotation>
<xs:documentation>Poem epigraph(s), if any</xs:documentation>
</xs:annotation>
</xs:element>
<xs:choice maxOccurs="unbounded">
<xs:element name="subtitle" type="pType"/>
<xs:element name="stanza">
<xs:annotation>
<xs:documentation>Each poem should have at least one stanza. Stanzas are usually separated with empty lines by user agents.</xs:documentation>
</xs:annotation>
<xs:complexType>
<xs:sequence>
<xs:element name="title" type="titleType" minOccurs="0"/>
<xs:element name="subtitle" type="pType" minOccurs="0"/>
<xs:element name="v" type="pType" maxOccurs="unbounded">
<xs:annotation>
<xs:documentation>An individual line in a stanza</xs:documentation>
</xs:annotation>
</xs:element>
</xs:sequence>
<xs:attribute ref="xml:lang"/>
</xs:complexType>
</xs:element>
</xs:choice>
<xs:element name="text-author" type="pType" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="date" type="dateType" minOccurs="0">
<xs:annotation>
<xs:documentation>Date this poem was written.</xs:documentation>
</xs:annotation>
</xs:element>
</xs:sequence>
<xs:attribute name="id" type="xs:ID" use="optional"/>
<xs:attribute ref="xml:lang"/>
</xs:complexType>
<xs:complexType name="epigraphType">
<xs:annotation>
<xs:documentation>An epigraph</xs:documentation>
</xs:annotation>
<xs:sequence>
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element name="p" type="pType"/>
<xs:element name="poem" type="poemType"/>
<xs:element name="cite" type="citeType"/>
<xs:element name="empty-line"/>
</xs:choice>
<xs:element name="text-author" type="pType" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="id" type="xs:ID" use="optional"/>
</xs:complexType>
<xs:complexType name="annotationType">
<xs:annotation>
<xs:documentation>A cut-down version of &lt;section&gt; used in annotations</xs:documentation>
</xs:annotation>
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element name="p" type="pType"/>
<xs:element name="poem" type="poemType"/>
<xs:element name="cite" type="citeType"/>
<xs:element name="subtitle" type="pType"/>
<xs:element name="table" type="tableType"/>
<xs:element name="empty-line"/>
</xs:choice>
<xs:attribute name="id" type="xs:ID" use="optional"/>
<xs:attribute ref="xml:lang"/>
</xs:complexType>
<xs:complexType name="sectionType">
<xs:annotation>
<xs:documentation>A basic block of a book, can contain more child sections or textual content</xs:documentation>
</xs:annotation>
<xs:sequence minOccurs="0">
<xs:element name="title" type="titleType" minOccurs="0">
<xs:annotation>
<xs:documentation>Section's title</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="epigraph" type="epigraphType" minOccurs="0" maxOccurs="unbounded">
<xs:annotation>
<xs:documentation>Epigraph(s) for this section</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="image" type="imageType" minOccurs="0">
<xs:annotation>
<xs:documentation>Image to be displayed at the top of this section</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="annotation" type="annotationType" minOccurs="0">
<xs:annotation>
<xs:documentation>Annotation for this section, if any</xs:documentation>
</xs:annotation>
</xs:element>
<xs:choice>
<xs:sequence>
<xs:element name="section" type="sectionType" maxOccurs="unbounded">
<xs:annotation>
<xs:documentation>Child sections</xs:documentation>
</xs:annotation>
</xs:element>
</xs:sequence>
<xs:sequence>
<xs:choice>
<xs:element name="p" type="pType"/>
<xs:element name="poem" type="poemType"/>
<xs:element name="subtitle" type="pType"/>
<xs:element name="cite" type="citeType"/>
<xs:element name="empty-line"/>
<xs:element name="table" type="tableType"/>
</xs:choice>
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element name="p" type="pType"/>
<xs:element name="image" type="imageType"/>
<xs:element name="poem" type="poemType"/>
<xs:element name="subtitle" type="pType"/>
<xs:element name="cite" type="citeType"/>
<xs:element name="empty-line"/>
<xs:element name="table" type="tableType"/>
</xs:choice>
</xs:sequence>
</xs:choice>
</xs:sequence>
<xs:attribute name="id" type="xs:ID" use="optional"/>
<xs:attribute ref="xml:lang"/>
</xs:complexType>
<xs:complexType name="styleType" mixed="true">
<xs:annotation>
<xs:documentation>Markup</xs:documentation>
</xs:annotation>
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element name="strong" type="styleType"/>
<xs:element name="emphasis" type="styleType"/>
<xs:element name="style" type="namedStyleType"/>
<xs:element name="a" type="linkType"/>
<xs:element name="strikethrough" type="styleType"/>
<xs:element name="sub" type="styleType"/>
<xs:element name="sup" type="styleType"/>
<xs:element name="code" type="styleType"/>
<xs:element name="image" type="inlineImageType"/>
</xs:choice>
<xs:attribute ref="xml:lang"/>
</xs:complexType>
<xs:complexType name="namedStyleType" mixed="true">
<xs:annotation>
<xs:documentation>Markup</xs:documentation>
</xs:annotation>
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element name="strong" type="styleType"/>
<xs:element name="emphasis" type="styleType"/>
<xs:element name="style" type="namedStyleType"/>
<xs:element name="a" type="linkType"/>
<xs:element name="strikethrough" type="styleType"/>
<xs:element name="sub" type="styleType"/>
<xs:element name="sup" type="styleType"/>
<xs:element name="code" type="styleType"/>
<xs:element name="image" type="inlineImageType"/>
</xs:choice>
<xs:attribute ref="xml:lang" use="optional"/>
<xs:attribute name="name" type="xs:token" use="required"/>
</xs:complexType>
<xs:complexType name="linkType" mixed="true">
<xs:annotation>
<xs:documentation>Generic hyperlinks. Cannot be nested. Footnotes should be implemented by links referring to additional bodies in the same document</xs:documentation>
</xs:annotation>
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element name="strong" type="styleLinkType"/>
<xs:element name="emphasis" type="styleLinkType"/>
<xs:element name="style" type="styleLinkType"/>
<xs:element name="strikethrough" type="styleLinkType"/>
<xs:element name="sub" type="styleLinkType"/>
<xs:element name="sup" type="styleLinkType"/>
<xs:element name="code" type="styleLinkType"/>
<xs:element name="image" type="inlineImageType"/>
</xs:choice>
<xs:attribute ref="xlink:type" use="optional"/>
<xs:attribute ref="xlink:href" use="required"/>
<xs:attribute name="type" type="xs:token" use="optional"/>
</xs:complexType>
<xs:complexType name="styleLinkType" mixed="true">
<xs:annotation>
<xs:documentation>Markup</xs:documentation>
</xs:annotation>
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element name="strong" type="styleLinkType"/>
<xs:element name="emphasis" type="styleLinkType"/>
<xs:element name="style" type="styleLinkType"/>
<xs:element name="strikethrough" type="styleLinkType"/>
<xs:element name="sub" type="styleLinkType"/>
<xs:element name="sup" type="styleLinkType"/>
<xs:element name="code" type="styleLinkType"/>
<xs:element name="image" type="inlineImageType"/>
</xs:choice>
</xs:complexType>
<xs:complexType name="sequenceType">
<xs:annotation>
<xs:documentation>Book sequences</xs:documentation>
</xs:annotation>
<xs:sequence>
<xs:element name="sequence" type="sequenceType" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="name" type="xs:string" use="required"/>
<xs:attribute name="number" type="xs:integer" use="optional"/>
<xs:attribute ref="xml:lang"/>
</xs:complexType>
<xs:complexType name="tableType">
<xs:annotation>
<xs:documentation>Basic html-like tables</xs:documentation>
</xs:annotation>
<xs:sequence>
<xs:element name="tr" maxOccurs="unbounded">
<xs:complexType>
<xs:choice maxOccurs="unbounded">
<xs:element name="th" type="tdType"/>
<xs:element name="td" type="tdType"/>
</xs:choice>
<xs:attribute name="align" type="alignType" use="optional" default="left"/>
</xs:complexType>
</xs:element>
</xs:sequence>
<xs:attribute name="style" type="xs:string" use="optional"/>
<xs:attribute name="id" type="xs:ID" use="optional"/>
</xs:complexType>
<xs:simpleType name="alignType">
<xs:annotation>
<xs:documentation>Align for table cells</xs:documentation>
</xs:annotation>
<xs:restriction base="xs:token">
<xs:enumeration value="left"/>
<xs:enumeration value="right"/>
<xs:enumeration value="center"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="vAlignType">
<xs:annotation>
<xs:documentation>Align for table cells</xs:documentation>
</xs:annotation>
<xs:restriction base="xs:token">
<xs:enumeration value="top"/>
<xs:enumeration value="middle"/>
<xs:enumeration value="bottom"/>
</xs:restriction>
</xs:simpleType>
<xs:complexType name="title-infoType">
<xs:annotation>
<xs:documentation>Book (as a book opposite a document) description</xs:documentation>
</xs:annotation>
<xs:sequence>
<xs:element name="genre" maxOccurs="unbounded">
<xs:annotation>
<xs:documentation>Genre of this book, with the optional match percentage</xs:documentation>
</xs:annotation>
<xs:complexType>
<xs:simpleContent>
<xs:extension base="genre:genreType">
<xs:attribute name="match" type="xs:integer" use="optional" default="100"/>
</xs:extension>
</xs:simpleContent>
</xs:complexType>
</xs:element>
<xs:element name="author" maxOccurs="unbounded">
<xs:annotation>
<xs:documentation>Author(s) of this book</xs:documentation>
</xs:annotation>
<xs:complexType>
<xs:complexContent>
<xs:extension base="authorType"/>
</xs:complexContent>
</xs:complexType>
</xs:element>
<xs:element name="book-title" type="textFieldType">
<xs:annotation>
<xs:documentation>Book title</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="annotation" type="annotationType" minOccurs="0">
<xs:annotation>
<xs:documentation>Annotation for this book</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="keywords" type="textFieldType" minOccurs="0">
<xs:annotation>
<xs:documentation>Any keywords for this book, intended for use in search engines</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="date" type="dateType" minOccurs="0">
<xs:annotation>
<xs:documentation>Date this book was written, can be not exact, e.g. 1863-1867. If an optional attribute is present, then it should contain some computer-readable date from the interval for use by search and indexingengines</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="coverpage" minOccurs="0">
<xs:annotation>
<xs:documentation>Any coverpage items, currently only images</xs:documentation>
</xs:annotation>
<xs:complexType>
<xs:sequence>
<xs:element name="image" type="inlineImageType" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
</xs:element>
<xs:element name="lang" type="xs:string">
<xs:annotation>
<xs:documentation>Book's language</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="src-lang" type="xs:string" minOccurs="0">
<xs:annotation>
<xs:documentation>Book's source language if this is a translation</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="translator" type="authorType" minOccurs="0" maxOccurs="unbounded">
<xs:annotation>
<xs:documentation>Translators if this is a translation</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="sequence" type="sequenceType" minOccurs="0" maxOccurs="unbounded">
<xs:annotation>
<xs:documentation>Any sequences this book might be part of</xs:documentation>
</xs:annotation>
</xs:element>
</xs:sequence>
</xs:complexType>
<xs:complexType name="shareInstructionType">
<xs:annotation>
<xs:documentation>In-document instruction for generating output free and payed documents</xs:documentation>
</xs:annotation>
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element name="part" type="partShareInstructionType"/>
<xs:element name="output-document-class" type="outPutDocumentType"/>
</xs:choice>
<xs:attribute name="mode" type="shareModesType" use="required"/>
<xs:attribute name="include-all" type="docGenerationInstructionType" use="required"/>
<xs:attribute name="price" type="xs:float" use="optional"/>
<xs:attribute name="currency" type="xs:string"/>
</xs:complexType>
<xs:simpleType name="shareModesType">
<xs:annotation>
<xs:documentation>Modes for document sharing (free|paid for now)</xs:documentation>
</xs:annotation>
<xs:restriction base="xs:token">
<xs:enumeration value="free"/>
<xs:enumeration value="paid"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="docGenerationInstructionType">
<xs:annotation>
<xs:documentation>List of instructions to process sections (allow|deny|require)</xs:documentation>
</xs:annotation>
<xs:restriction base="xs:token">
<xs:enumeration value="require"/>
<xs:enumeration value="allow"/>
<xs:enumeration value="deny"/>
</xs:restriction>
</xs:simpleType>
<xs:complexType name="partShareInstructionType">
<xs:annotation>
<xs:documentation>Pointer to specific document section, explaining how to deal with it</xs:documentation>
</xs:annotation>
<xs:attribute ref="xlink:type"/>
<xs:attribute ref="xlink:href" use="required"/>
<xs:attribute name="include" type="docGenerationInstructionType" use="required"/>
</xs:complexType>
<xs:complexType name="outPutDocumentType">
<xs:annotation>
<xs:documentation>Selector for output documents. Defines, which rule to apply to any specific output documents</xs:documentation>
</xs:annotation>
<xs:sequence minOccurs="0" maxOccurs="unbounded">
<xs:element name="part" type="partShareInstructionType"/>
</xs:sequence>
<xs:attribute name="name" type="xs:string" use="required"/>
<xs:attribute name="create" type="docGenerationInstructionType" use="optional"/>
<xs:attribute name="price" type="xs:float" use="optional"/>
</xs:complexType>
<xs:complexType name="tdType" mixed="true">
<xs:complexContent mixed="true">
<xs:extension base="styleType">
<xs:attribute name="id" type="xs:ID" use="optional"/>
<xs:attribute name="style" type="xs:string" use="optional"/>
<xs:attribute name="colspan" type="xs:integer" use="optional"/>
<xs:attribute name="rowspan" type="xs:integer" use="optional"/>
<xs:attribute name="align" type="alignType" use="optional" default="left"/>
<xs:attribute name="valign" type="vAlignType" use="optional" default="top"/>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="inlineImageType">
<xs:attribute ref="xlink:type"/>
<xs:attribute ref="xlink:href"/>
<xs:attribute name="alt" type="xs:string" use="optional"/>
</xs:complexType>
</xs:schema>

View File

@ -0,0 +1,194 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- edited with XML Spy v4.2 U (http://www.xmlspy.com) by * (*) -->
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns="http://www.gribuser.ru/xml/fictionbook/2.0/genres" targetNamespace="http://www.gribuser.ru/xml/fictionbook/2.0/genres" elementFormDefault="qualified" attributeFormDefault="unqualified">
<xs:simpleType name="genreType">
<xs:restriction base="xs:token">
<xs:enumeration value="accounting"/>
<xs:enumeration value="adv_animal"/>
<xs:enumeration value="adv_geo"/>
<xs:enumeration value="adv_history"/>
<xs:enumeration value="adv_maritime"/>
<xs:enumeration value="adv_western"/>
<xs:enumeration value="adventure"/>
<xs:enumeration value="antique"/>
<xs:enumeration value="antique_ant"/>
<xs:enumeration value="antique_east"/>
<xs:enumeration value="antique_european"/>
<xs:enumeration value="antique_myths"/>
<xs:enumeration value="antique_russian"/>
<xs:enumeration value="aphorism_quote"/>
<xs:enumeration value="architecture_book"/>
<xs:enumeration value="auto_regulations"/>
<xs:enumeration value="banking"/>
<xs:enumeration value="beginning_authors"/>
<xs:enumeration value="child_adv"/>
<xs:enumeration value="child_det"/>
<xs:enumeration value="child_education"/>
<xs:enumeration value="child_prose"/>
<xs:enumeration value="child_sf"/>
<xs:enumeration value="child_tale"/>
<xs:enumeration value="child_verse"/>
<xs:enumeration value="children"/>
<xs:enumeration value="cinema_theatre"/>
<xs:enumeration value="city_fantasy"/>
<xs:enumeration value="comp_db"/>
<xs:enumeration value="comp_hard"/>
<xs:enumeration value="comp_osnet"/>
<xs:enumeration value="comp_programming"/>
<xs:enumeration value="comp_soft"/>
<xs:enumeration value="comp_www"/>
<xs:enumeration value="computers"/>
<xs:enumeration value="design"/>
<xs:enumeration value="det_action"/>
<xs:enumeration value="det_classic"/>
<xs:enumeration value="det_crime"/>
<xs:enumeration value="det_espionage"/>
<xs:enumeration value="det_hard"/>
<xs:enumeration value="det_history"/>
<xs:enumeration value="det_irony"/>
<xs:enumeration value="det_police"/>
<xs:enumeration value="det_political"/>
<xs:enumeration value="detective"/>
<xs:enumeration value="dragon_fantasy"/>
<xs:enumeration value="dramaturgy"/>
<xs:enumeration value="economics"/>
<xs:enumeration value="essays"/>
<xs:enumeration value="fantasy_fight"/>
<xs:enumeration value="foreign_action"/>
<xs:enumeration value="foreign_adventure"/>
<xs:enumeration value="foreign_antique"/>
<xs:enumeration value="foreign_business"/>
<xs:enumeration value="foreign_children"/>
<xs:enumeration value="foreign_comp"/>
<xs:enumeration value="foreign_contemporary"/>
<xs:enumeration value="foreign_contemporary_lit"/>
<xs:enumeration value="foreign_desc"/>
<xs:enumeration value="foreign_detective"/>
<xs:enumeration value="foreign_dramaturgy"/>
<xs:enumeration value="foreign_edu"/>
<xs:enumeration value="foreign_fantasy"/>
<xs:enumeration value="foreign_home"/>
<xs:enumeration value="foreign_humor"/>
<xs:enumeration value="foreign_language"/>
<xs:enumeration value="foreign_love"/>
<xs:enumeration value="foreign_novel"/>
<xs:enumeration value="foreign_other"/>
<xs:enumeration value="foreign_poetry"/>
<xs:enumeration value="foreign_prose"/>
<xs:enumeration value="foreign_psychology"/>
<xs:enumeration value="foreign_publicism"/>
<xs:enumeration value="foreign_religion"/>
<xs:enumeration value="foreign_sf"/>
<xs:enumeration value="geo_guides"/>
<xs:enumeration value="geography_book"/>
<xs:enumeration value="global_economy"/>
<xs:enumeration value="historical_fantasy"/>
<xs:enumeration value="home"/>
<xs:enumeration value="home_cooking"/>
<xs:enumeration value="home_crafts"/>
<xs:enumeration value="home_diy"/>
<xs:enumeration value="home_entertain"/>
<xs:enumeration value="home_garden"/>
<xs:enumeration value="home_health"/>
<xs:enumeration value="home_pets"/>
<xs:enumeration value="home_sex"/>
<xs:enumeration value="home_sport"/>
<xs:enumeration value="humor"/>
<xs:enumeration value="humor_anecdote"/>
<xs:enumeration value="humor_fantasy"/>
<xs:enumeration value="humor_prose"/>
<xs:enumeration value="humor_verse"/>
<xs:enumeration value="industries"/>
<xs:enumeration value="job_hunting"/>
<xs:enumeration value="literature_18"/>
<xs:enumeration value="literature_19"/>
<xs:enumeration value="literature_20"/>
<xs:enumeration value="love_contemporary"/>
<xs:enumeration value="love_detective"/>
<xs:enumeration value="love_erotica"/>
<xs:enumeration value="love_fantasy"/>
<xs:enumeration value="love_history"/>
<xs:enumeration value="love_sf"/>
<xs:enumeration value="love_short"/>
<xs:enumeration value="magician_book"/>
<xs:enumeration value="management"/>
<xs:enumeration value="marketing"/>
<xs:enumeration value="military_special"/>
<xs:enumeration value="music_dancing"/>
<xs:enumeration value="narrative"/>
<xs:enumeration value="newspapers"/>
<xs:enumeration value="nonf_biography"/>
<xs:enumeration value="nonf_criticism"/>
<xs:enumeration value="nonf_publicism"/>
<xs:enumeration value="nonfiction"/>
<xs:enumeration value="org_behavior"/>
<xs:enumeration value="paper_work"/>
<xs:enumeration value="pedagogy_book"/>
<xs:enumeration value="periodic"/>
<xs:enumeration value="personal_finance"/>
<xs:enumeration value="poetry"/>
<xs:enumeration value="popadanec"/>
<xs:enumeration value="popular_business"/>
<xs:enumeration value="prose_classic"/>
<xs:enumeration value="prose_counter"/>
<xs:enumeration value="prose_history"/>
<xs:enumeration value="prose_military"/>
<xs:enumeration value="prose_rus_classic"/>
<xs:enumeration value="prose_su_classics"/>
<xs:enumeration value="psy_alassic"/>
<xs:enumeration value="psy_childs"/>
<xs:enumeration value="psy_generic"/>
<xs:enumeration value="psy_personal"/>
<xs:enumeration value="psy_sex_and_family"/>
<xs:enumeration value="psy_social"/>
<xs:enumeration value="psy_theraphy"/>
<xs:enumeration value="real_estate"/>
<xs:enumeration value="ref_dict"/>
<xs:enumeration value="ref_encyc"/>
<xs:enumeration value="ref_guide"/>
<xs:enumeration value="ref_ref"/>
<xs:enumeration value="reference"/>
<xs:enumeration value="religion"/>
<xs:enumeration value="religion_esoterics"/>
<xs:enumeration value="religion_rel"/>
<xs:enumeration value="religion_self"/>
<xs:enumeration value="russian_contemporary"/>
<xs:enumeration value="russian_fantasy"/>
<xs:enumeration value="sci_biology"/>
<xs:enumeration value="sci_chem"/>
<xs:enumeration value="sci_culture"/>
<xs:enumeration value="sci_history"/>
<xs:enumeration value="sci_juris"/>
<xs:enumeration value="sci_linguistic"/>
<xs:enumeration value="sci_math"/>
<xs:enumeration value="sci_medicine"/>
<xs:enumeration value="sci_philosophy"/>
<xs:enumeration value="sci_phys"/>
<xs:enumeration value="sci_politics"/>
<xs:enumeration value="sci_religion"/>
<xs:enumeration value="sci_tech"/>
<xs:enumeration value="science"/>
<xs:enumeration value="sf"/>
<xs:enumeration value="sf_action"/>
<xs:enumeration value="sf_cyberpunk"/>
<xs:enumeration value="sf_detective"/>
<xs:enumeration value="sf_fantasy"/>
<xs:enumeration value="sf_heroic"/>
<xs:enumeration value="sf_history"/>
<xs:enumeration value="sf_horror"/>
<xs:enumeration value="sf_humor"/>
<xs:enumeration value="sf_social"/>
<xs:enumeration value="sf_space"/>
<xs:enumeration value="short_story"/>
<xs:enumeration value="sketch"/>
<xs:enumeration value="small_business"/>
<xs:enumeration value="sociology_book"/>
<xs:enumeration value="stock"/>
<xs:enumeration value="thriller"/>
<xs:enumeration value="upbringing_book"/>
<xs:enumeration value="vampire_book"/>
<xs:enumeration value="visual_arts"/>
<xs:enumeration value="unrecognised"/>
</xs:restriction>
</xs:simpleType>
</xs:schema>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- edited with XML Spy v4.4 U (http://www.xmlspy.com) by Dmitry Grobov (DDS) -->
<xs:schema targetNamespace="http://www.w3.org/XML/1998/namespace" xmlns:xs="http://www.w3.org/2001/XMLSchema" elementFormDefault="qualified" attributeFormDefault="unqualified">
<xs:attribute name="lang" type="xs:language">
<xs:annotation>
<xs:documentation>Element content's language</xs:documentation>
</xs:annotation>
</xs:attribute>
</xs:schema>

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- edited with XML Spy v4.2 U (http://www.xmlspy.com) by * (*) -->
<xs:schema targetNamespace="http://www.w3.org/1999/xlink" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns="http://www.w3.org/1999/xlink" elementFormDefault="qualified" attributeFormDefault="unqualified">
<xs:attribute name="type" type="xs:string" fixed="simple">
<xs:annotation>
<xs:documentation>link type</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="href" type="xs:string">
<xs:annotation>
<xs:documentation>link target</xs:documentation>
</xs:annotation>
</xs:attribute>
</xs:schema>

View File

@ -0,0 +1,12 @@
package net.sergeych.toread
import kotlin.test.Test
import kotlin.test.assertEquals
class SharedCommonTest {
@Test
fun example() {
assertEquals(3, 1 + 2)
}
}

View File

@ -0,0 +1,111 @@
package net.sergeych.toread.fb2
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class Fb2FormatTest {
@Test
fun parsesPlainXml() {
val book = Fb2Format.parseXml(sampleXml)
assertEquals("The Test Book", book.title)
assertEquals("Ada Lovelace", book.authors.single().displayName)
assertEquals("en", book.language)
assertEquals(listOf("sf"), book.genres)
assertEquals("Chapter 1", book.sections.single().title)
assertEquals(listOf("Hello & welcome."), book.sections.single().paragraphs)
}
@Test
fun exportsXmlThatCanBeParsedAgain() {
val book = Fb2Book(
title = "Exported",
authors = listOf(Fb2Author(firstName = "Ann", lastName = "Writer")),
language = "en",
genres = listOf("prose"),
sections = listOf(Fb2Section(title = "Start", paragraphs = listOf("One < two & three."))),
)
val reparsed = Fb2Format.parseXml(Fb2Format.exportXml(book))
assertEquals(book.title, reparsed.title)
assertEquals(book.authors.single().displayName, reparsed.authors.single().displayName)
assertEquals(book.sections.single().paragraphs, reparsed.sections.single().paragraphs)
}
@Test
fun parsesStoredZip() {
val zip = Fb2Format.exportZip(Fb2Format.parseXml(sampleXml), "sample.fb2")
val book = Fb2Format.parse(zip, "sample.fb2.zip")
assertEquals("The Test Book", book.title)
assertTrue(zip.copyOfRange(0, 4).contentEquals(byteArrayOf(0x50, 0x4b, 0x03, 0x04)))
}
@Test
fun preservesReadableBlocksAndInlineStyles() {
val book = Fb2Format.parseXml(richXml)
val section = book.sections.single()
assertEquals("Part", section.blocks[0].let { (it as Fb2Block.Subtitle).content.plainText })
val paragraph = section.blocks[1] as Fb2Block.Paragraph
assertEquals("Plain styled tail.", paragraph.content.plainText)
assertEquals(setOf(Fb2TextStyle.Emphasis), paragraph.content.spans[1].styles)
assertTrue(section.blocks[2] is Fb2Block.EmptyLine)
assertEquals("pic.png", (section.blocks[3] as Fb2Block.Image).image.binaryId)
}
private val sampleXml = """
<?xml version="1.0" encoding="UTF-8"?>
<FictionBook xmlns="http://www.gribuser.ru/xml/fictionbook/2.0" xmlns:xlink="http://www.w3.org/1999/xlink">
<description>
<title-info>
<genre>sf</genre>
<author><first-name>Ada</first-name><last-name>Lovelace</last-name></author>
<book-title>The Test Book</book-title>
<lang>en</lang>
</title-info>
<document-info>
<author><nickname>Toread</nickname></author>
<date>2026-05-12</date>
<id>sample</id>
<version>1.0</version>
</document-info>
</description>
<body>
<section>
<title><p>Chapter 1</p></title>
<p>Hello &amp; welcome.</p>
</section>
</body>
</FictionBook>
""".trimIndent()
private val richXml = """
<?xml version="1.0" encoding="UTF-8"?>
<FictionBook xmlns="http://www.gribuser.ru/xml/fictionbook/2.0" xmlns:xlink="http://www.w3.org/1999/xlink">
<description>
<title-info>
<author><nickname>A</nickname></author>
<book-title>Rich</book-title>
<lang>en</lang>
</title-info>
<document-info>
<author><nickname>Toread</nickname></author>
<date>2026-05-12</date>
<id>rich</id>
<version>1.0</version>
</document-info>
</description>
<body>
<section>
<subtitle>Part</subtitle>
<p>Plain <emphasis>styled</emphasis> tail.</p>
<empty-line/>
<image xlink:href="#pic.png"/>
</section>
</body>
</FictionBook>
""".trimIndent()
}

View File

@ -0,0 +1,23 @@
package net.sergeych.toread.text
import kotlin.test.Test
import kotlin.test.assertNotEquals
import kotlin.test.assertTrue
class HyphenationTest {
@Test
fun selectsEnglishPluginByLanguage() {
val hyphenated = HyphenationRegistry().hyphenate("composition", "en")
assertNotEquals("composition", hyphenated)
assertTrue(SoftHyphen in hyphenated)
}
@Test
fun selectsRussianPluginByLanguage() {
val hyphenated = HyphenationRegistry().hyphenate("повествование", "ru")
assertNotEquals("повествование", hyphenated)
assertTrue(SoftHyphen in hyphenated)
}
}

View File

@ -0,0 +1,752 @@
package net.sergeych.toread.storage.jdbc
import net.sergeych.toread.storage.BodyClusterRecord
import net.sergeych.toread.storage.BodyClusterRepository
import net.sergeych.toread.storage.BookBodyRecord
import net.sergeych.toread.storage.BookBodyRepository
import net.sergeych.toread.storage.BookFileRecord
import net.sergeych.toread.storage.BookFileRepository
import net.sergeych.toread.storage.BookFileStorageKind
import net.sergeych.toread.storage.BookRecord
import net.sergeych.toread.storage.BookRepository
import net.sergeych.toread.storage.BookmarkRecord
import net.sergeych.toread.storage.BookmarkRepository
import net.sergeych.toread.storage.ContentAnchor
import net.sergeych.toread.storage.LibraryDatabase
import net.sergeych.toread.storage.NoteRecord
import net.sergeych.toread.storage.NoteRepository
import net.sergeych.toread.storage.ReadingStateRecord
import net.sergeych.toread.storage.ReadingStateRepository
import java.sql.Connection
import java.sql.DriverManager
import java.sql.PreparedStatement
import java.sql.ResultSet
import java.sql.SQLException
class H2LibraryDatabase private constructor(
private val connection: Connection,
) : LibraryDatabase {
override val books: BookRepository = JdbcBookRepository(connection)
override val bodies: BookBodyRepository = JdbcBookBodyRepository(connection)
override val clusters: BodyClusterRepository = JdbcBodyClusterRepository(connection)
override val files: BookFileRepository = JdbcBookFileRepository(connection)
override val readingStates: ReadingStateRepository = JdbcReadingStateRepository(connection)
override val bookmarks: BookmarkRepository = JdbcBookmarkRepository(connection)
override val notes: NoteRepository = JdbcNoteRepository(connection)
init {
migrate(connection)
}
override fun <T> transaction(block: LibraryDatabase.() -> T): T {
val previousAutoCommit = connection.autoCommit
connection.autoCommit = false
return try {
val result = block(this)
connection.commit()
result
} catch (t: Throwable) {
connection.rollback()
throw t
} finally {
connection.autoCommit = previousAutoCommit
}
}
override fun close() {
connection.close()
}
fun shutdownCompact() {
connection.createStatement().use { it.execute("SHUTDOWN COMPACT") }
}
fun getAppFlag(key: String): String? =
connection.selectOne("SELECT flag_value FROM app_flags WHERE flag_key = ?", key) {
it.getString("flag_value")
}
fun setAppFlag(key: String, value: String?) {
if (value == null) {
connection.prepareStatement("DELETE FROM app_flags WHERE flag_key = ?").use { statement ->
statement.setString(1, key)
statement.executeUpdate()
}
} else {
connection.prepareStatement(
"""
MERGE INTO app_flags(flag_key, flag_value, updated_at)
KEY(flag_key) VALUES(?, ?, ?)
""".trimIndent()
).use { statement ->
statement.setString(1, key)
statement.setString(2, value)
statement.setLong(3, System.currentTimeMillis())
statement.executeUpdate()
}
}
}
companion object {
fun openFile(path: String, user: String = "sa", password: String = ""): H2LibraryDatabase {
return openUrl("jdbc:h2:file:$path;DB_CLOSE_DELAY=0", user, password)
}
fun openMemory(name: String = "toread", user: String = "sa", password: String = ""): H2LibraryDatabase {
return openUrl("jdbc:h2:mem:$name;DB_CLOSE_DELAY=-1", user, password)
}
fun openUrl(url: String, user: String = "sa", password: String = ""): H2LibraryDatabase {
Class.forName("org.h2.Driver")
return H2LibraryDatabase(DriverManager.getConnection(url, user, password))
}
}
}
private const val SchemaVersion = 1
private fun migrate(connection: Connection) {
connection.createStatement().use { statement ->
statement.execute(
"""
CREATE TABLE IF NOT EXISTS library_schema (
id INT PRIMARY KEY,
version INT NOT NULL
)
""".trimIndent()
)
statement.execute(
"""
CREATE TABLE IF NOT EXISTS app_flags (
flag_key VARCHAR PRIMARY KEY,
flag_value CLOB,
updated_at BIGINT NOT NULL
)
""".trimIndent()
)
statement.execute(
"""
CREATE TABLE IF NOT EXISTS books (
id VARCHAR PRIMARY KEY,
title VARCHAR,
subtitle VARCHAR,
language VARCHAR,
description CLOB,
cover_image BLOB,
cover_image_mime_type VARCHAR,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL
)
""".trimIndent()
)
statement.execute("ALTER TABLE books ADD COLUMN IF NOT EXISTS cover_image BLOB")
statement.execute("ALTER TABLE books ADD COLUMN IF NOT EXISTS cover_image_mime_type VARCHAR")
statement.execute(
"""
CREATE TABLE IF NOT EXISTS book_bodies (
id VARCHAR PRIMARY KEY,
exact_text_hash VARCHAR NOT NULL,
canonicalization_version INT NOT NULL,
near_signature VARCHAR,
word_count INT,
language VARCHAR,
created_at BIGINT NOT NULL
)
""".trimIndent()
)
connection.createIndexIfMissing(
"idx_book_bodies_exact",
"""
CREATE UNIQUE INDEX IF NOT EXISTS idx_book_bodies_exact
ON book_bodies(exact_text_hash, canonicalization_version)
""".trimIndent()
)
statement.execute(
"""
CREATE TABLE IF NOT EXISTS body_clusters (
id VARCHAR PRIMARY KEY,
representative_body_id VARCHAR NOT NULL,
created_at BIGINT NOT NULL,
FOREIGN KEY (representative_body_id) REFERENCES book_bodies(id)
)
""".trimIndent()
)
statement.execute(
"""
CREATE TABLE IF NOT EXISTS book_files (
id VARCHAR PRIMARY KEY,
book_id VARCHAR,
body_id VARCHAR,
body_cluster_id VARCHAR,
raw_sha256 VARCHAR NOT NULL,
format VARCHAR,
mime_type VARCHAR,
size_bytes BIGINT,
original_filename VARCHAR,
storage_kind VARCHAR NOT NULL,
storage_uri VARCHAR,
content_object_id VARCHAR,
last_modified_millis BIGINT,
last_seen_at BIGINT,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
FOREIGN KEY (book_id) REFERENCES books(id),
FOREIGN KEY (body_id) REFERENCES book_bodies(id),
FOREIGN KEY (body_cluster_id) REFERENCES body_clusters(id)
)
""".trimIndent()
)
connection.createIndexIfMissing("idx_book_files_raw_sha256", "CREATE INDEX IF NOT EXISTS idx_book_files_raw_sha256 ON book_files(raw_sha256)")
connection.createIndexIfMissing("idx_book_files_book_id", "CREATE INDEX IF NOT EXISTS idx_book_files_book_id ON book_files(book_id)")
statement.execute(
"""
CREATE TABLE IF NOT EXISTS reading_states (
id VARCHAR PRIMARY KEY,
body_cluster_id VARCHAR NOT NULL,
body_id VARCHAR,
anchor_version INT NOT NULL,
canonical_char_offset BIGINT,
canonical_token_offset BIGINT,
progress DOUBLE PRECISION,
exact_text CLOB,
prefix_text CLOB,
suffix_text CLOB,
fingerprints_json CLOB,
format_hints_json CLOB,
updated_at BIGINT NOT NULL,
FOREIGN KEY (body_cluster_id) REFERENCES body_clusters(id),
FOREIGN KEY (body_id) REFERENCES book_bodies(id)
)
""".trimIndent()
)
connection.createIndexIfMissing("idx_reading_states_cluster", "CREATE INDEX IF NOT EXISTS idx_reading_states_cluster ON reading_states(body_cluster_id)")
statement.execute(
"""
CREATE TABLE IF NOT EXISTS bookmarks (
id VARCHAR PRIMARY KEY,
body_cluster_id VARCHAR NOT NULL,
body_id VARCHAR,
anchor_version INT NOT NULL,
canonical_char_offset BIGINT,
canonical_token_offset BIGINT,
progress DOUBLE PRECISION,
exact_text CLOB,
prefix_text CLOB,
suffix_text CLOB,
fingerprints_json CLOB,
format_hints_json CLOB,
title VARCHAR,
selected_text_snapshot CLOB,
color VARCHAR,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
FOREIGN KEY (body_cluster_id) REFERENCES body_clusters(id),
FOREIGN KEY (body_id) REFERENCES book_bodies(id)
)
""".trimIndent()
)
connection.createIndexIfMissing("idx_bookmarks_cluster", "CREATE INDEX IF NOT EXISTS idx_bookmarks_cluster ON bookmarks(body_cluster_id)")
statement.execute(
"""
CREATE TABLE IF NOT EXISTS notes (
id VARCHAR PRIMARY KEY,
bookmark_id VARCHAR,
body_cluster_id VARCHAR NOT NULL,
body_id VARCHAR,
anchor_version INT NOT NULL,
canonical_char_offset BIGINT,
canonical_token_offset BIGINT,
progress DOUBLE PRECISION,
exact_text CLOB,
prefix_text CLOB,
suffix_text CLOB,
fingerprints_json CLOB,
format_hints_json CLOB,
text CLOB NOT NULL,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
FOREIGN KEY (bookmark_id) REFERENCES bookmarks(id),
FOREIGN KEY (body_cluster_id) REFERENCES body_clusters(id),
FOREIGN KEY (body_id) REFERENCES book_bodies(id)
)
""".trimIndent()
)
connection.createIndexIfMissing("idx_notes_bookmark", "CREATE INDEX IF NOT EXISTS idx_notes_bookmark ON notes(bookmark_id)")
connection.createIndexIfMissing("idx_notes_cluster", "CREATE INDEX IF NOT EXISTS idx_notes_cluster ON notes(body_cluster_id)")
statement.execute(
"""
MERGE INTO library_schema(id, version)
KEY(id) VALUES(1, $SchemaVersion)
""".trimIndent()
)
}
}
private fun Connection.createIndexIfMissing(indexName: String, createSql: String) {
if (indexExists(indexName)) return
createStatement().use { statement ->
try {
statement.execute(createSql)
} catch (e: SQLException) {
if (!indexExists(indexName)) throw e
}
}
}
private fun Connection.indexExists(indexName: String): Boolean {
return prepareStatement(
"""
SELECT 1
FROM INFORMATION_SCHEMA.INDEXES
WHERE UPPER(INDEX_NAME) = UPPER(?)
""".trimIndent()
).use { statement ->
statement.setString(1, indexName)
statement.executeQuery().use { resultSet -> resultSet.next() }
}
}
private class JdbcBookRepository(private val connection: Connection) : BookRepository {
override fun upsert(book: BookRecord) {
connection.prepareStatement(
"""
MERGE INTO books(
id, title, subtitle, language, description, cover_image, cover_image_mime_type, created_at, updated_at
)
KEY(id) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)
""".trimIndent()
).use { statement ->
statement.setString(1, book.id)
statement.setStringOrNull(2, book.title)
statement.setStringOrNull(3, book.subtitle)
statement.setStringOrNull(4, book.language)
statement.setStringOrNull(5, book.description)
statement.setBytesOrNull(6, book.coverImage)
statement.setStringOrNull(7, book.coverImageMimeType)
statement.setLong(8, book.createdAt)
statement.setLong(9, book.updatedAt)
statement.executeUpdate()
}
}
override fun get(id: String): BookRecord? {
return connection.selectOne("SELECT * FROM books WHERE id = ?", id) { it.toBookRecord() }
}
override fun list(limit: Int, offset: Int): List<BookRecord> {
return connection.prepareStatement("SELECT * FROM books ORDER BY updated_at DESC LIMIT ? OFFSET ?").use { statement ->
statement.setInt(1, limit)
statement.setInt(2, offset)
statement.executeQuery().use { resultSet -> resultSet.mapRows { it.toBookRecord() } }
}
}
override fun delete(id: String): Boolean = connection.deleteById("books", id)
}
private class JdbcBookBodyRepository(private val connection: Connection) : BookBodyRepository {
override fun upsert(body: BookBodyRecord) {
connection.prepareStatement(
"""
MERGE INTO book_bodies(
id, exact_text_hash, canonicalization_version, near_signature, word_count, language, created_at
)
KEY(id) VALUES(?, ?, ?, ?, ?, ?, ?)
""".trimIndent()
).use { statement ->
statement.setString(1, body.id)
statement.setString(2, body.exactTextHash)
statement.setInt(3, body.canonicalizationVersion)
statement.setStringOrNull(4, body.nearSignature)
statement.setIntOrNull(5, body.wordCount)
statement.setStringOrNull(6, body.language)
statement.setLong(7, body.createdAt)
statement.executeUpdate()
}
}
override fun get(id: String): BookBodyRecord? {
return connection.selectOne("SELECT * FROM book_bodies WHERE id = ?", id) { it.toBookBodyRecord() }
}
override fun findByExactTextHash(exactTextHash: String, canonicalizationVersion: Int): BookBodyRecord? {
return connection.prepareStatement(
"SELECT * FROM book_bodies WHERE exact_text_hash = ? AND canonicalization_version = ?"
).use { statement ->
statement.setString(1, exactTextHash)
statement.setInt(2, canonicalizationVersion)
statement.executeQuery().use { resultSet ->
if (resultSet.next()) resultSet.toBookBodyRecord() else null
}
}
}
}
private class JdbcBodyClusterRepository(private val connection: Connection) : BodyClusterRepository {
override fun upsert(cluster: BodyClusterRecord) {
connection.prepareStatement(
"""
MERGE INTO body_clusters(id, representative_body_id, created_at)
KEY(id) VALUES(?, ?, ?)
""".trimIndent()
).use { statement ->
statement.setString(1, cluster.id)
statement.setString(2, cluster.representativeBodyId)
statement.setLong(3, cluster.createdAt)
statement.executeUpdate()
}
}
override fun get(id: String): BodyClusterRecord? {
return connection.selectOne("SELECT * FROM body_clusters WHERE id = ?", id) { it.toBodyClusterRecord() }
}
override fun findByRepresentativeBodyId(bodyId: String): BodyClusterRecord? {
return connection.selectOne(
"SELECT * FROM body_clusters WHERE representative_body_id = ? ORDER BY created_at",
bodyId,
) { it.toBodyClusterRecord() }
}
}
private class JdbcBookFileRepository(private val connection: Connection) : BookFileRepository {
override fun upsert(file: BookFileRecord) {
connection.prepareStatement(
"""
MERGE INTO book_files(
id, book_id, body_id, body_cluster_id, raw_sha256, format, mime_type, size_bytes,
original_filename, storage_kind, storage_uri, content_object_id, last_modified_millis,
last_seen_at, created_at, updated_at
)
KEY(id) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""".trimIndent()
).use { statement ->
statement.setString(1, file.id)
statement.setStringOrNull(2, file.bookId)
statement.setStringOrNull(3, file.bodyId)
statement.setStringOrNull(4, file.bodyClusterId)
statement.setString(5, file.rawSha256)
statement.setStringOrNull(6, file.format)
statement.setStringOrNull(7, file.mimeType)
statement.setLongOrNull(8, file.sizeBytes)
statement.setStringOrNull(9, file.originalFilename)
statement.setString(10, file.storageKind.name)
statement.setStringOrNull(11, file.storageUri)
statement.setStringOrNull(12, file.contentObjectId)
statement.setLongOrNull(13, file.lastModifiedMillis)
statement.setLongOrNull(14, file.lastSeenAt)
statement.setLong(15, file.createdAt)
statement.setLong(16, file.updatedAt)
statement.executeUpdate()
}
}
override fun get(id: String): BookFileRecord? {
return connection.selectOne("SELECT * FROM book_files WHERE id = ?", id) { it.toBookFileRecord() }
}
override fun findByRawSha256(rawSha256: String): List<BookFileRecord> {
return connection.selectMany("SELECT * FROM book_files WHERE raw_sha256 = ? ORDER BY created_at", rawSha256) {
it.toBookFileRecord()
}
}
override fun list(limit: Int, offset: Int): List<BookFileRecord> {
return connection.prepareStatement("SELECT * FROM book_files ORDER BY updated_at DESC LIMIT ? OFFSET ?").use { statement ->
statement.setInt(1, limit)
statement.setInt(2, offset)
statement.executeQuery().use { resultSet -> resultSet.mapRows { it.toBookFileRecord() } }
}
}
override fun listForBook(bookId: String): List<BookFileRecord> {
return connection.selectMany("SELECT * FROM book_files WHERE book_id = ? ORDER BY created_at", bookId) {
it.toBookFileRecord()
}
}
override fun delete(id: String): Boolean = connection.deleteById("book_files", id)
}
private class JdbcReadingStateRepository(private val connection: Connection) : ReadingStateRepository {
override fun upsert(state: ReadingStateRecord) {
connection.prepareStatement(
"""
MERGE INTO reading_states(
id, body_cluster_id, body_id, anchor_version, canonical_char_offset, canonical_token_offset,
progress, exact_text, prefix_text, suffix_text, fingerprints_json, format_hints_json, updated_at
)
KEY(id) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""".trimIndent()
).use { statement ->
statement.setString(1, state.id)
statement.setString(2, state.bodyClusterId)
statement.setStringOrNull(3, state.bodyId)
statement.setAnchor(4, state.anchor)
statement.setLong(13, state.updatedAt)
statement.executeUpdate()
}
}
override fun get(id: String): ReadingStateRecord? {
return connection.selectOne("SELECT * FROM reading_states WHERE id = ?", id) { it.toReadingStateRecord() }
}
override fun getForBodyCluster(bodyClusterId: String): ReadingStateRecord? {
return connection.selectOne("SELECT * FROM reading_states WHERE body_cluster_id = ? ORDER BY updated_at DESC", bodyClusterId) {
it.toReadingStateRecord()
}
}
}
private class JdbcBookmarkRepository(private val connection: Connection) : BookmarkRepository {
override fun upsert(bookmark: BookmarkRecord) {
connection.prepareStatement(
"""
MERGE INTO bookmarks(
id, body_cluster_id, body_id, anchor_version, canonical_char_offset, canonical_token_offset,
progress, exact_text, prefix_text, suffix_text, fingerprints_json, format_hints_json,
title, selected_text_snapshot, color, created_at, updated_at
)
KEY(id) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""".trimIndent()
).use { statement ->
statement.setString(1, bookmark.id)
statement.setString(2, bookmark.bodyClusterId)
statement.setStringOrNull(3, bookmark.bodyId)
statement.setAnchor(4, bookmark.anchor)
statement.setStringOrNull(13, bookmark.title)
statement.setStringOrNull(14, bookmark.selectedTextSnapshot)
statement.setStringOrNull(15, bookmark.color)
statement.setLong(16, bookmark.createdAt)
statement.setLong(17, bookmark.updatedAt)
statement.executeUpdate()
}
}
override fun get(id: String): BookmarkRecord? {
return connection.selectOne("SELECT * FROM bookmarks WHERE id = ?", id) { it.toBookmarkRecord() }
}
override fun listForBodyCluster(bodyClusterId: String): List<BookmarkRecord> {
return connection.selectMany("SELECT * FROM bookmarks WHERE body_cluster_id = ? ORDER BY progress, created_at", bodyClusterId) {
it.toBookmarkRecord()
}
}
override fun delete(id: String): Boolean = connection.deleteById("bookmarks", id)
}
private class JdbcNoteRepository(private val connection: Connection) : NoteRepository {
override fun upsert(note: NoteRecord) {
connection.prepareStatement(
"""
MERGE INTO notes(
id, bookmark_id, body_cluster_id, body_id, anchor_version, canonical_char_offset,
canonical_token_offset, progress, exact_text, prefix_text, suffix_text, fingerprints_json,
format_hints_json, text, created_at, updated_at
)
KEY(id) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""".trimIndent()
).use { statement ->
statement.setString(1, note.id)
statement.setStringOrNull(2, note.bookmarkId)
statement.setString(3, note.bodyClusterId)
statement.setStringOrNull(4, note.bodyId)
statement.setAnchor(5, note.anchor)
statement.setString(14, note.text)
statement.setLong(15, note.createdAt)
statement.setLong(16, note.updatedAt)
statement.executeUpdate()
}
}
override fun get(id: String): NoteRecord? {
return connection.selectOne("SELECT * FROM notes WHERE id = ?", id) { it.toNoteRecord() }
}
override fun listForBookmark(bookmarkId: String): List<NoteRecord> {
return connection.selectMany("SELECT * FROM notes WHERE bookmark_id = ? ORDER BY created_at", bookmarkId) {
it.toNoteRecord()
}
}
override fun listForBodyCluster(bodyClusterId: String): List<NoteRecord> {
return connection.selectMany("SELECT * FROM notes WHERE body_cluster_id = ? ORDER BY progress, created_at", bodyClusterId) {
it.toNoteRecord()
}
}
override fun delete(id: String): Boolean = connection.deleteById("notes", id)
}
private fun PreparedStatement.setAnchor(firstIndex: Int, anchor: ContentAnchor) {
setInt(firstIndex, anchor.version)
setLongOrNull(firstIndex + 1, anchor.canonicalCharOffset)
setLongOrNull(firstIndex + 2, anchor.canonicalTokenOffset)
setDoubleOrNull(firstIndex + 3, anchor.progress)
setStringOrNull(firstIndex + 4, anchor.exact)
setStringOrNull(firstIndex + 5, anchor.prefix)
setStringOrNull(firstIndex + 6, anchor.suffix)
setStringOrNull(firstIndex + 7, anchor.fingerprintsJson)
setStringOrNull(firstIndex + 8, anchor.formatHintsJson)
}
private fun ResultSet.toAnchor(): ContentAnchor {
return ContentAnchor(
version = getInt("anchor_version"),
canonicalCharOffset = getLongOrNull("canonical_char_offset"),
canonicalTokenOffset = getLongOrNull("canonical_token_offset"),
progress = getDoubleOrNull("progress"),
exact = getString("exact_text"),
prefix = getString("prefix_text"),
suffix = getString("suffix_text"),
fingerprintsJson = getString("fingerprints_json"),
formatHintsJson = getString("format_hints_json"),
)
}
private fun ResultSet.toBookRecord() = BookRecord(
id = getString("id"),
title = getString("title"),
subtitle = getString("subtitle"),
language = getString("language"),
description = getString("description"),
coverImage = getBytes("cover_image"),
coverImageMimeType = getString("cover_image_mime_type"),
createdAt = getLong("created_at"),
updatedAt = getLong("updated_at"),
)
private fun ResultSet.toBookBodyRecord() = BookBodyRecord(
id = getString("id"),
exactTextHash = getString("exact_text_hash"),
canonicalizationVersion = getInt("canonicalization_version"),
nearSignature = getString("near_signature"),
wordCount = getIntOrNull("word_count"),
language = getString("language"),
createdAt = getLong("created_at"),
)
private fun ResultSet.toBodyClusterRecord() = BodyClusterRecord(
id = getString("id"),
representativeBodyId = getString("representative_body_id"),
createdAt = getLong("created_at"),
)
private fun ResultSet.toBookFileRecord() = BookFileRecord(
id = getString("id"),
bookId = getString("book_id"),
bodyId = getString("body_id"),
bodyClusterId = getString("body_cluster_id"),
rawSha256 = getString("raw_sha256"),
format = getString("format"),
mimeType = getString("mime_type"),
sizeBytes = getLongOrNull("size_bytes"),
originalFilename = getString("original_filename"),
storageKind = BookFileStorageKind.valueOf(getString("storage_kind")),
storageUri = getString("storage_uri"),
contentObjectId = getString("content_object_id"),
lastModifiedMillis = getLongOrNull("last_modified_millis"),
lastSeenAt = getLongOrNull("last_seen_at"),
createdAt = getLong("created_at"),
updatedAt = getLong("updated_at"),
)
private fun ResultSet.toReadingStateRecord() = ReadingStateRecord(
id = getString("id"),
bodyClusterId = getString("body_cluster_id"),
bodyId = getString("body_id"),
anchor = toAnchor(),
updatedAt = getLong("updated_at"),
)
private fun ResultSet.toBookmarkRecord() = BookmarkRecord(
id = getString("id"),
bodyClusterId = getString("body_cluster_id"),
bodyId = getString("body_id"),
anchor = toAnchor(),
title = getString("title"),
selectedTextSnapshot = getString("selected_text_snapshot"),
color = getString("color"),
createdAt = getLong("created_at"),
updatedAt = getLong("updated_at"),
)
private fun ResultSet.toNoteRecord() = NoteRecord(
id = getString("id"),
bookmarkId = getString("bookmark_id"),
bodyClusterId = getString("body_cluster_id"),
bodyId = getString("body_id"),
anchor = toAnchor(),
text = getString("text"),
createdAt = getLong("created_at"),
updatedAt = getLong("updated_at"),
)
private fun <T> Connection.selectOne(sql: String, id: String, mapper: (ResultSet) -> T): T? {
return prepareStatement(sql).use { statement ->
statement.setString(1, id)
statement.executeQuery().use { resultSet ->
if (resultSet.next()) mapper(resultSet) else null
}
}
}
private fun <T> Connection.selectMany(sql: String, value: String, mapper: (ResultSet) -> T): List<T> {
return prepareStatement(sql).use { statement ->
statement.setString(1, value)
statement.executeQuery().use { resultSet -> resultSet.mapRows(mapper) }
}
}
private fun <T> ResultSet.mapRows(mapper: (ResultSet) -> T): List<T> {
val result = mutableListOf<T>()
while (next()) {
result += mapper(this)
}
return result
}
private fun Connection.deleteById(tableName: String, id: String): Boolean {
return prepareStatement("DELETE FROM $tableName WHERE id = ?").use { statement ->
statement.setString(1, id)
statement.executeUpdate() > 0
}
}
private fun PreparedStatement.setStringOrNull(index: Int, value: String?) {
if (value == null) setNull(index, java.sql.Types.VARCHAR) else setString(index, value)
}
private fun PreparedStatement.setIntOrNull(index: Int, value: Int?) {
if (value == null) setNull(index, java.sql.Types.INTEGER) else setInt(index, value)
}
private fun PreparedStatement.setLongOrNull(index: Int, value: Long?) {
if (value == null) setNull(index, java.sql.Types.BIGINT) else setLong(index, value)
}
private fun PreparedStatement.setDoubleOrNull(index: Int, value: Double?) {
if (value == null) setNull(index, java.sql.Types.DOUBLE) else setDouble(index, value)
}
private fun PreparedStatement.setBytesOrNull(index: Int, value: ByteArray?) {
if (value == null) setNull(index, java.sql.Types.BLOB) else setBytes(index, value)
}
private fun ResultSet.getIntOrNull(column: String): Int? {
val value = getInt(column)
return if (wasNull()) null else value
}
private fun ResultSet.getLongOrNull(column: String): Long? {
val value = getLong(column)
return if (wasNull()) null else value
}
private fun ResultSet.getDoubleOrNull(column: String): Double? {
val value = getDouble(column)
return if (wasNull()) null else value
}

View File

@ -0,0 +1,260 @@
package net.sergeych.toread.storage.jdbc
import net.sergeych.toread.fb2.Fb2Block
import net.sergeych.toread.fb2.Fb2Book
import net.sergeych.toread.fb2.Fb2Format
import net.sergeych.toread.fb2.Fb2Section
import net.sergeych.toread.storage.BodyClusterRecord
import net.sergeych.toread.storage.BookBodyRecord
import net.sergeych.toread.storage.BookFileRecord
import net.sergeych.toread.storage.BookFileStorageKind
import net.sergeych.toread.storage.BookRecord
import java.io.File
import java.security.MessageDigest
import java.util.UUID
data class LibraryScanSummary(
val scannedFiles: Int,
val importedFiles: Int,
val skippedFiles: Int,
val failedFiles: Int,
val currentFile: String? = null,
)
class LibraryScanner(
private val database: H2LibraryDatabase,
private val log: (String) -> Unit = {},
) {
fun scanSubtree(root: File, onProgress: (LibraryScanSummary) -> Unit = {}): LibraryScanSummary {
require(root.isDirectory) { "Scan root is not a directory: ${root.path}" }
log("scan start root=${root.absolutePath}")
var scanned = 0
var imported = 0
var skipped = 0
var failed = 0
var visited = 0
root.walkTopDown()
.forEach { file ->
if (!file.isFile) return@forEach
visited += 1
if (!file.isSupportedBookFile()) {
if (visited % 50 == 0) {
onProgress(LibraryScanSummary(scanned, imported, skipped, failed, file.name))
}
return@forEach
}
scanned += 1
log("scan file path=${file.absolutePath} size=${file.length()}")
onProgress(LibraryScanSummary(scanned, imported, skipped, failed, file.name))
val result = runCatching { importLinkedFile(file) }
if (result.isSuccess) {
if (result.getOrThrow()) {
imported += 1
log("scan imported path=${file.absolutePath}")
} else {
skipped += 1
log("scan skipped duplicate path=${file.absolutePath}")
}
} else {
failed += 1
log("scan failed path=${file.absolutePath} error=${result.exceptionOrNull()?.message}")
}
onProgress(LibraryScanSummary(scanned, imported, skipped, failed, file.name))
}
val summary = LibraryScanSummary(
scannedFiles = scanned,
importedFiles = imported,
skippedFiles = skipped,
failedFiles = failed,
)
log("scan finish root=${root.absolutePath} scanned=$scanned imported=$imported skipped=$skipped failed=$failed")
return summary
}
fun importExternalFile(
bytes: ByteArray,
displayName: String,
storageUri: String,
sizeBytes: Long?,
lastModifiedMillis: Long?,
): Boolean {
val rawSha256 = bytes.sha256Hex()
if (database.files.findByRawSha256(rawSha256).isNotEmpty()) return false
val book = Fb2Format.parse(bytes, displayName)
val cover = book.coverImage()
val canonicalText = book.canonicalText()
val bodyHash = canonicalText.encodeToByteArray().sha256Hex()
val now = System.currentTimeMillis()
val knownBody = database.bodies.findByExactTextHash(bodyHash, CanonicalizationVersion)
val bodyId = knownBody?.id ?: "body-${UUID.randomUUID()}"
val knownCluster = knownBody?.let { database.clusters.findByRepresentativeBodyId(it.id) }
val clusterId = knownCluster?.id ?: "cluster-${UUID.randomUUID()}"
val bookId = "book-${UUID.randomUUID()}"
database.transaction {
books.upsert(
BookRecord(
id = bookId,
title = book.title.ifBlank { displayName.substringBeforeLast('.') },
language = book.language,
description = book.annotation,
coverImage = cover?.bytes,
coverImageMimeType = cover?.mimeType,
createdAt = now,
updatedAt = now,
)
)
if (knownBody == null) {
bodies.upsert(
BookBodyRecord(
id = bodyId,
exactTextHash = bodyHash,
canonicalizationVersion = CanonicalizationVersion,
wordCount = canonicalText.wordCount(),
language = book.language,
createdAt = now,
)
)
clusters.upsert(
BodyClusterRecord(
id = clusterId,
representativeBodyId = bodyId,
createdAt = now,
)
)
} else if (knownCluster == null) {
clusters.upsert(
BodyClusterRecord(
id = clusterId,
representativeBodyId = bodyId,
createdAt = now,
)
)
}
files.upsert(
BookFileRecord(
id = "file-${UUID.randomUUID()}",
bookId = bookId,
bodyId = bodyId,
bodyClusterId = clusterId,
rawSha256 = rawSha256,
format = displayName.bookFormat(),
mimeType = displayName.bookMimeType(),
sizeBytes = sizeBytes,
originalFilename = displayName,
storageKind = BookFileStorageKind.EXTERNAL_URI,
storageUri = storageUri,
lastModifiedMillis = lastModifiedMillis,
lastSeenAt = now,
createdAt = now,
updatedAt = now,
)
)
}
return true
}
private fun importLinkedFile(file: File): Boolean =
importExternalFile(
bytes = file.readBytes(),
displayName = file.name,
storageUri = file.absolutePath,
sizeBytes = file.length(),
lastModifiedMillis = file.lastModified(),
)
}
private const val CanonicalizationVersion = 1
private data class ScannedCover(
val bytes: ByteArray,
val mimeType: String,
)
private fun Fb2Book.coverImage(): ScannedCover? {
val image = coverImages.firstOrNull() ?: bodyImages.firstOrNull()
val binary = image?.let(::binaryFor) ?: return null
return runCatching {
ScannedCover(
bytes = binary.base64.decodeBase64Bytes(),
mimeType = binary.contentType,
)
}.getOrNull()
}
private fun File.isSupportedBookFile(): Boolean =
name.endsWith(".fb2", ignoreCase = true) || name.endsWith(".fb2.zip", ignoreCase = true)
private fun String.bookFormat(): String =
if (endsWith(".zip", ignoreCase = true)) "fb2.zip" else "fb2"
private fun String.bookMimeType(): String =
if (endsWith(".zip", ignoreCase = true)) "application/zip" else "application/x-fictionbook+xml"
private fun Fb2Book.canonicalText(): String =
sections.flatMap { it.textBlocks() }
.joinToString(separator = "\n") { it.normalizeForBodyHash() }
.trim()
private fun Fb2Section.textBlocks(): List<String> {
val current = buildList {
title?.let { add(it) }
blocks.forEach { block ->
when (block) {
Fb2Block.EmptyLine -> Unit
is Fb2Block.Image -> Unit
is Fb2Block.Paragraph -> add(block.content.plainText)
is Fb2Block.Subtitle -> add(block.content.plainText)
}
}
paragraphs.forEach { add(it) }
}
return current + sections.flatMap { it.textBlocks() }
}
private fun String.normalizeForBodyHash(): String =
lowercase()
.replace('\u00ad'.toString(), "")
.replace(Regex("\\s+"), " ")
.trim()
private fun String.wordCount(): Int =
split(Regex("\\s+")).count { it.isNotBlank() }
private fun ByteArray.sha256Hex(): String =
MessageDigest.getInstance("SHA-256").digest(this).toHex()
private fun ByteArray.toHex(): String = joinToString(separator = "") { byte ->
(byte.toInt() and 0xff).toString(16).padStart(2, '0')
}
private fun String.decodeBase64Bytes(): ByteArray {
val clean = filterNot { it.isWhitespace() }
val output = ArrayList<Byte>(clean.length * 3 / 4)
var buffer = 0
var bits = 0
for (char in clean) {
if (char == '=') break
val value = char.base64Value()
buffer = (buffer shl 6) or value
bits += 6
if (bits >= 8) {
bits -= 8
output += ((buffer shr bits) and 0xff).toByte()
}
}
return output.toByteArray()
}
private fun Char.base64Value(): Int = when (this) {
in 'A'..'Z' -> code - 'A'.code
in 'a'..'z' -> code - 'a'.code + 26
in '0'..'9' -> code - '0'.code + 52
'+' -> 62
'/' -> 63
else -> error("Invalid base64 character")
}

View File

@ -0,0 +1,74 @@
package net.sergeych.toread.storage.jdbc
import java.io.File
import java.security.MessageDigest
data class ManagedBookObject(
val objectId: String,
val relativePath: String,
val sizeBytes: Long,
)
class ManagedBookObjectStore(
private val libraryDirectory: File,
) {
private val objectsDirectory = File(libraryDirectory, "objects")
fun importFile(source: File): ManagedBookObject {
require(source.isFile) { "Book source is not a file: ${source.path}" }
objectsDirectory.mkdirs()
val tempFile = File.createTempFile("book-object-", ".tmp", objectsDirectory)
val digest = MessageDigest.getInstance("SHA-256")
var size = 0L
try {
source.inputStream().use { input ->
tempFile.outputStream().use { output ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
while (true) {
val read = input.read(buffer)
if (read < 0) break
digest.update(buffer, 0, read)
output.write(buffer, 0, read)
size += read
}
}
}
val objectId = digest.digest().toHex()
val relativePath = relativeObjectPath(objectId)
val target = File(libraryDirectory, relativePath)
target.parentFile?.mkdirs()
if (target.exists()) {
tempFile.delete()
} else if (!tempFile.renameTo(target)) {
tempFile.copyTo(target, overwrite = false)
tempFile.delete()
}
return ManagedBookObject(
objectId = objectId,
relativePath = relativePath,
sizeBytes = size,
)
} catch (t: Throwable) {
tempFile.delete()
throw t
}
}
fun fileForObject(objectId: String): File = File(libraryDirectory, relativeObjectPath(objectId))
fun exists(objectId: String): Boolean = fileForObject(objectId).isFile
private fun relativeObjectPath(objectId: String): String {
val prefix = objectId.take(2)
return "objects/$prefix/$objectId"
}
}
private fun ByteArray.toHex(): String = joinToString(separator = "") { byte ->
(byte.toInt() and 0xff).toString(16).padStart(2, '0')
}

View File

@ -0,0 +1,7 @@
package net.sergeych.toread
class JsPlatform : Platform {
override val name: String = "Web with Kotlin/JS"
}
actual fun getPlatform(): Platform = JsPlatform()

View File

@ -0,0 +1,5 @@
package net.sergeych.toread.fb2
internal actual fun inflateRaw(input: ByteArray, expectedSize: Int): ByteArray {
throw Fb2ParseException("Deflated FB2 ZIP import is not supported on this target yet")
}

View File

@ -0,0 +1,7 @@
package net.sergeych.toread
class JVMPlatform : Platform {
override val name: String = "Java ${System.getProperty("java.version")}"
}
actual fun getPlatform(): Platform = JVMPlatform()

View File

@ -0,0 +1,21 @@
package net.sergeych.toread.fb2
import java.util.zip.DataFormatException
import java.util.zip.Inflater
internal actual fun inflateRaw(input: ByteArray, expectedSize: Int): ByteArray {
val inflater = Inflater(true)
return try {
inflater.setInput(input)
val output = ByteArray(expectedSize)
val inflated = inflater.inflate(output)
if (!inflater.finished() || inflated != expectedSize) {
throw Fb2ParseException("Could not inflate ZIP entry: expected $expectedSize bytes, got $inflated")
}
output
} catch (cause: DataFormatException) {
throw Fb2ParseException("Could not inflate ZIP entry: ${cause.message ?: "invalid deflate stream"}")
} finally {
inflater.end()
}
}

View File

@ -0,0 +1,98 @@
package net.sergeych.toread.fb2
import java.io.File
import java.util.Base64
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class Fb2FixtureBookTest {
@Test
fun parsesOnlyBookFromTestBooksWithImagesInPlace() {
val bookFile = findTestBooksDirectory()
.listFiles { file -> file.isFile && file.name.endsWith(".fb2.zip") }
.orEmpty()
.single()
val book = Fb2Format.parse(bookFile.readBytes(), bookFile.name)
assertEquals("Записки Терезы Нумы", book.title)
assertEquals(listOf("prose_contemporary"), book.genres)
assertEquals("ru", book.language)
assertEquals("it", book.sourceLanguage)
assertEquals("1976", book.date)
assertEquals("Дача Мараини", book.authors.single().displayName)
assertEquals("Николай Борисович Томашевский", book.translators.single().displayName)
assertTrue(book.annotation.orEmpty().startsWith("«Записки Терезы Нумы»"))
assertEquals("F99D3F1D-2A6C-4349-B7CF-1A390A38745A", book.documentInfo.id)
assertEquals("1.0", book.documentInfo.version)
assertEquals("12 May 2026", book.documentInfo.date)
assertEquals("alexej36", book.documentInfo.authors.single().displayName)
assertEquals(listOf("Дача Мараини", "ЗАПИСКИ ТЕРЕЗЫ НУМЫ", "Роман"), book.bodyTitle)
assertEquals(listOf("#i_001.png"), book.bodyImages.map { it.href })
assertEquals(2, book.sections.size)
assertEquals("ПРЕДИСЛОВИЕ", book.sections[0].title)
assertTrue(book.sections[0].paragraphs.first().startsWith("Роман Дачи Мараини"))
assertEquals(emptyList(), book.sections[0].images)
assertEquals("ЗАПИСКИ ТЕРЕЗЫ НУМЫ", book.sections[1].title)
assertEquals(listOf("#i_002.png", "#i_003.png", "#i_004.jpg"), book.sections[1].images.map { it.href })
assertTrue(book.sections[1].paragraphs.size > 4_000)
assertEquals(listOf("#cover.jpg"), book.coverImages.map { it.href })
assertEquals(
listOf("cover.jpg", "i_001.png", "i_002.png", "i_003.png", "i_004.jpg"),
book.binaries.map { it.id },
)
assertImageBinary(book, book.coverImages.single(), "image/jpeg", jpegSignature)
assertImageBinary(book, book.bodyImages.single(), "image/png", pngSignature)
book.sections[1].images.forEach { image ->
val expectedSignature = if (image.href.endsWith(".jpg")) jpegSignature else pngSignature
val expectedContentType = if (image.href.endsWith(".jpg")) "image/jpeg" else "image/png"
assertImageBinary(book, image, expectedContentType, expectedSignature)
}
}
private fun findTestBooksDirectory(): File {
var current = File(System.getProperty("user.dir")).absoluteFile
while (true) {
val candidate = File(current, "test_books")
if (candidate.isDirectory) return candidate
current = current.parentFile ?: error("Could not find test_books directory")
}
}
private fun assertImageBinary(
book: Fb2Book,
image: Fb2ImageRef,
contentType: String,
signature: ByteArray,
) {
val binary = assertNotNull(book.binaryFor(image), "Missing binary for ${image.href}")
assertEquals(image.binaryId, binary.id)
assertEquals(contentType, binary.contentType)
val bytes = Base64.getDecoder().decode(binary.base64)
assertContentEquals(signature, bytes.copyOf(signature.size))
}
private val pngSignature = byteArrayOf(
0x89.toByte(),
0x50,
0x4e,
0x47,
0x0d,
0x0a,
0x1a,
0x0a,
)
private val jpegSignature = byteArrayOf(
0xff.toByte(),
0xd8.toByte(),
0xff.toByte(),
)
}

View File

@ -0,0 +1,45 @@
package net.sergeych.toread.fb2
import java.io.ByteArrayOutputStream
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
import kotlin.test.Test
import kotlin.test.assertEquals
class Fb2JvmZipTest {
@Test
fun parsesDeflatedZip() {
val zip = ByteArrayOutputStream()
ZipOutputStream(zip).use { output ->
output.putNextEntry(ZipEntry("deflated.fb2"))
output.write(sampleXml.encodeToByteArray())
output.closeEntry()
}
val book = Fb2Format.parse(zip.toByteArray(), "deflated.fb2.zip")
assertEquals("Deflated Book", book.title)
assertEquals("Zip Writer", book.authors.single().displayName)
}
private val sampleXml = """
<?xml version="1.0" encoding="UTF-8"?>
<FictionBook xmlns="http://www.gribuser.ru/xml/fictionbook/2.0">
<description>
<title-info>
<genre>prose</genre>
<author><first-name>Zip</first-name><last-name>Writer</last-name></author>
<book-title>Deflated Book</book-title>
<lang>en</lang>
</title-info>
<document-info>
<author><nickname>Toread</nickname></author>
<date>2026-05-12</date>
<id>deflated</id>
<version>1.0</version>
</document-info>
</description>
<body><section><p>Compressed.</p></section></body>
</FictionBook>
""".trimIndent()
}

View File

@ -0,0 +1,179 @@
package net.sergeych.toread.storage.jdbc
import net.sergeych.toread.storage.BodyClusterRecord
import net.sergeych.toread.storage.BookBodyRecord
import net.sergeych.toread.storage.BookFileRecord
import net.sergeych.toread.storage.BookFileStorageKind
import net.sergeych.toread.storage.BookRecord
import net.sergeych.toread.storage.BookmarkRecord
import net.sergeych.toread.storage.ContentAnchor
import net.sergeych.toread.storage.NoteRecord
import net.sergeych.toread.storage.ReadingStateRecord
import java.nio.file.Files
import java.sql.DriverManager
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
class H2LibraryDatabaseTest {
@Test
fun storesCatalogFileAndUserData() {
val db = H2LibraryDatabase.openMemory("storesCatalogFileAndUserData")
val now = 1_700_000_000_000L
db.transaction {
books.upsert(
BookRecord(
id = "book-1",
title = "Example",
language = "en",
createdAt = now,
updatedAt = now,
)
)
bodies.upsert(
BookBodyRecord(
id = "body-1",
exactTextHash = "text-sha",
canonicalizationVersion = 1,
nearSignature = "near",
wordCount = 42,
language = "en",
createdAt = now,
)
)
clusters.upsert(
BodyClusterRecord(
id = "cluster-1",
representativeBodyId = "body-1",
createdAt = now,
)
)
files.upsert(
BookFileRecord(
id = "file-1",
bookId = "book-1",
bodyId = "body-1",
bodyClusterId = "cluster-1",
rawSha256 = "raw-sha",
format = "fb2",
mimeType = "application/x-fictionbook+xml",
sizeBytes = 1024,
originalFilename = "example.fb2",
storageKind = BookFileStorageKind.EXTERNAL_URI,
storageUri = "/books/example.fb2",
lastSeenAt = now,
createdAt = now,
updatedAt = now,
)
)
val anchor = ContentAnchor(
canonicalCharOffset = 120,
canonicalTokenOffset = 30,
progress = 0.25,
exact = "selected text",
prefix = "before",
suffix = "after",
)
readingStates.upsert(
ReadingStateRecord(
id = "reading-1",
bodyClusterId = "cluster-1",
bodyId = "body-1",
anchor = anchor,
updatedAt = now + 1,
)
)
bookmarks.upsert(
BookmarkRecord(
id = "bookmark-1",
bodyClusterId = "cluster-1",
bodyId = "body-1",
anchor = anchor,
title = "Important",
selectedTextSnapshot = "selected text",
createdAt = now + 2,
updatedAt = now + 2,
)
)
notes.upsert(
NoteRecord(
id = "note-1",
bookmarkId = "bookmark-1",
bodyClusterId = "cluster-1",
bodyId = "body-1",
anchor = anchor,
text = "Remember this",
createdAt = now + 3,
updatedAt = now + 3,
)
)
}
assertEquals("Example", db.books.get("book-1")?.title)
assertEquals("body-1", db.bodies.findByExactTextHash("text-sha", 1)?.id)
assertEquals("cluster-1", db.clusters.get("cluster-1")?.id)
assertEquals(BookFileStorageKind.EXTERNAL_URI, db.files.get("file-1")?.storageKind)
assertEquals(1, db.files.findByRawSha256("raw-sha").size)
assertEquals(0.25, db.readingStates.getForBodyCluster("cluster-1")?.anchor?.progress)
assertEquals("Important", db.bookmarks.listForBodyCluster("cluster-1").single().title)
assertEquals("Remember this", db.notes.listForBookmark("bookmark-1").single().text)
db.close()
}
@Test
fun transactionRollsBackOnFailure() {
val db = H2LibraryDatabase.openMemory("transactionRollsBackOnFailure")
val error = runCatching {
db.transaction {
books.upsert(
BookRecord(
id = "book-rollback",
title = "Rollback",
createdAt = 1,
updatedAt = 1,
)
)
error("boom")
}
}.exceptionOrNull()
assertNotNull(error)
assertNull(db.books.get("book-rollback"))
db.close()
}
@Test
fun opensDatabaseWithExistingUppercaseIndex() {
val path = Files.createTempDirectory("toread-h2-index-").resolve("library").toString()
Class.forName("org.h2.Driver")
DriverManager.getConnection("jdbc:h2:file:$path;DB_CLOSE_DELAY=0", "sa", "").use { connection ->
connection.createStatement().use { statement ->
statement.execute(
"""
CREATE TABLE book_bodies (
id VARCHAR PRIMARY KEY,
exact_text_hash VARCHAR NOT NULL,
canonicalization_version INT NOT NULL,
near_signature VARCHAR,
word_count INT,
language VARCHAR,
created_at BIGINT NOT NULL
)
""".trimIndent()
)
statement.execute(
"""
CREATE UNIQUE INDEX IDX_BOOK_BODIES_EXACT
ON book_bodies(exact_text_hash, canonicalization_version)
""".trimIndent()
)
}
}
H2LibraryDatabase.openFile(path).close()
}
}

View File

@ -0,0 +1,46 @@
package net.sergeych.toread.storage.jdbc
import java.io.File
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class LibraryScannerTest {
@Test
fun scansFb2ZipSubtreeIntoLinkedLibraryRecords() {
val root = findProjectRoot()
val db = H2LibraryDatabase.openMemory("scansFb2ZipSubtreeIntoLinkedLibraryRecords")
try {
val summary = LibraryScanner(db).scanSubtree(File(root, "test_books"))
assertEquals(1, summary.scannedFiles)
assertEquals(1, summary.importedFiles)
assertEquals(0, summary.skippedFiles)
assertEquals(0, summary.failedFiles)
val file = db.files.list().single()
assertEquals("fb2.zip", file.format)
assertTrue(file.storageUri?.endsWith(".fb2.zip") == true)
val book = assertNotNull(file.bookId?.let(db.books::get))
val coverImage = assertNotNull(book.coverImage)
assertTrue(coverImage.isNotEmpty())
assertNotNull(file.bodyId?.let(db.bodies::get))
val second = LibraryScanner(db).scanSubtree(File(root, "test_books"))
assertEquals(0, second.importedFiles)
assertEquals(1, second.skippedFiles)
} finally {
db.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
}
error("Could not locate project root")
}
}

View File

@ -0,0 +1,29 @@
package net.sergeych.toread.storage.jdbc
import java.io.File
import java.nio.file.Files
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class ManagedBookObjectStoreTest {
@Test
fun copiesFileIntoContentAddressedObjectsDirectory() {
val root = Files.createTempDirectory("toread-object-store-").toFile()
try {
val source = File(root, "source.fb2")
source.writeText("book body")
val store = ManagedBookObjectStore(File(root, "library"))
val stored = store.importFile(source)
assertEquals("f91f20e9d49a55906a28ec7c5c11cfe59d51f1244fa32e2aaf9c2e8f8ee71925", stored.objectId)
assertEquals("objects/f9/${stored.objectId}", stored.relativePath)
assertEquals(source.length(), stored.sizeBytes)
assertTrue(store.exists(stored.objectId))
assertEquals("book body", store.fileForObject(stored.objectId).readText())
} finally {
root.deleteRecursively()
}
}
}

View File

@ -0,0 +1,7 @@
package net.sergeych.toread
class WasmPlatform : Platform {
override val name: String = "Web with Kotlin/Wasm"
}
actual fun getPlatform(): Platform = WasmPlatform()

View File

@ -0,0 +1,5 @@
package net.sergeych.toread.fb2
internal actual fun inflateRaw(input: ByteArray, expectedSize: Int): ByteArray {
throw Fb2ParseException("Deflated FB2 ZIP import is not supported on this target yet")
}