better UI, +windows1251 encoding support
This commit is contained in:
parent
ddf68c1e6a
commit
da88a6e221
@ -268,6 +268,37 @@ actual suspend fun markLibraryReadingStatus(fileId: String, status: BookReadingS
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
actual suspend fun shareLibraryBookFile(fileId: String): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
val shareFile = openLibraryDatabase().useLibrary { db ->
|
||||||
|
val file = db.files.getLibraryFile(fileId) ?: return@useLibrary null
|
||||||
|
val bytes = readStorageUriBytes(file.storageUri ?: return@useLibrary null) ?: return@useLibrary null
|
||||||
|
val shareDir = File(appContext.cacheDir, "shared-books").also { it.mkdirs() }
|
||||||
|
File(shareDir, file.shareFileName()).also { it.writeBytes(bytes) }
|
||||||
|
} ?: return@withContext false
|
||||||
|
|
||||||
|
val uri = FileProvider.getUriForFile(
|
||||||
|
appContext,
|
||||||
|
"${appContext.packageName}.imageviewer.fileprovider",
|
||||||
|
shareFile,
|
||||||
|
)
|
||||||
|
val mimeType = shareFile.bookMimeType()
|
||||||
|
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||||
|
type = mimeType
|
||||||
|
putExtra(Intent.EXTRA_STREAM, uri)
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
}
|
||||||
|
val chooser = Intent.createChooser(intent, "Share book").apply {
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
}
|
||||||
|
appContext.startActivity(chooser)
|
||||||
|
true
|
||||||
|
}.getOrDefault(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual suspend fun viewLibraryBookFile(fileId: String): Boolean = false
|
||||||
|
|
||||||
actual suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras = withContext(Dispatchers.IO) {
|
actual suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras = withContext(Dispatchers.IO) {
|
||||||
openLibraryDatabase().useLibrary { db ->
|
openLibraryDatabase().useLibrary { db ->
|
||||||
val file = db.files.get(fileId) ?: return@useLibrary BookInfoExtras()
|
val file = db.files.get(fileId) ?: return@useLibrary BookInfoExtras()
|
||||||
@ -528,6 +559,26 @@ private fun String.requiresExternalFileAccess(): Boolean {
|
|||||||
private fun String.isSupportedBookFile(): Boolean =
|
private fun String.isSupportedBookFile(): Boolean =
|
||||||
endsWith(".fb2", ignoreCase = true) || endsWith(".fb2.zip", ignoreCase = true)
|
endsWith(".fb2", ignoreCase = true) || endsWith(".fb2.zip", ignoreCase = true)
|
||||||
|
|
||||||
|
private fun LibraryFileRecord.shareFileName(): String {
|
||||||
|
val raw = originalFilename?.takeIf { it.isNotBlank() }
|
||||||
|
?: title?.takeIf { it.isNotBlank() }?.let { title ->
|
||||||
|
val extension = when {
|
||||||
|
format.equals("fb2.zip", ignoreCase = true) || storageUri?.endsWith(".fb2.zip", ignoreCase = true) == true -> ".fb2.zip"
|
||||||
|
else -> ".fb2"
|
||||||
|
}
|
||||||
|
"$title$extension"
|
||||||
|
}
|
||||||
|
?: "book.fb2"
|
||||||
|
return raw.replace(Regex("""[\\/:*?"<>|]+"""), "_")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun File.bookMimeType(): String =
|
||||||
|
if (name.endsWith(".zip", ignoreCase = true)) {
|
||||||
|
"application/zip"
|
||||||
|
} else {
|
||||||
|
"application/x-fictionbook+xml"
|
||||||
|
}
|
||||||
|
|
||||||
private fun displayNameFor(uri: Uri): String =
|
private fun displayNameFor(uri: Uri): String =
|
||||||
appContext.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
|
appContext.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
|
||||||
if (cursor.moveToFirst()) cursor.getString(0) else null
|
if (cursor.moveToFirst()) cursor.getString(0) else null
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<cache-path name="clipboard_images" path="clipboard-images/"/>
|
<cache-path name="clipboard_images" path="clipboard-images/"/>
|
||||||
|
<cache-path name="shared_books" path="shared-books/"/>
|
||||||
</paths>
|
</paths>
|
||||||
|
|||||||
@ -166,6 +166,9 @@ private fun BookReaderApp(onThemeToggle: () -> Unit) {
|
|||||||
message = current.message,
|
message = current.message,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
onDeleted = { message ->
|
||||||
|
state = AppState.Library(emptyList(), current.scanPath, message)
|
||||||
|
},
|
||||||
onBack = {
|
onBack = {
|
||||||
state = AppState.Library(emptyList(), current.scanPath, current.message)
|
state = AppState.Library(emptyList(), current.scanPath, current.message)
|
||||||
},
|
},
|
||||||
|
|||||||
@ -0,0 +1,14 @@
|
|||||||
|
package net.sergeych.toread
|
||||||
|
|
||||||
|
internal data class LibraryDeleteResult(
|
||||||
|
val deleted: Boolean,
|
||||||
|
val message: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal suspend fun deleteLibraryBook(fileId: String, title: String): LibraryDeleteResult {
|
||||||
|
val deleted = runCatching { deleteLibraryItem(fileId) }.getOrDefault(false)
|
||||||
|
return LibraryDeleteResult(
|
||||||
|
deleted = deleted,
|
||||||
|
message = if (deleted) "Removed $title." else "Could not remove $title.",
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -108,6 +108,10 @@ expect suspend fun saveLibraryReadingPosition(fileId: String, position: ReadingP
|
|||||||
|
|
||||||
expect suspend fun markLibraryReadingStatus(fileId: String, status: BookReadingStatus): Boolean
|
expect suspend fun markLibraryReadingStatus(fileId: String, status: BookReadingStatus): Boolean
|
||||||
|
|
||||||
|
expect suspend fun shareLibraryBookFile(fileId: String): Boolean
|
||||||
|
|
||||||
|
expect suspend fun viewLibraryBookFile(fileId: String): Boolean
|
||||||
|
|
||||||
expect suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras
|
expect suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras
|
||||||
|
|
||||||
expect suspend fun loadActiveReadingFileId(): String?
|
expect suspend fun loadActiveReadingFileId(): String?
|
||||||
|
|||||||
@ -364,9 +364,9 @@ internal fun LibraryScreen(
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
busy = true
|
busy = true
|
||||||
try {
|
try {
|
||||||
val deleted = runCatching { deleteLibraryItem(item.fileId) }.getOrDefault(false)
|
val result = deleteLibraryBook(item.fileId, item.title)
|
||||||
message = if (deleted) "Removed ${item.title}." else "Could not remove ${item.title}."
|
message = result.message
|
||||||
if (deleted) {
|
if (result.deleted) {
|
||||||
items = items.filterNot { it.fileId == item.fileId }
|
items = items.filterNot { it.fileId == item.fileId }
|
||||||
searchResults = searchResults.filterNot { it.fileId == item.fileId }
|
searchResults = searchResults.filterNot { it.fileId == item.fileId }
|
||||||
coverCache.remove(item.fileId)
|
coverCache.remove(item.fileId)
|
||||||
|
|||||||
@ -90,9 +90,9 @@ internal fun ContinuousBookReader(
|
|||||||
val hyphenation = remember { HyphenationRegistry() }
|
val hyphenation = remember { HyphenationRegistry() }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val contentPadding = if (isAndroidPlatform()) {
|
val contentPadding = if (isAndroidPlatform()) {
|
||||||
PaddingValues(start = 6.dp, top = 6.dp, end = 0.dp, bottom = 6.dp)
|
PaddingValues(start = 0.dp, top = 6.dp, end = 0.dp, bottom = 6.dp)
|
||||||
} else {
|
} else {
|
||||||
PaddingValues(horizontal = 8.dp, vertical = 6.dp)
|
PaddingValues(horizontal = 4.dp, vertical = 6.dp)
|
||||||
}
|
}
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
@ -461,8 +461,8 @@ private fun ReaderText(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun readerParagraphTextStyle(language: String?): TextStyle =
|
private fun readerParagraphTextStyle(language: String?): TextStyle =
|
||||||
MaterialTheme.typography.bodyLarge.copy(
|
MaterialTheme.typography.bodyLarge.copy(
|
||||||
fontWeight = FontWeight(350),
|
fontWeight = if( isAndroidPlatform()) FontWeight(350) else FontWeight.Normal,
|
||||||
fontSize = 21.sp,
|
fontSize = if( isAndroidPlatform()) 21.sp else 18.sp,
|
||||||
lineHeight = 28.sp,
|
lineHeight = 28.sp,
|
||||||
hyphens = if (isAndroidPlatform()) Hyphens.Auto else Hyphens.Unspecified,
|
hyphens = if (isAndroidPlatform()) Hyphens.Auto else Hyphens.Unspecified,
|
||||||
lineBreak = if (isAndroidPlatform()) LineBreak.Paragraph else LineBreak.Unspecified,
|
lineBreak = if (isAndroidPlatform()) LineBreak.Paragraph else LineBreak.Unspecified,
|
||||||
|
|||||||
@ -12,13 +12,19 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
||||||
import androidx.compose.material.icons.filled.Info
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
import androidx.compose.material.icons.filled.Palette
|
import androidx.compose.material.icons.filled.Palette
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarDuration
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@ -47,13 +53,36 @@ internal fun BookView(
|
|||||||
onImageOpen: (ViewedBookImage) -> Unit,
|
onImageOpen: (ViewedBookImage) -> Unit,
|
||||||
onThemeToggle: () -> Unit,
|
onThemeToggle: () -> Unit,
|
||||||
onBookInfo: () -> Unit,
|
onBookInfo: () -> Unit,
|
||||||
|
onDeleted: (String) -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val stats = remember(book) { BookStats.from(book) }
|
val stats = remember(book) { BookStats.from(book) }
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
var restored by remember(fileId) { mutableStateOf(false) }
|
var restored by remember(fileId) { mutableStateOf(false) }
|
||||||
var markedRead by remember(fileId) { mutableStateOf(false) }
|
var markedRead by remember(fileId) { mutableStateOf(false) }
|
||||||
|
val platformName = getPlatform().name
|
||||||
|
val showShareAction = platformName.startsWith("Android")
|
||||||
|
val showViewFileAction = platformName.startsWith("Java")
|
||||||
|
|
||||||
|
fun showMessage(message: String) {
|
||||||
|
scope.launch {
|
||||||
|
snackbarHostState.showSnackbar(message, duration = SnackbarDuration.Short)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setReadingStatus(status: BookReadingStatus, successMessage: String) {
|
||||||
|
scope.launch {
|
||||||
|
if (markLibraryReadingStatus(fileId, status)) {
|
||||||
|
if (status == BookReadingStatus.READ) markedRead = true
|
||||||
|
if (status == BookReadingStatus.NEW) markedRead = false
|
||||||
|
showMessage(successMessage)
|
||||||
|
} else {
|
||||||
|
showMessage("Could not update book.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(fileId) {
|
LaunchedEffect(fileId) {
|
||||||
markLibraryReadingStatus(fileId, BookReadingStatus.READING)
|
markLibraryReadingStatus(fileId, BookReadingStatus.READING)
|
||||||
@ -91,6 +120,7 @@ internal fun BookView(
|
|||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
contentWindowInsets = WindowInsets(0, 0, 0, 0),
|
contentWindowInsets = WindowInsets(0, 0, 0, 0),
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
topBar = {
|
topBar = {
|
||||||
CompactReaderTopBar(
|
CompactReaderTopBar(
|
||||||
title = book.title,
|
title = book.title,
|
||||||
@ -104,6 +134,40 @@ internal fun BookView(
|
|||||||
onBookInfo()
|
onBookInfo()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onMarkAsRead = {
|
||||||
|
setReadingStatus(BookReadingStatus.READ, "Marked as read.")
|
||||||
|
},
|
||||||
|
onNotInterested = {
|
||||||
|
setReadingStatus(BookReadingStatus.NOT_INTERESTED, "Marked as not interested.")
|
||||||
|
},
|
||||||
|
onClearMarks = {
|
||||||
|
setReadingStatus(BookReadingStatus.NEW, "Cleared marks.")
|
||||||
|
},
|
||||||
|
showShareAction = showShareAction,
|
||||||
|
onShare = {
|
||||||
|
scope.launch {
|
||||||
|
val shared = shareLibraryBookFile(fileId)
|
||||||
|
showMessage(if (shared) "Share opened." else "Could not share book.")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
showViewFileAction = showViewFileAction,
|
||||||
|
onViewFile = {
|
||||||
|
scope.launch {
|
||||||
|
val opened = viewLibraryBookFile(fileId)
|
||||||
|
showMessage(if (opened) "Opened file location." else "Could not open file location.")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDelete = {
|
||||||
|
scope.launch {
|
||||||
|
val result = deleteLibraryBook(fileId, book.title)
|
||||||
|
if (result.deleted) {
|
||||||
|
saveActiveReadingFileId(null)
|
||||||
|
onDeleted(result.message)
|
||||||
|
} else {
|
||||||
|
showMessage(result.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
onBack = {
|
onBack = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
saveLibraryReadingPosition(
|
saveLibraryReadingPosition(
|
||||||
@ -139,8 +203,18 @@ private fun CompactReaderTopBar(
|
|||||||
title: String,
|
title: String,
|
||||||
onThemeToggle: () -> Unit,
|
onThemeToggle: () -> Unit,
|
||||||
onBookInfo: () -> Unit,
|
onBookInfo: () -> Unit,
|
||||||
|
onMarkAsRead: () -> Unit,
|
||||||
|
onNotInterested: () -> Unit,
|
||||||
|
onClearMarks: () -> Unit,
|
||||||
|
showShareAction: Boolean,
|
||||||
|
onShare: () -> Unit,
|
||||||
|
showViewFileAction: Boolean,
|
||||||
|
onViewFile: () -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
var menuOpen by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
ThemedTopBarSurface {
|
ThemedTopBarSurface {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||||
@ -158,12 +232,74 @@ private fun CompactReaderTopBar(
|
|||||||
IconButton(onClick = onThemeToggle) {
|
IconButton(onClick = onThemeToggle) {
|
||||||
Icon(Icons.Filled.Palette, contentDescription = "Theme")
|
Icon(Icons.Filled.Palette, contentDescription = "Theme")
|
||||||
}
|
}
|
||||||
IconButton(onClick = onBookInfo) {
|
|
||||||
Icon(Icons.Filled.Info, contentDescription = "Properties")
|
|
||||||
}
|
|
||||||
IconButton(onClick = { }) {
|
IconButton(onClick = { }) {
|
||||||
Icon(Icons.AutoMirrored.Filled.VolumeUp, contentDescription = "Read aloud")
|
Icon(Icons.AutoMirrored.Filled.VolumeUp, contentDescription = "Read aloud")
|
||||||
}
|
}
|
||||||
|
Box {
|
||||||
|
IconButton(onClick = { menuOpen = true }) {
|
||||||
|
Icon(Icons.Filled.MoreVert, contentDescription = "Book reader menu")
|
||||||
|
}
|
||||||
|
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Info...") },
|
||||||
|
onClick = {
|
||||||
|
menuOpen = false
|
||||||
|
onBookInfo()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Mark as read") },
|
||||||
|
onClick = {
|
||||||
|
menuOpen = false
|
||||||
|
onMarkAsRead()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Not interested") },
|
||||||
|
onClick = {
|
||||||
|
menuOpen = false
|
||||||
|
onNotInterested()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Clear marks") },
|
||||||
|
onClick = {
|
||||||
|
menuOpen = false
|
||||||
|
onClearMarks()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (showShareAction || showViewFileAction) {
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
if (showShareAction) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Share") },
|
||||||
|
onClick = {
|
||||||
|
menuOpen = false
|
||||||
|
onShare()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showViewFileAction) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("View file") },
|
||||||
|
onClick = {
|
||||||
|
menuOpen = false
|
||||||
|
onViewFile()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalDivider()
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Delete") },
|
||||||
|
onClick = {
|
||||||
|
menuOpen = false
|
||||||
|
onDelete()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import net.sergeych.toread.storage.ReadingStateRecord
|
|||||||
import net.sergeych.toread.storage.jdbc.H2LibraryDatabase
|
import net.sergeych.toread.storage.jdbc.H2LibraryDatabase
|
||||||
import net.sergeych.toread.storage.jdbc.LibraryScanner
|
import net.sergeych.toread.storage.jdbc.LibraryScanner
|
||||||
import org.jetbrains.skia.Image
|
import org.jetbrains.skia.Image
|
||||||
|
import java.awt.Desktop
|
||||||
import java.awt.Toolkit
|
import java.awt.Toolkit
|
||||||
import java.awt.datatransfer.DataFlavor
|
import java.awt.datatransfer.DataFlavor
|
||||||
import java.awt.datatransfer.Transferable
|
import java.awt.datatransfer.Transferable
|
||||||
@ -218,6 +219,24 @@ actual suspend fun markLibraryReadingStatus(fileId: String, status: BookReadingS
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
actual suspend fun shareLibraryBookFile(fileId: String): Boolean = false
|
||||||
|
|
||||||
|
actual suspend fun viewLibraryBookFile(fileId: String): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
if (!Desktop.isDesktopSupported()) return@withContext false
|
||||||
|
val file = openLibraryDatabase().useLibrary { db ->
|
||||||
|
db.files.get(fileId)?.storageUri?.let(::File)?.takeIf { it.isFile }
|
||||||
|
} ?: return@withContext false
|
||||||
|
val desktop = Desktop.getDesktop()
|
||||||
|
if (desktop.isSupported(Desktop.Action.BROWSE_FILE_DIR)) {
|
||||||
|
desktop.browseFileDirectory(file)
|
||||||
|
} else {
|
||||||
|
desktop.open(file.parentFile ?: file)
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}.getOrDefault(false)
|
||||||
|
}
|
||||||
|
|
||||||
actual suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras = withContext(Dispatchers.IO) {
|
actual suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras = withContext(Dispatchers.IO) {
|
||||||
openLibraryDatabase().useLibrary { db ->
|
openLibraryDatabase().useLibrary { db ->
|
||||||
val file = db.files.get(fileId) ?: return@useLibrary BookInfoExtras()
|
val file = db.files.get(fileId) ?: return@useLibrary BookInfoExtras()
|
||||||
|
|||||||
@ -46,6 +46,10 @@ actual suspend fun saveLibraryReadingPosition(fileId: String, position: ReadingP
|
|||||||
|
|
||||||
actual suspend fun markLibraryReadingStatus(fileId: String, status: BookReadingStatus): Boolean = false
|
actual suspend fun markLibraryReadingStatus(fileId: String, status: BookReadingStatus): Boolean = false
|
||||||
|
|
||||||
|
actual suspend fun shareLibraryBookFile(fileId: String): Boolean = false
|
||||||
|
|
||||||
|
actual suspend fun viewLibraryBookFile(fileId: String): Boolean = false
|
||||||
|
|
||||||
actual suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras = BookInfoExtras()
|
actual suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras = BookInfoExtras()
|
||||||
|
|
||||||
actual suspend fun loadActiveReadingFileId(): String? = null
|
actual suspend fun loadActiveReadingFileId(): String? = null
|
||||||
|
|||||||
@ -20,7 +20,7 @@ The common API is `Fb2Format.parse(input: ByteArray, fileName: String? = null)`.
|
|||||||
Import detection:
|
Import detection:
|
||||||
|
|
||||||
- A file is treated as ZIP when its bytes start with the ZIP local-file signature `PK\003\004` or the provided filename ends with `.zip`.
|
- A file is treated as ZIP when its bytes start with the ZIP local-file signature `PK\003\004` or the provided filename ends with `.zip`.
|
||||||
- Otherwise bytes are decoded as UTF-8 XML.
|
- Otherwise bytes are decoded according to the XML declaration when supported. UTF-8 is the default, and unsupported or missing encodings fall back to UTF-8. `windows-1251` is supported for legacy FB2 files.
|
||||||
- In ZIP archives, the first entry ending with `.fb2` is used. If no such entry exists, the first non-directory entry is used.
|
- In ZIP archives, the first entry ending with `.fb2` is used. If no such entry exists, the first non-directory entry is used.
|
||||||
|
|
||||||
ZIP support:
|
ZIP support:
|
||||||
|
|||||||
@ -10,7 +10,7 @@ object Fb2Format {
|
|||||||
val xml = if (looksLikeZip(input) || fileName?.endsWith(".zip", ignoreCase = true) == true) {
|
val xml = if (looksLikeZip(input) || fileName?.endsWith(".zip", ignoreCase = true) == true) {
|
||||||
Fb2Zip.extractFb2Xml(input)
|
Fb2Zip.extractFb2Xml(input)
|
||||||
} else {
|
} else {
|
||||||
input.decodeToString()
|
Fb2XmlEncoding.decodeXml(input)
|
||||||
}
|
}
|
||||||
return parseXml(xml)
|
return parseXml(xml)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,103 @@
|
|||||||
|
package net.sergeych.toread.fb2
|
||||||
|
|
||||||
|
internal object Fb2XmlEncoding {
|
||||||
|
private val EncodingPattern = Regex("""encoding\s*=\s*["']([^"']+)["']""", RegexOption.IGNORE_CASE)
|
||||||
|
|
||||||
|
fun decodeXml(bytes: ByteArray): String =
|
||||||
|
when (declaredEncoding(bytes)?.lowercase()) {
|
||||||
|
"windows-1251" -> decodeWindows1251(bytes)
|
||||||
|
else -> bytes.decodeToString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun declaredEncoding(bytes: ByteArray): String? {
|
||||||
|
val probe = bytes
|
||||||
|
.copyOfRange(0, minOf(bytes.size, 256))
|
||||||
|
.map { byte ->
|
||||||
|
val value = byte.toInt() and 0xff
|
||||||
|
if (value in 0x20..0x7e || value == '\n'.code || value == '\r'.code || value == '\t'.code) {
|
||||||
|
value.toChar()
|
||||||
|
} else {
|
||||||
|
' '
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.joinToString("")
|
||||||
|
return EncodingPattern.find(probe)?.groupValues?.getOrNull(1)?.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeWindows1251(bytes: ByteArray): String = buildString(bytes.size) {
|
||||||
|
bytes.forEach { byte ->
|
||||||
|
append(windows1251Char(byte.toInt() and 0xff))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun windows1251Char(value: Int): Char =
|
||||||
|
when (value) {
|
||||||
|
in 0x00..0x7f -> value.toChar()
|
||||||
|
0x80 -> '\u0402'
|
||||||
|
0x81 -> '\u0403'
|
||||||
|
0x82 -> '\u201a'
|
||||||
|
0x83 -> '\u0453'
|
||||||
|
0x84 -> '\u201e'
|
||||||
|
0x85 -> '\u2026'
|
||||||
|
0x86 -> '\u2020'
|
||||||
|
0x87 -> '\u2021'
|
||||||
|
0x88 -> '\u20ac'
|
||||||
|
0x89 -> '\u2030'
|
||||||
|
0x8a -> '\u0409'
|
||||||
|
0x8b -> '\u2039'
|
||||||
|
0x8c -> '\u040a'
|
||||||
|
0x8d -> '\u040c'
|
||||||
|
0x8e -> '\u040b'
|
||||||
|
0x8f -> '\u040f'
|
||||||
|
0x90 -> '\u0452'
|
||||||
|
0x91 -> '\u2018'
|
||||||
|
0x92 -> '\u2019'
|
||||||
|
0x93 -> '\u201c'
|
||||||
|
0x94 -> '\u201d'
|
||||||
|
0x95 -> '\u2022'
|
||||||
|
0x96 -> '\u2013'
|
||||||
|
0x97 -> '\u2014'
|
||||||
|
0x98 -> '\uFFFD'
|
||||||
|
0x99 -> '\u2122'
|
||||||
|
0x9a -> '\u0459'
|
||||||
|
0x9b -> '\u203a'
|
||||||
|
0x9c -> '\u045a'
|
||||||
|
0x9d -> '\u045c'
|
||||||
|
0x9e -> '\u045b'
|
||||||
|
0x9f -> '\u045f'
|
||||||
|
0xa0 -> '\u00a0'
|
||||||
|
0xa1 -> '\u040e'
|
||||||
|
0xa2 -> '\u045e'
|
||||||
|
0xa3 -> '\u0408'
|
||||||
|
0xa4 -> '\u00a4'
|
||||||
|
0xa5 -> '\u0490'
|
||||||
|
0xa6 -> '\u00a6'
|
||||||
|
0xa7 -> '\u00a7'
|
||||||
|
0xa8 -> '\u0401'
|
||||||
|
0xa9 -> '\u00a9'
|
||||||
|
0xaa -> '\u0404'
|
||||||
|
0xab -> '\u00ab'
|
||||||
|
0xac -> '\u00ac'
|
||||||
|
0xad -> '\u00ad'
|
||||||
|
0xae -> '\u00ae'
|
||||||
|
0xaf -> '\u0407'
|
||||||
|
0xb0 -> '\u00b0'
|
||||||
|
0xb1 -> '\u00b1'
|
||||||
|
0xb2 -> '\u0406'
|
||||||
|
0xb3 -> '\u0456'
|
||||||
|
0xb4 -> '\u0491'
|
||||||
|
0xb5 -> '\u00b5'
|
||||||
|
0xb6 -> '\u00b6'
|
||||||
|
0xb7 -> '\u00b7'
|
||||||
|
0xb8 -> '\u0451'
|
||||||
|
0xb9 -> '\u2116'
|
||||||
|
0xba -> '\u0454'
|
||||||
|
0xbb -> '\u00bb'
|
||||||
|
0xbc -> '\u0458'
|
||||||
|
0xbd -> '\u0405'
|
||||||
|
0xbe -> '\u0455'
|
||||||
|
0xbf -> '\u0457'
|
||||||
|
in 0xc0..0xff -> (0x0410 + value - 0xc0).toChar()
|
||||||
|
else -> '\uFFFD'
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -12,7 +12,7 @@ internal object Fb2Zip {
|
|||||||
val entry = entries.firstOrNull { it.name.endsWith(".fb2", ignoreCase = true) }
|
val entry = entries.firstOrNull { it.name.endsWith(".fb2", ignoreCase = true) }
|
||||||
?: entries.firstOrNull { !it.name.endsWith("/") }
|
?: entries.firstOrNull { !it.name.endsWith("/") }
|
||||||
?: throw Fb2ParseException("ZIP archive does not contain an FB2 entry")
|
?: throw Fb2ParseException("ZIP archive does not contain an FB2 entry")
|
||||||
return readEntry(zip, entry).decodeToString()
|
return Fb2XmlEncoding.decodeXml(readEntry(zip, entry))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun createStoredZip(entryName: String, content: ByteArray): ByteArray {
|
fun createStoredZip(entryName: String, content: ByteArray): ByteArray {
|
||||||
|
|||||||
@ -43,6 +43,31 @@ class Fb2FormatTest {
|
|||||||
assertTrue(zip.copyOfRange(0, 4).contentEquals(byteArrayOf(0x50, 0x4b, 0x03, 0x04)))
|
assertTrue(zip.copyOfRange(0, 4).contentEquals(byteArrayOf(0x50, 0x4b, 0x03, 0x04)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parsesWindows1251PlainXml() {
|
||||||
|
val book = Fb2Format.parse(windows1251Xml.encodeWindows1251(), "legacy.fb2")
|
||||||
|
|
||||||
|
assertEquals("Тестовая книга", book.title)
|
||||||
|
assertEquals("Привет, мир.", book.sections.single().paragraphs.single())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parsesWindows1251StoredZip() {
|
||||||
|
val zip = Fb2Zip.createStoredZip("legacy.fb2", windows1251Xml.encodeWindows1251())
|
||||||
|
val book = Fb2Format.parse(zip, "legacy.fb2.zip")
|
||||||
|
|
||||||
|
assertEquals("Тестовая книга", book.title)
|
||||||
|
assertEquals("Привет, мир.", book.sections.single().paragraphs.single())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fallsBackToUtf8ForUnknownEncoding() {
|
||||||
|
val xml = sampleXml.replace("encoding=\"UTF-8\"", "encoding=\"KOI8-R\"")
|
||||||
|
val book = Fb2Format.parse(xml.encodeToByteArray(), "unknown.fb2")
|
||||||
|
|
||||||
|
assertEquals("The Test Book", book.title)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun preservesReadableBlocksAndInlineStyles() {
|
fun preservesReadableBlocksAndInlineStyles() {
|
||||||
val book = Fb2Format.parseXml(richXml)
|
val book = Fb2Format.parseXml(richXml)
|
||||||
@ -108,4 +133,37 @@ class Fb2FormatTest {
|
|||||||
</body>
|
</body>
|
||||||
</FictionBook>
|
</FictionBook>
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
|
private val windows1251Xml = """
|
||||||
|
<?xml version="1.0" encoding="windows-1251"?>
|
||||||
|
<FictionBook xmlns="http://www.gribuser.ru/xml/fictionbook/2.0" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<description>
|
||||||
|
<title-info>
|
||||||
|
<author><nickname>Автор</nickname></author>
|
||||||
|
<book-title>Тестовая книга</book-title>
|
||||||
|
<lang>ru</lang>
|
||||||
|
</title-info>
|
||||||
|
<document-info>
|
||||||
|
<author><nickname>Toread</nickname></author>
|
||||||
|
<date>2026-05-12</date>
|
||||||
|
<id>legacy</id>
|
||||||
|
<version>1.0</version>
|
||||||
|
</document-info>
|
||||||
|
</description>
|
||||||
|
<body><section><p>Привет, мир.</p></section></body>
|
||||||
|
</FictionBook>
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
private fun String.encodeWindows1251(): ByteArray =
|
||||||
|
ByteArray(length) { index ->
|
||||||
|
val char = this[index]
|
||||||
|
val value = when {
|
||||||
|
char.code <= 0x7f -> char.code
|
||||||
|
char in 'А'..'я' -> 0xc0 + char.code - 'А'.code
|
||||||
|
char == 'Ё' -> 0xa8
|
||||||
|
char == 'ё' -> 0xb8
|
||||||
|
else -> error("Test character $char is not mapped to windows-1251")
|
||||||
|
}
|
||||||
|
value.toByte()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user