library UI + reader mode

This commit is contained in:
sergeych committed 2026-05-17 00:53:18 +03:00
commit 8f8b466e89
81 files changed
+7502

No files matched your search

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