library UI + reader mode
19
.gitignore
vendored
Normal 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
@ -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
@ -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
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
48
composeApp/src/androidMain/AndroidManifest.xml
Normal 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>
|
||||||
@ -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"
|
||||||
@ -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()
|
||||||
|
}
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
BIN
composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
BIN
composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 12 KiB |
BIN
composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 16 KiB |
7
composeApp/src/androidMain/res/values-night/themes.xml
Normal 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>
|
||||||
3
composeApp/src/androidMain/res/values/strings.xml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">toread</string>
|
||||||
|
</resources>
|
||||||
7
composeApp/src/androidMain/res/values/themes.xml
Normal 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>
|
||||||
@ -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>
|
||||||
1394
composeApp/src/commonMain/kotlin/net/sergeych/toread/App.kt
Normal 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)
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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"
|
||||||
13
composeApp/src/jvmMain/kotlin/net/sergeych/toread/main.kt
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
11
composeApp/src/webMain/kotlin/net/sergeych/toread/main.kt
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
20
composeApp/src/webMain/resources/index.html
Normal 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>
|
||||||
7
composeApp/src/webMain/resources/styles.css
Normal 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
@ -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
@ -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
@ -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
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
@ -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
@ -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
@ -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)
|
||||||
|
}
|
||||||
20
server/src/main/kotlin/net/sergeych/toread/Application.kt
Normal 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()}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
server/src/main/resources/logback.xml
Normal 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>
|
||||||
@ -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
@ -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
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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()
|
||||||
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
package net.sergeych.toread
|
||||||
|
|
||||||
|
const val SERVER_PORT = 8080
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
package net.sergeych.toread
|
||||||
|
|
||||||
|
class Greeting {
|
||||||
|
private val platform = getPlatform()
|
||||||
|
|
||||||
|
fun greet(): String {
|
||||||
|
return "Hello, ${platform.name}!"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
package net.sergeych.toread
|
||||||
|
|
||||||
|
interface Platform {
|
||||||
|
val name: String
|
||||||
|
}
|
||||||
|
|
||||||
|
expect fun getPlatform(): Platform
|
||||||
@ -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)
|
||||||
@ -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()
|
||||||
|
}
|
||||||
@ -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("&")
|
||||||
|
'<' -> append("<")
|
||||||
|
'>' -> append(">")
|
||||||
|
else -> append(char)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun escapeAttribute(value: String): String = buildString(value.length) {
|
||||||
|
value.forEach { char ->
|
||||||
|
when (char) {
|
||||||
|
'&' -> append("&")
|
||||||
|
'<' -> append("<")
|
||||||
|
'"' -> append(""")
|
||||||
|
'\'' -> append("'")
|
||||||
|
else -> append(char)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun XmlElement.attributeLocal(localName: String): String? =
|
||||||
|
attributes.entries.firstOrNull { it.key.substringAfter(':') == localName }?.value
|
||||||
182
shared/src/commonMain/kotlin/net/sergeych/toread/fb2/Fb2Zip.kt
Normal 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()
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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()
|
||||||
|
}
|
||||||
@ -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 "бвгджзйклмнпрстфхцчшщ"
|
||||||
717
shared/src/commonMain/resources/fb2/FictionBook.xsd
Normal 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 <description> 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 <title-info> 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 <section> 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>
|
||||||
194
shared/src/commonMain/resources/fb2/FictionBookGenres.xsd
Normal 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>
|
||||||
9
shared/src/commonMain/resources/fb2/FictionBookLang.xsd
Normal 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>
|
||||||
14
shared/src/commonMain/resources/fb2/FictionBookLinks.xsd
Normal 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>
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 & 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()
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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")
|
||||||
|
}
|
||||||
@ -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')
|
||||||
|
}
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
package net.sergeych.toread
|
||||||
|
|
||||||
|
class JsPlatform : Platform {
|
||||||
|
override val name: String = "Web with Kotlin/JS"
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun getPlatform(): Platform = JsPlatform()
|
||||||
@ -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")
|
||||||
|
}
|
||||||
@ -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()
|
||||||
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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()
|
||||||
|
}
|
||||||
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
package net.sergeych.toread
|
||||||
|
|
||||||
|
class WasmPlatform : Platform {
|
||||||
|
override val name: String = "Web with Kotlin/Wasm"
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun getPlatform(): Platform = WasmPlatform()
|
||||||
@ -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")
|
||||||
|
}
|
||||||