Windows (#291)
* Centralize library management logic and introduce support for plain text and HTML formats * Centralize library management logic and introduce support for plain text and HTML formats * Expand unit test coverage for library state management, UI models, and MainViewModel features. * Add comprehensive unit tests for PDF reader core logic, preferences, and data persistence * Add unit tests for EPUB parsing, content loading, search functionality, and reader JavaScript bridges. * Add unit tests for OPDS parsing and Smart Collection engine, and integrate Kover plugin * Add comprehensive unit tests * Centralize library snapshot serialization in the `shared` module and improve filtering and sorting logic. * Implement text selection, highlighting, and reading state persistence for PDF and EPUB engines in desktop version * Folder import support for desktop app * Introduce Smart Shelves with rule-based filtering in desktop version * Implement shared EPUB annotation serialization and highlight rendering * Centralize file type capabilities and platform-specific support logic * Refactor reader state management to use a central reducer * Implement customizable reader toolbar and advanced formatting settings in shared * Implement locator-based navigation and customizable highlight palette for desktop app * Enhance reader customization and expand search functionality in desktop app * Redesign reader settings and tools into a tabbed control panel in desktop app * Enhance reader navigation and highlight precision in desktop app * Implement bidirectional position synchronization and dynamic highlights in the desktop reader * Implement shared state management and enhanced search for the PDF reader in desktop app * Add vertical scroll support to the desktop PDF reader * Implement ink, text, and eraser annotation support in desktop PDF viewer * Implement PDF bookmarks, Table of Contents, and annotation editing in desktop app * Implement link handling and navigation for PDF and EPUB readers in desktop app * Implement PDF jump history for navigation in desktop app * Enhance PDF ink rendering and annotation capabilities in desktop app * Implement advanced PDF text annotations with inline editing and rich styling in desktop app * Add move handle and movement logic for PDF text annotations in desktop app * Implement local folder synchronization and metadata sidecar support in desktop app * Implement book metadata extraction and drag-and-drop import for Desktop * Implement dynamic and custom app theme management for desktop * Introduce canonical PDF annotation codec and support for multi-segment highlights * Implement rich text editing and pagination support for the PDF reader in desktop app * Improve PDF rich text pagination, synchronization, and observability in desktop * Hide trailing structural page breaks in rich text editor * Implement a unified JVM book loader and expand supported formats on Desktop * Add comic archive support for Desktop and enhance MOBI parsing * Implement shared OPDS catalog support and UI for Android and Desktop * Improve native WebView lifecycle and surface transition management on Desktop * Enable Compose Swing interop blending and simplify Desktop WebView management * Integrate BYOK AI features and Cloud TTS for desktop * Enhance Desktop TTS with streaming audio and improved secure storage for AI key * Implement scoped Cloud TTS with synchronized highlighting for EPUB and PDF in desktop app * Implement custom font management and utility screens in desktop app * Implement PDFium-based PDF annotation export * Remove PdfBox dependency and standardize PDF export via Pdfium * Implement local audio caching and playback controls for Gemini Cloud TTS in desktop app * Implement reader themes and custom texture support in desktop app * Redesign non-reader UI with responsive navigation and enhanced library management in desktop app * Introduce ReaderWorkspaceShell to unify EPUB and PDF reader layouts in desktop app * Exclude manual-only files from automated sync and import * Implement customizable Text-to-Speech (TTS) word replacements * Optimize reader performance with persistent layout caching and decoupled theme rendering * Improve position restoration during reader reconfiguration in epub pagination * Use independent thickness for eraser tool and stylus override
This commit is contained in:
parent
88c7fa7b5c
commit
8366d76dcd
214 changed files with 53372 additions and 4702 deletions
|
|
@ -0,0 +1,8 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
import java.security.MessageDigest
|
||||
|
||||
internal actual fun localFolderSyncSha256ShortHex(value: String): String {
|
||||
val bytes = MessageDigest.getInstance("SHA-256").digest(value.toByteArray())
|
||||
return bytes.joinToString("") { "%02x".format(it) }.take(12)
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import android.graphics.BitmapFactory
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
|
||||
@Composable
|
||||
internal actual fun LocalBookCoverImage(
|
||||
path: String,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier
|
||||
) {
|
||||
val bitmap = remember(path) {
|
||||
runCatching { BitmapFactory.decodeFile(path)?.asImageBitmap() }.getOrNull()
|
||||
}
|
||||
if (bitmap != null) {
|
||||
Image(
|
||||
bitmap = bitmap,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -178,7 +178,8 @@ object CssParser {
|
|||
constraints: Constraints,
|
||||
isDarkTheme: Boolean,
|
||||
themeBackgroundColor: Color = Color.Unspecified,
|
||||
themeTextColor: Color = Color.Unspecified
|
||||
themeTextColor: Color = Color.Unspecified,
|
||||
adaptThemeColors: Boolean = true
|
||||
): OptimizedCssParseResult {
|
||||
val byTag = mutableMapOf<String, MutableList<CssRule>>()
|
||||
val byClass = mutableMapOf<String, MutableList<CssRule>>()
|
||||
|
|
@ -193,7 +194,7 @@ object CssParser {
|
|||
val mediaQueryRegex = Regex("@media[^{]+\\{((?>[^{}]+|\\{[^{}]*\\})*)\\}")
|
||||
mediaQueryRegex.findAll(cleanedCss).forEach { match ->
|
||||
val condition = match.groups[0]?.value?.trim() ?: ""
|
||||
if (isDarkTheme && condition.contains("prefers-color-scheme: dark")) {
|
||||
if (adaptThemeColors && isDarkTheme && condition.contains("prefers-color-scheme: dark")) {
|
||||
val darkCss = match.groups[1]?.value ?: ""
|
||||
cleanedCss += "\n$darkCss"
|
||||
}
|
||||
|
|
@ -231,12 +232,26 @@ object CssParser {
|
|||
}
|
||||
val specificity = calculateSpecificity(originalSelector)
|
||||
val normalStyle = parseProperties(
|
||||
propertiesGroup, baseFontSizeSp, density, constraints, onlyImportant = false,
|
||||
isDarkTheme, themeBackgroundColor, themeTextColor
|
||||
properties = propertiesGroup,
|
||||
baseFontSizeSp = baseFontSizeSp,
|
||||
density = density,
|
||||
constraints = constraints,
|
||||
onlyImportant = false,
|
||||
isDarkTheme = isDarkTheme,
|
||||
themeBackgroundColor = themeBackgroundColor,
|
||||
themeTextColor = themeTextColor,
|
||||
adaptThemeColors = adaptThemeColors
|
||||
)
|
||||
val importantStyle = parseProperties(
|
||||
propertiesGroup, baseFontSizeSp, density, constraints, onlyImportant = true,
|
||||
isDarkTheme, themeBackgroundColor, themeTextColor
|
||||
properties = propertiesGroup,
|
||||
baseFontSizeSp = baseFontSizeSp,
|
||||
density = density,
|
||||
constraints = constraints,
|
||||
onlyImportant = true,
|
||||
isDarkTheme = isDarkTheme,
|
||||
themeBackgroundColor = themeBackgroundColor,
|
||||
themeTextColor = themeTextColor,
|
||||
adaptThemeColors = adaptThemeColors
|
||||
)
|
||||
|
||||
fun addRule(style: CssStyle, spec: Int) {
|
||||
|
|
@ -376,7 +391,8 @@ object CssParser {
|
|||
onlyImportant: Boolean,
|
||||
isDarkTheme: Boolean,
|
||||
themeBackgroundColor: Color = Color.Unspecified,
|
||||
themeTextColor: Color = Color.Unspecified
|
||||
themeTextColor: Color = Color.Unspecified,
|
||||
adaptThemeColors: Boolean = true
|
||||
): CssStyle {
|
||||
var spanStyle = SpanStyle()
|
||||
var paragraphStyle = ParagraphStyle()
|
||||
|
|
@ -451,6 +467,14 @@ object CssParser {
|
|||
var borderBottomRightRadius: Dp = 0.dp
|
||||
var borderBottomLeftRadius: Dp = 0.dp
|
||||
|
||||
fun maybeAdaptColor(color: Color, isBackground: Boolean): Color {
|
||||
return if (adaptThemeColors) {
|
||||
this@CssParser.adaptColorForTheme(color, isDarkTheme, isBackground, themeBackgroundColor, themeTextColor)
|
||||
} else {
|
||||
color
|
||||
}
|
||||
}
|
||||
|
||||
splitDeclarations(properties).filter { it.isNotBlank() }.forEach { prop ->
|
||||
val parts = prop.split(':', limit = 2).map { it.trim() }
|
||||
if (parts.size == 2) {
|
||||
|
|
@ -473,7 +497,7 @@ object CssParser {
|
|||
styleStr: String?
|
||||
) {
|
||||
val parsedWidth = widthStr?.let { parseCssSizeToDp(it, baseFontSizeSp, density, containerWidthPx) } ?: 0.dp
|
||||
val parsedColor = colorStr?.let { parseColor(it) }?.let { this@CssParser.adaptColorForTheme(it, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor) }
|
||||
val parsedColor = colorStr?.let { parseColor(it) }?.let { maybeAdaptColor(it, isBackground = false) }
|
||||
|
||||
val isExplicitWidth = widthStr != null
|
||||
|
||||
|
|
@ -528,7 +552,7 @@ object CssParser {
|
|||
}
|
||||
"color" -> {
|
||||
parseColor(value)?.let {
|
||||
spanStyle = spanStyle.copy(color = this@CssParser.adaptColorForTheme(it, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor))
|
||||
spanStyle = spanStyle.copy(color = maybeAdaptColor(it, isBackground = false))
|
||||
}
|
||||
}
|
||||
"text-align" -> {
|
||||
|
|
@ -585,7 +609,7 @@ object CssParser {
|
|||
val styles = listOf("solid", "double", "dotted", "dashed", "wavy")
|
||||
parts.firstOrNull { it in styles }?.let { textDecorationStyle = it }
|
||||
parts.firstNotNullOfOrNull { parseColor(it) }?.let { color ->
|
||||
textDecorationColor = this@CssParser.adaptColorForTheme(color, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor)
|
||||
textDecorationColor = maybeAdaptColor(color, isBackground = false)
|
||||
}
|
||||
}
|
||||
"word-spacing" -> {
|
||||
|
|
@ -601,7 +625,7 @@ object CssParser {
|
|||
}
|
||||
"text-decoration-color" -> {
|
||||
parseColor(value)?.let {
|
||||
textDecorationColor = this@CssParser.adaptColorForTheme(it, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor)
|
||||
textDecorationColor = maybeAdaptColor(it, isBackground = false)
|
||||
}
|
||||
}
|
||||
"text-underline-offset" -> {
|
||||
|
|
@ -661,7 +685,7 @@ object CssParser {
|
|||
|
||||
"background-color" -> {
|
||||
val originalColor = parseColor(value) ?: Color.Unspecified
|
||||
backgroundColor = this@CssParser.adaptColorForTheme(originalColor, isDarkTheme, isBackground = true, themeBackgroundColor, themeTextColor)
|
||||
backgroundColor = maybeAdaptColor(originalColor, isBackground = true)
|
||||
}
|
||||
|
||||
// Border Properties
|
||||
|
|
@ -801,7 +825,7 @@ object CssParser {
|
|||
textEmphasisStyleString = value
|
||||
}
|
||||
"text-emphasis-color", "-epub-text-emphasis-color" -> {
|
||||
textEmphasisColor = parseColor(value)?.let { this@CssParser.adaptColorForTheme(it, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor) }
|
||||
textEmphasisColor = parseColor(value)?.let { maybeAdaptColor(it, isBackground = false) }
|
||||
}
|
||||
"text-emphasis-position", "-epub-text-emphasis-position" -> {
|
||||
if (value in listOf("over", "under")) {
|
||||
|
|
@ -859,7 +883,7 @@ object CssParser {
|
|||
val finalStyle = style ?: "none"
|
||||
val finalColor = color ?: spanStyle.color.takeIf { it.isSpecified } ?: Color.Black
|
||||
|
||||
val adaptedColor = this@CssParser.adaptColorForTheme(finalColor, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor)
|
||||
val adaptedColor = maybeAdaptColor(finalColor, isBackground = false)
|
||||
|
||||
if (finalWidth > 0.dp && finalStyle != "none" && finalStyle != "hidden") {
|
||||
return BorderStyle(finalWidth, adaptedColor, finalStyle)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.aryan.reader.shared.reader.ReaderSettings
|
||||
import com.aryan.reader.shared.reader.ReaderSearchOptions
|
||||
|
||||
sealed interface LibraryAction {
|
||||
data class SearchChanged(val query: String) : LibraryAction
|
||||
data class SortChanged(val sortOrder: SortOrder) : LibraryAction
|
||||
|
|
@ -16,15 +20,36 @@ sealed interface ReaderAction {
|
|||
data object NextPage : ReaderAction
|
||||
data object PreviousPage : ReaderAction
|
||||
data class GoToPage(val pageIndex: Int) : ReaderAction
|
||||
data class GoToPageNumber(val pageNumber: Int) : ReaderAction
|
||||
data class GoToProgress(val progress: Float) : ReaderAction
|
||||
data class GoToChapter(val chapterIndex: Int) : ReaderAction
|
||||
data class GoToLocator(val locator: ReaderLocator) : ReaderAction
|
||||
data class VisiblePageChanged(val pageIndex: Int, val locator: ReaderLocator? = null) : ReaderAction
|
||||
data class GoToSearchResult(val resultIndex: Int) : ReaderAction
|
||||
data class SearchChanged(val query: String) : ReaderAction
|
||||
data object SearchOpened : ReaderAction
|
||||
data object SearchClosed : ReaderAction
|
||||
data object SearchResultsPanelToggled : ReaderAction
|
||||
data class SearchOptionsChanged(val options: ReaderSearchOptions) : ReaderAction
|
||||
data object NextSearchResult : ReaderAction
|
||||
data object PreviousSearchResult : ReaderAction
|
||||
data object ToggleBookmark : ReaderAction
|
||||
data class ToggleBookmarkAtLocator(
|
||||
val locator: ReaderLocator,
|
||||
val title: String? = null,
|
||||
val preview: String? = null
|
||||
) : ReaderAction
|
||||
data class SettingsChanged(val settings: ReaderSettings) : ReaderAction
|
||||
data class RenderModeChanged(val renderMode: RenderMode) : ReaderAction
|
||||
data class ThemeChanged(val theme: ReaderTheme) : ReaderAction
|
||||
data class FormatChanged(val settings: FormatSettings) : ReaderAction
|
||||
data class HighlightCreated(val highlight: UserHighlight) : ReaderAction
|
||||
data class HighlightUpdated(
|
||||
val highlightId: String,
|
||||
val color: HighlightColor? = null,
|
||||
val note: String? = null
|
||||
) : ReaderAction
|
||||
data class HighlightDeleted(val highlightId: String) : ReaderAction
|
||||
}
|
||||
|
||||
sealed interface AppAction {
|
||||
|
|
@ -33,6 +58,25 @@ sealed interface AppAction {
|
|||
data class NavigationRequested(val event: NavigationEvent) : AppAction
|
||||
data class AppThemeChanged(val mode: AppThemeMode) : AppAction
|
||||
data class AppContrastChanged(val option: AppContrastOption) : AppAction
|
||||
data class AppTextDimFactorLightChanged(val factor: Float) : AppAction
|
||||
data class AppTextDimFactorDarkChanged(val factor: Float) : AppAction
|
||||
data class AppSeedColorChanged(val color: Color?) : AppAction
|
||||
data class CustomAppThemeAdded(val theme: CustomAppTheme) : AppAction
|
||||
data class CustomAppThemeDeleted(val themeId: String) : AppAction
|
||||
data class SyncEnabledChanged(val enabled: Boolean) : AppAction
|
||||
data class FolderSyncEnabledChanged(val enabled: Boolean) : AppAction
|
||||
data class TabsEnabledChanged(val enabled: Boolean) : AppAction
|
||||
data class BookTabOpened(val bookId: String) : AppAction
|
||||
data class BookTabClosed(val bookId: String) : AppAction
|
||||
data object AllTabsClosed : AppAction
|
||||
data class HomePinToggled(val bookId: String) : AppAction
|
||||
data class LibraryPinToggled(val bookId: String) : AppAction
|
||||
data class ReaderToolbarPreferencesChanged(val preferences: ReaderToolbarPreferences) : AppAction
|
||||
data class ReaderToolVisibilityChanged(val tool: ReaderTool, val hidden: Boolean) : AppAction
|
||||
data class ReaderToolPlacementChanged(val tool: ReaderTool, val bottom: Boolean) : AppAction
|
||||
data class ReaderToolOrderChanged(val toolOrder: List<ReaderTool>) : AppAction
|
||||
data class ReaderHighlightPaletteChanged(val palette: ReaderHighlightPalette) : AppAction
|
||||
data class ReaderTtsReplacementPreferencesChanged(
|
||||
val preferences: ReaderTtsReplacementPreferences,
|
||||
) : AppAction
|
||||
}
|
||||
|
|
|
|||
|
|
@ -118,5 +118,8 @@ data class SharedReaderScreenState(
|
|||
val appSeedColor: Color? = null,
|
||||
val customAppThemes: List<CustomAppTheme> = emptyList(),
|
||||
val allTags: List<Tag> = emptyList(),
|
||||
val showTagSelectionDialogFor: Set<String> = emptySet()
|
||||
val showTagSelectionDialogFor: Set<String> = emptySet(),
|
||||
val readerToolbarPreferences: ReaderToolbarPreferences = ReaderToolbarPreferences(),
|
||||
val readerHighlightPalette: ReaderHighlightPalette = ReaderHighlightPalette(),
|
||||
val readerTtsReplacementPreferences: ReaderTtsReplacementPreferences = ReaderTtsReplacementPreferences()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
data class CustomFontItem(
|
||||
val id: String,
|
||||
val displayName: String,
|
||||
val fileName: String,
|
||||
val fileExtension: String,
|
||||
val path: String,
|
||||
val timestamp: Long,
|
||||
val isDeleted: Boolean = false
|
||||
)
|
||||
|
||||
|
|
@ -0,0 +1,180 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
enum class ReaderPlatform {
|
||||
ANDROID,
|
||||
DESKTOP
|
||||
}
|
||||
|
||||
enum class ReaderFeatureSurface {
|
||||
PDF_VIEWER,
|
||||
EPUB_READER,
|
||||
TEXT_READER
|
||||
}
|
||||
|
||||
data class FileTypeCapability(
|
||||
val type: FileType,
|
||||
val displayName: String,
|
||||
val extensions: Set<String>,
|
||||
val androidSurface: ReaderFeatureSurface?,
|
||||
val desktopSurface: ReaderFeatureSurface?,
|
||||
val syncEligible: Boolean = true
|
||||
) {
|
||||
val isReadableOnAndroid: Boolean get() = androidSurface != null
|
||||
val isReadableOnDesktop: Boolean get() = desktopSurface != null
|
||||
|
||||
fun surfaceFor(platform: ReaderPlatform): ReaderFeatureSurface? {
|
||||
return when (platform) {
|
||||
ReaderPlatform.ANDROID -> androidSurface
|
||||
ReaderPlatform.DESKTOP -> desktopSurface
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object SharedFileCapabilities {
|
||||
val all: List<FileTypeCapability> = listOf(
|
||||
FileTypeCapability(
|
||||
type = FileType.EPUB,
|
||||
displayName = "EPUB",
|
||||
extensions = setOf("epub"),
|
||||
androidSurface = ReaderFeatureSurface.EPUB_READER,
|
||||
desktopSurface = ReaderFeatureSurface.EPUB_READER
|
||||
),
|
||||
FileTypeCapability(
|
||||
type = FileType.PDF,
|
||||
displayName = "PDF",
|
||||
extensions = setOf("pdf"),
|
||||
androidSurface = ReaderFeatureSurface.PDF_VIEWER,
|
||||
desktopSurface = ReaderFeatureSurface.PDF_VIEWER
|
||||
),
|
||||
FileTypeCapability(
|
||||
type = FileType.TXT,
|
||||
displayName = "TXT",
|
||||
extensions = setOf("txt"),
|
||||
androidSurface = ReaderFeatureSurface.EPUB_READER,
|
||||
desktopSurface = ReaderFeatureSurface.TEXT_READER
|
||||
),
|
||||
FileTypeCapability(
|
||||
type = FileType.MD,
|
||||
displayName = "Markdown",
|
||||
extensions = setOf("md", "markdown"),
|
||||
androidSurface = ReaderFeatureSurface.EPUB_READER,
|
||||
desktopSurface = ReaderFeatureSurface.TEXT_READER
|
||||
),
|
||||
FileTypeCapability(
|
||||
type = FileType.HTML,
|
||||
displayName = "HTML",
|
||||
extensions = setOf("html", "htm", "xhtml"),
|
||||
androidSurface = ReaderFeatureSurface.EPUB_READER,
|
||||
desktopSurface = ReaderFeatureSurface.TEXT_READER
|
||||
),
|
||||
FileTypeCapability(
|
||||
type = FileType.MOBI,
|
||||
displayName = "MOBI",
|
||||
extensions = setOf("mobi", "azw", "azw3", "prc"),
|
||||
androidSurface = ReaderFeatureSurface.EPUB_READER,
|
||||
desktopSurface = ReaderFeatureSurface.TEXT_READER
|
||||
),
|
||||
FileTypeCapability(
|
||||
type = FileType.FB2,
|
||||
displayName = "FB2",
|
||||
extensions = setOf("fb2"),
|
||||
androidSurface = ReaderFeatureSurface.EPUB_READER,
|
||||
desktopSurface = ReaderFeatureSurface.TEXT_READER
|
||||
),
|
||||
FileTypeCapability(
|
||||
type = FileType.CBZ,
|
||||
displayName = "CBZ",
|
||||
extensions = setOf("cbz"),
|
||||
androidSurface = ReaderFeatureSurface.PDF_VIEWER,
|
||||
desktopSurface = ReaderFeatureSurface.PDF_VIEWER
|
||||
),
|
||||
FileTypeCapability(
|
||||
type = FileType.CBR,
|
||||
displayName = "CBR",
|
||||
extensions = setOf("cbr"),
|
||||
androidSurface = ReaderFeatureSurface.PDF_VIEWER,
|
||||
desktopSurface = ReaderFeatureSurface.PDF_VIEWER
|
||||
),
|
||||
FileTypeCapability(
|
||||
type = FileType.CB7,
|
||||
displayName = "CB7",
|
||||
extensions = setOf("cb7"),
|
||||
androidSurface = ReaderFeatureSurface.PDF_VIEWER,
|
||||
desktopSurface = ReaderFeatureSurface.PDF_VIEWER
|
||||
),
|
||||
FileTypeCapability(
|
||||
type = FileType.DOCX,
|
||||
displayName = "DOCX",
|
||||
extensions = setOf("docx"),
|
||||
androidSurface = ReaderFeatureSurface.EPUB_READER,
|
||||
desktopSurface = ReaderFeatureSurface.TEXT_READER
|
||||
),
|
||||
FileTypeCapability(
|
||||
type = FileType.ODT,
|
||||
displayName = "ODT",
|
||||
extensions = setOf("odt"),
|
||||
androidSurface = ReaderFeatureSurface.EPUB_READER,
|
||||
desktopSurface = ReaderFeatureSurface.TEXT_READER
|
||||
),
|
||||
FileTypeCapability(
|
||||
type = FileType.FODT,
|
||||
displayName = "FODT",
|
||||
extensions = setOf("fodt"),
|
||||
androidSurface = ReaderFeatureSurface.EPUB_READER,
|
||||
desktopSurface = ReaderFeatureSurface.TEXT_READER
|
||||
)
|
||||
)
|
||||
|
||||
private val capabilitiesByType: Map<FileType, FileTypeCapability> = all.associateBy { it.type }
|
||||
private val typesByExtension: Map<String, FileType> = all
|
||||
.flatMap { capability -> capability.extensions.map { it.lowercase() to capability.type } }
|
||||
.toMap()
|
||||
|
||||
fun capabilityFor(type: FileType): FileTypeCapability? {
|
||||
return capabilitiesByType[type]
|
||||
}
|
||||
|
||||
fun displayNameFor(type: FileType): String {
|
||||
return capabilityFor(type)?.displayName ?: type.name
|
||||
}
|
||||
|
||||
fun fileTypeForName(fileName: String): FileType {
|
||||
val extension = fileName.substringAfterLast('.', missingDelimiterValue = "")
|
||||
.substringBefore('?')
|
||||
.substringBefore('#')
|
||||
.lowercase()
|
||||
return typesByExtension[extension] ?: FileType.UNKNOWN
|
||||
}
|
||||
|
||||
fun surfaceFor(type: FileType, platform: ReaderPlatform): ReaderFeatureSurface? {
|
||||
return capabilityFor(type)?.surfaceFor(platform)
|
||||
}
|
||||
|
||||
fun canOpen(type: FileType, platform: ReaderPlatform): Boolean {
|
||||
return surfaceFor(type, platform) != null
|
||||
}
|
||||
|
||||
fun readableTypesFor(platform: ReaderPlatform): Set<FileType> {
|
||||
return all.mapNotNullTo(mutableSetOf()) { capability ->
|
||||
capability.type.takeIf { capability.surfaceFor(platform) != null }
|
||||
}
|
||||
}
|
||||
|
||||
fun syncableTypesFor(platform: ReaderPlatform): Set<FileType> {
|
||||
return all.mapNotNullTo(mutableSetOf()) { capability ->
|
||||
capability.type.takeIf { capability.syncEligible && capability.surfaceFor(platform) != null }
|
||||
}
|
||||
}
|
||||
|
||||
fun supportedFormatsLabel(platform: ReaderPlatform): String {
|
||||
return all
|
||||
.filter { it.surfaceFor(platform) != null }
|
||||
.joinToString(", ") { it.displayName }
|
||||
}
|
||||
|
||||
fun desktopParityGaps(): List<FileType> {
|
||||
return all
|
||||
.filter { it.isReadableOnAndroid && !it.isReadableOnDesktop }
|
||||
.map { it.type }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,8 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
import com.aryan.reader.shared.reader.ReaderBookmark
|
||||
import com.aryan.reader.shared.reader.ReaderSettings
|
||||
|
||||
enum class FileType {
|
||||
PDF, EPUB, MOBI, MD, TXT, HTML, FB2, CBZ, CBR, CB7, DOCX, ODT, FODT, UNKNOWN
|
||||
}
|
||||
|
|
@ -45,6 +48,8 @@ enum class ReadStatusFilter {
|
|||
COMPLETED
|
||||
}
|
||||
|
||||
const val IN_APP_STORAGE_SOURCE = "IN_APP_STORAGE"
|
||||
|
||||
enum class ShelfType {
|
||||
MANUAL,
|
||||
SMART,
|
||||
|
|
@ -72,15 +77,21 @@ data class BookItem(
|
|||
val type: FileType,
|
||||
val displayName: String,
|
||||
val timestamp: Long,
|
||||
val coverImagePath: String? = null,
|
||||
val title: String? = null,
|
||||
val author: String? = null,
|
||||
val progressPercentage: Float? = null,
|
||||
val isRecent: Boolean = true,
|
||||
val fileSize: Long = 0L,
|
||||
val sourceFolder: String? = null,
|
||||
val folderTextMetadataParsed: Boolean = false,
|
||||
val seriesName: String? = null,
|
||||
val seriesIndex: Double? = null,
|
||||
val tags: List<Tag> = emptyList()
|
||||
val tags: List<Tag> = emptyList(),
|
||||
val lastPageIndex: Int? = null,
|
||||
val readerSettings: ReaderSettings? = null,
|
||||
val readerBookmarks: List<ReaderBookmark> = emptyList(),
|
||||
val readerHighlights: List<UserHighlight> = emptyList()
|
||||
)
|
||||
|
||||
data class Shelf(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,293 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
data class SharedLibraryMutationResult(
|
||||
val state: SharedReaderScreenState,
|
||||
val shelfRecords: List<ShelfRecord>,
|
||||
val shelfRefs: List<BookShelfRef>
|
||||
)
|
||||
|
||||
object SharedLibraryEditor {
|
||||
fun cleanShelfName(name: String): String? {
|
||||
return name.trim().takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
fun canMutateShelf(shelfId: String?): Boolean {
|
||||
val trimmed = shelfId?.trim()
|
||||
return !trimmed.isNullOrBlank() && trimmed != "unshelved"
|
||||
}
|
||||
|
||||
fun createShelfRecord(
|
||||
name: String,
|
||||
id: String,
|
||||
isSmart: Boolean = false,
|
||||
smartRulesJson: String? = null
|
||||
): ShelfRecord? {
|
||||
val trimmed = cleanShelfName(name) ?: return null
|
||||
val trimmedId = id.trim().takeIf { it.isNotBlank() } ?: return null
|
||||
return ShelfRecord(
|
||||
id = trimmedId,
|
||||
name = trimmed,
|
||||
isSmart = isSmart,
|
||||
smartRulesJson = smartRulesJson
|
||||
)
|
||||
}
|
||||
|
||||
fun cleanTagName(name: String): String? {
|
||||
return name.trim().takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
fun createTag(
|
||||
name: String,
|
||||
id: String,
|
||||
color: Int? = 0xFF64B5F6.toInt()
|
||||
): Tag? {
|
||||
val trimmed = cleanTagName(name) ?: return null
|
||||
val trimmedId = id.trim().takeIf { it.isNotBlank() } ?: return null
|
||||
return Tag(
|
||||
id = trimmedId,
|
||||
name = trimmed,
|
||||
color = color
|
||||
)
|
||||
}
|
||||
|
||||
fun cleanBookIds(bookIds: Iterable<String>): Set<String> {
|
||||
return bookIds.mapTo(mutableSetOf()) { it.trim() }.filterTo(mutableSetOf()) { it.isNotBlank() }
|
||||
}
|
||||
|
||||
fun removeSelectedBooks(
|
||||
state: SharedReaderScreenState,
|
||||
shelfRecords: List<ShelfRecord>,
|
||||
shelfRefs: List<BookShelfRef>
|
||||
): SharedLibraryMutationResult? {
|
||||
val selected = state.selectedBookIds
|
||||
if (selected.isEmpty()) return null
|
||||
return SharedLibraryMutationResult(
|
||||
state = state.copy(
|
||||
rawLibraryBooks = state.rawLibraryBooks.filterNot { it.id in selected },
|
||||
selectedBookIds = emptySet(),
|
||||
bannerMessage = BannerMessage("Removed ${selected.size} book(s) from the library.")
|
||||
),
|
||||
shelfRecords = shelfRecords,
|
||||
shelfRefs = shelfRefs.filterNot { it.bookId in selected }
|
||||
)
|
||||
}
|
||||
|
||||
fun createShelf(
|
||||
state: SharedReaderScreenState,
|
||||
shelfRecords: List<ShelfRecord>,
|
||||
shelfRefs: List<BookShelfRef>,
|
||||
name: String,
|
||||
nowMillis: Long = currentTimestamp()
|
||||
): SharedLibraryMutationResult? {
|
||||
val trimmed = cleanShelfName(name) ?: return null
|
||||
return SharedLibraryMutationResult(
|
||||
state = state.copy(bannerMessage = BannerMessage("Created shelf \"$trimmed\".")),
|
||||
shelfRecords = shelfRecords + ShelfRecord(id = "shelf_$nowMillis", name = trimmed),
|
||||
shelfRefs = shelfRefs
|
||||
)
|
||||
}
|
||||
|
||||
fun createSmartShelf(
|
||||
state: SharedReaderScreenState,
|
||||
shelfRecords: List<ShelfRecord>,
|
||||
shelfRefs: List<BookShelfRef>,
|
||||
name: String,
|
||||
definition: SmartCollectionDefinition,
|
||||
nowMillis: Long = currentTimestamp()
|
||||
): SharedLibraryMutationResult? {
|
||||
val trimmed = cleanShelfName(name) ?: return null
|
||||
val cleanedRules = definition.rules.mapNotNull { rule ->
|
||||
rule.value.trim().takeIf { it.isNotBlank() }?.let { value -> rule.copy(value = value) }
|
||||
}
|
||||
if (cleanedRules.isEmpty()) return null
|
||||
val cleanedDefinition = definition.copy(rules = cleanedRules)
|
||||
return SharedLibraryMutationResult(
|
||||
state = state.copy(bannerMessage = BannerMessage("Created smart shelf \"$trimmed\".")),
|
||||
shelfRecords = shelfRecords + ShelfRecord(
|
||||
id = "smart_$nowMillis",
|
||||
name = trimmed,
|
||||
isSmart = true,
|
||||
smartRulesJson = SmartCollectionEngine.toJson(cleanedDefinition)
|
||||
),
|
||||
shelfRefs = shelfRefs
|
||||
)
|
||||
}
|
||||
|
||||
fun renameShelf(
|
||||
state: SharedReaderScreenState,
|
||||
shelfRecords: List<ShelfRecord>,
|
||||
shelfRefs: List<BookShelfRef>,
|
||||
shelf: Shelf,
|
||||
name: String
|
||||
): SharedLibraryMutationResult? {
|
||||
val trimmed = cleanShelfName(name) ?: return null
|
||||
return SharedLibraryMutationResult(
|
||||
state = state.copy(bannerMessage = BannerMessage("Renamed shelf to \"$trimmed\".")),
|
||||
shelfRecords = shelfRecords.map { if (it.id == shelf.id) it.copy(name = trimmed) else it },
|
||||
shelfRefs = shelfRefs
|
||||
)
|
||||
}
|
||||
|
||||
fun deleteShelf(
|
||||
state: SharedReaderScreenState,
|
||||
shelfRecords: List<ShelfRecord>,
|
||||
shelfRefs: List<BookShelfRef>,
|
||||
shelf: Shelf
|
||||
): SharedLibraryMutationResult {
|
||||
return SharedLibraryMutationResult(
|
||||
state = state.copy(bannerMessage = BannerMessage("Deleted shelf \"${shelf.name}\".")),
|
||||
shelfRecords = shelfRecords.filterNot { it.id == shelf.id },
|
||||
shelfRefs = shelfRefs.filterNot { it.shelfId == shelf.id }
|
||||
)
|
||||
}
|
||||
|
||||
fun removeFolder(
|
||||
state: SharedReaderScreenState,
|
||||
shelfRecords: List<ShelfRecord>,
|
||||
shelfRefs: List<BookShelfRef>,
|
||||
folder: Shelf
|
||||
): SharedLibraryMutationResult? {
|
||||
if (folder.type != ShelfType.FOLDER) return null
|
||||
val folderBookIds = cleanBookIds(folder.books.map { it.id })
|
||||
if (folderBookIds.isEmpty()) return null
|
||||
val rootSourceFolder = folder.books.firstNotNullOfOrNull { it.sourceFolder }
|
||||
val remainingTabs = state.openTabIds.filterNot { it in folderBookIds }
|
||||
return SharedLibraryMutationResult(
|
||||
state = state.copy(
|
||||
rawLibraryBooks = state.rawLibraryBooks.filterNot { it.id in folderBookIds },
|
||||
selectedBookIds = state.selectedBookIds - folderBookIds,
|
||||
pinnedHomeBookIds = state.pinnedHomeBookIds - folderBookIds,
|
||||
pinnedLibraryBookIds = state.pinnedLibraryBookIds - folderBookIds,
|
||||
openTabIds = remainingTabs,
|
||||
activeTabBookId = state.activeTabBookId?.takeUnless { it in folderBookIds },
|
||||
syncedFolders = if (folder.parentShelfId == null && rootSourceFolder != null) {
|
||||
state.syncedFolders.filterNot { it.uriString == rootSourceFolder }
|
||||
} else {
|
||||
state.syncedFolders
|
||||
},
|
||||
libraryFilters = if (rootSourceFolder != null) {
|
||||
state.libraryFilters.copy(sourceFolders = state.libraryFilters.sourceFolders - rootSourceFolder)
|
||||
} else {
|
||||
state.libraryFilters
|
||||
},
|
||||
bannerMessage = BannerMessage("Removed folder \"${folder.name}\" and ${folderBookIds.size} book(s) from the app.")
|
||||
),
|
||||
shelfRecords = shelfRecords,
|
||||
shelfRefs = shelfRefs.filterNot { it.bookId in folderBookIds }
|
||||
)
|
||||
}
|
||||
|
||||
fun markBookOpened(
|
||||
state: SharedReaderScreenState,
|
||||
bookId: String,
|
||||
nowMillis: Long = currentTimestamp()
|
||||
): SharedReaderScreenState {
|
||||
val cleanedBookId = bookId.trim()
|
||||
if (cleanedBookId.isBlank()) return state
|
||||
return state.copy(
|
||||
rawLibraryBooks = state.rawLibraryBooks.map { book ->
|
||||
if (book.id == cleanedBookId) {
|
||||
book.copy(isRecent = true, timestamp = nowMillis)
|
||||
} else {
|
||||
book
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun addSelectedBooksToShelf(
|
||||
state: SharedReaderScreenState,
|
||||
shelfRecords: List<ShelfRecord>,
|
||||
shelfRefs: List<BookShelfRef>,
|
||||
shelfId: String,
|
||||
nowMillis: Long = currentTimestamp()
|
||||
): SharedLibraryMutationResult? {
|
||||
val selected = state.selectedBookIds
|
||||
if (selected.isEmpty()) return null
|
||||
val existing = shelfRefs.mapTo(mutableSetOf()) { it.bookId to it.shelfId }
|
||||
val additions = selected.mapNotNull { bookId ->
|
||||
if (!existing.add(bookId to shelfId)) null else BookShelfRef(bookId = bookId, shelfId = shelfId, addedAt = nowMillis)
|
||||
}
|
||||
return SharedLibraryMutationResult(
|
||||
state = state.copy(
|
||||
selectedBookIds = emptySet(),
|
||||
bannerMessage = BannerMessage("Added ${additions.size} book(s) to shelf.")
|
||||
),
|
||||
shelfRecords = shelfRecords,
|
||||
shelfRefs = shelfRefs + additions
|
||||
)
|
||||
}
|
||||
|
||||
fun tagSelectedBooks(
|
||||
state: SharedReaderScreenState,
|
||||
shelfRecords: List<ShelfRecord>,
|
||||
shelfRefs: List<BookShelfRef>,
|
||||
tagName: String,
|
||||
nowMillis: Long = currentTimestamp()
|
||||
): SharedLibraryMutationResult? {
|
||||
val selected = cleanBookIds(state.selectedBookIds)
|
||||
val trimmed = cleanTagName(tagName) ?: return null
|
||||
if (selected.isEmpty()) return null
|
||||
val existingTag = state.allTags.firstOrNull { it.name.equals(trimmed, ignoreCase = true) }
|
||||
val tag = existingTag ?: Tag(
|
||||
id = trimmed.toStableTagId("tag_$nowMillis"),
|
||||
name = trimmed,
|
||||
color = 0xFF64B5F6.toInt()
|
||||
)
|
||||
val allTags = (state.allTags + tag).distinctBy { it.id }.sortedBy { it.name.lowercase() }
|
||||
val books = state.rawLibraryBooks.map { book ->
|
||||
if (book.id in selected && book.tags.none { it.id == tag.id }) {
|
||||
book.copy(tags = (book.tags + tag).sortedBy { it.name.lowercase() })
|
||||
} else {
|
||||
book
|
||||
}
|
||||
}
|
||||
return SharedLibraryMutationResult(
|
||||
state = state.copy(
|
||||
rawLibraryBooks = books,
|
||||
allTags = allTags,
|
||||
selectedBookIds = emptySet(),
|
||||
bannerMessage = BannerMessage("Tagged ${selected.size} book(s) with \"${tag.name}\".")
|
||||
),
|
||||
shelfRecords = shelfRecords,
|
||||
shelfRefs = shelfRefs
|
||||
)
|
||||
}
|
||||
|
||||
fun updateBookMetadata(
|
||||
state: SharedReaderScreenState,
|
||||
shelfRecords: List<ShelfRecord>,
|
||||
shelfRefs: List<BookShelfRef>,
|
||||
updated: BookItem,
|
||||
nowMillis: Long = currentTimestamp()
|
||||
): SharedLibraryMutationResult {
|
||||
return SharedLibraryMutationResult(
|
||||
state = state.copy(
|
||||
rawLibraryBooks = state.rawLibraryBooks.map { if (it.id == updated.id) updated.copy(timestamp = nowMillis) else it },
|
||||
allTags = (state.allTags + updated.tags).distinctBy { it.id }.sortedBy { it.name.lowercase() },
|
||||
bannerMessage = BannerMessage("Updated \"${updated.cardTitle()}\".")
|
||||
),
|
||||
shelfRecords = shelfRecords,
|
||||
shelfRefs = shelfRefs
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun parseTagList(input: String, knownTags: List<Tag>, nowMillis: Long = currentTimestamp()): List<Tag> {
|
||||
return input.split(',')
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotBlank() }
|
||||
.distinctBy { it.lowercase() }
|
||||
.mapIndexed { index, name ->
|
||||
knownTags.firstOrNull { it.name.equals(name, ignoreCase = true) }
|
||||
?: Tag(
|
||||
id = name.toStableTagId("tag_${nowMillis + index}"),
|
||||
name = name,
|
||||
color = 0xFF64B5F6.toInt()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.toStableTagId(fallback: String): String {
|
||||
return lowercase().replace(Regex("[^a-z0-9]+"), "_").trim('_').ifBlank { fallback }
|
||||
}
|
||||
|
|
@ -43,7 +43,8 @@ class LibraryProjector {
|
|||
timestamp = now + index,
|
||||
title = file.name.substringBeforeLast('.'),
|
||||
fileSize = file.size,
|
||||
sourceFolder = file.path?.parentPath()
|
||||
sourceFolder = file.sourceFolder ?: file.path?.parentPath(),
|
||||
isRecent = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -57,7 +58,7 @@ class LibraryProjector {
|
|||
return when (sortOrder) {
|
||||
SortOrder.RECENT -> books.sortedByDescending { it.timestamp }
|
||||
SortOrder.TITLE_ASC -> books.sortedBy { it.title?.lowercase() ?: it.displayName.lowercase() }
|
||||
SortOrder.AUTHOR_ASC -> books.sortedBy { it.author?.lowercase() ?: "" }
|
||||
SortOrder.AUTHOR_ASC -> books.sortedWith(compareBy(nullsLast()) { it.author?.lowercase() })
|
||||
SortOrder.PERCENT_ASC -> books.sortedBy { it.progressPercentage ?: 0f }
|
||||
SortOrder.PERCENT_DESC -> books.sortedByDescending { it.progressPercentage ?: 0f }
|
||||
SortOrder.SIZE_ASC -> books.sortedBy { it.fileSize }
|
||||
|
|
@ -79,7 +80,7 @@ class LibraryProjector {
|
|||
fun applyFilters(books: List<BookItem>, filters: LibraryFilters): List<BookItem> {
|
||||
return books.filter { book ->
|
||||
val matchesType = filters.fileTypes.isEmpty() || book.type in filters.fileTypes
|
||||
val matchesFolder = filters.sourceFolders.isEmpty() || book.sourceFolder in filters.sourceFolders
|
||||
val matchesFolder = book.matchesSourceFolders(filters.sourceFolders)
|
||||
val progress = book.progressPercentage ?: 0f
|
||||
val matchesStatus = when (filters.readStatus) {
|
||||
ReadStatusFilter.ALL -> true
|
||||
|
|
@ -148,26 +149,12 @@ private fun String.folderDisplayName(): String {
|
|||
data class ImportedFile(
|
||||
val name: String,
|
||||
val path: String?,
|
||||
val size: Long
|
||||
val size: Long,
|
||||
val sourceFolder: String? = null
|
||||
)
|
||||
|
||||
expect fun currentTimestamp(): Long
|
||||
|
||||
fun String.toFileType(): FileType {
|
||||
return when (substringAfterLast('.', "").lowercase()) {
|
||||
"pdf" -> FileType.PDF
|
||||
"epub" -> FileType.EPUB
|
||||
"mobi" -> FileType.MOBI
|
||||
"md" -> FileType.MD
|
||||
"txt" -> FileType.TXT
|
||||
"html", "htm" -> FileType.HTML
|
||||
"fb2" -> FileType.FB2
|
||||
"cbz" -> FileType.CBZ
|
||||
"cbr" -> FileType.CBR
|
||||
"cb7" -> FileType.CB7
|
||||
"docx" -> FileType.DOCX
|
||||
"odt" -> FileType.ODT
|
||||
"fodt" -> FileType.FODT
|
||||
else -> FileType.UNKNOWN
|
||||
}
|
||||
return SharedFileCapabilities.fileTypeForName(this)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,11 +38,16 @@ class SharedLibraryStateProjector(
|
|||
val queried = filterBySearch(allLibraryBooks, current.searchQuery)
|
||||
val filtered = applyLibraryFilters(queried, current.libraryFilters)
|
||||
val sortedLibraryBooks = sortBooks(filtered, current.sortOrder)
|
||||
.withPinnedFirst(current.pinnedLibraryBookIds)
|
||||
val visibleRecentBooks = sortBooks(
|
||||
allLibraryBooks.filter { it.isRecent },
|
||||
current.sortOrder
|
||||
).take(if (current.recentFilesLimit > 0) current.recentFilesLimit else Int.MAX_VALUE)
|
||||
)
|
||||
.withPinnedFirst(current.pinnedHomeBookIds)
|
||||
.take(if (current.recentFilesLimit > 0) current.recentFilesLimit else Int.MAX_VALUE)
|
||||
val openTabs = current.openTabIds.mapNotNull { tabId -> allLibraryBooks.find { it.id == tabId } }
|
||||
val openTabIds = openTabs.map { it.id }
|
||||
val activeTabBookId = current.activeTabBookId?.takeIf { it in openTabIds }
|
||||
val shelfProjection = buildShelves(
|
||||
allLibraryBooks = allLibraryBooks,
|
||||
shelfRecords = input.shelfRecords,
|
||||
|
|
@ -81,6 +86,8 @@ class SharedLibraryStateProjector(
|
|||
},
|
||||
shelves = shelfProjection.shelves,
|
||||
openTabs = openTabs,
|
||||
openTabIds = openTabIds,
|
||||
activeTabBookId = activeTabBookId,
|
||||
booksAvailableForAdding = booksAvailableForAdding,
|
||||
allTags = input.tags
|
||||
)
|
||||
|
|
@ -99,13 +106,22 @@ class SharedLibraryStateProjector(
|
|||
val booksById = allLibraryBooks.associateBy { it.id }
|
||||
|
||||
shelfRecords.forEach { shelf ->
|
||||
val bookIds = shelfRefs
|
||||
.filter { it.shelfId == shelf.id }
|
||||
.sortedBy { it.addedAt }
|
||||
.map { it.bookId }
|
||||
val books = bookIds.mapNotNull { booksById[it] }
|
||||
shelves.add(Shelf(shelf.id, shelf.name, ShelfType.MANUAL, sortBooks(books, sortOrder)))
|
||||
shelvedBookIds.addAll(bookIds)
|
||||
if (shelf.isSmart && shelf.smartRulesJson != null) {
|
||||
val definition = SmartCollectionEngine.fromJson(shelf.smartRulesJson)
|
||||
if (definition != null) {
|
||||
val matchingBooks = allLibraryBooks.filter { SmartCollectionEngine.evaluate(it, definition) }
|
||||
shelves.add(Shelf(shelf.id, shelf.name, ShelfType.SMART, sortBooks(matchingBooks, sortOrder)))
|
||||
shelvedBookIds.addAll(matchingBooks.map { it.id })
|
||||
}
|
||||
} else {
|
||||
val bookIds = shelfRefs
|
||||
.filter { it.shelfId == shelf.id }
|
||||
.sortedBy { it.addedAt }
|
||||
.map { it.bookId }
|
||||
val books = bookIds.mapNotNull { booksById[it] }
|
||||
shelves.add(Shelf(shelf.id, shelf.name, ShelfType.MANUAL, sortBooks(books, sortOrder)))
|
||||
shelvedBookIds.addAll(bookIds)
|
||||
}
|
||||
}
|
||||
|
||||
val tagShelves = tags.mapNotNull { tag ->
|
||||
|
|
@ -257,7 +273,7 @@ fun filterBySearch(books: List<BookItem>, searchQuery: String): List<BookItem> {
|
|||
fun applyLibraryFilters(books: List<BookItem>, filters: LibraryFilters): List<BookItem> {
|
||||
return books.filter { book ->
|
||||
val matchType = filters.fileTypes.isEmpty() || book.type in filters.fileTypes
|
||||
val matchFolder = filters.sourceFolders.isEmpty() || book.sourceFolder in filters.sourceFolders
|
||||
val matchFolder = book.matchesSourceFolders(filters.sourceFolders)
|
||||
val progress = book.progressPercentage ?: 0f
|
||||
val matchStatus = when (filters.readStatus) {
|
||||
ReadStatusFilter.ALL -> true
|
||||
|
|
@ -274,7 +290,7 @@ fun sortBooks(books: List<BookItem>, sortOrder: SortOrder): List<BookItem> {
|
|||
return when (sortOrder) {
|
||||
SortOrder.RECENT -> books.sortedByDescending { it.timestamp }
|
||||
SortOrder.TITLE_ASC -> books.sortedBy { it.title?.lowercase() ?: it.displayName.lowercase() }
|
||||
SortOrder.AUTHOR_ASC -> books.sortedBy { it.author?.lowercase() ?: "" }
|
||||
SortOrder.AUTHOR_ASC -> books.sortedWith(compareBy(nullsLast()) { it.author?.lowercase() })
|
||||
SortOrder.PERCENT_ASC -> books.sortedBy { it.progressPercentage ?: 0f }
|
||||
SortOrder.PERCENT_DESC -> books.sortedByDescending { it.progressPercentage ?: 0f }
|
||||
SortOrder.SIZE_ASC -> books.sortedBy { it.fileSize }
|
||||
|
|
@ -301,7 +317,8 @@ fun SharedReaderScreenState.withImportedFiles(
|
|||
timestamp = now + index,
|
||||
title = file.name.substringBeforeLast('.'),
|
||||
fileSize = file.size,
|
||||
sourceFolder = file.localPath?.parentPath()
|
||||
sourceFolder = file.sourceFolder ?: file.localPath?.parentPath(),
|
||||
isRecent = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -322,3 +339,13 @@ private fun String.parentPath(): String? {
|
|||
val parent = normalized.substringBeforeLast('/', missingDelimiterValue = "")
|
||||
return parent.ifBlank { null }
|
||||
}
|
||||
|
||||
private fun List<BookItem>.withPinnedFirst(pinnedBookIds: Set<String>): List<BookItem> {
|
||||
if (pinnedBookIds.isEmpty()) return this
|
||||
return withIndex()
|
||||
.sortedWith(
|
||||
compareByDescending<IndexedValue<BookItem>> { it.value.id in pinnedBookIds }
|
||||
.thenBy { it.index }
|
||||
)
|
||||
.map { it.value }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,507 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
import com.aryan.reader.shared.reader.ReaderBookmark
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.doubleOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
|
||||
const val LOCAL_FOLDER_SYNC_DATA_DIR = "EpistemeSyncData"
|
||||
const val LOCAL_FOLDER_ANNOTATION_SUFFIX = "_annotations"
|
||||
|
||||
internal expect fun localFolderSyncSha256ShortHex(value: String): String
|
||||
|
||||
data class SharedFolderBookMetadata(
|
||||
val bookId: String,
|
||||
val title: String?,
|
||||
val author: String?,
|
||||
val displayName: String,
|
||||
val type: String,
|
||||
val lastChapterIndex: Int?,
|
||||
val lastPage: Int?,
|
||||
val lastPositionCfi: String?,
|
||||
val progressPercentage: Float,
|
||||
val isRecent: Boolean,
|
||||
val lastModifiedTimestamp: Long,
|
||||
val bookmarksJson: String?,
|
||||
val locatorBlockIndex: Int?,
|
||||
val locatorCharOffset: Int?,
|
||||
val customName: String?,
|
||||
val highlightsJson: String?
|
||||
) {
|
||||
fun toJsonString(): String {
|
||||
return folderSyncJson.encodeToString(
|
||||
JsonElement.serializer(),
|
||||
JsonObject(
|
||||
mapOf(
|
||||
"bookId" to JsonPrimitive(bookId),
|
||||
"title" to title.asJson(),
|
||||
"author" to author.asJson(),
|
||||
"displayName" to JsonPrimitive(displayName),
|
||||
"type" to JsonPrimitive(type),
|
||||
"lastChapterIndex" to JsonPrimitive(lastChapterIndex ?: -1),
|
||||
"lastPage" to JsonPrimitive(lastPage ?: -1),
|
||||
"lastPositionCfi" to lastPositionCfi.asJson(),
|
||||
"progressPercentage" to JsonPrimitive(progressPercentage.toDouble()),
|
||||
"isRecent" to JsonPrimitive(isRecent),
|
||||
"lastModifiedTimestamp" to JsonPrimitive(lastModifiedTimestamp),
|
||||
"bookmarksJson" to bookmarksJson.asJson(),
|
||||
"locatorBlockIndex" to JsonPrimitive(locatorBlockIndex ?: -1),
|
||||
"locatorCharOffset" to JsonPrimitive(locatorCharOffset ?: -1),
|
||||
"customName" to customName.asJson(),
|
||||
"highlightsJson" to highlightsJson.asJson()
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun toBookItem(
|
||||
file: SharedFolderScannedFile,
|
||||
existing: BookItem? = null,
|
||||
nowMillis: Long = currentTimestamp()
|
||||
): BookItem {
|
||||
val parsedHighlights = highlightsJson
|
||||
?.let(EpubAnnotationSerializer::parseHighlightsJson)
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
val parsedBookmarks = parseReaderBookmarks(bookId)
|
||||
.takeIf { it.isNotEmpty() }
|
||||
val parsedType = runCatching { FileType.valueOf(type) }.getOrNull() ?: file.type
|
||||
val metadataTimestamp = lastModifiedTimestamp.takeIf { it > 0L } ?: nowMillis
|
||||
|
||||
return (existing ?: BookItem(
|
||||
id = bookId,
|
||||
path = file.path,
|
||||
type = parsedType,
|
||||
displayName = displayName.ifBlank { file.name },
|
||||
timestamp = metadataTimestamp,
|
||||
title = title ?: displayName.ifBlank { file.name },
|
||||
author = author,
|
||||
fileSize = file.size,
|
||||
sourceFolder = file.sourceFolder,
|
||||
isRecent = isRecent
|
||||
)).copy(
|
||||
id = bookId,
|
||||
path = file.path,
|
||||
type = parsedType,
|
||||
displayName = displayName.ifBlank { file.name },
|
||||
timestamp = if (isRecent || existing == null) metadataTimestamp else existing.timestamp,
|
||||
coverImagePath = existing?.coverImagePath,
|
||||
title = title ?: existing?.title ?: displayName.ifBlank { file.name },
|
||||
author = author ?: existing?.author,
|
||||
progressPercentage = progressPercentage,
|
||||
isRecent = isRecent || (existing?.isRecent ?: false),
|
||||
fileSize = file.size.takeIf { it > 0L } ?: existing?.fileSize ?: 0L,
|
||||
sourceFolder = file.sourceFolder,
|
||||
folderTextMetadataParsed = existing?.folderTextMetadataParsed ?: false,
|
||||
lastPageIndex = lastPage,
|
||||
readerBookmarks = parsedBookmarks ?: existing?.readerBookmarks.orEmpty(),
|
||||
readerHighlights = parsedHighlights ?: existing?.readerHighlights.orEmpty()
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseReaderBookmarks(bookId: String): List<ReaderBookmark> {
|
||||
return EpubAnnotationSerializer.parseBookmarksJson(bookmarksJson)
|
||||
.mapIndexed { index, bookmark ->
|
||||
val locator = bookmark.locator.withFallbacks(
|
||||
chapterIndex = bookmark.chapterIndex,
|
||||
cfi = bookmark.cfi,
|
||||
pageIndex = bookmark.pageInChapter?.minus(1),
|
||||
textQuote = bookmark.snippet
|
||||
)
|
||||
val pageIndex = locator.pageIndex ?: bookmark.pageInChapter?.minus(1) ?: 0
|
||||
ReaderBookmark(
|
||||
id = "bookmark_${localFolderSyncSha256ShortHex("$bookId:$index:${bookmark.cfi}")}",
|
||||
pageIndex = pageIndex.coerceAtLeast(0),
|
||||
chapterTitle = bookmark.chapterTitle,
|
||||
preview = bookmark.snippet,
|
||||
locator = locator
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromJsonString(rawJson: String): SharedFolderBookMetadata? {
|
||||
val obj = runCatching { folderSyncJson.parseToJsonElement(rawJson).jsonObject }.getOrNull()
|
||||
?: return null
|
||||
val bookId = obj.string("bookId")?.takeIf { it.isNotBlank() } ?: return null
|
||||
return SharedFolderBookMetadata(
|
||||
bookId = bookId,
|
||||
title = obj.string("title"),
|
||||
author = obj.string("author"),
|
||||
displayName = obj.string("displayName") ?: "Unknown",
|
||||
type = obj.string("type") ?: FileType.PDF.name,
|
||||
lastChapterIndex = obj.sentinelInt("lastChapterIndex"),
|
||||
lastPage = obj.sentinelInt("lastPage"),
|
||||
lastPositionCfi = obj.string("lastPositionCfi"),
|
||||
progressPercentage = obj.double("progressPercentage")?.toFloat() ?: 0f,
|
||||
isRecent = obj.boolean("isRecent") ?: true,
|
||||
lastModifiedTimestamp = obj.long("lastModifiedTimestamp") ?: 0L,
|
||||
bookmarksJson = obj.string("bookmarksJson"),
|
||||
locatorBlockIndex = obj.sentinelInt("locatorBlockIndex"),
|
||||
locatorCharOffset = obj.sentinelInt("locatorCharOffset"),
|
||||
customName = obj.string("customName"),
|
||||
highlightsJson = obj.string("highlightsJson")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class SharedFolderScannedFile(
|
||||
val name: String,
|
||||
val path: String,
|
||||
val sourceFolder: String,
|
||||
val relativePath: String,
|
||||
val type: FileType,
|
||||
val size: Long,
|
||||
val lastModified: Long
|
||||
) {
|
||||
val stableBookId: String
|
||||
get() = LocalFolderSyncEngine.buildStableBookId(name, relativePath)
|
||||
}
|
||||
|
||||
data class LocalFolderSyncStats(
|
||||
val scannedFiles: Int = 0,
|
||||
val supportedFiles: Int = 0,
|
||||
val newBooks: Int = 0,
|
||||
val updatedBooks: Int = 0,
|
||||
val unchangedBooks: Int = 0,
|
||||
val removedBooks: Int = 0,
|
||||
val migratedBooks: Int = 0,
|
||||
val remoteMetadataUpdates: Int = 0
|
||||
) {
|
||||
operator fun plus(other: LocalFolderSyncStats): LocalFolderSyncStats {
|
||||
return LocalFolderSyncStats(
|
||||
scannedFiles = scannedFiles + other.scannedFiles,
|
||||
supportedFiles = supportedFiles + other.supportedFiles,
|
||||
newBooks = newBooks + other.newBooks,
|
||||
updatedBooks = updatedBooks + other.updatedBooks,
|
||||
unchangedBooks = unchangedBooks + other.unchangedBooks,
|
||||
removedBooks = removedBooks + other.removedBooks,
|
||||
migratedBooks = migratedBooks + other.migratedBooks,
|
||||
remoteMetadataUpdates = remoteMetadataUpdates + other.remoteMetadataUpdates
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class LocalFolderSyncResult(
|
||||
val state: SharedReaderScreenState,
|
||||
val idMigrations: Map<String, String>,
|
||||
val removedBookIds: Set<String>,
|
||||
val stats: LocalFolderSyncStats
|
||||
)
|
||||
|
||||
object LocalFolderSyncEngine {
|
||||
fun buildStableBookId(name: String, relativePath: String): String {
|
||||
val normalizedRelativePath = relativePath.toSyncRelativePath().ifBlank { name }
|
||||
return if (normalizedRelativePath.equals(name, ignoreCase = true)) {
|
||||
"local_$name"
|
||||
} else {
|
||||
"local_${name}_${localFolderSyncSha256ShortHex(normalizedRelativePath.lowercase())}"
|
||||
}
|
||||
}
|
||||
|
||||
fun syncFolder(
|
||||
state: SharedReaderScreenState,
|
||||
folder: SyncedFolder,
|
||||
files: List<SharedFolderScannedFile>,
|
||||
remoteMetadata: Map<String, SharedFolderBookMetadata>,
|
||||
nowMillis: Long = currentTimestamp(),
|
||||
metadataOnly: Boolean = false
|
||||
): LocalFolderSyncResult {
|
||||
val folderRoot = folder.uriString
|
||||
val allowedTypes = folder.allowedFileTypes
|
||||
val booksById = linkedMapOf<String, BookItem>()
|
||||
state.rawLibraryBooks.forEach { booksById[it.id] = it }
|
||||
val idMigrations = linkedMapOf<String, String>()
|
||||
var stats = LocalFolderSyncStats(
|
||||
scannedFiles = files.size,
|
||||
supportedFiles = files.count { it.type in allowedTypes }
|
||||
)
|
||||
var removedIds = emptySet<String>()
|
||||
|
||||
val existingFolderBookIds = booksById.values
|
||||
.filter { it.sourceFolder == folderRoot }
|
||||
.mapTo(linkedSetOf()) { it.id }
|
||||
|
||||
remoteMetadata.forEach { (bookId, metadata) ->
|
||||
val existing = booksById[bookId]?.takeIf { it.sourceFolder == folderRoot }
|
||||
if (existing != null && metadata.lastModifiedTimestamp > existing.localFolderModifiedTimestamp()) {
|
||||
booksById[bookId] = existing.withAppliedFolderMetadata(metadata, nowMillis)
|
||||
stats = stats.copy(remoteMetadataUpdates = stats.remoteMetadataUpdates + 1)
|
||||
}
|
||||
}
|
||||
|
||||
if (!metadataOnly) {
|
||||
val foundBookIds = linkedSetOf<String>()
|
||||
val folderBooksByPath = booksById.values
|
||||
.filter { it.sourceFolder == folderRoot && !it.path.isNullOrBlank() }
|
||||
.associateBy { it.path.orEmpty() }
|
||||
.toMutableMap()
|
||||
val legacyItemsByName = booksById.values
|
||||
.asSequence()
|
||||
.filter { it.sourceFolder == folderRoot }
|
||||
.filter { it.id.startsWith("local_${it.displayName}_") || it.id == it.path }
|
||||
.groupBy { it.displayName }
|
||||
.mapValues { (_, books) -> ArrayDeque<BookItem>().apply { addAll(books) } }
|
||||
.toMutableMap()
|
||||
|
||||
files
|
||||
.asSequence()
|
||||
.filter { it.type in allowedTypes }
|
||||
.sortedBy { it.relativePath.lowercase() }
|
||||
.forEach { file ->
|
||||
val stableId = file.stableBookId
|
||||
foundBookIds += stableId
|
||||
var existing = booksById[stableId]?.takeIf { it.sourceFolder == folderRoot }
|
||||
|
||||
if (existing == null) {
|
||||
val migrated = folderBooksByPath[file.path]?.takeIf { it.id != stableId }
|
||||
?: legacyItemsByName[file.name]?.firstOrNull { it.id != stableId }
|
||||
if (migrated != null) {
|
||||
val oldId = migrated.id
|
||||
val migratedBook = migrated.copy(id = stableId).withScannedFile(file)
|
||||
booksById.remove(oldId)
|
||||
booksById[stableId] = migratedBook
|
||||
idMigrations[oldId] = stableId
|
||||
legacyItemsByName[file.name]?.remove(migrated)
|
||||
existing = migratedBook
|
||||
stats = stats.copy(migratedBooks = stats.migratedBooks + 1)
|
||||
}
|
||||
}
|
||||
|
||||
val metadata = remoteMetadata[stableId]
|
||||
if (existing == null) {
|
||||
booksById[stableId] = metadata?.toBookItem(file, nowMillis = nowMillis)
|
||||
?: file.toBookItem(stableId, nowMillis)
|
||||
stats = stats.copy(newBooks = stats.newBooks + 1)
|
||||
} else {
|
||||
val updatedForFile = existing.withScannedFile(file)
|
||||
val updated = metadata
|
||||
?.takeIf { it.lastModifiedTimestamp > updatedForFile.localFolderModifiedTimestamp() }
|
||||
?.toBookItem(file = file, existing = updatedForFile, nowMillis = nowMillis)
|
||||
?: updatedForFile
|
||||
booksById[stableId] = updated
|
||||
if (updated != existing) {
|
||||
stats = stats.copy(updatedBooks = stats.updatedBooks + 1)
|
||||
} else {
|
||||
stats = stats.copy(unchangedBooks = stats.unchangedBooks + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removedIds = existingFolderBookIds
|
||||
.map { idMigrations[it] ?: it }
|
||||
.filter { it !in foundBookIds }
|
||||
.toSet()
|
||||
removedIds.forEach(booksById::remove)
|
||||
stats = stats.copy(removedBooks = removedIds.size)
|
||||
}
|
||||
|
||||
val syncedFolder = folder.copy(lastScanTime = nowMillis)
|
||||
val syncedFolders = (state.syncedFolders.filterNot { it.uriString == folderRoot } + syncedFolder)
|
||||
.sortedBy { it.name.lowercase() }
|
||||
val migratedState = state
|
||||
.withMigratedBookIds(idMigrations)
|
||||
val nextState = migratedState
|
||||
.withoutBookIds(removedIds)
|
||||
.copy(
|
||||
rawLibraryBooks = booksById.values.toList(),
|
||||
syncedFolders = syncedFolders,
|
||||
lastFolderScanTime = nowMillis
|
||||
)
|
||||
|
||||
return LocalFolderSyncResult(
|
||||
state = nextState,
|
||||
idMigrations = idMigrations,
|
||||
removedBookIds = removedIds,
|
||||
stats = stats
|
||||
)
|
||||
}
|
||||
|
||||
fun applyIdMigrationsToShelfRefs(
|
||||
shelfRefs: List<BookShelfRef>,
|
||||
migrations: Map<String, String>
|
||||
): List<BookShelfRef> {
|
||||
if (migrations.isEmpty()) return shelfRefs
|
||||
return shelfRefs.map { ref ->
|
||||
migrations[ref.bookId]?.let { ref.copy(bookId = it) } ?: ref
|
||||
}.distinctBy { it.bookId to it.shelfId }
|
||||
}
|
||||
}
|
||||
|
||||
fun BookItem.toSharedFolderBookMetadata(): SharedFolderBookMetadata? {
|
||||
if (sourceFolder.isNullOrBlank()) return null
|
||||
|
||||
val bookmarksJson = readerBookmarks
|
||||
.mapNotNull { it.toEpubBookmarkOrNull() }
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.let(EpubAnnotationSerializer::bookmarksToJson)
|
||||
val highlightsJson = readerHighlights
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.let(EpubAnnotationSerializer::highlightsToJson)
|
||||
val hasProgress = (progressPercentage ?: 0f) > 0f || lastPageIndex != null
|
||||
val isDirty = isRecent || hasProgress || !bookmarksJson.isNullOrBlank() || !highlightsJson.isNullOrBlank()
|
||||
if (!isDirty) return null
|
||||
|
||||
return SharedFolderBookMetadata(
|
||||
bookId = id,
|
||||
title = title,
|
||||
author = author,
|
||||
displayName = displayName,
|
||||
type = type.name,
|
||||
lastChapterIndex = null,
|
||||
lastPage = lastPageIndex,
|
||||
lastPositionCfi = null,
|
||||
progressPercentage = progressPercentage ?: 0f,
|
||||
isRecent = isRecent,
|
||||
lastModifiedTimestamp = localFolderModifiedTimestamp(),
|
||||
bookmarksJson = bookmarksJson,
|
||||
locatorBlockIndex = null,
|
||||
locatorCharOffset = null,
|
||||
customName = null,
|
||||
highlightsJson = highlightsJson
|
||||
)
|
||||
}
|
||||
|
||||
private val folderSyncJson = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
private fun BookItem.withAppliedFolderMetadata(
|
||||
metadata: SharedFolderBookMetadata,
|
||||
nowMillis: Long
|
||||
): BookItem {
|
||||
val file = SharedFolderScannedFile(
|
||||
name = displayName,
|
||||
path = path.orEmpty(),
|
||||
sourceFolder = sourceFolder.orEmpty(),
|
||||
relativePath = displayName,
|
||||
type = runCatching { FileType.valueOf(metadata.type) }.getOrNull() ?: type,
|
||||
size = fileSize,
|
||||
lastModified = 0L
|
||||
)
|
||||
return metadata.toBookItem(file = file, existing = this, nowMillis = nowMillis)
|
||||
}
|
||||
|
||||
private fun SharedFolderScannedFile.toBookItem(bookId: String, nowMillis: Long): BookItem {
|
||||
return BookItem(
|
||||
id = bookId,
|
||||
path = path,
|
||||
type = type,
|
||||
displayName = name,
|
||||
timestamp = nowMillis,
|
||||
title = name.substringBeforeLast('.', missingDelimiterValue = name),
|
||||
fileSize = size,
|
||||
sourceFolder = sourceFolder,
|
||||
isRecent = false
|
||||
)
|
||||
}
|
||||
|
||||
private fun BookItem.withScannedFile(file: SharedFolderScannedFile): BookItem {
|
||||
val sizeChanged = fileSize > 0L && file.size > 0L && fileSize != file.size
|
||||
return copy(
|
||||
path = file.path,
|
||||
type = file.type,
|
||||
displayName = file.name,
|
||||
coverImagePath = if (sizeChanged) null else coverImagePath,
|
||||
fileSize = file.size.takeIf { it > 0L } ?: fileSize,
|
||||
sourceFolder = file.sourceFolder,
|
||||
folderTextMetadataParsed = if (sizeChanged) false else folderTextMetadataParsed
|
||||
)
|
||||
}
|
||||
|
||||
private fun BookItem.localFolderModifiedTimestamp(): Long {
|
||||
return timestamp
|
||||
}
|
||||
|
||||
private fun ReaderBookmark.toEpubBookmarkOrNull(): EpubBookmark? {
|
||||
val chapterIndex = locator.chapterIndex ?: 0
|
||||
val cfi = locator.cfi ?: "desktop:$chapterIndex:$pageIndex"
|
||||
return EpubBookmark(
|
||||
cfi = cfi,
|
||||
chapterTitle = chapterTitle,
|
||||
label = null,
|
||||
snippet = preview,
|
||||
pageInChapter = pageIndex + 1,
|
||||
totalPagesInChapter = null,
|
||||
chapterIndex = chapterIndex,
|
||||
locator = locator.withFallbacks(
|
||||
chapterIndex = chapterIndex,
|
||||
cfi = cfi,
|
||||
pageIndex = pageIndex,
|
||||
textQuote = preview
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun SharedReaderScreenState.withMigratedBookIds(
|
||||
migrations: Map<String, String>
|
||||
): SharedReaderScreenState {
|
||||
if (migrations.isEmpty()) return this
|
||||
|
||||
fun String.migrated(): String = migrations[this] ?: this
|
||||
fun Set<String>.migrated(): Set<String> = mapTo(linkedSetOf()) { it.migrated() }
|
||||
|
||||
return copy(
|
||||
selectedBookIds = selectedBookIds.migrated(),
|
||||
booksSelectedForAdding = booksSelectedForAdding.migrated(),
|
||||
pinnedHomeBookIds = pinnedHomeBookIds.migrated(),
|
||||
pinnedLibraryBookIds = pinnedLibraryBookIds.migrated(),
|
||||
openTabIds = openTabIds.map { it.migrated() }.distinct(),
|
||||
activeTabBookId = activeTabBookId?.migrated(),
|
||||
selectedBookId = selectedBookId?.migrated()
|
||||
)
|
||||
}
|
||||
|
||||
private fun SharedReaderScreenState.withoutBookIds(bookIds: Set<String>): SharedReaderScreenState {
|
||||
if (bookIds.isEmpty()) return this
|
||||
return copy(
|
||||
selectedBookIds = selectedBookIds - bookIds,
|
||||
booksSelectedForAdding = booksSelectedForAdding - bookIds,
|
||||
pinnedHomeBookIds = pinnedHomeBookIds - bookIds,
|
||||
pinnedLibraryBookIds = pinnedLibraryBookIds - bookIds,
|
||||
openTabIds = openTabIds.filterNot { it in bookIds },
|
||||
activeTabBookId = activeTabBookId?.takeUnless { it in bookIds },
|
||||
selectedBookId = selectedBookId?.takeUnless { it in bookIds }
|
||||
)
|
||||
}
|
||||
|
||||
private fun String.toSyncRelativePath(): String {
|
||||
return replace('\\', '/')
|
||||
.split('/')
|
||||
.filter { it.isNotBlank() && it != "." }
|
||||
.joinToString("/")
|
||||
}
|
||||
|
||||
private fun JsonObject.string(name: String): String? {
|
||||
return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.long(name: String): Long? {
|
||||
return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.longOrNull }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.double(name: String): Double? {
|
||||
return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.doubleOrNull }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.boolean(name: String): Boolean? {
|
||||
return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.booleanOrNull }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.sentinelInt(name: String): Int? {
|
||||
val value = runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull }.getOrNull()
|
||||
return value?.takeUnless { it == -1 }
|
||||
}
|
||||
|
||||
private fun String?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull
|
||||
|
|
@ -9,7 +9,13 @@ data class EpubBookmark(
|
|||
val snippet: String,
|
||||
val pageInChapter: Int?,
|
||||
val totalPagesInChapter: Int?,
|
||||
val chapterIndex: Int
|
||||
val chapterIndex: Int,
|
||||
val locator: ReaderLocator = ReaderLocator.fromLegacy(
|
||||
chapterIndex = chapterIndex,
|
||||
cfi = cfi,
|
||||
pageIndex = pageInChapter?.minus(1),
|
||||
textQuote = snippet
|
||||
)
|
||||
)
|
||||
|
||||
enum class HighlightColor(val id: String, val color: Color, val cssClass: String) {
|
||||
|
|
@ -29,13 +35,142 @@ enum class HighlightColor(val id: String, val color: Color, val cssClass: String
|
|||
WHITE("white", Color(0xFFF5F5F5), "user-highlight-white")
|
||||
}
|
||||
|
||||
data class ReaderLocator(
|
||||
val chapterIndex: Int? = null,
|
||||
val chapterId: String? = null,
|
||||
val href: String? = null,
|
||||
val pageIndex: Int? = null,
|
||||
val startOffset: Int? = null,
|
||||
val endOffset: Int? = null,
|
||||
val textQuote: String? = null,
|
||||
val cfi: String? = null
|
||||
) {
|
||||
val hasTextRange: Boolean
|
||||
get() = startOffset != null && endOffset != null && endOffset >= startOffset
|
||||
|
||||
fun withFallbacks(
|
||||
chapterIndex: Int? = null,
|
||||
chapterId: String? = null,
|
||||
href: String? = null,
|
||||
pageIndex: Int? = null,
|
||||
startOffset: Int? = null,
|
||||
endOffset: Int? = null,
|
||||
textQuote: String? = null,
|
||||
cfi: String? = null
|
||||
): ReaderLocator {
|
||||
return copy(
|
||||
chapterIndex = this.chapterIndex ?: chapterIndex,
|
||||
chapterId = this.chapterId ?: chapterId,
|
||||
href = this.href ?: href,
|
||||
pageIndex = this.pageIndex ?: pageIndex,
|
||||
startOffset = this.startOffset ?: startOffset,
|
||||
endOffset = this.endOffset ?: endOffset,
|
||||
textQuote = this.textQuote ?: textQuote,
|
||||
cfi = this.cfi ?: cfi
|
||||
)
|
||||
}
|
||||
|
||||
fun sameLocation(other: ReaderLocator): Boolean {
|
||||
val sameChapter = chapterIndex == null || other.chapterIndex == null || chapterIndex == other.chapterIndex
|
||||
if (!sameChapter) return false
|
||||
|
||||
if (hasTextRange && other.hasTextRange) {
|
||||
return startOffset == other.startOffset && endOffset == other.endOffset
|
||||
}
|
||||
|
||||
if (pageIndex != null && other.pageIndex != null) {
|
||||
return pageIndex == other.pageIndex
|
||||
}
|
||||
|
||||
return cfi != null && cfi == other.cfi
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromLegacy(
|
||||
chapterIndex: Int? = null,
|
||||
cfi: String? = null,
|
||||
pageIndex: Int? = null,
|
||||
textQuote: String? = null
|
||||
): ReaderLocator {
|
||||
val desktopParts = cfi
|
||||
?.takeIf { it.startsWith("desktop:") }
|
||||
?.split(':')
|
||||
.orEmpty()
|
||||
val parsedChapterIndex = desktopParts.getOrNull(1)?.toIntOrNull()
|
||||
val possibleStartOffset = desktopParts.getOrNull(2)?.toIntOrNull()
|
||||
val possibleEndOffset = desktopParts.getOrNull(3)?.toIntOrNull()
|
||||
val hasOffsetRange = desktopParts.size == 4 &&
|
||||
possibleStartOffset != null &&
|
||||
possibleEndOffset != null &&
|
||||
possibleStartOffset >= 0 &&
|
||||
possibleEndOffset >= possibleStartOffset &&
|
||||
possibleEndOffset - possibleStartOffset <= 100_000
|
||||
val parsedStartOffset = if (hasOffsetRange) possibleStartOffset else null
|
||||
val parsedEndOffset = if (hasOffsetRange) possibleEndOffset else null
|
||||
val parsedPageIndex = when {
|
||||
pageIndex != null -> pageIndex
|
||||
desktopParts.size == 3 || desktopParts.size >= 5 || (desktopParts.size == 4 && !hasOffsetRange) ->
|
||||
desktopParts.getOrNull(2)?.toIntOrNull()
|
||||
else -> null
|
||||
}
|
||||
return ReaderLocator(
|
||||
chapterIndex = chapterIndex ?: parsedChapterIndex,
|
||||
pageIndex = parsedPageIndex,
|
||||
startOffset = parsedStartOffset,
|
||||
endOffset = parsedEndOffset,
|
||||
textQuote = textQuote,
|
||||
cfi = cfi
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class ReaderHighlightPalette(
|
||||
val colors: List<HighlightColor> = defaultColors
|
||||
) {
|
||||
fun sanitized(): ReaderHighlightPalette {
|
||||
val distinct = colors.distinct().filter { it in HighlightColor.entries }
|
||||
return copy(colors = distinct.ifEmpty { defaultColors })
|
||||
}
|
||||
|
||||
fun contains(color: HighlightColor): Boolean {
|
||||
return color in sanitized().colors
|
||||
}
|
||||
|
||||
fun withColor(color: HighlightColor, enabled: Boolean): ReaderHighlightPalette {
|
||||
val next = if (enabled) {
|
||||
colors + color
|
||||
} else {
|
||||
colors - color
|
||||
}
|
||||
return copy(colors = next).sanitized()
|
||||
}
|
||||
|
||||
companion object {
|
||||
val defaultColors: List<HighlightColor>
|
||||
get() = listOf(
|
||||
HighlightColor.YELLOW,
|
||||
HighlightColor.GREEN,
|
||||
HighlightColor.BLUE,
|
||||
HighlightColor.RED,
|
||||
HighlightColor.PURPLE,
|
||||
HighlightColor.ORANGE
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class UserHighlight(
|
||||
val id: String,
|
||||
val cfi: String,
|
||||
val text: String,
|
||||
val color: HighlightColor,
|
||||
val chapterIndex: Int,
|
||||
val note: String? = null
|
||||
val note: String? = null,
|
||||
val locator: ReaderLocator = ReaderLocator.fromLegacy(
|
||||
chapterIndex = chapterIndex,
|
||||
cfi = cfi,
|
||||
textQuote = text
|
||||
)
|
||||
)
|
||||
|
||||
fun escapeJsString(value: String): String {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,268 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
object EpubAnnotationSerializer {
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
|
||||
fun parseBookmarksJson(rawJson: String?, chapterTitles: List<String> = emptyList()): Set<EpubBookmark> {
|
||||
if (rawJson.isNullOrBlank()) return emptySet()
|
||||
val root = runCatching { json.parseToJsonElement(rawJson).jsonArray }.getOrNull() ?: return emptySet()
|
||||
return root.mapNotNull { element ->
|
||||
when (element) {
|
||||
is JsonObject -> element.asBookmarkOrNull(chapterTitles)
|
||||
else -> element.contentOrNull()
|
||||
?.let { rawBookmark -> parseBookmarkObject(rawBookmark, chapterTitles) }
|
||||
}
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
fun parseBookmarkEntries(entries: Collection<String>, chapterTitles: List<String> = emptyList()): Set<EpubBookmark> {
|
||||
return entries.mapNotNull { parseBookmarkObject(it, chapterTitles) }.toSet()
|
||||
}
|
||||
|
||||
fun bookmarksToJson(bookmarks: Collection<EpubBookmark>): String {
|
||||
val bookmarkEntries = bookmarks.map { JsonPrimitive(it.toJsonString()) }
|
||||
return json.encodeToString(JsonElement.serializer(), JsonArray(bookmarkEntries))
|
||||
}
|
||||
|
||||
fun parseHighlightsJson(rawJson: String?): List<UserHighlight> {
|
||||
if (rawJson.isNullOrBlank()) return emptyList()
|
||||
val root = runCatching { json.parseToJsonElement(rawJson).jsonArray }.getOrNull() ?: return emptyList()
|
||||
return root.mapNotNull { element ->
|
||||
runCatching { element.jsonObject.asHighlightOrNull() }.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
fun parseHighlightJson(rawJson: String?): UserHighlight? {
|
||||
if (rawJson.isNullOrBlank()) return null
|
||||
return runCatching { json.parseToJsonElement(rawJson).jsonObject.asHighlightOrNull() }.getOrNull()
|
||||
}
|
||||
|
||||
fun parseHighlightJsonLenient(rawJson: String?): UserHighlight? {
|
||||
if (rawJson.isNullOrBlank()) return null
|
||||
parseHighlightJson(rawJson)?.let { return it }
|
||||
val unwrapped = runCatching {
|
||||
json.parseToJsonElement(rawJson).jsonPrimitive.content
|
||||
}.getOrNull()
|
||||
return parseHighlightJson(unwrapped)
|
||||
}
|
||||
|
||||
fun highlightsToJson(highlights: Collection<UserHighlight>): String {
|
||||
return json.encodeToString(
|
||||
JsonElement.serializer(),
|
||||
JsonArray(highlights.map { it.toJsonObject() })
|
||||
)
|
||||
}
|
||||
|
||||
fun processAndAddHighlight(
|
||||
newCfi: String,
|
||||
newText: String,
|
||||
newColor: HighlightColor,
|
||||
chapterIndex: Int,
|
||||
currentList: MutableList<UserHighlight>,
|
||||
locator: ReaderLocator = ReaderLocator.fromLegacy(
|
||||
chapterIndex = chapterIndex,
|
||||
cfi = newCfi,
|
||||
textQuote = newText
|
||||
)
|
||||
): String {
|
||||
val normalizedLocator = locator.withFallbacks(
|
||||
chapterIndex = chapterIndex,
|
||||
cfi = newCfi,
|
||||
textQuote = newText
|
||||
)
|
||||
val exactMatchIndex = currentList.indexOfFirst {
|
||||
it.chapterIndex == chapterIndex &&
|
||||
(it.cfi == newCfi || it.locator.sameLocation(normalizedLocator))
|
||||
}
|
||||
|
||||
if (exactMatchIndex != -1) {
|
||||
val existing = currentList[exactMatchIndex]
|
||||
currentList[exactMatchIndex] = existing.copy(
|
||||
cfi = newCfi,
|
||||
color = newColor,
|
||||
text = newText,
|
||||
locator = existing.locator.copy(cfi = newCfi, textQuote = newText).withFallbacks(
|
||||
chapterIndex = chapterIndex,
|
||||
cfi = newCfi,
|
||||
textQuote = newText
|
||||
)
|
||||
)
|
||||
return newCfi
|
||||
}
|
||||
|
||||
currentList.add(
|
||||
UserHighlight(
|
||||
id = stableHighlightId(newCfi, chapterIndex),
|
||||
cfi = newCfi,
|
||||
text = newText,
|
||||
color = newColor,
|
||||
chapterIndex = chapterIndex,
|
||||
note = null,
|
||||
locator = normalizedLocator
|
||||
)
|
||||
)
|
||||
return newCfi
|
||||
}
|
||||
|
||||
private fun parseBookmarkObject(rawJson: String, chapterTitles: List<String>): EpubBookmark? {
|
||||
return runCatching { json.parseToJsonElement(rawJson).jsonObject.asBookmarkOrNull(chapterTitles) }.getOrNull()
|
||||
}
|
||||
|
||||
private fun EpubBookmark.toJsonString(): String {
|
||||
return json.encodeToString(JsonElement.serializer(), toJsonObject())
|
||||
}
|
||||
|
||||
private fun EpubBookmark.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
buildMap {
|
||||
put("cfi", JsonPrimitive(cfi))
|
||||
put("chapterTitle", JsonPrimitive(chapterTitle))
|
||||
put("label", label.asJson())
|
||||
put("snippet", JsonPrimitive(snippet))
|
||||
pageInChapter?.let { put("pageInChapter", JsonPrimitive(it)) }
|
||||
totalPagesInChapter?.let { put("totalPagesInChapter", JsonPrimitive(it)) }
|
||||
put("chapterIndex", JsonPrimitive(chapterIndex))
|
||||
put("locator", locator.toJsonObject())
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonObject.asBookmarkOrNull(chapterTitles: List<String>): EpubBookmark? {
|
||||
val cfi = string("cfi") ?: return null
|
||||
val chapterTitle = string("chapterTitle") ?: return null
|
||||
val chapterIndex = int("chapterIndex")
|
||||
?: chapterTitles.indexOfFirst { it == chapterTitle }.coerceAtLeast(0)
|
||||
return EpubBookmark(
|
||||
cfi = cfi,
|
||||
chapterTitle = chapterTitle,
|
||||
label = string("label"),
|
||||
snippet = string("snippet") ?: "",
|
||||
pageInChapter = int("pageInChapter"),
|
||||
totalPagesInChapter = int("totalPagesInChapter"),
|
||||
chapterIndex = chapterIndex,
|
||||
locator = this["locator"]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.asReaderLocatorOrNull()
|
||||
?.withFallbacks(
|
||||
chapterIndex = chapterIndex,
|
||||
cfi = cfi,
|
||||
pageIndex = int("pageInChapter")?.minus(1),
|
||||
textQuote = string("snippet") ?: ""
|
||||
)
|
||||
?: ReaderLocator.fromLegacy(
|
||||
chapterIndex = chapterIndex,
|
||||
cfi = cfi,
|
||||
pageIndex = int("pageInChapter")?.minus(1),
|
||||
textQuote = string("snippet") ?: ""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonObject.asHighlightOrNull(): UserHighlight? {
|
||||
val cfi = string("cfi") ?: return null
|
||||
val text = string("text") ?: return null
|
||||
val chapterIndex = int("chapterIndex") ?: return null
|
||||
val colorId = string("colorId")
|
||||
val color = HighlightColor.entries.firstOrNull { it.id == colorId } ?: HighlightColor.YELLOW
|
||||
val note = string("note")?.takeIf { it.isNotBlank() }
|
||||
return UserHighlight(
|
||||
id = string("id")?.takeIf { it.isNotBlank() } ?: stableHighlightId(cfi, chapterIndex),
|
||||
cfi = cfi,
|
||||
text = text,
|
||||
color = color,
|
||||
chapterIndex = chapterIndex,
|
||||
note = note,
|
||||
locator = this["locator"]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.asReaderLocatorOrNull()
|
||||
?.withFallbacks(
|
||||
chapterIndex = chapterIndex,
|
||||
cfi = cfi,
|
||||
textQuote = text
|
||||
)
|
||||
?: ReaderLocator.fromLegacy(
|
||||
chapterIndex = chapterIndex,
|
||||
cfi = cfi,
|
||||
textQuote = text
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun UserHighlight.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"id" to JsonPrimitive(id),
|
||||
"cfi" to JsonPrimitive(cfi),
|
||||
"text" to JsonPrimitive(text),
|
||||
"colorId" to JsonPrimitive(color.id),
|
||||
"chapterIndex" to JsonPrimitive(chapterIndex),
|
||||
"note" to (note ?: "").asJson(),
|
||||
"locator" to locator.toJsonObject()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun ReaderLocator.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
buildMap {
|
||||
chapterIndex?.let { put("chapterIndex", JsonPrimitive(it)) }
|
||||
chapterId?.let { put("chapterId", JsonPrimitive(it)) }
|
||||
href?.let { put("href", JsonPrimitive(it)) }
|
||||
pageIndex?.let { put("pageIndex", JsonPrimitive(it)) }
|
||||
startOffset?.let { put("startOffset", JsonPrimitive(it)) }
|
||||
endOffset?.let { put("endOffset", JsonPrimitive(it)) }
|
||||
textQuote?.let { put("textQuote", JsonPrimitive(it)) }
|
||||
cfi?.let { put("cfi", JsonPrimitive(it)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonElement.asReaderLocatorOrNull(): ReaderLocator? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
return ReaderLocator(
|
||||
chapterIndex = obj.int("chapterIndex"),
|
||||
chapterId = obj.string("chapterId"),
|
||||
href = obj.string("href"),
|
||||
pageIndex = obj.int("pageIndex"),
|
||||
startOffset = obj.int("startOffset"),
|
||||
endOffset = obj.int("endOffset"),
|
||||
textQuote = obj.string("textQuote"),
|
||||
cfi = obj.string("cfi")
|
||||
)
|
||||
}
|
||||
|
||||
private fun stableHighlightId(cfi: String, chapterIndex: Int): String {
|
||||
val key = "$chapterIndex:$cfi"
|
||||
var hash = 1125899906842597L
|
||||
key.forEach { char -> hash = 31 * hash + char.code }
|
||||
return "highlight_${hash.toString(16)}"
|
||||
}
|
||||
|
||||
private fun JsonObject.string(name: String): String? {
|
||||
return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.content }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.int(name: String): Int? {
|
||||
return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonElement.contentOrNull(): String? {
|
||||
return runCatching { takeUnless { it is JsonNull }?.jsonPrimitive?.content }.getOrNull()
|
||||
}
|
||||
|
||||
private fun String?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull
|
||||
}
|
||||
|
|
@ -1,6 +1,13 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.isSpecified
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import com.aryan.reader.shared.reader.ReaderReadingMode
|
||||
import com.aryan.reader.shared.reader.ReaderSettings
|
||||
import com.aryan.reader.shared.reader.SharedReaderTextAlign
|
||||
import kotlin.math.max
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
enum class ReaderFont(val id: String, val displayName: String, val fontFamilyName: String) {
|
||||
ORIGINAL("original", "Original", "Original"),
|
||||
|
|
@ -29,6 +36,11 @@ enum class PageInfoMode(val id: Int, val title: String) {
|
|||
HIDDEN(2, "Always Hide")
|
||||
}
|
||||
|
||||
enum class PageInfoPosition(val id: Int, val title: String) {
|
||||
BOTTOM(0, "Bottom"),
|
||||
TOP(1, "Top")
|
||||
}
|
||||
|
||||
data class FormatSettings(
|
||||
val fontSize: Float,
|
||||
val lineHeight: Float,
|
||||
|
|
@ -37,16 +49,26 @@ data class FormatSettings(
|
|||
val horizontalMargin: Float,
|
||||
val font: ReaderFont,
|
||||
val customPath: String?,
|
||||
val textAlign: ReaderTextAlign
|
||||
val textAlign: ReaderTextAlign,
|
||||
val verticalMargin: Float = 1.0f
|
||||
)
|
||||
|
||||
enum class ReaderTexture(val id: String, val displayName: String) {
|
||||
PAPER("paper", "Paper"),
|
||||
CANVAS("canvas", "Canvas"),
|
||||
EINK("eink", "E-Ink"),
|
||||
SLATE("slate", "Slate")
|
||||
enum class ReaderTexture(val id: String, val displayName: String, val assetPath: String) {
|
||||
NATURAL_WHITE("asset:ep_naturalwhite.webp", "Natural White", "textures/ep_naturalwhite.webp"),
|
||||
NATURAL_BLACK("asset:ep_naturalblack.webp", "Natural Black", "textures/ep_naturalblack.webp"),
|
||||
LIGHT_VENEER("asset:light-veneer.webp", "Light Veneer", "textures/light-veneer.webp"),
|
||||
RETINA_WOOD("asset:retina_wood.webp", "Retina Wood", "textures/retina_wood.webp"),
|
||||
GREY_WASH("asset:grey_wash_wall.webp", "Grey Wash", "textures/grey_wash_wall.webp"),
|
||||
CLASSY_FABRIC("asset:classy_fabric.webp", "Classy Fabric", "textures/classy_fabric.webp"),
|
||||
RETRO_INTRO("asset:retro_intro.webp", "Retro Intro", "textures/retro_intro.webp"),
|
||||
PAPER("paper", "Paper", "textures/texture_paper.png"),
|
||||
CANVAS("canvas", "Canvas", "textures/texture_canvas.png"),
|
||||
EINK("eink", "E-Ink", "textures/texture_eink.webp"),
|
||||
SLATE("slate", "Slate", "textures/texture_slate.png")
|
||||
}
|
||||
|
||||
const val ReaderTextureFilePrefix = "file:"
|
||||
|
||||
data class ReaderTheme(
|
||||
val id: String,
|
||||
val name: String,
|
||||
|
|
@ -63,5 +85,124 @@ val BuiltInReaderThemes = listOf(
|
|||
ReaderTheme("dark", "Dark", Color(0xFF121212), Color(0xFFE0E0E0), true),
|
||||
ReaderTheme("sepia", "Sepia", Color(0xFFFBF0D9), Color(0xFF5F4B32), false),
|
||||
ReaderTheme("slate", "Slate", Color(0xFF2E3440), Color(0xFFECEFF4), true),
|
||||
ReaderTheme("oled", "OLED", Color(0xFF000000), Color(0xFFB0B0B0), true)
|
||||
ReaderTheme("oled", "OLED", Color(0xFF000000), Color(0xFFB0B0B0), true),
|
||||
ReaderTheme("natural_white_texture", "Natural White", Color(0xFFF7F1E5), Color(0xFF1D1B18), false, textureId = ReaderTexture.NATURAL_WHITE.id),
|
||||
ReaderTheme("retina_texture", "Retina", Color(0xFFF1E4CD), Color(0xFF2A2119), false, textureId = ReaderTexture.RETINA_WOOD.id),
|
||||
ReaderTheme("veneer_texture", "Veneer", Color(0xFFF4E7CF), Color(0xFF2A2119), false, textureId = ReaderTexture.LIGHT_VENEER.id),
|
||||
ReaderTheme("grey_wash_texture", "Grey Wash", Color(0xFF202124), Color(0xFFFFFFFF), true, textureId = ReaderTexture.GREY_WASH.id),
|
||||
ReaderTheme("fabric_texture", "Fabric", Color(0xFF262626), Color(0xFFE8E2D8), true, textureId = ReaderTexture.CLASSY_FABRIC.id),
|
||||
ReaderTheme("retro_texture", "Retro", Color(0xFFF6ECD8), Color(0xFF2F2118), false, textureId = ReaderTexture.RETRO_INTRO.id)
|
||||
)
|
||||
|
||||
val BuiltInPdfReaderThemes = listOf(
|
||||
ReaderTheme("no_theme", "No Theme", Color.Unspecified, Color.Unspecified, false),
|
||||
ReaderTheme("reverse", "Reverse", Color.Black, Color.White, true),
|
||||
ReaderTheme("light", "Light", Color(0xFFFFFFFF), Color(0xFF000000), false),
|
||||
ReaderTheme("dark", "Dark", Color(0xFF121212), Color(0xFFE0E0E0), true),
|
||||
ReaderTheme("sepia", "Sepia", Color(0xFFFBF0D9), Color(0xFF5F4B32), false),
|
||||
ReaderTheme("slate", "Slate", Color(0xFF2E3440), Color(0xFFECEFF4), true),
|
||||
ReaderTheme("oled", "OLED", Color(0xFF000000), Color(0xFFB0B0B0), true),
|
||||
ReaderTheme("pdf_natural_white_texture", "Natural White", Color(0xFFF7F1E5), Color(0xFF1D1B18), false, textureId = ReaderTexture.NATURAL_WHITE.id),
|
||||
ReaderTheme("pdf_retina_texture", "Retina", Color(0xFFF1E4CD), Color(0xFF2A2119), false, textureId = ReaderTexture.RETINA_WOOD.id),
|
||||
ReaderTheme("pdf_veneer_texture", "Veneer", Color(0xFFF4E7CF), Color(0xFF2A2119), false, textureId = ReaderTexture.LIGHT_VENEER.id),
|
||||
ReaderTheme("pdf_grey_wash_texture", "Grey Wash", Color(0xFF202124), Color(0xFFFFFFFF), true, textureId = ReaderTexture.GREY_WASH.id),
|
||||
ReaderTheme("pdf_fabric_texture", "Fabric", Color(0xFF262626), Color(0xFFE8E2D8), true, textureId = ReaderTexture.CLASSY_FABRIC.id),
|
||||
ReaderTheme("pdf_retro_texture", "Retro", Color(0xFFF6ECD8), Color(0xFF2F2118), false, textureId = ReaderTexture.RETRO_INTRO.id)
|
||||
)
|
||||
|
||||
fun FormatSettings.toReaderSettings(base: ReaderSettings = ReaderSettings()): ReaderSettings {
|
||||
val horizontalMarginPx = (ReaderAppearanceDefaults.marginPx * horizontalMargin).roundToInt()
|
||||
.coerceIn(ReaderAppearanceDefaults.minMarginPx, ReaderAppearanceDefaults.maxMarginPx)
|
||||
val verticalMarginPx = (ReaderAppearanceDefaults.marginPx * verticalMargin).roundToInt()
|
||||
.coerceIn(ReaderAppearanceDefaults.minMarginPx, ReaderAppearanceDefaults.maxMarginPx)
|
||||
return base.copy(
|
||||
fontSize = (ReaderAppearanceDefaults.fontSizePx * fontSize).roundToInt()
|
||||
.coerceIn(ReaderAppearanceDefaults.minFontSizePx, ReaderAppearanceDefaults.maxFontSizePx),
|
||||
lineSpacing = (ReaderAppearanceDefaults.lineSpacing * lineHeight)
|
||||
.coerceIn(ReaderAppearanceDefaults.minLineSpacing, ReaderAppearanceDefaults.maxLineSpacing),
|
||||
margin = max(horizontalMarginPx, verticalMarginPx),
|
||||
horizontalMargin = horizontalMarginPx,
|
||||
verticalMargin = verticalMarginPx,
|
||||
textAlign = textAlign.toSharedReaderTextAlign(),
|
||||
fontFamily = customPath?.takeIf { it.isNotBlank() } ?: font.toReaderSettingsFontFamily(),
|
||||
customFontPath = customPath?.takeIf { it.isNotBlank() },
|
||||
paragraphSpacing = paragraphGap.coerceIn(
|
||||
ReaderAppearanceDefaults.minParagraphSpacing,
|
||||
ReaderAppearanceDefaults.maxParagraphSpacing
|
||||
),
|
||||
imageScale = imageSize.coerceIn(
|
||||
ReaderAppearanceDefaults.minImageScale,
|
||||
ReaderAppearanceDefaults.maxImageScale
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun ReaderTheme.toReaderSettings(base: ReaderSettings = ReaderSettings()): ReaderSettings {
|
||||
return base.copy(
|
||||
darkMode = isDark,
|
||||
themeId = id,
|
||||
textureId = textureId,
|
||||
backgroundColorArgb = backgroundColor.takeIf { it.isSpecified }?.toArgb()?.toLong(),
|
||||
textColorArgb = textColor.takeIf { it.isSpecified }?.toArgb()?.toLong()
|
||||
)
|
||||
}
|
||||
|
||||
fun readerThemeById(themeId: String?): ReaderTheme? {
|
||||
return BuiltInReaderThemes.firstOrNull { it.id == themeId }
|
||||
}
|
||||
|
||||
fun readerTextureDisplayName(textureId: String?): String {
|
||||
return if (textureId == null) {
|
||||
"None"
|
||||
} else {
|
||||
ReaderTexture.entries.firstOrNull { it.id == textureId }?.displayName
|
||||
?: textureId
|
||||
.removePrefix(ReaderTextureFilePrefix)
|
||||
.substringAfterLast('/')
|
||||
.substringAfterLast('\\')
|
||||
.let { fileName -> fileName.substringBeforeLast('.', missingDelimiterValue = fileName) }
|
||||
.ifBlank { "Custom Image" }
|
||||
}
|
||||
}
|
||||
|
||||
fun RenderMode.toReaderReadingMode(): ReaderReadingMode {
|
||||
return when (this) {
|
||||
RenderMode.VERTICAL_SCROLL -> ReaderReadingMode.VERTICAL
|
||||
RenderMode.PAGINATED -> ReaderReadingMode.PAGINATED
|
||||
}
|
||||
}
|
||||
|
||||
fun ReaderTextAlign.toSharedReaderTextAlign(): SharedReaderTextAlign {
|
||||
return when (this) {
|
||||
ReaderTextAlign.DEFAULT,
|
||||
ReaderTextAlign.LEFT -> SharedReaderTextAlign.START
|
||||
ReaderTextAlign.JUSTIFY -> SharedReaderTextAlign.JUSTIFY
|
||||
}
|
||||
}
|
||||
|
||||
fun ReaderFont.toReaderSettingsFontFamily(): String {
|
||||
return when (this) {
|
||||
ReaderFont.ORIGINAL -> "Default"
|
||||
ReaderFont.MERRIWEATHER,
|
||||
ReaderFont.LORA -> "Serif"
|
||||
ReaderFont.LATO,
|
||||
ReaderFont.LEXEND -> "Sans"
|
||||
ReaderFont.ROBOTO_MONO -> "Mono"
|
||||
}
|
||||
}
|
||||
|
||||
private object ReaderAppearanceDefaults {
|
||||
const val fontSizePx = 18f
|
||||
const val minFontSizePx = 12
|
||||
const val maxFontSizePx = 42
|
||||
const val lineSpacing = 1.45f
|
||||
const val minLineSpacing = 1.0f
|
||||
const val maxLineSpacing = 2.8f
|
||||
const val marginPx = 48f
|
||||
const val minMarginPx = 0
|
||||
const val maxMarginPx = 160
|
||||
const val minParagraphSpacing = 0.5f
|
||||
const val maxParagraphSpacing = 2.5f
|
||||
const val minImageScale = 0.5f
|
||||
const val maxImageScale = 2.0f
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,734 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
import com.aryan.reader.paginatedreader.SemanticBlock
|
||||
import com.aryan.reader.paginatedreader.SemanticFlexContainer
|
||||
import com.aryan.reader.paginatedreader.SemanticList
|
||||
import com.aryan.reader.paginatedreader.SemanticTable
|
||||
import com.aryan.reader.paginatedreader.SemanticTextBlock
|
||||
import com.aryan.reader.paginatedreader.SemanticWrappingBlock
|
||||
import com.aryan.reader.shared.reader.ReaderPage
|
||||
import com.aryan.reader.shared.reader.ReaderSessionState
|
||||
import com.aryan.reader.shared.reader.SharedEpubBook
|
||||
import com.aryan.reader.shared.reader.SharedEpubChapter
|
||||
|
||||
const val GEMINI_CLOUD_TTS_MODEL = "gemini-3.1-flash-live-preview"
|
||||
const val GEMINI_CLOUD_TTS_MODEL_ID = "gemini:$GEMINI_CLOUD_TTS_MODEL"
|
||||
const val DEFAULT_CLOUD_TTS_SPEAKER_ID = "Aoede"
|
||||
const val READER_TTS_CHUNK_MAX_LENGTH = 250
|
||||
|
||||
data class ReaderCloudTtsVoice(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val description: String
|
||||
)
|
||||
|
||||
enum class ReaderAiFeature(val displayName: String) {
|
||||
DEFINE("Smart dictionary"),
|
||||
SUMMARIZE("Summaries"),
|
||||
RECAP("Recaps")
|
||||
}
|
||||
|
||||
data class ReaderAiModelOption(
|
||||
val provider: String,
|
||||
val name: String,
|
||||
val label: String = "${provider.replaceFirstChar { it.uppercaseChar() }} - $name"
|
||||
) {
|
||||
val id: String = "$provider:$name"
|
||||
}
|
||||
|
||||
data class ReaderAiByokSettings(
|
||||
val geminiKey: String = "",
|
||||
val groqKey: String = "",
|
||||
val useOneModel: Boolean = true,
|
||||
val modelForAll: String = "",
|
||||
val defineModel: String = "",
|
||||
val summarizeModel: String = "",
|
||||
val recapModel: String = "",
|
||||
val ttsModel: String = "",
|
||||
val hideReaderAiFeatures: Boolean = false,
|
||||
val ttsSpeakerId: String = DEFAULT_CLOUD_TTS_SPEAKER_ID
|
||||
) {
|
||||
fun sanitized(): ReaderAiByokSettings {
|
||||
val knownTextModelIds = ReaderAiModelOptions.mapTo(mutableSetOf()) { it.id }
|
||||
return copy(
|
||||
geminiKey = geminiKey.trim(),
|
||||
groqKey = groqKey.trim(),
|
||||
modelForAll = modelForAll.takeIf { it in knownTextModelIds }.orEmpty(),
|
||||
defineModel = defineModel.takeIf { it in knownTextModelIds }.orEmpty(),
|
||||
summarizeModel = summarizeModel.takeIf { it in knownTextModelIds }.orEmpty(),
|
||||
recapModel = recapModel.takeIf { it in knownTextModelIds }.orEmpty(),
|
||||
ttsModel = ttsModel.takeIf { it == GEMINI_CLOUD_TTS_MODEL_ID }.orEmpty(),
|
||||
ttsSpeakerId = ttsSpeakerId.ifBlank { DEFAULT_CLOUD_TTS_SPEAKER_ID }
|
||||
)
|
||||
}
|
||||
|
||||
fun modelIdFor(feature: ReaderAiFeature): String {
|
||||
return if (useOneModel) {
|
||||
modelForAll
|
||||
} else {
|
||||
when (feature) {
|
||||
ReaderAiFeature.DEFINE -> defineModel
|
||||
ReaderAiFeature.SUMMARIZE -> summarizeModel
|
||||
ReaderAiFeature.RECAP -> recapModel
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun apiKeyFor(provider: String): String {
|
||||
return when (provider) {
|
||||
"gemini" -> geminiKey
|
||||
"groq" -> groqKey
|
||||
else -> ""
|
||||
}.trim()
|
||||
}
|
||||
|
||||
val hasAnyAiKey: Boolean get() = geminiKey.isNotBlank() || groqKey.isNotBlank()
|
||||
val areReaderAiFeaturesAvailable: Boolean get() = !hideReaderAiFeatures && hasAnyAiKey
|
||||
val isCloudTtsAvailable: Boolean get() = geminiKey.isNotBlank() && ttsModel == GEMINI_CLOUD_TTS_MODEL_ID
|
||||
}
|
||||
|
||||
val ReaderAiModelOptions = listOf(
|
||||
ReaderAiModelOption("groq", "qwen/qwen3-32b"),
|
||||
ReaderAiModelOption("groq", "llama-3.3-70b-versatile"),
|
||||
ReaderAiModelOption("groq", "llama-3.1-8b-instant"),
|
||||
ReaderAiModelOption("gemini", "gemma-4-26b-a4b-it"),
|
||||
ReaderAiModelOption("gemini", "gemma-4-31b-it"),
|
||||
ReaderAiModelOption("gemini", "gemini-flash-lite-latest"),
|
||||
ReaderAiModelOption("gemini", "gemini-2.5-flash-lite"),
|
||||
ReaderAiModelOption("gemini", "gemini-3.1-flash-lite-preview")
|
||||
)
|
||||
|
||||
val ReaderCloudTtsVoices = listOf(
|
||||
ReaderCloudTtsVoice("Zephyr", "Zephyr", "Bright, Higher pitch"),
|
||||
ReaderCloudTtsVoice("Puck", "Puck", "Upbeat, Middle pitch"),
|
||||
ReaderCloudTtsVoice("Charon", "Charon", "Informative, Lower pitch"),
|
||||
ReaderCloudTtsVoice("Kore", "Kore", "Firm, Middle pitch"),
|
||||
ReaderCloudTtsVoice("Fenrir", "Fenrir", "Excitable, Lower middle pitch"),
|
||||
ReaderCloudTtsVoice("Leda", "Leda", "Youthful, Higher pitch"),
|
||||
ReaderCloudTtsVoice("Orus", "Orus", "Firm, Lower middle pitch"),
|
||||
ReaderCloudTtsVoice("Aoede", "Aoede", "Breezy, Middle pitch"),
|
||||
ReaderCloudTtsVoice("Callirrhoe", "Callirrhoe", "Easy-going, Middle pitch"),
|
||||
ReaderCloudTtsVoice("Autonoe", "Autonoe", "Bright, Middle pitch"),
|
||||
ReaderCloudTtsVoice("Enceladus", "Enceladus", "Breathy, Lower pitch"),
|
||||
ReaderCloudTtsVoice("Iapetus", "Iapetus", "Clear, Lower middle pitch"),
|
||||
ReaderCloudTtsVoice("Umbriel", "Umbriel", "Easy-going, Lower middle pitch"),
|
||||
ReaderCloudTtsVoice("Algieba", "Algieba", "Smooth, Lower pitch"),
|
||||
ReaderCloudTtsVoice("Despina", "Despina", "Smooth, Middle pitch"),
|
||||
ReaderCloudTtsVoice("Erinome", "Erinome", "Clear, Middle pitch"),
|
||||
ReaderCloudTtsVoice("Algenib", "Algenib", "Gravelly, Lower pitch"),
|
||||
ReaderCloudTtsVoice("Rasalgethi", "Rasalgethi", "Informative, Middle pitch"),
|
||||
ReaderCloudTtsVoice("Laomedeia", "Laomedeia", "Upbeat, Higher pitch"),
|
||||
ReaderCloudTtsVoice("Achernar", "Achernar", "Soft, Higher pitch"),
|
||||
ReaderCloudTtsVoice("Alnilam", "Alnilam", "Firm, Lower middle pitch"),
|
||||
ReaderCloudTtsVoice("Schedar", "Schedar", "Even, Lower middle pitch"),
|
||||
ReaderCloudTtsVoice("Gacrux", "Gacrux", "Mature, Middle pitch"),
|
||||
ReaderCloudTtsVoice("Pulcherrima", "Pulcherrima", "Forward, Middle pitch"),
|
||||
ReaderCloudTtsVoice("Achird", "Achird", "Friendly, Lower middle pitch"),
|
||||
ReaderCloudTtsVoice("Zubenelgenubi", "Zubenelgenubi", "Casual, Lower middle pitch"),
|
||||
ReaderCloudTtsVoice("Vindemiatrix", "Vindemiatrix", "Gentle, Middle pitch"),
|
||||
ReaderCloudTtsVoice("Sadachbia", "Sadachbia", "Lively, Lower pitch"),
|
||||
ReaderCloudTtsVoice("Sadaltager", "Sadaltager", "Lively, Lower pitch"),
|
||||
ReaderCloudTtsVoice("Sulafat", "Sulafat", "Warm, Middle pitch")
|
||||
)
|
||||
|
||||
val ReaderCloudTtsSpeakers = ReaderCloudTtsVoices.map { it.id }
|
||||
|
||||
fun readerCloudTtsVoiceById(id: String): ReaderCloudTtsVoice? {
|
||||
return ReaderCloudTtsVoices.firstOrNull { it.id == id }
|
||||
}
|
||||
|
||||
fun formatReaderTtsBytes(bytes: Long): String {
|
||||
if (bytes < 1024) return "$bytes B"
|
||||
val units = listOf("KB", "MB", "GB", "TB", "PB")
|
||||
var value = bytes.toDouble() / 1024.0
|
||||
var unitIndex = 0
|
||||
while (value >= 1024.0 && unitIndex < units.lastIndex) {
|
||||
value /= 1024.0
|
||||
unitIndex++
|
||||
}
|
||||
return "${(value * 10).toInt() / 10.0} ${units[unitIndex]}"
|
||||
}
|
||||
|
||||
fun splitReaderTextIntoTtsChunks(
|
||||
text: String,
|
||||
maxLength: Int = READER_TTS_CHUNK_MAX_LENGTH
|
||||
): List<String> {
|
||||
if (text.isBlank()) return emptyList()
|
||||
val sentenceBoundaryRegex = Regex("""(?<!\w\.\w.)(?<![A-Z][a-z]\.)(?<=[.?!\n])\s+""")
|
||||
val sentences = text.trim()
|
||||
.split(sentenceBoundaryRegex)
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotBlank() }
|
||||
if (sentences.isEmpty()) return emptyList()
|
||||
|
||||
val chunks = mutableListOf<String>()
|
||||
val currentChunk = StringBuilder()
|
||||
fun flush() {
|
||||
if (currentChunk.isNotEmpty()) {
|
||||
chunks += currentChunk.toString()
|
||||
currentChunk.clear()
|
||||
}
|
||||
}
|
||||
|
||||
sentences.forEach { sentence ->
|
||||
if (sentence.length > maxLength) {
|
||||
flush()
|
||||
chunks += sentence
|
||||
return@forEach
|
||||
}
|
||||
if (currentChunk.isNotEmpty() && currentChunk.length + sentence.length + 1 > maxLength) {
|
||||
flush()
|
||||
}
|
||||
if (currentChunk.isNotEmpty()) currentChunk.append(' ')
|
||||
currentChunk.append(sentence)
|
||||
}
|
||||
flush()
|
||||
return chunks
|
||||
}
|
||||
|
||||
fun readerAiModelById(id: String): ReaderAiModelOption? {
|
||||
return ReaderAiModelOptions.firstOrNull { it.id == id }
|
||||
}
|
||||
|
||||
fun maskedReaderAiKey(value: String): String {
|
||||
val trimmed = value.trim()
|
||||
return when {
|
||||
trimmed.isBlank() -> ""
|
||||
trimmed.length <= 6 -> "***"
|
||||
else -> "${trimmed.take(3)}...${trimmed.takeLast(3)}"
|
||||
}
|
||||
}
|
||||
|
||||
enum class ReaderExternalLookupAction(val title: String) {
|
||||
DICTIONARY("Dictionary"),
|
||||
TRANSLATE("Translate"),
|
||||
SEARCH("Search")
|
||||
}
|
||||
|
||||
fun externalLookupUrl(action: ReaderExternalLookupAction, text: String): String {
|
||||
val encoded = text.trim().urlEncoded()
|
||||
return when (action) {
|
||||
ReaderExternalLookupAction.DICTIONARY -> "https://www.google.com/search?q=define+$encoded"
|
||||
ReaderExternalLookupAction.TRANSLATE -> "https://translate.google.com/?sl=auto&tl=en&text=$encoded&op=translate"
|
||||
ReaderExternalLookupAction.SEARCH -> "https://www.google.com/search?q=$encoded"
|
||||
}
|
||||
}
|
||||
|
||||
data class ReaderAutoScrollState(
|
||||
val enabled: Boolean = false,
|
||||
val speed: Float = 36f
|
||||
) {
|
||||
fun sanitized(): ReaderAutoScrollState {
|
||||
return copy(speed = speed.coerceIn(12f, 160f))
|
||||
}
|
||||
}
|
||||
|
||||
enum class ReaderTtsReadScope(val label: String) {
|
||||
PAGE("Page"),
|
||||
CHAPTER("Chapter"),
|
||||
BOOK("From here")
|
||||
}
|
||||
|
||||
data class ReaderTtsChunk(
|
||||
val index: Int,
|
||||
val pageIndex: Int,
|
||||
val chapterIndex: Int,
|
||||
val chapterTitle: String,
|
||||
val text: String,
|
||||
val startOffset: Int,
|
||||
val endOffset: Int,
|
||||
val sourceCfi: String? = null,
|
||||
val spokenText: String = text
|
||||
) {
|
||||
fun toLocator(): ReaderLocator {
|
||||
val boundedEnd = endOffset.coerceAtLeast(startOffset)
|
||||
return ReaderLocator(
|
||||
chapterIndex = chapterIndex,
|
||||
pageIndex = pageIndex,
|
||||
startOffset = startOffset,
|
||||
endOffset = boundedEnd,
|
||||
textQuote = text,
|
||||
cfi = sourceCfi ?: "desktop:$chapterIndex:$startOffset:$boundedEnd"
|
||||
)
|
||||
}
|
||||
|
||||
fun toHighlight(sessionId: Long): UserHighlight {
|
||||
val locator = toLocator()
|
||||
return UserHighlight(
|
||||
id = "tts_${sessionId}_$index",
|
||||
cfi = locator.cfi.orEmpty(),
|
||||
text = text,
|
||||
color = HighlightColor.YELLOW,
|
||||
chapterIndex = chapterIndex,
|
||||
locator = locator
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class ReaderTtsProgress(
|
||||
val sessionId: Long = 0L,
|
||||
val scope: ReaderTtsReadScope = ReaderTtsReadScope.PAGE,
|
||||
val chunks: List<ReaderTtsChunk> = emptyList(),
|
||||
val currentChunkIndex: Int = -1
|
||||
) {
|
||||
val currentChunk: ReaderTtsChunk?
|
||||
get() = chunks.getOrNull(currentChunkIndex)
|
||||
|
||||
val isActive: Boolean
|
||||
get() = currentChunk != null
|
||||
|
||||
val currentPositionLabel: String?
|
||||
get() = currentChunk?.let { chunk ->
|
||||
"Part ${currentChunkIndex + 1}/${chunks.size} - ${chunk.chapterTitle.ifBlank { scope.label }}"
|
||||
}
|
||||
}
|
||||
|
||||
data class ReaderTtsCacheSummary(
|
||||
val cachedChapterCount: Int = 0,
|
||||
val cachedChunkCount: Int = 0,
|
||||
val currentVoiceChunkCount: Int = 0,
|
||||
val totalSizeBytes: Long = 0L,
|
||||
val currentVoiceSizeBytes: Long = 0L
|
||||
) {
|
||||
val hasCachedAudio: Boolean get() = cachedChunkCount > 0
|
||||
val hasCurrentVoiceCachedAudio: Boolean get() = currentVoiceChunkCount > 0
|
||||
|
||||
val currentVoiceLabel: String
|
||||
get() = if (hasCurrentVoiceCachedAudio) {
|
||||
"$currentVoiceChunkCount chunks, ${formatReaderTtsBytes(currentVoiceSizeBytes)}"
|
||||
} else {
|
||||
"No cached chunks for this voice"
|
||||
}
|
||||
}
|
||||
|
||||
object ReaderTtsPlanner {
|
||||
fun chunksForCurrentPage(session: ReaderSessionState): List<ReaderTtsChunk> {
|
||||
val page = session.reader.currentPage ?: return emptyList()
|
||||
return chunksForPages(session.reader.book, listOf(page))
|
||||
}
|
||||
|
||||
fun chunksForCurrentChapter(session: ReaderSessionState): List<ReaderTtsChunk> {
|
||||
val page = session.reader.currentPage ?: return emptyList()
|
||||
return chunksForPages(
|
||||
session.reader.book,
|
||||
session.reader.pages
|
||||
.asSequence()
|
||||
.filter { it.pageIndex >= page.pageIndex && it.chapterIndex == page.chapterIndex }
|
||||
.toList()
|
||||
)
|
||||
}
|
||||
|
||||
fun chunksFromCurrentLocation(session: ReaderSessionState): List<ReaderTtsChunk> {
|
||||
val pageIndex = session.reader.currentPageIndex
|
||||
return chunksForPages(session.reader.book, session.reader.pages.drop(pageIndex.coerceAtLeast(0)))
|
||||
}
|
||||
|
||||
fun chunksForText(
|
||||
text: String,
|
||||
pageIndex: Int,
|
||||
chapterIndex: Int,
|
||||
chapterTitle: String,
|
||||
sourceStartOffset: Int = 0
|
||||
): List<ReaderTtsChunk> {
|
||||
return splitTextIntoRanges(text).mapIndexed { index, range ->
|
||||
ReaderTtsChunk(
|
||||
index = index,
|
||||
pageIndex = pageIndex,
|
||||
chapterIndex = chapterIndex,
|
||||
chapterTitle = chapterTitle,
|
||||
text = range.text,
|
||||
startOffset = sourceStartOffset + range.start,
|
||||
endOffset = sourceStartOffset + range.end
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun chunksForPages(book: SharedEpubBook, pages: List<ReaderPage>): List<ReaderTtsChunk> {
|
||||
var nextIndex = 0
|
||||
return pages
|
||||
.groupBy { it.chapterIndex }
|
||||
.entries
|
||||
.sortedBy { (chapterIndex, _) ->
|
||||
pages.indexOfFirst { it.chapterIndex == chapterIndex }.takeIf { it >= 0 } ?: Int.MAX_VALUE
|
||||
}
|
||||
.flatMap { chapterPages ->
|
||||
val chapter = book.chapters.getOrNull(chapterPages.key)
|
||||
val semanticChunks = chapter
|
||||
?.let { chunksForSemanticPages(it, chapterPages.value) }
|
||||
.orEmpty()
|
||||
if (semanticChunks.isNotEmpty()) {
|
||||
semanticChunks
|
||||
} else {
|
||||
chunksForPlainPages(book, chapterPages.value)
|
||||
}
|
||||
}
|
||||
.distinctBy { "${it.sourceCfi}:${it.startOffset}:${it.endOffset}:${it.text}" }
|
||||
.map { it.copy(index = nextIndex++) }
|
||||
.toList()
|
||||
}
|
||||
|
||||
private fun chunksForPlainPages(book: SharedEpubBook, pages: List<ReaderPage>): List<ReaderTtsChunk> {
|
||||
return pages.flatMap { page ->
|
||||
val chapterText = book.chapters
|
||||
.getOrNull(page.chapterIndex)
|
||||
?.normalizedTtsSourceText()
|
||||
.orEmpty()
|
||||
val sourceStartOffset = page.sourceTextStartOffset(chapterText)
|
||||
splitTextIntoRanges(page.text).map { range ->
|
||||
ReaderTtsChunk(
|
||||
index = 0,
|
||||
pageIndex = page.pageIndex,
|
||||
chapterIndex = page.chapterIndex,
|
||||
chapterTitle = page.chapterTitle,
|
||||
text = range.text,
|
||||
startOffset = sourceStartOffset + range.start,
|
||||
endOffset = sourceStartOffset + range.end
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun chunksForSemanticPages(
|
||||
chapter: SharedEpubChapter,
|
||||
pages: List<ReaderPage>
|
||||
): List<ReaderTtsChunk> {
|
||||
if (chapter.semanticBlocks.isEmpty() || pages.isEmpty()) return emptyList()
|
||||
val ranges = pages.map { it.startOffset to it.endOffset }
|
||||
val textBlocks = chapter.semanticBlocks.semanticTextBlocks()
|
||||
.filter { block ->
|
||||
block.cfi != null &&
|
||||
block.text.isNotBlank() &&
|
||||
ranges.any { (start, end) -> block.intersects(start, end) }
|
||||
}
|
||||
return textBlocks.flatMap { block ->
|
||||
val blockStart = block.startCharOffsetInSource.coerceAtLeast(0)
|
||||
splitTextIntoRanges(block.text).mapNotNull { range ->
|
||||
val chunkStart = blockStart + range.start
|
||||
val chunkEnd = blockStart + range.end
|
||||
if (ranges.none { (start, end) -> chunkStart < end && chunkEnd > start }) return@mapNotNull null
|
||||
val page = pages.firstOrNull { it.intersects(chunkStart, chunkEnd) }
|
||||
?: pages.minByOrNull { kotlin.math.abs(it.startOffset - chunkStart) }
|
||||
?: return@mapNotNull null
|
||||
ReaderTtsChunk(
|
||||
index = 0,
|
||||
pageIndex = page.pageIndex,
|
||||
chapterIndex = page.chapterIndex,
|
||||
chapterTitle = page.chapterTitle,
|
||||
text = range.text,
|
||||
startOffset = chunkStart,
|
||||
endOffset = chunkEnd,
|
||||
sourceCfi = block.cfi
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<SemanticBlock>.semanticTextBlocks(): List<SemanticTextBlock> {
|
||||
val blocks = mutableListOf<SemanticTextBlock>()
|
||||
fun visit(block: SemanticBlock) {
|
||||
when (block) {
|
||||
is SemanticTextBlock -> blocks += block
|
||||
is SemanticFlexContainer -> block.children.forEach(::visit)
|
||||
is SemanticTable -> block.rows.forEach { row -> row.forEach { cell -> cell.content.forEach(::visit) } }
|
||||
is SemanticList -> block.items.forEach(::visit)
|
||||
is SemanticWrappingBlock -> block.paragraphsToWrap.forEach(::visit)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
forEach(::visit)
|
||||
return blocks
|
||||
}
|
||||
|
||||
private fun SemanticTextBlock.intersects(startOffset: Int, endOffset: Int): Boolean {
|
||||
val start = startCharOffsetInSource
|
||||
val end = start + text.length
|
||||
return start < endOffset && end > startOffset
|
||||
}
|
||||
|
||||
private fun ReaderPage.intersects(startOffset: Int, endOffset: Int): Boolean {
|
||||
return startOffset < endOffset && startOffset < this.endOffset && endOffset > this.startOffset
|
||||
}
|
||||
|
||||
private fun ReaderPage.sourceTextStartOffset(chapterText: String): Int {
|
||||
if (chapterText.isBlank()) return startOffset
|
||||
val boundedStart = startOffset.coerceIn(0, chapterText.length)
|
||||
val boundedEnd = endOffset.coerceIn(boundedStart, chapterText.length)
|
||||
val pageSlice = chapterText.substring(boundedStart, boundedEnd)
|
||||
val trimAdjustedStart = boundedStart + pageSlice.leadingWhitespaceLength()
|
||||
val exactTextStart = text
|
||||
.takeIf { it.isNotBlank() }
|
||||
?.let { needle ->
|
||||
chapterText.indexOf(needle, startIndex = boundedStart)
|
||||
.takeIf { found -> found >= boundedStart && found + needle.length <= boundedEnd }
|
||||
}
|
||||
if (exactTextStart != null) return exactTextStart
|
||||
val trimmedTextStart = text
|
||||
.trim()
|
||||
.takeIf { it.isNotBlank() }
|
||||
?.let { needle ->
|
||||
chapterText.indexOf(needle, startIndex = boundedStart)
|
||||
.takeIf { found -> found >= boundedStart && found + needle.length <= boundedEnd }
|
||||
}
|
||||
return trimmedTextStart ?: trimAdjustedStart
|
||||
}
|
||||
|
||||
private fun String.leadingWhitespaceLength(): Int {
|
||||
return length - trimStart().length
|
||||
}
|
||||
|
||||
private fun SharedEpubChapter.normalizedTtsSourceText(): String {
|
||||
return plainText
|
||||
.replace("\r\n", "\n")
|
||||
.replace(Regex("\\n{3,}"), "\n\n")
|
||||
.trim()
|
||||
}
|
||||
|
||||
private fun splitTextIntoRanges(
|
||||
text: String,
|
||||
maxLength: Int = READER_TTS_CHUNK_MAX_LENGTH
|
||||
): List<ReaderTtsTextRange> {
|
||||
val sourceStart = text.indexOfFirst { !it.isWhitespace() }
|
||||
if (sourceStart < 0) return emptyList()
|
||||
val sourceEnd = text.indexOfLast { !it.isWhitespace() } + 1
|
||||
val source = text.substring(sourceStart, sourceEnd)
|
||||
val sentenceRanges = androidStyleSentenceRanges(source, sourceStart)
|
||||
if (sentenceRanges.isEmpty()) return emptyList()
|
||||
|
||||
val chunks = mutableListOf<ReaderTtsTextRange>()
|
||||
var currentText = StringBuilder()
|
||||
var currentStart = -1
|
||||
var currentEnd = -1
|
||||
fun flushCurrent() {
|
||||
if (currentText.isNotEmpty() && currentStart >= 0 && currentEnd >= currentStart) {
|
||||
chunks += ReaderTtsTextRange(
|
||||
text = currentText.toString(),
|
||||
start = currentStart,
|
||||
end = currentStart + currentText.length
|
||||
)
|
||||
}
|
||||
currentText = StringBuilder()
|
||||
currentStart = -1
|
||||
currentEnd = -1
|
||||
}
|
||||
|
||||
for (sentence in sentenceRanges) {
|
||||
if (sentence.text.length > maxLength) {
|
||||
flushCurrent()
|
||||
chunks += sentence
|
||||
continue
|
||||
}
|
||||
if (currentText.isNotEmpty() && currentText.length + sentence.text.length + 1 > maxLength) {
|
||||
flushCurrent()
|
||||
currentText.append(sentence.text)
|
||||
currentStart = sentence.start
|
||||
currentEnd = sentence.end
|
||||
} else {
|
||||
if (currentText.isNotEmpty()) currentText.append(" ")
|
||||
currentText.append(sentence.text)
|
||||
if (currentStart < 0) currentStart = sentence.start
|
||||
currentEnd = sentence.end
|
||||
}
|
||||
}
|
||||
flushCurrent()
|
||||
return chunks
|
||||
}
|
||||
|
||||
private fun androidStyleSentenceRanges(source: String, sourceOffset: Int): List<ReaderTtsTextRange> {
|
||||
val sentenceBoundaryRegex = Regex("""(?<!\w\.\w.)(?<![A-Z][a-z]\.)(?<=[.?!\n])\s+""")
|
||||
val ranges = mutableListOf<ReaderTtsTextRange>()
|
||||
var start = 0
|
||||
sentenceBoundaryRegex.findAll(source).forEach { match ->
|
||||
val end = match.range.first
|
||||
if (end > start) {
|
||||
source.substring(start, end)
|
||||
.takeIf { it.isNotBlank() }
|
||||
?.let { sentence ->
|
||||
ranges += ReaderTtsTextRange(
|
||||
text = sentence,
|
||||
start = sourceOffset + start,
|
||||
end = sourceOffset + end
|
||||
)
|
||||
}
|
||||
}
|
||||
start = match.range.last + 1
|
||||
}
|
||||
if (start < source.length) {
|
||||
val sentence = source.substring(start)
|
||||
if (sentence.isNotBlank()) {
|
||||
ranges += ReaderTtsTextRange(
|
||||
text = sentence,
|
||||
start = sourceOffset + start,
|
||||
end = sourceOffset + source.length
|
||||
)
|
||||
}
|
||||
}
|
||||
return ranges
|
||||
}
|
||||
|
||||
private data class ReaderTtsTextRange(
|
||||
val text: String,
|
||||
val start: Int,
|
||||
val end: Int
|
||||
)
|
||||
}
|
||||
|
||||
data class ReaderCloudTtsState(
|
||||
val isAvailable: Boolean = false,
|
||||
val isPlaying: Boolean = false,
|
||||
val isLoading: Boolean = false,
|
||||
val isPaused: Boolean = false,
|
||||
val statusMessage: String? = null,
|
||||
val errorMessage: String? = null,
|
||||
val progress: ReaderTtsProgress = ReaderTtsProgress(),
|
||||
val cacheSummary: ReaderTtsCacheSummary = ReaderTtsCacheSummary()
|
||||
)
|
||||
|
||||
data class ReaderAiResultState(
|
||||
val title: String? = null,
|
||||
val text: String = "",
|
||||
val isLoading: Boolean = false,
|
||||
val errorMessage: String? = null
|
||||
) {
|
||||
val hasContent: Boolean get() = text.isNotBlank() || errorMessage != null || isLoading
|
||||
}
|
||||
|
||||
data class ReaderExtrasState(
|
||||
val autoScroll: ReaderAutoScrollState = ReaderAutoScrollState(),
|
||||
val cloudTts: ReaderCloudTtsState = ReaderCloudTtsState(),
|
||||
val aiResult: ReaderAiResultState = ReaderAiResultState()
|
||||
)
|
||||
|
||||
data class ReaderByokTextRequest(
|
||||
val model: ReaderAiModelOption,
|
||||
val apiKey: String,
|
||||
val systemInstruction: String,
|
||||
val userPrompt: String,
|
||||
val temperature: Double,
|
||||
val maxTokens: Int
|
||||
)
|
||||
|
||||
sealed interface ReaderByokTextRequestResult {
|
||||
data class Ready(val request: ReaderByokTextRequest) : ReaderByokTextRequestResult
|
||||
data class MissingModel(val featureName: String) : ReaderByokTextRequestResult
|
||||
data class MissingKey(val provider: String) : ReaderByokTextRequestResult
|
||||
data object Hidden : ReaderByokTextRequestResult
|
||||
}
|
||||
|
||||
object ReaderByokTextRequests {
|
||||
fun build(
|
||||
settings: ReaderAiByokSettings,
|
||||
feature: ReaderAiFeature,
|
||||
text: String,
|
||||
context: String? = null
|
||||
): ReaderByokTextRequestResult {
|
||||
val sanitized = settings.sanitized()
|
||||
if (sanitized.hideReaderAiFeatures) return ReaderByokTextRequestResult.Hidden
|
||||
val model = readerAiModelById(sanitized.modelIdFor(feature))
|
||||
?: return ReaderByokTextRequestResult.MissingModel(feature.displayName)
|
||||
val apiKey = sanitized.apiKeyFor(model.provider)
|
||||
if (apiKey.isBlank()) return ReaderByokTextRequestResult.MissingKey(model.provider)
|
||||
val prompt = promptFor(feature, text, context)
|
||||
return ReaderByokTextRequestResult.Ready(
|
||||
ReaderByokTextRequest(
|
||||
model = model,
|
||||
apiKey = apiKey,
|
||||
systemInstruction = prompt.systemInstruction,
|
||||
userPrompt = prompt.userPrompt,
|
||||
temperature = prompt.temperature,
|
||||
maxTokens = prompt.maxTokens
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun promptFor(feature: ReaderAiFeature, text: String, context: String?): ReaderPrompt {
|
||||
return when (feature) {
|
||||
ReaderAiFeature.DEFINE -> ReaderPrompt(
|
||||
systemInstruction = "You are a concise reading dictionary. Define the selected word or passage, explain nuance in context, and avoid unrelated commentary.",
|
||||
userPrompt = buildString {
|
||||
context?.takeIf { it.isNotBlank() }?.let {
|
||||
append("Context:\n")
|
||||
append(it.trim().take(3000))
|
||||
append("\n\n")
|
||||
}
|
||||
append("Selection:\n")
|
||||
append(text.trim())
|
||||
},
|
||||
temperature = 0.15,
|
||||
maxTokens = 1024
|
||||
)
|
||||
|
||||
ReaderAiFeature.SUMMARIZE -> ReaderPrompt(
|
||||
systemInstruction = "You are an expert reading assistant. Summarize the provided passage clearly and concisely. Focus on the main ideas, plot points, and useful context. Do not add a preamble.",
|
||||
userPrompt = text.trim(),
|
||||
temperature = 0.2,
|
||||
maxTokens = 4096
|
||||
)
|
||||
|
||||
ReaderAiFeature.RECAP -> ReaderPrompt(
|
||||
systemInstruction = "You are a reading assistant creating a recap up to the reader's current position. Synthesize prior context and current text into a cohesive recap. Conclude exactly where the reader is positioned. Do not add a preamble.",
|
||||
userPrompt = text.trim(),
|
||||
temperature = 0.3,
|
||||
maxTokens = 4096
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class ReaderPrompt(
|
||||
val systemInstruction: String,
|
||||
val userPrompt: String,
|
||||
val temperature: Double,
|
||||
val maxTokens: Int
|
||||
)
|
||||
|
||||
object ReaderContextExtractor {
|
||||
fun currentPageText(session: ReaderSessionState, maxChars: Int = 6000): String {
|
||||
return session.reader.currentPage?.text.orEmpty().trim().take(maxChars)
|
||||
}
|
||||
|
||||
fun currentChapterText(session: ReaderSessionState, maxChars: Int = 20_000): String {
|
||||
val chapterIndex = session.reader.currentPage?.chapterIndex ?: return currentPageText(session, maxChars)
|
||||
return session.reader.book.chapters
|
||||
.getOrNull(chapterIndex)
|
||||
?.plainText
|
||||
.orEmpty()
|
||||
.trim()
|
||||
.take(maxChars)
|
||||
}
|
||||
|
||||
fun textBeforeCurrentLocation(session: ReaderSessionState, maxChars: Int = 24_000): String {
|
||||
val page = session.reader.currentPage ?: return ""
|
||||
val builder = StringBuilder()
|
||||
session.reader.book.chapters.forEachIndexed { chapterIndex, chapter ->
|
||||
when {
|
||||
chapterIndex < page.chapterIndex -> {
|
||||
builder.append(chapter.title).append('\n')
|
||||
builder.append(chapter.plainText.trim()).append("\n\n")
|
||||
}
|
||||
chapterIndex == page.chapterIndex -> {
|
||||
builder.append(chapter.title).append('\n')
|
||||
builder.append(chapter.plainText.take(page.endOffset.coerceAtMost(chapter.plainText.length)).trim())
|
||||
}
|
||||
}
|
||||
}
|
||||
return builder.toString().trim().takeLast(maxChars)
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.urlEncoded(): String {
|
||||
val bytes = toByteArray(Charsets.UTF_8)
|
||||
val builder = StringBuilder()
|
||||
bytes.forEach { raw ->
|
||||
val value = raw.toInt() and 0xFF
|
||||
val char = value.toChar()
|
||||
when {
|
||||
value in 'A'.code..'Z'.code ||
|
||||
value in 'a'.code..'z'.code ||
|
||||
value in '0'.code..'9'.code ||
|
||||
char in "-_.~" -> builder.append(char)
|
||||
char == ' ' -> builder.append('+')
|
||||
else -> builder.append('%').append(value.toString(16).uppercase().padStart(2, '0'))
|
||||
}
|
||||
}
|
||||
return builder.toString()
|
||||
}
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
data class ReaderMarkdownDocument(
|
||||
val blocks: List<ReaderMarkdownBlock>
|
||||
)
|
||||
|
||||
sealed interface ReaderMarkdownBlock {
|
||||
data class Heading(val level: Int, val text: String) : ReaderMarkdownBlock
|
||||
data class Paragraph(val text: String) : ReaderMarkdownBlock
|
||||
data class ListItems(val ordered: Boolean, val items: List<String>) : ReaderMarkdownBlock
|
||||
data class CodeBlock(val text: String) : ReaderMarkdownBlock
|
||||
data class Quote(val text: String) : ReaderMarkdownBlock
|
||||
}
|
||||
|
||||
object ReaderMarkdownParser {
|
||||
fun parse(markdown: String): ReaderMarkdownDocument {
|
||||
val lines = markdown.replace("\r\n", "\n").split('\n')
|
||||
val blocks = mutableListOf<ReaderMarkdownBlock>()
|
||||
val paragraph = mutableListOf<String>()
|
||||
var index = 0
|
||||
|
||||
fun flushParagraph() {
|
||||
if (paragraph.isNotEmpty()) {
|
||||
blocks += ReaderMarkdownBlock.Paragraph(paragraph.joinToString(" ").trim())
|
||||
paragraph.clear()
|
||||
}
|
||||
}
|
||||
|
||||
while (index < lines.size) {
|
||||
val line = lines[index]
|
||||
val trimmed = line.trim()
|
||||
when {
|
||||
trimmed.isBlank() -> {
|
||||
flushParagraph()
|
||||
index += 1
|
||||
}
|
||||
|
||||
trimmed.startsWith("```") -> {
|
||||
flushParagraph()
|
||||
val code = mutableListOf<String>()
|
||||
index += 1
|
||||
while (index < lines.size && !lines[index].trim().startsWith("```")) {
|
||||
code += lines[index]
|
||||
index += 1
|
||||
}
|
||||
if (index < lines.size) index += 1
|
||||
blocks += ReaderMarkdownBlock.CodeBlock(code.joinToString("\n").trimEnd())
|
||||
}
|
||||
|
||||
trimmed.headingLevel() != null -> {
|
||||
flushParagraph()
|
||||
val level = trimmed.headingLevel() ?: 1
|
||||
blocks += ReaderMarkdownBlock.Heading(
|
||||
level = level,
|
||||
text = trimmed.drop(level).trim()
|
||||
)
|
||||
index += 1
|
||||
}
|
||||
|
||||
trimmed.startsWith(">") -> {
|
||||
flushParagraph()
|
||||
val quote = mutableListOf<String>()
|
||||
while (index < lines.size && lines[index].trim().startsWith(">")) {
|
||||
quote += lines[index].trim().removePrefix(">").trim()
|
||||
index += 1
|
||||
}
|
||||
blocks += ReaderMarkdownBlock.Quote(quote.joinToString(" ").trim())
|
||||
}
|
||||
|
||||
trimmed.unorderedListText() != null || trimmed.orderedListText() != null -> {
|
||||
flushParagraph()
|
||||
val ordered = trimmed.orderedListText() != null
|
||||
val items = mutableListOf<String>()
|
||||
while (index < lines.size) {
|
||||
val itemLine = lines[index].trim()
|
||||
val item = if (ordered) itemLine.orderedListText() else itemLine.unorderedListText()
|
||||
if (item == null) break
|
||||
items += item
|
||||
index += 1
|
||||
}
|
||||
blocks += ReaderMarkdownBlock.ListItems(ordered = ordered, items = items)
|
||||
}
|
||||
|
||||
else -> {
|
||||
paragraph += trimmed
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flushParagraph()
|
||||
return ReaderMarkdownDocument(blocks)
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.headingLevel(): Int? {
|
||||
val count = takeWhile { it == '#' }.length
|
||||
return count.takeIf { it in 1..6 && getOrNull(it) == ' ' }
|
||||
}
|
||||
|
||||
private fun String.unorderedListText(): String? {
|
||||
return if (length > 2 && first() in listOf('-', '*', '+') && this[1] == ' ') {
|
||||
drop(2).trim()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.orderedListText(): String? {
|
||||
val dotIndex = indexOf('.')
|
||||
if (dotIndex <= 0 || dotIndex + 1 >= length || this[dotIndex + 1] != ' ') return null
|
||||
return take(dotIndex).takeIf { number -> number.all { it.isDigit() } }
|
||||
?.let { drop(dotIndex + 2).trim() }
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
private val DefaultReaderBottomToolIds: Set<String>
|
||||
get() = setOf(
|
||||
ReaderTool.SLIDER.id,
|
||||
ReaderTool.TOC.id,
|
||||
ReaderTool.FORMAT.id,
|
||||
ReaderTool.SEARCH.id,
|
||||
ReaderTool.AI_FEATURES.id,
|
||||
ReaderTool.TTS_CONTROLS.id
|
||||
)
|
||||
|
||||
enum class ReaderTool(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val category: String,
|
||||
val supportsDesktopQuickAction: Boolean = false
|
||||
) {
|
||||
DICTIONARY("dictionary", "External Apps", "Top Bar", supportsDesktopQuickAction = true),
|
||||
THEME("theme", "Theme Settings", "Top Bar", supportsDesktopQuickAction = true),
|
||||
SLIDER("slider", "Navigation Slider", "Bottom Bar"),
|
||||
TOC("toc", "Sidebar", "Bottom Bar"),
|
||||
FORMAT("format", "Text Formatting", "Bottom Bar"),
|
||||
SEARCH("search", "Search", "Bottom Bar", supportsDesktopQuickAction = true),
|
||||
AI_FEATURES("ai_features", "AI Features", "Bottom Bar", supportsDesktopQuickAction = true),
|
||||
TTS_CONTROLS("tts_controls", "TTS Controls", "Bottom Bar", supportsDesktopQuickAction = true),
|
||||
READING_MODE("reading_mode", "Reading Mode", "Overflow Menu"),
|
||||
BOOKMARK("bookmark", "Bookmark", "Overflow Menu", supportsDesktopQuickAction = true),
|
||||
TAP_TO_TURN("tap_to_turn", "Tap to Turn Pages", "Overflow Menu"),
|
||||
VOLUME_SCROLL("volume_scroll", "Volume Button Scrolling", "Overflow Menu"),
|
||||
PAGE_TURN_ANIM("page_turn_anim", "Realistic Page Turns", "Overflow Menu"),
|
||||
KEEP_SCREEN_ON("keep_screen_on", "Keep Screen On", "Overflow Menu"),
|
||||
VISUAL_OPTIONS("visual_options", "Visual Options", "Overflow Menu"),
|
||||
AUTO_SCROLL("auto_scroll", "Auto Scroll", "Overflow Menu", supportsDesktopQuickAction = true),
|
||||
TTS_SETTINGS("tts_settings", "TTS Voice Settings", "Overflow Menu"),
|
||||
TTS_REPLACEMENTS("tts_replacements", "TTS Word Replacements", "Overflow Menu");
|
||||
|
||||
companion object {
|
||||
fun fromId(id: String): ReaderTool? {
|
||||
return entries.firstOrNull { it.id == id || it.name == id }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class ReaderToolbarPreferences(
|
||||
val hiddenToolIds: Set<String> = emptySet(),
|
||||
val toolOrder: List<ReaderTool> = ReaderTool.entries.toList(),
|
||||
val bottomToolIds: Set<String> = DefaultReaderBottomToolIds
|
||||
) {
|
||||
fun sanitized(): ReaderToolbarPreferences {
|
||||
val orderedTools = (toolOrder + ReaderTool.entries.toList())
|
||||
.distinct()
|
||||
.filter { it in ReaderTool.entries }
|
||||
val knownToolIds = ReaderTool.entries.mapTo(mutableSetOf()) { it.id }
|
||||
return copy(
|
||||
hiddenToolIds = hiddenToolIds.filterTo(mutableSetOf()) { it in knownToolIds },
|
||||
toolOrder = orderedTools,
|
||||
bottomToolIds = bottomToolIds.filterTo(mutableSetOf()) { it in knownToolIds }
|
||||
)
|
||||
}
|
||||
|
||||
fun isVisible(tool: ReaderTool): Boolean {
|
||||
return tool.id !in hiddenToolIds
|
||||
}
|
||||
|
||||
fun isBottom(tool: ReaderTool): Boolean {
|
||||
return tool.id in bottomToolIds
|
||||
}
|
||||
|
||||
fun withVisibility(tool: ReaderTool, hidden: Boolean): ReaderToolbarPreferences {
|
||||
val nextHidden = if (hidden) hiddenToolIds + tool.id else hiddenToolIds - tool.id
|
||||
return copy(hiddenToolIds = nextHidden).sanitized()
|
||||
}
|
||||
|
||||
fun withBottomPlacement(tool: ReaderTool, bottom: Boolean): ReaderToolbarPreferences {
|
||||
val nextBottom = if (bottom) bottomToolIds + tool.id else bottomToolIds - tool.id
|
||||
return copy(bottomToolIds = nextBottom).sanitized()
|
||||
}
|
||||
|
||||
fun withToolOrder(order: List<ReaderTool>): ReaderToolbarPreferences {
|
||||
return copy(toolOrder = order).sanitized()
|
||||
}
|
||||
|
||||
fun orderedVisibleTools(): List<ReaderTool> {
|
||||
return sanitized().toolOrder.filter(::isVisible)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val defaultBottomToolIds: Set<String> get() = DefaultReaderBottomToolIds
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,353 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
data class ReaderTtsReplacementRule(
|
||||
val id: String,
|
||||
val from: String,
|
||||
val to: String,
|
||||
val enabled: Boolean = true,
|
||||
val isRegex: Boolean = false,
|
||||
val matchCase: Boolean = false,
|
||||
val wholeWord: Boolean = true,
|
||||
)
|
||||
|
||||
data class ReaderTtsReplacementBookSettings(
|
||||
val localRulesEnabled: Boolean = true,
|
||||
val globalRulesEnabled: Boolean = true,
|
||||
val disabledGlobalRuleIds: Set<String> = emptySet(),
|
||||
)
|
||||
|
||||
data class ReaderTtsReplacementPreferences(
|
||||
val isEnabled: Boolean = true,
|
||||
val globalRules: List<ReaderTtsReplacementRule> = emptyList(),
|
||||
val bookRules: Map<String, List<ReaderTtsReplacementRule>> = emptyMap(),
|
||||
val bookSettings: Map<String, ReaderTtsReplacementBookSettings> = emptyMap(),
|
||||
) {
|
||||
fun settingsForBook(bookId: String?): ReaderTtsReplacementBookSettings {
|
||||
return bookSettings[bookId.orEmpty()] ?: ReaderTtsReplacementBookSettings()
|
||||
}
|
||||
|
||||
fun rulesForBook(bookId: String?): List<ReaderTtsReplacementRule> {
|
||||
return bookRules[bookId.orEmpty()].orEmpty()
|
||||
}
|
||||
|
||||
fun activeRulesForBook(bookId: String?): List<ReaderTtsReplacementRule> {
|
||||
if (!isEnabled) return emptyList()
|
||||
val settings = settingsForBook(bookId)
|
||||
val inherited = if (settings.globalRulesEnabled) {
|
||||
globalRules.filter { it.id !in settings.disabledGlobalRuleIds }
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
val local = if (settings.localRulesEnabled) rulesForBook(bookId) else emptyList()
|
||||
return inherited + local
|
||||
}
|
||||
|
||||
fun withBookSettings(
|
||||
bookId: String?,
|
||||
settings: ReaderTtsReplacementBookSettings,
|
||||
): ReaderTtsReplacementPreferences {
|
||||
return copy(bookSettings = bookSettings + (bookId.orEmpty() to settings))
|
||||
}
|
||||
|
||||
fun withBookRules(
|
||||
bookId: String?,
|
||||
rules: List<ReaderTtsReplacementRule>,
|
||||
): ReaderTtsReplacementPreferences {
|
||||
return copy(bookRules = bookRules + (bookId.orEmpty() to rules))
|
||||
}
|
||||
}
|
||||
|
||||
data class ReaderTtsReplacementValidation(
|
||||
val isValid: Boolean,
|
||||
val message: String? = null,
|
||||
)
|
||||
|
||||
data class ReaderTtsReplacementError(
|
||||
val ruleId: String,
|
||||
val message: String,
|
||||
)
|
||||
|
||||
data class ReaderTtsReplacementApplyResult(
|
||||
val text: String,
|
||||
val appliedRuleIds: List<String> = emptyList(),
|
||||
val errors: List<ReaderTtsReplacementError> = emptyList(),
|
||||
) {
|
||||
val hasUnmappableChanges: Boolean
|
||||
get() = appliedRuleIds.isNotEmpty()
|
||||
}
|
||||
|
||||
object ReaderTtsReplacementEngine {
|
||||
fun validate(rule: ReaderTtsReplacementRule): ReaderTtsReplacementValidation {
|
||||
if (rule.from.isBlank()) {
|
||||
return ReaderTtsReplacementValidation(isValid = false, message = "Enter text to replace.")
|
||||
}
|
||||
if (!rule.isRegex) {
|
||||
return ReaderTtsReplacementValidation(isValid = true)
|
||||
}
|
||||
return runCatching { rule.toRegex() }
|
||||
.fold(
|
||||
onSuccess = { ReaderTtsReplacementValidation(isValid = true) },
|
||||
onFailure = {
|
||||
ReaderTtsReplacementValidation(
|
||||
isValid = false,
|
||||
message = it.message ?: "This regex is not valid.",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun apply(
|
||||
text: String,
|
||||
preferences: ReaderTtsReplacementPreferences,
|
||||
bookId: String? = null,
|
||||
): ReaderTtsReplacementApplyResult {
|
||||
if (text.isEmpty() || !preferences.isEnabled) {
|
||||
return ReaderTtsReplacementApplyResult(text = text)
|
||||
}
|
||||
|
||||
var current = text
|
||||
val applied = mutableListOf<String>()
|
||||
val errors = mutableListOf<ReaderTtsReplacementError>()
|
||||
|
||||
preferences.activeRulesForBook(bookId).forEach { rule ->
|
||||
if (!rule.enabled || rule.from.isBlank()) return@forEach
|
||||
val regex = runCatching { rule.toRegex() }
|
||||
.onFailure {
|
||||
errors += ReaderTtsReplacementError(
|
||||
ruleId = rule.id,
|
||||
message = it.message ?: "Invalid regex.",
|
||||
)
|
||||
}
|
||||
.getOrNull() ?: return@forEach
|
||||
val replacement = if (rule.isRegex) rule.to else Regex.escapeReplacement(rule.to)
|
||||
val next = runCatching { regex.replace(current, replacement) }
|
||||
.onFailure {
|
||||
errors += ReaderTtsReplacementError(
|
||||
ruleId = rule.id,
|
||||
message = it.message ?: "Invalid replacement.",
|
||||
)
|
||||
}
|
||||
.getOrNull() ?: return@forEach
|
||||
if (next != current) {
|
||||
applied += rule.id
|
||||
current = next
|
||||
}
|
||||
}
|
||||
|
||||
return ReaderTtsReplacementApplyResult(
|
||||
text = current,
|
||||
appliedRuleIds = applied,
|
||||
errors = errors,
|
||||
)
|
||||
}
|
||||
|
||||
private fun ReaderTtsReplacementRule.toRegex(): Regex {
|
||||
val source = if (isRegex) from else Regex.escape(from)
|
||||
val boundedSource = if (wholeWord) {
|
||||
"""(?<![\p{L}\p{N}_])(?:$source)(?![\p{L}\p{N}_])"""
|
||||
} else {
|
||||
source
|
||||
}
|
||||
val options = if (matchCase) emptySet() else setOf(RegexOption.IGNORE_CASE)
|
||||
return Regex(pattern = boundedSource, options = options)
|
||||
}
|
||||
}
|
||||
|
||||
fun ReaderTtsChunk.withTtsReplacements(
|
||||
preferences: ReaderTtsReplacementPreferences,
|
||||
bookId: String? = null,
|
||||
): ReaderTtsChunk {
|
||||
val result = ReaderTtsReplacementEngine.apply(
|
||||
text = text,
|
||||
preferences = preferences,
|
||||
bookId = bookId,
|
||||
)
|
||||
return copy(spokenText = result.text)
|
||||
}
|
||||
|
||||
fun List<ReaderTtsChunk>.withTtsReplacements(
|
||||
preferences: ReaderTtsReplacementPreferences,
|
||||
bookId: String? = null,
|
||||
): List<ReaderTtsChunk> = map { it.withTtsReplacements(preferences, bookId) }
|
||||
|
||||
object ReaderTtsReplacementSuggestions {
|
||||
val presets: List<ReaderTtsReplacementRule> = listOf(
|
||||
ReaderTtsReplacementRule(
|
||||
id = "suggestion_dr",
|
||||
from = "Dr.",
|
||||
to = "Doctor",
|
||||
wholeWord = false,
|
||||
),
|
||||
ReaderTtsReplacementRule(
|
||||
id = "suggestion_mr",
|
||||
from = "Mr.",
|
||||
to = "Mister",
|
||||
wholeWord = false,
|
||||
),
|
||||
ReaderTtsReplacementRule(
|
||||
id = "suggestion_mrs",
|
||||
from = "Mrs.",
|
||||
to = "Missus",
|
||||
wholeWord = false,
|
||||
),
|
||||
ReaderTtsReplacementRule(
|
||||
id = "suggestion_ms",
|
||||
from = "Ms.",
|
||||
to = "Miss",
|
||||
wholeWord = false,
|
||||
),
|
||||
ReaderTtsReplacementRule(
|
||||
id = "suggestion_vs",
|
||||
from = "vs.",
|
||||
to = "versus",
|
||||
wholeWord = false,
|
||||
),
|
||||
ReaderTtsReplacementRule(
|
||||
id = "suggestion_et_al",
|
||||
from = "et al.",
|
||||
to = "and others",
|
||||
wholeWord = false,
|
||||
),
|
||||
ReaderTtsReplacementRule(
|
||||
id = "suggestion_initials",
|
||||
from = """\b([A-Z])\.\s*([A-Z])\.""",
|
||||
to = "\$1 \$2",
|
||||
isRegex = true,
|
||||
wholeWord = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
object ReaderTtsReplacementPreferencesJson {
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
prettyPrint = false
|
||||
}
|
||||
|
||||
fun encode(preferences: ReaderTtsReplacementPreferences): String {
|
||||
return json.encodeToString(JsonElement.serializer(), toJsonElement(preferences))
|
||||
}
|
||||
|
||||
fun decodeOrEmpty(raw: String?): ReaderTtsReplacementPreferences {
|
||||
if (raw.isNullOrBlank()) return ReaderTtsReplacementPreferences()
|
||||
return runCatching {
|
||||
fromJsonElement(json.parseToJsonElement(raw))
|
||||
}.getOrNull() ?: ReaderTtsReplacementPreferences()
|
||||
}
|
||||
|
||||
fun toJsonElement(preferences: ReaderTtsReplacementPreferences): JsonElement {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"isEnabled" to JsonPrimitive(preferences.isEnabled),
|
||||
"globalRules" to rulesToJson(preferences.globalRules),
|
||||
"bookRules" to JsonObject(
|
||||
preferences.bookRules.mapValues { (_, rules) -> rulesToJson(rules) as JsonElement },
|
||||
),
|
||||
"bookSettings" to JsonObject(
|
||||
preferences.bookSettings.mapValues { (_, settings) -> settingsToJson(settings) as JsonElement },
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun fromJsonElement(element: JsonElement?): ReaderTtsReplacementPreferences {
|
||||
val root = element as? JsonObject ?: return ReaderTtsReplacementPreferences()
|
||||
val bookRules = root["bookRules"]?.jsonObjectOrNull()
|
||||
?.mapValues { (_, value) -> value.jsonArrayOrNull()?.mapNotNull(::ruleFromJson).orEmpty() }
|
||||
.orEmpty()
|
||||
val bookSettings = root["bookSettings"]?.jsonObjectOrNull()
|
||||
?.mapValues { (_, value) -> settingsFromJson(value) }
|
||||
.orEmpty()
|
||||
return ReaderTtsReplacementPreferences(
|
||||
isEnabled = root.booleanValue("isEnabled") ?: true,
|
||||
globalRules = root["globalRules"]?.jsonArrayOrNull()?.mapNotNull(::ruleFromJson).orEmpty(),
|
||||
bookRules = bookRules,
|
||||
bookSettings = bookSettings,
|
||||
)
|
||||
}
|
||||
|
||||
private fun rulesToJson(rules: List<ReaderTtsReplacementRule>): JsonArray {
|
||||
return JsonArray(rules.map(::ruleToJson))
|
||||
}
|
||||
|
||||
private fun ruleToJson(rule: ReaderTtsReplacementRule): JsonObject {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"id" to JsonPrimitive(rule.id),
|
||||
"from" to JsonPrimitive(rule.from),
|
||||
"to" to JsonPrimitive(rule.to),
|
||||
"enabled" to JsonPrimitive(rule.enabled),
|
||||
"isRegex" to JsonPrimitive(rule.isRegex),
|
||||
"matchCase" to JsonPrimitive(rule.matchCase),
|
||||
"wholeWord" to JsonPrimitive(rule.wholeWord),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun ruleFromJson(element: JsonElement): ReaderTtsReplacementRule? {
|
||||
val root = element as? JsonObject ?: return null
|
||||
val id = root.stringValue("id") ?: return null
|
||||
val from = root.stringValue("from") ?: return null
|
||||
return ReaderTtsReplacementRule(
|
||||
id = id,
|
||||
from = from,
|
||||
to = root.stringValue("to").orEmpty(),
|
||||
enabled = root.booleanValue("enabled") ?: true,
|
||||
isRegex = root.booleanValue("isRegex") ?: false,
|
||||
matchCase = root.booleanValue("matchCase") ?: false,
|
||||
wholeWord = root.booleanValue("wholeWord") ?: true,
|
||||
)
|
||||
}
|
||||
|
||||
private fun settingsToJson(settings: ReaderTtsReplacementBookSettings): JsonObject {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"localRulesEnabled" to JsonPrimitive(settings.localRulesEnabled),
|
||||
"globalRulesEnabled" to JsonPrimitive(settings.globalRulesEnabled),
|
||||
"disabledGlobalRuleIds" to JsonArray(settings.disabledGlobalRuleIds.map(::JsonPrimitive)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun settingsFromJson(element: JsonElement): ReaderTtsReplacementBookSettings {
|
||||
val root = element as? JsonObject ?: return ReaderTtsReplacementBookSettings()
|
||||
return ReaderTtsReplacementBookSettings(
|
||||
localRulesEnabled = root.booleanValue("localRulesEnabled") ?: true,
|
||||
globalRulesEnabled = root.booleanValue("globalRulesEnabled") ?: true,
|
||||
disabledGlobalRuleIds = root["disabledGlobalRuleIds"]?.jsonArrayOrNull()
|
||||
?.mapNotNull { it.jsonPrimitiveOrNull()?.contentOrNull }
|
||||
?.toSet()
|
||||
.orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonObject.stringValue(name: String): String? {
|
||||
return get(name)?.jsonPrimitiveOrNull()?.contentOrNull
|
||||
}
|
||||
|
||||
private fun JsonObject.booleanValue(name: String): Boolean? {
|
||||
return get(name)?.jsonPrimitiveOrNull()?.booleanOrNull
|
||||
}
|
||||
|
||||
private fun JsonElement.jsonObjectOrNull(): JsonObject? = this as? JsonObject
|
||||
|
||||
private fun JsonElement.jsonArrayOrNull(): JsonArray? = this as? JsonArray
|
||||
|
||||
private fun JsonElement.jsonPrimitiveOrNull() = when (this) {
|
||||
is JsonPrimitive -> this
|
||||
JsonNull -> null
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
|
@ -6,7 +6,8 @@ data class ImportedBookFile(
|
|||
val name: String,
|
||||
val uriString: String?,
|
||||
val localPath: String?,
|
||||
val size: Long
|
||||
val size: Long,
|
||||
val sourceFolder: String? = null
|
||||
)
|
||||
|
||||
interface BookRepository {
|
||||
|
|
@ -54,5 +55,7 @@ interface AiAdapter {
|
|||
interface TtsAdapter {
|
||||
val isAvailable: Boolean
|
||||
suspend fun speak(text: String)
|
||||
suspend fun pause() = Unit
|
||||
suspend fun resume() = Unit
|
||||
suspend fun stop()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,14 @@ fun BookItem.isOpdsStream(): Boolean {
|
|||
return path?.startsWith("opds-pse://") == true
|
||||
}
|
||||
|
||||
fun BookItem.matchesSourceFolders(sourceFolders: Set<String>): Boolean {
|
||||
if (sourceFolders.isEmpty()) return true
|
||||
val matchesInAppStorage = IN_APP_STORAGE_SOURCE in sourceFolders &&
|
||||
sourceFolder == null &&
|
||||
!isOpdsStream()
|
||||
return matchesInAppStorage || sourceFolder in sourceFolders
|
||||
}
|
||||
|
||||
private fun formatDecimal(value: Double, decimals: Int): String {
|
||||
val factor = 10.0.pow(decimals)
|
||||
val rounded = (value * factor).roundToInt() / factor
|
||||
|
|
|
|||
|
|
@ -0,0 +1,618 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.doubleOrNull
|
||||
import kotlinx.serialization.json.floatOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import com.aryan.reader.shared.reader.ReaderBookmark
|
||||
import com.aryan.reader.shared.reader.ReaderReadingMode
|
||||
import com.aryan.reader.shared.reader.ReaderSettings
|
||||
import com.aryan.reader.shared.reader.SharedReaderTextAlign
|
||||
|
||||
data class SharedLibrarySnapshot(
|
||||
val books: List<BookItem> = emptyList(),
|
||||
val shelfRecords: List<ShelfRecord> = emptyList(),
|
||||
val shelfRefs: List<BookShelfRef> = emptyList(),
|
||||
val tags: List<Tag> = emptyList(),
|
||||
val customFonts: List<CustomFontItem> = emptyList(),
|
||||
val syncedFolders: List<SyncedFolder> = emptyList(),
|
||||
val recentFilesLimit: Int = 12,
|
||||
val isTabsEnabled: Boolean = false,
|
||||
val openTabIds: List<String> = emptyList(),
|
||||
val activeTabBookId: String? = null,
|
||||
val pinnedHomeBookIds: Set<String> = emptySet(),
|
||||
val pinnedLibraryBookIds: Set<String> = emptySet(),
|
||||
val useStrictFileFilter: Boolean = false,
|
||||
val appThemeMode: AppThemeMode = AppThemeMode.SYSTEM,
|
||||
val appContrastOption: AppContrastOption = AppContrastOption.STANDARD,
|
||||
val appTextDimFactorLight: Float = 1.0f,
|
||||
val appTextDimFactorDark: Float = 1.0f,
|
||||
val appSeedColor: Color? = null,
|
||||
val customAppThemes: List<CustomAppTheme> = emptyList(),
|
||||
val readerToolbarPreferences: ReaderToolbarPreferences = ReaderToolbarPreferences(),
|
||||
val readerHighlightPalette: ReaderHighlightPalette = ReaderHighlightPalette(),
|
||||
val readerTtsReplacementPreferences: ReaderTtsReplacementPreferences = ReaderTtsReplacementPreferences()
|
||||
)
|
||||
|
||||
object SharedLibrarySnapshotJson {
|
||||
private const val SCHEMA_VERSION = 10
|
||||
|
||||
private val json = Json {
|
||||
prettyPrint = true
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
|
||||
fun decodeOrEmpty(rawJson: String): SharedLibrarySnapshot {
|
||||
val root = runCatching {
|
||||
json.parseToJsonElement(rawJson).jsonObject
|
||||
}.getOrNull() ?: return SharedLibrarySnapshot()
|
||||
|
||||
val schemaVersion = root.int("schemaVersion", 1)
|
||||
val openTabIds = root.stringArray("openTabIds")
|
||||
return SharedLibrarySnapshot(
|
||||
books = root.array("books")
|
||||
.mapNotNull { it.asBookItemOrNull() }
|
||||
.migrateLegacyRecentState(schemaVersion, openTabIds),
|
||||
shelfRecords = root.array("shelves").mapNotNull { it.asShelfRecordOrNull() },
|
||||
shelfRefs = root.array("bookShelfRefs").mapNotNull { it.asBookShelfRefOrNull() },
|
||||
tags = root.array("tags").mapNotNull { it.asTagOrNull() },
|
||||
customFonts = root.array("customFonts").mapNotNull { it.asCustomFontItemOrNull() },
|
||||
syncedFolders = root.array("syncedFolders").mapNotNull { it.asSyncedFolderOrNull() },
|
||||
recentFilesLimit = root.int("recentFilesLimit", 12),
|
||||
isTabsEnabled = root.boolean("isTabsEnabled", false),
|
||||
openTabIds = openTabIds,
|
||||
activeTabBookId = root.string("activeTabBookId"),
|
||||
pinnedHomeBookIds = root.stringArray("pinnedHomeBookIds").toSet(),
|
||||
pinnedLibraryBookIds = root.stringArray("pinnedLibraryBookIds").toSet(),
|
||||
useStrictFileFilter = root.boolean("useStrictFileFilter", false),
|
||||
appThemeMode = root.string("appThemeMode")
|
||||
?.let { runCatching { AppThemeMode.valueOf(it) }.getOrNull() }
|
||||
?: AppThemeMode.SYSTEM,
|
||||
appContrastOption = root.string("appContrastOption")
|
||||
?.let { runCatching { AppContrastOption.valueOf(it) }.getOrNull() }
|
||||
?: AppContrastOption.STANDARD,
|
||||
appTextDimFactorLight = root.float("appTextDimFactorLight")
|
||||
?: root.float("appTextDimFactor")
|
||||
?: 1.0f,
|
||||
appTextDimFactorDark = root.float("appTextDimFactorDark")
|
||||
?: root.float("appTextDimFactor")
|
||||
?: 1.0f,
|
||||
appSeedColor = root.int("appSeedColor")?.let { Color(it) },
|
||||
customAppThemes = root.array("customAppThemes").mapNotNull { it.asCustomAppThemeOrNull() },
|
||||
readerToolbarPreferences = root["readerToolbarPreferences"]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.asReaderToolbarPreferencesOrNull()
|
||||
?: ReaderToolbarPreferences(),
|
||||
readerHighlightPalette = root["readerHighlightPalette"]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.asReaderHighlightPaletteOrNull()
|
||||
?: ReaderHighlightPalette(),
|
||||
readerTtsReplacementPreferences = root["readerTtsReplacementPreferences"]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.let { ReaderTtsReplacementPreferencesJson.fromJsonElement(it) }
|
||||
?: ReaderTtsReplacementPreferences()
|
||||
)
|
||||
}
|
||||
|
||||
fun encode(snapshot: SharedLibrarySnapshot): String {
|
||||
val root = JsonObject(
|
||||
mapOf(
|
||||
"schemaVersion" to JsonPrimitive(SCHEMA_VERSION),
|
||||
"books" to JsonArray(snapshot.books.map { it.toJsonObject() }),
|
||||
"shelves" to JsonArray(snapshot.shelfRecords.map { it.toJsonObject() }),
|
||||
"bookShelfRefs" to JsonArray(snapshot.shelfRefs.map { it.toJsonObject() }),
|
||||
"tags" to JsonArray(snapshot.tags.map { it.toJsonObject() }),
|
||||
"customFonts" to JsonArray(snapshot.customFonts.map { it.toJsonObject() }),
|
||||
"syncedFolders" to JsonArray(snapshot.syncedFolders.map { it.toJsonObject() }),
|
||||
"recentFilesLimit" to JsonPrimitive(snapshot.recentFilesLimit),
|
||||
"isTabsEnabled" to JsonPrimitive(snapshot.isTabsEnabled),
|
||||
"openTabIds" to snapshot.openTabIds.asJsonArray(),
|
||||
"activeTabBookId" to snapshot.activeTabBookId.asJson(),
|
||||
"pinnedHomeBookIds" to snapshot.pinnedHomeBookIds.toList().asJsonArray(),
|
||||
"pinnedLibraryBookIds" to snapshot.pinnedLibraryBookIds.toList().asJsonArray(),
|
||||
"useStrictFileFilter" to JsonPrimitive(snapshot.useStrictFileFilter),
|
||||
"appThemeMode" to JsonPrimitive(snapshot.appThemeMode.name),
|
||||
"appContrastOption" to JsonPrimitive(snapshot.appContrastOption.name),
|
||||
"appTextDimFactorLight" to JsonPrimitive(snapshot.appTextDimFactorLight),
|
||||
"appTextDimFactorDark" to JsonPrimitive(snapshot.appTextDimFactorDark),
|
||||
"appSeedColor" to snapshot.appSeedColor.asJson(),
|
||||
"customAppThemes" to JsonArray(snapshot.customAppThemes.map { it.toJsonObject() }),
|
||||
"readerToolbarPreferences" to snapshot.readerToolbarPreferences.sanitized().toJsonObject(),
|
||||
"readerHighlightPalette" to snapshot.readerHighlightPalette.sanitized().toJsonObject(),
|
||||
"readerTtsReplacementPreferences" to ReaderTtsReplacementPreferencesJson.toJsonElement(
|
||||
snapshot.readerTtsReplacementPreferences,
|
||||
)
|
||||
)
|
||||
)
|
||||
return json.encodeToString(JsonElement.serializer(), root)
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonObject.array(name: String): List<JsonElement> {
|
||||
return runCatching { this[name]?.jsonArray?.toList().orEmpty() }.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
private fun JsonObject.stringArray(name: String): List<String> {
|
||||
return array(name).mapNotNull { element ->
|
||||
runCatching { element.jsonPrimitive.content }.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonObject.string(name: String): String? {
|
||||
return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.content }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.long(name: String, fallback: Long = 0L): Long {
|
||||
return runCatching { this[name]?.jsonPrimitive?.longOrNull }.getOrNull() ?: fallback
|
||||
}
|
||||
|
||||
private fun JsonObject.nullableLong(name: String): Long? {
|
||||
return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.longOrNull }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.int(name: String): Int? {
|
||||
return runCatching { this[name]?.jsonPrimitive?.content?.toIntOrNull() }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.int(name: String, fallback: Int): Int {
|
||||
return int(name) ?: fallback
|
||||
}
|
||||
|
||||
private fun JsonObject.float(name: String): Float? {
|
||||
return runCatching { this[name]?.jsonPrimitive?.floatOrNull }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.double(name: String): Double? {
|
||||
return runCatching { this[name]?.jsonPrimitive?.doubleOrNull }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.boolean(name: String, fallback: Boolean): Boolean {
|
||||
return runCatching { this[name]?.jsonPrimitive?.booleanOrNull }.getOrNull() ?: fallback
|
||||
}
|
||||
|
||||
private fun List<BookItem>.migrateLegacyRecentState(schemaVersion: Int, openTabIds: List<String>): List<BookItem> {
|
||||
if (schemaVersion >= 3) return this
|
||||
val openedBookIds = openTabIds.toSet()
|
||||
return map { book ->
|
||||
if (book.isRecent && !book.hasReaderFootprint(openedBookIds)) {
|
||||
book.copy(isRecent = false)
|
||||
} else {
|
||||
book
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun BookItem.hasReaderFootprint(openedBookIds: Set<String>): Boolean {
|
||||
return id in openedBookIds ||
|
||||
lastPageIndex != null ||
|
||||
(progressPercentage ?: 0f) > 0f ||
|
||||
readerSettings != null ||
|
||||
readerBookmarks.isNotEmpty() ||
|
||||
readerHighlights.isNotEmpty()
|
||||
}
|
||||
|
||||
private fun JsonElement.asBookItemOrNull(): BookItem? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
val id = obj.string("id") ?: return null
|
||||
val displayName = obj.string("displayName") ?: return null
|
||||
val type = obj.string("type")?.let { runCatching { FileType.valueOf(it) }.getOrNull() } ?: FileType.UNKNOWN
|
||||
return BookItem(
|
||||
id = id,
|
||||
path = obj.string("path"),
|
||||
type = type,
|
||||
displayName = displayName,
|
||||
timestamp = obj.long("timestamp"),
|
||||
coverImagePath = obj.string("coverImagePath"),
|
||||
title = obj.string("title"),
|
||||
author = obj.string("author"),
|
||||
progressPercentage = obj.float("progressPercentage"),
|
||||
isRecent = obj.boolean("isRecent", true),
|
||||
fileSize = obj.long("fileSize"),
|
||||
sourceFolder = obj.string("sourceFolder"),
|
||||
folderTextMetadataParsed = obj.boolean("folderTextMetadataParsed", false),
|
||||
seriesName = obj.string("seriesName"),
|
||||
seriesIndex = obj.double("seriesIndex"),
|
||||
tags = obj.array("tags").mapNotNull { it.asTagOrNull() },
|
||||
lastPageIndex = obj.int("lastPageIndex"),
|
||||
readerSettings = obj["readerSettings"]?.takeUnless { it is JsonNull }?.asReaderSettingsOrNull(),
|
||||
readerBookmarks = obj.array("readerBookmarks").mapNotNull { it.asReaderBookmarkOrNull() },
|
||||
readerHighlights = obj.array("readerHighlights").mapNotNull { it.asReaderHighlightOrNull() }
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonElement.asShelfRecordOrNull(): ShelfRecord? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
return ShelfRecord(
|
||||
id = obj.string("id") ?: return null,
|
||||
name = obj.string("name") ?: return null,
|
||||
isSmart = obj.boolean("isSmart", false),
|
||||
smartRulesJson = obj.string("smartRulesJson")
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonElement.asBookShelfRefOrNull(): BookShelfRef? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
return BookShelfRef(
|
||||
bookId = obj.string("bookId") ?: return null,
|
||||
shelfId = obj.string("shelfId") ?: return null,
|
||||
addedAt = obj.long("addedAt")
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonElement.asTagOrNull(): Tag? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
return Tag(
|
||||
id = obj.string("id") ?: return null,
|
||||
name = obj.string("name") ?: return null,
|
||||
color = runCatching {
|
||||
obj["color"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.content?.toIntOrNull()
|
||||
}.getOrNull()
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonElement.asCustomFontItemOrNull(): CustomFontItem? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
return CustomFontItem(
|
||||
id = obj.string("id") ?: return null,
|
||||
displayName = obj.string("displayName") ?: return null,
|
||||
fileName = obj.string("fileName") ?: return null,
|
||||
fileExtension = obj.string("fileExtension") ?: return null,
|
||||
path = obj.string("path") ?: return null,
|
||||
timestamp = obj.long("timestamp"),
|
||||
isDeleted = obj.boolean("isDeleted", false)
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonElement.asSyncedFolderOrNull(): SyncedFolder? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
return SyncedFolder(
|
||||
uriString = obj.string("uriString") ?: return null,
|
||||
name = obj.string("name") ?: return null,
|
||||
lastScanTime = obj.long("lastScanTime"),
|
||||
allowedFileTypes = obj.stringArray("allowedFileTypes")
|
||||
.mapNotNull { runCatching { FileType.valueOf(it) }.getOrNull() }
|
||||
.toSet()
|
||||
.ifEmpty { FileType.entries.toSet() }
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonElement.asCustomAppThemeOrNull(): CustomAppTheme? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
return CustomAppTheme(
|
||||
id = obj.string("id") ?: return null,
|
||||
name = obj.string("name") ?: return null,
|
||||
seedColor = obj.int("seedColor")?.let { Color(it) } ?: return null
|
||||
)
|
||||
}
|
||||
|
||||
private fun BookItem.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"id" to JsonPrimitive(id),
|
||||
"path" to path.asJson(),
|
||||
"type" to JsonPrimitive(type.name),
|
||||
"displayName" to JsonPrimitive(displayName),
|
||||
"timestamp" to JsonPrimitive(timestamp),
|
||||
"coverImagePath" to coverImagePath.asJson(),
|
||||
"title" to title.asJson(),
|
||||
"author" to author.asJson(),
|
||||
"progressPercentage" to progressPercentage.asJson(),
|
||||
"isRecent" to JsonPrimitive(isRecent),
|
||||
"fileSize" to JsonPrimitive(fileSize),
|
||||
"sourceFolder" to sourceFolder.asJson(),
|
||||
"folderTextMetadataParsed" to JsonPrimitive(folderTextMetadataParsed),
|
||||
"seriesName" to seriesName.asJson(),
|
||||
"seriesIndex" to seriesIndex.asJson(),
|
||||
"tags" to JsonArray(tags.map { it.toJsonObject() }),
|
||||
"lastPageIndex" to lastPageIndex.asJson(),
|
||||
"readerSettings" to readerSettings.asJson(),
|
||||
"readerBookmarks" to JsonArray(readerBookmarks.map { it.toJsonObject() }),
|
||||
"readerHighlights" to JsonArray(readerHighlights.map { it.toJsonObject() })
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun ShelfRecord.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"id" to JsonPrimitive(id),
|
||||
"name" to JsonPrimitive(name),
|
||||
"isSmart" to JsonPrimitive(isSmart),
|
||||
"smartRulesJson" to smartRulesJson.asJson()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun BookShelfRef.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"bookId" to JsonPrimitive(bookId),
|
||||
"shelfId" to JsonPrimitive(shelfId),
|
||||
"addedAt" to JsonPrimitive(addedAt)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun Tag.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"id" to JsonPrimitive(id),
|
||||
"name" to JsonPrimitive(name),
|
||||
"color" to color.asJson()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun CustomFontItem.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"id" to JsonPrimitive(id),
|
||||
"displayName" to JsonPrimitive(displayName),
|
||||
"fileName" to JsonPrimitive(fileName),
|
||||
"fileExtension" to JsonPrimitive(fileExtension),
|
||||
"path" to JsonPrimitive(path),
|
||||
"timestamp" to JsonPrimitive(timestamp),
|
||||
"isDeleted" to JsonPrimitive(isDeleted)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun SyncedFolder.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"uriString" to JsonPrimitive(uriString),
|
||||
"name" to JsonPrimitive(name),
|
||||
"lastScanTime" to JsonPrimitive(lastScanTime),
|
||||
"allowedFileTypes" to allowedFileTypes.map { it.name }.sorted().asJsonArray()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun CustomAppTheme.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"id" to JsonPrimitive(id),
|
||||
"name" to JsonPrimitive(name),
|
||||
"seedColor" to JsonPrimitive(seedColor.toArgb())
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun String?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull
|
||||
private fun Float?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull
|
||||
private fun Double?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull
|
||||
private fun Int?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull
|
||||
private fun Long?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull
|
||||
private fun Color?.asJson(): JsonElement = this?.let { JsonPrimitive(it.toArgb()) } ?: JsonNull
|
||||
|
||||
private fun List<String>.asJsonArray(): JsonArray {
|
||||
return JsonArray(map { JsonPrimitive(it) })
|
||||
}
|
||||
|
||||
private fun JsonElement.asReaderSettingsOrNull(): ReaderSettings? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
val defaults = ReaderSettings()
|
||||
return ReaderSettings(
|
||||
fontSize = obj.int("fontSize") ?: defaults.fontSize,
|
||||
lineSpacing = obj.float("lineSpacing") ?: defaults.lineSpacing,
|
||||
margin = obj.int("margin") ?: defaults.margin,
|
||||
darkMode = obj.boolean("darkMode", defaults.darkMode),
|
||||
readingMode = obj.string("readingMode")
|
||||
?.let { runCatching { ReaderReadingMode.valueOf(it) }.getOrNull() }
|
||||
?: defaults.readingMode,
|
||||
textAlign = obj.string("textAlign")
|
||||
?.let { runCatching { SharedReaderTextAlign.valueOf(it) }.getOrNull() }
|
||||
?: defaults.textAlign,
|
||||
pageWidth = obj.int("pageWidth") ?: defaults.pageWidth,
|
||||
fontFamily = obj.string("fontFamily") ?: defaults.fontFamily,
|
||||
paragraphSpacing = obj.float("paragraphSpacing") ?: defaults.paragraphSpacing,
|
||||
imageScale = obj.float("imageScale") ?: defaults.imageScale,
|
||||
horizontalMargin = obj.int("horizontalMargin"),
|
||||
verticalMargin = obj.int("verticalMargin"),
|
||||
themeId = obj.string("themeId"),
|
||||
textureId = obj.string("textureId"),
|
||||
textureAlpha = obj.float("textureAlpha") ?: defaults.textureAlpha,
|
||||
customFontPath = obj.string("customFontPath"),
|
||||
backgroundColorArgb = obj.nullableLong("backgroundColorArgb"),
|
||||
textColorArgb = obj.nullableLong("textColorArgb"),
|
||||
systemUiMode = obj.string("systemUiMode")
|
||||
?.let { runCatching { SystemUiMode.valueOf(it) }.getOrNull() }
|
||||
?: defaults.systemUiMode,
|
||||
pageInfoMode = obj.string("pageInfoMode")
|
||||
?.let { runCatching { PageInfoMode.valueOf(it) }.getOrNull() }
|
||||
?: defaults.pageInfoMode,
|
||||
pageInfoPosition = obj.string("pageInfoPosition")
|
||||
?.let { runCatching { PageInfoPosition.valueOf(it) }.getOrNull() }
|
||||
?: defaults.pageInfoPosition,
|
||||
seamlessChapterNavigation = obj.boolean("seamlessChapterNavigation", defaults.seamlessChapterNavigation),
|
||||
chapterTurnDragMultiplier = obj.float("chapterTurnDragMultiplier") ?: defaults.chapterTurnDragMultiplier
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonElement.asReaderToolbarPreferencesOrNull(): ReaderToolbarPreferences? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
val order = obj.stringArray("toolOrder").mapNotNull(ReaderTool::fromId)
|
||||
val bottomToolIds = if (obj["bottomToolIds"] == null) {
|
||||
ReaderToolbarPreferences.defaultBottomToolIds
|
||||
} else {
|
||||
obj.stringArray("bottomToolIds").toSet()
|
||||
}
|
||||
return ReaderToolbarPreferences(
|
||||
hiddenToolIds = obj.stringArray("hiddenToolIds").toSet(),
|
||||
toolOrder = order.ifEmpty { ReaderTool.entries.toList() },
|
||||
bottomToolIds = bottomToolIds
|
||||
).sanitized()
|
||||
}
|
||||
|
||||
private fun JsonElement.asReaderHighlightPaletteOrNull(): ReaderHighlightPalette? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
val colors = obj.stringArray("colorIds")
|
||||
.mapNotNull { colorId -> HighlightColor.entries.firstOrNull { it.id == colorId || it.name == colorId } }
|
||||
return ReaderHighlightPalette(colors = colors).sanitized()
|
||||
}
|
||||
|
||||
private fun JsonElement.asReaderBookmarkOrNull(): ReaderBookmark? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
val pageIndex = obj.int("pageIndex") ?: return null
|
||||
return ReaderBookmark(
|
||||
id = obj.string("id") ?: return null,
|
||||
pageIndex = pageIndex,
|
||||
chapterTitle = obj.string("chapterTitle") ?: "",
|
||||
preview = obj.string("preview") ?: "",
|
||||
locator = obj["locator"]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.asReaderLocatorOrNull()
|
||||
?.withFallbacks(pageIndex = pageIndex, textQuote = obj.string("preview") ?: "")
|
||||
?: ReaderLocator(
|
||||
pageIndex = pageIndex,
|
||||
textQuote = obj.string("preview") ?: ""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonElement.asReaderHighlightOrNull(): UserHighlight? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
val cfi = obj.string("cfi") ?: return null
|
||||
val text = obj.string("text") ?: return null
|
||||
val chapterIndex = obj.int("chapterIndex") ?: return null
|
||||
val color = obj.string("colorId")
|
||||
?.let { colorId -> HighlightColor.entries.firstOrNull { it.id == colorId } }
|
||||
?: HighlightColor.YELLOW
|
||||
return UserHighlight(
|
||||
id = obj.string("id") ?: return null,
|
||||
cfi = cfi,
|
||||
text = text,
|
||||
color = color,
|
||||
chapterIndex = chapterIndex,
|
||||
note = obj.string("note")?.takeIf { it.isNotBlank() },
|
||||
locator = obj["locator"]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.asReaderLocatorOrNull()
|
||||
?.withFallbacks(
|
||||
chapterIndex = chapterIndex,
|
||||
cfi = cfi,
|
||||
textQuote = text
|
||||
)
|
||||
?: ReaderLocator.fromLegacy(
|
||||
chapterIndex = chapterIndex,
|
||||
cfi = cfi,
|
||||
textQuote = text
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonElement.asReaderLocatorOrNull(): ReaderLocator? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
return ReaderLocator(
|
||||
chapterIndex = obj.int("chapterIndex"),
|
||||
chapterId = obj.string("chapterId"),
|
||||
href = obj.string("href"),
|
||||
pageIndex = obj.int("pageIndex"),
|
||||
startOffset = obj.int("startOffset"),
|
||||
endOffset = obj.int("endOffset"),
|
||||
textQuote = obj.string("textQuote"),
|
||||
cfi = obj.string("cfi")
|
||||
)
|
||||
}
|
||||
|
||||
private fun ReaderSettings?.asJson(): JsonElement {
|
||||
val settings = this ?: return JsonNull
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"fontSize" to JsonPrimitive(settings.fontSize),
|
||||
"lineSpacing" to JsonPrimitive(settings.lineSpacing),
|
||||
"margin" to JsonPrimitive(settings.margin),
|
||||
"darkMode" to JsonPrimitive(settings.darkMode),
|
||||
"readingMode" to JsonPrimitive(settings.readingMode.name),
|
||||
"textAlign" to JsonPrimitive(settings.textAlign.name),
|
||||
"pageWidth" to JsonPrimitive(settings.pageWidth),
|
||||
"fontFamily" to JsonPrimitive(settings.fontFamily),
|
||||
"paragraphSpacing" to JsonPrimitive(settings.paragraphSpacing),
|
||||
"imageScale" to JsonPrimitive(settings.imageScale),
|
||||
"horizontalMargin" to settings.horizontalMargin.asJson(),
|
||||
"verticalMargin" to settings.verticalMargin.asJson(),
|
||||
"themeId" to settings.themeId.asJson(),
|
||||
"textureId" to settings.textureId.asJson(),
|
||||
"textureAlpha" to JsonPrimitive(settings.textureAlpha),
|
||||
"customFontPath" to settings.customFontPath.asJson(),
|
||||
"backgroundColorArgb" to settings.backgroundColorArgb.asJson(),
|
||||
"textColorArgb" to settings.textColorArgb.asJson(),
|
||||
"systemUiMode" to JsonPrimitive(settings.systemUiMode.name),
|
||||
"pageInfoMode" to JsonPrimitive(settings.pageInfoMode.name),
|
||||
"pageInfoPosition" to JsonPrimitive(settings.pageInfoPosition.name),
|
||||
"seamlessChapterNavigation" to JsonPrimitive(settings.seamlessChapterNavigation),
|
||||
"chapterTurnDragMultiplier" to JsonPrimitive(settings.chapterTurnDragMultiplier)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun ReaderToolbarPreferences.toJsonObject(): JsonObject {
|
||||
val sanitized = sanitized()
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"hiddenToolIds" to sanitized.hiddenToolIds.toList().sorted().asJsonArray(),
|
||||
"toolOrder" to sanitized.toolOrder.map { it.id }.asJsonArray(),
|
||||
"bottomToolIds" to sanitized.bottomToolIds.toList().sorted().asJsonArray()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun ReaderHighlightPalette.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"colorIds" to sanitized().colors.map { it.id }.asJsonArray()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun ReaderBookmark.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"id" to JsonPrimitive(id),
|
||||
"pageIndex" to JsonPrimitive(pageIndex),
|
||||
"chapterTitle" to JsonPrimitive(chapterTitle),
|
||||
"preview" to JsonPrimitive(preview),
|
||||
"locator" to locator.toJsonObject()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun UserHighlight.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"id" to JsonPrimitive(id),
|
||||
"cfi" to JsonPrimitive(cfi),
|
||||
"text" to JsonPrimitive(text),
|
||||
"colorId" to JsonPrimitive(color.id),
|
||||
"chapterIndex" to JsonPrimitive(chapterIndex),
|
||||
"note" to note.asJson(),
|
||||
"locator" to locator.toJsonObject()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun ReaderLocator.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
buildMap {
|
||||
chapterIndex?.let { put("chapterIndex", JsonPrimitive(it)) }
|
||||
chapterId?.let { put("chapterId", JsonPrimitive(it)) }
|
||||
href?.let { put("href", JsonPrimitive(it)) }
|
||||
pageIndex?.let { put("pageIndex", JsonPrimitive(it)) }
|
||||
startOffset?.let { put("startOffset", JsonPrimitive(it)) }
|
||||
endOffset?.let { put("endOffset", JsonPrimitive(it)) }
|
||||
textQuote?.let { put("textQuote", JsonPrimitive(it)) }
|
||||
cfi?.let { put("cfi", JsonPrimitive(it)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -1,5 +1,8 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
import com.aryan.reader.shared.reader.ReaderEngine
|
||||
import com.aryan.reader.shared.reader.ReaderSessionState
|
||||
|
||||
fun LibraryState.reduce(action: LibraryAction): LibraryState {
|
||||
return when (action) {
|
||||
is LibraryAction.SearchChanged -> copy(searchQuery = action.query)
|
||||
|
|
@ -56,7 +59,122 @@ fun SharedReaderScreenState.reduce(action: AppAction): SharedReaderScreenState {
|
|||
is AppAction.NavigationRequested -> this
|
||||
is AppAction.AppThemeChanged -> copy(appThemeMode = action.mode)
|
||||
is AppAction.AppContrastChanged -> copy(appContrastOption = action.option)
|
||||
is AppAction.AppTextDimFactorLightChanged -> copy(appTextDimFactorLight = action.factor.coerceIn(0.3f, 1.0f))
|
||||
is AppAction.AppTextDimFactorDarkChanged -> copy(appTextDimFactorDark = action.factor.coerceIn(0.3f, 1.0f))
|
||||
is AppAction.AppSeedColorChanged -> copy(appSeedColor = action.color)
|
||||
is AppAction.CustomAppThemeAdded -> {
|
||||
val updatedThemes = customAppThemes.filterNot { it.id == action.theme.id } + action.theme
|
||||
copy(customAppThemes = updatedThemes, appSeedColor = action.theme.seedColor)
|
||||
}
|
||||
is AppAction.CustomAppThemeDeleted -> {
|
||||
val updatedThemes = customAppThemes.filterNot { it.id == action.themeId }
|
||||
val shouldClearSeed = appSeedColor != null && updatedThemes.none { it.seedColor == appSeedColor }
|
||||
copy(
|
||||
customAppThemes = updatedThemes,
|
||||
appSeedColor = if (shouldClearSeed) null else appSeedColor
|
||||
)
|
||||
}
|
||||
is AppAction.SyncEnabledChanged -> copy(isSyncEnabled = action.enabled)
|
||||
is AppAction.FolderSyncEnabledChanged -> copy(isFolderSyncEnabled = action.enabled)
|
||||
is AppAction.TabsEnabledChanged -> copy(
|
||||
isTabsEnabled = action.enabled,
|
||||
openTabIds = if (action.enabled) openTabIds else emptyList(),
|
||||
activeTabBookId = if (action.enabled) activeTabBookId else null
|
||||
)
|
||||
is AppAction.BookTabOpened -> {
|
||||
val bookId = action.bookId.trim()
|
||||
if (bookId.isBlank()) {
|
||||
this
|
||||
} else {
|
||||
copy(
|
||||
isTabsEnabled = true,
|
||||
openTabIds = (openTabIds - bookId) + bookId,
|
||||
activeTabBookId = bookId
|
||||
)
|
||||
}
|
||||
}
|
||||
is AppAction.BookTabClosed -> {
|
||||
val remaining = openTabIds.filterNot { it == action.bookId }
|
||||
copy(
|
||||
openTabIds = remaining,
|
||||
activeTabBookId = if (activeTabBookId == action.bookId) remaining.lastOrNull() else activeTabBookId
|
||||
)
|
||||
}
|
||||
AppAction.AllTabsClosed -> copy(openTabIds = emptyList(), activeTabBookId = null)
|
||||
is AppAction.HomePinToggled -> copy(
|
||||
pinnedHomeBookIds = if (action.bookId in pinnedHomeBookIds) {
|
||||
pinnedHomeBookIds - action.bookId
|
||||
} else {
|
||||
pinnedHomeBookIds + action.bookId
|
||||
}
|
||||
)
|
||||
is AppAction.LibraryPinToggled -> copy(
|
||||
pinnedLibraryBookIds = if (action.bookId in pinnedLibraryBookIds) {
|
||||
pinnedLibraryBookIds - action.bookId
|
||||
} else {
|
||||
pinnedLibraryBookIds + action.bookId
|
||||
}
|
||||
)
|
||||
is AppAction.ReaderToolbarPreferencesChanged -> copy(
|
||||
readerToolbarPreferences = action.preferences.sanitized()
|
||||
)
|
||||
is AppAction.ReaderToolVisibilityChanged -> copy(
|
||||
readerToolbarPreferences = readerToolbarPreferences.withVisibility(action.tool, action.hidden)
|
||||
)
|
||||
is AppAction.ReaderToolPlacementChanged -> copy(
|
||||
readerToolbarPreferences = readerToolbarPreferences.withBottomPlacement(action.tool, action.bottom)
|
||||
)
|
||||
is AppAction.ReaderToolOrderChanged -> copy(
|
||||
readerToolbarPreferences = readerToolbarPreferences.withToolOrder(action.toolOrder)
|
||||
)
|
||||
is AppAction.ReaderHighlightPaletteChanged -> copy(
|
||||
readerHighlightPalette = action.palette.sanitized()
|
||||
)
|
||||
is AppAction.ReaderTtsReplacementPreferencesChanged -> copy(
|
||||
readerTtsReplacementPreferences = action.preferences
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun ReaderSessionState.reduce(action: ReaderAction, readerEngine: ReaderEngine): ReaderSessionState {
|
||||
return when (action) {
|
||||
ReaderAction.NextPage -> readerEngine.next(this)
|
||||
ReaderAction.PreviousPage -> readerEngine.previous(this)
|
||||
is ReaderAction.GoToPage -> readerEngine.goToPage(this, action.pageIndex)
|
||||
is ReaderAction.GoToPageNumber -> readerEngine.goToPageNumber(this, action.pageNumber)
|
||||
is ReaderAction.GoToProgress -> readerEngine.goToProgress(this, action.progress)
|
||||
is ReaderAction.GoToChapter -> readerEngine.goToChapter(this, action.chapterIndex)
|
||||
is ReaderAction.GoToLocator -> readerEngine.goToLocator(this, action.locator)
|
||||
is ReaderAction.VisiblePageChanged -> readerEngine.syncVisiblePage(this, action.pageIndex, action.locator)
|
||||
is ReaderAction.GoToSearchResult -> readerEngine.goToSearchResult(this, action.resultIndex)
|
||||
is ReaderAction.SearchChanged -> readerEngine.search(this, action.query)
|
||||
ReaderAction.SearchOpened -> readerEngine.openSearch(this)
|
||||
ReaderAction.SearchClosed -> readerEngine.closeSearch(this)
|
||||
ReaderAction.SearchResultsPanelToggled -> readerEngine.toggleSearchResultsPanel(this)
|
||||
is ReaderAction.SearchOptionsChanged -> readerEngine.updateSearchOptions(this, action.options)
|
||||
ReaderAction.NextSearchResult -> readerEngine.nextSearchResult(this)
|
||||
ReaderAction.PreviousSearchResult -> readerEngine.previousSearchResult(this)
|
||||
ReaderAction.ToggleBookmark -> readerEngine.toggleBookmark(this)
|
||||
is ReaderAction.ToggleBookmarkAtLocator -> readerEngine.toggleBookmarkAtLocator(
|
||||
state = this,
|
||||
locator = action.locator,
|
||||
chapterTitle = action.title,
|
||||
preview = action.preview
|
||||
)
|
||||
is ReaderAction.SettingsChanged -> readerEngine.updateSettings(this, action.settings)
|
||||
is ReaderAction.RenderModeChanged -> readerEngine.updateSettings(
|
||||
this,
|
||||
reader.settings.copy(readingMode = action.renderMode.toReaderReadingMode())
|
||||
)
|
||||
is ReaderAction.ThemeChanged -> readerEngine.updateSettings(this, action.theme.toReaderSettings(reader.settings))
|
||||
is ReaderAction.FormatChanged -> readerEngine.updateSettings(this, action.settings.toReaderSettings(reader.settings))
|
||||
is ReaderAction.HighlightCreated -> readerEngine.upsertHighlight(this, action.highlight)
|
||||
is ReaderAction.HighlightUpdated -> readerEngine.updateHighlight(
|
||||
state = this,
|
||||
highlightId = action.highlightId,
|
||||
color = action.color,
|
||||
note = action.note
|
||||
)
|
||||
is ReaderAction.HighlightDeleted -> readerEngine.deleteHighlight(this, action.highlightId)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
@Serializable
|
||||
enum class SmartField {
|
||||
TITLE,
|
||||
AUTHOR,
|
||||
PROGRESS,
|
||||
FILE_TYPE,
|
||||
FOLDER,
|
||||
TAG
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class SmartOperator {
|
||||
EQUALS,
|
||||
CONTAINS,
|
||||
GREATER_THAN,
|
||||
LESS_THAN
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class SmartRule(
|
||||
val field: SmartField,
|
||||
val operator: SmartOperator,
|
||||
val value: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SmartCollectionDefinition(
|
||||
val matchAll: Boolean = true,
|
||||
val rules: List<SmartRule> = emptyList()
|
||||
)
|
||||
|
||||
object SmartCollectionEngine {
|
||||
private val json = Json {
|
||||
encodeDefaults = true
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
|
||||
fun toJson(definition: SmartCollectionDefinition): String = json.encodeToString(definition)
|
||||
|
||||
fun fromJson(rawJson: String?): SmartCollectionDefinition? {
|
||||
if (rawJson.isNullOrBlank()) return null
|
||||
return runCatching {
|
||||
json.decodeFromString<SmartCollectionDefinition>(rawJson)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
fun evaluate(book: BookItem, definition: SmartCollectionDefinition): Boolean {
|
||||
if (definition.rules.isEmpty()) return false
|
||||
|
||||
val results = definition.rules.map { rule ->
|
||||
when (rule.field) {
|
||||
SmartField.TITLE -> evaluateString(book.title ?: book.displayName, rule)
|
||||
SmartField.AUTHOR -> evaluateString(book.author.orEmpty(), rule)
|
||||
SmartField.FILE_TYPE -> evaluateString(book.type.name, rule)
|
||||
SmartField.FOLDER -> evaluateString(book.sourceFolder.orEmpty(), rule)
|
||||
SmartField.TAG -> evaluateTags(book.tags.map { it.name }, rule)
|
||||
SmartField.PROGRESS -> evaluateNumber(book.progressPercentage ?: 0f, rule)
|
||||
}
|
||||
}
|
||||
return if (definition.matchAll) results.all { it } else results.any { it }
|
||||
}
|
||||
|
||||
private fun evaluateString(target: String, rule: SmartRule): Boolean {
|
||||
return when (rule.operator) {
|
||||
SmartOperator.EQUALS -> target.equals(rule.value, ignoreCase = true)
|
||||
SmartOperator.CONTAINS -> target.contains(rule.value, ignoreCase = true)
|
||||
SmartOperator.GREATER_THAN,
|
||||
SmartOperator.LESS_THAN -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun evaluateNumber(target: Float, rule: SmartRule): Boolean {
|
||||
val ruleValue = rule.value.toFloatOrNull() ?: return false
|
||||
return when (rule.operator) {
|
||||
SmartOperator.EQUALS -> target == ruleValue
|
||||
SmartOperator.GREATER_THAN -> target > ruleValue
|
||||
SmartOperator.LESS_THAN -> target < ruleValue
|
||||
SmartOperator.CONTAINS -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun evaluateTags(tags: List<String>, rule: SmartRule): Boolean {
|
||||
return when (rule.operator) {
|
||||
SmartOperator.EQUALS -> tags.any { it.equals(rule.value, ignoreCase = true) }
|
||||
SmartOperator.CONTAINS -> tags.any { it.contains(rule.value, ignoreCase = true) }
|
||||
SmartOperator.GREATER_THAN,
|
||||
SmartOperator.LESS_THAN -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
package com.aryan.reader.shared.opds
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
object SharedOpdsCatalogs {
|
||||
private val json = Json {
|
||||
prettyPrint = true
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
|
||||
fun defaultCatalogs(idFactory: () -> String): List<OpdsCatalog> {
|
||||
return listOf(
|
||||
OpdsCatalog(
|
||||
id = idFactory(),
|
||||
title = "Project Gutenberg",
|
||||
url = "https://m.gutenberg.org/ebooks.opds/",
|
||||
isDefault = true
|
||||
),
|
||||
OpdsCatalog(
|
||||
id = idFactory(),
|
||||
title = "Standard Ebooks",
|
||||
url = "https://standardebooks.org/feeds/opds",
|
||||
isDefault = true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun decode(rawJson: String?): List<OpdsCatalog> {
|
||||
if (rawJson.isNullOrBlank()) return emptyList()
|
||||
return runCatching {
|
||||
json.parseToJsonElement(rawJson)
|
||||
.jsonArray
|
||||
.mapNotNull { it.asCatalogOrNull() }
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
fun decodeOrSeed(rawJson: String?, idFactory: () -> String): List<OpdsCatalog> {
|
||||
return decode(rawJson).ifEmpty { defaultCatalogs(idFactory) }
|
||||
}
|
||||
|
||||
fun encode(catalogs: List<OpdsCatalog>): String {
|
||||
val array = JsonArray(catalogs.map { it.toJsonObject() })
|
||||
return json.encodeToString(JsonElement.serializer(), array)
|
||||
}
|
||||
|
||||
fun addCatalog(
|
||||
catalogs: List<OpdsCatalog>,
|
||||
title: String,
|
||||
url: String,
|
||||
username: String?,
|
||||
password: String?,
|
||||
idFactory: () -> String
|
||||
): List<OpdsCatalog> {
|
||||
val normalizedTitle = title.trim()
|
||||
val normalizedUrl = url.trim()
|
||||
if (normalizedTitle.isBlank() || normalizedUrl.isBlank()) return catalogs
|
||||
return catalogs + OpdsCatalog(
|
||||
id = idFactory(),
|
||||
title = normalizedTitle,
|
||||
url = normalizedUrl,
|
||||
username = username.normalizedCredential(),
|
||||
password = password.normalizedCredential()
|
||||
)
|
||||
}
|
||||
|
||||
fun updateCatalog(
|
||||
catalogs: List<OpdsCatalog>,
|
||||
id: String,
|
||||
title: String,
|
||||
url: String,
|
||||
username: String?,
|
||||
password: String?
|
||||
): List<OpdsCatalog> {
|
||||
return catalogs.map { catalog ->
|
||||
if (catalog.id != id || catalog.isDefault) {
|
||||
catalog
|
||||
} else {
|
||||
catalog.copy(
|
||||
title = title.trim(),
|
||||
url = url.trim(),
|
||||
username = username.normalizedCredential(),
|
||||
password = password.normalizedCredential()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeCatalog(catalogs: List<OpdsCatalog>, id: String): List<OpdsCatalog> {
|
||||
val catalog = catalogs.firstOrNull { it.id == id }
|
||||
if (catalog?.isDefault == true) return catalogs
|
||||
return catalogs.filterNot { it.id == id }
|
||||
}
|
||||
|
||||
private fun JsonElement.asCatalogOrNull(): OpdsCatalog? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
val id = obj.string("id") ?: return null
|
||||
val title = obj.string("title") ?: return null
|
||||
val url = obj.string("url") ?: return null
|
||||
return OpdsCatalog(
|
||||
id = id,
|
||||
title = title,
|
||||
url = url,
|
||||
isDefault = obj.boolean("isDefault") ?: false,
|
||||
username = obj.string("username").normalizedCredential(),
|
||||
password = obj.string("password").normalizedCredential()
|
||||
)
|
||||
}
|
||||
|
||||
private fun OpdsCatalog.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
buildMap {
|
||||
put("id", JsonPrimitive(id))
|
||||
put("title", JsonPrimitive(title))
|
||||
put("url", JsonPrimitive(url))
|
||||
put("isDefault", JsonPrimitive(isDefault))
|
||||
put("username", username?.let(::JsonPrimitive) ?: JsonNull)
|
||||
put("password", password?.let(::JsonPrimitive) ?: JsonNull)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonObject.string(name: String): String? {
|
||||
val value = this[name]?.takeUnless { it is JsonNull } ?: return null
|
||||
return runCatching { value.jsonPrimitive.contentOrNull }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.boolean(name: String): Boolean? {
|
||||
return runCatching { this[name]?.jsonPrimitive?.booleanOrNull }.getOrNull()
|
||||
}
|
||||
|
||||
private fun String?.normalizedCredential(): String? {
|
||||
return this?.trim()?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
package com.aryan.reader.shared.opds
|
||||
|
||||
class SharedOpdsController(
|
||||
private val repository: SharedOpdsRepository,
|
||||
private val idFactory: () -> String
|
||||
) {
|
||||
private val urlStack = mutableListOf<String>()
|
||||
|
||||
var state: SharedOpdsScreenState = SharedOpdsScreenState(catalogs = repository.loadCatalogs())
|
||||
private set
|
||||
|
||||
fun reloadCatalogs(): SharedOpdsScreenState {
|
||||
state = state.copy(catalogs = repository.loadCatalogs())
|
||||
return state
|
||||
}
|
||||
|
||||
fun addCatalog(title: String, url: String, username: String?, password: String?): SharedOpdsScreenState {
|
||||
val nextCatalogs = SharedOpdsCatalogs.addCatalog(
|
||||
catalogs = repository.loadCatalogs(),
|
||||
title = title,
|
||||
url = url,
|
||||
username = username,
|
||||
password = password,
|
||||
idFactory = idFactory
|
||||
)
|
||||
repository.saveCatalogs(nextCatalogs)
|
||||
state = state.copy(catalogs = nextCatalogs)
|
||||
return state
|
||||
}
|
||||
|
||||
fun updateCatalog(id: String, title: String, url: String, username: String?, password: String?): SharedOpdsScreenState {
|
||||
val nextCatalogs = SharedOpdsCatalogs.updateCatalog(
|
||||
catalogs = repository.loadCatalogs(),
|
||||
id = id,
|
||||
title = title,
|
||||
url = url,
|
||||
username = username,
|
||||
password = password
|
||||
)
|
||||
repository.saveCatalogs(nextCatalogs)
|
||||
state = state.copy(
|
||||
catalogs = nextCatalogs,
|
||||
currentCatalog = state.currentCatalog?.let { current ->
|
||||
nextCatalogs.firstOrNull { it.id == current.id } ?: current
|
||||
}
|
||||
)
|
||||
return state
|
||||
}
|
||||
|
||||
fun removeCatalog(id: String): SharedOpdsScreenState {
|
||||
val nextCatalogs = SharedOpdsCatalogs.removeCatalog(repository.loadCatalogs(), id)
|
||||
repository.saveCatalogs(nextCatalogs)
|
||||
state = state.copy(catalogs = nextCatalogs)
|
||||
return state
|
||||
}
|
||||
|
||||
suspend fun openCatalog(catalog: OpdsCatalog, emit: (SharedOpdsScreenState) -> Unit) {
|
||||
urlStack.clear()
|
||||
state = state.copy(searchUrlTemplate = null, currentCatalog = catalog)
|
||||
fetchUrl(catalog.url, isPagination = false, emit = emit)
|
||||
}
|
||||
|
||||
suspend fun openFeedUrl(url: String, emit: (SharedOpdsScreenState) -> Unit) {
|
||||
fetchUrl(url, isPagination = false, emit = emit)
|
||||
}
|
||||
|
||||
suspend fun loadNextPage(emit: (SharedOpdsScreenState) -> Unit) {
|
||||
val nextUrl = state.currentFeed?.nextUrl ?: return
|
||||
if (state.isLoading) return
|
||||
fetchUrl(nextUrl, isPagination = true, emit = emit)
|
||||
}
|
||||
|
||||
suspend fun navigateBack(emit: (SharedOpdsScreenState) -> Unit): Boolean {
|
||||
return if (urlStack.size > 1) {
|
||||
urlStack.removeAt(urlStack.lastIndex)
|
||||
val previousUrl = urlStack.removeAt(urlStack.lastIndex)
|
||||
fetchUrl(previousUrl, isPagination = false, emit = emit)
|
||||
true
|
||||
} else {
|
||||
urlStack.clear()
|
||||
state = state.copy(
|
||||
isViewingCatalog = false,
|
||||
currentFeed = null,
|
||||
searchUrlTemplate = null,
|
||||
currentCatalog = null
|
||||
)
|
||||
emit(state)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun search(query: String, emit: (SharedOpdsScreenState) -> Unit) {
|
||||
val searchLink = state.searchUrlTemplate ?: return
|
||||
if (query.isBlank()) return
|
||||
val catalog = state.currentCatalog
|
||||
state = state.copy(isLoading = true, errorMessage = null)
|
||||
emit(state)
|
||||
val finalUrl = runCatching {
|
||||
SharedOpdsSearch.buildSearchUrl(searchLink, query) { openSearchUrl ->
|
||||
repository.getSearchTemplate(openSearchUrl, catalog?.username, catalog?.password)
|
||||
}
|
||||
}.getOrElse { error ->
|
||||
state = state.copy(isLoading = false, errorMessage = "Failed to search catalog: ${error.message}")
|
||||
emit(state)
|
||||
return
|
||||
}
|
||||
fetchUrl(finalUrl, isPagination = false, emit = emit)
|
||||
}
|
||||
|
||||
fun clearError(): SharedOpdsScreenState {
|
||||
state = state.copy(errorMessage = null)
|
||||
return state
|
||||
}
|
||||
|
||||
fun updateDownloadState(entryId: String, downloadState: SharedOpdsDownloadState?): SharedOpdsScreenState {
|
||||
val nextMap = if (downloadState == null) {
|
||||
state.downloadingState - entryId
|
||||
} else {
|
||||
state.downloadingState + (entryId to downloadState)
|
||||
}
|
||||
state = state.copy(downloadingState = nextMap)
|
||||
return state
|
||||
}
|
||||
|
||||
private suspend fun fetchUrl(
|
||||
url: String,
|
||||
isPagination: Boolean,
|
||||
emit: (SharedOpdsScreenState) -> Unit
|
||||
) {
|
||||
val catalog = state.currentCatalog
|
||||
state = state.copy(isLoading = true, errorMessage = null, isViewingCatalog = true)
|
||||
emit(state)
|
||||
|
||||
val result = repository.fetchFeed(url, catalog?.username, catalog?.password)
|
||||
result.onSuccess { newFeed ->
|
||||
val template = newFeed.searchUrl ?: state.searchUrlTemplate
|
||||
state = if (isPagination) {
|
||||
val currentEntries = state.currentFeed?.entries.orEmpty()
|
||||
state.copy(
|
||||
isLoading = false,
|
||||
currentFeed = newFeed.copy(entries = currentEntries + newFeed.entries),
|
||||
searchUrlTemplate = template
|
||||
)
|
||||
} else {
|
||||
if (urlStack.isEmpty() || urlStack.last() != url) {
|
||||
urlStack.add(url)
|
||||
}
|
||||
state.copy(
|
||||
isLoading = false,
|
||||
currentFeed = newFeed,
|
||||
searchUrlTemplate = template
|
||||
)
|
||||
}
|
||||
}.onFailure { error ->
|
||||
state = state.copy(
|
||||
isLoading = false,
|
||||
errorMessage = "Failed to load feed: ${error.message ?: "unknown error"}"
|
||||
)
|
||||
}
|
||||
emit(state)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
package com.aryan.reader.shared.opds
|
||||
|
||||
data class OpdsCatalog(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val url: String,
|
||||
val isDefault: Boolean = false,
|
||||
val username: String? = null,
|
||||
val password: String? = null
|
||||
)
|
||||
|
||||
data class OpdsFacet(
|
||||
val title: String,
|
||||
val group: String,
|
||||
val url: String,
|
||||
val isActive: Boolean
|
||||
)
|
||||
|
||||
data class OpdsFeed(
|
||||
val title: String,
|
||||
val entries: List<OpdsEntry>,
|
||||
val nextUrl: String?,
|
||||
val searchUrl: String? = null,
|
||||
val facets: List<OpdsFacet> = emptyList()
|
||||
)
|
||||
|
||||
data class OpdsAuthor(
|
||||
val name: String,
|
||||
val url: String?
|
||||
)
|
||||
|
||||
data class OpdsAcquisition(
|
||||
val url: String,
|
||||
val mimeType: String
|
||||
) {
|
||||
val formatName: String
|
||||
get() = when {
|
||||
mimeType.contains("epub", ignoreCase = true) -> "EPUB"
|
||||
mimeType.contains("pdf", ignoreCase = true) -> "PDF"
|
||||
mimeType.contains("markdown", ignoreCase = true) ||
|
||||
mimeType.contains("text/x-markdown", ignoreCase = true) -> "MD"
|
||||
mimeType.contains("html", ignoreCase = true) ||
|
||||
mimeType.contains("xhtml", ignoreCase = true) -> "HTML"
|
||||
mimeType.contains("mobi", ignoreCase = true) ||
|
||||
mimeType.contains("x-mobipocket-ebook", ignoreCase = true) -> "MOBI"
|
||||
mimeType.contains("fictionbook", ignoreCase = true) ||
|
||||
mimeType.contains("fb2", ignoreCase = true) -> "FB2"
|
||||
mimeType.contains("cbz", ignoreCase = true) ||
|
||||
mimeType.contains("comicbook", ignoreCase = true) -> "CBZ"
|
||||
mimeType.contains("cbr", ignoreCase = true) ||
|
||||
mimeType.contains("rar", ignoreCase = true) -> "CBR"
|
||||
mimeType.contains("txt", ignoreCase = true) ||
|
||||
mimeType.contains("text/plain", ignoreCase = true) -> "TXT"
|
||||
else -> mimeType.substringAfterLast("/").uppercase()
|
||||
}
|
||||
|
||||
val priority: Int
|
||||
get() = when (formatName) {
|
||||
"EPUB" -> 5
|
||||
"PDF" -> 4
|
||||
"MOBI" -> 3
|
||||
"FB2", "MD", "HTML" -> 2
|
||||
"CBZ", "CBR", "CB7" -> 1
|
||||
"TXT" -> 0
|
||||
else -> -1
|
||||
}
|
||||
}
|
||||
|
||||
data class OpdsEntry(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val summary: String?,
|
||||
val authors: List<OpdsAuthor> = emptyList(),
|
||||
val coverUrl: String?,
|
||||
val acquisitions: List<OpdsAcquisition> = emptyList(),
|
||||
val navigationUrl: String?,
|
||||
val publisher: String? = null,
|
||||
val published: String? = null,
|
||||
val language: String? = null,
|
||||
val series: String? = null,
|
||||
val seriesIndex: String? = null,
|
||||
val categories: List<String> = emptyList(),
|
||||
val pseCount: Int? = null,
|
||||
val pseUrlTemplate: String? = null
|
||||
) {
|
||||
val author: String?
|
||||
get() = authors.firstOrNull()?.name
|
||||
|
||||
val bestAcquisition: OpdsAcquisition?
|
||||
get() = acquisitions.maxByOrNull { it.priority }
|
||||
|
||||
val isAcquisition: Boolean
|
||||
get() = acquisitions.isNotEmpty()
|
||||
|
||||
val isNavigation: Boolean
|
||||
get() = navigationUrl != null && acquisitions.isEmpty()
|
||||
|
||||
val isStreamable: Boolean
|
||||
get() = pseUrlTemplate != null && pseCount != null && pseCount > 0
|
||||
}
|
||||
|
||||
data class SharedOpdsDownloadState(
|
||||
val isDownloading: Boolean,
|
||||
val progress: Float? = null
|
||||
)
|
||||
|
||||
data class SharedOpdsScreenState(
|
||||
val catalogs: List<OpdsCatalog> = emptyList(),
|
||||
val currentCatalog: OpdsCatalog? = null,
|
||||
val currentFeed: OpdsFeed? = null,
|
||||
val isLoading: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
val isViewingCatalog: Boolean = false,
|
||||
val searchUrlTemplate: String? = null,
|
||||
val downloadingState: Map<String, SharedOpdsDownloadState> = emptyMap()
|
||||
)
|
||||
|
||||
data class OpdsStreamReference(
|
||||
val id: String,
|
||||
val count: Int,
|
||||
val urlTemplate: String,
|
||||
val catalogId: String? = null
|
||||
)
|
||||
|
||||
interface SharedOpdsRepository {
|
||||
fun loadCatalogs(): List<OpdsCatalog>
|
||||
fun saveCatalogs(catalogs: List<OpdsCatalog>)
|
||||
suspend fun fetchFeed(url: String, username: String? = null, password: String? = null): Result<OpdsFeed>
|
||||
suspend fun getSearchTemplate(openSearchUrl: String, username: String? = null, password: String? = null): String?
|
||||
}
|
||||
|
|
@ -0,0 +1,206 @@
|
|||
package com.aryan.reader.shared.opds
|
||||
|
||||
import com.aryan.reader.shared.SharedFileCapabilities
|
||||
|
||||
object SharedOpdsSearch {
|
||||
suspend fun buildSearchUrl(
|
||||
searchLink: String,
|
||||
query: String,
|
||||
openSearchTemplateResolver: suspend (String) -> String?
|
||||
): String {
|
||||
val template = if (searchLink.hasSearchTemplateToken()) {
|
||||
searchLink
|
||||
} else {
|
||||
openSearchTemplateResolver(searchLink) ?: searchLink
|
||||
}
|
||||
return expandSearchTemplate(template, query)
|
||||
}
|
||||
|
||||
fun expandSearchTemplate(template: String, query: String): String {
|
||||
val encoded = query.percentEncode()
|
||||
val expandedSearchTerms = template.replace("{searchTerms}", encoded)
|
||||
if (expandedSearchTerms != template) return expandedSearchTerms
|
||||
|
||||
val queryTemplate = Regex("""\{([?&])([^}]+)\}""").find(template)
|
||||
if (queryTemplate != null) {
|
||||
val operator = queryTemplate.groupValues[1]
|
||||
val variables = queryTemplate.groupValues[2]
|
||||
.split(',')
|
||||
.map { it.substringBefore(':').substringBefore('*').trim() }
|
||||
.filter { it.isNotBlank() }
|
||||
val parameterName = variables.firstOrNull { it.equals("searchTerms", ignoreCase = true) }
|
||||
?: variables.firstOrNull()
|
||||
?: "query"
|
||||
val prefix = template.substringBefore(queryTemplate.value)
|
||||
val suffix = template.substringAfter(queryTemplate.value)
|
||||
val separator = when {
|
||||
operator == "&" -> "&"
|
||||
prefix.contains("?") -> "&"
|
||||
else -> "?"
|
||||
}
|
||||
return "$prefix$separator$parameterName=$encoded$suffix"
|
||||
}
|
||||
|
||||
val expandedQuery = template
|
||||
.replace("{query}", encoded)
|
||||
.replace("{keyword}", encoded)
|
||||
if (expandedQuery != template) return expandedQuery
|
||||
|
||||
val separator = if (template.contains("?")) "&" else "?"
|
||||
return "$template${separator}query=$encoded"
|
||||
}
|
||||
|
||||
private fun String.hasSearchTemplateToken(): Boolean {
|
||||
return contains("{searchTerms}") ||
|
||||
Regex("""\{[?&][^}]+\}""").containsMatchIn(this) ||
|
||||
contains("{query}") ||
|
||||
contains("{keyword}")
|
||||
}
|
||||
}
|
||||
|
||||
object SharedOpdsDownloadNamer {
|
||||
fun resolveExtension(
|
||||
acquisition: OpdsAcquisition,
|
||||
contentDisposition: String?,
|
||||
urlPathSegment: String?
|
||||
): String {
|
||||
val candidates = listOfNotNull(
|
||||
extractContentDispositionFilename(contentDisposition),
|
||||
urlPathSegment
|
||||
)
|
||||
|
||||
candidates.forEach { candidate ->
|
||||
extensionSuffixFromName(candidate.percentDecode())?.let { return it }
|
||||
}
|
||||
|
||||
return when (acquisition.formatName) {
|
||||
"EPUB" -> ".epub"
|
||||
"PDF" -> ".pdf"
|
||||
"MOBI" -> ".mobi"
|
||||
"FB2" -> ".fb2"
|
||||
"CBZ" -> ".cbz"
|
||||
"CBR" -> ".cbr"
|
||||
"CB7" -> ".cb7"
|
||||
"MD" -> ".md"
|
||||
"HTML" -> ".html"
|
||||
"TXT" -> ".txt"
|
||||
else -> ".epub"
|
||||
}
|
||||
}
|
||||
|
||||
fun safeFileStem(title: String, fallback: String = "opds_book"): String {
|
||||
val safe = title
|
||||
.replace(Regex("""[^a-zA-Z0-9._-]+"""), "_")
|
||||
.trim('_')
|
||||
.take(80)
|
||||
return safe.ifBlank { fallback }
|
||||
}
|
||||
|
||||
fun extractContentDispositionFilename(contentDisposition: String?): String? {
|
||||
if (contentDisposition.isNullOrBlank()) return null
|
||||
val encodedFilename = Regex("""filename\*=UTF-8''([^;]+)""", RegexOption.IGNORE_CASE)
|
||||
.find(contentDisposition)
|
||||
?.groupValues
|
||||
?.getOrNull(1)
|
||||
if (!encodedFilename.isNullOrBlank()) return encodedFilename.trim('"')
|
||||
|
||||
return Regex("""filename="?([^";]+)"?""", RegexOption.IGNORE_CASE)
|
||||
.find(contentDisposition)
|
||||
?.groupValues
|
||||
?.getOrNull(1)
|
||||
?.trim()
|
||||
?.trim('"')
|
||||
}
|
||||
|
||||
private fun extensionSuffixFromName(fileName: String?): String? {
|
||||
if (fileName.isNullOrBlank()) return null
|
||||
val cleanName = fileName.substringBefore('?').substringBefore('#')
|
||||
val extension = cleanName.substringAfterLast('.', missingDelimiterValue = "")
|
||||
.lowercase()
|
||||
.takeIf { it.isNotBlank() }
|
||||
?: return null
|
||||
if (SharedFileCapabilities.fileTypeForName(cleanName) == com.aryan.reader.shared.FileType.UNKNOWN) return null
|
||||
return ".$extension"
|
||||
}
|
||||
}
|
||||
|
||||
object SharedOpdsStreamUri {
|
||||
private const val SCHEME_PREFIX = "opds-pse://stream"
|
||||
|
||||
fun build(reference: OpdsStreamReference): String {
|
||||
return "$SCHEME_PREFIX?id=${reference.id.percentEncode()}" +
|
||||
"&count=${reference.count}" +
|
||||
"&url=${reference.urlTemplate.percentEncode()}" +
|
||||
reference.catalogId?.let { "&catalogId=${it.percentEncode()}" }.orEmpty()
|
||||
}
|
||||
|
||||
fun parse(uriString: String?): OpdsStreamReference? {
|
||||
if (uriString.isNullOrBlank() || !uriString.startsWith(SCHEME_PREFIX)) return null
|
||||
val query = uriString.substringAfter('?', missingDelimiterValue = "")
|
||||
val params = query.split('&')
|
||||
.mapNotNull { pair ->
|
||||
if (pair.isBlank()) return@mapNotNull null
|
||||
val key = pair.substringBefore('=').percentDecode()
|
||||
val value = pair.substringAfter('=', missingDelimiterValue = "").percentDecode()
|
||||
key to value
|
||||
}
|
||||
.toMap()
|
||||
val id = params["id"]?.takeIf { it.isNotBlank() } ?: return null
|
||||
val count = params["count"]?.toIntOrNull()?.takeIf { it > 0 } ?: return null
|
||||
val url = params["url"]?.takeIf { it.isNotBlank() } ?: return null
|
||||
return OpdsStreamReference(
|
||||
id = id,
|
||||
count = count,
|
||||
urlTemplate = url,
|
||||
catalogId = params["catalogId"]?.takeIf { it.isNotBlank() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun String.percentEncode(): String {
|
||||
val bytes = encodeToByteArray()
|
||||
return buildString(bytes.size) {
|
||||
bytes.forEach { byte ->
|
||||
val value = byte.toInt() and 0xFF
|
||||
val char = value.toChar()
|
||||
if (char in 'A'..'Z' || char in 'a'..'z' || char in '0'..'9' || char in "-_.~") {
|
||||
append(char)
|
||||
} else {
|
||||
append('%')
|
||||
append(value.toString(16).uppercase().padStart(2, '0'))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun String.percentDecode(): String {
|
||||
val bytes = mutableListOf<Byte>()
|
||||
var index = 0
|
||||
while (index < length) {
|
||||
val char = this[index]
|
||||
if (char == '%' && index + 2 < length) {
|
||||
val value = substring(index + 1, index + 3).toIntOrNull(16)
|
||||
if (value != null) {
|
||||
bytes += value.toByte()
|
||||
index += 3
|
||||
continue
|
||||
}
|
||||
}
|
||||
val encoded = char.toString().encodeToByteArray()
|
||||
encoded.forEach { bytes += it }
|
||||
index += 1
|
||||
}
|
||||
return bytes.toByteArray().decodeToString()
|
||||
}
|
||||
|
||||
object SharedOpdsText {
|
||||
fun cleanSummary(summary: String?): String {
|
||||
if (summary.isNullOrBlank()) return ""
|
||||
return summary
|
||||
.replace(Regex("""<br\s*/?>""", RegexOption.IGNORE_CASE), "\n")
|
||||
.replace(Regex("""</p\s*>""", RegexOption.IGNORE_CASE), "\n\n")
|
||||
.replace(Regex("""<[^>]+>"""), " ")
|
||||
.replace(Regex("""\s+"""), " ")
|
||||
.trim()
|
||||
}
|
||||
}
|
||||
|
|
@ -8,7 +8,8 @@ import kotlin.math.roundToInt
|
|||
|
||||
enum class PdfAnnotationKind {
|
||||
INK,
|
||||
TEXT
|
||||
TEXT,
|
||||
HIGHLIGHT
|
||||
}
|
||||
|
||||
enum class PdfInkTool {
|
||||
|
|
@ -44,16 +45,96 @@ data class SharedPdfAnnotation(
|
|||
val tool: PdfInkTool = PdfInkTool.PEN,
|
||||
val points: List<PdfPagePoint> = emptyList(),
|
||||
val bounds: PdfPageBounds? = null,
|
||||
val boundsList: List<PdfPageBounds> = emptyList(),
|
||||
val text: String = "",
|
||||
val note: String? = null,
|
||||
val colorArgb: Int,
|
||||
val backgroundArgb: Int = 0x00FFFFFF,
|
||||
val strokeWidth: Float = 2f,
|
||||
val fontSize: Float = 16f,
|
||||
val isBold: Boolean = false,
|
||||
val isItalic: Boolean = false,
|
||||
val isUnderline: Boolean = false,
|
||||
val isStrikeThrough: Boolean = false,
|
||||
val fontPath: String? = null,
|
||||
val fontName: String? = null,
|
||||
val rangeStartIndex: Int? = null,
|
||||
val rangeEndIndex: Int? = null,
|
||||
val createdAt: Long = 0L
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SharedPdfEmbeddedAnnotation(
|
||||
val id: String,
|
||||
val pageIndex: Int,
|
||||
val index: Int,
|
||||
val subtype: Int,
|
||||
val bounds: PdfPageBounds,
|
||||
val contents: String = "",
|
||||
val author: String = "",
|
||||
val name: String = "",
|
||||
val inReplyTo: String = "",
|
||||
val replies: List<SharedPdfEmbeddedAnnotation> = emptyList()
|
||||
) {
|
||||
val hasVisibleText: Boolean
|
||||
get() = contents.isNotBlank() || replies.any { it.hasVisibleText }
|
||||
}
|
||||
|
||||
object SharedPdfEmbeddedAnnotationThreads {
|
||||
fun group(
|
||||
annotations: List<SharedPdfEmbeddedAnnotation>,
|
||||
geometryTolerance: Float = 0.02f
|
||||
): List<SharedPdfEmbeddedAnnotation> {
|
||||
if (annotations.isEmpty()) return emptyList()
|
||||
|
||||
val byName = annotations
|
||||
.filter { it.name.isNotBlank() }
|
||||
.associateBy { it.name }
|
||||
val childrenByParentId = mutableMapOf<String, MutableList<SharedPdfEmbeddedAnnotation>>()
|
||||
val roots = mutableListOf<SharedPdfEmbeddedAnnotation>()
|
||||
|
||||
annotations.forEach { annotation ->
|
||||
val parent = byName[annotation.inReplyTo]
|
||||
if (parent != null && parent.id != annotation.id) {
|
||||
childrenByParentId.getOrPut(parent.id) { mutableListOf() } += annotation
|
||||
} else {
|
||||
roots += annotation
|
||||
}
|
||||
}
|
||||
|
||||
fun attachReplies(
|
||||
annotation: SharedPdfEmbeddedAnnotation,
|
||||
visitedIds: Set<String> = emptySet()
|
||||
): SharedPdfEmbeddedAnnotation {
|
||||
if (annotation.id in visitedIds) return annotation.copy(replies = emptyList())
|
||||
val nextVisited = visitedIds + annotation.id
|
||||
val replies = childrenByParentId[annotation.id]
|
||||
.orEmpty()
|
||||
.map { attachReplies(it, nextVisited) }
|
||||
return annotation.copy(replies = annotation.replies + replies)
|
||||
}
|
||||
|
||||
val groupedRoots = mutableListOf<MutableList<SharedPdfEmbeddedAnnotation>>()
|
||||
roots.map { attachReplies(it) }.forEach { annotation ->
|
||||
val group = groupedRoots.firstOrNull { existingGroup ->
|
||||
existingGroup.firstOrNull()?.bounds?.inflatedBy(geometryTolerance)?.intersects(annotation.bounds) == true
|
||||
}
|
||||
if (group == null) {
|
||||
groupedRoots += mutableListOf(annotation)
|
||||
} else {
|
||||
group += annotation
|
||||
}
|
||||
}
|
||||
|
||||
return groupedRoots
|
||||
.mapNotNull { group ->
|
||||
val root = group.firstOrNull() ?: return@mapNotNull null
|
||||
root.copy(replies = root.replies + group.drop(1))
|
||||
}
|
||||
.filter { it.hasVisibleText }
|
||||
}
|
||||
}
|
||||
|
||||
data class PdfToolConfig(
|
||||
val colorArgb: Int,
|
||||
val strokeWidth: Float
|
||||
|
|
@ -61,10 +142,10 @@ data class PdfToolConfig(
|
|||
|
||||
object SharedPdfAnnotationDefaults {
|
||||
val penPalette: List<Int> = listOf(
|
||||
0xFF111111.toInt(),
|
||||
0xFFD32F2F.toInt(),
|
||||
0xFF1976D2.toInt(),
|
||||
0xFF388E3C.toInt(),
|
||||
0xFF000000.toInt(),
|
||||
0xFFFF0000.toInt(),
|
||||
0xFF0000FF.toInt(),
|
||||
0xFF4CAF50.toInt(),
|
||||
0xFFFFFFFF.toInt()
|
||||
)
|
||||
|
||||
|
|
@ -78,13 +159,13 @@ object SharedPdfAnnotationDefaults {
|
|||
|
||||
fun configFor(tool: PdfInkTool): PdfToolConfig {
|
||||
return when (tool) {
|
||||
PdfInkTool.PEN -> PdfToolConfig(0xFF111111.toInt(), 2.5f)
|
||||
PdfInkTool.FOUNTAIN_PEN -> PdfToolConfig(0xFF111111.toInt(), 3.5f)
|
||||
PdfInkTool.PENCIL -> PdfToolConfig(0xFF616161.toInt(), 1.8f)
|
||||
PdfInkTool.HIGHLIGHTER -> PdfToolConfig(0x8CFFEB3B.toInt(), 12f)
|
||||
PdfInkTool.HIGHLIGHTER_ROUND -> PdfToolConfig(0x8CFF9800.toInt(), 16f)
|
||||
PdfInkTool.ERASER -> PdfToolConfig(0x00000000, 18f)
|
||||
PdfInkTool.TEXT -> PdfToolConfig(0xFF111111.toInt(), 1f)
|
||||
PdfInkTool.PEN -> PdfToolConfig(0xFFFF0000.toInt(), 0.008f)
|
||||
PdfInkTool.FOUNTAIN_PEN -> PdfToolConfig(0xFF0000FF.toInt(), 0.008f)
|
||||
PdfInkTool.PENCIL -> PdfToolConfig(0xFF444444.toInt(), 0.008f)
|
||||
PdfInkTool.HIGHLIGHTER -> PdfToolConfig(0x8CFF9800.toInt(), 0.035f)
|
||||
PdfInkTool.HIGHLIGHTER_ROUND -> PdfToolConfig(0x8CFFEB3B.toInt(), 0.035f)
|
||||
PdfInkTool.ERASER -> PdfToolConfig(0x00000000, 0.03f)
|
||||
PdfInkTool.TEXT -> PdfToolConfig(0xFF000000.toInt(), 0.02f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -116,6 +197,22 @@ object SharedPdfAnnotationSerializer {
|
|||
}
|
||||
}
|
||||
|
||||
private fun PdfPageBounds.inflatedBy(amount: Float): PdfPageBounds {
|
||||
return PdfPageBounds(
|
||||
left = (left - amount).coerceAtLeast(0f),
|
||||
top = (top - amount).coerceAtLeast(0f),
|
||||
right = (right + amount).coerceAtMost(1f),
|
||||
bottom = (bottom + amount).coerceAtMost(1f)
|
||||
)
|
||||
}
|
||||
|
||||
private fun PdfPageBounds.intersects(other: PdfPageBounds): Boolean {
|
||||
return left <= other.right &&
|
||||
right >= other.left &&
|
||||
top <= other.bottom &&
|
||||
bottom >= other.top
|
||||
}
|
||||
|
||||
data class PdfZoomSpec(
|
||||
val min: Float = 0.65f,
|
||||
val max: Float = 3.0f,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,573 @@
|
|||
package com.aryan.reader.shared.pdf
|
||||
|
||||
import com.aryan.reader.shared.PdfDisplayMode
|
||||
import com.aryan.reader.shared.SearchHighlightMode
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
data class SharedPdfSearchResult(
|
||||
val pageIndex: Int,
|
||||
val preview: String,
|
||||
val matchIndex: Int,
|
||||
val matchLength: Int = 0
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SharedPdfBookmark(
|
||||
val pageIndex: Int,
|
||||
val label: String = "",
|
||||
val createdAt: Long = 0L
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SharedPdfBookmarkStore(
|
||||
val version: Int = 1,
|
||||
val bookmarks: List<SharedPdfBookmark> = emptyList()
|
||||
)
|
||||
|
||||
object SharedPdfBookmarkSerializer {
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
prettyPrint = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
fun encode(bookmarks: List<SharedPdfBookmark>): String {
|
||||
return json.encodeToString(SharedPdfBookmarkStore(bookmarks = bookmarks))
|
||||
}
|
||||
|
||||
fun decode(raw: String): List<SharedPdfBookmark> {
|
||||
if (raw.isBlank()) return emptyList()
|
||||
return runCatching {
|
||||
json.decodeFromString<SharedPdfBookmarkStore>(raw).bookmarks
|
||||
}.getOrElse {
|
||||
runCatching { json.decodeFromString<List<SharedPdfBookmark>>(raw) }.getOrDefault(emptyList())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class SharedPdfJumpHistory(
|
||||
val pages: List<Int> = emptyList(),
|
||||
val cursor: Int = -1,
|
||||
val maxEntries: Int = 21
|
||||
) {
|
||||
val backPage: Int? get() = pages.getOrNull(cursor - 1)
|
||||
val forwardPage: Int? get() = pages.getOrNull(cursor + 1)
|
||||
val hasJumpTargets: Boolean get() = backPage != null || forwardPage != null
|
||||
|
||||
fun record(
|
||||
currentPageIndex: Int,
|
||||
targetPageIndex: Int,
|
||||
pageCount: Int
|
||||
): SharedPdfJumpHistory {
|
||||
if (
|
||||
pageCount <= 0 ||
|
||||
currentPageIndex !in 0 until pageCount ||
|
||||
targetPageIndex !in 0 until pageCount ||
|
||||
currentPageIndex == targetPageIndex
|
||||
) {
|
||||
return this
|
||||
}
|
||||
|
||||
val pruned = pruned(pageCount)
|
||||
val nextPages = pruned.pages.toMutableList()
|
||||
var nextCursor = pruned.cursor
|
||||
|
||||
while (nextPages.lastIndex > nextCursor) {
|
||||
nextPages.removeAt(nextPages.lastIndex)
|
||||
}
|
||||
|
||||
if (nextCursor > 0 && nextPages.getOrNull(nextCursor - 1) == currentPageIndex) {
|
||||
nextPages[nextCursor] = targetPageIndex
|
||||
return copy(
|
||||
pages = nextPages,
|
||||
cursor = nextCursor
|
||||
).bounded()
|
||||
}
|
||||
|
||||
if (nextCursor == -1 || nextPages.getOrNull(nextCursor) != currentPageIndex) {
|
||||
nextPages += currentPageIndex
|
||||
nextCursor = nextPages.lastIndex
|
||||
}
|
||||
|
||||
if (nextPages.lastOrNull() != targetPageIndex) {
|
||||
nextPages += targetPageIndex
|
||||
nextCursor = nextPages.lastIndex
|
||||
}
|
||||
|
||||
return copy(
|
||||
pages = nextPages,
|
||||
cursor = nextCursor
|
||||
).bounded()
|
||||
}
|
||||
|
||||
fun pruned(pageCount: Int): SharedPdfJumpHistory {
|
||||
if (pageCount <= 0) return clear()
|
||||
val nextPages = pages.toMutableList()
|
||||
var nextCursor = cursor
|
||||
var index = nextPages.lastIndex
|
||||
while (index >= 0) {
|
||||
if (nextPages[index] !in 0 until pageCount) {
|
||||
nextPages.removeAt(index)
|
||||
if (nextCursor >= index) nextCursor--
|
||||
}
|
||||
index--
|
||||
}
|
||||
return copy(
|
||||
pages = nextPages,
|
||||
cursor = nextCursor.coerceIn(-1, nextPages.lastIndex)
|
||||
).bounded()
|
||||
}
|
||||
|
||||
fun stepBack(): SharedPdfJumpHistory {
|
||||
return if (backPage == null) this else copy(cursor = (cursor - 1).coerceAtLeast(0))
|
||||
}
|
||||
|
||||
fun stepForward(): SharedPdfJumpHistory {
|
||||
return if (forwardPage == null) this else copy(cursor = (cursor + 1).coerceAtMost(pages.lastIndex))
|
||||
}
|
||||
|
||||
fun clear(): SharedPdfJumpHistory = copy(pages = emptyList(), cursor = -1)
|
||||
|
||||
private fun bounded(): SharedPdfJumpHistory {
|
||||
val safeMaxEntries = maxEntries.coerceAtLeast(2)
|
||||
if (pages.size <= safeMaxEntries) {
|
||||
return copy(cursor = cursor.coerceIn(-1, pages.lastIndex))
|
||||
}
|
||||
val overflow = pages.size - safeMaxEntries
|
||||
return copy(
|
||||
pages = pages.drop(overflow),
|
||||
cursor = (cursor - overflow).coerceIn(-1, pages.size - overflow - 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class SharedPdfReaderState(
|
||||
val pageIndex: Int = 0,
|
||||
val pageCount: Int = 0,
|
||||
val displayMode: PdfDisplayMode = PdfDisplayMode.PAGINATION,
|
||||
val zoom: Float = PdfZoomSpec().default,
|
||||
val searchQuery: String = "",
|
||||
val activeSearchResultIndex: Int = -1,
|
||||
val searchHighlightMode: SearchHighlightMode = SearchHighlightMode.ALL,
|
||||
val selectedTool: PdfInkTool = PdfInkTool.PEN,
|
||||
val selectedColorArgb: Int = SharedPdfAnnotationDefaults.configFor(PdfInkTool.PEN).colorArgb,
|
||||
val strokeWidth: Float = SharedPdfAnnotationDefaults.configFor(PdfInkTool.PEN).strokeWidth,
|
||||
val isTextSelectionMode: Boolean = false,
|
||||
val bookmarks: List<SharedPdfBookmark> = emptyList(),
|
||||
val selectedAnnotationId: String? = null,
|
||||
val annotations: List<SharedPdfAnnotation> = emptyList()
|
||||
) {
|
||||
val safePageCount: Int get() = pageCount.coerceAtLeast(0)
|
||||
val lastPageIndex: Int get() = (safePageCount - 1).coerceAtLeast(0)
|
||||
val canGoPrevious: Boolean get() = pageIndex > 0
|
||||
val canGoNext: Boolean get() = pageIndex < lastPageIndex
|
||||
val progressPercent: Float get() = ((pageIndex + 1).toFloat() / safePageCount.coerceAtLeast(1)) * 100f
|
||||
|
||||
fun coerced(zoomSpec: PdfZoomSpec = PdfZoomSpec()): SharedPdfReaderState {
|
||||
val safePage = pageIndex.coerceIn(0, lastPageIndex)
|
||||
return copy(
|
||||
pageIndex = safePage,
|
||||
pageCount = safePageCount,
|
||||
activeSearchResultIndex = activeSearchResultIndex.coerceAtLeast(-1),
|
||||
zoom = zoomSpec.clamp(zoom),
|
||||
bookmarks = bookmarks.normalizedBookmarks(lastPageIndex),
|
||||
selectedAnnotationId = selectedAnnotationId?.takeIf { selectedId ->
|
||||
annotations.any { it.id == selectedId }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun initial(
|
||||
pageCount: Int,
|
||||
initialPageIndex: Int = 0,
|
||||
zoomSpec: PdfZoomSpec = PdfZoomSpec()
|
||||
): SharedPdfReaderState {
|
||||
val safePageCount = pageCount.coerceAtLeast(0)
|
||||
val lastPageIndex = (safePageCount - 1).coerceAtLeast(0)
|
||||
return SharedPdfReaderState(
|
||||
pageIndex = initialPageIndex.coerceIn(0, lastPageIndex),
|
||||
pageCount = safePageCount,
|
||||
zoom = zoomSpec.clamp(zoomSpec.default)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface SharedPdfReaderAction {
|
||||
data class GoToPage(val pageIndex: Int) : SharedPdfReaderAction
|
||||
data object PreviousPage : SharedPdfReaderAction
|
||||
data object NextPage : SharedPdfReaderAction
|
||||
data object FirstPage : SharedPdfReaderAction
|
||||
data object LastPage : SharedPdfReaderAction
|
||||
data class DisplayModeChanged(val mode: PdfDisplayMode) : SharedPdfReaderAction
|
||||
data object DisplayModeToggled : SharedPdfReaderAction
|
||||
data class ZoomChanged(val zoom: Float) : SharedPdfReaderAction
|
||||
data class ZoomBy(val delta: Float) : SharedPdfReaderAction
|
||||
data class SearchChanged(val query: String) : SharedPdfReaderAction
|
||||
data class SearchHighlightModeChanged(val mode: SearchHighlightMode) : SharedPdfReaderAction
|
||||
data object SearchHighlightModeToggled : SharedPdfReaderAction
|
||||
data class GoToSearchResult(
|
||||
val resultIndex: Int,
|
||||
val results: List<SharedPdfSearchResult>
|
||||
) : SharedPdfReaderAction
|
||||
data class ToolSelected(val tool: PdfInkTool) : SharedPdfReaderAction
|
||||
data class ColorSelected(val colorArgb: Int) : SharedPdfReaderAction
|
||||
data class StrokeWidthChanged(val strokeWidth: Float) : SharedPdfReaderAction
|
||||
data class TextSelectionModeChanged(val enabled: Boolean) : SharedPdfReaderAction
|
||||
data class BookmarksLoaded(val bookmarks: List<SharedPdfBookmark>) : SharedPdfReaderAction
|
||||
data class BookmarkToggled(
|
||||
val pageIndex: Int,
|
||||
val label: String = "",
|
||||
val createdAt: Long = 0L
|
||||
) : SharedPdfReaderAction
|
||||
data class AnnotationsLoaded(val annotations: List<SharedPdfAnnotation>) : SharedPdfReaderAction
|
||||
data class AnnotationAdded(val annotation: SharedPdfAnnotation) : SharedPdfReaderAction
|
||||
data class AnnotationSelected(val annotationId: String?) : SharedPdfReaderAction
|
||||
data class AnnotationUpdated(val annotation: SharedPdfAnnotation) : SharedPdfReaderAction
|
||||
data class AnnotationDeleted(val annotationId: String) : SharedPdfReaderAction
|
||||
data class AnnotationsChanged(val annotations: List<SharedPdfAnnotation>) : SharedPdfReaderAction
|
||||
data class UndoLastAnnotationOnPage(val pageIndex: Int) : SharedPdfReaderAction
|
||||
data class ClearPageAnnotations(val pageIndex: Int) : SharedPdfReaderAction
|
||||
}
|
||||
|
||||
fun SharedPdfReaderState.reduce(
|
||||
action: SharedPdfReaderAction,
|
||||
zoomSpec: PdfZoomSpec = PdfZoomSpec()
|
||||
): SharedPdfReaderState {
|
||||
fun goToPage(target: Int): SharedPdfReaderState {
|
||||
return copy(pageIndex = target.coerceIn(0, lastPageIndex)).coerced(zoomSpec)
|
||||
}
|
||||
|
||||
return when (action) {
|
||||
is SharedPdfReaderAction.GoToPage -> goToPage(action.pageIndex)
|
||||
SharedPdfReaderAction.PreviousPage -> goToPage(pageIndex - 1)
|
||||
SharedPdfReaderAction.NextPage -> goToPage(pageIndex + 1)
|
||||
SharedPdfReaderAction.FirstPage -> goToPage(0)
|
||||
SharedPdfReaderAction.LastPage -> goToPage(lastPageIndex)
|
||||
is SharedPdfReaderAction.DisplayModeChanged -> copy(displayMode = action.mode)
|
||||
SharedPdfReaderAction.DisplayModeToggled -> copy(
|
||||
displayMode = when (displayMode) {
|
||||
PdfDisplayMode.PAGINATION -> PdfDisplayMode.VERTICAL_SCROLL
|
||||
PdfDisplayMode.VERTICAL_SCROLL -> PdfDisplayMode.PAGINATION
|
||||
}
|
||||
)
|
||||
is SharedPdfReaderAction.ZoomChanged -> copy(zoom = zoomSpec.clamp(action.zoom))
|
||||
is SharedPdfReaderAction.ZoomBy -> copy(zoom = zoomSpec.clamp(zoom + action.delta))
|
||||
is SharedPdfReaderAction.SearchChanged -> copy(
|
||||
searchQuery = action.query,
|
||||
activeSearchResultIndex = -1
|
||||
)
|
||||
is SharedPdfReaderAction.SearchHighlightModeChanged -> copy(searchHighlightMode = action.mode)
|
||||
SharedPdfReaderAction.SearchHighlightModeToggled -> copy(
|
||||
searchHighlightMode = when (searchHighlightMode) {
|
||||
SearchHighlightMode.ALL -> SearchHighlightMode.FOCUSED
|
||||
SearchHighlightMode.FOCUSED -> SearchHighlightMode.ALL
|
||||
}
|
||||
)
|
||||
is SharedPdfReaderAction.GoToSearchResult -> {
|
||||
if (action.results.isEmpty()) {
|
||||
this
|
||||
} else {
|
||||
val normalizedIndex = action.resultIndex.wrapIndex(action.results.size)
|
||||
copy(
|
||||
activeSearchResultIndex = normalizedIndex,
|
||||
pageIndex = action.results[normalizedIndex].pageIndex.coerceIn(0, lastPageIndex)
|
||||
)
|
||||
}
|
||||
}
|
||||
is SharedPdfReaderAction.ToolSelected -> {
|
||||
val config = SharedPdfAnnotationDefaults.configFor(action.tool)
|
||||
copy(
|
||||
selectedTool = action.tool,
|
||||
selectedColorArgb = config.colorArgb,
|
||||
strokeWidth = config.strokeWidth
|
||||
)
|
||||
}
|
||||
is SharedPdfReaderAction.ColorSelected -> copy(selectedColorArgb = action.colorArgb)
|
||||
is SharedPdfReaderAction.StrokeWidthChanged -> copy(strokeWidth = action.strokeWidth.coerceAtLeast(0.0001f))
|
||||
is SharedPdfReaderAction.TextSelectionModeChanged -> copy(isTextSelectionMode = action.enabled)
|
||||
is SharedPdfReaderAction.BookmarksLoaded -> copy(bookmarks = action.bookmarks.normalizedBookmarks(lastPageIndex))
|
||||
is SharedPdfReaderAction.BookmarkToggled -> {
|
||||
val page = action.pageIndex.coerceIn(0, lastPageIndex)
|
||||
val withoutPage = bookmarks.filterNot { it.pageIndex == page }
|
||||
val nextBookmarks = if (withoutPage.size == bookmarks.size) {
|
||||
withoutPage + SharedPdfBookmark(
|
||||
pageIndex = page,
|
||||
label = action.label.ifBlank { "Page ${page + 1}" },
|
||||
createdAt = action.createdAt
|
||||
)
|
||||
} else {
|
||||
withoutPage
|
||||
}
|
||||
copy(bookmarks = nextBookmarks.normalizedBookmarks(lastPageIndex))
|
||||
}
|
||||
is SharedPdfReaderAction.AnnotationsLoaded -> copy(annotations = action.annotations.toList())
|
||||
is SharedPdfReaderAction.AnnotationAdded -> copy(
|
||||
annotations = annotations + action.annotation,
|
||||
selectedAnnotationId = action.annotation.id
|
||||
)
|
||||
is SharedPdfReaderAction.AnnotationSelected -> copy(
|
||||
selectedAnnotationId = action.annotationId?.takeIf { id -> annotations.any { it.id == id } }
|
||||
)
|
||||
is SharedPdfReaderAction.AnnotationUpdated -> {
|
||||
val index = annotations.indexOfFirst { it.id == action.annotation.id }
|
||||
if (index < 0) {
|
||||
this
|
||||
} else {
|
||||
copy(annotations = annotations.toMutableList().also { it[index] = action.annotation })
|
||||
}
|
||||
}
|
||||
is SharedPdfReaderAction.AnnotationDeleted -> copy(
|
||||
annotations = annotations.filterNot { it.id == action.annotationId },
|
||||
selectedAnnotationId = selectedAnnotationId?.takeIf { it != action.annotationId }
|
||||
)
|
||||
is SharedPdfReaderAction.AnnotationsChanged -> copy(annotations = action.annotations.toList())
|
||||
is SharedPdfReaderAction.UndoLastAnnotationOnPage -> {
|
||||
val index = annotations.indexOfLast { it.pageIndex == action.pageIndex }
|
||||
if (index < 0) {
|
||||
this
|
||||
} else {
|
||||
val removedId = annotations[index].id
|
||||
copy(
|
||||
annotations = annotations.toMutableList().also { it.removeAt(index) },
|
||||
selectedAnnotationId = selectedAnnotationId?.takeIf { it != removedId }
|
||||
)
|
||||
}
|
||||
}
|
||||
is SharedPdfReaderAction.ClearPageAnnotations -> {
|
||||
val removedIds = annotations.filter { it.pageIndex == action.pageIndex }.map { it.id }.toSet()
|
||||
copy(
|
||||
annotations = annotations.filterNot { it.pageIndex == action.pageIndex },
|
||||
selectedAnnotationId = selectedAnnotationId?.takeIf { it !in removedIds }
|
||||
)
|
||||
}
|
||||
}.coerced(zoomSpec)
|
||||
}
|
||||
|
||||
object SharedPdfSearchEngine {
|
||||
fun search(
|
||||
pageTexts: List<String>,
|
||||
query: String,
|
||||
previewRadiusBefore: Int = 70,
|
||||
previewRadiusAfter: Int = 100
|
||||
): List<SharedPdfSearchResult> {
|
||||
val normalized = query.trim()
|
||||
if (normalized.isBlank()) return emptyList()
|
||||
return pageTexts.flatMapIndexed { pageIndex, text ->
|
||||
val matches = mutableListOf<SharedPdfSearchResult>()
|
||||
var startIndex = 0
|
||||
while (startIndex < text.length) {
|
||||
val matchIndex = text.indexOf(normalized, startIndex, ignoreCase = true)
|
||||
if (matchIndex < 0) break
|
||||
matches += SharedPdfSearchResult(
|
||||
pageIndex = pageIndex,
|
||||
preview = text.previewAround(
|
||||
index = matchIndex,
|
||||
queryLength = normalized.length,
|
||||
before = previewRadiusBefore,
|
||||
after = previewRadiusAfter
|
||||
),
|
||||
matchIndex = matchIndex,
|
||||
matchLength = normalized.length
|
||||
)
|
||||
startIndex = matchIndex + normalized.length.coerceAtLeast(1)
|
||||
}
|
||||
matches
|
||||
}
|
||||
}
|
||||
|
||||
fun highlightsForPage(
|
||||
results: List<SharedPdfSearchResult>,
|
||||
pageIndex: Int,
|
||||
activeResultIndex: Int,
|
||||
mode: SearchHighlightMode
|
||||
): List<SharedPdfSearchResult> {
|
||||
return when (mode) {
|
||||
SearchHighlightMode.ALL -> results.filter { it.pageIndex == pageIndex }
|
||||
SearchHighlightMode.FOCUSED -> {
|
||||
val active = results.getOrNull(activeResultIndex)
|
||||
if (active?.pageIndex == pageIndex) listOf(active) else emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SharedPdfSearchIndex(
|
||||
val pageCount: Int = 0
|
||||
) {
|
||||
private val pageTexts = LinkedHashMap<Int, String>()
|
||||
private val tokenPages = LinkedHashMap<String, MutableSet<Int>>()
|
||||
|
||||
val indexedPageCount: Int
|
||||
get() = pageTexts.size
|
||||
|
||||
fun hasPage(pageIndex: Int): Boolean = pageTexts.containsKey(pageIndex)
|
||||
|
||||
fun pageText(pageIndex: Int): String? = pageTexts[pageIndex]
|
||||
|
||||
fun indexedPages(): List<SharedPdfIndexedPage> {
|
||||
return pageTexts.entries
|
||||
.sortedBy { it.key }
|
||||
.map { SharedPdfIndexedPage(pageIndex = it.key, text = it.value) }
|
||||
}
|
||||
|
||||
fun putPage(pageIndex: Int, text: String) {
|
||||
if (pageCount > 0 && pageIndex !in 0 until pageCount) return
|
||||
removePageTokens(pageIndex)
|
||||
pageTexts[pageIndex] = text
|
||||
text.searchTokens().forEach { token ->
|
||||
tokenPages.getOrPut(token) { linkedSetOf() } += pageIndex
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
pageTexts.clear()
|
||||
tokenPages.clear()
|
||||
}
|
||||
|
||||
fun search(
|
||||
query: String,
|
||||
previewRadiusBefore: Int = 70,
|
||||
previewRadiusAfter: Int = 100
|
||||
): List<SharedPdfSearchResult> {
|
||||
val normalized = query.trim()
|
||||
if (normalized.isBlank()) return emptyList()
|
||||
val matcher = SharedPdfPhraseMatcher(normalized)
|
||||
val candidates = candidatePages(matcher.tokens)
|
||||
return candidates.flatMap { pageIndex ->
|
||||
val text = pageTexts[pageIndex].orEmpty()
|
||||
matcher.findAll(text).map { match ->
|
||||
SharedPdfSearchResult(
|
||||
pageIndex = pageIndex,
|
||||
preview = text.previewAround(
|
||||
index = match.startIndex,
|
||||
queryLength = match.length,
|
||||
before = previewRadiusBefore,
|
||||
after = previewRadiusAfter
|
||||
),
|
||||
matchIndex = match.startIndex,
|
||||
matchLength = match.length
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun candidatePages(tokens: List<String>): List<Int> {
|
||||
if (tokens.isEmpty()) return pageTexts.keys.sorted()
|
||||
val candidateSets = tokens.map { token ->
|
||||
tokenPages.asSequence()
|
||||
.filter { (indexedToken, _) -> indexedToken.startsWith(token) }
|
||||
.flatMap { (_, pages) -> pages.asSequence() }
|
||||
.toSet()
|
||||
}
|
||||
if (candidateSets.any { it.isEmpty() }) return emptyList()
|
||||
return candidateSets
|
||||
.drop(1)
|
||||
.fold(candidateSets.first()) { acc, pages -> acc.intersect(pages) }
|
||||
.sorted()
|
||||
}
|
||||
|
||||
private fun removePageTokens(pageIndex: Int) {
|
||||
if (!pageTexts.containsKey(pageIndex)) return
|
||||
val emptyTokens = mutableListOf<String>()
|
||||
tokenPages.forEach { (token, pages) ->
|
||||
pages.remove(pageIndex)
|
||||
if (pages.isEmpty()) emptyTokens += token
|
||||
}
|
||||
emptyTokens.forEach(tokenPages::remove)
|
||||
}
|
||||
}
|
||||
|
||||
data class SharedPdfIndexedPage(
|
||||
val pageIndex: Int,
|
||||
val text: String
|
||||
)
|
||||
|
||||
private data class SharedPdfPhraseMatch(
|
||||
val startIndex: Int,
|
||||
val length: Int
|
||||
)
|
||||
|
||||
private class SharedPdfPhraseMatcher(query: String) {
|
||||
val tokens: List<String> = query.searchTokens()
|
||||
private val regex = query.toSearchPhraseRegex()
|
||||
private val literal = query.takeIf { regex == null }
|
||||
|
||||
fun findAll(text: String): List<SharedPdfPhraseMatch> {
|
||||
return if (regex != null) {
|
||||
regex.findAll(text).map { match ->
|
||||
SharedPdfPhraseMatch(
|
||||
startIndex = match.range.first,
|
||||
length = match.range.last - match.range.first + 1
|
||||
)
|
||||
}.toList()
|
||||
} else {
|
||||
val needle = literal.orEmpty()
|
||||
val matches = mutableListOf<SharedPdfPhraseMatch>()
|
||||
var startIndex = 0
|
||||
while (startIndex < text.length) {
|
||||
val matchIndex = text.indexOf(needle, startIndex, ignoreCase = true)
|
||||
if (matchIndex < 0) break
|
||||
matches += SharedPdfPhraseMatch(matchIndex, needle.length)
|
||||
startIndex = matchIndex + needle.length.coerceAtLeast(1)
|
||||
}
|
||||
matches
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.toSearchPhraseRegex(): Regex? {
|
||||
val tokens = trim().split(Regex("\\s+")).filter { it.isNotBlank() }
|
||||
if (tokens.size <= 1) return null
|
||||
val prefix = if (all { it.code < 128 }) "\\b" else ""
|
||||
return Regex(prefix + tokens.joinToString("\\s+") { Regex.escape(it) }, RegexOption.IGNORE_CASE)
|
||||
}
|
||||
|
||||
private fun Int.wrapIndex(size: Int): Int {
|
||||
if (size <= 0) return -1
|
||||
return when {
|
||||
this < 0 -> size - 1
|
||||
this >= size -> 0
|
||||
else -> this
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<SharedPdfBookmark>.normalizedBookmarks(lastPageIndex: Int): List<SharedPdfBookmark> {
|
||||
return asSequence()
|
||||
.filter { it.pageIndex in 0..lastPageIndex }
|
||||
.distinctBy { it.pageIndex }
|
||||
.sortedBy { it.pageIndex }
|
||||
.toList()
|
||||
}
|
||||
|
||||
private fun String.previewAround(
|
||||
index: Int,
|
||||
queryLength: Int,
|
||||
before: Int,
|
||||
after: Int
|
||||
): String {
|
||||
val start = (index - before).coerceAtLeast(0)
|
||||
val end = (index + queryLength + after).coerceAtMost(length)
|
||||
val prefix = if (start > 0) "..." else ""
|
||||
val suffix = if (end < length) "..." else ""
|
||||
return prefix + substring(start, end).replace(Regex("\\s+"), " ").trim() + suffix
|
||||
}
|
||||
|
||||
private fun String.searchTokens(): List<String> {
|
||||
val tokens = mutableListOf<String>()
|
||||
val current = StringBuilder()
|
||||
forEach { char ->
|
||||
if (char.isLetterOrDigit() || char == '_') {
|
||||
current.append(char.lowercaseChar())
|
||||
} else if (current.isNotEmpty()) {
|
||||
tokens += current.toString()
|
||||
current.setLength(0)
|
||||
}
|
||||
}
|
||||
if (current.isNotEmpty()) tokens += current.toString()
|
||||
return tokens.distinct()
|
||||
}
|
||||
|
|
@ -0,0 +1,172 @@
|
|||
package com.aryan.reader.shared.pdf
|
||||
|
||||
import kotlin.math.abs
|
||||
|
||||
data class PdfNormalizedPoint(
|
||||
val x: Float,
|
||||
val y: Float
|
||||
)
|
||||
|
||||
data class PdfTextCharBounds(
|
||||
val index: Int,
|
||||
val left: Float,
|
||||
val top: Float,
|
||||
val right: Float,
|
||||
val bottom: Float
|
||||
) {
|
||||
val hasBounds: Boolean
|
||||
get() = right > left && bottom > top
|
||||
}
|
||||
|
||||
object PdfSelectionGeometry {
|
||||
private const val DefaultMergedLineTolerance = 0.006f
|
||||
private const val DefaultCharLineTolerance = 0.012f
|
||||
private const val MinLineTolerance = 0.002f
|
||||
|
||||
fun normalizedPoint(
|
||||
pointX: Float,
|
||||
pointY: Float,
|
||||
viewportWidth: Int,
|
||||
viewportHeight: Int
|
||||
): PdfNormalizedPoint? {
|
||||
if (viewportWidth <= 0 || viewportHeight <= 0) return null
|
||||
return PdfNormalizedPoint(
|
||||
x = (pointX / viewportWidth).coerceIn(0f, 1f),
|
||||
y = (pointY / viewportHeight).coerceIn(0f, 1f)
|
||||
)
|
||||
}
|
||||
|
||||
fun mergeBoundsByLine(
|
||||
bounds: List<PdfPageBounds>,
|
||||
lineTolerance: Float = DefaultMergedLineTolerance
|
||||
): List<PdfPageBounds> {
|
||||
if (bounds.isEmpty()) return emptyList()
|
||||
val lines = mutableListOf<MutableList<PdfPageBounds>>()
|
||||
bounds.sortedWith(compareBy<PdfPageBounds> { it.top }.thenBy { it.left }).forEach { boundsForChar ->
|
||||
val line = lines.firstOrNull { existing ->
|
||||
existing.any { it.isSameVisualLineAs(boundsForChar, lineTolerance) }
|
||||
}
|
||||
if (line == null) {
|
||||
lines += mutableListOf(boundsForChar)
|
||||
} else {
|
||||
line += boundsForChar
|
||||
}
|
||||
}
|
||||
return lines.map { it.toMergedBounds() }
|
||||
}
|
||||
|
||||
fun lineBoundsForChars(
|
||||
chars: List<PdfTextCharBounds>,
|
||||
lineTolerance: Float = DefaultCharLineTolerance
|
||||
): List<PdfPageBounds> {
|
||||
return chars.groupByLine(lineTolerance).map { it.toCharLineBounds() }
|
||||
}
|
||||
|
||||
fun nearestCharOnLine(
|
||||
chars: List<PdfTextCharBounds>,
|
||||
point: PdfNormalizedPoint,
|
||||
lineTolerance: Float = DefaultCharLineTolerance
|
||||
): PdfTextCharBounds? {
|
||||
val lines = chars.groupByLine(lineTolerance)
|
||||
val matchingLines = lines.filter { line ->
|
||||
val top = line.minOf { it.top }
|
||||
val bottom = line.maxOf { it.bottom }
|
||||
val averageHeight = line.map { it.bottom - it.top }.average().toFloat()
|
||||
val verticalPadding = maxOf(averageHeight * 0.45f, MinLineTolerance)
|
||||
point.y in (top - verticalPadding)..(bottom + verticalPadding)
|
||||
}
|
||||
val line = matchingLines.minWithOrNull(
|
||||
compareBy<List<PdfTextCharBounds>>(
|
||||
{ lineVerticalDistance(point.y, it) },
|
||||
{ lineHorizontalDistance(point.x, it) }
|
||||
)
|
||||
) ?: return null
|
||||
|
||||
return line.minByOrNull { char ->
|
||||
horizontalDistance(point.x, char)
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<PdfTextCharBounds>.groupByLine(lineTolerance: Float): List<List<PdfTextCharBounds>> {
|
||||
val lines = mutableListOf<MutableList<PdfTextCharBounds>>()
|
||||
filter { it.hasBounds }
|
||||
.sortedWith(compareBy<PdfTextCharBounds> { it.top }.thenBy { it.left })
|
||||
.forEach { char ->
|
||||
val line = lines.firstOrNull { existing ->
|
||||
val averageHeight = existing.map { it.bottom - it.top }.average().toFloat()
|
||||
val charHeight = char.bottom - char.top
|
||||
val dynamicTolerance = maxOf(minOf(averageHeight, charHeight) * 0.55f, MinLineTolerance)
|
||||
abs(existing.averageVerticalMidpoint() - char.verticalMidpoint()) <= minOf(lineTolerance, dynamicTolerance)
|
||||
}
|
||||
if (line == null) {
|
||||
lines += mutableListOf(char)
|
||||
} else {
|
||||
line += char
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
private fun List<PdfTextCharBounds>.toCharLineBounds(): PdfPageBounds {
|
||||
return PdfPageBounds(
|
||||
left = minOf { it.left }.coerceIn(0f, 1f),
|
||||
top = minOf { it.top }.coerceIn(0f, 1f),
|
||||
right = maxOf { it.right }.coerceIn(0f, 1f),
|
||||
bottom = maxOf { it.bottom }.coerceIn(0f, 1f)
|
||||
)
|
||||
}
|
||||
|
||||
private fun List<PdfPageBounds>.toMergedBounds(): PdfPageBounds {
|
||||
return PdfPageBounds(
|
||||
left = minOf { it.left }.coerceIn(0f, 1f),
|
||||
top = minOf { it.top }.coerceIn(0f, 1f),
|
||||
right = maxOf { it.right }.coerceIn(0f, 1f),
|
||||
bottom = maxOf { it.bottom }.coerceIn(0f, 1f)
|
||||
)
|
||||
}
|
||||
|
||||
private fun PdfTextCharBounds.verticalMidpoint(): Float = (top + bottom) / 2f
|
||||
|
||||
private fun PdfPageBounds.isSameVisualLineAs(other: PdfPageBounds, lineTolerance: Float): Boolean {
|
||||
val overlap = minOf(bottom, other.bottom) - maxOf(top, other.top)
|
||||
val minHeight = minOf(bottom - top, other.bottom - other.top)
|
||||
if (overlap > 0f && overlap >= minHeight * 0.45f) return true
|
||||
|
||||
val dynamicTolerance = maxOf(minHeight * 0.35f, MinLineTolerance)
|
||||
return abs(verticalMidpoint() - other.verticalMidpoint()) <= minOf(lineTolerance, dynamicTolerance)
|
||||
}
|
||||
|
||||
private fun PdfPageBounds.verticalMidpoint(): Float = (top + bottom) / 2f
|
||||
|
||||
private fun List<PdfTextCharBounds>.averageVerticalMidpoint(): Float {
|
||||
return map { it.verticalMidpoint() }.average().toFloat()
|
||||
}
|
||||
|
||||
private fun lineVerticalDistance(pointY: Float, line: List<PdfTextCharBounds>): Float {
|
||||
val top = line.minOf { it.top }
|
||||
val bottom = line.maxOf { it.bottom }
|
||||
return when {
|
||||
pointY < top -> top - pointY
|
||||
pointY > bottom -> pointY - bottom
|
||||
else -> 0f
|
||||
}
|
||||
}
|
||||
|
||||
private fun lineHorizontalDistance(pointX: Float, line: List<PdfTextCharBounds>): Float {
|
||||
val left = line.minOf { it.left }
|
||||
val right = line.maxOf { it.right }
|
||||
return when {
|
||||
pointX < left -> left - pointX
|
||||
pointX > right -> pointX - right
|
||||
else -> 0f
|
||||
}
|
||||
}
|
||||
|
||||
private fun horizontalDistance(pointX: Float, char: PdfTextCharBounds): Float {
|
||||
return when {
|
||||
pointX < char.left -> char.left - pointX
|
||||
pointX > char.right -> pointX - char.right
|
||||
else -> 0f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
package com.aryan.reader.shared.pdf
|
||||
|
||||
data class PdfVisiblePageLayout(
|
||||
val pageIndex: Int,
|
||||
val top: Float,
|
||||
val bottom: Float
|
||||
) {
|
||||
val visibleHeight: Float
|
||||
get() = (bottom - top).coerceAtLeast(0f)
|
||||
}
|
||||
|
||||
fun mostVisiblePdfPageIndex(
|
||||
visiblePages: List<PdfVisiblePageLayout>,
|
||||
viewportTop: Float,
|
||||
viewportBottom: Float,
|
||||
fallbackPageIndex: Int
|
||||
): Int {
|
||||
return visiblePages
|
||||
.filter { it.visibleHeight > 0f }
|
||||
.map { page ->
|
||||
val top = maxOf(page.top, viewportTop)
|
||||
val bottom = minOf(page.bottom, viewportBottom)
|
||||
page to (bottom - top).coerceAtLeast(0f)
|
||||
}
|
||||
.maxByOrNull { it.second }
|
||||
?.takeIf { it.second > 0f }
|
||||
?.first
|
||||
?.pageIndex
|
||||
?: fallbackPageIndex
|
||||
}
|
||||
|
|
@ -0,0 +1,415 @@
|
|||
package com.aryan.reader.shared.pdf
|
||||
|
||||
import com.aryan.reader.shared.localFolderSyncSha256ShortHex
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.doubleOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
import kotlin.math.pow
|
||||
|
||||
object SharedPdfAnnotationSidecarCodec {
|
||||
const val KEY_PDF_ANNOTATIONS = "pdfAnnotations"
|
||||
const val KEY_LEGACY_INK = "ink"
|
||||
const val KEY_LEGACY_TEXT_BOXES = "textBoxes"
|
||||
const val KEY_LEGACY_HIGHLIGHTS = "highlights"
|
||||
|
||||
private const val LEGACY_TEXT_BOX_FONT_REFERENCE_DP = 500f
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
prettyPrint = true
|
||||
}
|
||||
|
||||
fun encodeAnnotationsElement(annotations: List<SharedPdfAnnotation>): JsonElement {
|
||||
return json.parseToJsonElement(SharedPdfAnnotationSerializer.encode(annotations))
|
||||
}
|
||||
|
||||
fun decodeAnnotationsElement(element: JsonElement): List<SharedPdfAnnotation> {
|
||||
return SharedPdfAnnotationSerializer.decode(json.encodeToString(JsonElement.serializer(), element))
|
||||
}
|
||||
|
||||
fun annotationsFromData(data: JsonObject): List<SharedPdfAnnotation> {
|
||||
data[KEY_PDF_ANNOTATIONS]?.let { return decodeAnnotationsElement(it) }
|
||||
|
||||
data[KEY_LEGACY_INK]?.let { ink ->
|
||||
val decoded = decodeAnnotationsElement(ink)
|
||||
if (decoded.isNotEmpty() || ink.looksLikeSharedAnnotationStore()) {
|
||||
return decoded
|
||||
}
|
||||
}
|
||||
|
||||
return legacyAndroidAnnotationsFromData(data)
|
||||
}
|
||||
|
||||
fun withCanonicalAnnotations(data: JsonObject): JsonObject {
|
||||
if (data[KEY_PDF_ANNOTATIONS] != null) return data
|
||||
val annotations = annotationsFromData(data)
|
||||
if (annotations.isEmpty()) return data
|
||||
return JsonObject(data + (KEY_PDF_ANNOTATIONS to encodeAnnotationsElement(annotations)))
|
||||
}
|
||||
|
||||
fun canonicalizeDataJson(rawDataJson: String): String {
|
||||
val data = parseObjectOrNull(rawDataJson) ?: return rawDataJson
|
||||
return json.encodeToString(JsonElement.serializer(), withCanonicalAnnotations(data))
|
||||
}
|
||||
|
||||
fun legacyAndroidDataFromAnnotations(
|
||||
annotations: List<SharedPdfAnnotation>,
|
||||
existingData: JsonObject = JsonObject(emptyMap())
|
||||
): JsonObject {
|
||||
if (annotations.isEmpty()) return existingData
|
||||
|
||||
val next = existingData.toMutableMap()
|
||||
if (!existingData[KEY_LEGACY_INK].isLegacyAndroidInkArray()) {
|
||||
next[KEY_LEGACY_INK] = annotations.toLegacyAndroidInkArray()
|
||||
}
|
||||
if (!existingData[KEY_LEGACY_TEXT_BOXES].isJsonArray()) {
|
||||
next[KEY_LEGACY_TEXT_BOXES] = annotations.toLegacyAndroidTextBoxArray()
|
||||
}
|
||||
if (!existingData[KEY_LEGACY_HIGHLIGHTS].isJsonArray()) {
|
||||
next[KEY_LEGACY_HIGHLIGHTS] = annotations.toLegacyAndroidHighlightArray()
|
||||
}
|
||||
return JsonObject(next)
|
||||
}
|
||||
|
||||
fun legacyAndroidDataJsonFromCanonical(rawDataJson: String): String {
|
||||
val data = parseObjectOrNull(rawDataJson) ?: return rawDataJson
|
||||
val annotations = annotationsFromData(data)
|
||||
if (annotations.isEmpty()) return rawDataJson
|
||||
return json.encodeToString(
|
||||
JsonElement.serializer(),
|
||||
legacyAndroidDataFromAnnotations(annotations, data)
|
||||
)
|
||||
}
|
||||
|
||||
private fun legacyAndroidAnnotationsFromData(data: JsonObject): List<SharedPdfAnnotation> {
|
||||
return buildList {
|
||||
addAll(data[KEY_LEGACY_INK].parseLegacyAndroidInk())
|
||||
addAll(data[KEY_LEGACY_TEXT_BOXES].parseLegacyAndroidTextBoxes())
|
||||
addAll(data[KEY_LEGACY_HIGHLIGHTS].parseLegacyAndroidHighlights())
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonElement?.parseLegacyAndroidInk(): List<SharedPdfAnnotation> {
|
||||
val array = this?.jsonArrayOrNull() ?: return emptyList()
|
||||
if (!this.isLegacyAndroidInkArray()) return emptyList()
|
||||
return array.mapNotNull { element ->
|
||||
val obj = element.jsonObjectOrNull() ?: return@mapNotNull null
|
||||
val points = obj.array("points")
|
||||
?.mapNotNull { pointElement ->
|
||||
val point = pointElement.jsonObjectOrNull() ?: return@mapNotNull null
|
||||
PdfPagePoint(
|
||||
x = point.float("x") ?: return@mapNotNull null,
|
||||
y = point.float("y") ?: return@mapNotNull null,
|
||||
timestamp = point.long("t") ?: point.long("timestamp") ?: 0L
|
||||
)
|
||||
}
|
||||
.orEmpty()
|
||||
if (points.isEmpty()) return@mapNotNull null
|
||||
|
||||
val tool = obj.string("inkType")
|
||||
?: obj.string("type")
|
||||
?: PdfInkTool.PEN.name
|
||||
SharedPdfAnnotation(
|
||||
id = obj.string("id") ?: stableAnnotationId("ink", element),
|
||||
pageIndex = obj.int("pageIndex") ?: return@mapNotNull null,
|
||||
kind = PdfAnnotationKind.INK,
|
||||
tool = tool.toPdfInkTool(),
|
||||
points = points,
|
||||
colorArgb = obj.int("color") ?: SharedPdfAnnotationDefaults.configFor(PdfInkTool.PEN).colorArgb,
|
||||
strokeWidth = obj.float("strokeWidth") ?: SharedPdfAnnotationDefaults.configFor(PdfInkTool.PEN).strokeWidth,
|
||||
createdAt = points.firstOrNull()?.timestamp ?: 0L
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonElement?.parseLegacyAndroidTextBoxes(): List<SharedPdfAnnotation> {
|
||||
val array = this?.jsonArrayOrNull() ?: return emptyList()
|
||||
return array.mapNotNull { element ->
|
||||
val obj = element.jsonObjectOrNull() ?: return@mapNotNull null
|
||||
val bounds = obj.objectValue("bounds")?.toPdfPageBoundsOrNull() ?: return@mapNotNull null
|
||||
val rawFontSize = obj.float("fontSize") ?: 16f
|
||||
SharedPdfAnnotation(
|
||||
id = obj.string("id") ?: stableAnnotationId("text", element),
|
||||
pageIndex = obj.int("pageIndex") ?: return@mapNotNull null,
|
||||
kind = PdfAnnotationKind.TEXT,
|
||||
tool = PdfInkTool.TEXT,
|
||||
bounds = bounds,
|
||||
text = obj.string("text").orEmpty(),
|
||||
colorArgb = obj.int("color") ?: 0xFF000000.toInt(),
|
||||
backgroundArgb = obj.int("backgroundColor") ?: 0x00000000,
|
||||
strokeWidth = SharedPdfAnnotationDefaults.configFor(PdfInkTool.TEXT).strokeWidth,
|
||||
fontSize = rawFontSize.legacyTextBoxFontSizeToShared(),
|
||||
isBold = obj.boolean("isBold") ?: false,
|
||||
isItalic = obj.boolean("isItalic") ?: false,
|
||||
isUnderline = obj.boolean("isUnderline") ?: false,
|
||||
isStrikeThrough = obj.boolean("isStrikeThrough") ?: false,
|
||||
fontPath = obj.string("fontPath"),
|
||||
fontName = obj.string("fontName")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonElement?.parseLegacyAndroidHighlights(): List<SharedPdfAnnotation> {
|
||||
val array = this?.jsonArrayOrNull() ?: return emptyList()
|
||||
return array.mapNotNull { element ->
|
||||
val obj = element.jsonObjectOrNull() ?: return@mapNotNull null
|
||||
val boundsList = obj.array("bounds")
|
||||
?.mapNotNull { it.jsonObjectOrNull()?.toPdfPageBoundsOrNull() }
|
||||
?.filter { it.isNormalizedPageBounds() }
|
||||
.orEmpty()
|
||||
val rangeStart = obj.int("rangeStart")
|
||||
val rangeEnd = obj.int("rangeEnd")
|
||||
if (boundsList.isEmpty() && (rangeStart == null || rangeEnd == null)) return@mapNotNull null
|
||||
val inclusiveRangeEnd = if (rangeStart != null && rangeEnd != null) {
|
||||
(rangeEnd - 1).coerceAtLeast(rangeStart)
|
||||
} else {
|
||||
rangeEnd
|
||||
}
|
||||
|
||||
val colorName = obj.string("color") ?: "YELLOW"
|
||||
SharedPdfAnnotation(
|
||||
id = obj.string("id") ?: stableAnnotationId("highlight", element),
|
||||
pageIndex = obj.int("pageIndex") ?: return@mapNotNull null,
|
||||
kind = PdfAnnotationKind.HIGHLIGHT,
|
||||
tool = PdfInkTool.HIGHLIGHTER,
|
||||
bounds = boundsList.firstOrNull(),
|
||||
boundsList = boundsList,
|
||||
text = obj.string("text").orEmpty(),
|
||||
note = obj.string("note"),
|
||||
colorArgb = colorName.toSharedHighlightArgb(),
|
||||
rangeStartIndex = rangeStart,
|
||||
rangeEndIndex = inclusiveRangeEnd
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<SharedPdfAnnotation>.toLegacyAndroidInkArray(): JsonArray {
|
||||
return JsonArray(
|
||||
filter { it.kind == PdfAnnotationKind.INK && it.points.isNotEmpty() }
|
||||
.map { annotation ->
|
||||
JsonObject(
|
||||
buildMap {
|
||||
put("id", JsonPrimitive(annotation.id))
|
||||
put("pageIndex", JsonPrimitive(annotation.pageIndex))
|
||||
put("annotationType", JsonPrimitive("INK"))
|
||||
put("inkType", JsonPrimitive(annotation.tool.name))
|
||||
put("color", JsonPrimitive(annotation.colorArgb))
|
||||
put("strokeWidth", JsonPrimitive(annotation.strokeWidth.toDouble()))
|
||||
put(
|
||||
"points",
|
||||
JsonArray(
|
||||
annotation.points.map { point ->
|
||||
JsonObject(
|
||||
mapOf(
|
||||
"x" to JsonPrimitive(point.x.toDouble()),
|
||||
"y" to JsonPrimitive(point.y.toDouble()),
|
||||
"t" to JsonPrimitive(point.timestamp)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun List<SharedPdfAnnotation>.toLegacyAndroidTextBoxArray(): JsonArray {
|
||||
return JsonArray(
|
||||
filter { it.kind == PdfAnnotationKind.TEXT && it.bounds != null }
|
||||
.map { annotation ->
|
||||
val bounds = requireNotNull(annotation.bounds)
|
||||
JsonObject(
|
||||
buildMap {
|
||||
put("id", JsonPrimitive(annotation.id))
|
||||
put("pageIndex", JsonPrimitive(annotation.pageIndex))
|
||||
put("text", JsonPrimitive(annotation.text))
|
||||
put("color", JsonPrimitive(annotation.colorArgb))
|
||||
put("backgroundColor", JsonPrimitive(annotation.backgroundArgb))
|
||||
put("fontSize", JsonPrimitive(annotation.fontSize.sharedFontSizeToLegacyTextBox().toDouble()))
|
||||
put("isBold", JsonPrimitive(annotation.isBold))
|
||||
put("isItalic", JsonPrimitive(annotation.isItalic))
|
||||
put("isUnderline", JsonPrimitive(annotation.isUnderline))
|
||||
put("isStrikeThrough", JsonPrimitive(annotation.isStrikeThrough))
|
||||
annotation.fontPath?.let { put("fontPath", JsonPrimitive(it)) }
|
||||
annotation.fontName?.let { put("fontName", JsonPrimitive(it)) }
|
||||
put("bounds", bounds.toJsonObject())
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun List<SharedPdfAnnotation>.toLegacyAndroidHighlightArray(): JsonArray {
|
||||
return JsonArray(
|
||||
filter { it.kind == PdfAnnotationKind.HIGHLIGHT }
|
||||
.map { annotation ->
|
||||
JsonObject(
|
||||
buildMap {
|
||||
put("id", JsonPrimitive(annotation.id))
|
||||
put("pageIndex", JsonPrimitive(annotation.pageIndex))
|
||||
put("color", JsonPrimitive(annotation.colorArgb.toLegacyHighlightColorName()))
|
||||
put("text", JsonPrimitive(annotation.text))
|
||||
val rangeStart = annotation.rangeStartIndex ?: 0
|
||||
val rangeEnd = annotation.rangeEndIndex?.plus(1)?.coerceAtLeast(rangeStart) ?: rangeStart
|
||||
put("rangeStart", JsonPrimitive(rangeStart))
|
||||
put("rangeEnd", JsonPrimitive(rangeEnd))
|
||||
annotation.note?.takeIf { it.isNotBlank() }?.let { put("note", JsonPrimitive(it)) }
|
||||
put("bounds", JsonArray(emptyList()))
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseObjectOrNull(raw: String): JsonObject? {
|
||||
return runCatching { json.parseToJsonElement(raw).jsonObject }.getOrNull()
|
||||
}
|
||||
|
||||
private fun stableAnnotationId(prefix: String, element: JsonElement): String {
|
||||
return "${prefix}_${localFolderSyncSha256ShortHex(json.encodeToString(JsonElement.serializer(), element))}"
|
||||
}
|
||||
|
||||
private fun JsonElement.looksLikeSharedAnnotationStore(): Boolean {
|
||||
val obj = jsonObjectOrNull()
|
||||
if (obj?.array("annotations") != null) return true
|
||||
val array = jsonArrayOrNull() ?: return false
|
||||
val first = array.firstOrNull()?.jsonObjectOrNull() ?: return false
|
||||
return first["kind"] != null && first["colorArgb"] != null
|
||||
}
|
||||
|
||||
private fun JsonElement?.isLegacyAndroidInkArray(): Boolean {
|
||||
val array = this?.jsonArrayOrNull() ?: return false
|
||||
if (array.isEmpty()) return true
|
||||
return array.all { element ->
|
||||
val obj = element.jsonObjectOrNull() ?: return@all false
|
||||
obj["kind"] == null &&
|
||||
obj["points"] != null &&
|
||||
(obj["annotationType"] != null || obj["inkType"] != null || obj["type"] != null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonElement?.isJsonArray(): Boolean = this?.jsonArrayOrNull() != null
|
||||
|
||||
private fun JsonElement.jsonArrayOrNull(): JsonArray? {
|
||||
if (this is JsonNull) return null
|
||||
return runCatching { jsonArray }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonElement.jsonObjectOrNull(): JsonObject? {
|
||||
if (this is JsonNull) return null
|
||||
return runCatching { jsonObject }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.array(name: String): JsonArray? = this[name]?.jsonArrayOrNull()
|
||||
|
||||
private fun JsonObject.objectValue(name: String): JsonObject? = this[name]?.jsonObjectOrNull()
|
||||
|
||||
private fun JsonObject.string(name: String): String? {
|
||||
return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull }
|
||||
.getOrNull()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
private fun JsonObject.int(name: String): Int? {
|
||||
return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.long(name: String): Long? {
|
||||
return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.longOrNull }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.float(name: String): Float? {
|
||||
return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.doubleOrNull?.toFloat() }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.boolean(name: String): Boolean? {
|
||||
return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.booleanOrNull }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.toPdfPageBoundsOrNull(): PdfPageBounds? {
|
||||
val left = float("left") ?: return null
|
||||
val top = float("top") ?: return null
|
||||
val right = float("right") ?: return null
|
||||
val bottom = float("bottom") ?: return null
|
||||
return PdfPageBounds(
|
||||
left = minOf(left, right),
|
||||
top = minOf(top, bottom),
|
||||
right = maxOf(left, right),
|
||||
bottom = maxOf(top, bottom)
|
||||
)
|
||||
}
|
||||
|
||||
private fun PdfPageBounds.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"left" to JsonPrimitive(left.toDouble()),
|
||||
"top" to JsonPrimitive(top.toDouble()),
|
||||
"right" to JsonPrimitive(right.toDouble()),
|
||||
"bottom" to JsonPrimitive(bottom.toDouble())
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun PdfPageBounds.isNormalizedPageBounds(): Boolean {
|
||||
return left in 0f..1f &&
|
||||
top in 0f..1f &&
|
||||
right in 0f..1f &&
|
||||
bottom in 0f..1f &&
|
||||
right >= left &&
|
||||
bottom >= top
|
||||
}
|
||||
|
||||
private fun String.toPdfInkTool(): PdfInkTool {
|
||||
return runCatching { PdfInkTool.valueOf(this) }.getOrDefault(PdfInkTool.PEN)
|
||||
}
|
||||
|
||||
private fun Float.legacyTextBoxFontSizeToShared(): Float {
|
||||
return if (this in 0f..1f) {
|
||||
(this * LEGACY_TEXT_BOX_FONT_REFERENCE_DP).coerceIn(8f, 48f)
|
||||
} else {
|
||||
coerceIn(8f, 96f)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Float.sharedFontSizeToLegacyTextBox(): Float {
|
||||
return (this / LEGACY_TEXT_BOX_FONT_REFERENCE_DP).coerceIn(0.012f, 0.12f)
|
||||
}
|
||||
|
||||
private fun String.toSharedHighlightArgb(): Int {
|
||||
val opaqueArgb = legacyHighlightColors[uppercase()] ?: legacyHighlightColors.getValue("YELLOW")
|
||||
return 0x8C000000.toInt() or (opaqueArgb and 0x00FFFFFF)
|
||||
}
|
||||
|
||||
private fun Int.toLegacyHighlightColorName(): String {
|
||||
val rgb = this and 0x00FFFFFF
|
||||
return legacyHighlightColors.minByOrNull { (_, color) ->
|
||||
val candidate = color and 0x00FFFFFF
|
||||
val dr = ((rgb shr 16) and 0xFF) - ((candidate shr 16) and 0xFF)
|
||||
val dg = ((rgb shr 8) and 0xFF) - ((candidate shr 8) and 0xFF)
|
||||
val db = (rgb and 0xFF) - (candidate and 0xFF)
|
||||
dr.toDouble().pow(2) + dg.toDouble().pow(2) + db.toDouble().pow(2)
|
||||
}?.key ?: "YELLOW"
|
||||
}
|
||||
|
||||
private val legacyHighlightColors = mapOf(
|
||||
"YELLOW" to 0xFFFBC02D.toInt(),
|
||||
"GREEN" to 0xFF388E3C.toInt(),
|
||||
"BLUE" to 0xFF1976D2.toInt(),
|
||||
"RED" to 0xFFD32F2F.toInt()
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,412 @@
|
|||
package com.aryan.reader.shared.pdf
|
||||
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
|
||||
sealed interface SharedPdfInkRenderData {
|
||||
data class Standard(
|
||||
val path: Path,
|
||||
val color: Color,
|
||||
val strokeWidthPx: Float,
|
||||
val cap: StrokeCap,
|
||||
val blendMode: BlendMode
|
||||
) : SharedPdfInkRenderData
|
||||
|
||||
data class Fountain(
|
||||
val path: Path,
|
||||
val color: Color
|
||||
) : SharedPdfInkRenderData
|
||||
|
||||
data class Pencil(
|
||||
val path: Path,
|
||||
val color: Color,
|
||||
val strokeWidthPx: Float,
|
||||
val velocityAlpha: Float
|
||||
) : SharedPdfInkRenderData
|
||||
}
|
||||
|
||||
object SharedPdfInkRenderer {
|
||||
fun createRenderData(
|
||||
annotation: SharedPdfAnnotation,
|
||||
canvasSize: IntSize
|
||||
): SharedPdfInkRenderData? {
|
||||
if (annotation.kind != PdfAnnotationKind.INK || annotation.points.isEmpty()) return null
|
||||
val widthPx = canvasSize.width.coerceAtLeast(1).toFloat()
|
||||
val heightPx = canvasSize.height.coerceAtLeast(1).toFloat()
|
||||
val strokeWidthPx = effectiveStrokeWidthPx(annotation.strokeWidth, widthPx)
|
||||
val color = Color(annotation.colorArgb)
|
||||
|
||||
if (annotation.points.size == 1) {
|
||||
val point = annotation.points.first()
|
||||
val x = point.x * widthPx
|
||||
val y = point.y * heightPx
|
||||
return when (annotation.tool) {
|
||||
PdfInkTool.FOUNTAIN_PEN -> {
|
||||
val path = Path().apply {
|
||||
addOval(Rect(center = Offset(x, y), radius = strokeWidthPx / 2f))
|
||||
}
|
||||
SharedPdfInkRenderData.Fountain(path = path, color = color)
|
||||
}
|
||||
PdfInkTool.PENCIL -> {
|
||||
val path = Path().apply {
|
||||
moveTo(x, y)
|
||||
lineTo(x, y)
|
||||
}
|
||||
SharedPdfInkRenderData.Pencil(
|
||||
path = path,
|
||||
color = color,
|
||||
strokeWidthPx = strokeWidthPx,
|
||||
velocityAlpha = 1f
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
val path = Path().apply {
|
||||
moveTo(x, y)
|
||||
lineTo(x, y)
|
||||
}
|
||||
SharedPdfInkRenderData.Standard(
|
||||
path = path,
|
||||
color = color,
|
||||
strokeWidthPx = strokeWidthPx,
|
||||
cap = annotation.tool.strokeCap,
|
||||
blendMode = annotation.tool.blendMode
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return when (annotation.tool) {
|
||||
PdfInkTool.PENCIL -> {
|
||||
val path = annotation.points.toSmoothPath(widthPx, heightPx)
|
||||
val velocityAlpha = annotation.points.velocityAlpha(widthPx, heightPx)
|
||||
SharedPdfInkRenderData.Pencil(
|
||||
path = path,
|
||||
color = color,
|
||||
strokeWidthPx = strokeWidthPx,
|
||||
velocityAlpha = velocityAlpha
|
||||
)
|
||||
}
|
||||
PdfInkTool.FOUNTAIN_PEN -> {
|
||||
val (leftSide, rightSide) = calculateFountainPenEdges(
|
||||
points = annotation.points,
|
||||
baseWidthPx = strokeWidthPx,
|
||||
pageWidthPx = widthPx,
|
||||
pageHeightPx = heightPx
|
||||
)
|
||||
val path = Path()
|
||||
if (leftSide.isNotEmpty()) {
|
||||
path.moveTo(leftSide.first().x, leftSide.first().y)
|
||||
leftSide.drop(1).forEach { path.lineTo(it.x, it.y) }
|
||||
rightSide.asReversed().forEach { path.lineTo(it.x, it.y) }
|
||||
path.close()
|
||||
}
|
||||
SharedPdfInkRenderData.Fountain(path = path, color = color)
|
||||
}
|
||||
PdfInkTool.PEN,
|
||||
PdfInkTool.HIGHLIGHTER,
|
||||
PdfInkTool.HIGHLIGHTER_ROUND,
|
||||
PdfInkTool.ERASER,
|
||||
PdfInkTool.TEXT -> {
|
||||
SharedPdfInkRenderData.Standard(
|
||||
path = annotation.points.toSmoothPath(widthPx, heightPx),
|
||||
color = color,
|
||||
strokeWidthPx = strokeWidthPx,
|
||||
cap = annotation.tool.strokeCap,
|
||||
blendMode = annotation.tool.blendMode
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun effectiveStrokeWidthPx(strokeWidth: Float, canvasSize: IntSize): Float {
|
||||
return effectiveStrokeWidthPx(strokeWidth, canvasSize.width.coerceAtLeast(1).toFloat())
|
||||
}
|
||||
|
||||
fun effectiveStrokeWidthPx(strokeWidth: Float, pageWidthPx: Float): Float {
|
||||
val safeWidth = pageWidthPx.coerceAtLeast(1f)
|
||||
return if (strokeWidth <= 1f) {
|
||||
(strokeWidth * safeWidth).coerceAtLeast(0.1f)
|
||||
} else {
|
||||
strokeWidth.coerceAtLeast(0.1f)
|
||||
}
|
||||
}
|
||||
|
||||
fun effectiveStrokeWidthNorm(strokeWidth: Float, pageWidthPx: Float): Float {
|
||||
val safeWidth = pageWidthPx.coerceAtLeast(1f)
|
||||
return if (strokeWidth <= 1f) strokeWidth.coerceAtLeast(0.0001f) else strokeWidth / safeWidth
|
||||
}
|
||||
|
||||
fun calculateSnappedPoint(
|
||||
currentPoint: PdfPagePoint,
|
||||
startPoint: PdfPagePoint?,
|
||||
pageAspectRatio: Float,
|
||||
thresholdDegrees: Double = 10.0
|
||||
): PdfPagePoint {
|
||||
if (startPoint == null) return currentPoint
|
||||
val safeAspectRatio = pageAspectRatio.takeIf { it > 0f } ?: 1f
|
||||
val dx = (currentPoint.x - startPoint.x) * safeAspectRatio
|
||||
val dy = currentPoint.y - startPoint.y
|
||||
val angleDeg = atan2(dy, dx) * 180 / PI
|
||||
val absAngle = abs(angleDeg)
|
||||
val isHorizontal = absAngle < thresholdDegrees || abs(absAngle - 180.0) < thresholdDegrees
|
||||
val isVertical = abs(absAngle - 90.0) < thresholdDegrees
|
||||
return when {
|
||||
isHorizontal -> currentPoint.copy(y = startPoint.y)
|
||||
isVertical -> currentPoint.copy(x = startPoint.x)
|
||||
else -> currentPoint
|
||||
}
|
||||
}
|
||||
|
||||
fun isAnnotationHit(
|
||||
annotation: SharedPdfAnnotation,
|
||||
hitPoint: PdfPagePoint,
|
||||
pageWidthPx: Float,
|
||||
pageAspectRatio: Float,
|
||||
eraserStrokeWidth: Float = SharedPdfAnnotationDefaults.configFor(PdfInkTool.ERASER).strokeWidth,
|
||||
lastHitPoint: PdfPagePoint? = null
|
||||
): Boolean {
|
||||
return when (annotation.kind) {
|
||||
PdfAnnotationKind.HIGHLIGHT,
|
||||
PdfAnnotationKind.TEXT -> annotation.allBounds().any { it.contains(hitPoint.x, hitPoint.y) }
|
||||
PdfAnnotationKind.INK -> isInkAnnotationHit(
|
||||
annotation = annotation,
|
||||
hitPoint = hitPoint,
|
||||
pageWidthPx = pageWidthPx,
|
||||
pageAspectRatio = pageAspectRatio,
|
||||
eraserStrokeWidth = eraserStrokeWidth,
|
||||
lastHitPoint = lastHitPoint
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isInkAnnotationHit(
|
||||
annotation: SharedPdfAnnotation,
|
||||
hitPoint: PdfPagePoint,
|
||||
pageWidthPx: Float,
|
||||
pageAspectRatio: Float,
|
||||
eraserStrokeWidth: Float,
|
||||
lastHitPoint: PdfPagePoint?
|
||||
): Boolean {
|
||||
if (annotation.points.isEmpty()) return false
|
||||
val safeAspectRatio = pageAspectRatio.takeIf { it > 0f } ?: 1f
|
||||
val eraserWidthNorm = effectiveStrokeWidthNorm(eraserStrokeWidth, pageWidthPx)
|
||||
val annotationWidthNorm = effectiveStrokeWidthNorm(annotation.strokeWidth, pageWidthPx)
|
||||
val threshold = eraserWidthNorm + annotationWidthNorm / 2f
|
||||
val thresholdSq = threshold * threshold
|
||||
|
||||
fun distSqToEraser(px: Float, pyScaled: Float): Float {
|
||||
val e1x = hitPoint.x
|
||||
val e1yScaled = hitPoint.y / safeAspectRatio
|
||||
if (lastHitPoint == null) {
|
||||
val dx = px - e1x
|
||||
val dy = pyScaled - e1yScaled
|
||||
return dx * dx + dy * dy
|
||||
}
|
||||
|
||||
val e0x = lastHitPoint.x
|
||||
val e0yScaled = lastHitPoint.y / safeAspectRatio
|
||||
val ex = e1x - e0x
|
||||
val ey = e1yScaled - e0yScaled
|
||||
val segmentLenSq = ex * ex + ey * ey
|
||||
if (segmentLenSq < 1e-8f) {
|
||||
val dx = px - e1x
|
||||
val dy = pyScaled - e1yScaled
|
||||
return dx * dx + dy * dy
|
||||
}
|
||||
|
||||
val t = ((px - e0x) * ex + (pyScaled - e0yScaled) * ey) / segmentLenSq
|
||||
val closestX = e0x + ex * t.coerceIn(0f, 1f)
|
||||
val closestY = e0yScaled + ey * t.coerceIn(0f, 1f)
|
||||
val dx = px - closestX
|
||||
val dy = pyScaled - closestY
|
||||
return dx * dx + dy * dy
|
||||
}
|
||||
|
||||
if (annotation.points.size == 1) {
|
||||
val p = annotation.points.first()
|
||||
return distSqToEraser(p.x, p.y / safeAspectRatio) < thresholdSq
|
||||
}
|
||||
|
||||
for (i in 0 until annotation.points.lastIndex) {
|
||||
val a = annotation.points[i]
|
||||
val b = annotation.points[i + 1]
|
||||
val pax = hitPoint.x - a.x
|
||||
val pay = (hitPoint.y - a.y) / safeAspectRatio
|
||||
val bax = b.x - a.x
|
||||
val bay = (b.y - a.y) / safeAspectRatio
|
||||
val segmentLenSq = (bax * bax + bay * bay).coerceAtLeast(1e-6f)
|
||||
val t = ((pax * bax + pay * bay) / segmentLenSq).coerceIn(0f, 1f)
|
||||
val closestX = bax * t
|
||||
val closestY = bay * t
|
||||
val dx = pax - closestX
|
||||
val dy = pay - closestY
|
||||
if (dx * dx + dy * dy < thresholdSq) return true
|
||||
|
||||
if (lastHitPoint != null) {
|
||||
if (distSqToEraser(a.x, a.y / safeAspectRatio) < thresholdSq) return true
|
||||
if (distSqToEraser(b.x, b.y / safeAspectRatio) < thresholdSq) return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun calculateFountainPenEdges(
|
||||
points: List<PdfPagePoint>,
|
||||
baseWidthPx: Float,
|
||||
pageWidthPx: Float,
|
||||
pageHeightPx: Float
|
||||
): Pair<List<Offset>, List<Offset>> {
|
||||
if (points.size < 2) return emptyList<Offset>() to emptyList<Offset>()
|
||||
|
||||
val leftSide = mutableListOf<Offset>()
|
||||
val rightSide = mutableListOf<Offset>()
|
||||
val computedWidths = FloatArray(points.size)
|
||||
computedWidths[0] = baseWidthPx
|
||||
val velocityFactor = 300f
|
||||
|
||||
for (i in 1 until points.size) {
|
||||
val p0 = points[i - 1]
|
||||
val p1 = points[i]
|
||||
val dx = p1.x - p0.x
|
||||
val dy = p1.y - p0.y
|
||||
val aspect = if (pageWidthPx > 0f && pageHeightPx > 0f) pageHeightPx / pageWidthPx else 1f
|
||||
val scaledDy = dy * aspect
|
||||
val distNorm = sqrt(dx * dx + scaledDy * scaledDy)
|
||||
val timeDelta = (p1.timestamp - p0.timestamp).coerceAtLeast(1)
|
||||
val velocityNorm = distNorm / timeDelta
|
||||
val targetWidth = (baseWidthPx * (1f / (1f + velocityNorm * velocityFactor))).coerceIn(
|
||||
baseWidthPx * 0.2f,
|
||||
baseWidthPx * 1.4f
|
||||
)
|
||||
computedWidths[i] = computedWidths[i - 1] * 0.6f + targetWidth * 0.4f
|
||||
}
|
||||
|
||||
for (i in 0 until points.lastIndex) {
|
||||
val current = points[i]
|
||||
val next = points[i + 1]
|
||||
val currentX = current.x * pageWidthPx
|
||||
val currentY = current.y * pageHeightPx
|
||||
val nextX = next.x * pageWidthPx
|
||||
val nextY = next.y * pageHeightPx
|
||||
val angle = atan2(nextY - currentY, nextX - currentX)
|
||||
val normalAngle = angle - (PI / 2f).toFloat()
|
||||
val halfWidth = computedWidths[i] / 2f
|
||||
leftSide += Offset(
|
||||
x = currentX + cos(normalAngle) * halfWidth,
|
||||
y = currentY + sin(normalAngle) * halfWidth
|
||||
)
|
||||
rightSide += Offset(
|
||||
x = currentX - cos(normalAngle) * halfWidth,
|
||||
y = currentY - sin(normalAngle) * halfWidth
|
||||
)
|
||||
}
|
||||
|
||||
val last = points.last()
|
||||
val previous = points[points.lastIndex - 1]
|
||||
val lastX = last.x * pageWidthPx
|
||||
val lastY = last.y * pageHeightPx
|
||||
val previousX = previous.x * pageWidthPx
|
||||
val previousY = previous.y * pageHeightPx
|
||||
val lastAngle = atan2(lastY - previousY, lastX - previousX)
|
||||
val lastNormal = lastAngle - (PI / 2f).toFloat()
|
||||
val lastHalfWidth = computedWidths.last() / 2f
|
||||
leftSide += Offset(
|
||||
x = lastX + cos(lastNormal) * lastHalfWidth,
|
||||
y = lastY + sin(lastNormal) * lastHalfWidth
|
||||
)
|
||||
rightSide += Offset(
|
||||
x = lastX - cos(lastNormal) * lastHalfWidth,
|
||||
y = lastY - sin(lastNormal) * lastHalfWidth
|
||||
)
|
||||
return leftSide to rightSide
|
||||
}
|
||||
}
|
||||
|
||||
fun PdfInkTool.sharedPdfStrokeWidthRange(): ClosedFloatingPointRange<Float> {
|
||||
return when (this) {
|
||||
PdfInkTool.HIGHLIGHTER,
|
||||
PdfInkTool.HIGHLIGHTER_ROUND -> 0.01f..0.06f
|
||||
PdfInkTool.ERASER -> 0.002f..0.10f
|
||||
PdfInkTool.TEXT -> 0.01f..0.08f
|
||||
PdfInkTool.PEN,
|
||||
PdfInkTool.FOUNTAIN_PEN,
|
||||
PdfInkTool.PENCIL -> 0.001f..0.015f
|
||||
}
|
||||
}
|
||||
|
||||
fun Float.sharedPdfStrokePercent(range: ClosedFloatingPointRange<Float>): Int {
|
||||
val span = (range.endInclusive - range.start).coerceAtLeast(0.0001f)
|
||||
return (((this - range.start) / span) * 100f).toInt().coerceIn(1, 100)
|
||||
}
|
||||
|
||||
private val PdfInkTool.strokeCap: StrokeCap
|
||||
get() = when (this) {
|
||||
PdfInkTool.HIGHLIGHTER -> StrokeCap.Butt
|
||||
PdfInkTool.HIGHLIGHTER_ROUND -> StrokeCap.Round
|
||||
else -> StrokeCap.Round
|
||||
}
|
||||
|
||||
private val PdfInkTool.blendMode: BlendMode
|
||||
get() = when (this) {
|
||||
PdfInkTool.HIGHLIGHTER,
|
||||
PdfInkTool.HIGHLIGHTER_ROUND -> BlendMode.Multiply
|
||||
else -> BlendMode.SrcOver
|
||||
}
|
||||
|
||||
private fun PdfPageBounds.contains(x: Float, y: Float): Boolean {
|
||||
return x in left..right && y in top..bottom
|
||||
}
|
||||
|
||||
private fun SharedPdfAnnotation.allBounds(): List<PdfPageBounds> {
|
||||
return boundsList.ifEmpty { listOfNotNull(bounds) }
|
||||
}
|
||||
|
||||
private fun List<PdfPagePoint>.toSmoothPath(widthPx: Float, heightPx: Float): Path {
|
||||
val path = Path()
|
||||
val first = first()
|
||||
path.moveTo(first.x * widthPx, first.y * heightPx)
|
||||
for (i in 1 until size) {
|
||||
val p0 = this[i - 1]
|
||||
val p1 = this[i]
|
||||
val p0x = p0.x * widthPx
|
||||
val p0y = p0.y * heightPx
|
||||
val p1x = p1.x * widthPx
|
||||
val p1y = p1.y * heightPx
|
||||
val midX = (p0x + p1x) / 2f
|
||||
val midY = (p0y + p1y) / 2f
|
||||
if (i == 1) {
|
||||
path.lineTo(midX, midY)
|
||||
} else {
|
||||
path.quadraticTo(p0x, p0y, midX, midY)
|
||||
}
|
||||
}
|
||||
val last = last()
|
||||
path.lineTo(last.x * widthPx, last.y * heightPx)
|
||||
return path
|
||||
}
|
||||
|
||||
private fun List<PdfPagePoint>.velocityAlpha(widthPx: Float, heightPx: Float): Float {
|
||||
if (size < 2) return 1f
|
||||
var totalDistance = 0f
|
||||
for (i in 1 until size) {
|
||||
val p0 = this[i - 1]
|
||||
val p1 = this[i]
|
||||
val dx = (p1.x - p0.x) * widthPx
|
||||
val dy = (p1.y - p0.y) * heightPx
|
||||
totalDistance += sqrt(dx * dx + dy * dy)
|
||||
}
|
||||
val duration = (last().timestamp - first().timestamp).coerceAtLeast(1)
|
||||
val velocity = totalDistance / duration
|
||||
return (1f - (velocity - 0.2f) / 1.8f).coerceIn(0.4f, 1f)
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,353 @@
|
|||
package com.aryan.reader.shared.pdf
|
||||
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlin.math.ceil
|
||||
|
||||
@Serializable
|
||||
data class SharedPdfTextStyleConfig(
|
||||
val colorArgb: Int = 0xFF000000.toInt(),
|
||||
val backgroundColorArgb: Int = 0x00000000,
|
||||
val fontSize: Float = 16f,
|
||||
val isBold: Boolean = false,
|
||||
val isItalic: Boolean = false,
|
||||
val isUnderline: Boolean = false,
|
||||
val isStrikeThrough: Boolean = false,
|
||||
val fontPath: String? = null,
|
||||
val fontName: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SharedPdfTextFontPreset(
|
||||
val name: String,
|
||||
val fontPath: String? = null
|
||||
)
|
||||
|
||||
enum class SharedPdfTextResizeHandle {
|
||||
TOP_LEFT,
|
||||
TOP_CENTER,
|
||||
TOP_RIGHT,
|
||||
RIGHT_CENTER,
|
||||
BOTTOM_RIGHT,
|
||||
BOTTOM_CENTER,
|
||||
BOTTOM_LEFT,
|
||||
LEFT_CENTER
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class SharedPdfTextDraft(
|
||||
val id: String,
|
||||
val pageIndex: Int,
|
||||
val bounds: PdfPageBounds,
|
||||
val text: String = "",
|
||||
val style: SharedPdfTextStyleConfig = SharedPdfTextStyleConfig(),
|
||||
val createdAt: Long = 0L,
|
||||
val isManuallySized: Boolean = false
|
||||
)
|
||||
|
||||
object SharedPdfTextAnnotationDefaults {
|
||||
val fontSizes: List<Float> = listOf(12f, 14f, 16f, 18f, 20f, 24f, 30f)
|
||||
|
||||
val fontPresets: List<SharedPdfTextFontPreset> = listOf(
|
||||
SharedPdfTextFontPreset("Default"),
|
||||
SharedPdfTextFontPreset("Merriweather", "asset:fonts/merriweather.ttf"),
|
||||
SharedPdfTextFontPreset("Lato", "asset:fonts/lato.ttf"),
|
||||
SharedPdfTextFontPreset("Lora", "asset:fonts/lora.ttf"),
|
||||
SharedPdfTextFontPreset("Roboto Mono", "asset:fonts/roboto_mono.ttf"),
|
||||
SharedPdfTextFontPreset("Lexend", "asset:fonts/lexend.ttf")
|
||||
)
|
||||
|
||||
val textColorPalette: List<Int>
|
||||
get() = SharedPdfAnnotationDefaults.penPalette
|
||||
|
||||
val backgroundColorPalette: List<Int> = listOf(
|
||||
0x00000000,
|
||||
0x8CFF9800.toInt(),
|
||||
0x8CFFEB3B.toInt(),
|
||||
0x8C81C784.toInt(),
|
||||
0x8C64B5F6.toInt(),
|
||||
0x8CE1BEE7.toInt()
|
||||
)
|
||||
|
||||
fun normalizeTextDraft(text: String): String {
|
||||
return text
|
||||
.replace("\r\n", "\n")
|
||||
.replace('\r', '\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
fun createAnnotation(
|
||||
id: String,
|
||||
pageIndex: Int,
|
||||
anchor: PdfPagePoint,
|
||||
canvasSize: IntSize,
|
||||
text: String,
|
||||
style: SharedPdfTextStyleConfig,
|
||||
createdAt: Long
|
||||
): SharedPdfAnnotation {
|
||||
val cleanText = normalizeTextDraft(text)
|
||||
return SharedPdfAnnotation(
|
||||
id = id,
|
||||
pageIndex = pageIndex,
|
||||
kind = PdfAnnotationKind.TEXT,
|
||||
tool = PdfInkTool.TEXT,
|
||||
bounds = boundsForPlacedText(anchor, canvasSize, cleanText, style),
|
||||
text = cleanText,
|
||||
colorArgb = style.colorArgb,
|
||||
backgroundArgb = style.backgroundColorArgb,
|
||||
strokeWidth = SharedPdfAnnotationDefaults.configFor(PdfInkTool.TEXT).strokeWidth,
|
||||
fontSize = style.fontSize,
|
||||
isBold = style.isBold,
|
||||
isItalic = style.isItalic,
|
||||
isUnderline = style.isUnderline,
|
||||
isStrikeThrough = style.isStrikeThrough,
|
||||
fontPath = style.fontPath,
|
||||
fontName = style.fontName,
|
||||
createdAt = createdAt
|
||||
)
|
||||
}
|
||||
|
||||
fun createDraft(
|
||||
id: String,
|
||||
pageIndex: Int,
|
||||
anchor: PdfPagePoint,
|
||||
canvasSize: IntSize,
|
||||
style: SharedPdfTextStyleConfig,
|
||||
createdAt: Long
|
||||
): SharedPdfTextDraft {
|
||||
return SharedPdfTextDraft(
|
||||
id = id,
|
||||
pageIndex = pageIndex,
|
||||
bounds = boundsForPlacedText(anchor, canvasSize, " ", style),
|
||||
text = "",
|
||||
style = style,
|
||||
createdAt = createdAt
|
||||
)
|
||||
}
|
||||
|
||||
fun boundsForPlacedText(
|
||||
anchor: PdfPagePoint,
|
||||
canvasSize: IntSize,
|
||||
text: String,
|
||||
style: SharedPdfTextStyleConfig
|
||||
): PdfPageBounds {
|
||||
val widthPx = canvasSize.width.coerceAtLeast(1).toFloat()
|
||||
val heightPx = canvasSize.height.coerceAtLeast(1).toFloat()
|
||||
val widthNorm = estimateWidthNorm(text, style, widthPx).coerceIn(0.18f, 0.62f)
|
||||
val lineCount = estimateLineCount(text, style.fontSize, widthPx * widthNorm)
|
||||
val heightNorm = (((style.fontSize * 1.35f * lineCount) + 14f) / heightPx).coerceIn(0.04f, 0.36f)
|
||||
val left = anchor.x.coerceIn(0f, 1f - widthNorm)
|
||||
val top = anchor.y.coerceIn(0f, 1f - heightNorm)
|
||||
return PdfPageBounds(
|
||||
left = left,
|
||||
top = top,
|
||||
right = left + widthNorm,
|
||||
bottom = top + heightNorm
|
||||
)
|
||||
}
|
||||
|
||||
fun estimateLineCount(text: String, fontSize: Float, widthPx: Float): Int {
|
||||
if (text.isBlank()) return 1
|
||||
val averageCharWidth = (fontSize * 0.55f).coerceAtLeast(1f)
|
||||
val charsPerLine = (widthPx / averageCharWidth).toInt().coerceAtLeast(8)
|
||||
return text.lineSequence().sumOf { rawLine ->
|
||||
val length = rawLine.length.coerceAtLeast(1)
|
||||
ceil(length / charsPerLine.toFloat()).toInt().coerceAtLeast(1)
|
||||
}.coerceAtLeast(1)
|
||||
}
|
||||
|
||||
private fun estimateWidthNorm(
|
||||
text: String,
|
||||
style: SharedPdfTextStyleConfig,
|
||||
pageWidthPx: Float
|
||||
): Float {
|
||||
val longestLine = text.lineSequence().maxOfOrNull { it.length } ?: 0
|
||||
val estimatedTextWidth = (longestLine.coerceAtLeast(12) * style.fontSize * 0.55f) + 18f
|
||||
return (estimatedTextWidth / pageWidthPx).coerceAtLeast(0.28f)
|
||||
}
|
||||
}
|
||||
|
||||
fun SharedPdfTextDraft.withText(
|
||||
text: String,
|
||||
canvasSize: IntSize
|
||||
): SharedPdfTextDraft {
|
||||
val normalizedText = text
|
||||
.replace("\r\n", "\n")
|
||||
.replace('\r', '\n')
|
||||
if (isManuallySized) {
|
||||
return copy(text = normalizedText)
|
||||
}
|
||||
val anchor = PdfPagePoint(bounds.left, bounds.top, createdAt)
|
||||
return copy(
|
||||
text = normalizedText,
|
||||
bounds = SharedPdfTextAnnotationDefaults.boundsForPlacedText(
|
||||
anchor = anchor,
|
||||
canvasSize = canvasSize,
|
||||
text = normalizedText.ifBlank { " " },
|
||||
style = style
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun SharedPdfTextDraft.withStyle(
|
||||
style: SharedPdfTextStyleConfig,
|
||||
canvasSize: IntSize
|
||||
): SharedPdfTextDraft {
|
||||
if (isManuallySized) {
|
||||
return copy(style = style)
|
||||
}
|
||||
val anchor = PdfPagePoint(bounds.left, bounds.top, createdAt)
|
||||
return copy(
|
||||
style = style,
|
||||
bounds = SharedPdfTextAnnotationDefaults.boundsForPlacedText(
|
||||
anchor = anchor,
|
||||
canvasSize = canvasSize,
|
||||
text = text.ifBlank { " " },
|
||||
style = style
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun SharedPdfTextDraft.withBounds(bounds: PdfPageBounds): SharedPdfTextDraft {
|
||||
return copy(bounds = bounds.coercedToPage(), isManuallySized = true)
|
||||
}
|
||||
|
||||
fun SharedPdfTextDraft.toAnnotation(): SharedPdfAnnotation {
|
||||
val cleanText = SharedPdfTextAnnotationDefaults.normalizeTextDraft(text)
|
||||
return SharedPdfAnnotation(
|
||||
id = id,
|
||||
pageIndex = pageIndex,
|
||||
kind = PdfAnnotationKind.TEXT,
|
||||
tool = PdfInkTool.TEXT,
|
||||
bounds = bounds,
|
||||
text = cleanText,
|
||||
colorArgb = style.colorArgb,
|
||||
backgroundArgb = style.backgroundColorArgb,
|
||||
strokeWidth = SharedPdfAnnotationDefaults.configFor(PdfInkTool.TEXT).strokeWidth,
|
||||
fontSize = style.fontSize,
|
||||
isBold = style.isBold,
|
||||
isItalic = style.isItalic,
|
||||
isUnderline = style.isUnderline,
|
||||
isStrikeThrough = style.isStrikeThrough,
|
||||
fontPath = style.fontPath,
|
||||
fontName = style.fontName,
|
||||
createdAt = createdAt
|
||||
)
|
||||
}
|
||||
|
||||
fun PdfPageBounds.resizedBy(
|
||||
handle: SharedPdfTextResizeHandle,
|
||||
deltaXPx: Float,
|
||||
deltaYPx: Float,
|
||||
canvasSize: IntSize,
|
||||
minWidthPx: Float = 50f,
|
||||
minHeightPx: Float = 50f
|
||||
): PdfPageBounds {
|
||||
val pageWidthPx = canvasSize.width.coerceAtLeast(1).toFloat()
|
||||
val pageHeightPx = canvasSize.height.coerceAtLeast(1).toFloat()
|
||||
val minWidth = minWidthPx.coerceIn(1f, pageWidthPx)
|
||||
val minHeight = minHeightPx.coerceIn(1f, pageHeightPx)
|
||||
|
||||
var leftPx = left * pageWidthPx
|
||||
var topPx = top * pageHeightPx
|
||||
var rightPx = right * pageWidthPx
|
||||
var bottomPx = bottom * pageHeightPx
|
||||
|
||||
when (handle) {
|
||||
SharedPdfTextResizeHandle.TOP_LEFT -> {
|
||||
leftPx = (leftPx + deltaXPx).coerceIn(0f, (rightPx - minWidth).coerceAtLeast(0f))
|
||||
topPx = (topPx + deltaYPx).coerceIn(0f, (bottomPx - minHeight).coerceAtLeast(0f))
|
||||
}
|
||||
SharedPdfTextResizeHandle.TOP_CENTER -> {
|
||||
topPx = (topPx + deltaYPx).coerceIn(0f, (bottomPx - minHeight).coerceAtLeast(0f))
|
||||
}
|
||||
SharedPdfTextResizeHandle.TOP_RIGHT -> {
|
||||
rightPx = (rightPx + deltaXPx).coerceIn((leftPx + minWidth).coerceAtMost(pageWidthPx), pageWidthPx)
|
||||
topPx = (topPx + deltaYPx).coerceIn(0f, (bottomPx - minHeight).coerceAtLeast(0f))
|
||||
}
|
||||
SharedPdfTextResizeHandle.RIGHT_CENTER -> {
|
||||
rightPx = (rightPx + deltaXPx).coerceIn((leftPx + minWidth).coerceAtMost(pageWidthPx), pageWidthPx)
|
||||
}
|
||||
SharedPdfTextResizeHandle.BOTTOM_RIGHT -> {
|
||||
rightPx = (rightPx + deltaXPx).coerceIn((leftPx + minWidth).coerceAtMost(pageWidthPx), pageWidthPx)
|
||||
bottomPx = (bottomPx + deltaYPx).coerceIn((topPx + minHeight).coerceAtMost(pageHeightPx), pageHeightPx)
|
||||
}
|
||||
SharedPdfTextResizeHandle.BOTTOM_CENTER -> {
|
||||
bottomPx = (bottomPx + deltaYPx).coerceIn((topPx + minHeight).coerceAtMost(pageHeightPx), pageHeightPx)
|
||||
}
|
||||
SharedPdfTextResizeHandle.BOTTOM_LEFT -> {
|
||||
leftPx = (leftPx + deltaXPx).coerceIn(0f, (rightPx - minWidth).coerceAtLeast(0f))
|
||||
bottomPx = (bottomPx + deltaYPx).coerceIn((topPx + minHeight).coerceAtMost(pageHeightPx), pageHeightPx)
|
||||
}
|
||||
SharedPdfTextResizeHandle.LEFT_CENTER -> {
|
||||
leftPx = (leftPx + deltaXPx).coerceIn(0f, (rightPx - minWidth).coerceAtLeast(0f))
|
||||
}
|
||||
}
|
||||
|
||||
return PdfPageBounds(
|
||||
left = leftPx / pageWidthPx,
|
||||
top = topPx / pageHeightPx,
|
||||
right = rightPx / pageWidthPx,
|
||||
bottom = bottomPx / pageHeightPx
|
||||
).coercedToPage()
|
||||
}
|
||||
|
||||
fun PdfPageBounds.movedBy(
|
||||
deltaXPx: Float,
|
||||
deltaYPx: Float,
|
||||
canvasSize: IntSize
|
||||
): PdfPageBounds {
|
||||
val pageWidthPx = canvasSize.width.coerceAtLeast(1).toFloat()
|
||||
val pageHeightPx = canvasSize.height.coerceAtLeast(1).toFloat()
|
||||
val widthPx = ((right - left) * pageWidthPx).coerceIn(1f, pageWidthPx)
|
||||
val heightPx = ((bottom - top) * pageHeightPx).coerceIn(1f, pageHeightPx)
|
||||
val nextLeftPx = ((left * pageWidthPx) + deltaXPx).coerceIn(0f, (pageWidthPx - widthPx).coerceAtLeast(0f))
|
||||
val nextTopPx = ((top * pageHeightPx) + deltaYPx).coerceIn(0f, (pageHeightPx - heightPx).coerceAtLeast(0f))
|
||||
return PdfPageBounds(
|
||||
left = nextLeftPx / pageWidthPx,
|
||||
top = nextTopPx / pageHeightPx,
|
||||
right = (nextLeftPx + widthPx) / pageWidthPx,
|
||||
bottom = (nextTopPx + heightPx) / pageHeightPx
|
||||
).coercedToPage()
|
||||
}
|
||||
|
||||
fun SharedPdfAnnotation.sharedPdfTextStyle(): SharedPdfTextStyleConfig {
|
||||
return SharedPdfTextStyleConfig(
|
||||
colorArgb = colorArgb,
|
||||
backgroundColorArgb = backgroundArgb,
|
||||
fontSize = fontSize,
|
||||
isBold = isBold,
|
||||
isItalic = isItalic,
|
||||
isUnderline = isUnderline,
|
||||
isStrikeThrough = isStrikeThrough,
|
||||
fontPath = fontPath,
|
||||
fontName = fontName
|
||||
)
|
||||
}
|
||||
|
||||
fun SharedPdfAnnotation.withSharedPdfTextStyle(style: SharedPdfTextStyleConfig): SharedPdfAnnotation {
|
||||
return copy(
|
||||
colorArgb = style.colorArgb,
|
||||
backgroundArgb = style.backgroundColorArgb,
|
||||
fontSize = style.fontSize,
|
||||
isBold = style.isBold,
|
||||
isItalic = style.isItalic,
|
||||
isUnderline = style.isUnderline,
|
||||
isStrikeThrough = style.isStrikeThrough,
|
||||
fontPath = style.fontPath,
|
||||
fontName = style.fontName
|
||||
)
|
||||
}
|
||||
|
||||
private fun PdfPageBounds.coercedToPage(): PdfPageBounds {
|
||||
val coercedLeft = left.coerceIn(0f, 1f)
|
||||
val coercedTop = top.coerceIn(0f, 1f)
|
||||
val coercedRight = right.coerceIn(coercedLeft, 1f)
|
||||
val coercedBottom = bottom.coerceIn(coercedTop, 1f)
|
||||
return PdfPageBounds(
|
||||
left = coercedLeft,
|
||||
top = coercedTop,
|
||||
right = coercedRight,
|
||||
bottom = coercedBottom
|
||||
)
|
||||
}
|
||||
|
|
@ -1,66 +1,152 @@
|
|||
package com.aryan.reader.shared.reader
|
||||
|
||||
import com.aryan.reader.paginatedreader.SemanticBlock
|
||||
import com.aryan.reader.paginatedreader.SemanticFlexContainer
|
||||
import com.aryan.reader.paginatedreader.SemanticList
|
||||
import com.aryan.reader.paginatedreader.SemanticTable
|
||||
import com.aryan.reader.paginatedreader.SemanticTextBlock
|
||||
import com.aryan.reader.paginatedreader.SemanticWrappingBlock
|
||||
import com.aryan.reader.shared.HighlightColor
|
||||
import com.aryan.reader.shared.UserHighlight
|
||||
|
||||
sealed interface ReaderLinkTarget {
|
||||
data class External(val url: String) : ReaderLinkTarget
|
||||
data class Internal(val locator: ReaderLocator) : ReaderLinkTarget
|
||||
data object Ignored : ReaderLinkTarget
|
||||
}
|
||||
|
||||
data class ReaderBookmark(
|
||||
val id: String,
|
||||
val pageIndex: Int,
|
||||
val chapterTitle: String,
|
||||
val preview: String
|
||||
val preview: String,
|
||||
val locator: ReaderLocator = ReaderLocator(pageIndex = pageIndex, textQuote = preview)
|
||||
)
|
||||
|
||||
data class ReaderSearchResult(
|
||||
val pageIndex: Int,
|
||||
val chapterTitle: String,
|
||||
val preview: String
|
||||
val preview: String,
|
||||
val matchIndex: Int = 0,
|
||||
val chapterIndex: Int = 0,
|
||||
val locator: ReaderLocator = ReaderLocator(
|
||||
chapterIndex = chapterIndex,
|
||||
pageIndex = pageIndex,
|
||||
startOffset = matchIndex,
|
||||
textQuote = preview
|
||||
)
|
||||
)
|
||||
|
||||
data class ReaderSearchOptions(
|
||||
val matchCase: Boolean = false,
|
||||
val wholeWords: Boolean = false
|
||||
)
|
||||
|
||||
data class ReaderSessionState(
|
||||
val reader: PaginatedReaderState,
|
||||
val bookmarks: List<ReaderBookmark> = emptyList(),
|
||||
val highlights: List<UserHighlight> = emptyList(),
|
||||
val isSearchActive: Boolean = false,
|
||||
val showSearchResultsPanel: Boolean = true,
|
||||
val searchQuery: String = "",
|
||||
val searchOptions: ReaderSearchOptions = ReaderSearchOptions(),
|
||||
val searchResults: List<ReaderSearchResult> = emptyList(),
|
||||
val activeSearchResultIndex: Int = -1
|
||||
val activeSearchResultIndex: Int = -1,
|
||||
val navigationLocator: ReaderLocator? = null,
|
||||
val navigationRequestId: Long = 0L
|
||||
) {
|
||||
val currentBookmark: ReaderBookmark?
|
||||
get() = bookmarks.firstOrNull { it.pageIndex == reader.currentPageIndex }
|
||||
get() = navigationLocator
|
||||
?.let { locator -> bookmarks.firstOrNull { it.locator.sameLocation(locator) } }
|
||||
?: bookmarks.firstOrNull { it.pageIndex == reader.currentPageIndex && !it.locator.hasTextRange }
|
||||
|
||||
val activeSearchResult: ReaderSearchResult?
|
||||
get() = searchResults.getOrNull(activeSearchResultIndex)
|
||||
|
||||
val canGoToPreviousSearchResult: Boolean
|
||||
get() = when {
|
||||
activeSearchResultIndex > 0 -> true
|
||||
activeSearchResultIndex >= 0 -> false
|
||||
else -> searchResults.any { it.pageIndex <= reader.currentPageIndex }
|
||||
}
|
||||
|
||||
val canGoToNextSearchResult: Boolean
|
||||
get() = when {
|
||||
activeSearchResultIndex in 0 until searchResults.lastIndex -> true
|
||||
activeSearchResultIndex >= 0 -> false
|
||||
else -> searchResults.any { it.pageIndex >= reader.currentPageIndex }
|
||||
}
|
||||
}
|
||||
|
||||
class ReaderEngine(
|
||||
private val paginator: SimplePaginator = SimplePaginator()
|
||||
) {
|
||||
private data class PaginationCacheKey(
|
||||
val bookId: String,
|
||||
val chapterSignature: Int,
|
||||
val settings: ReaderSettings
|
||||
)
|
||||
|
||||
private val paginationCache = object : LinkedHashMap<PaginationCacheKey, List<ReaderPage>>(8, 0.75f, true) {
|
||||
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<PaginationCacheKey, List<ReaderPage>>?): Boolean {
|
||||
return size > 8
|
||||
}
|
||||
}
|
||||
|
||||
fun createSession(
|
||||
book: SharedEpubBook,
|
||||
settings: ReaderSettings = ReaderSettings()
|
||||
settings: ReaderSettings = ReaderSettings(),
|
||||
initialPageIndex: Int = 0,
|
||||
bookmarks: List<ReaderBookmark> = emptyList(),
|
||||
highlights: List<UserHighlight> = emptyList()
|
||||
): ReaderSessionState {
|
||||
val pages = pagesFor(book, settings)
|
||||
val initialIndex = initialPageIndex.coerceIn(0, pages.lastIndex.coerceAtLeast(0))
|
||||
val reader = PaginatedReaderState(
|
||||
book = book,
|
||||
pages = pages,
|
||||
currentPageIndex = initialIndex,
|
||||
settings = settings
|
||||
)
|
||||
return ReaderSessionState(
|
||||
reader = PaginatedReaderState(
|
||||
book = book,
|
||||
pages = paginator.paginate(book, settings),
|
||||
settings = settings
|
||||
)
|
||||
reader = reader,
|
||||
bookmarks = bookmarks
|
||||
.mapNotNull { it.normalizedForBook(book, pages) }
|
||||
.distinctBy { it.locationKey() }
|
||||
.sortedWith(compareBy<ReaderBookmark> { it.pageIndex }.thenBy { it.locator.startOffset ?: -1 }),
|
||||
highlights = highlights
|
||||
.map { it.withNormalizedLocator() }
|
||||
.filter { (it.locator.chapterIndex ?: it.chapterIndex) in book.chapters.indices }
|
||||
.distinctBy { it.id },
|
||||
navigationLocator = reader.currentPage?.toLocator(book)
|
||||
)
|
||||
}
|
||||
|
||||
fun next(state: ReaderSessionState): ReaderSessionState {
|
||||
if (!state.reader.canGoNext) return state
|
||||
return state.copy(reader = state.reader.copy(currentPageIndex = state.reader.currentPageIndex + 1))
|
||||
return goToPage(state, state.reader.currentPageIndex + 1)
|
||||
}
|
||||
|
||||
fun previous(state: ReaderSessionState): ReaderSessionState {
|
||||
if (!state.reader.canGoPrevious) return state
|
||||
return state.copy(reader = state.reader.copy(currentPageIndex = state.reader.currentPageIndex - 1))
|
||||
return goToPage(state, state.reader.currentPageIndex - 1)
|
||||
}
|
||||
|
||||
fun goToPage(state: ReaderSessionState, pageIndex: Int): ReaderSessionState {
|
||||
val target = pageIndex.coerceIn(0, state.reader.pages.lastIndex.coerceAtLeast(0))
|
||||
val page = state.reader.pages.getOrNull(target)
|
||||
return state.copy(
|
||||
reader = state.reader.copy(currentPageIndex = target),
|
||||
activeSearchResultIndex = state.searchResults.indexOfFirst { it.pageIndex == target }
|
||||
activeSearchResultIndex = state.searchResults.indexOfFirst { it.pageIndex == target },
|
||||
navigationLocator = page?.toLocator(state.reader.book),
|
||||
navigationRequestId = state.navigationRequestId + 1
|
||||
)
|
||||
}
|
||||
|
||||
fun goToPageNumber(state: ReaderSessionState, pageNumber: Int): ReaderSessionState {
|
||||
return goToPage(state, pageNumber - 1)
|
||||
}
|
||||
|
||||
fun goToProgress(state: ReaderSessionState, progress: Float): ReaderSessionState {
|
||||
if (state.reader.pages.isEmpty()) return state
|
||||
val target = ((state.reader.pages.lastIndex) * progress.coerceIn(0f, 1f)).toInt()
|
||||
|
|
@ -72,24 +158,307 @@ class ReaderEngine(
|
|||
return if (pageIndex >= 0) goToPage(state, pageIndex) else state
|
||||
}
|
||||
|
||||
fun goToLocator(state: ReaderSessionState, locator: ReaderLocator): ReaderSessionState {
|
||||
val pageIndex = state.reader.pages.indexOfFirst { page -> page.contains(locator) }
|
||||
.takeIf { it >= 0 }
|
||||
?: locator.pageIndex
|
||||
?.takeIf { it in state.reader.pages.indices }
|
||||
?: return state
|
||||
val page = state.reader.pages.getOrNull(pageIndex) ?: return state
|
||||
val chapter = state.reader.book.chapters.getOrNull(page.chapterIndex)
|
||||
val normalizedLocator = locator.copy(pageIndex = pageIndex).withFallbacks(
|
||||
chapterIndex = page.chapterIndex,
|
||||
chapterId = chapter?.id,
|
||||
href = chapter?.baseHref,
|
||||
pageIndex = pageIndex,
|
||||
startOffset = page.startOffset,
|
||||
endOffset = page.endOffset,
|
||||
textQuote = locator.textQuote ?: page.text.preview(),
|
||||
cfi = locator.cfi ?: page.toDesktopCfi()
|
||||
)
|
||||
return state.copy(
|
||||
reader = state.reader.copy(currentPageIndex = pageIndex),
|
||||
activeSearchResultIndex = state.searchResults.indexOfFirst { it.pageIndex == pageIndex },
|
||||
navigationLocator = normalizedLocator,
|
||||
navigationRequestId = state.navigationRequestId + 1
|
||||
)
|
||||
}
|
||||
|
||||
fun resolveLink(
|
||||
state: ReaderSessionState,
|
||||
href: String,
|
||||
sourceChapterIndex: Int? = state.reader.currentPage?.chapterIndex
|
||||
): ReaderLinkTarget {
|
||||
val trimmed = href.trim()
|
||||
if (trimmed.isBlank()) {
|
||||
logReaderLink("resolve_ignored reason=blank")
|
||||
return ReaderLinkTarget.Ignored
|
||||
}
|
||||
val normalizedHref = when {
|
||||
trimmed.startsWith("about:blank#", ignoreCase = true) -> "#${trimmed.substringAfter('#')}"
|
||||
trimmed.startsWith("www.", ignoreCase = true) -> "https://$trimmed"
|
||||
else -> trimmed
|
||||
}
|
||||
logReaderLink("resolve_start href=\"$trimmed\" normalized=\"$normalizedHref\" sourceChapter=$sourceChapterIndex")
|
||||
|
||||
val scheme = normalizedHref.schemeOrNull()
|
||||
if (scheme != null) {
|
||||
return when (scheme.lowercase()) {
|
||||
"http", "https", "mailto", "tel" -> {
|
||||
logReaderLink("resolve_external scheme=$scheme")
|
||||
ReaderLinkTarget.External(normalizedHref)
|
||||
}
|
||||
else -> {
|
||||
logReaderLink("resolve_ignored reason=unsupported_scheme scheme=$scheme")
|
||||
ReaderLinkTarget.Ignored
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val sourceIndex = sourceChapterIndex
|
||||
?.takeIf { it in state.reader.book.chapters.indices }
|
||||
?: state.reader.currentPage?.chapterIndex
|
||||
?: 0
|
||||
val sourceChapter = state.reader.book.chapters.getOrNull(sourceIndex)
|
||||
?: run {
|
||||
logReaderLink("resolve_ignored reason=missing_source sourceChapter=$sourceIndex")
|
||||
return ReaderLinkTarget.Ignored
|
||||
}
|
||||
|
||||
val pathPart = normalizedHref.substringBefore('#').substringBefore('?')
|
||||
val fragment = normalizedHref.substringAfter('#', missingDelimiterValue = "").substringBefore('?')
|
||||
.takeIf { it.isNotBlank() }
|
||||
?.percentDecodedOrSelf()
|
||||
|
||||
val targetChapterIndex = if (pathPart.isBlank()) {
|
||||
sourceIndex
|
||||
} else {
|
||||
val targetPath = resolveEpubPath(sourceChapter.baseHref, pathPart.percentDecodedOrSelf())
|
||||
state.reader.book.chapters.indexOfFirst { chapter ->
|
||||
val chapterPath = normalizeEpubPath(chapter.baseHref.orEmpty())
|
||||
chapterPath == targetPath ||
|
||||
chapter.id == pathPart ||
|
||||
chapterPath.substringAfterLast('/') == targetPath.substringAfterLast('/')
|
||||
}
|
||||
}
|
||||
|
||||
if (targetChapterIndex !in state.reader.book.chapters.indices) {
|
||||
logReaderLink(
|
||||
"resolve_ignored reason=missing_target path=\"$pathPart\" sourceChapter=$sourceIndex " +
|
||||
"base=\"${sourceChapter.baseHref.orEmpty()}\""
|
||||
)
|
||||
return ReaderLinkTarget.Ignored
|
||||
}
|
||||
|
||||
val targetChapter = state.reader.book.chapters[targetChapterIndex]
|
||||
val targetOffset = fragment
|
||||
?.let { targetChapter.semanticBlocks.findElementOffset(it) }
|
||||
?: 0
|
||||
val targetPageIndex = state.reader.pages.indexOfFirst { page ->
|
||||
page.chapterIndex == targetChapterIndex && targetOffset in page.startOffset..page.endOffset
|
||||
}.takeIf { it >= 0 }
|
||||
|
||||
val locator = ReaderLocator(
|
||||
chapterIndex = targetChapterIndex,
|
||||
chapterId = targetChapter.id,
|
||||
href = targetChapter.baseHref,
|
||||
pageIndex = targetPageIndex,
|
||||
startOffset = targetOffset,
|
||||
endOffset = targetOffset,
|
||||
cfi = "desktop:$targetChapterIndex:$targetOffset:$targetOffset"
|
||||
)
|
||||
logReaderLink(
|
||||
"resolve_internal targetChapter=$targetChapterIndex targetPage=$targetPageIndex " +
|
||||
"fragment=\"${fragment.orEmpty()}\" offset=$targetOffset"
|
||||
)
|
||||
return ReaderLinkTarget.Internal(locator)
|
||||
}
|
||||
|
||||
fun syncVisiblePage(state: ReaderSessionState, pageIndex: Int, locator: ReaderLocator? = null): ReaderSessionState {
|
||||
val target = pageIndex.coerceIn(0, state.reader.pages.lastIndex.coerceAtLeast(0))
|
||||
val normalizedLocator = locator?.normalizedForPage(state, target)
|
||||
if (target == state.reader.currentPageIndex && normalizedLocator == null) return state
|
||||
return state.copy(
|
||||
reader = state.reader.copy(currentPageIndex = target),
|
||||
activeSearchResultIndex = state.searchResults.indexOfFirst { it.pageIndex == target },
|
||||
navigationLocator = normalizedLocator ?: state.navigationLocator
|
||||
)
|
||||
}
|
||||
|
||||
fun updateSettings(state: ReaderSessionState, settings: ReaderSettings): ReaderSessionState {
|
||||
return state.copy(reader = paginator.repaginate(state.reader, settings))
|
||||
val current = state.reader.currentPage
|
||||
val pages = pagesFor(state.reader.book, settings)
|
||||
val newIndex = if (current == null) {
|
||||
0
|
||||
} else {
|
||||
pages.indexOfFirst {
|
||||
it.chapterIndex == current.chapterIndex && it.startOffset <= current.startOffset && it.endOffset >= current.startOffset
|
||||
}.takeIf { it >= 0 } ?: 0
|
||||
}
|
||||
val updated = state.copy(
|
||||
reader = state.reader.copy(
|
||||
pages = pages,
|
||||
currentPageIndex = newIndex.coerceIn(0, pages.lastIndex.coerceAtLeast(0)),
|
||||
settings = settings
|
||||
)
|
||||
)
|
||||
return if (updated.searchQuery.isNotBlank()) search(updated, updated.searchQuery) else updated
|
||||
}
|
||||
|
||||
private fun pagesFor(book: SharedEpubBook, settings: ReaderSettings): List<ReaderPage> {
|
||||
val key = PaginationCacheKey(
|
||||
bookId = book.id,
|
||||
chapterSignature = book.chapters.fold(1) { acc, chapter ->
|
||||
31 * acc + chapter.id.hashCode() + chapter.plainText.length + chapter.plainText.hashCode()
|
||||
},
|
||||
settings = settings
|
||||
)
|
||||
return synchronized(paginationCache) {
|
||||
paginationCache.getOrPut(key) {
|
||||
paginator.paginate(book, settings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun openSearch(state: ReaderSessionState): ReaderSessionState {
|
||||
return state.copy(isSearchActive = true, showSearchResultsPanel = true)
|
||||
}
|
||||
|
||||
fun closeSearch(state: ReaderSessionState): ReaderSessionState {
|
||||
return state.copy(
|
||||
isSearchActive = false,
|
||||
showSearchResultsPanel = true,
|
||||
searchQuery = "",
|
||||
searchResults = emptyList(),
|
||||
activeSearchResultIndex = -1
|
||||
)
|
||||
}
|
||||
|
||||
fun toggleSearchResultsPanel(state: ReaderSessionState): ReaderSessionState {
|
||||
return state.copy(showSearchResultsPanel = !state.showSearchResultsPanel)
|
||||
}
|
||||
|
||||
fun updateSearchOptions(state: ReaderSessionState, options: ReaderSearchOptions): ReaderSessionState {
|
||||
val updated = state.copy(searchOptions = options)
|
||||
return if (updated.searchQuery.isBlank()) updated else search(updated, updated.searchQuery)
|
||||
}
|
||||
|
||||
fun toggleBookmark(state: ReaderSessionState): ReaderSessionState {
|
||||
val page = state.reader.currentPage ?: return state
|
||||
val existing = state.bookmarks.firstOrNull { it.pageIndex == state.reader.currentPageIndex }
|
||||
val chapter = state.reader.book.chapters.getOrNull(page.chapterIndex)
|
||||
val locator = state.navigationLocator
|
||||
?.takeIf { it.belongsTo(page) }
|
||||
?.normalizedForPage(state, page.pageIndex)
|
||||
?: ReaderLocator(
|
||||
chapterIndex = page.chapterIndex,
|
||||
chapterId = chapter?.id,
|
||||
pageIndex = page.pageIndex,
|
||||
startOffset = page.startOffset,
|
||||
endOffset = page.endOffset,
|
||||
textQuote = page.text.preview()
|
||||
)
|
||||
val preview = locator.textQuote?.takeIf { it.isNotBlank() } ?: page.text.preview()
|
||||
return toggleBookmarkAtLocator(
|
||||
state = state,
|
||||
locator = locator,
|
||||
chapterTitle = page.chapterTitle,
|
||||
preview = preview
|
||||
)
|
||||
}
|
||||
|
||||
fun toggleBookmarkAtLocator(
|
||||
state: ReaderSessionState,
|
||||
locator: ReaderLocator,
|
||||
chapterTitle: String? = null,
|
||||
preview: String? = null
|
||||
): ReaderSessionState {
|
||||
val targetPageIndex = state.reader.pages.indexOfFirst { page -> page.contains(locator) }
|
||||
.takeIf { it >= 0 }
|
||||
?: locator.pageIndex
|
||||
?.takeIf { it in state.reader.pages.indices }
|
||||
?: state.reader.currentPageIndex
|
||||
val page = state.reader.pages.getOrNull(targetPageIndex) ?: return state
|
||||
val chapter = state.reader.book.chapters.getOrNull(page.chapterIndex)
|
||||
val normalizedLocator = locator.copy(pageIndex = targetPageIndex).withFallbacks(
|
||||
chapterIndex = page.chapterIndex,
|
||||
chapterId = chapter?.id,
|
||||
href = chapter?.baseHref,
|
||||
pageIndex = targetPageIndex,
|
||||
startOffset = page.startOffset,
|
||||
endOffset = page.endOffset,
|
||||
textQuote = preview ?: page.text.preview(),
|
||||
cfi = locator.cfi ?: "desktop:${page.chapterIndex}:${locator.startOffset ?: page.startOffset}:${locator.endOffset ?: locator.startOffset ?: page.startOffset}"
|
||||
)
|
||||
val existing = state.bookmarks.firstOrNull {
|
||||
it.locator.sameLocation(normalizedLocator) ||
|
||||
(!normalizedLocator.hasTextRange && it.pageIndex == targetPageIndex)
|
||||
}
|
||||
val updated = if (existing != null) {
|
||||
state.bookmarks - existing
|
||||
} else {
|
||||
state.bookmarks + ReaderBookmark(
|
||||
id = "${state.reader.book.id}_${state.reader.currentPageIndex}",
|
||||
pageIndex = state.reader.currentPageIndex,
|
||||
chapterTitle = page.chapterTitle,
|
||||
preview = page.text.preview()
|
||||
id = bookmarkId(state.reader.book.id, targetPageIndex, normalizedLocator),
|
||||
pageIndex = targetPageIndex,
|
||||
chapterTitle = chapterTitle ?: page.chapterTitle,
|
||||
preview = preview ?: page.text.preview(),
|
||||
locator = normalizedLocator
|
||||
)
|
||||
}
|
||||
return state.copy(bookmarks = updated.sortedBy { it.pageIndex })
|
||||
return state.copy(
|
||||
bookmarks = updated.sortedWith(
|
||||
compareBy<ReaderBookmark> { it.pageIndex }.thenBy { it.locator.startOffset ?: -1 }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun upsertHighlight(state: ReaderSessionState, highlight: UserHighlight): ReaderSessionState {
|
||||
if (highlight.text.isBlank()) return state
|
||||
val normalized = highlight.withNormalizedLocator()
|
||||
val existingIndex = state.highlights.indexOfFirst {
|
||||
it.id == normalized.id ||
|
||||
(it.chapterIndex == normalized.chapterIndex && it.locator.sameLocation(normalized.locator))
|
||||
}
|
||||
val updated = state.highlights.toMutableList()
|
||||
if (existingIndex >= 0) {
|
||||
updated[existingIndex] = updated[existingIndex].copy(
|
||||
cfi = normalized.cfi,
|
||||
text = normalized.text,
|
||||
color = normalized.color,
|
||||
chapterIndex = normalized.chapterIndex,
|
||||
locator = normalized.locator
|
||||
)
|
||||
} else {
|
||||
updated += normalized
|
||||
}
|
||||
return state.copy(
|
||||
highlights = updated
|
||||
.filter { (it.locator.chapterIndex ?: it.chapterIndex) in state.reader.book.chapters.indices }
|
||||
.distinctBy { it.id }
|
||||
)
|
||||
}
|
||||
|
||||
fun updateHighlight(
|
||||
state: ReaderSessionState,
|
||||
highlightId: String,
|
||||
color: HighlightColor? = null,
|
||||
note: String? = null
|
||||
): ReaderSessionState {
|
||||
return state.copy(
|
||||
highlights = state.highlights.map { highlight ->
|
||||
if (highlight.id == highlightId) {
|
||||
highlight.copy(
|
||||
color = color ?: highlight.color,
|
||||
note = if (note != null) note.takeIf { it.isNotBlank() } else highlight.note
|
||||
)
|
||||
} else {
|
||||
highlight
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun deleteHighlight(state: ReaderSessionState, highlightId: String): ReaderSessionState {
|
||||
return state.copy(highlights = state.highlights.filterNot { it.id == highlightId })
|
||||
}
|
||||
|
||||
fun search(state: ReaderSessionState, query: String): ReaderSessionState {
|
||||
|
|
@ -97,57 +466,305 @@ class ReaderEngine(
|
|||
val results = if (normalized.isBlank()) {
|
||||
emptyList()
|
||||
} else {
|
||||
state.reader.pages.mapNotNull { page ->
|
||||
val index = page.text.indexOf(normalized, ignoreCase = true)
|
||||
if (index < 0) {
|
||||
null
|
||||
} else {
|
||||
ReaderSearchResult(
|
||||
pageIndex = page.pageIndex,
|
||||
chapterTitle = page.chapterTitle,
|
||||
preview = page.text.previewAround(index, normalized.length)
|
||||
)
|
||||
state.reader.pages.flatMap { page ->
|
||||
val matches = mutableListOf<ReaderSearchResult>()
|
||||
var startIndex = 0
|
||||
while (startIndex < page.text.length) {
|
||||
val index = page.text.indexOfSearch(normalized, startIndex, state.searchOptions)
|
||||
if (index < 0) break
|
||||
val endIndex = (index + normalized.length).coerceAtMost(page.text.length)
|
||||
matches +=
|
||||
ReaderSearchResult(
|
||||
pageIndex = page.pageIndex,
|
||||
chapterTitle = page.chapterTitle,
|
||||
preview = page.text.previewAround(index, normalized.length),
|
||||
matchIndex = index,
|
||||
chapterIndex = page.chapterIndex,
|
||||
locator = ReaderLocator(
|
||||
chapterIndex = page.chapterIndex,
|
||||
pageIndex = page.pageIndex,
|
||||
startOffset = page.startOffset + index,
|
||||
endOffset = page.startOffset + endIndex,
|
||||
textQuote = page.text.substring(index, endIndex)
|
||||
)
|
||||
)
|
||||
startIndex = index + normalized.length.coerceAtLeast(1)
|
||||
}
|
||||
matches
|
||||
}
|
||||
}
|
||||
val activeIndex = results.indexOfFirst { it.pageIndex >= state.reader.currentPageIndex }
|
||||
.takeIf { it >= 0 }
|
||||
?: if (results.isNotEmpty()) 0 else -1
|
||||
val updated = state.copy(
|
||||
isSearchActive = state.isSearchActive || normalized.isNotBlank(),
|
||||
showSearchResultsPanel = state.showSearchResultsPanel || normalized.isNotBlank(),
|
||||
searchQuery = query,
|
||||
searchResults = results,
|
||||
activeSearchResultIndex = activeIndex
|
||||
)
|
||||
return updated.activeSearchResult?.let { goToPage(updated, it.pageIndex) } ?: updated
|
||||
return updated.activeSearchResult?.let { goToSearchResult(updated, activeIndex) } ?: updated
|
||||
}
|
||||
|
||||
fun nextSearchResult(state: ReaderSessionState): ReaderSessionState {
|
||||
if (state.searchResults.isEmpty()) return state
|
||||
val nextIndex = if (state.activeSearchResultIndex < state.searchResults.lastIndex) {
|
||||
val targetIndex = if (state.activeSearchResultIndex >= 0) {
|
||||
state.activeSearchResultIndex + 1
|
||||
} else {
|
||||
0
|
||||
state.searchResults.indexOfFirst { it.pageIndex >= state.reader.currentPageIndex }
|
||||
}
|
||||
return state.copy(
|
||||
reader = state.reader.copy(currentPageIndex = state.searchResults[nextIndex].pageIndex),
|
||||
activeSearchResultIndex = nextIndex
|
||||
)
|
||||
if (targetIndex !in state.searchResults.indices) return state
|
||||
return goToSearchResult(state, targetIndex)
|
||||
}
|
||||
|
||||
fun previousSearchResult(state: ReaderSessionState): ReaderSessionState {
|
||||
if (state.searchResults.isEmpty()) return state
|
||||
val nextIndex = if (state.activeSearchResultIndex > 0) {
|
||||
val targetIndex = if (state.activeSearchResultIndex >= 0) {
|
||||
state.activeSearchResultIndex - 1
|
||||
} else {
|
||||
state.searchResults.lastIndex
|
||||
state.searchResults.indexOfLast { it.pageIndex <= state.reader.currentPageIndex }
|
||||
}
|
||||
if (targetIndex !in state.searchResults.indices) return state
|
||||
return goToSearchResult(state, targetIndex)
|
||||
}
|
||||
|
||||
fun goToSearchResult(state: ReaderSessionState, resultIndex: Int): ReaderSessionState {
|
||||
if (state.searchResults.isEmpty()) return state
|
||||
val targetIndex = resultIndex.coerceIn(0, state.searchResults.lastIndex)
|
||||
val result = state.searchResults[targetIndex]
|
||||
val targetPage = state.reader.pages.indexOfFirst { page -> page.contains(result.locator) }
|
||||
.takeIf { it >= 0 }
|
||||
?: result.pageIndex.coerceIn(0, state.reader.pages.lastIndex.coerceAtLeast(0))
|
||||
val page = state.reader.pages.getOrNull(targetPage)
|
||||
val chapter = page?.let { state.reader.book.chapters.getOrNull(it.chapterIndex) }
|
||||
return state.copy(
|
||||
reader = state.reader.copy(currentPageIndex = state.searchResults[nextIndex].pageIndex),
|
||||
activeSearchResultIndex = nextIndex
|
||||
reader = state.reader.copy(currentPageIndex = targetPage),
|
||||
activeSearchResultIndex = targetIndex,
|
||||
navigationLocator = result.locator.copy(pageIndex = targetPage).withFallbacks(
|
||||
chapterIndex = page?.chapterIndex,
|
||||
chapterId = chapter?.id,
|
||||
href = chapter?.baseHref,
|
||||
pageIndex = targetPage
|
||||
),
|
||||
navigationRequestId = state.navigationRequestId + 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ReaderPage.contains(locator: ReaderLocator): Boolean {
|
||||
val targetChapter = locator.chapterIndex
|
||||
if (targetChapter != null && targetChapter != chapterIndex) return false
|
||||
if (locator.hasTextRange) {
|
||||
val start = locator.startOffset ?: return false
|
||||
val end = locator.endOffset ?: start
|
||||
return if (start == end) {
|
||||
start in startOffset..endOffset
|
||||
} else {
|
||||
start < endOffset && end > startOffset
|
||||
}
|
||||
}
|
||||
val targetPage = locator.pageIndex
|
||||
return targetPage != null && targetPage == pageIndex
|
||||
}
|
||||
|
||||
private fun ReaderBookmark.normalizedForBook(book: SharedEpubBook, pages: List<ReaderPage>): ReaderBookmark? {
|
||||
val targetPageIndex = pages.indexOfFirst { page -> page.contains(locator) }
|
||||
.takeIf { it >= 0 }
|
||||
?: pageIndex.takeIf { it in pages.indices }
|
||||
?: return null
|
||||
val page = pages.getOrNull(targetPageIndex) ?: return null
|
||||
val chapter = book.chapters.getOrNull(page.chapterIndex)
|
||||
val normalizedLocator = locator.copy(pageIndex = targetPageIndex).withFallbacks(
|
||||
chapterIndex = page.chapterIndex,
|
||||
chapterId = chapter?.id,
|
||||
href = chapter?.baseHref,
|
||||
pageIndex = targetPageIndex,
|
||||
startOffset = page.startOffset,
|
||||
endOffset = page.endOffset,
|
||||
textQuote = preview.ifBlank { page.text.preview() },
|
||||
cfi = locator.cfi ?: page.toDesktopCfi()
|
||||
)
|
||||
return copy(
|
||||
pageIndex = targetPageIndex,
|
||||
chapterTitle = chapterTitle.ifBlank { page.chapterTitle },
|
||||
preview = preview.ifBlank { normalizedLocator.textQuote ?: page.text.preview() },
|
||||
locator = normalizedLocator
|
||||
)
|
||||
}
|
||||
|
||||
private fun ReaderBookmark.locationKey(): String {
|
||||
val locator = locator
|
||||
return listOf(
|
||||
locator.chapterIndex,
|
||||
locator.pageIndex,
|
||||
locator.startOffset,
|
||||
locator.endOffset,
|
||||
locator.cfi
|
||||
).joinToString(":")
|
||||
}
|
||||
|
||||
private fun bookmarkId(bookId: String, pageIndex: Int, locator: ReaderLocator): String {
|
||||
val chapter = locator.chapterIndex ?: -1
|
||||
val start = locator.startOffset ?: -1
|
||||
val end = locator.endOffset ?: start
|
||||
return "${bookId}_${pageIndex}_${chapter}_${start}_${end}"
|
||||
}
|
||||
|
||||
private fun ReaderLocator.belongsTo(page: ReaderPage): Boolean {
|
||||
val targetChapter = chapterIndex
|
||||
if (targetChapter != null && targetChapter != page.chapterIndex) return false
|
||||
if (pageIndex == page.pageIndex) return true
|
||||
val start = startOffset
|
||||
val end = endOffset ?: start
|
||||
if (start != null && end != null) {
|
||||
return if (start == end) {
|
||||
start in page.startOffset..page.endOffset
|
||||
} else {
|
||||
start < page.endOffset && end > page.startOffset
|
||||
}
|
||||
}
|
||||
return pageIndex == page.pageIndex
|
||||
}
|
||||
|
||||
private fun ReaderLocator.normalizedForPage(state: ReaderSessionState, pageIndex: Int): ReaderLocator? {
|
||||
val page = state.reader.pages.getOrNull(pageIndex) ?: return null
|
||||
val chapter = state.reader.book.chapters.getOrNull(page.chapterIndex)
|
||||
val start = startOffset ?: page.startOffset
|
||||
val end = (endOffset ?: start).coerceAtLeast(start)
|
||||
return copy(pageIndex = page.pageIndex).withFallbacks(
|
||||
chapterIndex = page.chapterIndex,
|
||||
chapterId = chapter?.id,
|
||||
href = chapter?.baseHref,
|
||||
pageIndex = page.pageIndex,
|
||||
startOffset = start,
|
||||
endOffset = end,
|
||||
textQuote = textQuote ?: page.text.preview(),
|
||||
cfi = cfi ?: "desktop:${page.chapterIndex}:$start:$end"
|
||||
)
|
||||
}
|
||||
|
||||
private fun ReaderPage.toLocator(book: SharedEpubBook): ReaderLocator {
|
||||
val chapter = book.chapters.getOrNull(chapterIndex)
|
||||
return ReaderLocator(
|
||||
chapterIndex = chapterIndex,
|
||||
chapterId = chapter?.id,
|
||||
href = chapter?.baseHref,
|
||||
pageIndex = pageIndex,
|
||||
startOffset = startOffset,
|
||||
endOffset = endOffset,
|
||||
textQuote = text.preview(),
|
||||
cfi = toDesktopCfi()
|
||||
)
|
||||
}
|
||||
|
||||
private fun ReaderPage.toDesktopCfi(): String {
|
||||
return "desktop:$chapterIndex:$startOffset:$endOffset"
|
||||
}
|
||||
|
||||
private fun String.schemeOrNull(): String? {
|
||||
val colonIndex = indexOf(':')
|
||||
if (colonIndex <= 0) return null
|
||||
val firstPathIndex = listOf(indexOf('/'), indexOf('?'), indexOf('#'))
|
||||
.filter { it >= 0 }
|
||||
.minOrNull()
|
||||
if (firstPathIndex != null && firstPathIndex < colonIndex) return null
|
||||
val candidate = substring(0, colonIndex)
|
||||
return candidate.takeIf { it.all { char -> char.isLetterOrDigit() || char == '+' || char == '-' || char == '.' } }
|
||||
}
|
||||
|
||||
private fun resolveEpubPath(baseHref: String?, hrefPath: String): String {
|
||||
val path = hrefPath.trimStart('/')
|
||||
if (path.isBlank()) return normalizeEpubPath(baseHref.orEmpty())
|
||||
val base = baseHref.orEmpty()
|
||||
val baseDirectory = if (base.substringAfterLast('/', base).contains('.')) {
|
||||
base.substringBeforeLast('/', missingDelimiterValue = "")
|
||||
} else {
|
||||
base
|
||||
}
|
||||
return normalizeEpubPath(if (baseDirectory.isBlank()) path else "$baseDirectory/$path")
|
||||
}
|
||||
|
||||
private fun normalizeEpubPath(path: String): String {
|
||||
val parts = mutableListOf<String>()
|
||||
path.replace('\\', '/')
|
||||
.split('/')
|
||||
.forEach { part ->
|
||||
when (part) {
|
||||
"", "." -> Unit
|
||||
".." -> if (parts.isNotEmpty()) parts.removeAt(parts.lastIndex)
|
||||
else -> parts += part
|
||||
}
|
||||
}
|
||||
return parts.joinToString("/")
|
||||
}
|
||||
|
||||
private fun String.percentDecodedOrSelf(): String {
|
||||
return runCatching {
|
||||
val output = StringBuilder()
|
||||
val bytes = mutableListOf<Byte>()
|
||||
fun flushBytes() {
|
||||
if (bytes.isNotEmpty()) {
|
||||
output.append(bytes.toByteArray().decodeToString())
|
||||
bytes.clear()
|
||||
}
|
||||
}
|
||||
var index = 0
|
||||
while (index < length) {
|
||||
val char = this[index]
|
||||
if (char == '%' && index + 2 < length) {
|
||||
val value = substring(index + 1, index + 3).toIntOrNull(16)
|
||||
if (value != null) {
|
||||
bytes += value.toByte()
|
||||
index += 3
|
||||
continue
|
||||
}
|
||||
}
|
||||
flushBytes()
|
||||
output.append(char)
|
||||
index++
|
||||
}
|
||||
flushBytes()
|
||||
output.toString()
|
||||
}.getOrDefault(this)
|
||||
}
|
||||
|
||||
private fun Iterable<SemanticBlock>.findElementOffset(elementId: String): Int? {
|
||||
for (block in this) {
|
||||
block.findElementOffset(elementId)?.let { return it }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun SemanticBlock.findElementOffset(elementId: String): Int? {
|
||||
if (this is SemanticTextBlock) {
|
||||
if (this.elementId == elementId) return startCharOffsetInSource
|
||||
spans.firstOrNull { it.elementId == elementId }?.let { span ->
|
||||
return startCharOffsetInSource + span.start.coerceAtLeast(0)
|
||||
}
|
||||
}
|
||||
return when (this) {
|
||||
is SemanticList -> items.findElementOffset(elementId)
|
||||
is SemanticTable -> rows.asSequence()
|
||||
.flatMap { it.asSequence() }
|
||||
.mapNotNull { it.content.findElementOffset(elementId) }
|
||||
.firstOrNull()
|
||||
is SemanticFlexContainer -> children.findElementOffset(elementId)
|
||||
is SemanticWrappingBlock -> paragraphsToWrap.findElementOffset(elementId)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun UserHighlight.withNormalizedLocator(): UserHighlight {
|
||||
val normalizedLocator = locator.copy(textQuote = text).withFallbacks(
|
||||
chapterIndex = chapterIndex,
|
||||
cfi = cfi,
|
||||
textQuote = text
|
||||
)
|
||||
return copy(
|
||||
chapterIndex = normalizedLocator.chapterIndex ?: chapterIndex,
|
||||
cfi = normalizedLocator.cfi ?: cfi,
|
||||
locator = normalizedLocator
|
||||
)
|
||||
}
|
||||
|
||||
private fun String.preview(): String {
|
||||
return trim()
|
||||
.replace(Regex("\\s+"), " ")
|
||||
|
|
@ -161,3 +778,23 @@ private fun String.previewAround(index: Int, queryLength: Int): String {
|
|||
val suffix = if (end < length) "..." else ""
|
||||
return prefix + substring(start, end).replace(Regex("\\s+"), " ").trim() + suffix
|
||||
}
|
||||
|
||||
private fun String.indexOfSearch(query: String, startIndex: Int, options: ReaderSearchOptions): Int {
|
||||
var index = indexOf(query, startIndex, ignoreCase = !options.matchCase)
|
||||
if (!options.wholeWords) return index
|
||||
while (index >= 0) {
|
||||
val before = getOrNull(index - 1)
|
||||
val after = getOrNull(index + query.length)
|
||||
if (!before.isWordChar() && !after.isWordChar()) return index
|
||||
index = indexOf(query, index + query.length.coerceAtLeast(1), ignoreCase = !options.matchCase)
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
private fun Char?.isWordChar(): Boolean {
|
||||
return this != null && (isLetterOrDigit() || this == '_')
|
||||
}
|
||||
|
||||
private fun logReaderLink(message: String) {
|
||||
println("ReaderLinkResolve $message")
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,9 @@
|
|||
package com.aryan.reader.shared.reader
|
||||
|
||||
import com.aryan.reader.paginatedreader.SemanticBlock
|
||||
import com.aryan.reader.shared.PageInfoMode
|
||||
import com.aryan.reader.shared.PageInfoPosition
|
||||
import com.aryan.reader.shared.SystemUiMode
|
||||
|
||||
data class SharedEpubBook(
|
||||
val id: String,
|
||||
|
|
@ -20,10 +23,7 @@ data class SharedEpubChapter(
|
|||
val baseHref: String? = null
|
||||
)
|
||||
|
||||
data class ReaderLocator(
|
||||
val chapterIndex: Int = 0,
|
||||
val charOffset: Int = 0
|
||||
)
|
||||
typealias ReaderLocator = com.aryan.reader.shared.ReaderLocator
|
||||
|
||||
enum class ReaderReadingMode {
|
||||
PAGINATED,
|
||||
|
|
@ -44,8 +44,26 @@ data class ReaderSettings(
|
|||
val readingMode: ReaderReadingMode = ReaderReadingMode.PAGINATED,
|
||||
val textAlign: SharedReaderTextAlign = SharedReaderTextAlign.START,
|
||||
val pageWidth: Int = 760,
|
||||
val fontFamily: String = "Default"
|
||||
)
|
||||
val fontFamily: String = "Default",
|
||||
val paragraphSpacing: Float = 1.0f,
|
||||
val imageScale: Float = 1.0f,
|
||||
val horizontalMargin: Int? = null,
|
||||
val verticalMargin: Int? = null,
|
||||
val themeId: String? = null,
|
||||
val textureId: String? = null,
|
||||
val textureAlpha: Float = 0.55f,
|
||||
val customFontPath: String? = null,
|
||||
val backgroundColorArgb: Long? = null,
|
||||
val textColorArgb: Long? = null,
|
||||
val systemUiMode: SystemUiMode = SystemUiMode.DEFAULT,
|
||||
val pageInfoMode: PageInfoMode = PageInfoMode.DEFAULT,
|
||||
val pageInfoPosition: PageInfoPosition = PageInfoPosition.BOTTOM,
|
||||
val seamlessChapterNavigation: Boolean = true,
|
||||
val chapterTurnDragMultiplier: Float = 1.0f
|
||||
) {
|
||||
val resolvedHorizontalMargin: Int get() = horizontalMargin ?: margin
|
||||
val resolvedVerticalMargin: Int get() = verticalMargin ?: margin
|
||||
}
|
||||
|
||||
data class ReaderPage(
|
||||
val pageIndex: Int,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,106 @@
|
|||
package com.aryan.reader.shared.reader
|
||||
|
||||
object SharedTextBookFactory {
|
||||
fun fromPlainText(
|
||||
id: String,
|
||||
fileName: String,
|
||||
title: String,
|
||||
plainText: String,
|
||||
author: String? = null
|
||||
): SharedEpubBook {
|
||||
return SharedEpubBook(
|
||||
id = id,
|
||||
fileName = fileName,
|
||||
title = title,
|
||||
author = author,
|
||||
chapters = listOf(
|
||||
SharedEpubChapter(
|
||||
id = "chapter_0",
|
||||
title = title,
|
||||
plainText = plainText.ifBlank { "This document did not contain readable text." }
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun fromHtml(
|
||||
id: String,
|
||||
fileName: String,
|
||||
title: String,
|
||||
html: String,
|
||||
author: String? = null
|
||||
): SharedEpubBook {
|
||||
val sanitizedHtml = html.sanitizeReaderHtml()
|
||||
val body = sanitizedHtml.extractBodyOrSelf()
|
||||
return SharedEpubBook(
|
||||
id = id,
|
||||
fileName = fileName,
|
||||
title = title,
|
||||
author = author,
|
||||
chapters = listOf(
|
||||
SharedEpubChapter(
|
||||
id = "chapter_0",
|
||||
title = sanitizedHtml.tagText("h1")
|
||||
.ifBlank { sanitizedHtml.tagText("title") }
|
||||
.ifBlank { title },
|
||||
plainText = sanitizedHtml.htmlToText().ifBlank { title },
|
||||
htmlContent = body
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun String.extractBodyOrSelf(): String {
|
||||
return Regex("(?is)<body\\b[^>]*>(.*?)</body>")
|
||||
.find(this)
|
||||
?.groupValues
|
||||
?.get(1)
|
||||
?.trim()
|
||||
?: this
|
||||
}
|
||||
|
||||
private fun String.tagText(tag: String): String {
|
||||
return Regex("<(?:[^:>]+:)?$tag\\b[^>]*>(.*?)</(?:[^:>]+:)?$tag>", RegexOption.IGNORE_CASE)
|
||||
.find(this)
|
||||
?.groupValues
|
||||
?.get(1)
|
||||
?.htmlToText()
|
||||
.orEmpty()
|
||||
}
|
||||
|
||||
private fun String.htmlToText(): String {
|
||||
return replace(Regex("(?is)<script.*?</script>"), "")
|
||||
.replace(Regex("(?is)<style.*?</style>"), "")
|
||||
.replace(Regex("(?i)<br\\s*/?>"), "\n")
|
||||
.replace(Regex("(?i)</p\\s*>"), "\n\n")
|
||||
.replace(Regex("(?i)</h[1-6]\\s*>"), "\n\n")
|
||||
.replace(Regex("<[^>]+>"), " ")
|
||||
.decodeEntities()
|
||||
.replace(Regex("[ \\t\\x0B\\f\\r]+"), " ")
|
||||
.replace(Regex(" *\\n *"), "\n")
|
||||
.replace(Regex("\\n{3,}"), "\n\n")
|
||||
.trim()
|
||||
}
|
||||
|
||||
private fun String.decodeEntities(): String {
|
||||
return replace(" ", " ")
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace(Regex("&#x([0-9a-fA-F]+);")) { match ->
|
||||
match.groupValues[1].toIntOrNull(16)?.toChar()?.toString().orEmpty()
|
||||
}
|
||||
.replace(Regex("&#(\\d+);")) { match ->
|
||||
match.groupValues[1].toIntOrNull()?.toChar()?.toString().orEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.sanitizeReaderHtml(): String {
|
||||
return replace(Regex("(?is)<script\\b.*?</script>"), "")
|
||||
.replace(Regex("(?is)<object\\b.*?</object>"), "")
|
||||
.replace(Regex("(?is)<embed\\b[^>]*>"), "")
|
||||
.replace(Regex("""(?i)\s+on[a-z]+\s*=\s*(['"]).*?\1"""), "")
|
||||
}
|
||||
}
|
||||
|
|
@ -98,8 +98,8 @@ class SimplePaginator {
|
|||
viewportWidth: Int,
|
||||
viewportHeight: Int
|
||||
): Int {
|
||||
val usableWidth = (viewportWidth - settings.margin * 2).coerceAtLeast(360)
|
||||
val usableHeight = (viewportHeight - settings.margin * 2).coerceAtLeast(360)
|
||||
val usableWidth = (viewportWidth - settings.resolvedHorizontalMargin * 2).coerceAtLeast(360)
|
||||
val usableHeight = (viewportHeight - settings.resolvedVerticalMargin * 2).coerceAtLeast(360)
|
||||
val averageCharWidth = settings.fontSize * 0.55f
|
||||
val lineHeight = settings.fontSize * settings.lineSpacing
|
||||
val charsPerLine = (usableWidth / averageCharWidth).toInt().coerceAtLeast(35)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
@Composable
|
||||
internal expect fun LocalBookCoverImage(
|
||||
path: String,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier
|
||||
)
|
||||
|
|
@ -0,0 +1,147 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import com.aryan.reader.shared.BookItem
|
||||
import com.aryan.reader.shared.FileType
|
||||
import com.aryan.reader.shared.LibraryFilters
|
||||
import com.aryan.reader.shared.ReadStatusFilter
|
||||
import com.aryan.reader.shared.SharedReaderScreenState
|
||||
import com.aryan.reader.shared.ShelfType
|
||||
import com.aryan.reader.shared.isOpdsStream
|
||||
import com.aryan.reader.shared.progressPercentValue
|
||||
import com.aryan.reader.shared.toHomeScreenModel
|
||||
|
||||
enum class SharedAppToolAction {
|
||||
IMPORT_FILES,
|
||||
IMPORT_FOLDER,
|
||||
SYNC,
|
||||
APP_THEME,
|
||||
AI_SETTINGS,
|
||||
CUSTOM_FONTS,
|
||||
HELP_FEEDBACK,
|
||||
SUPPORT,
|
||||
ABOUT,
|
||||
TABS_TOGGLE
|
||||
}
|
||||
|
||||
data class SharedAppShellModel(
|
||||
val primaryTabs: List<SharedAppTab>,
|
||||
val selectedPrimaryTab: SharedAppTab,
|
||||
val toolActions: List<SharedAppToolAction>
|
||||
)
|
||||
|
||||
fun sharedAppShellModel(
|
||||
selectedTab: SharedAppTab,
|
||||
aiSettingsAvailable: Boolean
|
||||
): SharedAppShellModel {
|
||||
val primaryTabs = listOf(
|
||||
SharedAppTab.HOME,
|
||||
SharedAppTab.LIBRARY,
|
||||
SharedAppTab.CATALOGS,
|
||||
SharedAppTab.READER
|
||||
)
|
||||
val selectedPrimaryTab = when (selectedTab) {
|
||||
SharedAppTab.SHELVES -> SharedAppTab.LIBRARY
|
||||
SharedAppTab.CUSTOM_FONTS,
|
||||
SharedAppTab.SUPPORT,
|
||||
SharedAppTab.FEEDBACK,
|
||||
SharedAppTab.ABOUT -> SharedAppTab.HOME
|
||||
else -> selectedTab
|
||||
}
|
||||
val toolActions = buildList {
|
||||
add(SharedAppToolAction.IMPORT_FILES)
|
||||
add(SharedAppToolAction.IMPORT_FOLDER)
|
||||
add(SharedAppToolAction.SYNC)
|
||||
add(SharedAppToolAction.APP_THEME)
|
||||
if (aiSettingsAvailable) add(SharedAppToolAction.AI_SETTINGS)
|
||||
add(SharedAppToolAction.CUSTOM_FONTS)
|
||||
add(SharedAppToolAction.HELP_FEEDBACK)
|
||||
add(SharedAppToolAction.SUPPORT)
|
||||
add(SharedAppToolAction.ABOUT)
|
||||
add(SharedAppToolAction.TABS_TOGGLE)
|
||||
}
|
||||
return SharedAppShellModel(
|
||||
primaryTabs = primaryTabs,
|
||||
selectedPrimaryTab = selectedPrimaryTab,
|
||||
toolActions = toolActions
|
||||
)
|
||||
}
|
||||
|
||||
data class NonReaderHomeLayoutModel(
|
||||
val continueBook: BookItem?,
|
||||
val activeTabs: List<BookItem>,
|
||||
val pinnedBooks: List<BookItem>,
|
||||
val recentBooks: List<BookItem>,
|
||||
val selectedBooks: List<BookItem>,
|
||||
val isContextualModeActive: Boolean,
|
||||
val isEmpty: Boolean,
|
||||
val isLibraryEmpty: Boolean
|
||||
)
|
||||
|
||||
fun SharedReaderScreenState.toNonReaderHomeLayoutModel(): NonReaderHomeLayoutModel {
|
||||
val model = toHomeScreenModel()
|
||||
val activeTabs = if (isTabsEnabled) model.openTabs else emptyList()
|
||||
val continueBook = activeTabs.firstOrNull { it.id == activeTabBookId }
|
||||
?: model.recentBooks.firstOrNull { progressPercentValue(it.progressPercentage) in 1..99 }
|
||||
?: model.recentBooks.firstOrNull()
|
||||
val continueId = continueBook?.id
|
||||
val pinnedBooks = model.recentBooks
|
||||
.filter { it.id in pinnedHomeBookIds && it.id != continueId }
|
||||
val recentBooks = model.recentBooks
|
||||
.filter { it.id !in pinnedHomeBookIds && it.id != continueId }
|
||||
return NonReaderHomeLayoutModel(
|
||||
continueBook = continueBook,
|
||||
activeTabs = activeTabs,
|
||||
pinnedBooks = pinnedBooks,
|
||||
recentBooks = recentBooks,
|
||||
selectedBooks = model.selectedBooks,
|
||||
isContextualModeActive = model.isContextualModeActive,
|
||||
isEmpty = continueBook == null && pinnedBooks.isEmpty() && recentBooks.isEmpty() && activeTabs.isEmpty(),
|
||||
isLibraryEmpty = model.isLibraryEmpty
|
||||
)
|
||||
}
|
||||
|
||||
data class NonReaderLibraryOrganizationModel(
|
||||
val allBooksCount: Int,
|
||||
val shelfCount: Int,
|
||||
val smartShelfCount: Int,
|
||||
val tagCount: Int,
|
||||
val folderCount: Int,
|
||||
val unreadCount: Int,
|
||||
val inProgressCount: Int,
|
||||
val completedCount: Int,
|
||||
val activeFilterCount: Int,
|
||||
val availableFileTypes: List<FileType>,
|
||||
val hasInAppBooks: Boolean,
|
||||
val hasOpdsStreams: Boolean
|
||||
)
|
||||
|
||||
fun SharedReaderScreenState.toNonReaderLibraryOrganizationModel(): NonReaderLibraryOrganizationModel {
|
||||
val books = rawLibraryBooks
|
||||
val rootFolderCount = shelves.count { it.type == ShelfType.FOLDER && it.parentShelfId == null }
|
||||
val tagIds = (allTags.map { it.id } + books.flatMap { book -> book.tags.map { it.id } }).toSet()
|
||||
return NonReaderLibraryOrganizationModel(
|
||||
allBooksCount = books.size,
|
||||
shelfCount = shelves.count { it.type != ShelfType.FOLDER && it.type != ShelfType.TAG && it.type != ShelfType.SMART },
|
||||
smartShelfCount = shelves.count { it.type == ShelfType.SMART },
|
||||
tagCount = tagIds.size,
|
||||
folderCount = maxOf(rootFolderCount, syncedFolders.size),
|
||||
unreadCount = books.count { progressPercentValue(it.progressPercentage) == 0 },
|
||||
inProgressCount = books.count { progressPercentValue(it.progressPercentage) in 1..99 },
|
||||
completedCount = books.count { progressPercentValue(it.progressPercentage) >= 100 },
|
||||
activeFilterCount = libraryFilters.activeFilterCount(),
|
||||
availableFileTypes = books
|
||||
.map { it.type }
|
||||
.filterNot { it == FileType.UNKNOWN }
|
||||
.distinct()
|
||||
.sortedBy { it.ordinal },
|
||||
hasInAppBooks = books.any { it.sourceFolder == null && !it.isOpdsStream() },
|
||||
hasOpdsStreams = books.any { it.isOpdsStream() }
|
||||
)
|
||||
}
|
||||
|
||||
private fun LibraryFilters.activeFilterCount(): Int {
|
||||
return fileTypes.size +
|
||||
sourceFolders.size +
|
||||
tagIds.size +
|
||||
if (readStatus == ReadStatusFilter.ALL) 0 else 1
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,235 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import com.aryan.reader.shared.PdfDisplayMode
|
||||
import com.aryan.reader.shared.ReaderAutoScrollState
|
||||
import com.aryan.reader.shared.ReaderExtrasState
|
||||
import com.aryan.reader.shared.ReaderTool
|
||||
import com.aryan.reader.shared.ReaderToolbarPreferences
|
||||
import com.aryan.reader.shared.pdf.PdfInkTool
|
||||
import com.aryan.reader.shared.pdf.SharedPdfReaderState
|
||||
import com.aryan.reader.shared.reader.ReaderSessionState
|
||||
|
||||
enum class ReaderWorkspaceKind {
|
||||
EPUB,
|
||||
PDF
|
||||
}
|
||||
|
||||
enum class ReaderWorkspaceLeftSection(val title: String) {
|
||||
CONTENTS("Contents"),
|
||||
SEARCH("Search"),
|
||||
BOOKMARKS("Bookmarks"),
|
||||
NOTES("Notes")
|
||||
}
|
||||
|
||||
enum class ReaderWorkspaceInspectorSection(val title: String) {
|
||||
APPEARANCE("Appearance"),
|
||||
TOOLS("Tools"),
|
||||
AI_TTS("AI/TTS"),
|
||||
TOOLBAR("Toolbar")
|
||||
}
|
||||
|
||||
enum class ReaderWorkspaceTopAction {
|
||||
CONTENTS,
|
||||
SEARCH,
|
||||
BOOKMARK,
|
||||
APPEARANCE,
|
||||
READ_ALOUD,
|
||||
AI,
|
||||
AUTO_SCROLL,
|
||||
TOOLS
|
||||
}
|
||||
|
||||
enum class ReaderWorkspaceBottomAction {
|
||||
PAGE_SLIDER,
|
||||
PREVIOUS,
|
||||
NEXT
|
||||
}
|
||||
|
||||
data class ReaderWorkspaceChromeModel(
|
||||
val preferAutoHide: Boolean,
|
||||
val forceVisible: Boolean,
|
||||
val forceVisibleReasons: Set<String> = emptySet()
|
||||
)
|
||||
|
||||
data class ReaderWorkspaceModel(
|
||||
val kind: ReaderWorkspaceKind,
|
||||
val leftSections: List<ReaderWorkspaceLeftSection>,
|
||||
val inspectorSections: List<ReaderWorkspaceInspectorSection>,
|
||||
val topActions: List<ReaderWorkspaceTopAction>,
|
||||
val bottomActions: List<ReaderWorkspaceBottomAction>,
|
||||
val defaultPdfInteractionMode: PdfInkTool? = null,
|
||||
val chrome: ReaderWorkspaceChromeModel
|
||||
)
|
||||
|
||||
fun epubReaderWorkspaceModel(
|
||||
session: ReaderSessionState,
|
||||
toolbarPreferences: ReaderToolbarPreferences,
|
||||
extrasState: ReaderExtrasState,
|
||||
aiAvailable: Boolean
|
||||
): ReaderWorkspaceModel {
|
||||
val preferences = toolbarPreferences.sanitized()
|
||||
val leftSections = buildList {
|
||||
if (preferences.isVisible(ReaderTool.TOC)) add(ReaderWorkspaceLeftSection.CONTENTS)
|
||||
if (preferences.isVisible(ReaderTool.SEARCH)) add(ReaderWorkspaceLeftSection.SEARCH)
|
||||
if (preferences.isVisible(ReaderTool.BOOKMARK)) add(ReaderWorkspaceLeftSection.BOOKMARKS)
|
||||
if (preferences.isVisible(ReaderTool.BOOKMARK)) add(ReaderWorkspaceLeftSection.NOTES)
|
||||
}
|
||||
val inspectorSections = buildList {
|
||||
if (preferences.isVisible(ReaderTool.THEME) || preferences.isVisible(ReaderTool.FORMAT)) {
|
||||
add(ReaderWorkspaceInspectorSection.APPEARANCE)
|
||||
}
|
||||
if (preferences.isVisible(ReaderTool.READING_MODE) || preferences.isVisible(ReaderTool.VISUAL_OPTIONS)) {
|
||||
add(ReaderWorkspaceInspectorSection.TOOLS)
|
||||
}
|
||||
if (
|
||||
preferences.isVisible(ReaderTool.DICTIONARY) ||
|
||||
preferences.isVisible(ReaderTool.AI_FEATURES) ||
|
||||
preferences.isVisible(ReaderTool.TTS_CONTROLS) ||
|
||||
preferences.isVisible(ReaderTool.AUTO_SCROLL)
|
||||
) {
|
||||
add(ReaderWorkspaceInspectorSection.AI_TTS)
|
||||
}
|
||||
add(ReaderWorkspaceInspectorSection.TOOLBAR)
|
||||
}.distinct()
|
||||
val topActions = buildList {
|
||||
if (ReaderWorkspaceLeftSection.CONTENTS in leftSections) add(ReaderWorkspaceTopAction.CONTENTS)
|
||||
if (preferences.isVisible(ReaderTool.SEARCH)) add(ReaderWorkspaceTopAction.SEARCH)
|
||||
if (preferences.isVisible(ReaderTool.BOOKMARK)) add(ReaderWorkspaceTopAction.BOOKMARK)
|
||||
if (ReaderWorkspaceInspectorSection.APPEARANCE in inspectorSections) add(ReaderWorkspaceTopAction.APPEARANCE)
|
||||
if (preferences.isVisible(ReaderTool.TTS_CONTROLS)) add(ReaderWorkspaceTopAction.READ_ALOUD)
|
||||
if (aiAvailable && preferences.isVisible(ReaderTool.AI_FEATURES)) add(ReaderWorkspaceTopAction.AI)
|
||||
if (preferences.isVisible(ReaderTool.AUTO_SCROLL)) add(ReaderWorkspaceTopAction.AUTO_SCROLL)
|
||||
if (inspectorSections.isNotEmpty()) add(ReaderWorkspaceTopAction.TOOLS)
|
||||
}.distinct()
|
||||
val bottomActions = buildList {
|
||||
if (preferences.isVisible(ReaderTool.SLIDER)) add(ReaderWorkspaceBottomAction.PAGE_SLIDER)
|
||||
add(ReaderWorkspaceBottomAction.PREVIOUS)
|
||||
add(ReaderWorkspaceBottomAction.NEXT)
|
||||
}
|
||||
return ReaderWorkspaceModel(
|
||||
kind = ReaderWorkspaceKind.EPUB,
|
||||
leftSections = leftSections,
|
||||
inspectorSections = inspectorSections,
|
||||
topActions = topActions,
|
||||
bottomActions = bottomActions,
|
||||
chrome = readerWorkspaceChromeModel(
|
||||
preferAutoHide = true,
|
||||
searchActive = session.isSearchActive,
|
||||
leftPanelOpen = false,
|
||||
inspectorOpen = false,
|
||||
annotationEditing = false,
|
||||
richTextEditing = false,
|
||||
loading = false,
|
||||
errorMessage = null,
|
||||
autoScroll = extrasState.autoScroll,
|
||||
ttsBusy = extrasState.cloudTts.isLoading || extrasState.cloudTts.isPlaying || extrasState.cloudTts.isPaused
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun readerWorkspaceQuickActionTools(
|
||||
toolbarPreferences: ReaderToolbarPreferences,
|
||||
bottom: Boolean,
|
||||
aiAvailable: Boolean
|
||||
): List<ReaderTool> {
|
||||
val preferences = toolbarPreferences.sanitized()
|
||||
return preferences.orderedVisibleTools()
|
||||
.filter { tool ->
|
||||
tool.supportsDesktopQuickAction &&
|
||||
preferences.isBottom(tool) == bottom &&
|
||||
(tool != ReaderTool.AI_FEATURES || aiAvailable)
|
||||
}
|
||||
}
|
||||
|
||||
fun pdfReaderWorkspaceModel(
|
||||
state: SharedPdfReaderState,
|
||||
displayMode: PdfDisplayMode,
|
||||
hasContents: Boolean,
|
||||
hasBookmarks: Boolean,
|
||||
hasAnnotations: Boolean,
|
||||
hasEmbeddedComments: Boolean,
|
||||
searchActive: Boolean,
|
||||
annotationEditing: Boolean,
|
||||
richTextEditing: Boolean,
|
||||
loading: Boolean,
|
||||
errorMessage: String?,
|
||||
extrasState: ReaderExtrasState,
|
||||
aiAvailable: Boolean
|
||||
): ReaderWorkspaceModel {
|
||||
val leftSections = buildList {
|
||||
add(ReaderWorkspaceLeftSection.CONTENTS)
|
||||
add(ReaderWorkspaceLeftSection.SEARCH)
|
||||
if (hasBookmarks) add(ReaderWorkspaceLeftSection.BOOKMARKS)
|
||||
if (hasContents || hasAnnotations || hasEmbeddedComments) add(ReaderWorkspaceLeftSection.NOTES)
|
||||
}.distinct()
|
||||
val inspectorSections = listOf(
|
||||
ReaderWorkspaceInspectorSection.APPEARANCE,
|
||||
ReaderWorkspaceInspectorSection.TOOLS,
|
||||
ReaderWorkspaceInspectorSection.AI_TTS,
|
||||
ReaderWorkspaceInspectorSection.TOOLBAR
|
||||
)
|
||||
val topActions = buildList {
|
||||
add(ReaderWorkspaceTopAction.CONTENTS)
|
||||
add(ReaderWorkspaceTopAction.SEARCH)
|
||||
add(ReaderWorkspaceTopAction.BOOKMARK)
|
||||
add(ReaderWorkspaceTopAction.APPEARANCE)
|
||||
add(ReaderWorkspaceTopAction.READ_ALOUD)
|
||||
if (aiAvailable) add(ReaderWorkspaceTopAction.AI)
|
||||
add(ReaderWorkspaceTopAction.AUTO_SCROLL)
|
||||
add(ReaderWorkspaceTopAction.TOOLS)
|
||||
}
|
||||
return ReaderWorkspaceModel(
|
||||
kind = ReaderWorkspaceKind.PDF,
|
||||
leftSections = leftSections,
|
||||
inspectorSections = inspectorSections,
|
||||
topActions = topActions,
|
||||
bottomActions = listOf(
|
||||
ReaderWorkspaceBottomAction.PAGE_SLIDER,
|
||||
ReaderWorkspaceBottomAction.PREVIOUS,
|
||||
ReaderWorkspaceBottomAction.NEXT
|
||||
),
|
||||
defaultPdfInteractionMode = null,
|
||||
chrome = readerWorkspaceChromeModel(
|
||||
preferAutoHide = true,
|
||||
searchActive = searchActive || state.searchQuery.isNotBlank(),
|
||||
leftPanelOpen = false,
|
||||
inspectorOpen = false,
|
||||
annotationEditing = annotationEditing || state.selectedAnnotationId != null || state.selectedTool != PdfInkTool.PEN,
|
||||
richTextEditing = richTextEditing,
|
||||
loading = loading,
|
||||
errorMessage = errorMessage,
|
||||
autoScroll = extrasState.autoScroll,
|
||||
ttsBusy = extrasState.cloudTts.isLoading || extrasState.cloudTts.isPlaying || extrasState.cloudTts.isPaused
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun readerWorkspaceChromeModel(
|
||||
preferAutoHide: Boolean,
|
||||
searchActive: Boolean,
|
||||
leftPanelOpen: Boolean,
|
||||
inspectorOpen: Boolean,
|
||||
annotationEditing: Boolean,
|
||||
richTextEditing: Boolean,
|
||||
loading: Boolean,
|
||||
errorMessage: String?,
|
||||
autoScroll: ReaderAutoScrollState,
|
||||
ttsBusy: Boolean
|
||||
): ReaderWorkspaceChromeModel {
|
||||
val reasons = buildSet {
|
||||
if (searchActive) add("search")
|
||||
if (leftPanelOpen) add("left-panel")
|
||||
if (inspectorOpen) add("inspector")
|
||||
if (annotationEditing) add("annotation")
|
||||
if (richTextEditing) add("rich-text")
|
||||
if (loading) add("loading")
|
||||
if (!errorMessage.isNullOrBlank()) add("error")
|
||||
if (autoScroll.sanitized().enabled) add("auto-scroll")
|
||||
if (ttsBusy) add("tts")
|
||||
}
|
||||
return ReaderWorkspaceChromeModel(
|
||||
preferAutoHide = preferAutoHide,
|
||||
forceVisible = reasons.isNotEmpty(),
|
||||
forceVisibleReasons = reasons
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,224 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@Composable
|
||||
fun ReaderWorkspaceShell(
|
||||
model: ReaderWorkspaceModel,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
progressLabel: String,
|
||||
modifier: Modifier = Modifier,
|
||||
topActions: @Composable RowScope.() -> Unit = {},
|
||||
leftSidebar: @Composable () -> Unit,
|
||||
rightInspector: @Composable () -> Unit,
|
||||
bottomBar: @Composable () -> Unit,
|
||||
content: @Composable BoxScope.() -> Unit
|
||||
) {
|
||||
var leftPanelOpen by remember(model.kind) { mutableStateOf(true) }
|
||||
var rightPanelOpen by remember(model.kind) { mutableStateOf(true) }
|
||||
var chromeVisible by remember(model.kind) { mutableStateOf(true) }
|
||||
val forceChrome = model.chrome.forceVisible || leftPanelOpen || rightPanelOpen
|
||||
|
||||
LaunchedEffect(forceChrome, model.chrome.preferAutoHide, model.chrome.forceVisibleReasons) {
|
||||
chromeVisible = true
|
||||
if (model.chrome.preferAutoHide && !forceChrome) {
|
||||
delay(3_200)
|
||||
chromeVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
BoxWithConstraints(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
) {
|
||||
val wide = maxWidth >= 1120.dp
|
||||
val showChrome = chromeVisible || forceChrome || !model.chrome.preferAutoHide
|
||||
LaunchedEffect(wide, leftPanelOpen, rightPanelOpen) {
|
||||
if (!wide && leftPanelOpen && rightPanelOpen) {
|
||||
rightPanelOpen = false
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
if (showChrome) {
|
||||
ReaderWorkspaceTopChrome(
|
||||
title = title,
|
||||
subtitle = subtitle,
|
||||
progressLabel = progressLabel,
|
||||
wide = wide,
|
||||
leftPanelOpen = leftPanelOpen,
|
||||
rightPanelOpen = rightPanelOpen,
|
||||
onToggleLeftPanel = { leftPanelOpen = !leftPanelOpen },
|
||||
onToggleRightPanel = { rightPanelOpen = !rightPanelOpen },
|
||||
topActions = topActions
|
||||
)
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
if (wide && leftPanelOpen && model.leftSections.isNotEmpty()) {
|
||||
leftSidebar()
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
content()
|
||||
}
|
||||
if (wide && rightPanelOpen && model.inspectorSections.isNotEmpty()) {
|
||||
rightInspector()
|
||||
}
|
||||
}
|
||||
|
||||
if (!wide && leftPanelOpen && model.leftSections.isNotEmpty()) {
|
||||
ReaderWorkspaceOverlayPanel(
|
||||
title = "Reader",
|
||||
onClose = { leftPanelOpen = false },
|
||||
modifier = Modifier.align(Alignment.CenterStart).width(320.dp)
|
||||
) {
|
||||
leftSidebar()
|
||||
}
|
||||
}
|
||||
if (!wide && rightPanelOpen && model.inspectorSections.isNotEmpty()) {
|
||||
ReaderWorkspaceOverlayPanel(
|
||||
title = "Tools",
|
||||
onClose = { rightPanelOpen = false },
|
||||
modifier = Modifier.align(Alignment.CenterEnd).width(360.dp)
|
||||
) {
|
||||
rightInspector()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showChrome) {
|
||||
bottomBar()
|
||||
} else {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(20.dp)
|
||||
.clickable { chromeVisible = true }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReaderWorkspaceTopChrome(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
progressLabel: String,
|
||||
wide: Boolean,
|
||||
leftPanelOpen: Boolean,
|
||||
rightPanelOpen: Boolean,
|
||||
onToggleLeftPanel: () -> Unit,
|
||||
onToggleRightPanel: () -> Unit,
|
||||
topActions: @Composable RowScope.() -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 2.dp
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
IconButton(onClick = onToggleLeftPanel) {
|
||||
Icon(Icons.Default.Menu, contentDescription = if (leftPanelOpen) "Hide reader navigation" else "Show reader navigation")
|
||||
}
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
Text(progressLabel, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
topActions()
|
||||
}
|
||||
IconButton(onClick = onToggleRightPanel) {
|
||||
Icon(Icons.Default.Tune, contentDescription = if (rightPanelOpen) "Hide reader tools" else "Show reader tools")
|
||||
}
|
||||
if (!wide) {
|
||||
TextButton(onClick = onToggleRightPanel, contentPadding = PaddingValues(horizontal = 8.dp)) {
|
||||
Text("Tools")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReaderWorkspaceOverlayPanel(
|
||||
title: String,
|
||||
onClose: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier.fillMaxHeight().padding(vertical = 8.dp),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 8.dp,
|
||||
shadowElevation = 8.dp
|
||||
) {
|
||||
Column(Modifier.fillMaxSize().padding(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(Icons.Default.Close, contentDescription = "Close")
|
||||
}
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,499 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.LibraryBooks
|
||||
import androidx.compose.material.icons.automirrored.filled.MenuBook
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Cloud
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.Feedback
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.filled.ImportExport
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Sync
|
||||
import androidx.compose.material.icons.filled.TextFields
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationRail
|
||||
import androidx.compose.material3.NavigationRailItem
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.aryan.reader.shared.AppContrastOption
|
||||
import com.aryan.reader.shared.AppThemeMode
|
||||
import com.aryan.reader.shared.CustomAppTheme
|
||||
|
||||
enum class SharedAppTab {
|
||||
HOME,
|
||||
LIBRARY,
|
||||
SHELVES,
|
||||
CATALOGS,
|
||||
READER,
|
||||
CUSTOM_FONTS,
|
||||
SUPPORT,
|
||||
FEEDBACK,
|
||||
ABOUT
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SharedAppShell(
|
||||
selectedTab: SharedAppTab,
|
||||
snackbarHostState: SnackbarHostState,
|
||||
appThemeMode: AppThemeMode = AppThemeMode.SYSTEM,
|
||||
appContrastOption: AppContrastOption = AppContrastOption.STANDARD,
|
||||
appTextDimFactorLight: Float = 1.0f,
|
||||
appTextDimFactorDark: Float = 1.0f,
|
||||
appSeedColor: Color? = null,
|
||||
customAppThemes: List<CustomAppTheme> = emptyList(),
|
||||
isTabsEnabled: Boolean = false,
|
||||
onTabSelected: (SharedAppTab) -> Unit,
|
||||
onImportFiles: () -> Unit,
|
||||
onImportFolder: () -> Unit = {},
|
||||
onSyncRequested: () -> Unit,
|
||||
onAppThemeModeChange: (AppThemeMode) -> Unit = {},
|
||||
onAppContrastOptionChange: (AppContrastOption) -> Unit = {},
|
||||
onAppTextDimFactorLightChange: (Float) -> Unit = {},
|
||||
onAppTextDimFactorDarkChange: (Float) -> Unit = {},
|
||||
onAppSeedColorChange: (Color?) -> Unit = {},
|
||||
onCustomAppThemeAdded: (CustomAppTheme) -> Unit = {},
|
||||
onCustomAppThemeDeleted: (String) -> Unit = {},
|
||||
onTabsEnabledChange: (Boolean) -> Unit = {},
|
||||
onAiSettingsRequested: (() -> Unit)? = null,
|
||||
content: @Composable (SharedAppTab) -> Unit
|
||||
) {
|
||||
val shellModel = remember(selectedTab, onAiSettingsRequested != null) {
|
||||
sharedAppShellModel(
|
||||
selectedTab = selectedTab,
|
||||
aiSettingsAvailable = onAiSettingsRequested != null
|
||||
)
|
||||
}
|
||||
var showToolsPanel by remember { mutableStateOf(false) }
|
||||
var showAppThemeSettings by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) }
|
||||
) { padding ->
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.padding(padding)
|
||||
) {
|
||||
val useSidebar = maxWidth >= 900.dp
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
if (useSidebar) {
|
||||
SharedAppSidebar(
|
||||
selectedTab = shellModel.selectedPrimaryTab,
|
||||
primaryTabs = shellModel.primaryTabs,
|
||||
onTabSelected = onTabSelected,
|
||||
onToolsClick = { showToolsPanel = true }
|
||||
)
|
||||
} else {
|
||||
SharedAppCompactRail(
|
||||
selectedTab = shellModel.selectedPrimaryTab,
|
||||
primaryTabs = shellModel.primaryTabs,
|
||||
onTabSelected = onTabSelected,
|
||||
onToolsClick = { showToolsPanel = true }
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
) {
|
||||
content(selectedTab)
|
||||
}
|
||||
}
|
||||
|
||||
if (showToolsPanel) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.24f))
|
||||
.clickable { showToolsPanel = false }
|
||||
)
|
||||
SharedToolsPanel(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.fillMaxHeight()
|
||||
.widthIn(max = 390.dp),
|
||||
isTabsEnabled = isTabsEnabled,
|
||||
aiSettingsAvailable = onAiSettingsRequested != null,
|
||||
onClose = { showToolsPanel = false },
|
||||
onImportFiles = {
|
||||
showToolsPanel = false
|
||||
onImportFiles()
|
||||
},
|
||||
onImportFolder = {
|
||||
showToolsPanel = false
|
||||
onImportFolder()
|
||||
},
|
||||
onSyncRequested = {
|
||||
showToolsPanel = false
|
||||
onSyncRequested()
|
||||
},
|
||||
onAppThemeRequested = {
|
||||
showToolsPanel = false
|
||||
showAppThemeSettings = true
|
||||
},
|
||||
onAiSettingsRequested = {
|
||||
showToolsPanel = false
|
||||
onAiSettingsRequested?.invoke()
|
||||
},
|
||||
onOpenTab = { tab ->
|
||||
showToolsPanel = false
|
||||
onTabSelected(tab)
|
||||
},
|
||||
onTabsEnabledChange = onTabsEnabledChange
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showAppThemeSettings) {
|
||||
SharedAppThemeSettingsDialog(
|
||||
appThemeMode = appThemeMode,
|
||||
appContrastOption = appContrastOption,
|
||||
appTextDimFactorLight = appTextDimFactorLight,
|
||||
appTextDimFactorDark = appTextDimFactorDark,
|
||||
appSeedColor = appSeedColor,
|
||||
customAppThemes = customAppThemes,
|
||||
onThemeModeChanged = onAppThemeModeChange,
|
||||
onContrastOptionChanged = onAppContrastOptionChange,
|
||||
onTextDimFactorLightChanged = onAppTextDimFactorLightChange,
|
||||
onTextDimFactorDarkChanged = onAppTextDimFactorDarkChange,
|
||||
onSeedColorChanged = onAppSeedColorChange,
|
||||
onCustomThemeAdded = onCustomAppThemeAdded,
|
||||
onCustomThemeDeleted = onCustomAppThemeDeleted,
|
||||
onDismiss = { showAppThemeSettings = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedAppSidebar(
|
||||
selectedTab: SharedAppTab,
|
||||
primaryTabs: List<SharedAppTab>,
|
||||
onTabSelected: (SharedAppTab) -> Unit,
|
||||
onToolsClick: () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.width(244.dp)
|
||||
.fillMaxHeight(),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 1.dp
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Column(Modifier.padding(horizontal = 10.dp, vertical = 12.dp)) {
|
||||
Text("Episteme", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
Text("Desktop library", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
primaryTabs.forEach { tab ->
|
||||
SharedSidebarNavItem(
|
||||
tab = tab,
|
||||
selected = selectedTab == tab,
|
||||
onClick = { onTabSelected(tab) }
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
HorizontalDivider()
|
||||
SharedSidebarButton(
|
||||
label = "Tools",
|
||||
icon = Icons.Default.Settings,
|
||||
onClick = onToolsClick
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedAppCompactRail(
|
||||
selectedTab: SharedAppTab,
|
||||
primaryTabs: List<SharedAppTab>,
|
||||
onTabSelected: (SharedAppTab) -> Unit,
|
||||
onToolsClick: () -> Unit
|
||||
) {
|
||||
NavigationRail(containerColor = MaterialTheme.colorScheme.surface) {
|
||||
primaryTabs.forEach { tab ->
|
||||
NavigationRailItem(
|
||||
selected = selectedTab == tab,
|
||||
onClick = { onTabSelected(tab) },
|
||||
icon = { Icon(tab.icon, contentDescription = null) },
|
||||
label = { Text(tab.label) }
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
IconButton(onClick = onToolsClick) {
|
||||
Icon(Icons.Default.Settings, contentDescription = "Tools")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedSidebarNavItem(
|
||||
tab: SharedAppTab,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val containerColor = if (selected) {
|
||||
MaterialTheme.colorScheme.secondaryContainer
|
||||
} else {
|
||||
Color.Transparent
|
||||
}
|
||||
val contentColor = if (selected) {
|
||||
MaterialTheme.colorScheme.onSecondaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = containerColor,
|
||||
contentColor = contentColor,
|
||||
onClick = onClick
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Icon(tab.icon, contentDescription = null, modifier = Modifier.size(21.dp))
|
||||
Text(tab.label, style = MaterialTheme.typography.bodyMedium, fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedSidebarButton(
|
||||
label: String,
|
||||
icon: ImageVector,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = Color.Transparent,
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
onClick = onClick
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Icon(icon, contentDescription = null, modifier = Modifier.size(21.dp))
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedToolsPanel(
|
||||
modifier: Modifier,
|
||||
isTabsEnabled: Boolean,
|
||||
aiSettingsAvailable: Boolean,
|
||||
onClose: () -> Unit,
|
||||
onImportFiles: () -> Unit,
|
||||
onImportFolder: () -> Unit,
|
||||
onSyncRequested: () -> Unit,
|
||||
onAppThemeRequested: () -> Unit,
|
||||
onAiSettingsRequested: () -> Unit,
|
||||
onOpenTab: (SharedAppTab) -> Unit,
|
||||
onTabsEnabledChange: (Boolean) -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 8.dp,
|
||||
shadowElevation = 8.dp
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("Tools", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
||||
Text("Import, sync, and app settings", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(Icons.Default.Close, contentDescription = "Close tools")
|
||||
}
|
||||
}
|
||||
|
||||
SharedToolsSection("Library") {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
Button(onClick = onImportFiles, modifier = Modifier.weight(1f)) {
|
||||
Icon(Icons.Default.ImportExport, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Files")
|
||||
}
|
||||
OutlinedButton(onClick = onImportFolder, modifier = Modifier.weight(1f)) {
|
||||
Icon(Icons.Default.Folder, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Folder")
|
||||
}
|
||||
}
|
||||
FilledTonalButton(onClick = onSyncRequested, modifier = Modifier.fillMaxWidth()) {
|
||||
Icon(Icons.Default.Sync, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Sync folders")
|
||||
}
|
||||
}
|
||||
|
||||
SharedToolsSection("Appearance") {
|
||||
SharedToolRow(
|
||||
icon = Icons.Default.Palette,
|
||||
title = "App theme",
|
||||
onClick = onAppThemeRequested
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 2.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("Active reader tabs", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
|
||||
Text(if (isTabsEnabled) "Enabled" else "Disabled", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Switch(
|
||||
checked = isTabsEnabled,
|
||||
onCheckedChange = onTabsEnabledChange
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SharedToolsSection("Settings") {
|
||||
if (aiSettingsAvailable) {
|
||||
SharedToolRow(Icons.Default.Settings, "AI keys and models", onAiSettingsRequested)
|
||||
}
|
||||
SharedToolRow(Icons.Default.TextFields, "Custom fonts") { onOpenTab(SharedAppTab.CUSTOM_FONTS) }
|
||||
}
|
||||
|
||||
SharedToolsSection("Project") {
|
||||
SharedToolRow(Icons.Default.Feedback, "Help & feedback") { onOpenTab(SharedAppTab.FEEDBACK) }
|
||||
SharedToolRow(Icons.Default.Favorite, "Support project") { onOpenTab(SharedAppTab.SUPPORT) }
|
||||
SharedToolRow(Icons.Default.Info, "About Episteme") { onOpenTab(SharedAppTab.ABOUT) }
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedToolsSection(
|
||||
title: String,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold)
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedToolRow(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
onClick = onClick
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Icon(icon, contentDescription = null, modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.primary)
|
||||
Text(title, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val SharedAppTab.label: String
|
||||
get() = when (this) {
|
||||
SharedAppTab.HOME -> "Home"
|
||||
SharedAppTab.LIBRARY -> "Library"
|
||||
SharedAppTab.SHELVES -> "Shelves"
|
||||
SharedAppTab.CATALOGS -> "OPDS"
|
||||
SharedAppTab.READER -> "Reader"
|
||||
SharedAppTab.CUSTOM_FONTS -> "Custom fonts"
|
||||
SharedAppTab.SUPPORT -> "Support"
|
||||
SharedAppTab.FEEDBACK -> "Feedback"
|
||||
SharedAppTab.ABOUT -> "About"
|
||||
}
|
||||
|
||||
private val SharedAppTab.icon: ImageVector
|
||||
get() = when (this) {
|
||||
SharedAppTab.HOME -> Icons.Default.Home
|
||||
SharedAppTab.LIBRARY -> Icons.AutoMirrored.Filled.LibraryBooks
|
||||
SharedAppTab.SHELVES -> Icons.Default.Folder
|
||||
SharedAppTab.CATALOGS -> Icons.Default.Cloud
|
||||
SharedAppTab.READER -> Icons.AutoMirrored.Filled.MenuBook
|
||||
SharedAppTab.CUSTOM_FONTS -> Icons.Default.TextFields
|
||||
SharedAppTab.SUPPORT -> Icons.Default.Favorite
|
||||
SharedAppTab.FEEDBACK -> Icons.Default.Feedback
|
||||
SharedAppTab.ABOUT -> Icons.Default.Info
|
||||
}
|
||||
|
|
@ -0,0 +1,980 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.drag
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.aryan.reader.shared.AppContrastOption
|
||||
import com.aryan.reader.shared.AppThemeMode
|
||||
import com.aryan.reader.shared.CustomAppTheme
|
||||
import com.materialkolor.PaletteStyle
|
||||
import com.materialkolor.dynamicColorScheme
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.random.Random
|
||||
|
||||
private val SharedLightColorScheme = lightColorScheme(
|
||||
primary = Color(0xFF4C662B),
|
||||
onPrimary = Color(0xFFFFFFFF),
|
||||
primaryContainer = Color(0xFFCDEDA3),
|
||||
onPrimaryContainer = Color(0xFF354E16),
|
||||
secondary = Color(0xFF586249),
|
||||
onSecondary = Color(0xFFFFFFFF),
|
||||
secondaryContainer = Color(0xFFDCE7C8),
|
||||
onSecondaryContainer = Color(0xFF404A33),
|
||||
tertiary = Color(0xFF386663),
|
||||
onTertiary = Color(0xFFFFFFFF),
|
||||
tertiaryContainer = Color(0xFFBCECE7),
|
||||
onTertiaryContainer = Color(0xFF1F4E4B),
|
||||
error = Color(0xFFBA1A1A),
|
||||
onError = Color(0xFFFFFFFF),
|
||||
errorContainer = Color(0xFFFFDAD6),
|
||||
onErrorContainer = Color(0xFF93000A),
|
||||
background = Color(0xFFF9FAEF),
|
||||
onBackground = Color(0xFF1A1C16),
|
||||
surface = Color(0xFFF9FAEF),
|
||||
onSurface = Color(0xFF1A1C16),
|
||||
surfaceVariant = Color(0xFFE1E4D5),
|
||||
onSurfaceVariant = Color(0xFF44483D),
|
||||
outline = Color(0xFF75796C),
|
||||
outlineVariant = Color(0xFFC5C8BA),
|
||||
scrim = Color(0xFF000000),
|
||||
inverseSurface = Color(0xFF2F312A),
|
||||
inverseOnSurface = Color(0xFFF1F2E6),
|
||||
inversePrimary = Color(0xFFB1D18A),
|
||||
surfaceDim = Color(0xFFDADBD0),
|
||||
surfaceBright = Color(0xFFF9FAEF),
|
||||
surfaceContainerLowest = Color(0xFFFFFFFF),
|
||||
surfaceContainerLow = Color(0xFFF3F4E9),
|
||||
surfaceContainer = Color(0xFFEEEFE3),
|
||||
surfaceContainerHigh = Color(0xFFE8E9DE),
|
||||
surfaceContainerHighest = Color(0xFFE2E3D8)
|
||||
)
|
||||
|
||||
private val SharedDarkColorScheme = darkColorScheme(
|
||||
primary = Color(0xFFB1D18A),
|
||||
onPrimary = Color(0xFF1F3701),
|
||||
primaryContainer = Color(0xFF354E16),
|
||||
onPrimaryContainer = Color(0xFFCDEDA3),
|
||||
secondary = Color(0xFFBFCBAD),
|
||||
onSecondary = Color(0xFF2A331E),
|
||||
secondaryContainer = Color(0xFF404A33),
|
||||
onSecondaryContainer = Color(0xFFDCE7C8),
|
||||
tertiary = Color(0xFFA0D0CB),
|
||||
onTertiary = Color(0xFF003735),
|
||||
tertiaryContainer = Color(0xFF1F4E4B),
|
||||
onTertiaryContainer = Color(0xFFBCECE7),
|
||||
error = Color(0xFFFFB4AB),
|
||||
onError = Color(0xFF690005),
|
||||
errorContainer = Color(0xFF93000A),
|
||||
onErrorContainer = Color(0xFFFFDAD6),
|
||||
background = Color(0xFF12140E),
|
||||
onBackground = Color(0xFFE2E3D8),
|
||||
surface = Color(0xFF12140E),
|
||||
onSurface = Color(0xFFE2E3D8),
|
||||
surfaceVariant = Color(0xFF44483D),
|
||||
onSurfaceVariant = Color(0xFFC5C8BA),
|
||||
outline = Color(0xFF8F9285),
|
||||
outlineVariant = Color(0xFF44483D),
|
||||
scrim = Color(0xFF000000),
|
||||
inverseSurface = Color(0xFFE2E3D8),
|
||||
inverseOnSurface = Color(0xFF2F312A),
|
||||
inversePrimary = Color(0xFF4C662B),
|
||||
surfaceDim = Color(0xFF12140E),
|
||||
surfaceBright = Color(0xFF383A32),
|
||||
surfaceContainerLowest = Color(0xFF0C0F09),
|
||||
surfaceContainerLow = Color(0xFF1A1C16),
|
||||
surfaceContainer = Color(0xFF1E201A),
|
||||
surfaceContainerHigh = Color(0xFF282B24),
|
||||
surfaceContainerHighest = Color(0xFF33362E)
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun SharedAppTheme(
|
||||
appThemeMode: AppThemeMode,
|
||||
appContrastOption: AppContrastOption,
|
||||
appTextDimFactorLight: Float,
|
||||
appTextDimFactorDark: Float,
|
||||
appSeedColor: Color?,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val darkTheme = resolveSharedAppDarkTheme(appThemeMode, isSystemInDarkTheme())
|
||||
val textDimFactor = sharedAppTextDimFactor(darkTheme, appTextDimFactorLight, appTextDimFactorDark)
|
||||
val colorScheme = remember(darkTheme, appContrastOption, textDimFactor, appSeedColor) {
|
||||
sharedAppColorScheme(
|
||||
darkTheme = darkTheme,
|
||||
seedColor = appSeedColor,
|
||||
contrastLevel = appContrastOption.value,
|
||||
textDimFactor = textDimFactor
|
||||
)
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography(),
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
fun resolveSharedAppDarkTheme(mode: AppThemeMode, isSystemDark: Boolean): Boolean {
|
||||
return when (mode) {
|
||||
AppThemeMode.LIGHT -> false
|
||||
AppThemeMode.DARK -> true
|
||||
AppThemeMode.SYSTEM -> isSystemDark
|
||||
}
|
||||
}
|
||||
|
||||
fun sharedAppTextDimFactor(
|
||||
darkTheme: Boolean,
|
||||
lightFactor: Float,
|
||||
darkFactor: Float
|
||||
): Float {
|
||||
return if (darkTheme) darkFactor else lightFactor
|
||||
}
|
||||
|
||||
fun sharedAppColorScheme(
|
||||
darkTheme: Boolean,
|
||||
seedColor: Color?,
|
||||
contrastLevel: Double,
|
||||
textDimFactor: Float
|
||||
): ColorScheme {
|
||||
val baseColorScheme = seedColor?.let {
|
||||
dynamicColorScheme(
|
||||
seedColor = it,
|
||||
isDark = darkTheme,
|
||||
contrastLevel = contrastLevel,
|
||||
style = PaletteStyle.Fidelity
|
||||
)
|
||||
} ?: if (darkTheme) {
|
||||
SharedDarkColorScheme
|
||||
} else {
|
||||
SharedLightColorScheme
|
||||
}
|
||||
|
||||
return baseColorScheme.withTextDimFactor(textDimFactor)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SharedAppThemeSettingsDialog(
|
||||
appThemeMode: AppThemeMode,
|
||||
appContrastOption: AppContrastOption,
|
||||
appTextDimFactorLight: Float,
|
||||
appTextDimFactorDark: Float,
|
||||
appSeedColor: Color?,
|
||||
customAppThemes: List<CustomAppTheme>,
|
||||
onThemeModeChanged: (AppThemeMode) -> Unit,
|
||||
onContrastOptionChanged: (AppContrastOption) -> Unit,
|
||||
onTextDimFactorLightChanged: (Float) -> Unit,
|
||||
onTextDimFactorDarkChanged: (Float) -> Unit,
|
||||
onSeedColorChanged: (Color?) -> Unit,
|
||||
onCustomThemeAdded: (CustomAppTheme) -> Unit,
|
||||
onCustomThemeDeleted: (String) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
var showCreateDialog by remember { mutableStateOf(false) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("App theme", fontWeight = FontWeight.Bold) },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 620.dp)
|
||||
.heightIn(max = 620.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
SettingsLabel("Appearance")
|
||||
SegmentedControl(
|
||||
values = AppThemeMode.entries,
|
||||
selectedValue = appThemeMode,
|
||||
label = { it.label },
|
||||
onValueSelected = onThemeModeChanged
|
||||
)
|
||||
|
||||
SettingsLabel("Contrast")
|
||||
SegmentedControl(
|
||||
values = AppContrastOption.entries,
|
||||
selectedValue = appContrastOption,
|
||||
label = { it.label },
|
||||
onValueSelected = onContrastOptionChanged
|
||||
)
|
||||
|
||||
if (appThemeMode == AppThemeMode.SYSTEM) {
|
||||
TextBrightnessSlider(
|
||||
label = "Text brightness (Light)",
|
||||
value = appTextDimFactorLight,
|
||||
onValueChange = onTextDimFactorLightChanged
|
||||
)
|
||||
TextBrightnessSlider(
|
||||
label = "Text brightness (Dark)",
|
||||
value = appTextDimFactorDark,
|
||||
onValueChange = onTextDimFactorDarkChanged
|
||||
)
|
||||
} else {
|
||||
TextBrightnessSlider(
|
||||
label = "Text brightness",
|
||||
value = if (appThemeMode == AppThemeMode.DARK) appTextDimFactorDark else appTextDimFactorLight,
|
||||
onValueChange = if (appThemeMode == AppThemeMode.DARK) onTextDimFactorDarkChanged else onTextDimFactorLightChanged
|
||||
)
|
||||
}
|
||||
|
||||
SettingsLabel("Color scheme")
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
ThemeSwatch(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
selected = appSeedColor == null,
|
||||
label = "Dynamic",
|
||||
onClick = { onSeedColorChanged(null) }
|
||||
)
|
||||
AppThemePresets.forEach { preset ->
|
||||
ThemeSwatch(
|
||||
color = preset.color,
|
||||
selected = appSeedColor == preset.color,
|
||||
label = preset.name,
|
||||
onClick = { onSeedColorChanged(preset.color) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
SettingsLabel("My themes")
|
||||
IconButton(onClick = { showCreateDialog = true }, modifier = Modifier.size(32.dp)) {
|
||||
Icon(Icons.Default.Add, contentDescription = "Add custom theme")
|
||||
}
|
||||
}
|
||||
|
||||
if (customAppThemes.isEmpty()) {
|
||||
Text(
|
||||
"No custom themes yet",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
} else {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
customAppThemes.forEach { theme ->
|
||||
ThemeSwatch(
|
||||
color = theme.seedColor,
|
||||
selected = appSeedColor == theme.seedColor,
|
||||
label = theme.name,
|
||||
onClick = { onSeedColorChanged(theme.seedColor) },
|
||||
onDelete = { onCustomThemeDeleted(theme.id) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Done")
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (showCreateDialog) {
|
||||
SharedCreateAppThemeDialog(
|
||||
onDismiss = { showCreateDialog = false },
|
||||
onSave = { name, color ->
|
||||
onCustomThemeAdded(
|
||||
CustomAppTheme(
|
||||
id = Random.nextLong().toString(),
|
||||
name = name.ifBlank { "Custom" },
|
||||
seedColor = color
|
||||
)
|
||||
)
|
||||
showCreateDialog = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsLabel(label: String) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun <T> SegmentedControl(
|
||||
values: List<T>,
|
||||
selectedValue: T,
|
||||
label: (T) -> String,
|
||||
onValueSelected: (T) -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHigh, RoundedCornerShape(24.dp))
|
||||
.padding(4.dp)
|
||||
) {
|
||||
values.forEach { value ->
|
||||
val selected = selectedValue == value
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(if (selected) MaterialTheme.colorScheme.primary else Color.Transparent)
|
||||
.clickable { onValueSelected(value) },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = label(value),
|
||||
color = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TextBrightnessSlider(
|
||||
label: String,
|
||||
value: Float,
|
||||
onValueChange: (Float) -> Unit
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
SettingsLabel(label)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHigh, RoundedCornerShape(24.dp))
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
"A",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
|
||||
)
|
||||
Slider(
|
||||
value = value.coerceIn(0.3f, 1.0f),
|
||||
onValueChange = { onValueChange(it.coerceIn(0.3f, 1.0f)) },
|
||||
valueRange = 0.3f..1.0f,
|
||||
modifier = Modifier.weight(1f).padding(horizontal = 16.dp)
|
||||
)
|
||||
Text(
|
||||
"A",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThemeSwatch(
|
||||
color: Color,
|
||||
selected: Boolean,
|
||||
label: String,
|
||||
onClick: () -> Unit,
|
||||
onDelete: (() -> Unit)? = null
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(56.dp)
|
||||
.clip(CircleShape)
|
||||
.background(color)
|
||||
.border(
|
||||
width = if (selected) 3.dp else 1.dp,
|
||||
color = if (selected) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.outlineVariant,
|
||||
shape = CircleShape
|
||||
)
|
||||
.clickable(onClick = onClick),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (selected) {
|
||||
Icon(
|
||||
Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = if (color.luminance() > 0.5f) Color.Black else Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.widthIn(max = 72.dp)
|
||||
)
|
||||
if (onDelete != null) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "Delete",
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(16.dp).clickable(onClick = onDelete)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedCreateAppThemeDialog(
|
||||
initialColor: Color = Color(0xFF6750A4),
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (String, Color) -> Unit
|
||||
) {
|
||||
var name by remember { mutableStateOf("") }
|
||||
var hsv by remember(initialColor) { mutableStateOf(initialColor.toSharedHsvColor()) }
|
||||
val color = hsv.toComposeColor()
|
||||
|
||||
fun updateFromColor(nextColor: Color) {
|
||||
hsv = nextColor.toSharedHsvColor()
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Create theme") },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.widthIn(max = 560.dp).verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("Theme name") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
SharedSpectrumBox(
|
||||
hue = hsv.hue,
|
||||
saturation = hsv.saturation,
|
||||
currentColor = color,
|
||||
onHueSatChanged = { hue, saturation ->
|
||||
hsv = hsv.copy(hue = hue, saturation = saturation)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(220.dp)
|
||||
)
|
||||
|
||||
SharedBrightnessSlider(
|
||||
hue = hsv.hue,
|
||||
saturation = hsv.saturation,
|
||||
value = hsv.value,
|
||||
onValueChanged = { hsv = hsv.copy(value = it) },
|
||||
modifier = Modifier.fillMaxWidth().height(24.dp).clip(RoundedCornerShape(12.dp))
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
SharedColorComparePill(
|
||||
oldColor = initialColor,
|
||||
newColor = color,
|
||||
modifier = Modifier.width(64.dp).height(36.dp)
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1.6f),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text("Hex", color = Color.Gray, fontSize = 12.sp, maxLines = 1)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
SharedHexInput(color = color, onHexChanged = { updateFromColor(it) })
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.weight(2.4f),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
SharedRgbInputColumn(
|
||||
label = "R",
|
||||
value = color.red,
|
||||
onValueChange = { updateFromColor(color.copy(red = it)) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
SharedRgbInputColumn(
|
||||
label = "G",
|
||||
value = color.green,
|
||||
onValueChange = { updateFromColor(color.copy(green = it)) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
SharedRgbInputColumn(
|
||||
label = "B",
|
||||
value = color.blue,
|
||||
onValueChange = { updateFromColor(color.copy(blue = it)) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = { onSave(name.trim().ifBlank { "Custom" }, color) },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = color,
|
||||
contentColor = if (color.luminance() > 0.5f) Color.Black else Color.White
|
||||
)
|
||||
) {
|
||||
Text("Save", fontWeight = FontWeight.Bold)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedSpectrumBox(
|
||||
hue: Float,
|
||||
saturation: Float,
|
||||
currentColor: Color,
|
||||
onHueSatChanged: (Float, Float) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val rainbowColors = listOf(
|
||||
Color.Red,
|
||||
Color.Yellow,
|
||||
Color.Green,
|
||||
Color.Cyan,
|
||||
Color.Blue,
|
||||
Color.Magenta,
|
||||
Color.Red
|
||||
)
|
||||
val touchPadding = 12.dp
|
||||
|
||||
Box(
|
||||
modifier = modifier.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown()
|
||||
val paddingPx = touchPadding.toPx()
|
||||
val activeWidth = size.width.toFloat() - (paddingPx * 2)
|
||||
val activeHeight = size.height.toFloat() - (paddingPx * 2)
|
||||
|
||||
fun update(offset: Offset) {
|
||||
val relativeX = offset.x - paddingPx
|
||||
val relativeY = offset.y - paddingPx
|
||||
val nextHue = (relativeX / activeWidth).coerceIn(0f, 1f) * 360f
|
||||
val nextSaturation = (relativeY / activeHeight).coerceIn(0f, 1f)
|
||||
onHueSatChanged(nextHue, nextSaturation)
|
||||
}
|
||||
|
||||
update(down.position)
|
||||
drag(down.id) { change ->
|
||||
change.consume()
|
||||
update(change.position)
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(touchPadding)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
) {
|
||||
drawRect(brush = Brush.horizontalGradient(rainbowColors))
|
||||
drawRect(
|
||||
brush = Brush.verticalGradient(
|
||||
colors = listOf(Color.White, Color.White.copy(alpha = 0f))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
val paddingPx = touchPadding.toPx()
|
||||
val activeWidth = size.width - (paddingPx * 2)
|
||||
val activeHeight = size.height - (paddingPx * 2)
|
||||
val x = paddingPx + (hue / 360f) * activeWidth
|
||||
val y = paddingPx + saturation * activeHeight
|
||||
val pointerRadius = 10.dp.toPx()
|
||||
val strokeWidth = 2.dp.toPx()
|
||||
|
||||
drawCircle(
|
||||
color = Color.Black.copy(alpha = 0.25f),
|
||||
radius = pointerRadius + 1.dp.toPx(),
|
||||
center = Offset(x, y + 1.dp.toPx())
|
||||
)
|
||||
drawCircle(
|
||||
color = currentColor.copy(alpha = 1f),
|
||||
radius = pointerRadius,
|
||||
center = Offset(x, y)
|
||||
)
|
||||
drawCircle(
|
||||
color = Color.White,
|
||||
radius = pointerRadius,
|
||||
center = Offset(x, y),
|
||||
style = Stroke(width = strokeWidth)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedBrightnessSlider(
|
||||
hue: Float,
|
||||
saturation: Float,
|
||||
value: Float,
|
||||
onValueChanged: (Float) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val baseColor = remember(hue, saturation) {
|
||||
Color.hsv(hue, saturation, 1f)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown()
|
||||
|
||||
fun update(offset: Offset) {
|
||||
val nextValue = (offset.x / size.width.toFloat()).coerceIn(0f, 1f)
|
||||
onValueChanged(nextValue)
|
||||
}
|
||||
|
||||
update(down.position)
|
||||
drag(down.id) { change ->
|
||||
change.consume()
|
||||
update(change.position)
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
drawRect(
|
||||
brush = Brush.horizontalGradient(
|
||||
colors = listOf(Color.Black, baseColor)
|
||||
)
|
||||
)
|
||||
drawCircle(
|
||||
color = Color.White,
|
||||
radius = 8.dp.toPx(),
|
||||
center = Offset(value.coerceIn(0f, 1f) * size.width, size.height / 2)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedRgbInputColumn(
|
||||
label: String,
|
||||
value: Float,
|
||||
onValueChange: (Float) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val intValue = (value.coerceIn(0f, 1f) * 255).roundToInt()
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = modifier
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
color = Color.Gray,
|
||||
fontSize = 11.sp,
|
||||
maxLines = 1
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
SharedRgbInput(value = intValue, onValueChange = onValueChange)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedRgbInput(
|
||||
value: Int,
|
||||
onValueChange: (Float) -> Unit
|
||||
) {
|
||||
var text by remember(value) { mutableStateOf(value.coerceIn(0, 255).toString()) }
|
||||
|
||||
BasicTextField(
|
||||
value = text,
|
||||
onValueChange = { newText ->
|
||||
if (newText.length <= 3 && newText.all { it.isDigit() }) {
|
||||
text = newText
|
||||
newText.toIntOrNull()?.let { channel ->
|
||||
onValueChange(channel.coerceIn(0, 255) / 255f)
|
||||
}
|
||||
}
|
||||
},
|
||||
textStyle = TextStyle(
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center,
|
||||
fontSize = 13.sp
|
||||
),
|
||||
singleLine = true,
|
||||
cursorBrush = SolidColor(Color.White),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(36.dp)
|
||||
.background(Color(0xFF3E3E3E), RoundedCornerShape(8.dp))
|
||||
.padding(vertical = 9.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedHexInput(
|
||||
color: Color,
|
||||
onHexChanged: (Color) -> Unit
|
||||
) {
|
||||
val hexValue = color.toSharedHexString().removePrefix("#")
|
||||
var text by remember(hexValue) { mutableStateOf(hexValue) }
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(36.dp)
|
||||
.background(Color(0xFF3E3E3E), RoundedCornerShape(8.dp))
|
||||
.padding(horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = "#",
|
||||
color = Color.Gray,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
BasicTextField(
|
||||
value = text,
|
||||
onValueChange = { newText ->
|
||||
if (newText.length <= 6) {
|
||||
val uppercased = newText.uppercase()
|
||||
if (uppercased.all { it.isDigit() || it in 'A'..'F' }) {
|
||||
text = uppercased
|
||||
if (uppercased.length == 6) {
|
||||
uppercased.toSharedHexColorOrNull()?.let(onHexChanged)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
textStyle = TextStyle(
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Start,
|
||||
fontSize = 13.sp
|
||||
),
|
||||
singleLine = true,
|
||||
cursorBrush = SolidColor(Color.White),
|
||||
modifier = Modifier
|
||||
.padding(start = 2.dp)
|
||||
.width(50.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedColorComparePill(
|
||||
oldColor: Color,
|
||||
newColor: Color,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Canvas(modifier = modifier.clip(RoundedCornerShape(8.dp))) {
|
||||
drawRect(
|
||||
color = oldColor.copy(alpha = 1f),
|
||||
size = Size(size.width / 2, size.height)
|
||||
)
|
||||
drawRect(
|
||||
color = newColor.copy(alpha = 1f),
|
||||
topLeft = Offset(size.width / 2, 0f),
|
||||
size = Size(size.width / 2, size.height)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ColorScheme.withTextDimFactor(factor: Float): ColorScheme {
|
||||
val dimFactor = factor.coerceIn(0.3f, 1.0f)
|
||||
if (dimFactor >= 1.0f) return this
|
||||
return copy(
|
||||
primary = primary.copy(alpha = dimFactor),
|
||||
secondary = secondary.copy(alpha = dimFactor),
|
||||
tertiary = tertiary.copy(alpha = dimFactor),
|
||||
error = error.copy(alpha = dimFactor),
|
||||
primaryContainer = primaryContainer.copy(alpha = dimFactor),
|
||||
secondaryContainer = secondaryContainer.copy(alpha = dimFactor),
|
||||
tertiaryContainer = tertiaryContainer.copy(alpha = dimFactor),
|
||||
errorContainer = errorContainer.copy(alpha = dimFactor),
|
||||
outline = outline.copy(alpha = dimFactor),
|
||||
outlineVariant = outlineVariant.copy(alpha = dimFactor),
|
||||
inversePrimary = inversePrimary.copy(alpha = dimFactor),
|
||||
inverseOnSurface = inverseOnSurface.copy(alpha = dimFactor),
|
||||
onPrimary = onPrimary.copy(alpha = dimFactor),
|
||||
onSecondary = onSecondary.copy(alpha = dimFactor),
|
||||
onTertiary = onTertiary.copy(alpha = dimFactor),
|
||||
onBackground = onBackground.copy(alpha = dimFactor),
|
||||
onSurface = onSurface.copy(alpha = dimFactor),
|
||||
onSurfaceVariant = onSurfaceVariant.copy(alpha = dimFactor),
|
||||
onError = onError.copy(alpha = dimFactor),
|
||||
onPrimaryContainer = onPrimaryContainer.copy(alpha = dimFactor),
|
||||
onSecondaryContainer = onSecondaryContainer.copy(alpha = dimFactor),
|
||||
onTertiaryContainer = onTertiaryContainer.copy(alpha = dimFactor),
|
||||
onErrorContainer = onErrorContainer.copy(alpha = dimFactor)
|
||||
)
|
||||
}
|
||||
|
||||
private data class AppThemePreset(
|
||||
val name: String,
|
||||
val color: Color
|
||||
)
|
||||
|
||||
private val AppThemePresets = listOf(
|
||||
AppThemePreset("Ocean", Color(0xFF00668B)),
|
||||
AppThemePreset("Mint", Color(0xFF006C4C)),
|
||||
AppThemePreset("Rose", Color(0xFF9C4146)),
|
||||
AppThemePreset("Sepia", Color(0xFF705D49)),
|
||||
AppThemePreset("Amethyst", Color(0xFF9B59B6)),
|
||||
AppThemePreset("Amber", Color(0xFFFFC107)),
|
||||
AppThemePreset("Sapphire", Color(0xFF0F52BA))
|
||||
)
|
||||
|
||||
private val AppThemeMode.label: String
|
||||
get() = when (this) {
|
||||
AppThemeMode.SYSTEM -> "System"
|
||||
AppThemeMode.LIGHT -> "Light"
|
||||
AppThemeMode.DARK -> "Dark"
|
||||
}
|
||||
|
||||
private val AppContrastOption.label: String
|
||||
get() = when (this) {
|
||||
AppContrastOption.STANDARD -> "Standard"
|
||||
AppContrastOption.MEDIUM -> "Medium"
|
||||
AppContrastOption.HIGH -> "High"
|
||||
}
|
||||
|
||||
internal data class SharedHsvColor(
|
||||
val hue: Float,
|
||||
val saturation: Float,
|
||||
val value: Float
|
||||
) {
|
||||
fun toComposeColor(): Color {
|
||||
return Color.hsv(
|
||||
hue.normalizedHue(),
|
||||
saturation.coerceIn(0f, 1f),
|
||||
value.coerceIn(0f, 1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun Color.toSharedHsvColor(): SharedHsvColor {
|
||||
val maximum = maxOf(red, green, blue)
|
||||
val minimum = minOf(red, green, blue)
|
||||
val delta = maximum - minimum
|
||||
val hue = when {
|
||||
delta == 0f -> 0f
|
||||
maximum == red -> 60f * (((green - blue) / delta) % 6f)
|
||||
maximum == green -> 60f * (((blue - red) / delta) + 2f)
|
||||
else -> 60f * (((red - green) / delta) + 4f)
|
||||
}
|
||||
val saturation = if (maximum == 0f) 0f else delta / maximum
|
||||
return SharedHsvColor(
|
||||
hue = hue.normalizedHue(),
|
||||
saturation = saturation.coerceIn(0f, 1f),
|
||||
value = maximum.coerceIn(0f, 1f)
|
||||
)
|
||||
}
|
||||
|
||||
internal fun Color.toSharedHexString(): String {
|
||||
val rgb = toArgb() and 0x00FFFFFF
|
||||
return "#${rgb.toString(16).padStart(6, '0').uppercase()}"
|
||||
}
|
||||
|
||||
internal fun String.toSharedHexColorOrNull(): Color? {
|
||||
val normalized = trim().removePrefix("#")
|
||||
if (normalized.length != 6 || normalized.any { !it.isDigit() && it.lowercaseChar() !in 'a'..'f' }) {
|
||||
return null
|
||||
}
|
||||
val rgb = normalized.toLongOrNull(16) ?: return null
|
||||
return Color((0xFF000000L or rgb).toInt())
|
||||
}
|
||||
|
||||
private fun Float.normalizedHue(): Float {
|
||||
return ((this % 360f) + 360f) % 360f
|
||||
}
|
||||
|
|
@ -0,0 +1,242 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.aryan.reader.shared.BookItem
|
||||
import com.aryan.reader.shared.Shelf
|
||||
import com.aryan.reader.shared.Tag
|
||||
import com.aryan.reader.shared.cardTitle
|
||||
import com.aryan.reader.shared.formatFileSize
|
||||
import com.aryan.reader.shared.parseTagList
|
||||
|
||||
@Composable
|
||||
fun SharedTextInputDialog(
|
||||
title: String,
|
||||
label: String,
|
||||
initialValue: String,
|
||||
confirmLabel: String,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (String) -> Unit
|
||||
) {
|
||||
var value by remember(initialValue) { mutableStateOf(initialValue) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = { value = it },
|
||||
label = { Text(label) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { onConfirm(value) }, enabled = value.isNotBlank()) {
|
||||
Text(confirmLabel)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SharedConfirmDialog(
|
||||
title: String,
|
||||
body: String,
|
||||
confirmLabel: String,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: () -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = { Text(body) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = onConfirm) {
|
||||
Text(confirmLabel)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SharedAddToShelfDialog(
|
||||
shelves: List<Shelf>,
|
||||
onDismiss: () -> Unit,
|
||||
onCreateShelf: () -> Unit,
|
||||
onShelfSelected: (Shelf) -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Add to shelf") },
|
||||
text = {
|
||||
if (shelves.isEmpty()) {
|
||||
Text("Create a shelf first, then add selected books to it.")
|
||||
} else {
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
items(shelves, key = { it.id }) { shelf ->
|
||||
Surface(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth().clickable { onShelfSelected(shelf) }
|
||||
) {
|
||||
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Default.Folder, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(shelf.name, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text("${shelf.bookCount}", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onCreateShelf) {
|
||||
Text("New shelf")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SharedBookInfoDialog(
|
||||
book: BookItem,
|
||||
onDismiss: () -> Unit,
|
||||
onEdit: () -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(book.cardTitle()) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
SharedInfoRow("File", book.displayName)
|
||||
SharedInfoRow("Type", book.type.name)
|
||||
SharedInfoRow("Author", book.author.orEmpty().ifBlank { "Unknown" })
|
||||
SharedInfoRow("Path", book.path.orEmpty().ifBlank { "Not available" })
|
||||
SharedInfoRow("Size", formatFileSize(book.fileSize))
|
||||
SharedInfoRow("Progress", "${(book.progressPercentage ?: 0f).toInt()}%")
|
||||
if (!book.seriesName.isNullOrBlank()) {
|
||||
SharedInfoRow("Series", listOfNotNull(book.seriesName, book.seriesIndex?.toString()).joinToString(" #"))
|
||||
}
|
||||
if (book.tags.isNotEmpty()) {
|
||||
SharedInfoRow("Tags", book.tags.joinToString { it.name })
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onEdit) {
|
||||
Text("Edit")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Close")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SharedBookEditDialog(
|
||||
book: BookItem,
|
||||
knownTags: List<Tag>,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (BookItem) -> Unit
|
||||
) {
|
||||
var title by remember(book.id) { mutableStateOf(book.title.orEmpty()) }
|
||||
var author by remember(book.id) { mutableStateOf(book.author.orEmpty()) }
|
||||
var seriesName by remember(book.id) { mutableStateOf(book.seriesName.orEmpty()) }
|
||||
var seriesIndex by remember(book.id) { mutableStateOf(book.seriesIndex?.toString().orEmpty()) }
|
||||
var tagText by remember(book.id) { mutableStateOf(book.tags.joinToString(", ") { it.name }) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Edit book") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
OutlinedTextField(value = title, onValueChange = { title = it }, label = { Text("Title") }, singleLine = true, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(value = author, onValueChange = { author = it }, label = { Text("Author") }, singleLine = true, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(value = seriesName, onValueChange = { seriesName = it }, label = { Text("Series") }, singleLine = true, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(value = seriesIndex, onValueChange = { seriesIndex = it }, label = { Text("Series index") }, singleLine = true, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(value = tagText, onValueChange = { tagText = it }, label = { Text("Tags, comma separated") }, singleLine = true, modifier = Modifier.fillMaxWidth())
|
||||
if (knownTags.isNotEmpty()) {
|
||||
Text("Existing: ${knownTags.joinToString { it.name }}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onSave(
|
||||
book.copy(
|
||||
title = title.trim().ifBlank { null },
|
||||
author = author.trim().ifBlank { null },
|
||||
seriesName = seriesName.trim().ifBlank { null },
|
||||
seriesIndex = seriesIndex.toDoubleOrNull(),
|
||||
tags = parseTagList(tagText, knownTags)
|
||||
)
|
||||
)
|
||||
}
|
||||
) {
|
||||
Text("Save")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedInfoRow(label: String, value: String) {
|
||||
Column {
|
||||
Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(value, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.aryan.reader.shared.ReaderMarkdownBlock
|
||||
import com.aryan.reader.shared.ReaderMarkdownParser
|
||||
|
||||
@Composable
|
||||
fun SharedMarkdownText(
|
||||
markdown: String,
|
||||
modifier: Modifier = Modifier,
|
||||
style: TextStyle = MaterialTheme.typography.bodySmall
|
||||
) {
|
||||
val document = remember(markdown) { ReaderMarkdownParser.parse(markdown) }
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
document.blocks.forEachIndexed { index, block ->
|
||||
when (block) {
|
||||
is ReaderMarkdownBlock.Heading -> {
|
||||
val headingStyle = when (block.level) {
|
||||
1 -> MaterialTheme.typography.titleLarge
|
||||
2 -> MaterialTheme.typography.titleMedium
|
||||
else -> MaterialTheme.typography.titleSmall
|
||||
}
|
||||
Text(
|
||||
text = block.text.markdownInlineAnnotatedString(),
|
||||
style = headingStyle,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
|
||||
is ReaderMarkdownBlock.Paragraph -> {
|
||||
Text(text = block.text.markdownInlineAnnotatedString(), style = style)
|
||||
}
|
||||
|
||||
is ReaderMarkdownBlock.Quote -> {
|
||||
Text(
|
||||
text = block.text.markdownInlineAnnotatedString(),
|
||||
style = style,
|
||||
color = colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(colorScheme.surfaceVariant.copy(alpha = 0.45f), RoundedCornerShape(6.dp))
|
||||
.padding(8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
is ReaderMarkdownBlock.CodeBlock -> {
|
||||
Surface(
|
||||
color = colorScheme.surfaceVariant.copy(alpha = 0.6f),
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = block.text,
|
||||
style = style.copy(fontFamily = FontFamily.Monospace),
|
||||
modifier = Modifier.padding(8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is ReaderMarkdownBlock.ListItems -> {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
block.items.forEachIndexed { itemIndex, item ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = if (block.ordered) "${itemIndex + 1}." else "-",
|
||||
style = style,
|
||||
color = colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = item.markdownInlineAnnotatedString(),
|
||||
style = style,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (document.blocks.isEmpty() && markdown.isNotBlank()) {
|
||||
Text(text = markdown, style = style)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun String.markdownInlineAnnotatedString(): AnnotatedString {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
return remember(this, colorScheme.primary, colorScheme.surfaceVariant) {
|
||||
buildAnnotatedString {
|
||||
appendMarkdownInline(
|
||||
text = this@markdownInlineAnnotatedString,
|
||||
codeStyle = SpanStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
background = colorScheme.surfaceVariant.copy(alpha = 0.7f)
|
||||
),
|
||||
linkStyle = SpanStyle(
|
||||
color = colorScheme.primary,
|
||||
textDecoration = TextDecoration.Underline
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun AnnotatedString.Builder.appendMarkdownInline(
|
||||
text: String,
|
||||
codeStyle: SpanStyle,
|
||||
linkStyle: SpanStyle
|
||||
) {
|
||||
var index = 0
|
||||
while (index < text.length) {
|
||||
when {
|
||||
text.startsWith("`", index) -> {
|
||||
val end = text.indexOf('`', startIndex = index + 1)
|
||||
if (end > index) {
|
||||
withStyle(codeStyle) { append(text.substring(index + 1, end)) }
|
||||
index = end + 1
|
||||
} else {
|
||||
append(text[index])
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
|
||||
text.startsWith("**", index) -> {
|
||||
val end = text.indexOf("**", startIndex = index + 2)
|
||||
if (end > index) {
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||
appendMarkdownInline(text.substring(index + 2, end), codeStyle, linkStyle)
|
||||
}
|
||||
index = end + 2
|
||||
} else {
|
||||
append(text[index])
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
|
||||
text.startsWith("*", index) -> {
|
||||
val end = text.indexOf('*', startIndex = index + 1)
|
||||
if (end > index) {
|
||||
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
|
||||
appendMarkdownInline(text.substring(index + 1, end), codeStyle, linkStyle)
|
||||
}
|
||||
index = end + 1
|
||||
} else {
|
||||
append(text[index])
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
|
||||
text[index] == '[' -> {
|
||||
val labelEnd = text.indexOf("](", startIndex = index + 1)
|
||||
val urlEnd = if (labelEnd > index) text.indexOf(')', startIndex = labelEnd + 2) else -1
|
||||
if (labelEnd > index && urlEnd > labelEnd) {
|
||||
withStyle(linkStyle) {
|
||||
appendMarkdownInline(text.substring(index + 1, labelEnd), codeStyle, linkStyle)
|
||||
}
|
||||
index = urlEnd + 1
|
||||
} else {
|
||||
append(text[index])
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
append(text[index])
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,841 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Cloud
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.aryan.reader.shared.BookItem
|
||||
import com.aryan.reader.shared.opds.OpdsAcquisition
|
||||
import com.aryan.reader.shared.opds.OpdsCatalog
|
||||
import com.aryan.reader.shared.opds.OpdsEntry
|
||||
import com.aryan.reader.shared.opds.SharedOpdsDownloadState
|
||||
import com.aryan.reader.shared.opds.SharedOpdsScreenState
|
||||
import com.aryan.reader.shared.opds.SharedOpdsText
|
||||
|
||||
@Composable
|
||||
fun SharedOpdsScreen(
|
||||
state: SharedOpdsScreenState,
|
||||
localLibraryBooks: List<BookItem>,
|
||||
onOpenCatalog: (OpdsCatalog) -> Unit,
|
||||
onOpenFeedUrl: (String) -> Unit,
|
||||
onNavigateBack: () -> Unit,
|
||||
onSearch: (String) -> Unit,
|
||||
onLoadNextPage: () -> Unit,
|
||||
onAddCatalog: (String, String, String?, String?) -> Unit,
|
||||
onUpdateCatalog: (String, String, String, String?, String?) -> Unit,
|
||||
onRemoveCatalog: (OpdsCatalog) -> Unit,
|
||||
onDownloadBook: (OpdsEntry, OpdsAcquisition) -> Unit,
|
||||
onReadBook: (BookItem) -> Unit,
|
||||
onStreamBook: (OpdsEntry, OpdsCatalog?) -> Unit,
|
||||
onClearError: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var selectedEntry by remember { mutableStateOf<OpdsEntry?>(null) }
|
||||
var showCatalogDialog by remember { mutableStateOf(false) }
|
||||
var editingCatalog by remember { mutableStateOf<OpdsCatalog?>(null) }
|
||||
var catalogToDelete by remember { mutableStateOf<OpdsCatalog?>(null) }
|
||||
|
||||
Box(modifier.fillMaxSize()) {
|
||||
if (!state.isViewingCatalog) {
|
||||
SharedOpdsCatalogList(
|
||||
catalogs = state.catalogs,
|
||||
onOpenCatalog = onOpenCatalog,
|
||||
onEditCatalog = { catalog ->
|
||||
editingCatalog = catalog
|
||||
showCatalogDialog = true
|
||||
},
|
||||
onDeleteCatalog = { catalogToDelete = it },
|
||||
onAddCatalog = {
|
||||
editingCatalog = null
|
||||
showCatalogDialog = true
|
||||
}
|
||||
)
|
||||
} else {
|
||||
SharedOpdsFeedView(
|
||||
state = state,
|
||||
localLibraryBooks = localLibraryBooks,
|
||||
onNavigateBack = onNavigateBack,
|
||||
onSearch = onSearch,
|
||||
onOpenFeedUrl = onOpenFeedUrl,
|
||||
onLoadNextPage = onLoadNextPage,
|
||||
onDownloadBook = onDownloadBook,
|
||||
onReadBook = onReadBook,
|
||||
onStreamBook = { entry -> onStreamBook(entry, state.currentCatalog) },
|
||||
onEntrySelected = { selectedEntry = it }
|
||||
)
|
||||
}
|
||||
|
||||
state.errorMessage?.let { error ->
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = error,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
TextButton(onClick = onClearError) {
|
||||
Text("Dismiss")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showCatalogDialog) {
|
||||
SharedOpdsCatalogDialog(
|
||||
catalog = editingCatalog,
|
||||
onDismiss = {
|
||||
showCatalogDialog = false
|
||||
editingCatalog = null
|
||||
},
|
||||
onSave = { title, url, username, password ->
|
||||
val editing = editingCatalog
|
||||
if (editing == null) {
|
||||
onAddCatalog(title, url, username, password)
|
||||
} else {
|
||||
onUpdateCatalog(editing.id, title, url, username, password)
|
||||
}
|
||||
showCatalogDialog = false
|
||||
editingCatalog = null
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
catalogToDelete?.let { catalog ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { catalogToDelete = null },
|
||||
title = { Text("Delete catalog") },
|
||||
text = { Text("Delete \"${catalog.title}\"? Streamed books from this catalog may stop opening if credentials change later.") },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onRemoveCatalog(catalog)
|
||||
catalogToDelete = null
|
||||
}
|
||||
) {
|
||||
Text("Delete")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { catalogToDelete = null }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
selectedEntry?.let { entry ->
|
||||
SharedOpdsEntryDetailsDialog(
|
||||
entry = entry,
|
||||
localLibraryBook = entry.findLocalBook(localLibraryBooks),
|
||||
downloadState = state.downloadingState[entry.id],
|
||||
onDismiss = { selectedEntry = null },
|
||||
onDownloadBook = { acquisition -> onDownloadBook(entry, acquisition) },
|
||||
onReadBook = onReadBook,
|
||||
onStreamBook = {
|
||||
onStreamBook(entry, state.currentCatalog)
|
||||
selectedEntry = null
|
||||
},
|
||||
onOpenFeedUrl = { url ->
|
||||
onOpenFeedUrl(url)
|
||||
selectedEntry = null
|
||||
},
|
||||
onSearch = { query ->
|
||||
onSearch(query)
|
||||
selectedEntry = null
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedOpdsCatalogList(
|
||||
catalogs: List<OpdsCatalog>,
|
||||
onOpenCatalog: (OpdsCatalog) -> Unit,
|
||||
onEditCatalog: (OpdsCatalog) -> Unit,
|
||||
onDeleteCatalog: (OpdsCatalog) -> Unit,
|
||||
onAddCatalog: () -> Unit
|
||||
) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
SharedScreenScaffold(
|
||||
title = "OPDS",
|
||||
subtitle = "Browse catalogs, streams, and downloads",
|
||||
trailing = {
|
||||
Button(onClick = onAddCatalog) {
|
||||
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Catalog")
|
||||
}
|
||||
}
|
||||
) {
|
||||
if (catalogs.isEmpty()) {
|
||||
SharedOpdsEmptyState(onAddCatalog = onAddCatalog, modifier = Modifier.weight(1f))
|
||||
} else {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(320.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = 24.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
items(catalogs, key = { it.id }) { catalog ->
|
||||
SharedOpdsCatalogCard(
|
||||
catalog = catalog,
|
||||
onOpenCatalog = { onOpenCatalog(catalog) },
|
||||
onEditCatalog = { onEditCatalog(catalog) },
|
||||
onDeleteCatalog = { onDeleteCatalog(catalog) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedOpdsFeedView(
|
||||
state: SharedOpdsScreenState,
|
||||
localLibraryBooks: List<BookItem>,
|
||||
onNavigateBack: () -> Unit,
|
||||
onSearch: (String) -> Unit,
|
||||
onOpenFeedUrl: (String) -> Unit,
|
||||
onLoadNextPage: () -> Unit,
|
||||
onDownloadBook: (OpdsEntry, OpdsAcquisition) -> Unit,
|
||||
onReadBook: (BookItem) -> Unit,
|
||||
onStreamBook: (OpdsEntry) -> Unit,
|
||||
onEntrySelected: (OpdsEntry) -> Unit
|
||||
) {
|
||||
var showSearch by remember { mutableStateOf(false) }
|
||||
var query by remember { mutableStateOf("") }
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Surface(color = MaterialTheme.colorScheme.surface, tonalElevation = 2.dp) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(64.dp)
|
||||
.padding(horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = {
|
||||
if (showSearch) {
|
||||
showSearch = false
|
||||
query = ""
|
||||
} else {
|
||||
onNavigateBack()
|
||||
}
|
||||
}) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
if (showSearch) {
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
placeholder = { Text("Search catalog") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = {
|
||||
if (query.isNotBlank()) {
|
||||
onSearch(query)
|
||||
query = ""
|
||||
showSearch = false
|
||||
}
|
||||
}) {
|
||||
Icon(Icons.Default.Search, contentDescription = "Search")
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = state.currentFeed?.title ?: "Loading",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
state.currentCatalog?.title?.let { catalogTitle ->
|
||||
Text(
|
||||
catalogTitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
if (state.searchUrlTemplate != null) {
|
||||
IconButton(onClick = { showSearch = true }) {
|
||||
Icon(Icons.Default.Search, contentDescription = "Search")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state.isLoading) {
|
||||
LinearProgressIndicator(Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val facets = state.currentFeed?.facets.orEmpty()
|
||||
if (facets.isNotEmpty()) {
|
||||
LazyRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
facets.groupBy { it.group }.forEach { (groupName, groupFacets) ->
|
||||
item(key = groupName) {
|
||||
SharedOpdsFacetMenu(
|
||||
groupName = groupName,
|
||||
facets = groupFacets,
|
||||
onOpenFeedUrl = onOpenFeedUrl
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val entries = state.currentFeed?.entries.orEmpty()
|
||||
if (entries.isEmpty() && !state.isLoading) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("This feed is empty.")
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
itemsIndexed(entries, key = { index, entry -> "${entry.id}_$index" }) { index, entry ->
|
||||
val nextUrl = state.currentFeed?.nextUrl
|
||||
if (index == entries.lastIndex && nextUrl != null) {
|
||||
LaunchedEffect(index, nextUrl) {
|
||||
onLoadNextPage()
|
||||
}
|
||||
}
|
||||
if (entry.isNavigation) {
|
||||
SharedOpdsNavigationCard(entry, onOpenFeedUrl)
|
||||
} else {
|
||||
SharedOpdsBookCard(
|
||||
entry = entry,
|
||||
localLibraryBook = entry.findLocalBook(localLibraryBooks),
|
||||
downloadState = state.downloadingState[entry.id],
|
||||
onDownloadBook = { acquisition -> onDownloadBook(entry, acquisition) },
|
||||
onReadBook = onReadBook,
|
||||
onStreamBook = { onStreamBook(entry) },
|
||||
onClick = { onEntrySelected(entry) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedOpdsCatalogCard(
|
||||
catalog: OpdsCatalog,
|
||||
onOpenCatalog: () -> Unit,
|
||||
onEditCatalog: () -> Unit,
|
||||
onDeleteCatalog: () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
onClick = onOpenCatalog,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
) {
|
||||
Box(Modifier.size(46.dp), contentAlignment = Alignment.Center) {
|
||||
Icon(Icons.Default.Cloud, contentDescription = null)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(catalog.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
catalog.url,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (catalog.isDefault) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
shape = RoundedCornerShape(6.dp)
|
||||
) {
|
||||
Text(
|
||||
"Preset",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (!catalog.isDefault) {
|
||||
IconButton(onClick = onEditCatalog) {
|
||||
Icon(Icons.Default.Edit, contentDescription = "Edit")
|
||||
}
|
||||
IconButton(onClick = onDeleteCatalog) {
|
||||
Icon(Icons.Default.Delete, contentDescription = "Delete")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedOpdsEmptyState(onAddCatalog: () -> Unit, modifier: Modifier = Modifier) {
|
||||
Surface(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.45f))
|
||||
) {
|
||||
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Icon(Icons.Default.Cloud, contentDescription = null, modifier = Modifier.size(56.dp), tint = MaterialTheme.colorScheme.primary)
|
||||
Text("No catalogs", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
Text("Add an OPDS catalog to browse remote books.", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Button(onClick = onAddCatalog) {
|
||||
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Add catalog")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedOpdsFacetMenu(
|
||||
groupName: String,
|
||||
facets: List<com.aryan.reader.shared.opds.OpdsFacet>,
|
||||
onOpenFeedUrl: (String) -> Unit
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val activeFacet = facets.firstOrNull { it.isActive } ?: facets.firstOrNull()
|
||||
Box {
|
||||
FilterChip(
|
||||
selected = activeFacet?.isActive == true,
|
||||
onClick = { expanded = true },
|
||||
label = { Text("$groupName: ${activeFacet?.title ?: "Select"}") },
|
||||
trailingIcon = { Icon(Icons.Default.ArrowDropDown, contentDescription = null) }
|
||||
)
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
facets.forEach { facet ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(facet.title) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
onOpenFeedUrl(facet.url)
|
||||
},
|
||||
trailingIcon = if (facet.isActive) {
|
||||
{ Icon(Icons.Default.Check, contentDescription = null) }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedOpdsNavigationCard(entry: OpdsEntry, onOpenFeedUrl: (String) -> Unit) {
|
||||
Surface(
|
||||
onClick = { entry.navigationUrl?.let(onOpenFeedUrl) },
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Default.Folder, contentDescription = null, tint = MaterialTheme.colorScheme.secondary)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(entry.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
val summary = SharedOpdsText.cleanSummary(entry.summary)
|
||||
if (summary.isNotBlank()) {
|
||||
Text(
|
||||
summary,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedOpdsBookCard(
|
||||
entry: OpdsEntry,
|
||||
localLibraryBook: BookItem?,
|
||||
downloadState: SharedOpdsDownloadState?,
|
||||
onDownloadBook: (OpdsAcquisition) -> Unit,
|
||||
onReadBook: (BookItem) -> Unit,
|
||||
onStreamBook: () -> Unit,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val uniqueAcquisitions = remember(entry.acquisitions) {
|
||||
entry.acquisitions.distinctBy { it.formatName }.sortedByDescending { it.priority }
|
||||
}
|
||||
val isDownloading = downloadState?.isDownloading == true
|
||||
var showFormatMenu by remember { mutableStateOf(false) }
|
||||
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(modifier = Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(width = 70.dp, height = 100.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(entry.title.take(1).uppercase(), style = MaterialTheme.typography.headlineMedium)
|
||||
}
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(entry.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
entry.author?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
|
||||
}
|
||||
val summary = SharedOpdsText.cleanSummary(entry.summary)
|
||||
if (summary.isNotBlank()) {
|
||||
Text(summary, style = MaterialTheme.typography.bodySmall, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 4.dp))
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
when {
|
||||
localLibraryBook != null -> {
|
||||
OutlinedButton(onClick = { onReadBook(localLibraryBook) }, contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp)) {
|
||||
Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Read")
|
||||
}
|
||||
}
|
||||
isDownloading -> SharedOpdsDownloadProgress(downloadState)
|
||||
else -> Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (entry.isStreamable) {
|
||||
FilledTonalButton(onClick = onStreamBook, contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp)) {
|
||||
Icon(Icons.Default.Cloud, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Stream")
|
||||
}
|
||||
}
|
||||
Box {
|
||||
FilledTonalButton(
|
||||
onClick = {
|
||||
when (uniqueAcquisitions.size) {
|
||||
0 -> Unit
|
||||
1 -> onDownloadBook(uniqueAcquisitions.first())
|
||||
else -> showFormatMenu = true
|
||||
}
|
||||
},
|
||||
enabled = uniqueAcquisitions.isNotEmpty(),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp)
|
||||
) {
|
||||
Icon(
|
||||
if (uniqueAcquisitions.isEmpty()) Icons.Default.Info else Icons.Default.Download,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(if (uniqueAcquisitions.isEmpty()) "Unavailable" else "Download")
|
||||
}
|
||||
DropdownMenu(expanded = showFormatMenu, onDismissRequest = { showFormatMenu = false }) {
|
||||
uniqueAcquisitions.forEach { acquisition ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(acquisition.formatName) },
|
||||
onClick = {
|
||||
showFormatMenu = false
|
||||
onDownloadBook(acquisition)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedOpdsDownloadProgress(downloadState: SharedOpdsDownloadState?) {
|
||||
val progress = downloadState?.progress
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Downloading", style = MaterialTheme.typography.labelMedium)
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (progress != null) {
|
||||
Text("${(progress * 100).toInt()}%", style = MaterialTheme.typography.labelMedium)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
if (progress != null) {
|
||||
LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth())
|
||||
} else {
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedOpdsEntryDetailsDialog(
|
||||
entry: OpdsEntry,
|
||||
localLibraryBook: BookItem?,
|
||||
downloadState: SharedOpdsDownloadState?,
|
||||
onDismiss: () -> Unit,
|
||||
onDownloadBook: (OpdsAcquisition) -> Unit,
|
||||
onReadBook: (BookItem) -> Unit,
|
||||
onStreamBook: () -> Unit,
|
||||
onOpenFeedUrl: (String) -> Unit,
|
||||
onSearch: (String) -> Unit
|
||||
) {
|
||||
val uniqueAcquisitions = remember(entry.acquisitions) {
|
||||
entry.acquisitions.distinctBy { it.formatName }.sortedByDescending { it.priority }
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Column {
|
||||
Text(entry.title, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
entry.author?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
},
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.heightIn(max = 520.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
localLibraryBook?.let { book ->
|
||||
Button(onClick = { onReadBook(book) }, modifier = Modifier.fillMaxWidth()) {
|
||||
Icon(Icons.Default.Check, contentDescription = null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Read")
|
||||
}
|
||||
}
|
||||
if (downloadState?.isDownloading == true) {
|
||||
SharedOpdsDownloadProgress(downloadState)
|
||||
} else {
|
||||
if (entry.isStreamable) {
|
||||
Button(onClick = onStreamBook, modifier = Modifier.fillMaxWidth()) {
|
||||
Icon(Icons.Default.Cloud, contentDescription = null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Stream now")
|
||||
}
|
||||
}
|
||||
if (uniqueAcquisitions.isNotEmpty()) {
|
||||
Text("Download format", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
uniqueAcquisitions.take(4).forEach { acquisition ->
|
||||
FilledTonalButton(onClick = { onDownloadBook(acquisition) }) {
|
||||
Text(acquisition.formatName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
entry.series?.takeIf { it.isNotBlank() }?.let { series ->
|
||||
Text(
|
||||
text = if (entry.seriesIndex.isNullOrBlank()) series else "$series #${entry.seriesIndex}",
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
}
|
||||
if (entry.authors.isNotEmpty()) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text("Authors", style = MaterialTheme.typography.labelLarge)
|
||||
entry.authors.forEach { author ->
|
||||
TextButton(
|
||||
onClick = {
|
||||
if (author.url != null) onOpenFeedUrl(author.url) else onSearch(author.name)
|
||||
}
|
||||
) {
|
||||
Text(author.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (entry.categories.isNotEmpty()) {
|
||||
Text("Categories", style = MaterialTheme.typography.labelLarge)
|
||||
entry.categories.distinct().take(8).forEach { category ->
|
||||
TextButton(onClick = { onSearch(category) }) {
|
||||
Text(category)
|
||||
}
|
||||
}
|
||||
}
|
||||
val secondary = listOfNotNull(
|
||||
entry.publisher?.takeIf { it.isNotBlank() }?.let { "Publisher: $it" },
|
||||
entry.published?.takeIf { it.isNotBlank() }?.substringBefore("T")?.let { "Published: $it" },
|
||||
entry.language?.takeIf { it.isNotBlank() }?.uppercase()?.let { "Language: $it" }
|
||||
)
|
||||
secondary.forEach { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||
val summary = SharedOpdsText.cleanSummary(entry.summary)
|
||||
if (summary.isNotBlank()) {
|
||||
Text("Synopsis", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold)
|
||||
Text(summary, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Close")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedOpdsCatalogDialog(
|
||||
catalog: OpdsCatalog?,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (String, String, String?, String?) -> Unit
|
||||
) {
|
||||
var title by remember(catalog) { mutableStateOf(catalog?.title.orEmpty()) }
|
||||
var url by remember(catalog) { mutableStateOf(catalog?.url.orEmpty()) }
|
||||
var username by remember(catalog) { mutableStateOf(catalog?.username.orEmpty()) }
|
||||
var password by remember(catalog) { mutableStateOf(catalog?.password.orEmpty()) }
|
||||
val isEditMode = catalog != null
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(if (isEditMode) "Edit catalog" else "Add OPDS catalog") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(value = title, onValueChange = { title = it }, label = { Text("Catalog name") }, singleLine = true)
|
||||
OutlinedTextField(value = url, onValueChange = { url = it }, label = { Text("URL") }, singleLine = true)
|
||||
Text("Authentication optional", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary)
|
||||
OutlinedTextField(value = username, onValueChange = { username = it }, label = { Text("Username") }, singleLine = true)
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text("Password") },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation()
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { onSave(title, url, username, password) },
|
||||
enabled = title.isNotBlank() && url.isNotBlank()
|
||||
) {
|
||||
Text("Save")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun OpdsEntry.findLocalBook(localLibraryBooks: List<BookItem>): BookItem? {
|
||||
return localLibraryBooks.firstOrNull {
|
||||
it.title.equals(title, ignoreCase = true) || it.displayName.equals(title, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,261 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.isSpecified
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.aryan.reader.shared.pdf.SharedPdfRichTextController
|
||||
import com.aryan.reader.shared.pdf.SharedPdfRichTextLog
|
||||
import com.aryan.reader.shared.pdf.withoutTrailingSharedPdfPageBreak
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
fun SharedPdfRichTextHiddenInput(
|
||||
controller: SharedPdfRichTextController,
|
||||
enabled: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
LaunchedEffect(enabled, controller.activePageIndex) {
|
||||
SharedPdfRichTextLog.d(
|
||||
"ui.hiddenInput enabled=$enabled activePage=${controller.activePageIndex} " +
|
||||
"editingLen=${controller.editingValue.text.length} selection=${controller.editingValue.selection}"
|
||||
)
|
||||
if (enabled && controller.activePageIndex != -1) {
|
||||
controller.requestEditingFocus()
|
||||
delay(16)
|
||||
controller.requestEditingFocus()
|
||||
}
|
||||
}
|
||||
|
||||
if (!enabled) return
|
||||
|
||||
BasicTextField(
|
||||
value = controller.editingValue,
|
||||
onValueChange = controller::onValueChanged,
|
||||
textStyle = TextStyle(
|
||||
color = controller.currentStyle.color,
|
||||
fontSize = controller.currentStyle.fontSize,
|
||||
fontWeight = controller.currentStyle.fontWeight,
|
||||
fontStyle = controller.currentStyle.fontStyle,
|
||||
textDecoration = controller.currentStyle.textDecoration
|
||||
),
|
||||
modifier = modifier
|
||||
.size(1.dp)
|
||||
.alpha(0f)
|
||||
.clearAndSetSemantics { }
|
||||
.focusRequester(controller.focusRequester)
|
||||
.onKeyEvent { event ->
|
||||
event.type == KeyEventType.KeyDown &&
|
||||
event.key == Key.Backspace &&
|
||||
controller.handleBackspaceAtStart()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SharedPdfRichTextLayer(
|
||||
pageIndex: Int,
|
||||
controller: SharedPdfRichTextController,
|
||||
pageWidth: Float,
|
||||
pageHeight: Float,
|
||||
isTextEditingEnabled: Boolean,
|
||||
centeringOffsetX: Float = 0f,
|
||||
centeringOffsetY: Float = 0f,
|
||||
isDarkMode: Boolean = false,
|
||||
isScrolling: Boolean = false,
|
||||
onPageTapped: (Int) -> Unit = {}
|
||||
) {
|
||||
LaunchedEffect(pageIndex, pageWidth, pageHeight, isTextEditingEnabled) {
|
||||
if (pageWidth <= 0f || pageHeight <= 0f) {
|
||||
SharedPdfRichTextLog.d(
|
||||
"ui.layer invalidSize page=$pageIndex size=${pageWidth.richTextUiFloat()}x${pageHeight.richTextUiFloat()} " +
|
||||
"editing=$isTextEditingEnabled"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (pageWidth <= 0f || pageHeight <= 0f) return
|
||||
|
||||
val density = LocalDensity.current
|
||||
val textMeasurer = rememberTextMeasurer()
|
||||
|
||||
LaunchedEffect(pageWidth, pageHeight, density, textMeasurer) {
|
||||
controller.updateLayoutConfig(pageWidth, pageHeight, density, textMeasurer)
|
||||
}
|
||||
|
||||
val pageLayout = remember(controller.pageLayouts, pageIndex) {
|
||||
controller.pageLayouts.find { it.pageIndex == pageIndex }
|
||||
}
|
||||
|
||||
LaunchedEffect(
|
||||
pageIndex,
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
isTextEditingEnabled,
|
||||
controller.activePageIndex,
|
||||
pageLayout?.globalStartIndex,
|
||||
pageLayout?.globalEndIndex
|
||||
) {
|
||||
SharedPdfRichTextLog.d(
|
||||
"ui.layer page=$pageIndex size=${pageWidth.richTextUiFloat()}x${pageHeight.richTextUiFloat()} " +
|
||||
"editing=$isTextEditingEnabled activePage=${controller.activePageIndex} " +
|
||||
"layout=${pageLayout?.globalStartIndex}-${pageLayout?.globalEndIndex} " +
|
||||
"visibleLen=${pageLayout?.visibleText?.length ?: 0}"
|
||||
)
|
||||
}
|
||||
|
||||
val marginX = pageWidth * 0.1f
|
||||
val marginY = pageHeight * 0.08f
|
||||
val editorWidth = (pageWidth - (marginX * 2f)).coerceAtLeast(10f)
|
||||
val editorHeight = (pageHeight - (marginY * 2f)).coerceAtLeast(10f)
|
||||
val editorWidthDp = with(density) { editorWidth.toDp() }
|
||||
val editorHeightDp = with(density) { editorHeight.toDp() }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.offset {
|
||||
IntOffset(
|
||||
(centeringOffsetX + marginX).roundToInt(),
|
||||
(centeringOffsetY + marginY).roundToInt()
|
||||
)
|
||||
}
|
||||
.size(editorWidthDp, editorHeightDp)
|
||||
.graphicsLayer()
|
||||
.clipToBounds()
|
||||
.then(
|
||||
if (isTextEditingEnabled) {
|
||||
Modifier.pointerInput(
|
||||
pageIndex,
|
||||
editorWidth,
|
||||
editorHeight,
|
||||
controller.activePageIndex,
|
||||
pageLayout?.globalStartIndex,
|
||||
pageLayout?.globalEndIndex
|
||||
) {
|
||||
detectTapGestures { tapOffset ->
|
||||
SharedPdfRichTextLog.d(
|
||||
"ui.layer.tap page=$pageIndex offset=${tapOffset.richTextUiOffsetSummary()} " +
|
||||
"editor=${editorWidth.richTextUiFloat()}x${editorHeight.richTextUiFloat()} " +
|
||||
"activePage=${controller.activePageIndex} hasLayout=${pageLayout != null}"
|
||||
)
|
||||
onPageTapped(pageIndex)
|
||||
controller.handleTapOnPage(pageIndex, tapOffset)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
) {
|
||||
val textToRender = if (controller.activePageIndex == pageIndex) {
|
||||
controller.localTextFieldValue.annotatedString
|
||||
} else {
|
||||
pageLayout?.visibleText?.withoutTrailingSharedPdfPageBreak()
|
||||
} ?: return@Box
|
||||
|
||||
val measureResult = remember(textToRender, editorWidth, density) {
|
||||
textMeasurer.measure(
|
||||
text = textToRender,
|
||||
style = TextStyle(fontSize = 16.sp),
|
||||
constraints = Constraints(maxWidth = editorWidth.toInt()),
|
||||
density = density
|
||||
)
|
||||
}
|
||||
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
measureResult.multiParagraph.paint(drawContext.canvas)
|
||||
}
|
||||
|
||||
if (isTextEditingEnabled && controller.activePageIndex == pageIndex) {
|
||||
val selection = controller.editingValue.selection
|
||||
val localStart = selection.start.coerceIn(0, textToRender.length)
|
||||
val localEnd = selection.end.coerceIn(0, textToRender.length)
|
||||
|
||||
if (localStart != localEnd) {
|
||||
val selectionPath = measureResult.getPathForRange(localStart, localEnd)
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
drawPath(selectionPath, Color(0xFFB3D7FF).copy(alpha = 0.5f))
|
||||
}
|
||||
}
|
||||
|
||||
if (selection.collapsed && controller.isCursorVisible) {
|
||||
val alpha = if (isScrolling) {
|
||||
1f
|
||||
} else {
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "pdfRichCursor")
|
||||
infiniteTransition.animateFloat(
|
||||
initialValue = 1f,
|
||||
targetValue = 0f,
|
||||
animationSpec = infiniteRepeatable(tween(500), RepeatMode.Reverse),
|
||||
label = "pdfRichCursorAlpha"
|
||||
).value
|
||||
}
|
||||
val cursorRect = measureResult.getCursorRect(localStart)
|
||||
val styleFontSize = controller.currentStyle.fontSize
|
||||
val cursorHeight = if (styleFontSize.isSpecified) {
|
||||
with(density) { styleFontSize.toPx() } * 1.2f
|
||||
} else {
|
||||
cursorRect.height
|
||||
}
|
||||
val centerY = cursorRect.center.y
|
||||
val cursorColor = if (isDarkMode) Color.White else Color.Black
|
||||
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
drawLine(
|
||||
color = cursorColor.copy(alpha = alpha),
|
||||
start = Offset(cursorRect.left, centerY - cursorHeight / 2f),
|
||||
end = Offset(cursorRect.left, centerY + cursorHeight / 2f),
|
||||
strokeWidth = 2.dp.toPx()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Float.richTextUiFloat(): String {
|
||||
return if (isFinite()) {
|
||||
val rounded = kotlin.math.round(this * 10f) / 10f
|
||||
rounded.toString()
|
||||
} else {
|
||||
toString()
|
||||
}
|
||||
}
|
||||
|
||||
private fun Offset.richTextUiOffsetSummary(): String {
|
||||
return "(${x.richTextUiFloat()},${y.richTextUiFloat()})"
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,600 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.CloudDownload
|
||||
import androidx.compose.material.icons.filled.Code
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Email
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.Feedback
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.OpenInNew
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.TextFields
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedCard
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.aryan.reader.shared.CustomFontItem
|
||||
|
||||
@Composable
|
||||
fun SharedCustomFontsScreen(
|
||||
fonts: List<CustomFontItem>,
|
||||
onImportFont: () -> Unit,
|
||||
onDeleteFont: (CustomFontItem) -> Unit,
|
||||
googleFontsAvailable: Boolean = false,
|
||||
getGoogleFonts: () -> List<String> = { emptyList() },
|
||||
onDownloadGoogleFont: (String, () -> Unit) -> Unit = { _, onComplete -> onComplete() },
|
||||
fontFamilyForPreview: (CustomFontItem) -> FontFamily? = { null },
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var fontPendingDelete by remember { mutableStateOf<CustomFontItem?>(null) }
|
||||
var showGoogleFontsDialog by remember { mutableStateOf(false) }
|
||||
|
||||
SharedScreenScaffold(
|
||||
title = "Custom Fonts",
|
||||
subtitle = "Imported fonts for the reader",
|
||||
modifier = modifier,
|
||||
trailing = {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (googleFontsAvailable) {
|
||||
Button(onClick = { showGoogleFontsDialog = true }) {
|
||||
Icon(Icons.Default.CloudDownload, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Google Fonts")
|
||||
}
|
||||
}
|
||||
Button(onClick = onImportFont) {
|
||||
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Import")
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
val activeFonts = fonts.filterNot { it.isDeleted }.sortedBy { it.displayName.lowercase() }
|
||||
if (activeFonts.isEmpty()) {
|
||||
SharedUtilityEmptyState(
|
||||
icon = { Icon(Icons.Default.TextFields, contentDescription = null, modifier = Modifier.size(56.dp)) },
|
||||
title = "No custom fonts",
|
||||
body = "Import TTF, OTF, or WOFF2 files to use them in books.",
|
||||
actionLabel = "Import font",
|
||||
onAction = onImportFont,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
contentPadding = PaddingValues(bottom = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
items(activeFonts, key = { it.id }) { font ->
|
||||
SharedFontListItem(
|
||||
font = font,
|
||||
onDelete = { fontPendingDelete = font },
|
||||
fontFamilyForPreview = fontFamilyForPreview
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (googleFontsAvailable && showGoogleFontsDialog) {
|
||||
SharedGoogleFontsDialog(
|
||||
existingFonts = fonts,
|
||||
getGoogleFonts = getGoogleFonts,
|
||||
onDownloadGoogleFont = onDownloadGoogleFont,
|
||||
onDismiss = { showGoogleFontsDialog = false }
|
||||
)
|
||||
}
|
||||
|
||||
fontPendingDelete?.let { font ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { fontPendingDelete = null },
|
||||
title = { Text("Delete font?") },
|
||||
text = { Text("Delete ${font.displayName}? Books using it will fall back to the default font.") },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onDeleteFont(font)
|
||||
fontPendingDelete = null
|
||||
}
|
||||
) {
|
||||
Text("Delete", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { fontPendingDelete = null }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedGoogleFontsDialog(
|
||||
existingFonts: List<CustomFontItem>,
|
||||
getGoogleFonts: () -> List<String>,
|
||||
onDownloadGoogleFont: (String, () -> Unit) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
var searchQuery by remember { mutableStateOf("") }
|
||||
var downloadingFontName by remember { mutableStateOf<String?>(null) }
|
||||
val popularPresets = remember {
|
||||
listOf(
|
||||
"Merriweather",
|
||||
"Open Sans",
|
||||
"Playfair Display",
|
||||
"Montserrat",
|
||||
"Oswald",
|
||||
"Raleway",
|
||||
"Nunito",
|
||||
"Poppins",
|
||||
"Ubuntu",
|
||||
"Fira Sans",
|
||||
"Quicksand",
|
||||
"Crimson Text",
|
||||
"Literata",
|
||||
"EB Garamond",
|
||||
"Libre Baskerville",
|
||||
"Inter",
|
||||
"Work Sans"
|
||||
)
|
||||
}
|
||||
val displayList = remember(searchQuery) {
|
||||
if (searchQuery.isBlank()) {
|
||||
popularPresets
|
||||
} else {
|
||||
getGoogleFonts()
|
||||
.filter { it.contains(searchQuery, ignoreCase = true) }
|
||||
.take(50)
|
||||
}
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Text("Browse Google Fonts", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
},
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = searchQuery,
|
||||
onValueChange = { searchQuery = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = { Text("Search 1900+ fonts...") },
|
||||
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
)
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp),
|
||||
contentPadding = PaddingValues(bottom = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
if (searchQuery.isBlank()) {
|
||||
item {
|
||||
Text(
|
||||
text = "Popular choices",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
} else if (displayList.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = "No fonts found matching '$searchQuery'",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
items(displayList, key = { it }) { fontName ->
|
||||
val isDownloaded = existingFonts.any { it.displayName.equals(fontName, ignoreCase = true) }
|
||||
val isDownloading = downloadingFontName == fontName
|
||||
fun startDownload() {
|
||||
downloadingFontName = fontName
|
||||
onDownloadGoogleFont(fontName) {
|
||||
if (downloadingFontName == fontName) {
|
||||
downloadingFontName = null
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
if (isDownloaded) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f)
|
||||
else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
|
||||
RoundedCornerShape(8.dp)
|
||||
)
|
||||
.clickable(enabled = !isDownloaded && !isDownloading) { startDownload() }
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = fontName,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = if (isDownloaded) FontWeight.Bold else FontWeight.Medium,
|
||||
color = if (isDownloaded) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier.padding(start = 12.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
when {
|
||||
isDownloaded -> Icon(Icons.Default.Check, contentDescription = "Already downloaded", tint = MaterialTheme.colorScheme.primary)
|
||||
isDownloading -> CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||
else -> Icon(Icons.Default.CloudDownload, contentDescription = "Download")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Close")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedFontListItem(
|
||||
font: CustomFontItem,
|
||||
onDelete: () -> Unit,
|
||||
fontFamilyForPreview: (CustomFontItem) -> FontFamily?
|
||||
) {
|
||||
val previewFontFamily = remember(font.path) { fontFamilyForPreview(font) }
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f))
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
) {
|
||||
Box(Modifier.size(42.dp), contentAlignment = Alignment.Center) {
|
||||
Text("Aa", fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = font.displayName,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = font.path,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
Surface(
|
||||
shape = RoundedCornerShape(50),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
) {
|
||||
Text(
|
||||
text = font.fileExtension.uppercase(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onDelete, modifier = Modifier.size(40.dp)) {
|
||||
Icon(Icons.Default.Delete, contentDescription = "Delete font", tint = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f), RoundedCornerShape(8.dp))
|
||||
.padding(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Grumpy wizards make toxic brew for the evil queen! 1234567890 ?.,;:",
|
||||
style = MaterialTheme.typography.bodyLarge.copy(fontSize = 18.sp),
|
||||
fontFamily = previewFontFamily,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SharedHelpFeedbackScreen(
|
||||
onOpenGitHubIssues: () -> Unit,
|
||||
onEmailSupport: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
SharedScreenScaffold(
|
||||
title = "Help & Feedback",
|
||||
subtitle = "Bug reports, feature requests, and support",
|
||||
modifier = modifier
|
||||
) {
|
||||
SharedUtilityHeader(
|
||||
icon = { Icon(Icons.Default.Feedback, contentDescription = null, modifier = Modifier.size(52.dp)) },
|
||||
title = "Get in touch",
|
||||
body = "Report bugs, request features, or contact support directly."
|
||||
)
|
||||
SharedUtilityOptionCard(
|
||||
title = "GitHub Issues",
|
||||
body = "Report bugs, request features, and track development progress.",
|
||||
icon = { Icon(Icons.Default.Code, contentDescription = null, modifier = Modifier.size(28.dp)) },
|
||||
onClick = onOpenGitHubIssues
|
||||
)
|
||||
SharedUtilityOptionCard(
|
||||
title = "Email Support",
|
||||
body = "Contact us directly by email for anything else.",
|
||||
icon = { Icon(Icons.Default.Email, contentDescription = null, modifier = Modifier.size(28.dp)) },
|
||||
onClick = onEmailSupport
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SharedSupportProjectScreen(
|
||||
onOpenGitHubSponsors: () -> Unit,
|
||||
onOpenPatreon: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
SharedScreenScaffold(
|
||||
title = "Support Project",
|
||||
subtitle = "Ways to support Episteme development",
|
||||
modifier = modifier
|
||||
) {
|
||||
SharedUtilityHeader(
|
||||
icon = { Icon(Icons.Default.Favorite, contentDescription = null, modifier = Modifier.size(52.dp)) },
|
||||
title = "Support Episteme",
|
||||
body = "Contributions help keep the reader improving across Android and desktop."
|
||||
)
|
||||
SharedUtilityOptionCard(
|
||||
title = "GitHub Sponsors",
|
||||
body = "Support development through GitHub Sponsors.",
|
||||
icon = { Icon(Icons.Default.Code, contentDescription = null, modifier = Modifier.size(28.dp)) },
|
||||
onClick = onOpenGitHubSponsors
|
||||
)
|
||||
SharedUtilityOptionCard(
|
||||
title = "Patreon",
|
||||
body = "Support the project on Patreon.",
|
||||
icon = { Icon(Icons.Default.Favorite, contentDescription = null, modifier = Modifier.size(28.dp)) },
|
||||
onClick = onOpenPatreon
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SharedAboutScreen(
|
||||
versionName: String,
|
||||
buildLabel: String,
|
||||
onOpenSource: () -> Unit,
|
||||
onOpenIssues: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
SharedScreenScaffold(
|
||||
title = "About Episteme",
|
||||
subtitle = "Desktop reader",
|
||||
modifier = modifier
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(20.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
) {
|
||||
Box(Modifier.size(52.dp), contentAlignment = Alignment.Center) {
|
||||
Icon(Icons.Default.Info, contentDescription = null)
|
||||
}
|
||||
}
|
||||
Column {
|
||||
Text("Episteme", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
||||
Text(versionName, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(buildLabel, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
SharedUtilityOptionCard(
|
||||
title = "Source Code",
|
||||
body = "Browse the project source on GitHub.",
|
||||
icon = { Icon(Icons.Default.Code, contentDescription = null, modifier = Modifier.size(28.dp)) },
|
||||
onClick = onOpenSource
|
||||
)
|
||||
SharedUtilityOptionCard(
|
||||
title = "Issues",
|
||||
body = "Open the issue tracker for bugs and feature requests.",
|
||||
icon = { Icon(Icons.Default.Feedback, contentDescription = null, modifier = Modifier.size(28.dp)) },
|
||||
onClick = onOpenIssues
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedUtilityHeader(
|
||||
icon: @Composable () -> Unit,
|
||||
title: String,
|
||||
body: String
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(18.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
) {
|
||||
Box(Modifier.size(58.dp), contentAlignment = Alignment.Center) {
|
||||
icon()
|
||||
}
|
||||
}
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
body,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedUtilityOptionCard(
|
||||
title: String,
|
||||
body: String,
|
||||
icon: @Composable () -> Unit,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
OutlinedCard(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(18.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
) {
|
||||
Box(Modifier.size(46.dp), contentAlignment = Alignment.Center) {
|
||||
icon()
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
Text(body, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowForward, contentDescription = "Open")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedUtilityEmptyState(
|
||||
icon: @Composable () -> Unit,
|
||||
title: String,
|
||||
body: String,
|
||||
actionLabel: String,
|
||||
onAction: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f))
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Surface(shape = RoundedCornerShape(18.dp), color = MaterialTheme.colorScheme.surfaceVariant) {
|
||||
Box(Modifier.padding(18.dp), contentAlignment = Alignment.Center) {
|
||||
icon()
|
||||
}
|
||||
}
|
||||
Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center)
|
||||
Text(
|
||||
body,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(0.7f)
|
||||
)
|
||||
TextButton(onClick = onAction) {
|
||||
Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(actionLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class EpubAnnotationSerializerTest {
|
||||
|
||||
@Test
|
||||
fun `highlights json round trips and tolerates legacy missing ids`() {
|
||||
val highlights = listOf(
|
||||
UserHighlight(
|
||||
id = "highlight-1",
|
||||
cfi = "epubcfi(/6/2!/4/2)",
|
||||
text = "A marked sentence",
|
||||
color = HighlightColor.BLUE,
|
||||
chapterIndex = 2,
|
||||
note = "Important",
|
||||
locator = ReaderLocator(
|
||||
chapterIndex = 2,
|
||||
chapterId = "chapter-2",
|
||||
pageIndex = 5,
|
||||
startOffset = 120,
|
||||
endOffset = 137,
|
||||
textQuote = "A marked sentence",
|
||||
cfi = "epubcfi(/6/2!/4/2)"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val decoded = EpubAnnotationSerializer.parseHighlightsJson(
|
||||
EpubAnnotationSerializer.highlightsToJson(highlights)
|
||||
)
|
||||
val legacyDecoded = EpubAnnotationSerializer.parseHighlightsJson(
|
||||
"""[{"cfi":"legacy","text":"Legacy mark","colorId":"missing","chapterIndex":1,"note":""}]"""
|
||||
)
|
||||
|
||||
assertEquals(highlights, decoded)
|
||||
assertEquals(HighlightColor.YELLOW, legacyDecoded.single().color)
|
||||
assertEquals(null, legacyDecoded.single().note)
|
||||
assertEquals(1, legacyDecoded.single().locator.chapterIndex)
|
||||
assertEquals("legacy", legacyDecoded.single().locator.cfi)
|
||||
assertTrue(legacyDecoded.single().id.startsWith("highlight_"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bookmarks json supports stored string entries and object arrays`() {
|
||||
val bookmark = EpubBookmark(
|
||||
cfi = "epubcfi(/6/4!/4/8)",
|
||||
chapterTitle = "Two",
|
||||
label = "Saved place",
|
||||
snippet = "A useful bookmark",
|
||||
pageInChapter = 3,
|
||||
totalPagesInChapter = 9,
|
||||
chapterIndex = 1,
|
||||
locator = ReaderLocator(
|
||||
chapterIndex = 1,
|
||||
pageIndex = 2,
|
||||
startOffset = 80,
|
||||
endOffset = 110,
|
||||
textQuote = "A useful bookmark",
|
||||
cfi = "epubcfi(/6/4!/4/8)"
|
||||
)
|
||||
)
|
||||
|
||||
val decoded = EpubAnnotationSerializer.parseBookmarksJson(
|
||||
EpubAnnotationSerializer.bookmarksToJson(listOf(bookmark)),
|
||||
chapterTitles = listOf("One", "Two")
|
||||
)
|
||||
val objectDecoded = EpubAnnotationSerializer.parseBookmarksJson(
|
||||
"""[{"cfi":"cfi","chapterTitle":"Two","snippet":"By title"}]""",
|
||||
chapterTitles = listOf("One", "Two")
|
||||
)
|
||||
|
||||
assertEquals(setOf(bookmark), decoded)
|
||||
assertEquals(1, objectDecoded.single().chapterIndex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `processAndAddHighlight updates exact matches and appends new highlights`() {
|
||||
val highlights = mutableListOf<UserHighlight>()
|
||||
val cfi = EpubAnnotationSerializer.processAndAddHighlight(
|
||||
newCfi = "same-cfi",
|
||||
newText = "First",
|
||||
newColor = HighlightColor.YELLOW,
|
||||
chapterIndex = 0,
|
||||
currentList = highlights
|
||||
)
|
||||
val initialId = highlights.single().id
|
||||
|
||||
EpubAnnotationSerializer.processAndAddHighlight(
|
||||
newCfi = "same-cfi",
|
||||
newText = "Updated",
|
||||
newColor = HighlightColor.GREEN,
|
||||
chapterIndex = 0,
|
||||
currentList = highlights
|
||||
)
|
||||
EpubAnnotationSerializer.processAndAddHighlight(
|
||||
newCfi = "other-cfi",
|
||||
newText = "Other",
|
||||
newColor = HighlightColor.BLUE,
|
||||
chapterIndex = 0,
|
||||
currentList = highlights
|
||||
)
|
||||
|
||||
assertEquals("same-cfi", cfi)
|
||||
assertEquals(2, highlights.size)
|
||||
assertEquals(initialId, highlights.first().id)
|
||||
assertEquals("Updated", highlights.first().text)
|
||||
assertEquals(HighlightColor.GREEN, highlights.first().color)
|
||||
assertNotEquals(initialId, highlights.last().id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `processAndAddHighlight matches shared locator ranges when cfi changes`() {
|
||||
val highlights = mutableListOf<UserHighlight>()
|
||||
val locator = ReaderLocator(
|
||||
chapterIndex = 0,
|
||||
pageIndex = 3,
|
||||
startOffset = 42,
|
||||
endOffset = 58,
|
||||
textQuote = "Stable quote",
|
||||
cfi = "desktop:0:42:58"
|
||||
)
|
||||
|
||||
EpubAnnotationSerializer.processAndAddHighlight(
|
||||
newCfi = "desktop:0:42:58",
|
||||
newText = "Stable quote",
|
||||
newColor = HighlightColor.YELLOW,
|
||||
chapterIndex = 0,
|
||||
currentList = highlights,
|
||||
locator = locator
|
||||
)
|
||||
val initialId = highlights.single().id
|
||||
|
||||
EpubAnnotationSerializer.processAndAddHighlight(
|
||||
newCfi = "changed-cfi",
|
||||
newText = "Stable quote updated",
|
||||
newColor = HighlightColor.BLUE,
|
||||
chapterIndex = 0,
|
||||
currentList = highlights,
|
||||
locator = locator.copy(cfi = "changed-cfi", textQuote = "Stable quote updated")
|
||||
)
|
||||
|
||||
assertEquals(1, highlights.size)
|
||||
assertEquals(initialId, highlights.single().id)
|
||||
assertEquals(HighlightColor.BLUE, highlights.single().color)
|
||||
assertEquals(42, highlights.single().locator.startOffset)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `highlight bridge parser accepts raw or wrapped json payloads`() {
|
||||
val payload = """{"cfi":"desktop:0:4:9","text":"word","colorId":"yellow","chapterIndex":0,"locator":{"chapterIndex":0,"startOffset":4,"endOffset":9,"textQuote":"word","cfi":"desktop:0:4:9"}}"""
|
||||
val wrappedPayload = "\"${payload.replace("\"", "\\\"")}\""
|
||||
|
||||
assertEquals(4, EpubAnnotationSerializer.parseHighlightJsonLenient(payload)?.locator?.startOffset)
|
||||
assertEquals(9, EpubAnnotationSerializer.parseHighlightJsonLenient(wrappedPayload)?.locator?.endOffset)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `legacy desktop cfi values hydrate shared locators`() {
|
||||
val oldDesktopLocator = ReaderLocator.fromLegacy(cfi = "desktop:2:7:123456:abc")
|
||||
val timestampFallbackLocator = ReaderLocator.fromLegacy(cfi = "desktop:2:7:1780000000000")
|
||||
val rangedDesktopLocator = ReaderLocator.fromLegacy(cfi = "desktop:2:40:55")
|
||||
|
||||
assertEquals(2, oldDesktopLocator.chapterIndex)
|
||||
assertEquals(7, oldDesktopLocator.pageIndex)
|
||||
assertEquals(7, timestampFallbackLocator.pageIndex)
|
||||
assertEquals(null, timestampFallbackLocator.startOffset)
|
||||
assertEquals(null, timestampFallbackLocator.endOffset)
|
||||
assertEquals(2, rangedDesktopLocator.chapterIndex)
|
||||
assertEquals(40, rangedDesktopLocator.startOffset)
|
||||
assertEquals(55, rangedDesktopLocator.endOffset)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class FileCapabilitiesTest {
|
||||
|
||||
@Test
|
||||
fun `shared file capabilities expose Android and desktop readable formats`() {
|
||||
assertEquals(
|
||||
PDF_VIEWER_FILE_TYPES + EPUB_READER_FILE_TYPES,
|
||||
SharedFileCapabilities.readableTypesFor(ReaderPlatform.ANDROID)
|
||||
)
|
||||
assertEquals(
|
||||
setOf(
|
||||
FileType.EPUB,
|
||||
FileType.PDF,
|
||||
FileType.TXT,
|
||||
FileType.MD,
|
||||
FileType.HTML,
|
||||
FileType.MOBI,
|
||||
FileType.FB2,
|
||||
FileType.CBZ,
|
||||
FileType.CBR,
|
||||
FileType.CB7,
|
||||
FileType.DOCX,
|
||||
FileType.ODT,
|
||||
FileType.FODT
|
||||
),
|
||||
SharedFileCapabilities.readableTypesFor(ReaderPlatform.DESKTOP)
|
||||
)
|
||||
assertEquals(
|
||||
SharedFileCapabilities.readableTypesFor(ReaderPlatform.DESKTOP),
|
||||
SharedFileCapabilities.syncableTypesFor(ReaderPlatform.DESKTOP)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shared file capabilities map reader surfaces per platform`() {
|
||||
assertEquals(
|
||||
ReaderFeatureSurface.PDF_VIEWER,
|
||||
SharedFileCapabilities.surfaceFor(FileType.PDF, ReaderPlatform.DESKTOP)
|
||||
)
|
||||
assertEquals(
|
||||
ReaderFeatureSurface.TEXT_READER,
|
||||
SharedFileCapabilities.surfaceFor(FileType.MD, ReaderPlatform.DESKTOP)
|
||||
)
|
||||
assertEquals(
|
||||
ReaderFeatureSurface.TEXT_READER,
|
||||
SharedFileCapabilities.surfaceFor(FileType.DOCX, ReaderPlatform.DESKTOP)
|
||||
)
|
||||
assertEquals(
|
||||
ReaderFeatureSurface.PDF_VIEWER,
|
||||
SharedFileCapabilities.surfaceFor(FileType.CBR, ReaderPlatform.DESKTOP)
|
||||
)
|
||||
assertEquals(
|
||||
ReaderFeatureSurface.EPUB_READER,
|
||||
SharedFileCapabilities.surfaceFor(FileType.MD, ReaderPlatform.ANDROID)
|
||||
)
|
||||
assertTrue(SharedFileCapabilities.canOpen(FileType.CBZ, ReaderPlatform.ANDROID))
|
||||
assertTrue(SharedFileCapabilities.canOpen(FileType.CBZ, ReaderPlatform.DESKTOP))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shared file type resolver recognizes aliases used by desktop imports`() {
|
||||
assertEquals(FileType.MD, SharedFileCapabilities.fileTypeForName("notes.markdown"))
|
||||
assertEquals(FileType.HTML, SharedFileCapabilities.fileTypeForName("chapter.xhtml"))
|
||||
assertEquals(FileType.HTML, "chapter.xhtml".toFileType())
|
||||
assertEquals(FileType.MOBI, SharedFileCapabilities.fileTypeForName("book.azw3"))
|
||||
assertEquals(FileType.UNKNOWN, SharedFileCapabilities.fileTypeForName("archive.zip"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop parity gaps list Android readable formats not yet available on desktop`() {
|
||||
assertEquals(emptyList(), SharedFileCapabilities.desktopParityGaps())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,285 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class LocalFolderSyncEngineTest {
|
||||
@Test
|
||||
fun `stable ids match android folder-relative scheme`() {
|
||||
assertEquals(
|
||||
"local_Book.pdf",
|
||||
LocalFolderSyncEngine.buildStableBookId("Book.pdf", "Book.pdf")
|
||||
)
|
||||
assertEquals(
|
||||
"local_Book.pdf_488206341973",
|
||||
LocalFolderSyncEngine.buildStableBookId("Book.pdf", "Series/Book.pdf")
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync imports scanned folder books with remote metadata`() {
|
||||
val state = SharedReaderScreenState()
|
||||
val folder = syncedFolder()
|
||||
val result = LocalFolderSyncEngine.syncFolder(
|
||||
state = state,
|
||||
folder = folder,
|
||||
files = listOf(scannedFile("Book.pdf", "Book.pdf")),
|
||||
remoteMetadata = mapOf(
|
||||
"local_Book.pdf" to metadata(
|
||||
id = "local_Book.pdf",
|
||||
title = "Remote Title",
|
||||
lastPage = 4,
|
||||
progress = 25f,
|
||||
modified = 2_000L
|
||||
)
|
||||
),
|
||||
nowMillis = 3_000L
|
||||
)
|
||||
|
||||
val book = result.state.rawLibraryBooks.single()
|
||||
assertEquals("local_Book.pdf", book.id)
|
||||
assertEquals("Remote Title", book.title)
|
||||
assertEquals(4, book.lastPageIndex)
|
||||
assertEquals(25f, book.progressPercentage)
|
||||
assertEquals("C:/Library", book.sourceFolder)
|
||||
assertEquals(1, result.stats.newBooks)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `newer remote metadata updates existing folder book`() {
|
||||
val existing = book(
|
||||
id = "local_Book.pdf",
|
||||
timestamp = 100L,
|
||||
title = "Local",
|
||||
progress = 10f
|
||||
)
|
||||
val result = LocalFolderSyncEngine.syncFolder(
|
||||
state = SharedReaderScreenState(rawLibraryBooks = listOf(existing)),
|
||||
folder = syncedFolder(),
|
||||
files = listOf(scannedFile("Book.pdf", "Book.pdf")),
|
||||
remoteMetadata = mapOf(
|
||||
"local_Book.pdf" to metadata(
|
||||
id = "local_Book.pdf",
|
||||
title = "Remote",
|
||||
progress = 80f,
|
||||
modified = 500L
|
||||
)
|
||||
),
|
||||
nowMillis = 1_000L
|
||||
)
|
||||
|
||||
val book = result.state.rawLibraryBooks.single()
|
||||
assertEquals("Remote", book.title)
|
||||
assertEquals(80f, book.progressPercentage)
|
||||
assertEquals(1, result.stats.remoteMetadataUpdates)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `older remote metadata does not clobber local book state`() {
|
||||
val existing = book(
|
||||
id = "local_Book.pdf",
|
||||
timestamp = 500L,
|
||||
title = "Local",
|
||||
progress = 60f
|
||||
)
|
||||
val result = LocalFolderSyncEngine.syncFolder(
|
||||
state = SharedReaderScreenState(rawLibraryBooks = listOf(existing)),
|
||||
folder = syncedFolder(),
|
||||
files = listOf(scannedFile("Book.pdf", "Book.pdf")),
|
||||
remoteMetadata = mapOf(
|
||||
"local_Book.pdf" to metadata(
|
||||
id = "local_Book.pdf",
|
||||
title = "Remote",
|
||||
progress = 5f,
|
||||
modified = 100L
|
||||
)
|
||||
),
|
||||
nowMillis = 1_000L
|
||||
)
|
||||
|
||||
val book = result.state.rawLibraryBooks.single()
|
||||
assertEquals("Local", book.title)
|
||||
assertEquals(60f, book.progressPercentage)
|
||||
assertEquals(0, result.stats.remoteMetadataUpdates)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync migrates desktop path ids and preserves references`() {
|
||||
val oldId = "C:/Library/Series/Book.pdf"
|
||||
val state = SharedReaderScreenState(
|
||||
rawLibraryBooks = listOf(
|
||||
book(
|
||||
id = oldId,
|
||||
path = oldId,
|
||||
displayName = "Book.pdf",
|
||||
sourceFolder = "C:/Library"
|
||||
)
|
||||
),
|
||||
selectedBookIds = setOf(oldId),
|
||||
pinnedHomeBookIds = setOf(oldId),
|
||||
openTabIds = listOf(oldId),
|
||||
activeTabBookId = oldId
|
||||
)
|
||||
|
||||
val result = LocalFolderSyncEngine.syncFolder(
|
||||
state = state,
|
||||
folder = syncedFolder(),
|
||||
files = listOf(scannedFile("Book.pdf", "Series/Book.pdf")),
|
||||
remoteMetadata = emptyMap(),
|
||||
nowMillis = 1_000L
|
||||
)
|
||||
val newId = "local_Book.pdf_488206341973"
|
||||
|
||||
assertEquals(newId, result.state.rawLibraryBooks.single().id)
|
||||
assertEquals(setOf(newId), result.state.selectedBookIds)
|
||||
assertEquals(setOf(newId), result.state.pinnedHomeBookIds)
|
||||
assertEquals(listOf(newId), result.state.openTabIds)
|
||||
assertEquals(newId, result.state.activeTabBookId)
|
||||
assertEquals(mapOf(oldId to newId), result.idMigrations)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync removes missing books from linked folder only`() {
|
||||
val missing = book(id = "local_Missing.pdf", path = "C:/Library/Missing.pdf")
|
||||
val keptExternal = book(
|
||||
id = "external",
|
||||
path = "C:/Other/External.pdf",
|
||||
sourceFolder = "C:/Other"
|
||||
)
|
||||
val result = LocalFolderSyncEngine.syncFolder(
|
||||
state = SharedReaderScreenState(
|
||||
rawLibraryBooks = listOf(missing, keptExternal),
|
||||
selectedBookIds = setOf(missing.id),
|
||||
pinnedHomeBookIds = setOf(missing.id),
|
||||
openTabIds = listOf(missing.id),
|
||||
activeTabBookId = missing.id
|
||||
),
|
||||
folder = syncedFolder(),
|
||||
files = listOf(scannedFile("Book.pdf", "Book.pdf")),
|
||||
remoteMetadata = emptyMap(),
|
||||
nowMillis = 1_000L
|
||||
)
|
||||
|
||||
assertNull(result.state.rawLibraryBooks.firstOrNull { it.id == "local_Missing.pdf" })
|
||||
assertTrue(result.state.rawLibraryBooks.any { it.id == "external" })
|
||||
assertTrue(result.state.selectedBookIds.isEmpty())
|
||||
assertTrue(result.state.openTabIds.isEmpty())
|
||||
assertNull(result.state.activeTabBookId)
|
||||
assertEquals(setOf("local_Missing.pdf"), result.removedBookIds)
|
||||
assertEquals(1, result.stats.removedBooks)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `metadata sidecar is skipped for clean unread folder books`() {
|
||||
assertNull(book(id = "local_Book.pdf", isRecent = false, progress = null).toSharedFolderBookMetadata())
|
||||
assertNotNull(book(id = "local_Book.pdf", isRecent = true).toSharedFolderBookMetadata())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync resets extracted metadata and cover when folder file size changes`() {
|
||||
val existing = book(
|
||||
id = "local_Book.pdf",
|
||||
fileSize = 123L,
|
||||
coverImagePath = "C:/Covers/book.png",
|
||||
folderTextMetadataParsed = true
|
||||
)
|
||||
val result = LocalFolderSyncEngine.syncFolder(
|
||||
state = SharedReaderScreenState(rawLibraryBooks = listOf(existing)),
|
||||
folder = syncedFolder(),
|
||||
files = listOf(scannedFile("Book.pdf", "Book.pdf", size = 456L)),
|
||||
remoteMetadata = emptyMap(),
|
||||
nowMillis = 1_000L
|
||||
)
|
||||
|
||||
val book = result.state.rawLibraryBooks.single()
|
||||
assertEquals(456L, book.fileSize)
|
||||
assertNull(book.coverImagePath)
|
||||
assertFalse(book.folderTextMetadataParsed)
|
||||
assertEquals(1, result.stats.updatedBooks)
|
||||
}
|
||||
|
||||
private fun syncedFolder(): SyncedFolder {
|
||||
return SyncedFolder(
|
||||
uriString = "C:/Library",
|
||||
name = "Library",
|
||||
lastScanTime = 0L,
|
||||
allowedFileTypes = setOf(FileType.PDF, FileType.EPUB)
|
||||
)
|
||||
}
|
||||
|
||||
private fun scannedFile(
|
||||
name: String,
|
||||
relativePath: String,
|
||||
size: Long = 123L
|
||||
): SharedFolderScannedFile {
|
||||
return SharedFolderScannedFile(
|
||||
name = name,
|
||||
path = "C:/Library/$relativePath",
|
||||
sourceFolder = "C:/Library",
|
||||
relativePath = relativePath,
|
||||
type = FileType.PDF,
|
||||
size = size,
|
||||
lastModified = 100L
|
||||
)
|
||||
}
|
||||
|
||||
private fun book(
|
||||
id: String,
|
||||
path: String = "C:/Library/Book.pdf",
|
||||
displayName: String = "Book.pdf",
|
||||
sourceFolder: String = "C:/Library",
|
||||
timestamp: Long = 100L,
|
||||
title: String = "Book",
|
||||
progress: Float? = null,
|
||||
isRecent: Boolean = false,
|
||||
fileSize: Long = 0L,
|
||||
coverImagePath: String? = null,
|
||||
folderTextMetadataParsed: Boolean = false
|
||||
): BookItem {
|
||||
return BookItem(
|
||||
id = id,
|
||||
path = path,
|
||||
type = FileType.PDF,
|
||||
displayName = displayName,
|
||||
timestamp = timestamp,
|
||||
coverImagePath = coverImagePath,
|
||||
title = title,
|
||||
progressPercentage = progress,
|
||||
fileSize = fileSize,
|
||||
sourceFolder = sourceFolder,
|
||||
isRecent = isRecent,
|
||||
folderTextMetadataParsed = folderTextMetadataParsed
|
||||
)
|
||||
}
|
||||
|
||||
private fun metadata(
|
||||
id: String,
|
||||
title: String = "Book",
|
||||
lastPage: Int? = null,
|
||||
progress: Float = 0f,
|
||||
modified: Long
|
||||
): SharedFolderBookMetadata {
|
||||
return SharedFolderBookMetadata(
|
||||
bookId = id,
|
||||
title = title,
|
||||
author = null,
|
||||
displayName = "Book.pdf",
|
||||
type = FileType.PDF.name,
|
||||
lastChapterIndex = null,
|
||||
lastPage = lastPage,
|
||||
lastPositionCfi = null,
|
||||
progressPercentage = progress,
|
||||
isRecent = true,
|
||||
lastModifiedTimestamp = modified,
|
||||
bookmarksJson = null,
|
||||
locatorBlockIndex = null,
|
||||
locatorCharOffset = null,
|
||||
customName = null,
|
||||
highlightsJson = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,327 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.aryan.reader.shared.reader.ReaderEngine
|
||||
import com.aryan.reader.shared.reader.ReaderReadingMode
|
||||
import com.aryan.reader.shared.reader.ReaderSearchOptions
|
||||
import com.aryan.reader.shared.reader.ReaderSettings
|
||||
import com.aryan.reader.shared.reader.SharedEpubBook
|
||||
import com.aryan.reader.shared.reader.SharedEpubChapter
|
||||
import com.aryan.reader.shared.reader.SharedReaderTextAlign
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ReaderActionReducerTest {
|
||||
|
||||
@Test
|
||||
fun `reader actions navigate search and toggle bookmarks through shared reducer`() {
|
||||
val engine = ReaderEngine()
|
||||
val session = engine.createSession(longBook(), settings = compactSettings())
|
||||
assertTrue(session.reader.pages.size > 2)
|
||||
|
||||
val pageTwo = session.reduce(ReaderAction.NextPage, engine)
|
||||
assertEquals(1, pageTwo.reader.currentPageIndex)
|
||||
|
||||
val previous = pageTwo.reduce(ReaderAction.PreviousPage, engine)
|
||||
assertEquals(0, previous.reader.currentPageIndex)
|
||||
|
||||
val pageByNumber = previous.reduce(ReaderAction.GoToPageNumber(2), engine)
|
||||
assertEquals(1, pageByNumber.reader.currentPageIndex)
|
||||
|
||||
val lastPage = previous.reduce(ReaderAction.GoToProgress(1f), engine)
|
||||
assertEquals(lastPage.reader.pages.lastIndex, lastPage.reader.currentPageIndex)
|
||||
|
||||
val chapterTwo = lastPage.reduce(ReaderAction.GoToChapter(1), engine)
|
||||
assertEquals(1, chapterTwo.reader.currentPage?.chapterIndex)
|
||||
|
||||
val searched = chapterTwo.reduce(ReaderAction.SearchChanged("needle"), engine)
|
||||
assertTrue(searched.searchResults.size >= 2)
|
||||
assertTrue(searched.activeSearchResultIndex >= 0)
|
||||
|
||||
val nextSearch = searched.reduce(ReaderAction.NextSearchResult, engine)
|
||||
assertEquals(searched.activeSearchResultIndex + 1, nextSearch.activeSearchResultIndex)
|
||||
|
||||
val directSearch = searched.reduce(ReaderAction.GoToSearchResult(0), engine)
|
||||
assertEquals(0, directSearch.activeSearchResultIndex)
|
||||
|
||||
val bookmarked = directSearch.reduce(ReaderAction.ToggleBookmark, engine)
|
||||
assertEquals(listOf(directSearch.reader.currentPageIndex), bookmarked.bookmarks.map { it.pageIndex })
|
||||
|
||||
val unbookmarked = bookmarked.reduce(ReaderAction.ToggleBookmark, engine)
|
||||
assertTrue(unbookmarked.bookmarks.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search options and search chrome state are owned by shared reducer`() {
|
||||
val engine = ReaderEngine()
|
||||
val session = engine.createSession(
|
||||
book = SharedEpubBook(
|
||||
id = "search",
|
||||
fileName = "search.epub",
|
||||
title = "Search",
|
||||
chapters = listOf(
|
||||
SharedEpubChapter(
|
||||
id = "one",
|
||||
title = "One",
|
||||
plainText = "Alpha alphabet alpha ALPHA"
|
||||
)
|
||||
)
|
||||
),
|
||||
settings = compactSettings()
|
||||
)
|
||||
|
||||
val opened = session.reduce(ReaderAction.SearchOpened, engine)
|
||||
val caseSensitive = opened
|
||||
.reduce(ReaderAction.SearchOptionsChanged(ReaderSearchOptions(matchCase = true)), engine)
|
||||
.reduce(ReaderAction.SearchChanged("alpha"), engine)
|
||||
val wholeWords = caseSensitive
|
||||
.reduce(
|
||||
ReaderAction.SearchOptionsChanged(
|
||||
ReaderSearchOptions(matchCase = true, wholeWords = true)
|
||||
),
|
||||
engine
|
||||
)
|
||||
val hiddenPanel = wholeWords.reduce(ReaderAction.SearchResultsPanelToggled, engine)
|
||||
val closed = hiddenPanel.reduce(ReaderAction.SearchClosed, engine)
|
||||
|
||||
assertTrue(opened.isSearchActive)
|
||||
assertEquals(2, caseSensitive.searchResults.size)
|
||||
assertEquals(1, wholeWords.searchResults.size)
|
||||
assertEquals(false, hiddenPanel.showSearchResultsPanel)
|
||||
assertEquals("", closed.searchQuery)
|
||||
assertTrue(closed.searchResults.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search navigation resumes from page position after page slider moves off a match`() {
|
||||
val engine = ReaderEngine()
|
||||
val book = SharedEpubBook(
|
||||
id = "spaced-search",
|
||||
fileName = "spaced.epub",
|
||||
title = "Spaced",
|
||||
chapters = listOf(
|
||||
SharedEpubChapter(
|
||||
id = "one",
|
||||
title = "One",
|
||||
plainText = buildString {
|
||||
append("needle\n\n")
|
||||
repeat(320) { index ->
|
||||
append("Paragraph ")
|
||||
append(index)
|
||||
append(" contains filler words for pagination only.\n\n")
|
||||
}
|
||||
append("final needle")
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
val session = engine.createSession(book, settings = compactSettings())
|
||||
val searched = session.reduce(ReaderAction.SearchChanged("needle"), engine)
|
||||
val middlePage = searched.reader.pages.indices.first { pageIndex ->
|
||||
searched.searchResults.none { result -> result.pageIndex == pageIndex }
|
||||
}
|
||||
|
||||
val moved = searched.reduce(ReaderAction.GoToPage(middlePage), engine)
|
||||
val next = moved.reduce(ReaderAction.NextSearchResult, engine)
|
||||
val previous = moved.reduce(ReaderAction.PreviousSearchResult, engine)
|
||||
|
||||
assertEquals(2, searched.searchResults.size)
|
||||
assertEquals(-1, moved.activeSearchResultIndex)
|
||||
assertTrue(moved.canGoToPreviousSearchResult)
|
||||
assertTrue(moved.canGoToNextSearchResult)
|
||||
assertEquals(1, next.activeSearchResultIndex)
|
||||
assertEquals(0, previous.activeSearchResultIndex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `settings theme and render actions update shared reader settings`() {
|
||||
val engine = ReaderEngine()
|
||||
val session = engine.createSession(longBook(), settings = compactSettings())
|
||||
|
||||
val settings = session.reader.settings.copy(fontSize = 24, pageWidth = 900, textAlign = SharedReaderTextAlign.CENTER)
|
||||
val changed = session.reduce(ReaderAction.SettingsChanged(settings), engine)
|
||||
assertEquals(24, changed.reader.settings.fontSize)
|
||||
assertEquals(900, changed.reader.settings.pageWidth)
|
||||
assertEquals(SharedReaderTextAlign.CENTER, changed.reader.settings.textAlign)
|
||||
|
||||
val vertical = changed.reduce(ReaderAction.RenderModeChanged(RenderMode.VERTICAL_SCROLL), engine)
|
||||
assertEquals(ReaderReadingMode.VERTICAL, vertical.reader.settings.readingMode)
|
||||
|
||||
val dark = vertical.reduce(
|
||||
ReaderAction.ThemeChanged(
|
||||
ReaderTheme(
|
||||
id = "dark",
|
||||
name = "Dark",
|
||||
backgroundColor = Color.Black,
|
||||
textColor = Color.White,
|
||||
isDark = true
|
||||
)
|
||||
),
|
||||
engine
|
||||
)
|
||||
assertTrue(dark.reader.settings.darkMode)
|
||||
assertEquals(-16777216L, dark.reader.settings.backgroundColorArgb)
|
||||
assertEquals(-1L, dark.reader.settings.textColorArgb)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `annotation actions use shared locators for navigation and edits`() {
|
||||
val engine = ReaderEngine()
|
||||
val session = engine.createSession(longBook(), settings = compactSettings())
|
||||
.reduce(ReaderAction.GoToPage(1), engine)
|
||||
val page = session.reader.currentPage ?: error("Expected current page")
|
||||
val locator = ReaderLocator(
|
||||
chapterIndex = page.chapterIndex,
|
||||
pageIndex = page.pageIndex,
|
||||
startOffset = page.startOffset + 4,
|
||||
endOffset = page.startOffset + 18,
|
||||
textQuote = "shared locator",
|
||||
cfi = "desktop:${page.chapterIndex}:${page.startOffset + 4}:${page.startOffset + 18}"
|
||||
)
|
||||
|
||||
val highlighted = session.reduce(
|
||||
ReaderAction.HighlightCreated(
|
||||
UserHighlight(
|
||||
id = "highlight-1",
|
||||
cfi = locator.cfi ?: "desktop",
|
||||
text = "shared locator",
|
||||
color = HighlightColor.YELLOW,
|
||||
chapterIndex = page.chapterIndex,
|
||||
locator = locator
|
||||
)
|
||||
),
|
||||
engine
|
||||
)
|
||||
val noted = highlighted.reduce(ReaderAction.HighlightUpdated("highlight-1", note = "Keep this"), engine)
|
||||
val recolored = noted.reduce(ReaderAction.HighlightUpdated("highlight-1", color = HighlightColor.GREEN), engine)
|
||||
val jumped = session.reduce(ReaderAction.GoToLocator(locator), engine)
|
||||
val deleted = recolored.reduce(ReaderAction.HighlightDeleted("highlight-1"), engine)
|
||||
|
||||
assertEquals(locator.startOffset, highlighted.highlights.single().locator.startOffset)
|
||||
assertEquals("Keep this", recolored.highlights.single().note)
|
||||
assertEquals(HighlightColor.GREEN, recolored.highlights.single().color)
|
||||
assertEquals(page.pageIndex, jumped.reader.currentPageIndex)
|
||||
assertEquals(locator.startOffset, jumped.navigationLocator?.startOffset)
|
||||
assertEquals(locator.endOffset, jumped.navigationLocator?.endOffset)
|
||||
assertTrue(deleted.highlights.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reader navigation stores locator for vertical scroll targets`() {
|
||||
val engine = ReaderEngine()
|
||||
val session = engine.createSession(longBook(), settings = compactSettings())
|
||||
val secondPage = session.reduce(ReaderAction.GoToPage(1), engine)
|
||||
val secondChapter = secondPage.reduce(ReaderAction.GoToChapter(1), engine)
|
||||
val search = secondChapter.reduce(ReaderAction.SearchChanged("needle"), engine)
|
||||
val searchTarget = search.searchResults.first()
|
||||
val jumpedToSearch = search.reduce(ReaderAction.GoToSearchResult(0), engine)
|
||||
|
||||
assertEquals(secondPage.reader.currentPage?.startOffset, secondPage.navigationLocator?.startOffset)
|
||||
assertEquals(1, secondChapter.navigationLocator?.chapterIndex)
|
||||
assertEquals(searchTarget.locator.startOffset, jumpedToSearch.navigationLocator?.startOffset)
|
||||
assertEquals(searchTarget.locator.endOffset, jumpedToSearch.navigationLocator?.endOffset)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `visible page sync updates slider position without creating navigation request`() {
|
||||
val engine = ReaderEngine()
|
||||
val session = engine.createSession(longBook(), settings = compactSettings())
|
||||
val navigated = session.reduce(ReaderAction.GoToPage(1), engine)
|
||||
val requestId = navigated.navigationRequestId
|
||||
val synced = navigated.reduce(ReaderAction.VisiblePageChanged(3), engine)
|
||||
|
||||
assertEquals(3, synced.reader.currentPageIndex)
|
||||
assertEquals(requestId, synced.navigationRequestId)
|
||||
assertEquals(navigated.navigationLocator, synced.navigationLocator)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `visible locator sync feeds top visible bookmark location`() {
|
||||
val engine = ReaderEngine()
|
||||
val session = engine.createSession(longBook(), settings = compactSettings())
|
||||
val page = session.reader.pages[1]
|
||||
val locator = ReaderLocator(
|
||||
chapterIndex = page.chapterIndex,
|
||||
pageIndex = page.pageIndex,
|
||||
startOffset = page.startOffset + 25,
|
||||
endOffset = page.startOffset + 25,
|
||||
textQuote = "top visible text",
|
||||
cfi = "desktop:${page.chapterIndex}:${page.startOffset + 25}:${page.startOffset + 25}"
|
||||
)
|
||||
|
||||
val synced = session.reduce(ReaderAction.VisiblePageChanged(page.pageIndex, locator), engine)
|
||||
val bookmarked = synced.reduce(ReaderAction.ToggleBookmark, engine)
|
||||
|
||||
assertEquals(locator.startOffset, synced.navigationLocator?.startOffset)
|
||||
assertEquals(locator.startOffset, bookmarked.bookmarks.single().locator.startOffset)
|
||||
assertEquals("top visible text", bookmarked.bookmarks.single().preview)
|
||||
assertTrue(bookmarked.reduce(ReaderAction.ToggleBookmark, engine).bookmarks.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `format action maps Android style reader appearance to shared reader settings`() {
|
||||
val engine = ReaderEngine()
|
||||
val session = engine.createSession(
|
||||
book = longBook(),
|
||||
settings = compactSettings().copy(darkMode = true, readingMode = ReaderReadingMode.VERTICAL, pageWidth = 812)
|
||||
)
|
||||
|
||||
val updated = session.reduce(
|
||||
ReaderAction.FormatChanged(
|
||||
FormatSettings(
|
||||
fontSize = 1.5f,
|
||||
lineHeight = 1.2f,
|
||||
paragraphGap = 0.8f,
|
||||
imageSize = 1.3f,
|
||||
horizontalMargin = 0.5f,
|
||||
verticalMargin = 2.0f,
|
||||
font = ReaderFont.ROBOTO_MONO,
|
||||
customPath = null,
|
||||
textAlign = ReaderTextAlign.JUSTIFY
|
||||
)
|
||||
),
|
||||
engine
|
||||
)
|
||||
|
||||
assertEquals(27, updated.reader.settings.fontSize)
|
||||
assertEquals(1.74f, updated.reader.settings.lineSpacing, 0.0001f)
|
||||
assertEquals(96, updated.reader.settings.margin)
|
||||
assertEquals(24, updated.reader.settings.resolvedHorizontalMargin)
|
||||
assertEquals(96, updated.reader.settings.resolvedVerticalMargin)
|
||||
assertEquals(0.8f, updated.reader.settings.paragraphSpacing, 0.0001f)
|
||||
assertEquals(1.3f, updated.reader.settings.imageScale, 0.0001f)
|
||||
assertEquals("Mono", updated.reader.settings.fontFamily)
|
||||
assertEquals(SharedReaderTextAlign.JUSTIFY, updated.reader.settings.textAlign)
|
||||
assertTrue(updated.reader.settings.darkMode)
|
||||
assertEquals(ReaderReadingMode.VERTICAL, updated.reader.settings.readingMode)
|
||||
assertEquals(812, updated.reader.settings.pageWidth)
|
||||
}
|
||||
|
||||
private fun compactSettings(): ReaderSettings {
|
||||
return ReaderSettings(fontSize = 14, margin = 16, lineSpacing = 1.1f, pageWidth = 560)
|
||||
}
|
||||
|
||||
private fun longBook(): SharedEpubBook {
|
||||
val repeated = List(240) { index ->
|
||||
"Paragraph $index gives the paginator enough text to create several pages with a needle hidden inside."
|
||||
}.joinToString("\n\n")
|
||||
return SharedEpubBook(
|
||||
id = "long",
|
||||
fileName = "long.epub",
|
||||
title = "Long",
|
||||
chapters = listOf(
|
||||
SharedEpubChapter(
|
||||
id = "one",
|
||||
title = "One",
|
||||
plainText = repeated
|
||||
),
|
||||
SharedEpubChapter(
|
||||
id = "two",
|
||||
title = "Two",
|
||||
plainText = "Second chapter starts here. Another needle appears for search navigation. $repeated"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ReaderAppearanceModelsTest {
|
||||
|
||||
@Test
|
||||
fun `pdf built in themes include android pdf defaults and textured presets`() {
|
||||
assertEquals("no_theme", BuiltInPdfReaderThemes.first().id)
|
||||
assertNotNull(BuiltInPdfReaderThemes.firstOrNull { it.id == "reverse" })
|
||||
|
||||
val texturedThemeIds = BuiltInPdfReaderThemes
|
||||
.filter { it.textureId != null }
|
||||
.mapTo(mutableSetOf()) { it.id }
|
||||
|
||||
assertEquals(
|
||||
setOf(
|
||||
"pdf_natural_white_texture",
|
||||
"pdf_retina_texture",
|
||||
"pdf_veneer_texture",
|
||||
"pdf_grey_wash_texture",
|
||||
"pdf_fabric_texture",
|
||||
"pdf_retro_texture"
|
||||
),
|
||||
texturedThemeIds
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reader textures expose shared desktop resource paths`() {
|
||||
assertTrue(ReaderTexture.entries.all { it.assetPath.startsWith("textures/") })
|
||||
assertEquals("textures/ep_naturalwhite.webp", ReaderTexture.NATURAL_WHITE.assetPath)
|
||||
assertEquals("textures/texture_paper.png", ReaderTexture.PAPER.assetPath)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `file texture display names use imported file names`() {
|
||||
assertEquals("custom-paper", readerTextureDisplayName("${ReaderTextureFilePrefix}C:\\textures\\custom-paper.png"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pdf textured theme maps into reader settings`() {
|
||||
val theme = BuiltInPdfReaderThemes.first { it.id == "pdf_fabric_texture" }
|
||||
val settings = theme.toReaderSettings()
|
||||
|
||||
assertEquals("pdf_fabric_texture", settings.themeId)
|
||||
assertEquals(ReaderTexture.CLASSY_FABRIC.id, settings.textureId)
|
||||
assertTrue(settings.darkMode)
|
||||
assertEquals(theme.backgroundColor.toArgb().toLong(), settings.backgroundColorArgb)
|
||||
assertEquals(theme.textColor.toArgb().toLong(), settings.textColorArgb)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,287 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
import com.aryan.reader.paginatedreader.CssStyle
|
||||
import com.aryan.reader.paginatedreader.SemanticParagraph
|
||||
import com.aryan.reader.shared.reader.ReaderEngine
|
||||
import com.aryan.reader.shared.reader.PaginatedReaderState
|
||||
import com.aryan.reader.shared.reader.ReaderPage
|
||||
import com.aryan.reader.shared.reader.ReaderReadingMode
|
||||
import com.aryan.reader.shared.reader.ReaderSessionState
|
||||
import com.aryan.reader.shared.reader.ReaderSettings
|
||||
import com.aryan.reader.shared.reader.SharedEpubBook
|
||||
import com.aryan.reader.shared.reader.SharedEpubChapter
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ReaderExtrasModelsTest {
|
||||
|
||||
@Test
|
||||
fun `reader ai settings require BYO key and selected model`() {
|
||||
val missingModel = ReaderByokTextRequests.build(
|
||||
settings = ReaderAiByokSettings(groqKey = "gsk_test"),
|
||||
feature = ReaderAiFeature.DEFINE,
|
||||
text = "epistemic"
|
||||
)
|
||||
|
||||
assertIs<ReaderByokTextRequestResult.MissingModel>(missingModel)
|
||||
|
||||
val missingKey = ReaderByokTextRequests.build(
|
||||
settings = ReaderAiByokSettings(modelForAll = "groq:qwen/qwen3-32b"),
|
||||
feature = ReaderAiFeature.DEFINE,
|
||||
text = "epistemic"
|
||||
)
|
||||
|
||||
assertIs<ReaderByokTextRequestResult.MissingKey>(missingKey)
|
||||
|
||||
val ready = ReaderByokTextRequests.build(
|
||||
settings = ReaderAiByokSettings(
|
||||
groqKey = "gsk_test",
|
||||
modelForAll = "groq:qwen/qwen3-32b"
|
||||
),
|
||||
feature = ReaderAiFeature.DEFINE,
|
||||
text = "epistemic"
|
||||
)
|
||||
|
||||
assertIs<ReaderByokTextRequestResult.Ready>(ready)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cloud tts is available only with gemini key and cloud tts model`() {
|
||||
assertFalse(ReaderAiByokSettings(geminiKey = "key").isCloudTtsAvailable)
|
||||
assertFalse(ReaderAiByokSettings(ttsModel = GEMINI_CLOUD_TTS_MODEL_ID).isCloudTtsAvailable)
|
||||
|
||||
assertTrue(
|
||||
ReaderAiByokSettings(
|
||||
geminiKey = "key",
|
||||
ttsModel = GEMINI_CLOUD_TTS_MODEL_ID
|
||||
).isCloudTtsAvailable
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shared cloud tts voices mirror android voice catalog`() {
|
||||
assertEquals("Aoede", DEFAULT_CLOUD_TTS_SPEAKER_ID)
|
||||
assertTrue(ReaderCloudTtsVoices.size >= 30)
|
||||
assertEquals(ReaderCloudTtsVoices.map { it.id }, ReaderCloudTtsSpeakers)
|
||||
assertEquals("Breezy, Middle pitch", readerCloudTtsVoiceById("Aoede")?.description)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shared cloud tts chunking keeps android sentence behavior`() {
|
||||
val chunks = splitReaderTextIntoTtsChunks(
|
||||
"First sentence. Second sentence? Third sentence!",
|
||||
maxLength = 32
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("First sentence. Second sentence?", "Third sentence!"),
|
||||
chunks
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shared cloud tts cache summary formats current voice label`() {
|
||||
val empty = ReaderTtsCacheSummary()
|
||||
val populated = ReaderTtsCacheSummary(
|
||||
cachedChapterCount = 2,
|
||||
cachedChunkCount = 3,
|
||||
currentVoiceChunkCount = 2,
|
||||
totalSizeBytes = 4096,
|
||||
currentVoiceSizeBytes = 2048
|
||||
)
|
||||
|
||||
assertEquals("No cached chunks for this voice", empty.currentVoiceLabel)
|
||||
assertEquals("2 chunks, 2.0 KB", populated.currentVoiceLabel)
|
||||
assertFalse(empty.hasCurrentVoiceCachedAudio)
|
||||
assertTrue(populated.hasCurrentVoiceCachedAudio)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hidden reader ai follows android availability logic`() {
|
||||
val visible = ReaderAiByokSettings(
|
||||
groqKey = "gsk_test",
|
||||
modelForAll = "groq:qwen/qwen3-32b"
|
||||
)
|
||||
val hidden = visible.copy(hideReaderAiFeatures = true)
|
||||
|
||||
assertTrue(visible.areReaderAiFeaturesAvailable)
|
||||
assertFalse(hidden.areReaderAiFeaturesAvailable)
|
||||
assertIs<ReaderByokTextRequestResult.Hidden>(
|
||||
ReaderByokTextRequests.build(hidden, ReaderAiFeature.DEFINE, "epistemic")
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `chapter summary context follows current chapter in pagination and vertical modes`() {
|
||||
val book = SharedEpubBook(
|
||||
id = "context",
|
||||
fileName = "context.epub",
|
||||
title = "Context",
|
||||
chapters = listOf(
|
||||
SharedEpubChapter("one", "One", "First chapter text"),
|
||||
SharedEpubChapter("two", "Two", "Second chapter text")
|
||||
)
|
||||
)
|
||||
val engine = ReaderEngine()
|
||||
val paginated = engine.createSession(book)
|
||||
.reduce(ReaderAction.GoToChapter(1), engine)
|
||||
val vertical = engine.createSession(book, settings = ReaderSettings(readingMode = ReaderReadingMode.VERTICAL))
|
||||
.reduce(ReaderAction.GoToChapter(1), engine)
|
||||
|
||||
assertEquals("Second chapter text", ReaderContextExtractor.currentChapterText(paginated))
|
||||
assertEquals("Second chapter text", ReaderContextExtractor.currentChapterText(vertical))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tts planner follows android sentence chunking`() {
|
||||
val sentenceOne = "First " + "word ".repeat(20).trim() + "."
|
||||
val sentenceTwo = "Second " + "word ".repeat(20).trim() + "!"
|
||||
val sentenceThree = "Third " + "word ".repeat(20).trim() + "?"
|
||||
val text = listOf(sentenceOne, sentenceTwo, sentenceThree).joinToString(" ")
|
||||
val chunks = ReaderTtsPlanner.chunksForText(
|
||||
text = text,
|
||||
pageIndex = 4,
|
||||
chapterIndex = 2,
|
||||
chapterTitle = "Offsets",
|
||||
sourceStartOffset = 12
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
"$sentenceOne $sentenceTwo",
|
||||
sentenceThree
|
||||
),
|
||||
chunks.map { it.text }
|
||||
)
|
||||
assertTrue(chunks.all { it.text.length <= READER_TTS_CHUNK_MAX_LENGTH })
|
||||
assertEquals(chunks.indices.toList(), chunks.map { it.index })
|
||||
assertEquals(12, chunks.first().startOffset)
|
||||
assertEquals(12 + text.trimEnd().length, chunks.last().endOffset)
|
||||
assertTrue(chunks.all { it.pageIndex == 4 && it.chapterIndex == 2 })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tts planner keeps android long sentence behavior`() {
|
||||
val text = "word ".repeat(80).trim()
|
||||
val chunks = ReaderTtsPlanner.chunksForText(
|
||||
text = text,
|
||||
pageIndex = 4,
|
||||
chapterIndex = 2,
|
||||
chapterTitle = "Offsets"
|
||||
)
|
||||
|
||||
assertEquals(listOf(text), chunks.map { it.text })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tts planner can read page chapter or onward from current location`() {
|
||||
val book = SharedEpubBook(
|
||||
id = "tts",
|
||||
fileName = "tts.epub",
|
||||
title = "TTS",
|
||||
chapters = listOf(
|
||||
SharedEpubChapter("one", "One", "First page text."),
|
||||
SharedEpubChapter("two", "Two", "Second page text.")
|
||||
)
|
||||
)
|
||||
val session = ReaderEngine().createSession(book)
|
||||
|
||||
assertEquals(listOf(0), ReaderTtsPlanner.chunksForCurrentPage(session).map { it.chapterIndex }.distinct())
|
||||
assertEquals(listOf(0), ReaderTtsPlanner.chunksForCurrentChapter(session).map { it.chapterIndex }.distinct())
|
||||
assertEquals(listOf(0, 1), ReaderTtsPlanner.chunksFromCurrentLocation(session).map { it.chapterIndex }.distinct())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tts planner maps trimmed page text back to source offsets`() {
|
||||
val source = "Intro.\n\n Leading words continue."
|
||||
val book = SharedEpubBook(
|
||||
id = "tts-offsets",
|
||||
fileName = "tts-offsets.epub",
|
||||
title = "TTS offsets",
|
||||
chapters = listOf(SharedEpubChapter("one", "One", source))
|
||||
)
|
||||
val page = ReaderPage(
|
||||
pageIndex = 0,
|
||||
chapterIndex = 0,
|
||||
chapterTitle = "One",
|
||||
text = "Leading words continue.",
|
||||
startOffset = 8,
|
||||
endOffset = source.length
|
||||
)
|
||||
val session = ReaderSessionState(
|
||||
reader = PaginatedReaderState(
|
||||
book = book,
|
||||
pages = listOf(page),
|
||||
currentPageIndex = 0
|
||||
)
|
||||
)
|
||||
|
||||
val chunk = ReaderTtsPlanner.chunksForCurrentPage(session).first()
|
||||
|
||||
assertEquals(source.indexOf("Leading"), chunk.startOffset)
|
||||
assertEquals("Leading words continue.", source.substring(chunk.startOffset, chunk.endOffset))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tts planner prefers semantic source cfi chunks when available`() {
|
||||
val source = "First sentence. Second sentence."
|
||||
val semanticBlock = SemanticParagraph(
|
||||
text = source,
|
||||
spans = emptyList(),
|
||||
style = CssStyle(),
|
||||
elementId = null,
|
||||
cfi = "/4/2",
|
||||
startCharOffsetInSource = 5,
|
||||
blockIndex = 1
|
||||
)
|
||||
val book = SharedEpubBook(
|
||||
id = "tts-semantic",
|
||||
fileName = "tts-semantic.epub",
|
||||
title = "TTS semantic",
|
||||
chapters = listOf(
|
||||
SharedEpubChapter(
|
||||
id = "one",
|
||||
title = "One",
|
||||
plainText = source,
|
||||
semanticBlocks = listOf(semanticBlock)
|
||||
)
|
||||
)
|
||||
)
|
||||
val page = ReaderPage(
|
||||
pageIndex = 0,
|
||||
chapterIndex = 0,
|
||||
chapterTitle = "One",
|
||||
text = source,
|
||||
startOffset = 0,
|
||||
endOffset = source.length + 5
|
||||
)
|
||||
val session = ReaderSessionState(
|
||||
reader = PaginatedReaderState(
|
||||
book = book,
|
||||
pages = listOf(page),
|
||||
currentPageIndex = 0
|
||||
)
|
||||
)
|
||||
|
||||
val chunks = ReaderTtsPlanner.chunksForCurrentPage(session)
|
||||
|
||||
assertEquals("/4/2", chunks.first().sourceCfi)
|
||||
assertEquals(5, chunks.first().startOffset)
|
||||
assertEquals("/4/2", chunks.first().toLocator().cfi)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `external lookup urls encode selected text`() {
|
||||
assertEquals(
|
||||
"https://www.google.com/search?q=define+hello+world",
|
||||
externalLookupUrl(ReaderExternalLookupAction.DICTIONARY, "hello world")
|
||||
)
|
||||
assertEquals(
|
||||
"https://translate.google.com/?sl=auto&tl=en&text=hello+world&op=translate",
|
||||
externalLookupUrl(ReaderExternalLookupAction.TRANSLATE, "hello world")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
|
||||
class ReaderMarkdownParserTest {
|
||||
@Test
|
||||
fun `parses headings lists quotes and code blocks`() {
|
||||
val document = ReaderMarkdownParser.parse(
|
||||
"""
|
||||
## Summary
|
||||
|
||||
- first point
|
||||
- second point
|
||||
|
||||
> quoted context
|
||||
|
||||
```
|
||||
code line
|
||||
```
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
assertIs<ReaderMarkdownBlock.Heading>(document.blocks[0])
|
||||
assertEquals("Summary", (document.blocks[0] as ReaderMarkdownBlock.Heading).text)
|
||||
assertEquals(listOf("first point", "second point"), (document.blocks[1] as ReaderMarkdownBlock.ListItems).items)
|
||||
assertEquals("quoted context", (document.blocks[2] as ReaderMarkdownBlock.Quote).text)
|
||||
assertEquals("code line", (document.blocks[3] as ReaderMarkdownBlock.CodeBlock).text)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ReaderToolbarPreferencesTest {
|
||||
|
||||
@Test
|
||||
fun `toolbar preferences sanitize unknown ids and preserve missing tools`() {
|
||||
val preferences = ReaderToolbarPreferences(
|
||||
hiddenToolIds = setOf(ReaderTool.SEARCH.id, "missing"),
|
||||
toolOrder = listOf(ReaderTool.BOOKMARK, ReaderTool.THEME),
|
||||
bottomToolIds = setOf(ReaderTool.BOOKMARK.id, "missing")
|
||||
).sanitized()
|
||||
|
||||
assertEquals(setOf(ReaderTool.SEARCH.id), preferences.hiddenToolIds)
|
||||
assertEquals(ReaderTool.BOOKMARK, preferences.toolOrder.first())
|
||||
assertEquals(ReaderTool.THEME, preferences.toolOrder[1])
|
||||
assertTrue(ReaderTool.SEARCH in preferences.toolOrder)
|
||||
assertEquals(setOf(ReaderTool.BOOKMARK.id), preferences.bottomToolIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toolbar reducers update shared screen state`() {
|
||||
val state = SharedReaderScreenState()
|
||||
.reduce(AppAction.ReaderToolVisibilityChanged(ReaderTool.SEARCH, hidden = true))
|
||||
.reduce(AppAction.ReaderToolPlacementChanged(ReaderTool.BOOKMARK, bottom = true))
|
||||
.reduce(AppAction.ReaderToolOrderChanged(listOf(ReaderTool.BOOKMARK, ReaderTool.THEME)))
|
||||
|
||||
assertFalse(state.readerToolbarPreferences.isVisible(ReaderTool.SEARCH))
|
||||
assertTrue(state.readerToolbarPreferences.isBottom(ReaderTool.BOOKMARK))
|
||||
assertEquals(ReaderTool.BOOKMARK, state.readerToolbarPreferences.toolOrder.first())
|
||||
assertEquals(ReaderTool.THEME, state.readerToolbarPreferences.toolOrder[1])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `highlight palette reducer sanitizes colors`() {
|
||||
val state = SharedReaderScreenState()
|
||||
.reduce(
|
||||
AppAction.ReaderHighlightPaletteChanged(
|
||||
ReaderHighlightPalette(
|
||||
colors = listOf(HighlightColor.CYAN, HighlightColor.CYAN, HighlightColor.YELLOW)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(listOf(HighlightColor.CYAN, HighlightColor.YELLOW), state.readerHighlightPalette.colors)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ReaderTtsReplacementEngineTest {
|
||||
@Test
|
||||
fun `literal replacement changes spoken text only`() {
|
||||
val preferences = ReaderTtsReplacementPreferences(
|
||||
globalRules = listOf(rule(from = "Dr.", to = "Doctor", wholeWord = false))
|
||||
)
|
||||
|
||||
val result = ReaderTtsReplacementEngine.apply("Dr. Smith arrived.", preferences)
|
||||
|
||||
assertEquals("Doctor Smith arrived.", result.text)
|
||||
assertEquals(listOf("rule"), result.appliedRuleIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `phrase replacement handles multi word phrases`() {
|
||||
val preferences = ReaderTtsReplacementPreferences(
|
||||
globalRules = listOf(rule(from = "et al.", to = "and others", wholeWord = false))
|
||||
)
|
||||
|
||||
val result = ReaderTtsReplacementEngine.apply("Smith et al. wrote it.", preferences)
|
||||
|
||||
assertEquals("Smith and others wrote it.", result.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `whole word replacement does not replace inside larger words`() {
|
||||
val preferences = ReaderTtsReplacementPreferences(
|
||||
globalRules = listOf(rule(from = "he", to = "they", wholeWord = true))
|
||||
)
|
||||
|
||||
val result = ReaderTtsReplacementEngine.apply("he heard the theme", preferences)
|
||||
|
||||
assertEquals("they heard the theme", result.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `case sensitivity can be required per rule`() {
|
||||
val preferences = ReaderTtsReplacementPreferences(
|
||||
globalRules = listOf(rule(from = "NASA", to = "N A S A", matchCase = true))
|
||||
)
|
||||
|
||||
val result = ReaderTtsReplacementEngine.apply("NASA and nasa", preferences)
|
||||
|
||||
assertEquals("N A S A and nasa", result.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `regex rule supports capture replacements`() {
|
||||
val preferences = ReaderTtsReplacementPreferences(
|
||||
globalRules = listOf(
|
||||
rule(
|
||||
from = """\b([A-Z])\.\s*([A-Z])\.""",
|
||||
to = "\$1 \$2",
|
||||
isRegex = true,
|
||||
wholeWord = false
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val result = ReaderTtsReplacementEngine.apply("J. R. wrote it.", preferences)
|
||||
|
||||
assertEquals("J R wrote it.", result.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invalid regex is skipped and reported`() {
|
||||
val preferences = ReaderTtsReplacementPreferences(
|
||||
globalRules = listOf(rule(from = "(", to = "open", isRegex = true))
|
||||
)
|
||||
|
||||
val result = ReaderTtsReplacementEngine.apply("Keep this text.", preferences)
|
||||
|
||||
assertEquals("Keep this text.", result.text)
|
||||
assertTrue(result.errors.isNotEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `global rules run before book rules`() {
|
||||
val preferences = ReaderTtsReplacementPreferences(
|
||||
globalRules = listOf(rule(id = "global", from = "Dr.", to = "Doctor", wholeWord = false)),
|
||||
bookRules = mapOf(
|
||||
"book" to listOf(rule(id = "book", from = "Doctor", to = "Professor"))
|
||||
)
|
||||
)
|
||||
|
||||
val result = ReaderTtsReplacementEngine.apply("Dr. Smith", preferences, bookId = "book")
|
||||
|
||||
assertEquals("Professor Smith", result.text)
|
||||
assertEquals(listOf("global", "book"), result.appliedRuleIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `book settings can disable inherited global rules`() {
|
||||
val preferences = ReaderTtsReplacementPreferences(
|
||||
globalRules = listOf(rule(id = "global", from = "Dr.", to = "Doctor", wholeWord = false)),
|
||||
bookSettings = mapOf(
|
||||
"book" to ReaderTtsReplacementBookSettings(disabledGlobalRuleIds = setOf("global"))
|
||||
)
|
||||
)
|
||||
|
||||
val result = ReaderTtsReplacementEngine.apply("Dr. Smith", preferences, bookId = "book")
|
||||
|
||||
assertEquals("Dr. Smith", result.text)
|
||||
assertTrue(result.appliedRuleIds.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `preferences serialize and deserialize without losing rules`() {
|
||||
val preferences = ReaderTtsReplacementPreferences(
|
||||
isEnabled = false,
|
||||
globalRules = listOf(rule(id = "global", from = "Mr.", to = "Mister", wholeWord = false)),
|
||||
bookRules = mapOf(
|
||||
"book" to listOf(rule(id = "book", from = "St.", to = "Saint", wholeWord = false))
|
||||
),
|
||||
bookSettings = mapOf(
|
||||
"book" to ReaderTtsReplacementBookSettings(
|
||||
localRulesEnabled = false,
|
||||
globalRulesEnabled = true,
|
||||
disabledGlobalRuleIds = setOf("global")
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val decoded = ReaderTtsReplacementPreferencesJson.decodeOrEmpty(
|
||||
ReaderTtsReplacementPreferencesJson.encode(preferences)
|
||||
)
|
||||
|
||||
assertEquals(preferences, decoded)
|
||||
}
|
||||
|
||||
private fun rule(
|
||||
id: String = "rule",
|
||||
from: String,
|
||||
to: String,
|
||||
enabled: Boolean = true,
|
||||
isRegex: Boolean = false,
|
||||
matchCase: Boolean = false,
|
||||
wholeWord: Boolean = true
|
||||
): ReaderTtsReplacementRule {
|
||||
return ReaderTtsReplacementRule(
|
||||
id = id,
|
||||
from = from,
|
||||
to = to,
|
||||
enabled = enabled,
|
||||
isRegex = isRegex,
|
||||
matchCase = matchCase,
|
||||
wholeWord = wholeWord
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SharedAppThemeReducerTest {
|
||||
|
||||
@Test
|
||||
fun `app appearance actions update shared settings`() {
|
||||
val seedColor = Color(0xFF006C4C)
|
||||
val state = SharedReaderScreenState()
|
||||
.reduce(AppAction.AppThemeChanged(AppThemeMode.DARK))
|
||||
.reduce(AppAction.AppContrastChanged(AppContrastOption.HIGH))
|
||||
.reduce(AppAction.AppTextDimFactorLightChanged(0.75f))
|
||||
.reduce(AppAction.AppTextDimFactorDarkChanged(0.65f))
|
||||
.reduce(AppAction.AppSeedColorChanged(seedColor))
|
||||
|
||||
assertEquals(AppThemeMode.DARK, state.appThemeMode)
|
||||
assertEquals(AppContrastOption.HIGH, state.appContrastOption)
|
||||
assertEquals(0.75f, state.appTextDimFactorLight)
|
||||
assertEquals(0.65f, state.appTextDimFactorDark)
|
||||
assertEquals(seedColor, state.appSeedColor)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `custom app theme add replaces matching id and selects seed color`() {
|
||||
val first = CustomAppTheme(id = "theme", name = "First", seedColor = Color(0xFF123456))
|
||||
val second = CustomAppTheme(id = "theme", name = "Second", seedColor = Color(0xFF654321))
|
||||
|
||||
val state = SharedReaderScreenState()
|
||||
.reduce(AppAction.CustomAppThemeAdded(first))
|
||||
.reduce(AppAction.CustomAppThemeAdded(second))
|
||||
|
||||
assertEquals(listOf(second), state.customAppThemes)
|
||||
assertEquals(second.seedColor, state.appSeedColor)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deleting selected custom app theme clears orphaned seed color`() {
|
||||
val theme = CustomAppTheme(id = "forest", name = "Forest", seedColor = Color(0xFF006C4C))
|
||||
val state = SharedReaderScreenState()
|
||||
.reduce(AppAction.CustomAppThemeAdded(theme))
|
||||
.reduce(AppAction.CustomAppThemeDeleted(theme.id))
|
||||
|
||||
assertTrue(state.customAppThemes.isEmpty())
|
||||
assertNull(state.appSeedColor)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `text dim factors stay inside supported slider range`() {
|
||||
val state = SharedReaderScreenState()
|
||||
.reduce(AppAction.AppTextDimFactorLightChanged(0.1f))
|
||||
.reduce(AppAction.AppTextDimFactorDarkChanged(1.2f))
|
||||
|
||||
assertEquals(0.3f, state.appTextDimFactorLight)
|
||||
assertEquals(1.0f, state.appTextDimFactorDark)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,229 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SharedLibraryEditorTest {
|
||||
|
||||
@Test
|
||||
fun `clean helpers trim names and reject blank values`() {
|
||||
assertEquals("Favorites", SharedLibraryEditor.cleanShelfName(" Favorites "))
|
||||
assertEquals("Reference", SharedLibraryEditor.cleanTagName(" Reference "))
|
||||
assertNull(SharedLibraryEditor.cleanShelfName(" "))
|
||||
assertNull(SharedLibraryEditor.cleanTagName(""))
|
||||
assertTrue(SharedLibraryEditor.canMutateShelf("manual"))
|
||||
assertTrue(!SharedLibraryEditor.canMutateShelf("unshelved"))
|
||||
assertTrue(!SharedLibraryEditor.canMutateShelf(" "))
|
||||
assertEquals(setOf("a", "b"), SharedLibraryEditor.cleanBookIds(listOf(" a ", "", "b", "a")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `create records trim input and reject blank ids`() {
|
||||
val shelf = SharedLibraryEditor.createShelfRecord(" Manual ", " shelf ")
|
||||
val tag = SharedLibraryEditor.createTag(" Sci-Fi ", " tag ", color = 7)
|
||||
|
||||
assertEquals(ShelfRecord(id = "shelf", name = "Manual"), shelf)
|
||||
assertEquals(Tag(id = "tag", name = "Sci-Fi", color = 7), tag)
|
||||
assertNull(SharedLibraryEditor.createShelfRecord("Manual", " "))
|
||||
assertNull(SharedLibraryEditor.createTag(" ", "tag"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `removeSelectedBooks removes books and shelf refs then clears selection`() {
|
||||
val state = SharedReaderScreenState(
|
||||
rawLibraryBooks = listOf(book("keep"), book("remove")),
|
||||
selectedBookIds = setOf("remove")
|
||||
)
|
||||
val refs = listOf(
|
||||
BookShelfRef(bookId = "keep", shelfId = "manual", addedAt = 1L),
|
||||
BookShelfRef(bookId = "remove", shelfId = "manual", addedAt = 2L)
|
||||
)
|
||||
|
||||
val result = SharedLibraryEditor.removeSelectedBooks(state, shelfRecords = emptyList(), shelfRefs = refs)
|
||||
|
||||
requireNotNull(result)
|
||||
assertEquals(listOf("keep"), result.state.rawLibraryBooks.ids())
|
||||
assertTrue(result.state.selectedBookIds.isEmpty())
|
||||
assertEquals(listOf("keep"), result.shelfRefs.map { it.bookId })
|
||||
assertEquals("Removed 1 book(s) from the library.", result.state.bannerMessage?.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `addSelectedBooksToShelf adds only missing refs and clears selection`() {
|
||||
val state = SharedReaderScreenState(selectedBookIds = setOf("existing", "new"))
|
||||
val refs = listOf(BookShelfRef(bookId = "existing", shelfId = "manual", addedAt = 1L))
|
||||
|
||||
val result = SharedLibraryEditor.addSelectedBooksToShelf(
|
||||
state = state,
|
||||
shelfRecords = listOf(ShelfRecord("manual", "Manual")),
|
||||
shelfRefs = refs,
|
||||
shelfId = "manual",
|
||||
nowMillis = 5L
|
||||
)
|
||||
|
||||
requireNotNull(result)
|
||||
assertTrue(result.state.selectedBookIds.isEmpty())
|
||||
assertEquals(
|
||||
listOf(
|
||||
BookShelfRef(bookId = "existing", shelfId = "manual", addedAt = 1L),
|
||||
BookShelfRef(bookId = "new", shelfId = "manual", addedAt = 5L)
|
||||
),
|
||||
result.shelfRefs
|
||||
)
|
||||
assertEquals("Added 1 book(s) to shelf.", result.state.bannerMessage?.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `createSmartShelf stores trimmed shared rules and rejects blank definitions`() {
|
||||
val definition = SmartCollectionDefinition(
|
||||
rules = listOf(
|
||||
SmartRule(SmartField.TITLE, SmartOperator.CONTAINS, " dune "),
|
||||
SmartRule(SmartField.AUTHOR, SmartOperator.CONTAINS, " ")
|
||||
)
|
||||
)
|
||||
|
||||
val result = SharedLibraryEditor.createSmartShelf(
|
||||
state = SharedReaderScreenState(),
|
||||
shelfRecords = emptyList(),
|
||||
shelfRefs = emptyList(),
|
||||
name = " Smart Picks ",
|
||||
definition = definition,
|
||||
nowMillis = 7L
|
||||
)
|
||||
|
||||
requireNotNull(result)
|
||||
val shelf = result.shelfRecords.single()
|
||||
val decoded = SmartCollectionEngine.fromJson(shelf.smartRulesJson)
|
||||
assertEquals(ShelfRecord("smart_7", "Smart Picks", isSmart = true, smartRulesJson = shelf.smartRulesJson), shelf)
|
||||
assertEquals(listOf(SmartRule(SmartField.TITLE, SmartOperator.CONTAINS, "dune")), decoded?.rules)
|
||||
assertEquals("Created smart shelf \"Smart Picks\".", result.state.bannerMessage?.message)
|
||||
assertNull(
|
||||
SharedLibraryEditor.createSmartShelf(
|
||||
state = SharedReaderScreenState(),
|
||||
shelfRecords = emptyList(),
|
||||
shelfRefs = emptyList(),
|
||||
name = "Blank",
|
||||
definition = SmartCollectionDefinition(rules = listOf(SmartRule(SmartField.TITLE, SmartOperator.CONTAINS, " "))),
|
||||
nowMillis = 8L
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tagSelectedBooks reuses matching tags case insensitively`() {
|
||||
val favorite = Tag(id = "favorite", name = "Favorite")
|
||||
val state = SharedReaderScreenState(
|
||||
rawLibraryBooks = listOf(book("one"), book("two", tags = listOf(favorite))),
|
||||
allTags = listOf(favorite),
|
||||
selectedBookIds = setOf("one", "two")
|
||||
)
|
||||
|
||||
val result = SharedLibraryEditor.tagSelectedBooks(
|
||||
state = state,
|
||||
shelfRecords = emptyList(),
|
||||
shelfRefs = emptyList(),
|
||||
tagName = " favorite ",
|
||||
nowMillis = 10L
|
||||
)
|
||||
|
||||
requireNotNull(result)
|
||||
assertEquals(listOf(favorite), result.state.allTags)
|
||||
assertEquals(listOf(favorite), result.state.rawLibraryBooks.first { it.id == "one" }.tags)
|
||||
assertEquals(listOf(favorite), result.state.rawLibraryBooks.first { it.id == "two" }.tags)
|
||||
assertTrue(result.state.selectedBookIds.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updateBookMetadata updates book timestamp and merges tags`() {
|
||||
val old = book("book", title = "Old")
|
||||
val newTag = Tag("new", "New")
|
||||
|
||||
val result = SharedLibraryEditor.updateBookMetadata(
|
||||
state = SharedReaderScreenState(rawLibraryBooks = listOf(old)),
|
||||
shelfRecords = emptyList(),
|
||||
shelfRefs = emptyList(),
|
||||
updated = old.copy(title = "New", tags = listOf(newTag)),
|
||||
nowMillis = 99L
|
||||
)
|
||||
|
||||
val updatedBook = result.state.rawLibraryBooks.single()
|
||||
assertEquals("New", updatedBook.title)
|
||||
assertEquals(99L, updatedBook.timestamp)
|
||||
assertEquals(listOf(newTag), result.state.allTags)
|
||||
assertEquals("Updated \"New\".", result.state.bannerMessage?.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `removeFolder removes folder books tabs pins refs and synced folder metadata`() {
|
||||
val folderBook = book("folder_book").copy(sourceFolder = "C:/Books")
|
||||
val otherBook = book("other")
|
||||
val folder = Shelf(
|
||||
id = "folder_C:/Books",
|
||||
name = "Books",
|
||||
type = ShelfType.FOLDER,
|
||||
books = listOf(folderBook)
|
||||
)
|
||||
val state = SharedReaderScreenState(
|
||||
rawLibraryBooks = listOf(folderBook, otherBook),
|
||||
selectedBookIds = setOf("folder_book", "other"),
|
||||
pinnedHomeBookIds = setOf("folder_book"),
|
||||
pinnedLibraryBookIds = setOf("folder_book", "other"),
|
||||
openTabIds = listOf("folder_book", "other"),
|
||||
activeTabBookId = "folder_book",
|
||||
syncedFolders = listOf(SyncedFolder("C:/Books", "Books", lastScanTime = 1L)),
|
||||
libraryFilters = LibraryFilters(sourceFolders = setOf("C:/Books"))
|
||||
)
|
||||
val refs = listOf(
|
||||
BookShelfRef(bookId = "folder_book", shelfId = "manual", addedAt = 1L),
|
||||
BookShelfRef(bookId = "other", shelfId = "manual", addedAt = 2L)
|
||||
)
|
||||
|
||||
val result = SharedLibraryEditor.removeFolder(state, emptyList(), refs, folder)
|
||||
|
||||
requireNotNull(result)
|
||||
assertEquals(listOf("other"), result.state.rawLibraryBooks.ids())
|
||||
assertEquals(setOf("other"), result.state.selectedBookIds)
|
||||
assertTrue(result.state.pinnedHomeBookIds.isEmpty())
|
||||
assertEquals(setOf("other"), result.state.pinnedLibraryBookIds)
|
||||
assertEquals(listOf("other"), result.state.openTabIds)
|
||||
assertNull(result.state.activeTabBookId)
|
||||
assertTrue(result.state.syncedFolders.isEmpty())
|
||||
assertTrue(result.state.libraryFilters.sourceFolders.isEmpty())
|
||||
assertEquals(listOf("other"), result.shelfRefs.map { it.bookId })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `markBookOpened marks book recent and updates timestamp`() {
|
||||
val state = SharedReaderScreenState(
|
||||
rawLibraryBooks = listOf(
|
||||
book("opened").copy(isRecent = false, timestamp = 1L),
|
||||
book("other").copy(isRecent = false, timestamp = 2L)
|
||||
)
|
||||
)
|
||||
|
||||
val result = SharedLibraryEditor.markBookOpened(state, "opened", nowMillis = 99L)
|
||||
|
||||
assertTrue(result.rawLibraryBooks.first { it.id == "opened" }.isRecent)
|
||||
assertEquals(99L, result.rawLibraryBooks.first { it.id == "opened" }.timestamp)
|
||||
assertTrue(!result.rawLibraryBooks.first { it.id == "other" }.isRecent)
|
||||
assertEquals(2L, result.rawLibraryBooks.first { it.id == "other" }.timestamp)
|
||||
}
|
||||
|
||||
private fun book(
|
||||
id: String,
|
||||
title: String? = id,
|
||||
tags: List<Tag> = emptyList()
|
||||
) = BookItem(
|
||||
id = id,
|
||||
path = "/library/$id.epub",
|
||||
type = FileType.EPUB,
|
||||
displayName = "$id.epub",
|
||||
timestamp = 1L,
|
||||
title = title,
|
||||
tags = tags
|
||||
)
|
||||
|
||||
private fun List<BookItem>.ids() = map { it.id }
|
||||
}
|
||||
|
|
@ -0,0 +1,373 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SharedLibraryProjectorTest {
|
||||
|
||||
@Test
|
||||
fun `LibraryProjector searches filters sorts and builds selected library model`() {
|
||||
val tag = Tag("favorite", "Favorite")
|
||||
val matching = book(
|
||||
id = "matching",
|
||||
title = "Clean Android",
|
||||
author = "Ada",
|
||||
type = FileType.PDF,
|
||||
progressPercentage = 50f,
|
||||
sourceFolder = "/books",
|
||||
tags = listOf(tag),
|
||||
timestamp = 3L
|
||||
)
|
||||
val wrongTag = book("wrong_tag", title = "Clean Kotlin", type = FileType.PDF, progressPercentage = 50f)
|
||||
val wrongStatus = book("wrong_status", title = "Clean Done", type = FileType.PDF, progressPercentage = 100f, tags = listOf(tag))
|
||||
|
||||
val model = LibraryProjector().library(
|
||||
LibraryState(
|
||||
books = listOf(wrongTag, matching, wrongStatus),
|
||||
searchQuery = "clean",
|
||||
sortOrder = SortOrder.TITLE_ASC,
|
||||
filters = LibraryFilters(
|
||||
fileTypes = setOf(FileType.PDF),
|
||||
sourceFolders = setOf("/books"),
|
||||
readStatus = ReadStatusFilter.IN_PROGRESS,
|
||||
tagIds = setOf(tag.id)
|
||||
),
|
||||
selectedBookIds = setOf("matching", "missing")
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(listOf("matching"), model.books.ids())
|
||||
assertEquals(listOf("matching"), model.selectedBooks.ids())
|
||||
assertEquals(SortOrder.TITLE_ASC, model.sortOrder)
|
||||
assertEquals("clean", model.searchQuery)
|
||||
assertTrue(model.filters.isActive)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `LibraryProjector home limits sorted recent books and keeps selected books`() {
|
||||
val model = LibraryProjector().home(
|
||||
LibraryState(
|
||||
books = listOf(
|
||||
book("old", timestamp = 1L),
|
||||
book("new", timestamp = 3L),
|
||||
book("archived", timestamp = 2L, isRecent = false)
|
||||
),
|
||||
selectedBookIds = setOf("old", "archived"),
|
||||
recentLimit = 1,
|
||||
sortOrder = SortOrder.RECENT
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(listOf("new"), model.recentBooks.ids())
|
||||
assertEquals(listOf("old", "archived"), model.selectedBooks.ids())
|
||||
assertFalse(model.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `LibraryProjector imports only new files and maps extensions and folders`() {
|
||||
val projector = LibraryProjector()
|
||||
val state = LibraryState(books = listOf(book("C:/books/existing.pdf", displayName = "existing.pdf", isRecent = false)))
|
||||
|
||||
val result = projector.withImportedFiles(
|
||||
state,
|
||||
listOf(
|
||||
ImportedFile(name = "existing.pdf", path = "C:/books/existing.pdf", size = 1L),
|
||||
ImportedFile(name = "notes.md", path = "C:/books/notes.md", size = 2L, sourceFolder = "C:/books"),
|
||||
ImportedFile(name = "mystery.bin", path = null, size = 3L)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(listOf("C:/books/notes.md", "mystery.bin", "C:/books/existing.pdf"), result.books.ids())
|
||||
assertEquals(FileType.MD, result.books[0].type)
|
||||
assertEquals("C:/books", result.books[0].sourceFolder)
|
||||
assertFalse(result.books[0].isRecent)
|
||||
assertEquals(FileType.UNKNOWN, result.books[1].type)
|
||||
assertFalse(result.books[1].isRecent)
|
||||
assertTrue(projector.home(result).recentBooks.isEmpty())
|
||||
assertEquals("Imported 2 file(s). Reader support comes later.", result.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SharedLibraryStateProjector prunes stale selections tabs and shelf state`() {
|
||||
val existing = book("existing")
|
||||
val result = SharedLibraryStateProjector().project(
|
||||
SharedLibraryProjectionInput(
|
||||
state = SharedReaderScreenState(
|
||||
selectedBookIds = setOf("existing", "missing"),
|
||||
openTabIds = listOf("missing", "existing"),
|
||||
activeTabBookId = "missing",
|
||||
viewingShelfId = "missing_shelf",
|
||||
isAddingBooksToShelf = true,
|
||||
selectedShelfIds = setOf("missing_shelf")
|
||||
),
|
||||
booksFromStore = listOf(existing),
|
||||
shelfRecords = emptyList(),
|
||||
shelfRefs = emptyList(),
|
||||
tags = emptyList()
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(setOf("existing"), result.selectedBookIds)
|
||||
assertEquals(listOf("existing"), result.openTabs.ids())
|
||||
assertEquals(listOf("existing"), result.openTabIds)
|
||||
assertNull(result.activeTabBookId)
|
||||
assertNull(result.viewingShelfId)
|
||||
assertFalse(result.isAddingBooksToShelf)
|
||||
assertTrue(result.selectedShelfIds.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SharedLibraryStateProjector keeps pinned home and library books first`() {
|
||||
val older = book("older", title = "Zulu", timestamp = 1L)
|
||||
val newer = book("newer", title = "Alpha", timestamp = 2L)
|
||||
|
||||
val result = SharedLibraryStateProjector().project(
|
||||
SharedLibraryProjectionInput(
|
||||
state = SharedReaderScreenState(
|
||||
rawLibraryBooks = listOf(older, newer),
|
||||
pinnedHomeBookIds = setOf("older"),
|
||||
pinnedLibraryBookIds = setOf("older"),
|
||||
sortOrder = SortOrder.TITLE_ASC
|
||||
),
|
||||
booksFromStore = listOf(older, newer),
|
||||
shelfRecords = emptyList(),
|
||||
shelfRefs = emptyList(),
|
||||
tags = emptyList()
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(listOf("older", "newer"), result.recentBooks.ids())
|
||||
assertEquals(listOf("older", "newer"), result.libraryBooks.ids())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shared app actions manage tabs and pins`() {
|
||||
val opened = SharedReaderScreenState()
|
||||
.reduce(AppAction.BookTabOpened("one"))
|
||||
.reduce(AppAction.BookTabOpened("two"))
|
||||
.reduce(AppAction.HomePinToggled("one"))
|
||||
.reduce(AppAction.LibraryPinToggled("two"))
|
||||
|
||||
assertTrue(opened.isTabsEnabled)
|
||||
assertEquals(listOf("one", "two"), opened.openTabIds)
|
||||
assertEquals("two", opened.activeTabBookId)
|
||||
assertEquals(setOf("one"), opened.pinnedHomeBookIds)
|
||||
assertEquals(setOf("two"), opened.pinnedLibraryBookIds)
|
||||
|
||||
val closedActive = opened.reduce(AppAction.BookTabClosed("two"))
|
||||
|
||||
assertEquals(listOf("one"), closedActive.openTabIds)
|
||||
assertEquals("one", closedActive.activeTabBookId)
|
||||
assertTrue(closedActive.reduce(AppAction.TabsEnabledChanged(false)).openTabIds.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SharedLibraryStateProjector builds manual tag series folder and unshelved shelves`() {
|
||||
val tag = Tag("favorite", "Favorite")
|
||||
val manual = book("manual")
|
||||
val tagged = book("tagged", tags = listOf(tag))
|
||||
val seriesOne = book("series_1", seriesName = "Saga", seriesIndex = 1.0)
|
||||
val seriesTwo = book("series_2", seriesName = "Saga", seriesIndex = 2.0)
|
||||
val folderBook = book("folder", sourceFolder = "content://library")
|
||||
val loose = book("loose")
|
||||
|
||||
val result = SharedLibraryStateProjector(
|
||||
SharedFolderPathResolver { item ->
|
||||
if (item.id == "folder") listOf("Nested") else emptyList()
|
||||
}
|
||||
).project(
|
||||
SharedLibraryProjectionInput(
|
||||
state = SharedReaderScreenState(
|
||||
syncedFolders = listOf(SyncedFolder("content://library", "Library", lastScanTime = 1L)),
|
||||
sortOrder = SortOrder.TITLE_ASC
|
||||
),
|
||||
booksFromStore = listOf(tagged, seriesTwo, loose, folderBook, manual, seriesOne),
|
||||
shelfRecords = listOf(ShelfRecord("manual_shelf", "Manual")),
|
||||
shelfRefs = listOf(BookShelfRef(bookId = "manual", shelfId = "manual_shelf", addedAt = 1L)),
|
||||
tags = listOf(tag)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(listOf("manual"), result.shelves.first { it.id == "manual_shelf" }.books.ids())
|
||||
assertEquals(listOf("tagged"), result.shelves.first { it.id == "tag_favorite" }.books.ids())
|
||||
assertEquals(listOf("series_1", "series_2"), result.shelves.first { it.id == "series_Saga" }.books.ids())
|
||||
assertEquals(listOf("folder"), result.shelves.first { it.id == "folder_content://library" }.books.ids())
|
||||
assertEquals(listOf("folder"), result.shelves.first { it.id == "folder_content://library::Nested" }.directBooks.ids())
|
||||
assertEquals(listOf("loose", "tagged"), result.shelves.first { it.id == "unshelved" }.books.ids())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SharedLibraryStateProjector builds smart shelves from shared rules`() {
|
||||
val smartRules = SmartCollectionEngine.toJson(
|
||||
SmartCollectionDefinition(
|
||||
rules = listOf(
|
||||
SmartRule(SmartField.FILE_TYPE, SmartOperator.EQUALS, "PDF"),
|
||||
SmartRule(SmartField.PROGRESS, SmartOperator.GREATER_THAN, "75")
|
||||
)
|
||||
)
|
||||
)
|
||||
val matching = book("matching", type = FileType.PDF, progressPercentage = 90f)
|
||||
val wrongType = book("wrong_type", type = FileType.EPUB, progressPercentage = 90f)
|
||||
val wrongProgress = book("wrong_progress", type = FileType.PDF, progressPercentage = 20f)
|
||||
|
||||
val result = SharedLibraryStateProjector().project(
|
||||
SharedLibraryProjectionInput(
|
||||
state = SharedReaderScreenState(sortOrder = SortOrder.TITLE_ASC),
|
||||
booksFromStore = listOf(wrongType, wrongProgress, matching),
|
||||
shelfRecords = listOf(ShelfRecord("smart", "Almost Done PDFs", isSmart = true, smartRulesJson = smartRules)),
|
||||
shelfRefs = emptyList(),
|
||||
tags = emptyList()
|
||||
)
|
||||
)
|
||||
|
||||
val smartShelf = result.shelves.first { it.id == "smart" }
|
||||
assertEquals(ShelfType.SMART, smartShelf.type)
|
||||
assertEquals(listOf("matching"), smartShelf.books.ids())
|
||||
assertEquals(listOf("wrong_progress", "wrong_type"), result.shelves.first { it.id == "unshelved" }.books.ids())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SharedReaderScreenState withImportedFiles dedupes imports and reports duplicates`() {
|
||||
val state = SharedReaderScreenState(rawLibraryBooks = listOf(book("/books/existing.epub", isRecent = false)))
|
||||
|
||||
val imported = state.withImportedFiles(
|
||||
listOf(
|
||||
ImportedBookFile(name = "existing.epub", uriString = null, localPath = "/books/existing.epub", size = 1L),
|
||||
ImportedBookFile(name = "new.pdf", uriString = "content://new", localPath = null, size = 2L, sourceFolder = "content://folder")
|
||||
),
|
||||
now = 10L
|
||||
)
|
||||
val duplicateOnly = imported.withImportedFiles(
|
||||
listOf(ImportedBookFile(name = "new.pdf", uriString = "content://new", localPath = null, size = 2L)),
|
||||
now = 20L
|
||||
)
|
||||
|
||||
assertEquals(listOf("content://new", "/books/existing.epub"), imported.rawLibraryBooks.ids())
|
||||
assertEquals(FileType.PDF, imported.rawLibraryBooks.first().type)
|
||||
assertEquals("content://folder", imported.rawLibraryBooks.first().sourceFolder)
|
||||
assertEquals(11L, imported.rawLibraryBooks.first().timestamp)
|
||||
assertFalse(imported.rawLibraryBooks.first().isRecent)
|
||||
val projected = SharedLibraryStateProjector().project(
|
||||
SharedLibraryProjectionInput(
|
||||
state = imported,
|
||||
booksFromStore = imported.rawLibraryBooks,
|
||||
shelfRecords = emptyList(),
|
||||
shelfRefs = emptyList(),
|
||||
tags = emptyList()
|
||||
)
|
||||
)
|
||||
assertTrue(projected.recentBooks.isEmpty())
|
||||
assertEquals("Imported 1 file(s).", imported.bannerMessage?.message)
|
||||
assertEquals("Those files are already in the library.", duplicateOnly.bannerMessage?.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shared filters treat in app storage separately from opds streams`() {
|
||||
val localBook = book("local", sourceFolder = null, path = "file:///local/book.epub")
|
||||
val streamedBook = book("streamed", sourceFolder = null, path = "opds-pse://book")
|
||||
val syncedBook = book("synced", sourceFolder = "content://sync", path = "content://synced")
|
||||
|
||||
assertEquals(
|
||||
listOf("local"),
|
||||
applyLibraryFilters(
|
||||
listOf(localBook, streamedBook, syncedBook),
|
||||
LibraryFilters(sourceFolders = setOf(IN_APP_STORAGE_SOURCE))
|
||||
).ids()
|
||||
)
|
||||
assertEquals(
|
||||
listOf("synced"),
|
||||
applyLibraryFilters(
|
||||
listOf(localBook, streamedBook, syncedBook),
|
||||
LibraryFilters(sourceFolders = setOf("content://sync"))
|
||||
).ids()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shared sort keeps books without authors last`() {
|
||||
val unknown = book("unknown", title = null, author = null, displayName = "Zulu.epub")
|
||||
val known = book("known", title = null, author = "Ada", displayName = "Beta.epub")
|
||||
val title = book("title", title = "Omega", author = "Grace", displayName = "Alpha.epub")
|
||||
|
||||
assertEquals(listOf("known", "title", "unknown"), sortBooks(listOf(unknown, known, title), SortOrder.AUTHOR_ASC).ids())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shared screen models expose home and library derived state`() {
|
||||
val folderBook = book("folder", sourceFolder = "/books")
|
||||
val recent = book("recent")
|
||||
val state = SharedReaderScreenState(
|
||||
recentBooks = listOf(recent),
|
||||
openTabs = listOf(folderBook),
|
||||
rawLibraryBooks = listOf(folderBook, recent),
|
||||
selectedBookIds = setOf("folder"),
|
||||
selectedShelfIds = setOf("manual"),
|
||||
isTabsEnabled = true,
|
||||
deviceLimitState = DeviceLimitReachedState(isLimitReached = true),
|
||||
searchQuery = "folder",
|
||||
isSearchActive = true
|
||||
)
|
||||
|
||||
val home = state.toHomeScreenModel()
|
||||
val library = state.toLibraryScreenModel()
|
||||
|
||||
assertEquals(listOf("recent"), home.recentBooks.ids())
|
||||
assertEquals(listOf("folder"), home.openTabs.ids())
|
||||
assertEquals(listOf("folder"), home.selectedBooks.ids())
|
||||
assertTrue(home.isContextualModeActive)
|
||||
assertFalse(home.isEmpty)
|
||||
assertFalse(home.isLibraryEmpty)
|
||||
assertTrue(home.deviceLimitState.isLimitReached)
|
||||
|
||||
assertEquals(listOf("folder"), library.selectedBooks.ids())
|
||||
assertEquals(setOf("manual"), library.selectedShelves)
|
||||
assertTrue(library.containsFolderItemsInSelection)
|
||||
assertTrue(library.isSearchActive)
|
||||
assertEquals("folder", library.searchQuery)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toFileType maps known document and archive extensions case insensitively`() {
|
||||
assertEquals(FileType.PDF, "REPORT.PDF".toFileType())
|
||||
assertEquals(FileType.HTML, "page.htm".toFileType())
|
||||
assertEquals(FileType.CBZ, "comic.cbz".toFileType())
|
||||
assertEquals(FileType.UNKNOWN, "archive.zip".toFileType())
|
||||
}
|
||||
|
||||
private fun book(
|
||||
id: String,
|
||||
displayName: String = "$id.epub",
|
||||
type: FileType = FileType.EPUB,
|
||||
title: String? = id,
|
||||
author: String? = null,
|
||||
timestamp: Long = 1L,
|
||||
progressPercentage: Float? = null,
|
||||
isRecent: Boolean = true,
|
||||
fileSize: Long = 0L,
|
||||
sourceFolder: String? = null,
|
||||
path: String? = "/library/$displayName",
|
||||
seriesName: String? = null,
|
||||
seriesIndex: Double? = null,
|
||||
tags: List<Tag> = emptyList()
|
||||
) = BookItem(
|
||||
id = id,
|
||||
path = path,
|
||||
type = type,
|
||||
displayName = displayName,
|
||||
timestamp = timestamp,
|
||||
title = title,
|
||||
author = author,
|
||||
progressPercentage = progressPercentage,
|
||||
isRecent = isRecent,
|
||||
fileSize = fileSize,
|
||||
sourceFolder = sourceFolder,
|
||||
seriesName = seriesName,
|
||||
seriesIndex = seriesIndex,
|
||||
tags = tags
|
||||
)
|
||||
|
||||
private fun List<BookItem>.ids() = map { it.id }
|
||||
}
|
||||
|
|
@ -0,0 +1,204 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.aryan.reader.shared.reader.ReaderBookmark
|
||||
import com.aryan.reader.shared.reader.ReaderReadingMode
|
||||
import com.aryan.reader.shared.reader.ReaderSettings
|
||||
import com.aryan.reader.shared.reader.SharedReaderTextAlign
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SharedLibrarySnapshotJsonTest {
|
||||
|
||||
@Test
|
||||
fun `snapshot json round trips library records used by desktop persistence`() {
|
||||
val tag = Tag(id = "favorite", name = "Favorite", color = 7)
|
||||
val snapshot = SharedLibrarySnapshot(
|
||||
books = listOf(
|
||||
BookItem(
|
||||
id = "book",
|
||||
path = "C:/Books/book.epub",
|
||||
type = FileType.EPUB,
|
||||
displayName = "book.epub",
|
||||
timestamp = 10L,
|
||||
coverImagePath = "C:/Covers/book.png",
|
||||
title = "Book",
|
||||
author = "Ada",
|
||||
progressPercentage = 42f,
|
||||
fileSize = 99L,
|
||||
sourceFolder = "C:/Books",
|
||||
folderTextMetadataParsed = true,
|
||||
seriesName = "Series",
|
||||
seriesIndex = 2.0,
|
||||
tags = listOf(tag),
|
||||
lastPageIndex = 4,
|
||||
readerSettings = ReaderSettings(
|
||||
fontSize = 22,
|
||||
lineSpacing = 1.7f,
|
||||
margin = 64,
|
||||
darkMode = true,
|
||||
readingMode = ReaderReadingMode.VERTICAL,
|
||||
textAlign = SharedReaderTextAlign.JUSTIFY,
|
||||
pageWidth = 840,
|
||||
fontFamily = "Serif",
|
||||
paragraphSpacing = 1.4f,
|
||||
imageScale = 1.2f,
|
||||
horizontalMargin = 40,
|
||||
verticalMargin = 72,
|
||||
themeId = "sepia",
|
||||
textureId = "paper",
|
||||
textureAlpha = 0.35f,
|
||||
customFontPath = "C:/Fonts/custom.ttf",
|
||||
backgroundColorArgb = -328967L,
|
||||
textColorArgb = -12345678L,
|
||||
systemUiMode = SystemUiMode.HIDDEN,
|
||||
pageInfoMode = PageInfoMode.SYNC,
|
||||
pageInfoPosition = PageInfoPosition.TOP,
|
||||
seamlessChapterNavigation = false,
|
||||
chapterTurnDragMultiplier = 1.6f
|
||||
),
|
||||
readerBookmarks = listOf(
|
||||
ReaderBookmark(
|
||||
id = "book_4",
|
||||
pageIndex = 4,
|
||||
chapterTitle = "Chapter",
|
||||
preview = "A useful paragraph",
|
||||
locator = ReaderLocator(
|
||||
chapterIndex = 0,
|
||||
pageIndex = 4,
|
||||
startOffset = 100,
|
||||
endOffset = 180,
|
||||
textQuote = "A useful paragraph"
|
||||
)
|
||||
)
|
||||
),
|
||||
readerHighlights = listOf(
|
||||
UserHighlight(
|
||||
id = "highlight_1",
|
||||
cfi = "desktop:0:128:144",
|
||||
text = "useful paragraph",
|
||||
color = HighlightColor.YELLOW,
|
||||
chapterIndex = 0,
|
||||
note = "Remember this",
|
||||
locator = ReaderLocator(
|
||||
chapterIndex = 0,
|
||||
pageIndex = 4,
|
||||
startOffset = 128,
|
||||
endOffset = 144,
|
||||
textQuote = "useful paragraph",
|
||||
cfi = "desktop:0:128:144"
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
shelfRecords = listOf(ShelfRecord(id = "shelf", name = "Shelf", isSmart = true, smartRulesJson = "{}")),
|
||||
shelfRefs = listOf(BookShelfRef(bookId = "book", shelfId = "shelf", addedAt = 11L)),
|
||||
tags = listOf(tag),
|
||||
customFonts = listOf(
|
||||
CustomFontItem(
|
||||
id = "font",
|
||||
displayName = "Literata",
|
||||
fileName = "font.ttf",
|
||||
fileExtension = "ttf",
|
||||
path = "C:/Fonts/font.ttf",
|
||||
timestamp = 13L
|
||||
)
|
||||
),
|
||||
syncedFolders = listOf(SyncedFolder("C:/Books", "Books", lastScanTime = 12L, allowedFileTypes = setOf(FileType.EPUB, FileType.PDF))),
|
||||
recentFilesLimit = 20,
|
||||
isTabsEnabled = true,
|
||||
openTabIds = listOf("book"),
|
||||
activeTabBookId = "book",
|
||||
pinnedHomeBookIds = setOf("book"),
|
||||
pinnedLibraryBookIds = setOf("book"),
|
||||
useStrictFileFilter = true,
|
||||
appThemeMode = AppThemeMode.DARK,
|
||||
appContrastOption = AppContrastOption.HIGH,
|
||||
appTextDimFactorLight = 0.75f,
|
||||
appTextDimFactorDark = 0.65f,
|
||||
appSeedColor = Color(0xFF006C4C),
|
||||
customAppThemes = listOf(
|
||||
CustomAppTheme(id = "forest", name = "Forest", seedColor = Color(0xFF006C4C))
|
||||
),
|
||||
readerToolbarPreferences = ReaderToolbarPreferences(
|
||||
hiddenToolIds = setOf(ReaderTool.SEARCH.id),
|
||||
toolOrder = listOf(ReaderTool.BOOKMARK, ReaderTool.THEME, ReaderTool.SEARCH),
|
||||
bottomToolIds = setOf(ReaderTool.BOOKMARK.id)
|
||||
).sanitized(),
|
||||
readerHighlightPalette = ReaderHighlightPalette(
|
||||
colors = listOf(HighlightColor.YELLOW, HighlightColor.CYAN)
|
||||
),
|
||||
readerTtsReplacementPreferences = ReaderTtsReplacementPreferences(
|
||||
globalRules = listOf(
|
||||
ReaderTtsReplacementRule(
|
||||
id = "dr",
|
||||
from = "Dr.",
|
||||
to = "Doctor",
|
||||
wholeWord = false
|
||||
)
|
||||
),
|
||||
bookRules = mapOf(
|
||||
"book" to listOf(
|
||||
ReaderTtsReplacementRule(
|
||||
id = "st",
|
||||
from = "St.",
|
||||
to = "Saint",
|
||||
wholeWord = false
|
||||
)
|
||||
)
|
||||
),
|
||||
bookSettings = mapOf(
|
||||
"book" to ReaderTtsReplacementBookSettings(disabledGlobalRuleIds = setOf("dr"))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val decoded = SharedLibrarySnapshotJson.decodeOrEmpty(SharedLibrarySnapshotJson.encode(snapshot))
|
||||
|
||||
assertEquals(snapshot, decoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `snapshot json tolerates malformed or missing data`() {
|
||||
val decoded = SharedLibrarySnapshotJson.decodeOrEmpty("""{"books":[{"id":"missingName"}]}""")
|
||||
|
||||
assertTrue(SharedLibrarySnapshotJson.decodeOrEmpty("not json").books.isEmpty())
|
||||
assertTrue(decoded.books.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `legacy snapshot hides imported only books from recent home`() {
|
||||
val decoded = SharedLibrarySnapshotJson.decodeOrEmpty(
|
||||
"""
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"books": [
|
||||
{
|
||||
"id": "imported",
|
||||
"path": "C:/Books/imported.epub",
|
||||
"type": "EPUB",
|
||||
"displayName": "imported.epub",
|
||||
"timestamp": 10,
|
||||
"isRecent": true
|
||||
},
|
||||
{
|
||||
"id": "opened",
|
||||
"path": "C:/Books/opened.epub",
|
||||
"type": "EPUB",
|
||||
"displayName": "opened.epub",
|
||||
"timestamp": 11,
|
||||
"isRecent": true
|
||||
}
|
||||
],
|
||||
"openTabIds": ["opened"]
|
||||
}
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
assertFalse(decoded.books.first { it.id == "imported" }.isRecent)
|
||||
assertTrue(decoded.books.first { it.id == "opened" }.isRecent)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SmartCollectionEngineTest {
|
||||
|
||||
@Test
|
||||
fun `definition JSON round trips and ignores unknown fields`() {
|
||||
val definition = SmartCollectionDefinition(
|
||||
matchAll = false,
|
||||
rules = listOf(
|
||||
SmartRule(SmartField.TITLE, SmartOperator.CONTAINS, "dune"),
|
||||
SmartRule(SmartField.PROGRESS, SmartOperator.GREATER_THAN, "50")
|
||||
)
|
||||
)
|
||||
|
||||
val encoded = SmartCollectionEngine.toJson(definition)
|
||||
val decoded = SmartCollectionEngine.fromJson(
|
||||
encoded.replaceFirst("{", """{"unknown":"kept-for-forward-compat",""")
|
||||
)
|
||||
|
||||
assertEquals(definition, decoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fromJson returns null for blank malformed and incompatible payloads`() {
|
||||
assertNull(SmartCollectionEngine.fromJson(null))
|
||||
assertNull(SmartCollectionEngine.fromJson(" "))
|
||||
assertNull(SmartCollectionEngine.fromJson("{not json"))
|
||||
assertNull(SmartCollectionEngine.fromJson("""{"matchAll":true,"rules":[{"field":"NOPE"}]}"""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `matchAll requires every rule while matchAny accepts a single matching rule`() {
|
||||
val book = book(
|
||||
title = "Dune Messiah",
|
||||
author = "Frank Herbert",
|
||||
progressPercentage = 41f,
|
||||
type = FileType.EPUB
|
||||
)
|
||||
|
||||
val titleAndHighProgress = SmartCollectionDefinition(
|
||||
matchAll = true,
|
||||
rules = listOf(
|
||||
SmartRule(SmartField.TITLE, SmartOperator.CONTAINS, "dune"),
|
||||
SmartRule(SmartField.PROGRESS, SmartOperator.GREATER_THAN, "80")
|
||||
)
|
||||
)
|
||||
val titleOrHighProgress = titleAndHighProgress.copy(matchAll = false)
|
||||
|
||||
assertFalse(SmartCollectionEngine.evaluate(book, titleAndHighProgress))
|
||||
assertTrue(SmartCollectionEngine.evaluate(book, titleOrHighProgress))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `string folder file type and tag rules are case insensitive`() {
|
||||
val book = book(
|
||||
displayName = "fallback-name.pdf",
|
||||
title = null,
|
||||
author = "Ursula K. Le Guin",
|
||||
sourceFolder = "content://library/Sci-Fi",
|
||||
type = FileType.PDF,
|
||||
tags = listOf(
|
||||
Tag(id = "t1", name = "Classic Science Fiction"),
|
||||
Tag(id = "t2", name = "Queued")
|
||||
)
|
||||
)
|
||||
|
||||
assertTrue(
|
||||
SmartCollectionEngine.evaluate(
|
||||
book,
|
||||
SmartCollectionDefinition(
|
||||
rules = listOf(
|
||||
SmartRule(SmartField.TITLE, SmartOperator.EQUALS, "fallback-name.pdf"),
|
||||
SmartRule(SmartField.AUTHOR, SmartOperator.CONTAINS, "le guin"),
|
||||
SmartRule(SmartField.FOLDER, SmartOperator.CONTAINS, "SCI-FI"),
|
||||
SmartRule(SmartField.FILE_TYPE, SmartOperator.EQUALS, "pdf"),
|
||||
SmartRule(SmartField.TAG, SmartOperator.CONTAINS, "science")
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `numeric rules handle equals greater less missing progress and invalid values`() {
|
||||
val startedBook = book(progressPercentage = 33.5f)
|
||||
val missingProgressBook = book(progressPercentage = null)
|
||||
|
||||
assertTrue(matchesProgress(startedBook, SmartOperator.EQUALS, "33.5"))
|
||||
assertTrue(matchesProgress(startedBook, SmartOperator.GREATER_THAN, "33"))
|
||||
assertTrue(matchesProgress(startedBook, SmartOperator.LESS_THAN, "34"))
|
||||
assertFalse(matchesProgress(startedBook, SmartOperator.GREATER_THAN, "not-a-number"))
|
||||
assertTrue(matchesProgress(missingProgressBook, SmartOperator.EQUALS, "0"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty definitions never match`() {
|
||||
assertFalse(SmartCollectionEngine.evaluate(book(), SmartCollectionDefinition()))
|
||||
}
|
||||
|
||||
private fun matchesProgress(
|
||||
book: BookItem,
|
||||
operator: SmartOperator,
|
||||
value: String
|
||||
): Boolean {
|
||||
return SmartCollectionEngine.evaluate(
|
||||
book,
|
||||
SmartCollectionDefinition(
|
||||
rules = listOf(SmartRule(SmartField.PROGRESS, operator, value))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun book(
|
||||
id: String = "book-id",
|
||||
displayName: String = "display.epub",
|
||||
title: String? = "Display",
|
||||
author: String? = null,
|
||||
progressPercentage: Float? = null,
|
||||
sourceFolder: String? = null,
|
||||
type: FileType = FileType.EPUB,
|
||||
tags: List<Tag> = emptyList()
|
||||
): BookItem {
|
||||
return BookItem(
|
||||
id = id,
|
||||
path = "/library/$displayName",
|
||||
type = type,
|
||||
displayName = displayName,
|
||||
timestamp = 1L,
|
||||
title = title,
|
||||
author = author,
|
||||
progressPercentage = progressPercentage,
|
||||
sourceFolder = sourceFolder,
|
||||
tags = tags
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
package com.aryan.reader.shared.opds
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SharedOpdsCatalogsTest {
|
||||
@Test
|
||||
fun `catalog json seeds defaults and preserves edits`() {
|
||||
var nextId = 0
|
||||
fun id() = "id-${nextId++}"
|
||||
|
||||
val defaults = SharedOpdsCatalogs.decodeOrSeed(null, ::id)
|
||||
assertEquals(2, defaults.size)
|
||||
assertTrue(defaults.all { it.isDefault })
|
||||
|
||||
val added = SharedOpdsCatalogs.addCatalog(defaults, " Custom ", " https://example.org/opds ", " user ", " pass ", ::id)
|
||||
val updated = SharedOpdsCatalogs.updateCatalog(
|
||||
catalogs = added,
|
||||
id = "id-2",
|
||||
title = " Updated ",
|
||||
url = " https://example.org/new ",
|
||||
username = " ",
|
||||
password = " token "
|
||||
)
|
||||
val custom = updated.single { !it.isDefault }
|
||||
assertEquals("Updated", custom.title)
|
||||
assertEquals("https://example.org/new", custom.url)
|
||||
assertNull(custom.username)
|
||||
assertEquals("token", custom.password)
|
||||
|
||||
val encoded = SharedOpdsCatalogs.encode(updated)
|
||||
assertEquals(updated, SharedOpdsCatalogs.decode(encoded))
|
||||
assertEquals(updated, SharedOpdsCatalogs.removeCatalog(updated, defaults.first().id))
|
||||
assertTrue(SharedOpdsCatalogs.removeCatalog(updated, custom.id).all { it.isDefault })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `catalog json decodes null credentials as absent credentials`() {
|
||||
val catalogs = SharedOpdsCatalogs.decode(
|
||||
"""
|
||||
[
|
||||
{
|
||||
"id": "catalog",
|
||||
"title": "Catalog",
|
||||
"url": "https://example.org/opds",
|
||||
"username": null,
|
||||
"password": null
|
||||
}
|
||||
]
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
val catalog = catalogs.single()
|
||||
assertNull(catalog.username)
|
||||
assertNull(catalog.password)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search templates expand opds uri template variants`() {
|
||||
assertEquals(
|
||||
"https://example.org/search?query=ada%20lovelace",
|
||||
SharedOpdsSearch.expandSearchTemplate("https://example.org/search{?query}", "ada lovelace")
|
||||
)
|
||||
assertEquals(
|
||||
"https://example.org/search?q=ada%20lovelace",
|
||||
SharedOpdsSearch.expandSearchTemplate("https://example.org/search?q={searchTerms}", "ada lovelace")
|
||||
)
|
||||
assertEquals(
|
||||
"https://example.org/search?existing=1&query=ada%20lovelace",
|
||||
SharedOpdsSearch.expandSearchTemplate("https://example.org/search?existing=1", "ada lovelace")
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stream uri round trips encoded template and catalog`() {
|
||||
val reference = OpdsStreamReference(
|
||||
id = "book 1",
|
||||
count = 12,
|
||||
urlTemplate = "https://example.org/page/{pageNumber}?w={maxWidth}",
|
||||
catalogId = "catalog 1"
|
||||
)
|
||||
|
||||
assertEquals(reference, SharedOpdsStreamUri.parse(SharedOpdsStreamUri.build(reference)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `download namer prefers content disposition and falls back to acquisition format`() {
|
||||
assertEquals(
|
||||
".azw3",
|
||||
SharedOpdsDownloadNamer.resolveExtension(
|
||||
acquisition = OpdsAcquisition("https://example.org/download", "application/octet-stream"),
|
||||
contentDisposition = "attachment; filename*=UTF-8''Book.azw3",
|
||||
urlPathSegment = null
|
||||
)
|
||||
)
|
||||
assertEquals(
|
||||
".pdf",
|
||||
SharedOpdsDownloadNamer.resolveExtension(
|
||||
acquisition = OpdsAcquisition("https://example.org/download", "application/pdf"),
|
||||
contentDisposition = null,
|
||||
urlPathSegment = null
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,308 @@
|
|||
package com.aryan.reader.shared.pdf
|
||||
|
||||
import com.aryan.reader.shared.PdfDisplayMode
|
||||
import com.aryan.reader.shared.SearchHighlightMode
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class PdfReaderSessionTest {
|
||||
|
||||
@Test
|
||||
fun `initial state clamps page and reports progress`() {
|
||||
val state = SharedPdfReaderState.initial(pageCount = 5, initialPageIndex = 99)
|
||||
|
||||
assertEquals(4, state.pageIndex)
|
||||
assertEquals(5, state.pageCount)
|
||||
assertEquals(100f, state.progressPercent)
|
||||
assertTrue(state.canGoPrevious)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `page navigation clamps to document bounds`() {
|
||||
val state = SharedPdfReaderState.initial(pageCount = 3, initialPageIndex = 1)
|
||||
.reduce(SharedPdfReaderAction.NextPage)
|
||||
.reduce(SharedPdfReaderAction.NextPage)
|
||||
.reduce(SharedPdfReaderAction.PreviousPage)
|
||||
.reduce(SharedPdfReaderAction.GoToPage(-20))
|
||||
|
||||
assertEquals(0, state.pageIndex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `first last and display mode actions are shared`() {
|
||||
val vertical = SharedPdfReaderState.initial(pageCount = 4, initialPageIndex = 1)
|
||||
.reduce(SharedPdfReaderAction.LastPage)
|
||||
.reduce(SharedPdfReaderAction.FirstPage)
|
||||
.reduce(SharedPdfReaderAction.DisplayModeToggled)
|
||||
val state = vertical.reduce(SharedPdfReaderAction.DisplayModeChanged(PdfDisplayMode.PAGINATION))
|
||||
|
||||
assertEquals(0, state.pageIndex)
|
||||
assertEquals(PdfDisplayMode.VERTICAL_SCROLL, vertical.displayMode)
|
||||
assertEquals(PdfDisplayMode.PAGINATION, state.displayMode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `zoom changes use provided zoom spec`() {
|
||||
val zoomSpec = PdfZoomSpec(min = 0.5f, max = 4f, default = 1f)
|
||||
val state = SharedPdfReaderState.initial(pageCount = 1, zoomSpec = zoomSpec)
|
||||
.reduce(SharedPdfReaderAction.ZoomChanged(10f), zoomSpec)
|
||||
.reduce(SharedPdfReaderAction.ZoomBy(-10f), zoomSpec)
|
||||
|
||||
assertEquals(0.5f, state.zoom)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial zoom is clamped to provided zoom spec`() {
|
||||
val zoomSpec = PdfZoomSpec(min = 0.5f, max = 4f, default = 10f)
|
||||
|
||||
val state = SharedPdfReaderState.initial(pageCount = 1, zoomSpec = zoomSpec)
|
||||
|
||||
assertEquals(4f, state.zoom)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search query resets active result and result navigation wraps`() {
|
||||
val results = listOf(
|
||||
SharedPdfSearchResult(pageIndex = 1, preview = "first", matchIndex = 5),
|
||||
SharedPdfSearchResult(pageIndex = 3, preview = "second", matchIndex = 7)
|
||||
)
|
||||
|
||||
val state = SharedPdfReaderState.initial(pageCount = 5)
|
||||
.reduce(SharedPdfReaderAction.GoToSearchResult(0, results))
|
||||
.reduce(SharedPdfReaderAction.SearchChanged("needle"))
|
||||
.reduce(SharedPdfReaderAction.GoToSearchResult(-1, results))
|
||||
|
||||
assertEquals("needle", state.searchQuery)
|
||||
assertEquals(1, state.activeSearchResultIndex)
|
||||
assertEquals(3, state.pageIndex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search highlight mode toggles between all and focused`() {
|
||||
val focused = SharedPdfReaderState.initial(pageCount = 1)
|
||||
.reduce(SharedPdfReaderAction.SearchHighlightModeToggled)
|
||||
val all = focused.reduce(SharedPdfReaderAction.SearchHighlightModeToggled)
|
||||
val explicit = all.reduce(SharedPdfReaderAction.SearchHighlightModeChanged(SearchHighlightMode.FOCUSED))
|
||||
|
||||
assertEquals(SearchHighlightMode.FOCUSED, focused.searchHighlightMode)
|
||||
assertEquals(SearchHighlightMode.ALL, all.searchHighlightMode)
|
||||
assertEquals(SearchHighlightMode.FOCUSED, explicit.searchHighlightMode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tool selection applies shared defaults`() {
|
||||
val state = SharedPdfReaderState.initial(pageCount = 1)
|
||||
.reduce(SharedPdfReaderAction.ToolSelected(PdfInkTool.HIGHLIGHTER))
|
||||
|
||||
val config = SharedPdfAnnotationDefaults.configFor(PdfInkTool.HIGHLIGHTER)
|
||||
assertEquals(PdfInkTool.HIGHLIGHTER, state.selectedTool)
|
||||
assertEquals(config.colorArgb, state.selectedColorArgb)
|
||||
assertEquals(config.strokeWidth, state.strokeWidth)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `annotation actions mutate immutable annotation list`() {
|
||||
val first = annotation("first", pageIndex = 0)
|
||||
val second = annotation("second", pageIndex = 0)
|
||||
val third = annotation("third", pageIndex = 1)
|
||||
|
||||
val state = SharedPdfReaderState.initial(pageCount = 2)
|
||||
.reduce(SharedPdfReaderAction.AnnotationsLoaded(listOf(first)))
|
||||
.reduce(SharedPdfReaderAction.AnnotationAdded(second))
|
||||
.reduce(SharedPdfReaderAction.AnnotationAdded(third))
|
||||
.reduce(SharedPdfReaderAction.UndoLastAnnotationOnPage(0))
|
||||
.reduce(SharedPdfReaderAction.ClearPageAnnotations(1))
|
||||
|
||||
assertEquals(listOf(first), state.annotations)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bookmark actions toggle and normalize pages`() {
|
||||
val state = SharedPdfReaderState.initial(pageCount = 4)
|
||||
.reduce(
|
||||
SharedPdfReaderAction.BookmarksLoaded(
|
||||
listOf(
|
||||
SharedPdfBookmark(pageIndex = 2, label = "Two"),
|
||||
SharedPdfBookmark(pageIndex = 99, label = "Invalid"),
|
||||
SharedPdfBookmark(pageIndex = 2, label = "Duplicate")
|
||||
)
|
||||
)
|
||||
)
|
||||
.reduce(SharedPdfReaderAction.BookmarkToggled(pageIndex = 1, createdAt = 10L))
|
||||
.reduce(SharedPdfReaderAction.BookmarkToggled(pageIndex = 2))
|
||||
|
||||
assertEquals(listOf(1), state.bookmarks.map { it.pageIndex })
|
||||
assertEquals("Page 2", state.bookmarks.single().label)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bookmark serializer round trips store and legacy arrays`() {
|
||||
val bookmarks = listOf(
|
||||
SharedPdfBookmark(pageIndex = 0, label = "Start", createdAt = 11L),
|
||||
SharedPdfBookmark(pageIndex = 3, label = "Appendix", createdAt = 22L)
|
||||
)
|
||||
|
||||
assertEquals(bookmarks, SharedPdfBookmarkSerializer.decode(SharedPdfBookmarkSerializer.encode(bookmarks)))
|
||||
assertEquals(
|
||||
listOf(SharedPdfBookmark(pageIndex = 1, label = "Legacy", createdAt = 33L)),
|
||||
SharedPdfBookmarkSerializer.decode("""[{"pageIndex":1,"label":"Legacy","createdAt":33}]""")
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `jump history records explicit jumps and exposes back and forward pages`() {
|
||||
val recorded = SharedPdfJumpHistory()
|
||||
.record(currentPageIndex = 0, targetPageIndex = 4, pageCount = 10)
|
||||
.record(currentPageIndex = 4, targetPageIndex = 8, pageCount = 10)
|
||||
|
||||
val steppedBack = recorded.stepBack()
|
||||
val branched = steppedBack.record(currentPageIndex = 4, targetPageIndex = 2, pageCount = 10)
|
||||
|
||||
assertEquals(listOf(0, 4, 8), recorded.pages)
|
||||
assertEquals(4, recorded.backPage)
|
||||
assertEquals(null, recorded.forwardPage)
|
||||
assertEquals(0, steppedBack.backPage)
|
||||
assertEquals(8, steppedBack.forwardPage)
|
||||
assertEquals(listOf(0, 4, 2), branched.pages)
|
||||
assertEquals(4, branched.backPage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `jump history ignores invalid jumps prunes document bounds and caps entries`() {
|
||||
val unchanged = SharedPdfJumpHistory()
|
||||
.record(currentPageIndex = 0, targetPageIndex = 0, pageCount = 10)
|
||||
.record(currentPageIndex = 0, targetPageIndex = 99, pageCount = 10)
|
||||
|
||||
val pruned = SharedPdfJumpHistory(pages = listOf(0, 3, 99, 4), cursor = 3)
|
||||
.pruned(pageCount = 5)
|
||||
|
||||
val capped = (0 until 40).fold(SharedPdfJumpHistory(maxEntries = 5)) { history, page ->
|
||||
history.record(
|
||||
currentPageIndex = page,
|
||||
targetPageIndex = page + 1,
|
||||
pageCount = 50
|
||||
)
|
||||
}
|
||||
|
||||
assertTrue(unchanged.pages.isEmpty())
|
||||
assertEquals(listOf(0, 3, 4), pruned.pages)
|
||||
assertEquals(2, pruned.cursor)
|
||||
assertEquals(listOf(36, 37, 38, 39, 40), capped.pages)
|
||||
assertEquals(4, capped.cursor)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `annotation selection update and delete are shared`() {
|
||||
val first = annotation("first", pageIndex = 0)
|
||||
val second = annotation("second", pageIndex = 1)
|
||||
val updated = second.copy(text = "changed", colorArgb = 0xFF222222.toInt())
|
||||
|
||||
val state = SharedPdfReaderState.initial(pageCount = 2)
|
||||
.reduce(SharedPdfReaderAction.AnnotationsLoaded(listOf(first, second)))
|
||||
.reduce(SharedPdfReaderAction.AnnotationSelected("second"))
|
||||
.reduce(SharedPdfReaderAction.AnnotationUpdated(updated))
|
||||
.reduce(SharedPdfReaderAction.AnnotationDeleted("second"))
|
||||
|
||||
assertEquals(listOf(first), state.annotations)
|
||||
assertEquals(null, state.selectedAnnotationId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search engine finds all case-insensitive matches with previews`() {
|
||||
val results = SharedPdfSearchEngine.search(
|
||||
pageTexts = listOf("Alpha beta alpha", "nothing", "ALPHA at the end"),
|
||||
query = "alpha"
|
||||
)
|
||||
|
||||
assertEquals(listOf(0, 0, 2), results.map { it.pageIndex })
|
||||
assertEquals(listOf(0, 11, 0), results.map { it.matchIndex })
|
||||
assertEquals(listOf(5, 5, 5), results.map { it.matchLength })
|
||||
assertTrue(results.first().preview.contains("Alpha"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search index reuses indexed page text and preserves raw match ranges`() {
|
||||
val index = SharedPdfSearchIndex(pageCount = 3)
|
||||
index.putPage(0, "Alpha beta")
|
||||
index.putPage(1, "hello,\nworld appears here")
|
||||
index.putPage(2, "alpha again")
|
||||
|
||||
val punctuationResults = index.search("hello, world")
|
||||
val alphaResults = index.search("alp")
|
||||
|
||||
assertEquals(3, index.indexedPageCount)
|
||||
assertEquals(listOf(1), punctuationResults.map { it.pageIndex })
|
||||
assertEquals(0, punctuationResults.single().matchIndex)
|
||||
assertEquals("hello,\nworld".length, punctuationResults.single().matchLength)
|
||||
assertEquals(listOf(0, 2), alphaResults.map { it.pageIndex })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search highlights return all page matches or only focused match`() {
|
||||
val results = listOf(
|
||||
SharedPdfSearchResult(pageIndex = 0, preview = "first", matchIndex = 0),
|
||||
SharedPdfSearchResult(pageIndex = 0, preview = "second", matchIndex = 12),
|
||||
SharedPdfSearchResult(pageIndex = 1, preview = "third", matchIndex = 3)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf(results[0], results[1]),
|
||||
SharedPdfSearchEngine.highlightsForPage(
|
||||
results = results,
|
||||
pageIndex = 0,
|
||||
activeResultIndex = 2,
|
||||
mode = SearchHighlightMode.ALL
|
||||
)
|
||||
)
|
||||
assertEquals(
|
||||
listOf(results[1]),
|
||||
SharedPdfSearchEngine.highlightsForPage(
|
||||
results = results,
|
||||
pageIndex = 0,
|
||||
activeResultIndex = 1,
|
||||
mode = SearchHighlightMode.FOCUSED
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `most visible page follows largest viewport overlap`() {
|
||||
val visiblePages = listOf(
|
||||
PdfVisiblePageLayout(pageIndex = 2, top = -120f, bottom = 320f),
|
||||
PdfVisiblePageLayout(pageIndex = 3, top = 320f, bottom = 920f),
|
||||
PdfVisiblePageLayout(pageIndex = 4, top = 920f, bottom = 1300f)
|
||||
)
|
||||
|
||||
val pageIndex = mostVisiblePdfPageIndex(
|
||||
visiblePages = visiblePages,
|
||||
viewportTop = 0f,
|
||||
viewportBottom = 800f,
|
||||
fallbackPageIndex = 2
|
||||
)
|
||||
|
||||
assertEquals(3, pageIndex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `most visible page falls back when no measured page overlaps`() {
|
||||
val pageIndex = mostVisiblePdfPageIndex(
|
||||
visiblePages = listOf(PdfVisiblePageLayout(pageIndex = 8, top = 900f, bottom = 1200f)),
|
||||
viewportTop = 0f,
|
||||
viewportBottom = 800f,
|
||||
fallbackPageIndex = 5
|
||||
)
|
||||
|
||||
assertEquals(5, pageIndex)
|
||||
}
|
||||
|
||||
private fun annotation(id: String, pageIndex: Int): SharedPdfAnnotation {
|
||||
return SharedPdfAnnotation(
|
||||
id = id,
|
||||
pageIndex = pageIndex,
|
||||
kind = PdfAnnotationKind.INK,
|
||||
points = listOf(PdfPagePoint(0.1f, 0.2f)),
|
||||
colorArgb = 0xFF111111.toInt()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
package com.aryan.reader.shared.pdf
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class PdfSelectionGeometryTest {
|
||||
|
||||
@Test
|
||||
fun `normalizes points against the current viewport size`() {
|
||||
val point = PdfSelectionGeometry.normalizedPoint(
|
||||
pointX = 50f,
|
||||
pointY = 200f,
|
||||
viewportWidth = 200,
|
||||
viewportHeight = 400
|
||||
)
|
||||
|
||||
assertEquals(PdfNormalizedPoint(0.25f, 0.5f), point)
|
||||
assertNull(PdfSelectionGeometry.normalizedPoint(50f, 200f, 0, 400))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `line fallback picks the nearest character only on a matching line`() {
|
||||
val chars = listOf(
|
||||
PdfTextCharBounds(index = 1, left = 0.10f, top = 0.10f, right = 0.12f, bottom = 0.13f),
|
||||
PdfTextCharBounds(index = 2, left = 0.13f, top = 0.10f, right = 0.15f, bottom = 0.13f),
|
||||
PdfTextCharBounds(index = 20, left = 0.10f, top = 0.30f, right = 0.12f, bottom = 0.33f)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
2,
|
||||
PdfSelectionGeometry.nearestCharOnLine(chars, PdfNormalizedPoint(0.90f, 0.115f))?.index
|
||||
)
|
||||
assertNull(PdfSelectionGeometry.nearestCharOnLine(chars, PdfNormalizedPoint(0.90f, 0.22f)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `merges text rects by visual line`() {
|
||||
val merged = PdfSelectionGeometry.mergeBoundsByLine(
|
||||
listOf(
|
||||
PdfPageBounds(left = 0.10f, top = 0.10f, right = 0.20f, bottom = 0.13f),
|
||||
PdfPageBounds(left = 0.21f, top = 0.101f, right = 0.35f, bottom = 0.131f),
|
||||
PdfPageBounds(left = 0.10f, top = 0.20f, right = 0.25f, bottom = 0.23f)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
PdfPageBounds(left = 0.10f, top = 0.10f, right = 0.35f, bottom = 0.131f),
|
||||
PdfPageBounds(left = 0.10f, top = 0.20f, right = 0.25f, bottom = 0.23f)
|
||||
),
|
||||
merged
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keeps nearby paragraph lines separate`() {
|
||||
val merged = PdfSelectionGeometry.mergeBoundsByLine(
|
||||
listOf(
|
||||
PdfPageBounds(left = 0.10f, top = 0.10f, right = 0.80f, bottom = 0.13f),
|
||||
PdfPageBounds(left = 0.10f, top = 0.118f, right = 0.75f, bottom = 0.148f)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(2, merged.size)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,216 @@
|
|||
package com.aryan.reader.shared.pdf
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SharedPdfAnnotationSerializerTest {
|
||||
|
||||
@Test
|
||||
fun `serializer round trips text highlight annotations`() {
|
||||
val annotation = SharedPdfAnnotation(
|
||||
id = "highlight",
|
||||
pageIndex = 3,
|
||||
kind = PdfAnnotationKind.HIGHLIGHT,
|
||||
tool = PdfInkTool.HIGHLIGHTER,
|
||||
bounds = PdfPageBounds(left = 0.1f, top = 0.2f, right = 0.5f, bottom = 0.24f),
|
||||
text = "Selected text",
|
||||
colorArgb = 0x8CFFEB3B.toInt(),
|
||||
createdAt = 42L
|
||||
)
|
||||
|
||||
val decoded = SharedPdfAnnotationSerializer.decode(
|
||||
SharedPdfAnnotationSerializer.encode(listOf(annotation))
|
||||
)
|
||||
|
||||
assertEquals(listOf(annotation), decoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sidecar codec canonicalizes legacy android annotation payloads`() {
|
||||
val legacyPayload = """
|
||||
{
|
||||
"ink": [
|
||||
{
|
||||
"pageIndex": 1,
|
||||
"annotationType": "INK",
|
||||
"inkType": "PENCIL",
|
||||
"color": -16777216,
|
||||
"strokeWidth": 0.008,
|
||||
"points": [{"x":0.1,"y":0.2,"t":10},{"x":0.3,"y":0.4,"t":12}]
|
||||
}
|
||||
],
|
||||
"textBoxes": [
|
||||
{
|
||||
"id": "box-1",
|
||||
"pageIndex": 2,
|
||||
"text": "Typed note",
|
||||
"color": -15654349,
|
||||
"backgroundColor": 1712398870,
|
||||
"fontSize": 0.032,
|
||||
"isBold": true,
|
||||
"bounds": {"left":0.1,"top":0.2,"right":0.5,"bottom":0.3}
|
||||
}
|
||||
],
|
||||
"highlights": [
|
||||
{
|
||||
"id": "highlight-1",
|
||||
"pageIndex": 3,
|
||||
"color": "BLUE",
|
||||
"text": "Selected text",
|
||||
"rangeStart": 4,
|
||||
"rangeEnd": 18,
|
||||
"note": "Keep this",
|
||||
"bounds": []
|
||||
}
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val canonical = SharedPdfAnnotationSidecarCodec.canonicalizeDataJson(legacyPayload)
|
||||
val data = testJson.parseToJsonElement(canonical).jsonObject
|
||||
val annotations = SharedPdfAnnotationSidecarCodec.annotationsFromData(data)
|
||||
|
||||
assertNotNull(data[SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS])
|
||||
assertEquals(listOf(PdfAnnotationKind.INK, PdfAnnotationKind.TEXT, PdfAnnotationKind.HIGHLIGHT), annotations.map { it.kind })
|
||||
assertEquals(PdfInkTool.PENCIL, annotations[0].tool)
|
||||
assertEquals(16f, annotations[1].fontSize, 0.001f)
|
||||
assertTrue(annotations[1].isBold)
|
||||
assertEquals("Keep this", annotations[2].note)
|
||||
assertEquals(4, annotations[2].rangeStartIndex)
|
||||
assertEquals(17, annotations[2].rangeEndIndex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sidecar codec expands canonical annotations for android legacy readers`() {
|
||||
val annotations = listOf(
|
||||
SharedPdfAnnotation(
|
||||
id = "ink-1",
|
||||
pageIndex = 0,
|
||||
kind = PdfAnnotationKind.INK,
|
||||
tool = PdfInkTool.FOUNTAIN_PEN,
|
||||
points = listOf(PdfPagePoint(0.1f, 0.2f, 1L), PdfPagePoint(0.2f, 0.3f, 2L)),
|
||||
colorArgb = 0xFF0000FF.toInt(),
|
||||
strokeWidth = 0.009f
|
||||
),
|
||||
SharedPdfAnnotation(
|
||||
id = "text-1",
|
||||
pageIndex = 1,
|
||||
kind = PdfAnnotationKind.TEXT,
|
||||
tool = PdfInkTool.TEXT,
|
||||
bounds = PdfPageBounds(0.2f, 0.3f, 0.6f, 0.5f),
|
||||
text = "Desktop text",
|
||||
colorArgb = 0xFF112233.toInt(),
|
||||
backgroundArgb = 0x66112233,
|
||||
fontSize = 20f
|
||||
),
|
||||
SharedPdfAnnotation(
|
||||
id = "highlight-1",
|
||||
pageIndex = 2,
|
||||
kind = PdfAnnotationKind.HIGHLIGHT,
|
||||
tool = PdfInkTool.HIGHLIGHTER,
|
||||
text = "Desktop highlight",
|
||||
note = "Synced note",
|
||||
colorArgb = 0x8C64B5F6.toInt(),
|
||||
rangeStartIndex = 7,
|
||||
rangeEndIndex = 21
|
||||
)
|
||||
)
|
||||
val canonicalPayload = testJson.encodeToString(
|
||||
JsonElement.serializer(),
|
||||
JsonObject(
|
||||
mapOf(
|
||||
SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS to
|
||||
SharedPdfAnnotationSidecarCodec.encodeAnnotationsElement(annotations)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val legacyPayload = SharedPdfAnnotationSidecarCodec.legacyAndroidDataJsonFromCanonical(canonicalPayload)
|
||||
val legacy = testJson.parseToJsonElement(legacyPayload).jsonObject
|
||||
|
||||
assertEquals(1, legacy.getValue("ink").jsonArray.size)
|
||||
assertEquals("FOUNTAIN_PEN", legacy.getValue("ink").jsonArray[0].jsonObject.getValue("inkType").jsonPrimitive.content)
|
||||
assertEquals(1, legacy.getValue("textBoxes").jsonArray.size)
|
||||
assertEquals(
|
||||
0.04,
|
||||
legacy.getValue("textBoxes").jsonArray[0].jsonObject.getValue("fontSize").jsonPrimitive.content.toDouble(),
|
||||
0.0001
|
||||
)
|
||||
assertEquals(1, legacy.getValue("highlights").jsonArray.size)
|
||||
assertEquals("Synced note", legacy.getValue("highlights").jsonArray[0].jsonObject.getValue("note").jsonPrimitive.content)
|
||||
assertEquals(22, legacy.getValue("highlights").jsonArray[0].jsonObject.getValue("rangeEnd").jsonPrimitive.content.toInt())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `embedded annotation threads link replies and nearby orphan comments`() {
|
||||
val root = embeddedAnnotation(
|
||||
id = "root",
|
||||
index = 0,
|
||||
contents = "Root comment",
|
||||
name = "root-name",
|
||||
bounds = PdfPageBounds(0.1f, 0.1f, 0.2f, 0.2f)
|
||||
)
|
||||
val reply = embeddedAnnotation(
|
||||
id = "reply",
|
||||
index = 1,
|
||||
contents = "Reply comment",
|
||||
name = "reply-name",
|
||||
inReplyTo = "root-name",
|
||||
bounds = PdfPageBounds(0.11f, 0.11f, 0.21f, 0.21f)
|
||||
)
|
||||
val nearbyOrphan = embeddedAnnotation(
|
||||
id = "nearby",
|
||||
index = 2,
|
||||
contents = "Nearby comment",
|
||||
name = "nearby-name",
|
||||
bounds = PdfPageBounds(0.12f, 0.12f, 0.22f, 0.22f)
|
||||
)
|
||||
val empty = embeddedAnnotation(
|
||||
id = "empty",
|
||||
index = 3,
|
||||
contents = "",
|
||||
name = "empty-name",
|
||||
bounds = PdfPageBounds(0.8f, 0.8f, 0.9f, 0.9f)
|
||||
)
|
||||
|
||||
val grouped = SharedPdfEmbeddedAnnotationThreads.group(listOf(root, reply, nearbyOrphan, empty))
|
||||
|
||||
assertEquals(listOf("root"), grouped.map { it.id })
|
||||
assertEquals(listOf("reply", "nearby"), grouped.single().replies.map { it.id })
|
||||
}
|
||||
|
||||
private fun embeddedAnnotation(
|
||||
id: String,
|
||||
index: Int,
|
||||
contents: String,
|
||||
name: String,
|
||||
bounds: PdfPageBounds,
|
||||
inReplyTo: String = ""
|
||||
): SharedPdfEmbeddedAnnotation {
|
||||
return SharedPdfEmbeddedAnnotation(
|
||||
id = id,
|
||||
pageIndex = 0,
|
||||
index = index,
|
||||
subtype = PdfiumAnnotationSubtype.TEXT,
|
||||
bounds = bounds,
|
||||
contents = contents,
|
||||
author = "Reader",
|
||||
name = name,
|
||||
inReplyTo = inReplyTo
|
||||
)
|
||||
}
|
||||
|
||||
private val testJson = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
package com.aryan.reader.shared.pdf
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SharedPdfInkRenderingTest {
|
||||
|
||||
@Test
|
||||
fun `normalized Android stroke widths scale from page width`() {
|
||||
assertEquals(
|
||||
expected = 8f,
|
||||
actual = SharedPdfInkRenderer.effectiveStrokeWidthPx(0.008f, pageWidthPx = 1_000f),
|
||||
absoluteTolerance = 0.0001f
|
||||
)
|
||||
assertEquals(
|
||||
expected = 35f,
|
||||
actual = SharedPdfInkRenderer.effectiveStrokeWidthPx(0.035f, pageWidthPx = 1_000f),
|
||||
absoluteTolerance = 0.0001f
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `legacy desktop pixel stroke widths remain usable`() {
|
||||
assertEquals(
|
||||
expected = 12f,
|
||||
actual = SharedPdfInkRenderer.effectiveStrokeWidthPx(12f, pageWidthPx = 1_000f),
|
||||
absoluteTolerance = 0.0001f
|
||||
)
|
||||
assertEquals(
|
||||
expected = 0.012f,
|
||||
actual = SharedPdfInkRenderer.effectiveStrokeWidthNorm(12f, pageWidthPx = 1_000f),
|
||||
absoluteTolerance = 0.0001f
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `snap helper follows Android horizontal and vertical threshold behavior`() {
|
||||
val start = PdfPagePoint(0.2f, 0.2f)
|
||||
val horizontal = SharedPdfInkRenderer.calculateSnappedPoint(
|
||||
currentPoint = PdfPagePoint(0.8f, 0.215f),
|
||||
startPoint = start,
|
||||
pageAspectRatio = 1f
|
||||
)
|
||||
val vertical = SharedPdfInkRenderer.calculateSnappedPoint(
|
||||
currentPoint = PdfPagePoint(0.215f, 0.8f),
|
||||
startPoint = start,
|
||||
pageAspectRatio = 1f
|
||||
)
|
||||
|
||||
assertEquals(start.y, horizontal.y)
|
||||
assertEquals(start.x, vertical.x)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `eraser hit test checks full ink segments instead of only sampled points`() {
|
||||
val annotation = SharedPdfAnnotation(
|
||||
id = "ink",
|
||||
pageIndex = 0,
|
||||
kind = PdfAnnotationKind.INK,
|
||||
tool = PdfInkTool.PEN,
|
||||
points = listOf(PdfPagePoint(0.1f, 0.2f), PdfPagePoint(0.9f, 0.2f)),
|
||||
colorArgb = 0xFFFF0000.toInt(),
|
||||
strokeWidth = 0.008f
|
||||
)
|
||||
|
||||
assertTrue(
|
||||
SharedPdfInkRenderer.isAnnotationHit(
|
||||
annotation = annotation,
|
||||
hitPoint = PdfPagePoint(0.5f, 0.205f),
|
||||
pageWidthPx = 1_000f,
|
||||
pageAspectRatio = 1f,
|
||||
eraserStrokeWidth = 0.01f
|
||||
)
|
||||
)
|
||||
assertFalse(
|
||||
SharedPdfInkRenderer.isAnnotationHit(
|
||||
annotation = annotation,
|
||||
hitPoint = PdfPagePoint(0.5f, 0.4f),
|
||||
pageWidthPx = 1_000f,
|
||||
pageAspectRatio = 1f,
|
||||
eraserStrokeWidth = 0.01f
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `serializer preserves richer shared text annotation style`() {
|
||||
val annotation = SharedPdfAnnotation(
|
||||
id = "text",
|
||||
pageIndex = 2,
|
||||
kind = PdfAnnotationKind.TEXT,
|
||||
tool = PdfInkTool.TEXT,
|
||||
bounds = PdfPageBounds(0.1f, 0.2f, 0.5f, 0.3f),
|
||||
text = "Styled note",
|
||||
colorArgb = 0xFF101010.toInt(),
|
||||
backgroundArgb = 0x55FFEB3B,
|
||||
fontSize = 20f,
|
||||
isBold = true,
|
||||
isItalic = true,
|
||||
isUnderline = true,
|
||||
isStrikeThrough = true,
|
||||
fontName = "Merriweather",
|
||||
fontPath = "asset:fonts/merriweather.ttf"
|
||||
)
|
||||
|
||||
val decoded = SharedPdfAnnotationSerializer.decode(
|
||||
SharedPdfAnnotationSerializer.encode(listOf(annotation))
|
||||
)
|
||||
|
||||
assertEquals(listOf(annotation), decoded)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,246 @@
|
|||
package com.aryan.reader.shared.pdf
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SharedPdfRichTextTest {
|
||||
|
||||
@Test
|
||||
fun `mapper clips global rich spans into requested local range`() {
|
||||
val document = SharedPdfRichDocument(
|
||||
text = "0123456789",
|
||||
spans = listOf(
|
||||
SharedPdfRichSpan(
|
||||
start = 2,
|
||||
end = 6,
|
||||
color = Color.Red.toArgb(),
|
||||
backgroundColor = Color.Yellow.toArgb(),
|
||||
fontSizeNorm = 0.02f,
|
||||
isBold = true,
|
||||
isItalic = true,
|
||||
isUnderline = true,
|
||||
isStrikethrough = true,
|
||||
fontPath = "asset:fonts/lora.ttf"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val annotated = SharedPdfRichTextMapper.toAnnotatedString(
|
||||
document = document,
|
||||
pageHeightPx = 1_000f,
|
||||
rangeStart = 4,
|
||||
rangeEnd = 8
|
||||
)
|
||||
|
||||
assertEquals("4567", annotated.text)
|
||||
val range = annotated.spanStyles.single()
|
||||
assertEquals(0, range.start)
|
||||
assertEquals(2, range.end)
|
||||
assertEquals(Color.Red, range.item.color)
|
||||
assertEquals(Color.Yellow, range.item.background)
|
||||
assertEquals(20.sp, range.item.fontSize)
|
||||
assertEquals(FontWeight.Bold, range.item.fontWeight)
|
||||
assertEquals(FontStyle.Italic, range.item.fontStyle)
|
||||
assertTrue(range.item.textDecoration!!.contains(TextDecoration.Underline))
|
||||
assertTrue(range.item.textDecoration!!.contains(TextDecoration.LineThrough))
|
||||
|
||||
val roundTrip = SharedPdfRichTextMapper.fromAnnotatedString(annotated, pageHeightPx = 1_000f)
|
||||
assertEquals("4567", roundTrip.text)
|
||||
assertEquals("asset:fonts/lora.ttf", roundTrip.spans.single().fontPath)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mapper fromAnnotatedString splits overlapping styles and preserves page breaks`() {
|
||||
val text = "Hello${SHARED_PDF_PAGE_BREAK_CHAR}World"
|
||||
val annotated = buildAnnotatedString {
|
||||
append(text)
|
||||
addStyle(
|
||||
SpanStyle(
|
||||
color = Color.Black,
|
||||
background = Color.Transparent,
|
||||
fontSize = 20.sp
|
||||
),
|
||||
start = 0,
|
||||
end = text.length
|
||||
)
|
||||
addStyle(
|
||||
SpanStyle(
|
||||
color = Color.Magenta,
|
||||
background = Color.Cyan,
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontStyle = FontStyle.Italic,
|
||||
textDecoration = TextDecoration.combine(
|
||||
listOf(TextDecoration.Underline, TextDecoration.LineThrough)
|
||||
)
|
||||
),
|
||||
start = 0,
|
||||
end = 5
|
||||
)
|
||||
}
|
||||
|
||||
val document = SharedPdfRichTextMapper.fromAnnotatedString(annotated, pageHeightPx = 1_000f)
|
||||
|
||||
assertEquals(text, document.text)
|
||||
assertEquals(2, document.spans.size)
|
||||
val first = document.spans[0]
|
||||
assertEquals(0, first.start)
|
||||
assertEquals(5, first.end)
|
||||
assertEquals(Color.Magenta.toArgb(), first.color)
|
||||
assertEquals(Color.Cyan.toArgb(), first.backgroundColor)
|
||||
assertEquals(0.024f, first.fontSizeNorm, 0.0001f)
|
||||
assertTrue(first.isBold)
|
||||
assertTrue(first.isItalic)
|
||||
assertTrue(first.isUnderline)
|
||||
assertTrue(first.isStrikethrough)
|
||||
val second = document.spans[1]
|
||||
assertEquals(5, second.start)
|
||||
assertEquals(text.length, second.end)
|
||||
assertEquals(Color.Black.toArgb(), second.color)
|
||||
assertFalse(second.isBold)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `serializer uses android rich text sidecar schema`() {
|
||||
val document = SharedPdfRichDocument(
|
||||
text = "Saved rich text",
|
||||
spans = listOf(
|
||||
SharedPdfRichSpan(
|
||||
start = 0,
|
||||
end = 5,
|
||||
color = Color.Red.toArgb(),
|
||||
backgroundColor = Color.Transparent.toArgb(),
|
||||
fontSizeNorm = 0.018f,
|
||||
isBold = true,
|
||||
isItalic = false,
|
||||
isUnderline = true,
|
||||
isStrikethrough = false,
|
||||
fontPath = "asset:fonts/lora.ttf"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val encoded = SharedPdfRichTextSerializer.encode(document)
|
||||
val decoded = SharedPdfRichTextSerializer.decode(encoded)
|
||||
|
||||
assertTrue(encoded.contains("\"s\""))
|
||||
assertTrue(encoded.contains("\"fp\""))
|
||||
assertEquals(document, decoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `serializer returns empty document for blank and corrupt payloads`() {
|
||||
assertEquals(SharedPdfRichDocument(), SharedPdfRichTextSerializer.decode(""))
|
||||
assertEquals(SharedPdfRichDocument(), SharedPdfRichTextSerializer.decode("{not json"))
|
||||
assertEquals(
|
||||
SharedPdfRichDocument("", emptyList()),
|
||||
SharedPdfRichTextMapper.fromAnnotatedString(AnnotatedString(""), pageHeightPx = 1_000f)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `trailing page break creates editable blank page layout`() {
|
||||
val globalText = AnnotatedString("$SHARED_PDF_PAGE_BREAK_CHAR")
|
||||
val layouts = listOf(
|
||||
SharedPdfRichPageLayout(
|
||||
pageIndex = 0,
|
||||
visibleText = globalText,
|
||||
globalStartIndex = 0,
|
||||
globalEndIndex = 1,
|
||||
pageHeightPx = 1_000f
|
||||
)
|
||||
)
|
||||
|
||||
val withBlankPage = layouts.withTrailingBlankRichTextPageIfNeeded(
|
||||
globalText = globalText,
|
||||
pageHeightPx = 1_000f
|
||||
)
|
||||
|
||||
assertEquals(2, withBlankPage.size)
|
||||
assertEquals(1, withBlankPage.last().pageIndex)
|
||||
assertEquals("", withBlankPage.last().visibleText.text)
|
||||
assertEquals(1, withBlankPage.last().globalStartIndex)
|
||||
assertEquals(1, withBlankPage.last().globalEndIndex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `trailing blank page helper is idempotent`() {
|
||||
val globalText = AnnotatedString("A$SHARED_PDF_PAGE_BREAK_CHAR")
|
||||
val layouts = listOf(
|
||||
SharedPdfRichPageLayout(
|
||||
pageIndex = 0,
|
||||
visibleText = AnnotatedString("A$SHARED_PDF_PAGE_BREAK_CHAR"),
|
||||
globalStartIndex = 0,
|
||||
globalEndIndex = 2,
|
||||
pageHeightPx = 1_000f
|
||||
),
|
||||
SharedPdfRichPageLayout(
|
||||
pageIndex = 1,
|
||||
visibleText = AnnotatedString(""),
|
||||
globalStartIndex = 2,
|
||||
globalEndIndex = 2,
|
||||
pageHeightPx = 1_000f
|
||||
)
|
||||
)
|
||||
|
||||
val withBlankPage = layouts.withTrailingBlankRichTextPageIfNeeded(
|
||||
globalText = globalText,
|
||||
pageHeightPx = 1_000f
|
||||
)
|
||||
|
||||
assertEquals(layouts, withBlankPage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `consecutive explicit page breaks keep editable blank pages`() {
|
||||
val globalText = AnnotatedString("A$SHARED_PDF_PAGE_BREAK_CHAR$SHARED_PDF_PAGE_BREAK_CHAR")
|
||||
val layouts = listOf(
|
||||
SharedPdfRichPageLayout(
|
||||
pageIndex = 0,
|
||||
visibleText = AnnotatedString("A$SHARED_PDF_PAGE_BREAK_CHAR"),
|
||||
globalStartIndex = 0,
|
||||
globalEndIndex = 2,
|
||||
pageHeightPx = 1_000f
|
||||
),
|
||||
SharedPdfRichPageLayout(
|
||||
pageIndex = 1,
|
||||
visibleText = AnnotatedString("$SHARED_PDF_PAGE_BREAK_CHAR"),
|
||||
globalStartIndex = 2,
|
||||
globalEndIndex = 3,
|
||||
pageHeightPx = 1_000f
|
||||
)
|
||||
)
|
||||
|
||||
val withBlankPage = layouts.withTrailingBlankRichTextPageIfNeeded(
|
||||
globalText = globalText,
|
||||
pageHeightPx = 1_000f
|
||||
)
|
||||
|
||||
assertEquals(3, withBlankPage.size)
|
||||
assertEquals("A$SHARED_PDF_PAGE_BREAK_CHAR", withBlankPage[0].visibleText.text)
|
||||
assertEquals("$SHARED_PDF_PAGE_BREAK_CHAR", withBlankPage[1].visibleText.text)
|
||||
assertEquals("", withBlankPage[2].visibleText.text)
|
||||
assertEquals(3, withBlankPage[2].globalStartIndex)
|
||||
assertEquals(3, withBlankPage[2].globalEndIndex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `editable rich text hides trailing structural page break`() {
|
||||
val text = AnnotatedString("Body$SHARED_PDF_PAGE_BREAK_CHAR")
|
||||
|
||||
val editable = text.withoutTrailingSharedPdfPageBreak()
|
||||
|
||||
assertEquals("Body", editable.text)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,222 @@
|
|||
package com.aryan.reader.shared.pdf
|
||||
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import kotlin.math.abs
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SharedPdfTextAnnotationsTest {
|
||||
|
||||
@Test
|
||||
fun `createAnnotation applies Android-style text config`() {
|
||||
val style = SharedPdfTextStyleConfig(
|
||||
colorArgb = 0xFF123456.toInt(),
|
||||
backgroundColorArgb = 0x8CFFEB3B.toInt(),
|
||||
fontSize = 20f,
|
||||
isBold = true,
|
||||
isItalic = true,
|
||||
isUnderline = true,
|
||||
isStrikeThrough = true,
|
||||
fontPath = "asset:fonts/lora.ttf",
|
||||
fontName = "Lora"
|
||||
)
|
||||
|
||||
val annotation = SharedPdfTextAnnotationDefaults.createAnnotation(
|
||||
id = "text-1",
|
||||
pageIndex = 3,
|
||||
anchor = PdfPagePoint(0.8f, 0.92f, 42L),
|
||||
canvasSize = IntSize(1_000, 1_400),
|
||||
text = " Styled note ",
|
||||
style = style,
|
||||
createdAt = 99L
|
||||
)
|
||||
|
||||
assertEquals(PdfAnnotationKind.TEXT, annotation.kind)
|
||||
assertEquals(PdfInkTool.TEXT, annotation.tool)
|
||||
assertEquals("Styled note", annotation.text)
|
||||
assertEquals(style, annotation.sharedPdfTextStyle())
|
||||
assertEquals(99L, annotation.createdAt)
|
||||
assertTrue(annotation.bounds!!.left >= 0f)
|
||||
assertTrue(annotation.bounds.right <= 1f)
|
||||
assertTrue(annotation.bounds.top >= 0f)
|
||||
assertTrue(annotation.bounds.bottom <= 1f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `withSharedPdfTextStyle replaces all style fields only`() {
|
||||
val original = SharedPdfAnnotation(
|
||||
id = "text-2",
|
||||
pageIndex = 1,
|
||||
kind = PdfAnnotationKind.TEXT,
|
||||
tool = PdfInkTool.TEXT,
|
||||
bounds = PdfPageBounds(0.1f, 0.2f, 0.5f, 0.3f),
|
||||
text = "Keep me",
|
||||
colorArgb = 0xFF000000.toInt(),
|
||||
backgroundArgb = 0x00000000,
|
||||
fontSize = 16f,
|
||||
createdAt = 5L
|
||||
)
|
||||
val style = SharedPdfTextStyleConfig(
|
||||
colorArgb = 0xFFFF0000.toInt(),
|
||||
backgroundColorArgb = 0x8C64B5F6.toInt(),
|
||||
fontSize = 24f,
|
||||
isBold = true,
|
||||
fontName = "Roboto Mono",
|
||||
fontPath = "asset:fonts/roboto_mono.ttf"
|
||||
)
|
||||
|
||||
val updated = original.withSharedPdfTextStyle(style)
|
||||
|
||||
assertEquals("text-2", updated.id)
|
||||
assertEquals("Keep me", updated.text)
|
||||
assertEquals(original.bounds, updated.bounds)
|
||||
assertEquals(5L, updated.createdAt)
|
||||
assertEquals(style, updated.sharedPdfTextStyle())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `text bounds grow for wrapped content and stay on page`() {
|
||||
val style = SharedPdfTextStyleConfig(fontSize = 18f)
|
||||
val shortBounds = SharedPdfTextAnnotationDefaults.boundsForPlacedText(
|
||||
anchor = PdfPagePoint(0.1f, 0.1f),
|
||||
canvasSize = IntSize(800, 1_200),
|
||||
text = "Short",
|
||||
style = style
|
||||
)
|
||||
val longBounds = SharedPdfTextAnnotationDefaults.boundsForPlacedText(
|
||||
anchor = PdfPagePoint(0.92f, 0.96f),
|
||||
canvasSize = IntSize(800, 1_200),
|
||||
text = "This is a much longer text annotation that should wrap across multiple lines.",
|
||||
style = style
|
||||
)
|
||||
|
||||
assertTrue(longBounds.bottom - longBounds.top > shortBounds.bottom - shortBounds.top)
|
||||
assertTrue(longBounds.right <= 1f)
|
||||
assertTrue(longBounds.bottom <= 1f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `draft starts empty at click location and commits as text annotation`() {
|
||||
val style = SharedPdfTextStyleConfig(
|
||||
colorArgb = 0xFF4A148C.toInt(),
|
||||
backgroundColorArgb = 0x8CFFEB3B.toInt(),
|
||||
fontSize = 18f,
|
||||
isBold = true
|
||||
)
|
||||
val draft = SharedPdfTextAnnotationDefaults.createDraft(
|
||||
id = "text-draft",
|
||||
pageIndex = 2,
|
||||
anchor = PdfPagePoint(0.2f, 0.3f, 7L),
|
||||
canvasSize = IntSize(1_000, 1_400),
|
||||
style = style,
|
||||
createdAt = 7L
|
||||
).withText(" Inline note ", IntSize(1_000, 1_400))
|
||||
|
||||
val annotation = draft.toAnnotation()
|
||||
|
||||
assertEquals(PdfAnnotationKind.TEXT, annotation.kind)
|
||||
assertEquals(PdfInkTool.TEXT, annotation.tool)
|
||||
assertEquals("Inline note", annotation.text)
|
||||
assertEquals(style, annotation.sharedPdfTextStyle())
|
||||
assertEquals(draft.bounds, annotation.bounds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `draft reflows when text or style changes`() {
|
||||
val canvasSize = IntSize(800, 1_200)
|
||||
val draft = SharedPdfTextAnnotationDefaults.createDraft(
|
||||
id = "text-draft-2",
|
||||
pageIndex = 0,
|
||||
anchor = PdfPagePoint(0.82f, 0.9f),
|
||||
canvasSize = canvasSize,
|
||||
style = SharedPdfTextStyleConfig(fontSize = 14f),
|
||||
createdAt = 11L
|
||||
)
|
||||
val expanded = draft.withText(
|
||||
"A longer inline text annotation that wraps across more than one row.",
|
||||
canvasSize
|
||||
)
|
||||
val restyled = expanded.withStyle(expanded.style.copy(fontSize = 24f), canvasSize)
|
||||
|
||||
assertTrue(expanded.bounds.bottom - expanded.bounds.top > draft.bounds.bottom - draft.bounds.top)
|
||||
assertTrue(restyled.bounds.bottom - restyled.bounds.top > expanded.bounds.bottom - expanded.bounds.top)
|
||||
assertTrue(restyled.bounds.right <= 1f)
|
||||
assertTrue(restyled.bounds.bottom <= 1f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `manually sized draft preserves bounds while typing and styling`() {
|
||||
val canvasSize = IntSize(800, 1_200)
|
||||
val resizedBounds = PdfPageBounds(0.2f, 0.3f, 0.7f, 0.48f)
|
||||
val draft = SharedPdfTextAnnotationDefaults.createDraft(
|
||||
id = "text-draft-3",
|
||||
pageIndex = 0,
|
||||
anchor = PdfPagePoint(0.2f, 0.3f),
|
||||
canvasSize = canvasSize,
|
||||
style = SharedPdfTextStyleConfig(fontSize = 14f),
|
||||
createdAt = 12L
|
||||
).withBounds(resizedBounds)
|
||||
|
||||
val typed = draft.withText("Manual size should stay fixed", canvasSize)
|
||||
val styled = typed.withStyle(typed.style.copy(fontSize = 24f), canvasSize)
|
||||
|
||||
assertEquals(resizedBounds, typed.bounds)
|
||||
assertEquals(resizedBounds, styled.bounds)
|
||||
assertTrue(styled.isManuallySized)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resize handle updates normalized bounds and keeps box on page`() {
|
||||
val resized = PdfPageBounds(0.2f, 0.2f, 0.5f, 0.4f).resizedBy(
|
||||
handle = SharedPdfTextResizeHandle.BOTTOM_RIGHT,
|
||||
deltaXPx = 160f,
|
||||
deltaYPx = 120f,
|
||||
canvasSize = IntSize(1_000, 1_000)
|
||||
)
|
||||
val clamped = resized.resizedBy(
|
||||
handle = SharedPdfTextResizeHandle.TOP_LEFT,
|
||||
deltaXPx = -1_000f,
|
||||
deltaYPx = -1_000f,
|
||||
canvasSize = IntSize(1_000, 1_000)
|
||||
)
|
||||
|
||||
assertTrue(abs(resized.right - 0.66f) < 0.001f)
|
||||
assertTrue(abs(resized.bottom - 0.52f) < 0.001f)
|
||||
assertEquals(0f, clamped.left)
|
||||
assertEquals(0f, clamped.top)
|
||||
assertTrue(clamped.right <= 1f)
|
||||
assertTrue(clamped.bottom <= 1f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `move keeps text box size and clamps to page`() {
|
||||
val moved = PdfPageBounds(0.2f, 0.3f, 0.5f, 0.45f).movedBy(
|
||||
deltaXPx = 100f,
|
||||
deltaYPx = -120f,
|
||||
canvasSize = IntSize(1_000, 1_000)
|
||||
)
|
||||
val clamped = moved.movedBy(
|
||||
deltaXPx = 1_000f,
|
||||
deltaYPx = 1_000f,
|
||||
canvasSize = IntSize(1_000, 1_000)
|
||||
)
|
||||
|
||||
assertTrue(abs((moved.right - moved.left) - 0.3f) < 0.001f)
|
||||
assertTrue(abs((moved.bottom - moved.top) - 0.15f) < 0.001f)
|
||||
assertTrue(abs(moved.left - 0.3f) < 0.001f)
|
||||
assertTrue(abs(moved.top - 0.18f) < 0.001f)
|
||||
assertTrue(abs(clamped.left - 0.7f) < 0.001f)
|
||||
assertTrue(abs(clamped.top - 0.85f) < 0.001f)
|
||||
assertEquals(1f, clamped.right)
|
||||
assertEquals(1f, clamped.bottom)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `normalizeTextDraft trims and normalizes line endings`() {
|
||||
assertEquals(
|
||||
"Line one\nLine two",
|
||||
SharedPdfTextAnnotationDefaults.normalizeTextDraft(" \r\nLine one\r\nLine two\n ")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,190 @@
|
|||
package com.aryan.reader.shared.reader
|
||||
|
||||
import com.aryan.reader.paginatedreader.CssStyle
|
||||
import com.aryan.reader.paginatedreader.SemanticParagraph
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertSame
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ReaderEngineTest {
|
||||
|
||||
@Test
|
||||
fun `createSession restores page and valid bookmarks`() {
|
||||
val engine = ReaderEngine()
|
||||
val book = longBook()
|
||||
val restored = engine.createSession(
|
||||
book = book,
|
||||
initialPageIndex = 2,
|
||||
bookmarks = listOf(
|
||||
ReaderBookmark("keep", pageIndex = 1, chapterTitle = "One", preview = "Valid"),
|
||||
ReaderBookmark("drop", pageIndex = 200, chapterTitle = "One", preview = "Invalid")
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(2, restored.reader.currentPageIndex)
|
||||
assertEquals(listOf("keep"), restored.bookmarks.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `createSession reuses paginated pages for the same book and settings`() {
|
||||
val engine = ReaderEngine()
|
||||
val book = longBook()
|
||||
|
||||
val first = engine.createSession(book)
|
||||
val second = engine.createSession(book)
|
||||
|
||||
assertSame(first.reader.pages, second.reader.pages)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search returns every match on a page`() {
|
||||
val engine = ReaderEngine()
|
||||
val session = engine.createSession(
|
||||
SharedEpubBook(
|
||||
id = "book",
|
||||
fileName = "book.epub",
|
||||
title = "Book",
|
||||
chapters = listOf(
|
||||
SharedEpubChapter(
|
||||
id = "one",
|
||||
title = "One",
|
||||
plainText = "Alpha beta alpha gamma ALPHA."
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val searched = engine.search(session, "alpha")
|
||||
|
||||
assertEquals(3, searched.searchResults.size)
|
||||
assertEquals(listOf(0, 11, 23), searched.searchResults.map { it.matchIndex })
|
||||
assertTrue(searched.searchResults.all { it.pageIndex == 0 })
|
||||
|
||||
val secondMatch = engine.goToSearchResult(searched, 1)
|
||||
|
||||
assertEquals(1, secondMatch.activeSearchResultIndex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolveLink returns external target for web urls`() {
|
||||
val engine = ReaderEngine()
|
||||
val session = engine.createSession(longBook())
|
||||
|
||||
val target = engine.resolveLink(session, "https://example.com/page", sourceChapterIndex = 0)
|
||||
|
||||
assertTrue(target is ReaderLinkTarget.External)
|
||||
target as ReaderLinkTarget.External
|
||||
assertEquals("https://example.com/page", target.url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolveLink normalizes scheme-less web links`() {
|
||||
val engine = ReaderEngine()
|
||||
val session = engine.createSession(longBook())
|
||||
|
||||
val target = engine.resolveLink(session, "www.example.com/page", sourceChapterIndex = 0)
|
||||
|
||||
assertTrue(target is ReaderLinkTarget.External)
|
||||
target as ReaderLinkTarget.External
|
||||
assertEquals("https://www.example.com/page", target.url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolveLink maps relative epub href to target chapter locator`() {
|
||||
val engine = ReaderEngine()
|
||||
val targetText = "Intro target paragraph"
|
||||
val session = engine.createSession(
|
||||
SharedEpubBook(
|
||||
id = "links",
|
||||
fileName = "links.epub",
|
||||
title = "Links",
|
||||
chapters = listOf(
|
||||
SharedEpubChapter(
|
||||
id = "one",
|
||||
title = "One",
|
||||
plainText = "Source chapter",
|
||||
baseHref = "Text/one.xhtml"
|
||||
),
|
||||
SharedEpubChapter(
|
||||
id = "two",
|
||||
title = "Two",
|
||||
plainText = targetText,
|
||||
semanticBlocks = listOf(
|
||||
SemanticParagraph(
|
||||
text = targetText,
|
||||
spans = emptyList(),
|
||||
style = CssStyle(),
|
||||
elementId = "target",
|
||||
cfi = null,
|
||||
startCharOffsetInSource = 6
|
||||
)
|
||||
),
|
||||
baseHref = "Text/two.xhtml"
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val target = engine.resolveLink(session, "two.xhtml?unused=1#target", sourceChapterIndex = 0)
|
||||
|
||||
assertTrue(target is ReaderLinkTarget.Internal)
|
||||
target as ReaderLinkTarget.Internal
|
||||
assertEquals(1, target.locator.chapterIndex)
|
||||
assertEquals(6, target.locator.startOffset)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolveLink maps intercepted about blank fragment to source chapter locator`() {
|
||||
val engine = ReaderEngine()
|
||||
val text = "Source target paragraph"
|
||||
val session = engine.createSession(
|
||||
SharedEpubBook(
|
||||
id = "links",
|
||||
fileName = "links.epub",
|
||||
title = "Links",
|
||||
chapters = listOf(
|
||||
SharedEpubChapter(
|
||||
id = "one",
|
||||
title = "One",
|
||||
plainText = text,
|
||||
semanticBlocks = listOf(
|
||||
SemanticParagraph(
|
||||
text = text,
|
||||
spans = emptyList(),
|
||||
style = CssStyle(),
|
||||
elementId = "spot",
|
||||
cfi = null,
|
||||
startCharOffsetInSource = 7
|
||||
)
|
||||
),
|
||||
baseHref = "Text/one.xhtml"
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val target = engine.resolveLink(session, "about:blank#spot", sourceChapterIndex = 0)
|
||||
|
||||
assertTrue(target is ReaderLinkTarget.Internal)
|
||||
target as ReaderLinkTarget.Internal
|
||||
assertEquals(0, target.locator.chapterIndex)
|
||||
assertEquals(7, target.locator.startOffset)
|
||||
}
|
||||
|
||||
private fun longBook(): SharedEpubBook {
|
||||
return SharedEpubBook(
|
||||
id = "long",
|
||||
fileName = "long.epub",
|
||||
title = "Long",
|
||||
chapters = listOf(
|
||||
SharedEpubChapter(
|
||||
id = "one",
|
||||
title = "One",
|
||||
plainText = List(280) { "This paragraph gives the paginator enough text to create several pages." }
|
||||
.joinToString("\n\n")
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,377 @@
|
|||
package com.aryan.reader.shared.reader
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.em
|
||||
import com.aryan.reader.paginatedreader.BlockStyle
|
||||
import com.aryan.reader.paginatedreader.BorderStyle
|
||||
import com.aryan.reader.paginatedreader.BoxBorders
|
||||
import com.aryan.reader.paginatedreader.CssStyle
|
||||
import com.aryan.reader.paginatedreader.SemanticImage
|
||||
import com.aryan.reader.paginatedreader.SemanticList
|
||||
import com.aryan.reader.paginatedreader.SemanticListItem
|
||||
import com.aryan.reader.paginatedreader.SemanticParagraph
|
||||
import com.aryan.reader.paginatedreader.SemanticSpan
|
||||
import com.aryan.reader.paginatedreader.SemanticTable
|
||||
import com.aryan.reader.paginatedreader.SemanticTableCell
|
||||
import com.aryan.reader.shared.HighlightColor
|
||||
import com.aryan.reader.shared.ReaderLocator
|
||||
import com.aryan.reader.shared.ReaderTexture
|
||||
import com.aryan.reader.shared.UserHighlight
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ReaderHtmlDocumentBuilderTest {
|
||||
|
||||
@Test
|
||||
fun `page document renders only the highlighted occurrence from locator offsets`() {
|
||||
val text = "alpha beta alpha beta"
|
||||
val page = ReaderPage(
|
||||
pageIndex = 0,
|
||||
chapterIndex = 0,
|
||||
chapterTitle = "One",
|
||||
text = text,
|
||||
startOffset = 0,
|
||||
endOffset = text.length
|
||||
)
|
||||
val highlight = UserHighlight(
|
||||
id = "highlight-1",
|
||||
cfi = "desktop:0:11:16",
|
||||
text = "alpha",
|
||||
color = HighlightColor.YELLOW,
|
||||
chapterIndex = 0,
|
||||
locator = ReaderLocator(
|
||||
chapterIndex = 0,
|
||||
pageIndex = 0,
|
||||
startOffset = 11,
|
||||
endOffset = 16,
|
||||
textQuote = "alpha",
|
||||
cfi = "desktop:0:11:16"
|
||||
)
|
||||
)
|
||||
|
||||
val html = ReaderHtmlDocumentBuilder.pageDocument(
|
||||
book = repeatedWordBook(text),
|
||||
page = page,
|
||||
settings = ReaderSettings(),
|
||||
highlights = listOf(highlight)
|
||||
)
|
||||
|
||||
assertEquals(1, Regex("<mark class=\"reader-user-highlight").findAll(html).count())
|
||||
assertTrue(html.contains("""alpha beta <mark class="reader-user-highlight user-highlight-yellow" data-reader-highlight-id="highlight-1" data-reader-start-offset="11" data-reader-end-offset="16">alpha</mark> beta"""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vertical document carries active locator for shared scroll navigation`() {
|
||||
val html = ReaderHtmlDocumentBuilder.verticalDocument(
|
||||
book = SharedEpubBook(
|
||||
id = "book",
|
||||
fileName = "book.epub",
|
||||
title = "Book",
|
||||
chapters = listOf(
|
||||
SharedEpubChapter("one", "One", "First chapter text."),
|
||||
SharedEpubChapter("two", "Two", "Second chapter text.")
|
||||
)
|
||||
),
|
||||
settings = ReaderSettings(readingMode = ReaderReadingMode.VERTICAL),
|
||||
navigationLocator = ReaderLocator(
|
||||
chapterIndex = 1,
|
||||
startOffset = 7,
|
||||
endOffset = 14,
|
||||
cfi = "desktop:1:7:14"
|
||||
)
|
||||
)
|
||||
|
||||
assertTrue(html.contains("data-reader-active-chapter-index=\"1\""))
|
||||
assertTrue(html.contains("data-reader-active-start-offset=\"7\""))
|
||||
assertTrue(html.contains("scrollToActiveLocator"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selection menu omits ai and tts actions when disabled`() {
|
||||
val html = ReaderHtmlDocumentBuilder.pageDocument(
|
||||
book = repeatedWordBook("alpha beta"),
|
||||
page = ReaderPage(
|
||||
pageIndex = 0,
|
||||
chapterIndex = 0,
|
||||
chapterTitle = "One",
|
||||
text = "alpha beta",
|
||||
startOffset = 0,
|
||||
endOffset = 10
|
||||
),
|
||||
settings = ReaderSettings(),
|
||||
readerAiFeaturesEnabled = false,
|
||||
cloudTtsEnabled = false
|
||||
)
|
||||
|
||||
assertFalse(html.contains("""data-action="define""""))
|
||||
assertFalse(html.contains("""data-action="speak""""))
|
||||
assertTrue(html.contains("""data-action="dictionary""""))
|
||||
assertTrue(html.contains("""data-action="web-search""""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `page document uses supplied texture data uri`() {
|
||||
val html = ReaderHtmlDocumentBuilder.pageDocument(
|
||||
book = repeatedWordBook("alpha beta"),
|
||||
page = ReaderPage(
|
||||
pageIndex = 0,
|
||||
chapterIndex = 0,
|
||||
chapterTitle = "One",
|
||||
text = "alpha beta",
|
||||
startOffset = 0,
|
||||
endOffset = 10
|
||||
),
|
||||
settings = ReaderSettings(
|
||||
textureId = ReaderTexture.PAPER.id,
|
||||
textureAlpha = 0.5f
|
||||
),
|
||||
textureDataUri = "data:image/png;base64,readertexture"
|
||||
)
|
||||
|
||||
assertTrue(html.contains("url('data:image/png;base64,readertexture')"))
|
||||
assertTrue(html.contains("mix-blend-mode: multiply"))
|
||||
assertTrue(html.contains("opacity: 0.5"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `page document keeps semantic images anchored to surrounding text page`() {
|
||||
val book = SharedEpubBook(
|
||||
id = "book",
|
||||
fileName = "book.epub",
|
||||
title = "Book",
|
||||
chapters = listOf(
|
||||
SharedEpubChapter(
|
||||
id = "one",
|
||||
title = "One",
|
||||
plainText = "Before image after image.",
|
||||
semanticBlocks = listOf(
|
||||
SemanticParagraph("Before image", emptyList(), CssStyle(), null, null, startCharOffsetInSource = 0),
|
||||
SemanticImage("data:image/png;base64,abc", "Cover", null, null, CssStyle(), null, null),
|
||||
SemanticParagraph("after image", emptyList(), CssStyle(), null, null, startCharOffsetInSource = 13)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val html = ReaderHtmlDocumentBuilder.pageDocument(
|
||||
book = book,
|
||||
page = ReaderPage(0, 0, "One", "Before image after image.", 0, 24),
|
||||
settings = ReaderSettings()
|
||||
)
|
||||
|
||||
assertTrue(html.contains("""<img src="data:image/png;base64,abc" alt="Cover""""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `page document renders semantic link spans as anchors`() {
|
||||
val text = "Open the reference"
|
||||
val book = SharedEpubBook(
|
||||
id = "book",
|
||||
fileName = "book.epub",
|
||||
title = "Book",
|
||||
chapters = listOf(
|
||||
SharedEpubChapter(
|
||||
id = "one",
|
||||
title = "One",
|
||||
plainText = text,
|
||||
semanticBlocks = listOf(
|
||||
SemanticParagraph(
|
||||
text = text,
|
||||
spans = listOf(
|
||||
SemanticSpan(
|
||||
start = 9,
|
||||
end = text.length,
|
||||
style = CssStyle(),
|
||||
linkHref = "notes.xhtml#ref",
|
||||
tag = "a"
|
||||
)
|
||||
),
|
||||
style = CssStyle(),
|
||||
elementId = null,
|
||||
cfi = null,
|
||||
startCharOffsetInSource = 0
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val html = ReaderHtmlDocumentBuilder.pageDocument(
|
||||
book = book,
|
||||
page = ReaderPage(0, 0, "One", text, 0, text.length),
|
||||
settings = ReaderSettings()
|
||||
)
|
||||
|
||||
assertTrue(html.contains("""<a href="notes.xhtml#ref" data-reader-link="true">reference</a>"""))
|
||||
assertTrue(html.contains("readerLinkClicked"))
|
||||
assertTrue(html.contains("bridge_missing"))
|
||||
assertTrue(html.contains("readerlink://click?payload="))
|
||||
assertTrue(html.contains("fallback_navigation_error"))
|
||||
assertTrue(html.contains("event.preventDefault();"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `page document carries semantic table and inline css without forced table grid`() {
|
||||
val text = "Styled cell"
|
||||
val book = SharedEpubBook(
|
||||
id = "book",
|
||||
fileName = "book.epub",
|
||||
title = "Book",
|
||||
chapters = listOf(
|
||||
SharedEpubChapter(
|
||||
id = "one",
|
||||
title = "One",
|
||||
plainText = text,
|
||||
semanticBlocks = listOf(
|
||||
SemanticTable(
|
||||
rows = listOf(
|
||||
listOf(
|
||||
SemanticTableCell(
|
||||
content = listOf(
|
||||
SemanticParagraph(
|
||||
text = text,
|
||||
spans = listOf(
|
||||
SemanticSpan(
|
||||
start = 0,
|
||||
end = 6,
|
||||
style = CssStyle(
|
||||
spanStyle = SpanStyle(fontWeight = FontWeight.Bold),
|
||||
textTransform = "uppercase"
|
||||
),
|
||||
tag = "span"
|
||||
)
|
||||
),
|
||||
style = CssStyle(),
|
||||
elementId = null,
|
||||
cfi = null,
|
||||
startCharOffsetInSource = 0
|
||||
)
|
||||
),
|
||||
isHeader = false,
|
||||
colspan = 1,
|
||||
style = CssStyle(
|
||||
blockStyle = BlockStyle(
|
||||
padding = BoxBorders(left = 4.dp),
|
||||
borderBottom = BorderStyle(width = 2.dp, color = Color.Red, style = "solid")
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
style = CssStyle(),
|
||||
elementId = null,
|
||||
cfi = null
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val html = ReaderHtmlDocumentBuilder.pageDocument(
|
||||
book = book,
|
||||
page = ReaderPage(0, 0, "One", text, 0, text.length),
|
||||
settings = ReaderSettings()
|
||||
)
|
||||
|
||||
assertTrue(html.contains("border-bottom:2.0px solid #ff0000"))
|
||||
assertTrue(html.contains("padding-left:4.0px"))
|
||||
assertTrue(html.contains("font-weight:700"))
|
||||
assertTrue(html.contains("text-transform:uppercase"))
|
||||
assertTrue(!Regex("""td,\s*th\s*\{\s*border:""").containsMatchIn(html))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `page document clips semantic lists to visible items and keeps marker styles`() {
|
||||
val first = "Chapter one"
|
||||
val second = "Chapter two"
|
||||
val book = SharedEpubBook(
|
||||
id = "book",
|
||||
fileName = "book.epub",
|
||||
title = "Book",
|
||||
chapters = listOf(
|
||||
SharedEpubChapter(
|
||||
id = "toc",
|
||||
title = "Contents",
|
||||
plainText = "$first\n$second",
|
||||
semanticBlocks = listOf(
|
||||
SemanticList(
|
||||
items = listOf(
|
||||
SemanticListItem(
|
||||
text = first,
|
||||
spans = emptyList(),
|
||||
style = CssStyle(),
|
||||
elementId = null,
|
||||
cfi = null,
|
||||
startCharOffsetInSource = 0,
|
||||
itemMarkerImage = null
|
||||
),
|
||||
SemanticListItem(
|
||||
text = second,
|
||||
spans = listOf(
|
||||
SemanticSpan(
|
||||
start = 0,
|
||||
end = second.length,
|
||||
style = CssStyle(),
|
||||
linkHref = "chap02.xhtml",
|
||||
tag = "a"
|
||||
)
|
||||
),
|
||||
style = CssStyle(
|
||||
blockStyle = BlockStyle(
|
||||
padding = BoxBorders(left = 2.dp),
|
||||
listStyleImage = "icons/toc-dot.png"
|
||||
)
|
||||
),
|
||||
elementId = null,
|
||||
cfi = null,
|
||||
startCharOffsetInSource = first.length + 1,
|
||||
itemMarkerImage = "icons/toc-dot.png"
|
||||
)
|
||||
),
|
||||
isOrdered = false,
|
||||
style = CssStyle(
|
||||
fontSize = 0.85.em,
|
||||
blockStyle = BlockStyle(listStyleType = "none")
|
||||
),
|
||||
elementId = null,
|
||||
cfi = null
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val html = ReaderHtmlDocumentBuilder.pageDocument(
|
||||
book = book,
|
||||
page = ReaderPage(0, 0, "Contents", second, first.length + 1, first.length + 1 + second.length),
|
||||
settings = ReaderSettings()
|
||||
)
|
||||
|
||||
assertTrue(!html.contains(first))
|
||||
assertTrue(html.contains(second))
|
||||
assertTrue(html.contains("list-style-type:none"))
|
||||
assertTrue(html.contains("font-size:0.85em"))
|
||||
assertTrue(html.contains("list-style-image:url('icons/toc-dot.png')"))
|
||||
assertTrue(html.contains("""<a href="chap02.xhtml" data-reader-link="true">Chapter two</a>"""))
|
||||
}
|
||||
|
||||
private fun repeatedWordBook(text: String): SharedEpubBook {
|
||||
return SharedEpubBook(
|
||||
id = "book",
|
||||
fileName = "book.epub",
|
||||
title = "Book",
|
||||
chapters = listOf(
|
||||
SharedEpubChapter(
|
||||
id = "one",
|
||||
title = "One",
|
||||
plainText = text
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import com.aryan.reader.shared.BookItem
|
||||
import com.aryan.reader.shared.FileType
|
||||
import com.aryan.reader.shared.LibraryFilters
|
||||
import com.aryan.reader.shared.ReadStatusFilter
|
||||
import com.aryan.reader.shared.SharedReaderScreenState
|
||||
import com.aryan.reader.shared.Shelf
|
||||
import com.aryan.reader.shared.ShelfType
|
||||
import com.aryan.reader.shared.SyncedFolder
|
||||
import com.aryan.reader.shared.Tag
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class NonReaderLayoutModelsTest {
|
||||
|
||||
@Test
|
||||
fun `home layout separates active tab pinned and recent books`() {
|
||||
val activeTab = book("tab", title = "Open Tab", progress = 12f)
|
||||
val inProgress = book("continue", title = "Continue", progress = 40f)
|
||||
val pinned = book("pinned", title = "Pinned")
|
||||
val recent = book("recent", title = "Recent")
|
||||
|
||||
val layout = SharedReaderScreenState(
|
||||
rawLibraryBooks = listOf(activeTab, inProgress, pinned, recent),
|
||||
recentBooks = listOf(inProgress, pinned, recent),
|
||||
openTabs = listOf(activeTab),
|
||||
openTabIds = listOf(activeTab.id),
|
||||
activeTabBookId = activeTab.id,
|
||||
isTabsEnabled = true,
|
||||
pinnedHomeBookIds = setOf(pinned.id),
|
||||
selectedBookIds = setOf(recent.id)
|
||||
).toNonReaderHomeLayoutModel()
|
||||
|
||||
assertEquals(activeTab.id, layout.continueBook?.id)
|
||||
assertEquals(listOf(activeTab.id), layout.activeTabs.map { it.id })
|
||||
assertEquals(listOf(pinned.id), layout.pinnedBooks.map { it.id })
|
||||
assertEquals(listOf(inProgress.id, recent.id), layout.recentBooks.map { it.id })
|
||||
assertEquals(listOf(recent.id), layout.selectedBooks.map { it.id })
|
||||
assertTrue(layout.isContextualModeActive)
|
||||
assertFalse(layout.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `home layout ignores open tabs when tabs are disabled`() {
|
||||
val activeTab = book("tab", title = "Open Tab", progress = 12f)
|
||||
|
||||
val layout = SharedReaderScreenState(
|
||||
rawLibraryBooks = listOf(activeTab),
|
||||
openTabs = listOf(activeTab),
|
||||
openTabIds = listOf(activeTab.id),
|
||||
activeTabBookId = activeTab.id,
|
||||
isTabsEnabled = false
|
||||
).toNonReaderHomeLayoutModel()
|
||||
|
||||
assertEquals(null, layout.continueBook)
|
||||
assertTrue(layout.activeTabs.isEmpty())
|
||||
assertTrue(layout.isEmpty)
|
||||
assertFalse(layout.isLibraryEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `library organization counts shelves tags folders status and filters`() {
|
||||
val favorite = Tag("favorite", "Favorite")
|
||||
val unread = book("unread", type = FileType.EPUB, progress = 0f)
|
||||
val inProgress = book("progress", type = FileType.PDF, progress = 50f, tags = listOf(favorite), sourceFolder = "/sync")
|
||||
val complete = book("complete", type = FileType.CBZ, progress = 100f, path = "opds-pse://stream")
|
||||
|
||||
val organization = SharedReaderScreenState(
|
||||
rawLibraryBooks = listOf(unread, inProgress, complete),
|
||||
allTags = listOf(favorite),
|
||||
syncedFolders = listOf(SyncedFolder("/sync", "Sync", lastScanTime = 1L)),
|
||||
shelves = listOf(
|
||||
Shelf("manual", "Manual", ShelfType.MANUAL, listOf(unread)),
|
||||
Shelf("series", "Series", ShelfType.SERIES, listOf(inProgress)),
|
||||
Shelf("smart", "Smart", ShelfType.SMART, listOf(complete)),
|
||||
Shelf("tag_favorite", "Favorite", ShelfType.TAG, listOf(inProgress)),
|
||||
Shelf("folder_root", "Sync", ShelfType.FOLDER, listOf(inProgress)),
|
||||
Shelf("folder_child", "Nested", ShelfType.FOLDER, listOf(inProgress), parentShelfId = "folder_root")
|
||||
),
|
||||
libraryFilters = LibraryFilters(
|
||||
fileTypes = setOf(FileType.PDF),
|
||||
sourceFolders = setOf("/sync"),
|
||||
readStatus = ReadStatusFilter.IN_PROGRESS,
|
||||
tagIds = setOf(favorite.id)
|
||||
)
|
||||
).toNonReaderLibraryOrganizationModel()
|
||||
|
||||
assertEquals(3, organization.allBooksCount)
|
||||
assertEquals(2, organization.shelfCount)
|
||||
assertEquals(1, organization.smartShelfCount)
|
||||
assertEquals(1, organization.tagCount)
|
||||
assertEquals(1, organization.folderCount)
|
||||
assertEquals(1, organization.unreadCount)
|
||||
assertEquals(1, organization.inProgressCount)
|
||||
assertEquals(1, organization.completedCount)
|
||||
assertEquals(4, organization.activeFilterCount)
|
||||
assertEquals(listOf(FileType.PDF, FileType.EPUB, FileType.CBZ), organization.availableFileTypes)
|
||||
assertTrue(organization.hasInAppBooks)
|
||||
assertTrue(organization.hasOpdsStreams)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `library organization falls back to book tags and synced folders`() {
|
||||
val favorite = Tag("favorite", "Favorite")
|
||||
val tagged = book("tagged", tags = listOf(favorite), sourceFolder = "/sync")
|
||||
|
||||
val organization = SharedReaderScreenState(
|
||||
rawLibraryBooks = listOf(tagged),
|
||||
syncedFolders = listOf(SyncedFolder("/sync", "Sync", lastScanTime = 1L))
|
||||
).toNonReaderLibraryOrganizationModel()
|
||||
|
||||
assertEquals(1, organization.tagCount)
|
||||
assertEquals(1, organization.folderCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shell model keeps primary navigation simple and exposes all tool actions`() {
|
||||
val model = sharedAppShellModel(
|
||||
selectedTab = SharedAppTab.CUSTOM_FONTS,
|
||||
aiSettingsAvailable = true
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf(SharedAppTab.HOME, SharedAppTab.LIBRARY, SharedAppTab.CATALOGS, SharedAppTab.READER),
|
||||
model.primaryTabs
|
||||
)
|
||||
assertEquals(SharedAppTab.HOME, model.selectedPrimaryTab)
|
||||
assertTrue(SharedAppToolAction.IMPORT_FILES in model.toolActions)
|
||||
assertTrue(SharedAppToolAction.IMPORT_FOLDER in model.toolActions)
|
||||
assertTrue(SharedAppToolAction.SYNC in model.toolActions)
|
||||
assertTrue(SharedAppToolAction.APP_THEME in model.toolActions)
|
||||
assertTrue(SharedAppToolAction.AI_SETTINGS in model.toolActions)
|
||||
assertTrue(SharedAppToolAction.CUSTOM_FONTS in model.toolActions)
|
||||
assertTrue(SharedAppToolAction.HELP_FEEDBACK in model.toolActions)
|
||||
assertTrue(SharedAppToolAction.SUPPORT in model.toolActions)
|
||||
assertTrue(SharedAppToolAction.ABOUT in model.toolActions)
|
||||
assertTrue(SharedAppToolAction.TABS_TOGGLE in model.toolActions)
|
||||
|
||||
val withoutAi = sharedAppShellModel(SharedAppTab.SHELVES, aiSettingsAvailable = false)
|
||||
assertEquals(SharedAppTab.LIBRARY, withoutAi.selectedPrimaryTab)
|
||||
assertFalse(SharedAppToolAction.AI_SETTINGS in withoutAi.toolActions)
|
||||
}
|
||||
|
||||
private fun book(
|
||||
id: String,
|
||||
title: String = id,
|
||||
type: FileType = FileType.EPUB,
|
||||
progress: Float? = null,
|
||||
tags: List<Tag> = emptyList(),
|
||||
sourceFolder: String? = null,
|
||||
path: String? = "/books/$id.epub"
|
||||
) = BookItem(
|
||||
id = id,
|
||||
path = path,
|
||||
type = type,
|
||||
displayName = "$id.epub",
|
||||
timestamp = 1L,
|
||||
title = title,
|
||||
progressPercentage = progress,
|
||||
tags = tags,
|
||||
sourceFolder = sourceFolder
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import com.aryan.reader.shared.PdfDisplayMode
|
||||
import com.aryan.reader.shared.ReaderAutoScrollState
|
||||
import com.aryan.reader.shared.ReaderCloudTtsState
|
||||
import com.aryan.reader.shared.ReaderExtrasState
|
||||
import com.aryan.reader.shared.ReaderTool
|
||||
import com.aryan.reader.shared.ReaderToolbarPreferences
|
||||
import com.aryan.reader.shared.pdf.SharedPdfReaderState
|
||||
import com.aryan.reader.shared.reader.ReaderEngine
|
||||
import com.aryan.reader.shared.reader.SampleReaderBooks
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ReaderWorkspaceModelsTest {
|
||||
|
||||
@Test
|
||||
fun `epub workspace maps shared toolbar preferences to reader sidebars and inspector`() {
|
||||
val session = ReaderEngine().createSession(SampleReaderBooks.desktopWelcomeBook())
|
||||
val preferences = ReaderToolbarPreferences(
|
||||
hiddenToolIds = setOf(ReaderTool.THEME.id, ReaderTool.FORMAT.id),
|
||||
bottomToolIds = setOf(ReaderTool.SLIDER.id, ReaderTool.SEARCH.id)
|
||||
)
|
||||
|
||||
val model = epubReaderWorkspaceModel(
|
||||
session = session,
|
||||
toolbarPreferences = preferences,
|
||||
extrasState = ReaderExtrasState(),
|
||||
aiAvailable = true
|
||||
)
|
||||
|
||||
assertEquals(ReaderWorkspaceKind.EPUB, model.kind)
|
||||
assertTrue(ReaderWorkspaceLeftSection.CONTENTS in model.leftSections)
|
||||
assertTrue(ReaderWorkspaceLeftSection.SEARCH in model.leftSections)
|
||||
assertTrue(ReaderWorkspaceLeftSection.BOOKMARKS in model.leftSections)
|
||||
assertFalse(ReaderWorkspaceInspectorSection.APPEARANCE in model.inspectorSections)
|
||||
assertTrue(ReaderWorkspaceInspectorSection.AI_TTS in model.inspectorSections)
|
||||
assertTrue(ReaderWorkspaceInspectorSection.TOOLBAR in model.inspectorSections)
|
||||
assertTrue(ReaderWorkspaceTopAction.SEARCH in model.topActions)
|
||||
assertTrue(ReaderWorkspaceTopAction.AI in model.topActions)
|
||||
assertTrue(ReaderWorkspaceBottomAction.PAGE_SLIDER in model.bottomActions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `chrome model is forced visible for active reader states`() {
|
||||
val model = readerWorkspaceChromeModel(
|
||||
preferAutoHide = true,
|
||||
searchActive = true,
|
||||
leftPanelOpen = false,
|
||||
inspectorOpen = true,
|
||||
annotationEditing = true,
|
||||
richTextEditing = true,
|
||||
loading = true,
|
||||
errorMessage = "Failed",
|
||||
autoScroll = ReaderAutoScrollState(enabled = true),
|
||||
ttsBusy = true
|
||||
)
|
||||
|
||||
assertTrue(model.preferAutoHide)
|
||||
assertTrue(model.forceVisible)
|
||||
assertEquals(
|
||||
setOf("search", "inspector", "annotation", "rich-text", "loading", "error", "auto-scroll", "tts"),
|
||||
model.forceVisibleReasons
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toolbar quick actions preserve visibility order and bottom placement`() {
|
||||
val preferences = ReaderToolbarPreferences(
|
||||
hiddenToolIds = setOf(ReaderTool.BOOKMARK.id),
|
||||
toolOrder = listOf(
|
||||
ReaderTool.AUTO_SCROLL,
|
||||
ReaderTool.SEARCH,
|
||||
ReaderTool.AI_FEATURES,
|
||||
ReaderTool.THEME,
|
||||
ReaderTool.BOOKMARK
|
||||
) + ReaderTool.entries,
|
||||
bottomToolIds = setOf(ReaderTool.SEARCH.id, ReaderTool.AI_FEATURES.id)
|
||||
)
|
||||
|
||||
val topTools = readerWorkspaceQuickActionTools(
|
||||
toolbarPreferences = preferences,
|
||||
bottom = false,
|
||||
aiAvailable = true
|
||||
)
|
||||
val bottomToolsWithoutAi = readerWorkspaceQuickActionTools(
|
||||
toolbarPreferences = preferences,
|
||||
bottom = true,
|
||||
aiAvailable = false
|
||||
)
|
||||
val bottomToolsWithAi = readerWorkspaceQuickActionTools(
|
||||
toolbarPreferences = preferences,
|
||||
bottom = true,
|
||||
aiAvailable = true
|
||||
)
|
||||
|
||||
assertEquals(listOf(ReaderTool.AUTO_SCROLL, ReaderTool.THEME), topTools.take(2))
|
||||
assertEquals(listOf(ReaderTool.SEARCH), bottomToolsWithoutAi)
|
||||
assertEquals(listOf(ReaderTool.SEARCH, ReaderTool.AI_FEATURES), bottomToolsWithAi)
|
||||
assertFalse(ReaderTool.BOOKMARK in topTools)
|
||||
assertFalse(ReaderTool.BOOKMARK in bottomToolsWithAi)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pdf workspace defaults to reading first while keeping annotation tools in inspector`() {
|
||||
val model = pdfReaderWorkspaceModel(
|
||||
state = SharedPdfReaderState.initial(pageCount = 4),
|
||||
displayMode = PdfDisplayMode.PAGINATION,
|
||||
hasContents = true,
|
||||
hasBookmarks = true,
|
||||
hasAnnotations = true,
|
||||
hasEmbeddedComments = true,
|
||||
searchActive = false,
|
||||
annotationEditing = false,
|
||||
richTextEditing = false,
|
||||
loading = false,
|
||||
errorMessage = null,
|
||||
extrasState = ReaderExtrasState(),
|
||||
aiAvailable = true
|
||||
)
|
||||
|
||||
assertEquals(ReaderWorkspaceKind.PDF, model.kind)
|
||||
assertNull(model.defaultPdfInteractionMode)
|
||||
assertTrue(ReaderWorkspaceLeftSection.CONTENTS in model.leftSections)
|
||||
assertTrue(ReaderWorkspaceLeftSection.SEARCH in model.leftSections)
|
||||
assertTrue(ReaderWorkspaceLeftSection.BOOKMARKS in model.leftSections)
|
||||
assertTrue(ReaderWorkspaceLeftSection.NOTES in model.leftSections)
|
||||
assertTrue(ReaderWorkspaceInspectorSection.APPEARANCE in model.inspectorSections)
|
||||
assertTrue(ReaderWorkspaceInspectorSection.TOOLS in model.inspectorSections)
|
||||
assertTrue(ReaderWorkspaceInspectorSection.AI_TTS in model.inspectorSections)
|
||||
assertTrue(ReaderWorkspaceTopAction.AI in model.topActions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pdf workspace forces chrome for search editing errors tts and vertical auto scroll`() {
|
||||
val model = pdfReaderWorkspaceModel(
|
||||
state = SharedPdfReaderState.initial(pageCount = 4).copy(searchQuery = "needle"),
|
||||
displayMode = PdfDisplayMode.VERTICAL_SCROLL,
|
||||
hasContents = false,
|
||||
hasBookmarks = false,
|
||||
hasAnnotations = false,
|
||||
hasEmbeddedComments = false,
|
||||
searchActive = false,
|
||||
annotationEditing = true,
|
||||
richTextEditing = false,
|
||||
loading = false,
|
||||
errorMessage = "Problem",
|
||||
extrasState = ReaderExtrasState(
|
||||
autoScroll = ReaderAutoScrollState(enabled = true),
|
||||
cloudTts = ReaderCloudTtsState(isPlaying = true)
|
||||
),
|
||||
aiAvailable = false
|
||||
)
|
||||
|
||||
assertTrue(model.chrome.forceVisible)
|
||||
assertTrue("search" in model.chrome.forceVisibleReasons)
|
||||
assertTrue("annotation" in model.chrome.forceVisibleReasons)
|
||||
assertTrue("error" in model.chrome.forceVisibleReasons)
|
||||
assertTrue("auto-scroll" in model.chrome.forceVisibleReasons)
|
||||
assertTrue("tts" in model.chrome.forceVisibleReasons)
|
||||
assertFalse(ReaderWorkspaceTopAction.AI in model.topActions)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import kotlin.math.abs
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SharedAppThemeColorMathTest {
|
||||
|
||||
@Test
|
||||
fun `rgb color converts to expected hsv components`() {
|
||||
val hsv = Color(0xFFFF0000).toSharedHsvColor()
|
||||
|
||||
assertClose(0f, hsv.hue)
|
||||
assertClose(1f, hsv.saturation)
|
||||
assertClose(1f, hsv.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hsv color converts back to compose rgb color`() {
|
||||
val color = SharedHsvColor(hue = 120f, saturation = 1f, value = 1f).toComposeColor()
|
||||
|
||||
assertEquals(Color(0xFF00FF00).toArgb(), color.toArgb())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hex parser accepts android style six digit colors`() {
|
||||
val color = "#006C4C".toSharedHexColorOrNull()
|
||||
|
||||
assertEquals(Color(0xFF006C4C).toArgb(), color?.toArgb())
|
||||
assertEquals("#006C4C", color?.toSharedHexString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hex parser rejects incomplete and invalid colors`() {
|
||||
assertNull("006C4".toSharedHexColorOrNull())
|
||||
assertNull("#006C4Z".toSharedHexColorOrNull())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rgb hsv conversion round trips common custom theme colors`() {
|
||||
val original = Color(0xFF2D6A4F)
|
||||
val roundTripped = original.toSharedHsvColor().toComposeColor()
|
||||
|
||||
assertTrue(abs(original.red - roundTripped.red) < 0.01f)
|
||||
assertTrue(abs(original.green - roundTripped.green) < 0.01f)
|
||||
assertTrue(abs(original.blue - roundTripped.blue) < 0.01f)
|
||||
}
|
||||
|
||||
private fun assertClose(expected: Float, actual: Float) {
|
||||
assertTrue(abs(expected - actual) < 0.01f, "Expected $expected but was $actual")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
import java.security.MessageDigest
|
||||
|
||||
internal actual fun localFolderSyncSha256ShortHex(value: String): String {
|
||||
val bytes = MessageDigest.getInstance("SHA-256").digest(value.toByteArray())
|
||||
return bytes.joinToString("") { "%02x".format(it) }.take(12)
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import org.jetbrains.skia.Image as SkiaImage
|
||||
import java.io.File
|
||||
|
||||
@Composable
|
||||
internal actual fun LocalBookCoverImage(
|
||||
path: String,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier
|
||||
) {
|
||||
val bitmap = remember(path) {
|
||||
runCatching {
|
||||
val file = File(path)
|
||||
if (!file.isFile) {
|
||||
null
|
||||
} else {
|
||||
SkiaImage.makeFromEncoded(file.readBytes()).toComposeImageBitmap()
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
if (bitmap != null) {
|
||||
Image(
|
||||
bitmap = bitmap,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
import java.nio.file.Files
|
||||
import kotlin.io.path.toFile
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ReaderTtsFileCacheManagerTest {
|
||||
|
||||
@Test
|
||||
fun `cache files are stable for book chapter text and speaker`() {
|
||||
val root = Files.createTempDirectory("reader-tts-cache").toFile()
|
||||
try {
|
||||
val cache = ReaderTtsFileCacheManager(root)
|
||||
|
||||
val first = cache.getCacheFile("Book: One", "Chapter/One", "Hello world.", "Aoede")
|
||||
val second = cache.getCacheFile("Book: One", "Chapter/One", "Hello world.", "Aoede")
|
||||
val otherSpeaker = cache.getCacheFile("Book: One", "Chapter/One", "Hello world.", "Kore")
|
||||
|
||||
assertEquals(first.absolutePath, second.absolutePath)
|
||||
assertFalse(first.absolutePath == otherSpeaker.absolutePath)
|
||||
assertTrue(first.parentFile.exists())
|
||||
} finally {
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cache summary filters current speaker`() {
|
||||
val root = Files.createTempDirectory("reader-tts-cache").toFile()
|
||||
try {
|
||||
val cache = ReaderTtsFileCacheManager(root)
|
||||
cache.saveTotalChunks("Book", "One", 3)
|
||||
cache.getCacheFile("Book", "One", "Hello.", "Aoede").writeBytes(ByteArray(144))
|
||||
cache.getCacheFile("Book", "One", "World.", "Kore").writeBytes(ByteArray(244))
|
||||
|
||||
val summary = cache.getCacheSummary("Book", "Aoede")
|
||||
|
||||
assertEquals(2, summary.cachedChunkCount)
|
||||
assertEquals(1, summary.currentVoiceChunkCount)
|
||||
assertEquals(388, summary.totalSizeBytes)
|
||||
assertEquals(144, summary.currentVoiceSizeBytes)
|
||||
} finally {
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,181 @@
|
|||
package com.aryan.reader.shared.opds
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SharedOpdsParserTest {
|
||||
@Test
|
||||
fun `parse OPDS 2 feed resolves links facets navigation publications and metadata`() {
|
||||
val feed = SharedOpdsParser().parse(
|
||||
bodyString = """
|
||||
{
|
||||
"metadata": {"title": "Catalog"},
|
||||
"links": [
|
||||
{"rel": "next", "href": "page/2"},
|
||||
{"rel": ["search"], "href": "search{?query}"}
|
||||
],
|
||||
"facets": [
|
||||
{
|
||||
"metadata": {"title": "Format"},
|
||||
"links": [
|
||||
{"title": "EPUB", "href": "?format=epub", "properties": {"active": true}}
|
||||
]
|
||||
}
|
||||
],
|
||||
"navigation": [
|
||||
{"title": "Authors", "href": "../authors", "description": "Browse authors"}
|
||||
],
|
||||
"publications": [
|
||||
{
|
||||
"metadata": {
|
||||
"identifier": "pub-1",
|
||||
"title": "Example Book",
|
||||
"description": "Long summary",
|
||||
"author": [{"name": "Ada Writer", "links": [{"href": "/authors/ada"}]}],
|
||||
"language": "en",
|
||||
"publisher": "Example Press",
|
||||
"published": "2026-01-02",
|
||||
"subject": [{"name": "Fiction"}],
|
||||
"belongsTo": {"series": {"name": "Series", "position": 2}}
|
||||
},
|
||||
"images": [
|
||||
{"href": "images/thumb.jpg"},
|
||||
{"rel": "cover", "href": "images/cover.jpg"}
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"rel": "http://opds-spec.org/acquisition",
|
||||
"href": "downloads/book.epub",
|
||||
"type": "application/epub+zip"
|
||||
},
|
||||
{
|
||||
"rel": ["http://vaemendis.net/opds-pse/stream"],
|
||||
"href": "stream/{pageNumber}",
|
||||
"properties": {"numberOfItems": 12}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""".trimIndent(),
|
||||
baseUrl = "https://example.org/opds/catalog/index.json"
|
||||
)
|
||||
|
||||
assertEquals("Catalog", feed.title)
|
||||
assertEquals("https://example.org/opds/catalog/page/2", feed.nextUrl)
|
||||
assertEquals("https://example.org/opds/catalog/search{?query}", feed.searchUrl)
|
||||
assertEquals(OpdsFacet("EPUB", "Format", "https://example.org/opds/catalog/?format=epub", true), feed.facets.single())
|
||||
|
||||
val navigation = feed.entries.first { it.isNavigation }
|
||||
assertEquals("Authors", navigation.title)
|
||||
assertEquals("https://example.org/opds/authors", navigation.navigationUrl)
|
||||
|
||||
val publication = feed.entries.first { it.isAcquisition }
|
||||
assertEquals("pub-1", publication.id)
|
||||
assertEquals("Example Book", publication.title)
|
||||
assertEquals("Ada Writer", publication.author)
|
||||
assertEquals("https://example.org/authors/ada", publication.authors.single().url)
|
||||
assertEquals("Long summary", publication.summary)
|
||||
assertEquals("https://example.org/opds/catalog/images/cover.jpg", publication.coverUrl)
|
||||
assertEquals("Example Press", publication.publisher)
|
||||
assertEquals("2026-01-02", publication.published)
|
||||
assertEquals("en", publication.language)
|
||||
assertEquals("Series", publication.series)
|
||||
assertEquals("2", publication.seriesIndex)
|
||||
assertEquals(listOf("Fiction"), publication.categories)
|
||||
assertEquals("https://example.org/opds/catalog/downloads/book.epub", publication.bestAcquisition?.url)
|
||||
assertEquals("EPUB", publication.bestAcquisition?.formatName)
|
||||
assertEquals(12, publication.pseCount)
|
||||
assertEquals("https://example.org/opds/catalog/stream/{pageNumber}", publication.pseUrlTemplate)
|
||||
assertTrue(publication.isStreamable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse OPDS 1 feed extracts metadata acquisitions and stream info`() {
|
||||
val feed = SharedOpdsParser().parse(
|
||||
bodyString = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom"
|
||||
xmlns:opds="http://opds-spec.org/2010/catalog"
|
||||
xmlns:pse="http://vaemendis.net/opds-pse/ns">
|
||||
<title>XML Catalog</title>
|
||||
<link rel="next" href="next.xml" />
|
||||
<link rel="search" href="/search.xml" />
|
||||
<link rel="facet" title="English" href="?lang=en" opds:facetGroup="Language" opds:activeFacet="true" />
|
||||
<entry>
|
||||
<id>xml-1</id>
|
||||
<title>XML Book</title>
|
||||
<summary>Summary text</summary>
|
||||
<author>
|
||||
<name>XML Author</name>
|
||||
<uri>/people/xml-author</uri>
|
||||
</author>
|
||||
<publisher>XML Press</publisher>
|
||||
<language>en</language>
|
||||
<published>2025-12-31</published>
|
||||
<category term="fiction" label="Fiction" />
|
||||
<meta property="calibre:series">XML Series</meta>
|
||||
<meta property="calibre:series_index">3</meta>
|
||||
<link rel="http://opds-spec.org/image/thumbnail" href="thumb.jpg" />
|
||||
<link rel="http://opds-spec.org/image" href="cover.jpg" />
|
||||
<link rel="http://opds-spec.org/acquisition" type="application/pdf" href="book.pdf" />
|
||||
<link rel="http://vaemendis.net/opds-pse/stream" href="stream/{pageNumber}" pse:count="8" />
|
||||
</entry>
|
||||
</feed>
|
||||
""".trimIndent(),
|
||||
baseUrl = "https://example.org/root/feed.xml"
|
||||
)
|
||||
|
||||
assertEquals("XML Catalog", feed.title)
|
||||
assertEquals("https://example.org/root/next.xml", feed.nextUrl)
|
||||
assertEquals("https://example.org/search.xml", feed.searchUrl)
|
||||
assertEquals(OpdsFacet("English", "Language", "https://example.org/root/?lang=en", true), feed.facets.single())
|
||||
|
||||
val entry = feed.entries.single()
|
||||
assertEquals("xml-1", entry.id)
|
||||
assertEquals("XML Book", entry.title)
|
||||
assertEquals("Summary text", entry.summary)
|
||||
assertEquals(OpdsAuthor("XML Author", "https://example.org/people/xml-author"), entry.authors.single())
|
||||
assertEquals("https://example.org/root/thumb.jpg", entry.coverUrl)
|
||||
assertEquals("XML Press", entry.publisher)
|
||||
assertEquals("2025-12-31", entry.published)
|
||||
assertEquals("en", entry.language)
|
||||
assertEquals("XML Series", entry.series)
|
||||
assertEquals("3", entry.seriesIndex)
|
||||
assertEquals(listOf("Fiction"), entry.categories)
|
||||
assertEquals(OpdsAcquisition("https://example.org/root/book.pdf", "application/pdf"), entry.acquisitions.single())
|
||||
assertEquals(8, entry.pseCount)
|
||||
assertEquals("https://example.org/root/stream/{pageNumber}", entry.pseUrlTemplate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse OPDS 2 groups and fallback metadata produce navigation entries`() {
|
||||
val feed = SharedOpdsParser().parse(
|
||||
bodyString = """
|
||||
{
|
||||
"groups": [
|
||||
{
|
||||
"metadata": {"title": "Group Title"},
|
||||
"links": [{"href": "group-feed"}],
|
||||
"navigation": [{"title": "Nested Nav", "href": "nested"}],
|
||||
"publications": [{"links": [], "metadata": {"title": "No Identifier"}}]
|
||||
}
|
||||
]
|
||||
}
|
||||
""".trimIndent(),
|
||||
baseUrl = "https://example.org/catalog/"
|
||||
)
|
||||
|
||||
assertEquals("OPDS 2.0 Feed", feed.title)
|
||||
assertEquals("Nested Nav", feed.entries[0].title)
|
||||
assertEquals("https://example.org/catalog/nested", feed.entries[0].navigationUrl)
|
||||
assertEquals("Group Title", feed.entries[2].title)
|
||||
assertEquals("https://example.org/catalog/group-feed", feed.entries[2].navigationUrl)
|
||||
assertEquals("No Identifier", feed.entries[1].title)
|
||||
assertFalse(feed.entries[1].isAcquisition)
|
||||
assertNull(feed.entries[1].bestAcquisition)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,201 @@
|
|||
package com.aryan.reader.shared.reader
|
||||
|
||||
import com.aryan.reader.shared.FileType
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SharedJvmBookLoaderTest {
|
||||
@Test
|
||||
fun `docx loader extracts core metadata and body text`() = withTempDir { dir ->
|
||||
val file = File(dir, "sample.docx")
|
||||
writeZip(file) {
|
||||
text(
|
||||
"docProps/core.xml",
|
||||
"""
|
||||
<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<dc:title>Portable DOCX</dc:title>
|
||||
<dc:creator>Casey Writer</dc:creator>
|
||||
</cp:coreProperties>
|
||||
""".trimIndent()
|
||||
)
|
||||
text(
|
||||
"word/document.xml",
|
||||
"""
|
||||
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:body>
|
||||
<w:p><w:r><w:t>Hello from DOCX.</w:t></w:r></w:p>
|
||||
</w:body>
|
||||
</w:document>
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
|
||||
val book = SharedJvmBookLoader.load(file, FileType.DOCX)
|
||||
|
||||
assertEquals("Portable DOCX", book.title)
|
||||
assertEquals("Casey Writer", book.author)
|
||||
assertTrue(book.chapters.single().plainText.contains("Hello from DOCX."))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `odt loader extracts metadata and document text`() = withTempDir { dir ->
|
||||
val file = File(dir, "sample.odt")
|
||||
writeZip(file) {
|
||||
text(
|
||||
"meta.xml",
|
||||
"""
|
||||
<office:document-meta xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<office:meta>
|
||||
<dc:title>Portable ODT</dc:title>
|
||||
<dc:creator>Open Author</dc:creator>
|
||||
</office:meta>
|
||||
</office:document-meta>
|
||||
""".trimIndent()
|
||||
)
|
||||
text(
|
||||
"content.xml",
|
||||
"""
|
||||
<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
|
||||
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0">
|
||||
<office:body>
|
||||
<office:text>
|
||||
<text:h text:outline-level="1">ODT Heading</text:h>
|
||||
<text:p>Hello from ODT.</text:p>
|
||||
</office:text>
|
||||
</office:body>
|
||||
</office:document-content>
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
|
||||
val book = SharedJvmBookLoader.load(file, FileType.ODT)
|
||||
|
||||
assertEquals("Portable ODT", book.title)
|
||||
assertEquals("Open Author", book.author)
|
||||
assertTrue(book.chapters.single().plainText.contains("Hello from ODT."))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fb2 loader splits readable sections`() = withTempDir { dir ->
|
||||
val file = File(dir, "sample.fb2").apply {
|
||||
writeText(
|
||||
"""
|
||||
<FictionBook xmlns:l="http://www.w3.org/1999/xlink">
|
||||
<description>
|
||||
<title-info>
|
||||
<author><first-name>Ada</first-name><last-name>Byron</last-name></author>
|
||||
<book-title>Portable FB2</book-title>
|
||||
</title-info>
|
||||
</description>
|
||||
<body>
|
||||
<section>
|
||||
<title><p>First Section</p></title>
|
||||
<p>Hello from FB2.</p>
|
||||
</section>
|
||||
</body>
|
||||
</FictionBook>
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
|
||||
val book = SharedJvmBookLoader.load(file, FileType.FB2)
|
||||
|
||||
assertEquals("Portable FB2", book.title)
|
||||
assertEquals("Ada Byron", book.author)
|
||||
assertEquals("First Section", book.chapters.single().title)
|
||||
assertTrue(book.chapters.single().plainText.contains("Hello from FB2."))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mobi loader reads uncompressed palmdoc text records`() = withTempDir { dir ->
|
||||
val file = File(dir, "sample.mobi").apply {
|
||||
writeBytes(
|
||||
minimalMobi(
|
||||
"<html><body><p>Hello from MOBI.</p></body></html>".toByteArray(Charsets.UTF_8)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val book = SharedJvmBookLoader.load(file, FileType.MOBI)
|
||||
|
||||
assertEquals("sample", book.title)
|
||||
assertTrue(book.chapters.single().plainText.contains("Hello from MOBI."))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mobi loader reads bundled huff cdic sample`() {
|
||||
val file = findRepoFile("app/src/main/cpp/libmobi/tests/samples/sample-unicode-huffdic.mobi")
|
||||
|
||||
val book = SharedJvmBookLoader.load(file, FileType.MOBI)
|
||||
|
||||
assertEquals("Libmobi", book.title)
|
||||
assertTrue(book.chapters.joinToString("\n") { it.plainText }.length > 100)
|
||||
}
|
||||
|
||||
private fun withTempDir(block: (File) -> Unit) {
|
||||
val dir = Files.createTempDirectory("reader-shared-loader").toFile()
|
||||
try {
|
||||
block(dir)
|
||||
} finally {
|
||||
dir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
private fun findRepoFile(path: String): File {
|
||||
return generateSequence(File(System.getProperty("user.dir")).absoluteFile) { it.parentFile }
|
||||
.take(8)
|
||||
.map { File(it, path) }
|
||||
.firstOrNull { it.isFile }
|
||||
?: error("Missing test fixture: $path")
|
||||
}
|
||||
|
||||
private fun writeZip(file: File, block: ZipBuilder.() -> Unit) {
|
||||
ZipOutputStream(file.outputStream()).use { zip ->
|
||||
ZipBuilder(zip).block()
|
||||
}
|
||||
}
|
||||
|
||||
private fun minimalMobi(textRecord: ByteArray): ByteArray {
|
||||
val record0 = ByteArray(16)
|
||||
record0.writeU16(0, 1)
|
||||
record0.writeU32(4, textRecord.size)
|
||||
record0.writeU16(8, 1)
|
||||
record0.writeU16(10, 4096)
|
||||
record0.writeU16(12, 0)
|
||||
|
||||
val record0Offset = 78 + 16
|
||||
val record1Offset = record0Offset + record0.size
|
||||
val header = ByteArray(record0Offset)
|
||||
header.writeU16(76, 2)
|
||||
header.writeU32(78, record0Offset)
|
||||
header.writeU32(86, record1Offset)
|
||||
return header + record0 + textRecord
|
||||
}
|
||||
|
||||
private fun ByteArray.writeU16(offset: Int, value: Int) {
|
||||
this[offset] = ((value ushr 8) and 0xFF).toByte()
|
||||
this[offset + 1] = (value and 0xFF).toByte()
|
||||
}
|
||||
|
||||
private fun ByteArray.writeU32(offset: Int, value: Int) {
|
||||
this[offset] = ((value ushr 24) and 0xFF).toByte()
|
||||
this[offset + 1] = ((value ushr 16) and 0xFF).toByte()
|
||||
this[offset + 2] = ((value ushr 8) and 0xFF).toByte()
|
||||
this[offset + 3] = (value and 0xFF).toByte()
|
||||
}
|
||||
|
||||
private class ZipBuilder(private val zip: ZipOutputStream) {
|
||||
fun text(path: String, value: String) {
|
||||
zip.putNextEntry(ZipEntry(path))
|
||||
zip.write(value.toByteArray(Charsets.UTF_8))
|
||||
zip.closeEntry()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -107,7 +107,8 @@ fun htmlToSemanticBlocks(
|
|||
imageDimensionsCache: Map<String, Pair<Float, Float>> = emptyMap(),
|
||||
mathSvgCache: Map<String, String> = emptyMap(),
|
||||
resourceResolver: HtmlResourceResolver = NoOpHtmlResourceResolver,
|
||||
fontFamilyLoader: HtmlFontFamilyLoader = NoOpHtmlFontFamilyLoader
|
||||
fontFamilyLoader: HtmlFontFamilyLoader = NoOpHtmlFontFamilyLoader,
|
||||
adaptThemeColors: Boolean = false
|
||||
): List<SemanticBlock> {
|
||||
return SemanticHtmlParser(
|
||||
cssRules,
|
||||
|
|
@ -120,7 +121,8 @@ fun htmlToSemanticBlocks(
|
|||
imageDimensionsCache,
|
||||
mathSvgCache,
|
||||
resourceResolver,
|
||||
fontFamilyLoader
|
||||
fontFamilyLoader,
|
||||
adaptThemeColors
|
||||
).parse(html)
|
||||
}
|
||||
|
||||
|
|
@ -138,7 +140,8 @@ private class SemanticHtmlParser(
|
|||
private val imageDimensionsCache: Map<String, Pair<Float, Float>>,
|
||||
private val mathSvgCache: Map<String, String>,
|
||||
private val resourceResolver: HtmlResourceResolver,
|
||||
private val fontFamilyLoader: HtmlFontFamilyLoader
|
||||
private val fontFamilyLoader: HtmlFontFamilyLoader,
|
||||
private val adaptThemeColors: Boolean
|
||||
) {
|
||||
private val styleCache = mutableMapOf<String, CssStyle>()
|
||||
private var combinedRules: OptimizedCssRules = cssRules
|
||||
|
|
@ -157,7 +160,8 @@ private class SemanticHtmlParser(
|
|||
baseFontSizeSp = textStyle.fontSize.value,
|
||||
density = density.density,
|
||||
constraints = constraints,
|
||||
isDarkTheme = false
|
||||
isDarkTheme = false,
|
||||
adaptThemeColors = adaptThemeColors
|
||||
)
|
||||
|
||||
if (inlineParseResult.fontFaces.isNotEmpty()) {
|
||||
|
|
@ -242,7 +246,15 @@ private class SemanticHtmlParser(
|
|||
var elementStyle = baseStyle
|
||||
val inlineStyleAttribute = element.attr("style")
|
||||
if (inlineStyleAttribute.isNotBlank()) {
|
||||
val inlineStyle = CssParser.parseProperties(inlineStyleAttribute, textStyle.fontSize.value, density.density, constraints, onlyImportant = false, isDarkTheme = false)
|
||||
val inlineStyle = CssParser.parseProperties(
|
||||
inlineStyleAttribute,
|
||||
textStyle.fontSize.value,
|
||||
density.density,
|
||||
constraints,
|
||||
onlyImportant = false,
|
||||
isDarkTheme = false,
|
||||
adaptThemeColors = adaptThemeColors
|
||||
)
|
||||
elementStyle = elementStyle.merge(inlineStyle)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,177 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.security.MessageDigest
|
||||
|
||||
data class ReaderTtsChapterCacheInfo(
|
||||
val chapterTitle: String,
|
||||
val chunkCount: Int,
|
||||
val totalChunks: Int?,
|
||||
val sizeBytes: Long,
|
||||
val directoryPath: String,
|
||||
val matchingFilePaths: List<String> = emptyList()
|
||||
)
|
||||
|
||||
class ReaderTtsFileCacheManager(
|
||||
cacheRoot: File
|
||||
) {
|
||||
private val baseDir = cacheRoot
|
||||
|
||||
fun saveTotalChunks(bookTitle: String, chapterTitle: String?, totalChunks: Int) {
|
||||
val chapterDir = chapterDir(bookTitle, chapterTitle)
|
||||
if (!chapterDir.exists()) chapterDir.mkdirs()
|
||||
File(chapterDir, "total_chunks.txt").writeText(totalChunks.toString())
|
||||
}
|
||||
|
||||
fun getCacheFile(
|
||||
bookTitle: String,
|
||||
chapterTitle: String?,
|
||||
text: String,
|
||||
speakerId: String
|
||||
): File {
|
||||
val chapterDir = chapterDir(bookTitle, chapterTitle)
|
||||
if (!chapterDir.exists()) chapterDir.mkdirs()
|
||||
val hashParams = hash(text + speakerId + "CLOUD")
|
||||
val safeSpeaker = sanitize(speakerId)
|
||||
return File(chapterDir, "cached_chunk_${safeSpeaker}_$hashParams.wav")
|
||||
}
|
||||
|
||||
fun getBookCacheDir(bookTitle: String): File {
|
||||
return File(baseDir, sanitize(bookTitle.take(50)))
|
||||
}
|
||||
|
||||
fun getChapterCaches(bookTitle: String, speakerFilter: String? = null): List<ReaderTtsChapterCacheInfo> {
|
||||
val bookDir = getBookCacheDir(bookTitle)
|
||||
if (!bookDir.exists()) return emptyList()
|
||||
|
||||
return bookDir.listFiles()
|
||||
?.filter { it.isDirectory }
|
||||
?.mapNotNull { chapterDir ->
|
||||
val files = chapterDir.listFiles()
|
||||
?.filter { file -> file.isFile && file.name.endsWith(".wav") && file.matchesSpeaker(speakerFilter) }
|
||||
.orEmpty()
|
||||
if (files.isEmpty()) return@mapNotNull null
|
||||
|
||||
val metaFile = File(chapterDir, "total_chunks.txt")
|
||||
ReaderTtsChapterCacheInfo(
|
||||
chapterTitle = chapterDir.name,
|
||||
chunkCount = files.size,
|
||||
totalChunks = metaFile.takeIf { it.exists() }?.readText()?.toIntOrNull(),
|
||||
sizeBytes = files.sumOf { it.length() },
|
||||
directoryPath = chapterDir.absolutePath,
|
||||
matchingFilePaths = files.map { it.absolutePath }
|
||||
)
|
||||
}
|
||||
?.sortedBy { it.chapterTitle }
|
||||
.orEmpty()
|
||||
}
|
||||
|
||||
fun getCacheSummary(bookTitle: String, speakerId: String? = null): ReaderTtsCacheSummary {
|
||||
val allChapters = getChapterCaches(bookTitle, speakerFilter = null)
|
||||
val voiceChapters = speakerId
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { getChapterCaches(bookTitle, speakerFilter = it) }
|
||||
.orEmpty()
|
||||
return ReaderTtsCacheSummary(
|
||||
cachedChapterCount = allChapters.size,
|
||||
cachedChunkCount = allChapters.sumOf { it.chunkCount },
|
||||
currentVoiceChunkCount = voiceChapters.sumOf { it.chunkCount },
|
||||
totalSizeBytes = allChapters.sumOf { it.sizeBytes },
|
||||
currentVoiceSizeBytes = voiceChapters.sumOf { it.sizeBytes }
|
||||
)
|
||||
}
|
||||
|
||||
fun cachedSpeakers(bookTitle: String): List<String> {
|
||||
val bookDir = getBookCacheDir(bookTitle)
|
||||
if (!bookDir.exists()) return emptyList()
|
||||
return bookDir.listFiles()
|
||||
?.filter { it.isDirectory }
|
||||
?.flatMap { chapterDir ->
|
||||
chapterDir.listFiles()
|
||||
?.mapNotNull { it.speakerFromCacheFileName() }
|
||||
.orEmpty()
|
||||
}
|
||||
?.distinct()
|
||||
?.sorted()
|
||||
.orEmpty()
|
||||
}
|
||||
|
||||
fun deleteSpecificFiles(filePaths: List<String>, chapterDirectoryPath: String) {
|
||||
filePaths.forEach { path -> File(path).delete() }
|
||||
val chapterDir = File(chapterDirectoryPath)
|
||||
if (chapterDir.listFiles()?.isEmpty() == true) {
|
||||
chapterDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
fun clearBookCache(bookTitle: String) {
|
||||
getBookCacheDir(bookTitle).deleteRecursively()
|
||||
}
|
||||
|
||||
fun clearBookCacheForSpeaker(bookTitle: String, speakerId: String) {
|
||||
getChapterCaches(bookTitle, speakerFilter = speakerId).forEach { chapter ->
|
||||
deleteSpecificFiles(chapter.matchingFilePaths, chapter.directoryPath)
|
||||
}
|
||||
}
|
||||
|
||||
private fun chapterDir(bookTitle: String, chapterTitle: String?): File {
|
||||
return File(getBookCacheDir(bookTitle), sanitize((chapterTitle ?: "Unknown_Chapter").take(50)))
|
||||
}
|
||||
|
||||
private fun File.matchesSpeaker(speakerFilter: String?): Boolean {
|
||||
if (speakerFilter.isNullOrBlank() || speakerFilter == "All") return true
|
||||
return speakerFromCacheFileName() == speakerFilter
|
||||
}
|
||||
|
||||
private fun File.speakerFromCacheFileName(): String? {
|
||||
if (!name.startsWith("cached_chunk_") || !name.endsWith(".wav")) return null
|
||||
val withoutPrefix = name.removePrefix("cached_chunk_")
|
||||
return withoutPrefix.substringBeforeLast('_').takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
private fun sanitize(name: String): String {
|
||||
return name.replace(Regex("[^a-zA-Z0-9.-]"), "_")
|
||||
}
|
||||
|
||||
private fun hash(input: String): String {
|
||||
val bytes = MessageDigest.getInstance("SHA-256").digest(input.toByteArray())
|
||||
return bytes.joinToString("") { "%02x".format(it) }.take(16)
|
||||
}
|
||||
}
|
||||
|
||||
fun createReaderTtsWavHeaderUnknownLength(sampleRate: Int): ByteArray {
|
||||
val numChannels = 1
|
||||
val bitsPerSample = 16
|
||||
val byteRate = sampleRate * numChannels * bitsPerSample / 8
|
||||
val blockAlign = numChannels * bitsPerSample / 8
|
||||
|
||||
val header = ByteBuffer.allocate(44)
|
||||
header.order(ByteOrder.LITTLE_ENDIAN)
|
||||
header.put("RIFF".toByteArray(Charsets.US_ASCII))
|
||||
header.putInt(0x7FFFFFFF)
|
||||
header.put("WAVE".toByteArray(Charsets.US_ASCII))
|
||||
header.put("fmt ".toByteArray(Charsets.US_ASCII))
|
||||
header.putInt(16)
|
||||
header.putShort(1.toShort())
|
||||
header.putShort(numChannels.toShort())
|
||||
header.putInt(sampleRate)
|
||||
header.putInt(byteRate)
|
||||
header.putShort(blockAlign.toShort())
|
||||
header.putShort(bitsPerSample.toShort())
|
||||
header.put("data".toByteArray(Charsets.US_ASCII))
|
||||
header.putInt(0x7FFFFFFF - 36)
|
||||
|
||||
return header.array()
|
||||
}
|
||||
|
||||
fun patchReaderTtsWavHeader(file: File, pcmDataLength: Int) {
|
||||
RandomAccessFile(file, "rw").use { raf ->
|
||||
raf.seek(4)
|
||||
raf.writeInt(Integer.reverseBytes(36 + pcmDataLength))
|
||||
raf.seek(40)
|
||||
raf.writeInt(Integer.reverseBytes(pcmDataLength))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,447 @@
|
|||
package com.aryan.reader.shared.opds
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.doubleOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.nodes.Element
|
||||
import org.jsoup.parser.Parser
|
||||
import java.net.URL
|
||||
import java.util.UUID
|
||||
|
||||
class SharedOpdsParser {
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
}
|
||||
|
||||
fun parse(bodyString: String, baseUrl: String): OpdsFeed {
|
||||
val trimmed = bodyString.trimStart()
|
||||
return if (trimmed.startsWith("{")) {
|
||||
parseOpds2(trimmed, baseUrl)
|
||||
} else {
|
||||
parseOpds1(trimmed, baseUrl)
|
||||
}
|
||||
}
|
||||
|
||||
fun extractOpenSearchTemplate(bodyString: String, openSearchUrl: String): String? {
|
||||
val document = Jsoup.parse(bodyString, openSearchUrl, Parser.xmlParser())
|
||||
return document.allElements
|
||||
.asSequence()
|
||||
.filter { it.localTagName().equals("url", ignoreCase = true) }
|
||||
.firstNotNullOfOrNull { urlElement ->
|
||||
val type = urlElement.attrAny("type").orEmpty()
|
||||
val template = urlElement.attrAny("template")
|
||||
if (
|
||||
template != null &&
|
||||
(type.contains("atom+xml", ignoreCase = true) || type.contains("opds+xml", ignoreCase = true))
|
||||
) {
|
||||
resolveUrl(openSearchUrl, template)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseOpds2(jsonString: String, baseUrl: String): OpdsFeed {
|
||||
val root = json.parseToJsonElement(jsonString).jsonObject
|
||||
val metadata = root.obj("metadata")
|
||||
val title = metadata?.string("title") ?: "OPDS 2.0 Feed"
|
||||
|
||||
var nextUrl: String? = null
|
||||
var searchUrl: String? = null
|
||||
val facets = mutableListOf<OpdsFacet>()
|
||||
|
||||
root.array("links").forEach { link ->
|
||||
val href = link.string("href")
|
||||
if (!href.isNullOrBlank()) {
|
||||
val resolvedHref = resolveUrl(baseUrl, href)
|
||||
val rels = link.rels()
|
||||
when {
|
||||
"next" in rels -> nextUrl = resolvedHref
|
||||
"search" in rels -> searchUrl = resolvedHref
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
root.array("facets").forEach { facetObj ->
|
||||
val group = facetObj.obj("metadata")?.string("title") ?: "Filter"
|
||||
facetObj.array("links").forEach { link ->
|
||||
val href = link.string("href")
|
||||
if (!href.isNullOrBlank()) {
|
||||
facets.add(
|
||||
OpdsFacet(
|
||||
title = link.string("title") ?: "Facet",
|
||||
group = group,
|
||||
url = resolveUrl(baseUrl, href),
|
||||
isActive = link.obj("properties")?.boolean("active") ?: false
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val entries = mutableListOf<OpdsEntry>()
|
||||
root.array("publications").forEach { entries.add(parseOpds2Publication(it, baseUrl)) }
|
||||
root.array("navigation").forEach { entries.add(parseOpds2Navigation(it, baseUrl)) }
|
||||
root.array("groups").forEach { group ->
|
||||
val groupTitle = group.obj("metadata")?.string("title").orEmpty()
|
||||
group.array("navigation").forEach { entries.add(parseOpds2Navigation(it, baseUrl)) }
|
||||
group.array("publications").forEach { entries.add(parseOpds2Publication(it, baseUrl)) }
|
||||
group.array("links").forEach { link ->
|
||||
val href = link.string("href")
|
||||
if (!href.isNullOrBlank()) {
|
||||
entries.add(
|
||||
OpdsEntry(
|
||||
id = href,
|
||||
title = link.string("title") ?: groupTitle,
|
||||
summary = null,
|
||||
authors = emptyList(),
|
||||
coverUrl = null,
|
||||
acquisitions = emptyList(),
|
||||
navigationUrl = resolveUrl(baseUrl, href)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return OpdsFeed(title = title, entries = entries, nextUrl = nextUrl, searchUrl = searchUrl, facets = facets)
|
||||
}
|
||||
|
||||
private fun parseOpds2Publication(pub: JsonObject, baseUrl: String): OpdsEntry {
|
||||
val metadata = pub.obj("metadata")
|
||||
val title = metadata?.string("title") ?: "Unknown Title"
|
||||
val id = metadata?.string("identifier") ?: pub.string("id") ?: UUID.randomUUID().toString()
|
||||
val summary = metadata?.string("description") ?: metadata?.string("summary")
|
||||
val language = metadata?.string("language")
|
||||
val publisher = metadata?.string("publisher")
|
||||
val published = metadata?.string("published")
|
||||
val authors = parseOpds2Authors(metadata?.get("author"), baseUrl)
|
||||
val categories = parseOpds2Categories(metadata?.get("subject"))
|
||||
val (series, seriesIndex) = parseOpds2Series(metadata?.obj("belongsTo"))
|
||||
|
||||
var coverUrl: String? = null
|
||||
pub.array("images").forEach { image ->
|
||||
val href = image.string("href")
|
||||
if (!href.isNullOrBlank()) {
|
||||
val resolvedHref = resolveUrl(baseUrl, href)
|
||||
if (coverUrl == null) coverUrl = resolvedHref
|
||||
if ("cover" in image.rels()) {
|
||||
coverUrl = resolvedHref
|
||||
return@forEach
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val acquisitions = mutableListOf<OpdsAcquisition>()
|
||||
var pseCount: Int? = null
|
||||
var pseUrlTemplate: String? = null
|
||||
pub.array("links").forEach { link ->
|
||||
val href = link.string("href")
|
||||
if (!href.isNullOrBlank()) {
|
||||
val rels = link.rels()
|
||||
if (rels.any { it == PSE_STREAM_REL }) {
|
||||
pseUrlTemplate = resolveUrl(baseUrl, href)
|
||||
pseCount = link.obj("properties")?.int("numberOfItems")?.takeIf { it > 0 }
|
||||
}
|
||||
if (rels.any { it.contains("acquisition") }) {
|
||||
acquisitions.add(OpdsAcquisition(resolveUrl(baseUrl, href), link.string("type").orEmpty()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return OpdsEntry(
|
||||
id = id,
|
||||
title = title,
|
||||
summary = summary,
|
||||
authors = authors,
|
||||
coverUrl = coverUrl,
|
||||
acquisitions = acquisitions,
|
||||
navigationUrl = null,
|
||||
publisher = publisher,
|
||||
published = published,
|
||||
language = language,
|
||||
series = series,
|
||||
seriesIndex = seriesIndex,
|
||||
categories = categories,
|
||||
pseCount = pseCount,
|
||||
pseUrlTemplate = pseUrlTemplate
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseOpds2Navigation(nav: JsonObject, baseUrl: String): OpdsEntry {
|
||||
val href = nav.string("href")
|
||||
return OpdsEntry(
|
||||
id = href.orEmpty(),
|
||||
title = nav.string("title") ?: "Unknown",
|
||||
summary = nav.string("description"),
|
||||
authors = emptyList(),
|
||||
coverUrl = null,
|
||||
acquisitions = emptyList(),
|
||||
navigationUrl = href?.takeIf { it.isNotBlank() }?.let { resolveUrl(baseUrl, it) }
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseOpds1(xmlString: String, baseUrl: String): OpdsFeed {
|
||||
val document = Jsoup.parse(xmlString, baseUrl, Parser.xmlParser())
|
||||
val feed = document.allElements.firstOrNull { it.localTagName() == "feed" }
|
||||
?: return OpdsFeed("OPDS Feed", emptyList(), nextUrl = null)
|
||||
var title = ""
|
||||
var nextUrl: String? = null
|
||||
var searchUrl: String? = null
|
||||
val entries = mutableListOf<OpdsEntry>()
|
||||
val facets = mutableListOf<OpdsFacet>()
|
||||
|
||||
feed.children().forEach { child ->
|
||||
when (child.localTagName()) {
|
||||
"title" -> title = child.cleanText()
|
||||
"entry" -> entries.add(readOpds1Entry(child, baseUrl))
|
||||
"link" -> {
|
||||
val rel = child.attrAny("rel")
|
||||
val href = child.attrAny("href")
|
||||
val linkTitle = child.attrAny("title")
|
||||
val facetGroup = child.attrAny("opds:facetGroup", "facetGroup") ?: "Filter"
|
||||
val activeFacet = child.attrAny("opds:activeFacet", "activeFacet") == "true"
|
||||
when {
|
||||
rel == "next" -> nextUrl = href?.let { resolveUrl(baseUrl, it) }
|
||||
rel == "search" -> searchUrl = href?.let { resolveUrl(baseUrl, it) }
|
||||
rel == "facet" || rel == "http://opds-spec.org/facet" -> {
|
||||
if (href != null && linkTitle != null) {
|
||||
facets.add(OpdsFacet(linkTitle, facetGroup, resolveUrl(baseUrl, href), activeFacet))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return OpdsFeed(title, entries, nextUrl, searchUrl, facets)
|
||||
}
|
||||
|
||||
private fun readOpds1Entry(entry: Element, baseUrl: String): OpdsEntry {
|
||||
var id = ""
|
||||
var title = ""
|
||||
var summary: String? = null
|
||||
var coverUrl: String? = null
|
||||
var navigationUrl: String? = null
|
||||
var publisher: String? = null
|
||||
var published: String? = null
|
||||
var language: String? = null
|
||||
var series: String? = null
|
||||
var seriesIndex: String? = null
|
||||
var pseCount: Int? = null
|
||||
var pseUrlTemplate: String? = null
|
||||
val authors = mutableListOf<OpdsAuthor>()
|
||||
val categories = mutableListOf<String>()
|
||||
val acquisitions = mutableListOf<OpdsAcquisition>()
|
||||
|
||||
entry.children().forEach { child ->
|
||||
when (val tagName = child.localTagName()) {
|
||||
"id" -> id = child.cleanText()
|
||||
"title" -> title = child.cleanText()
|
||||
"summary", "content" -> summary = child.text().trim()
|
||||
"author" -> authors.add(readOpds1Author(child, baseUrl))
|
||||
"publisher" -> publisher = child.cleanText()
|
||||
"language" -> if (language == null) language = child.cleanText()
|
||||
"issued", "published", "updated" -> {
|
||||
val date = child.cleanText()
|
||||
if (published == null || tagName != "updated") published = date
|
||||
}
|
||||
"category" -> {
|
||||
val category = child.attrAny("label") ?: child.attrAny("term")
|
||||
if (!category.isNullOrBlank()) categories.add(category)
|
||||
}
|
||||
"meta" -> {
|
||||
val property = child.attrAny("property", "name")
|
||||
val content = child.attrAny("content")
|
||||
val textContent = child.cleanText()
|
||||
when (property) {
|
||||
"calibre:series" -> series = content ?: textContent.takeIf { it.isNotBlank() }
|
||||
"calibre:series_index" -> seriesIndex = content ?: textContent.takeIf { it.isNotBlank() }
|
||||
}
|
||||
}
|
||||
"link" -> {
|
||||
val rel = child.attrAny("rel").orEmpty()
|
||||
val href = child.attrAny("href").orEmpty()
|
||||
val type = child.attrAny("type").orEmpty()
|
||||
val linkTitle = child.attrAny("title")
|
||||
|
||||
if (rel == PSE_STREAM_REL) {
|
||||
pseUrlTemplate = resolveUrl(baseUrl, href)
|
||||
pseCount = child.attrAny("pse:count", "count")?.toIntOrNull()
|
||||
}
|
||||
|
||||
if (rel == "http://calibre-ebook.com/opds/series" && series == null) {
|
||||
series = linkTitle
|
||||
}
|
||||
|
||||
if (href.isNotEmpty()) {
|
||||
val absoluteUrl = resolveUrl(baseUrl, href)
|
||||
when {
|
||||
rel.contains("http://opds-spec.org/image") -> {
|
||||
if (coverUrl == null || rel.contains("thumbnail")) coverUrl = absoluteUrl
|
||||
}
|
||||
rel.contains("http://opds-spec.org/acquisition") -> {
|
||||
acquisitions.add(OpdsAcquisition(absoluteUrl, type))
|
||||
}
|
||||
type.contains("profile=opds-catalog") || type.contains("application/atom+xml") -> {
|
||||
if (navigationUrl == null) navigationUrl = absoluteUrl
|
||||
}
|
||||
rel == "subsection" || rel == "collection" || rel == "start" -> {
|
||||
if (navigationUrl == null) navigationUrl = absoluteUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return OpdsEntry(
|
||||
id = id,
|
||||
title = title,
|
||||
summary = summary,
|
||||
authors = authors,
|
||||
coverUrl = coverUrl,
|
||||
acquisitions = acquisitions,
|
||||
navigationUrl = navigationUrl,
|
||||
publisher = publisher,
|
||||
published = published,
|
||||
language = language,
|
||||
series = series,
|
||||
seriesIndex = seriesIndex,
|
||||
categories = categories,
|
||||
pseCount = pseCount,
|
||||
pseUrlTemplate = pseUrlTemplate
|
||||
)
|
||||
}
|
||||
|
||||
private fun readOpds1Author(author: Element, baseUrl: String): OpdsAuthor {
|
||||
var name = ""
|
||||
var uri: String? = null
|
||||
author.children().forEach { child ->
|
||||
when (child.localTagName()) {
|
||||
"name" -> name = child.cleanText()
|
||||
"uri" -> uri = resolveUrl(baseUrl, child.cleanText())
|
||||
}
|
||||
}
|
||||
return OpdsAuthor(name, uri)
|
||||
}
|
||||
|
||||
private fun parseOpds2Authors(authorElement: JsonElement?, baseUrl: String): List<OpdsAuthor> {
|
||||
return when (authorElement) {
|
||||
is JsonArray -> authorElement.mapNotNull { parseOpds2Author(it, baseUrl) }
|
||||
null -> emptyList()
|
||||
else -> listOfNotNull(parseOpds2Author(authorElement, baseUrl))
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseOpds2Author(authorElement: JsonElement, baseUrl: String): OpdsAuthor? {
|
||||
authorElement.primitiveString()?.let { return OpdsAuthor(it, null) }
|
||||
val obj = authorElement.asObjectOrNull() ?: return null
|
||||
val name = obj.string("name")?.takeIf { it.isNotBlank() } ?: return null
|
||||
val uri = obj.array("links")
|
||||
.firstOrNull()
|
||||
?.string("href")
|
||||
?.let { resolveUrl(baseUrl, it) }
|
||||
return OpdsAuthor(name, uri)
|
||||
}
|
||||
|
||||
private fun parseOpds2Categories(subjectElement: JsonElement?): List<String> {
|
||||
return when (subjectElement) {
|
||||
is JsonArray -> subjectElement.mapNotNull(::parseOpds2Category)
|
||||
null -> emptyList()
|
||||
else -> listOfNotNull(parseOpds2Category(subjectElement))
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseOpds2Category(subjectElement: JsonElement): String? {
|
||||
subjectElement.primitiveString()?.let { return it }
|
||||
return subjectElement.asObjectOrNull()?.string("name")?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
private fun parseOpds2Series(belongsTo: JsonObject?): Pair<String?, String?> {
|
||||
val seriesElement = belongsTo?.get("series") ?: return null to null
|
||||
val first = if (seriesElement is JsonArray) seriesElement.firstOrNull() else seriesElement
|
||||
first?.primitiveString()?.let { return it to null }
|
||||
val seriesObj = first?.asObjectOrNull() ?: return null to null
|
||||
val name = seriesObj.string("name")
|
||||
val index = seriesObj.get("position")
|
||||
?.jsonPrimitive
|
||||
?.doubleOrNull
|
||||
?.toString()
|
||||
?.removeSuffix(".0")
|
||||
return name to index
|
||||
}
|
||||
|
||||
private fun resolveUrl(baseUrl: String, href: String): String {
|
||||
return runCatching {
|
||||
URL(URL(baseUrl), href).toString()
|
||||
.replace("http://m.gutenberg.org", "https://m.gutenberg.org")
|
||||
.replace("http://www.gutenberg.org", "https://www.gutenberg.org")
|
||||
}.getOrDefault(href)
|
||||
}
|
||||
|
||||
private fun JsonObject.obj(name: String): JsonObject? = get(name)?.asObjectOrNull()
|
||||
|
||||
private fun JsonObject.array(name: String): List<JsonObject> {
|
||||
return runCatching { get(name)?.jsonArray?.mapNotNull { it.asObjectOrNull() }.orEmpty() }
|
||||
.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
private fun JsonObject.string(name: String): String? {
|
||||
return runCatching { get(name)?.jsonPrimitive?.contentOrNull }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.boolean(name: String): Boolean? {
|
||||
return runCatching { get(name)?.jsonPrimitive?.contentOrNull?.toBooleanStrictOrNull() }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.int(name: String): Int? {
|
||||
return runCatching { get(name)?.jsonPrimitive?.intOrNull }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.rels(): List<String> {
|
||||
val rel = get("rel") ?: return emptyList()
|
||||
rel.primitiveString()?.let { return listOf(it) }
|
||||
return runCatching { rel.jsonArray.mapNotNull { it.primitiveString() } }.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
private fun JsonElement.primitiveString(): String? {
|
||||
return runCatching { jsonPrimitive.contentOrNull }.getOrNull()?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
private fun JsonElement.asObjectOrNull(): JsonObject? {
|
||||
return runCatching { jsonObject }.getOrNull()
|
||||
}
|
||||
|
||||
private fun Element.localTagName(): String = tagName().substringAfter(":")
|
||||
|
||||
private fun Element.cleanText(): String = wholeText().trim().ifBlank { text().trim() }
|
||||
|
||||
private fun Element.attrAny(vararg names: String): String? {
|
||||
names.forEach { name ->
|
||||
val direct = attr(name)
|
||||
if (direct.isNotBlank()) return direct
|
||||
}
|
||||
val localNames = names.map { it.substringAfter(":") }
|
||||
return attributes()
|
||||
.asList()
|
||||
.firstOrNull { attribute ->
|
||||
localNames.any { local -> attribute.key.substringAfter(":").equals(local, ignoreCase = true) }
|
||||
}
|
||||
?.value
|
||||
?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private const val PSE_STREAM_REL = "http://vaemendis.net/opds-pse/stream"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue