Added URL detection in reader, improved platform URL handling, library item auto-rescan, and file association support.
This commit is contained in:
parent
2e9a52f4af
commit
cde4d89eed
@ -140,6 +140,8 @@ compose.desktop {
|
|||||||
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
|
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
|
||||||
packageName = "To Read"
|
packageName = "To Read"
|
||||||
packageVersion = appVersionDisplay
|
packageVersion = appVersionDisplay
|
||||||
|
fileAssociation("application/x-fictionbook+xml", "fb2", "FictionBook document")
|
||||||
|
fileAssociation("application/zip", "fb2.zip", "Zipped FictionBook document")
|
||||||
|
|
||||||
macOS {
|
macOS {
|
||||||
iconFile.set(project.file("src/jvmMain/resources/icons/icon.icns"))
|
iconFile.set(project.file("src/jvmMain/resources/icons/icon.icns"))
|
||||||
|
|||||||
@ -24,6 +24,7 @@
|
|||||||
android:theme="@style/AppTheme">
|
android:theme="@style/AppTheme">
|
||||||
<activity
|
<activity
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
android:launchMode="singleTop"
|
||||||
android:name=".MainActivity">
|
android:name=".MainActivity">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN"/>
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
@ -39,6 +40,8 @@
|
|||||||
<data android:scheme="content"/>
|
<data android:scheme="content"/>
|
||||||
<data android:scheme="file"/>
|
<data android:scheme="file"/>
|
||||||
<data android:mimeType="application/x-fictionbook+xml"/>
|
<data android:mimeType="application/x-fictionbook+xml"/>
|
||||||
|
<data android:mimeType="application/xml"/>
|
||||||
|
<data android:mimeType="text/xml"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.VIEW"/>
|
<action android:name="android.intent.action.VIEW"/>
|
||||||
@ -48,8 +51,12 @@
|
|||||||
|
|
||||||
<data android:scheme="content" android:mimeType="*/*" android:pathPattern=".*\\.fb2"/>
|
<data android:scheme="content" android:mimeType="*/*" android:pathPattern=".*\\.fb2"/>
|
||||||
<data android:scheme="content" android:mimeType="*/*" android:pathPattern=".*\\.fb2\\.zip"/>
|
<data android:scheme="content" android:mimeType="*/*" android:pathPattern=".*\\.fb2\\.zip"/>
|
||||||
|
<data android:scheme="content" android:mimeType="application/octet-stream" android:pathPattern=".*\\.fb2"/>
|
||||||
|
<data android:scheme="content" android:mimeType="application/octet-stream" android:pathPattern=".*\\.fb2\\.zip"/>
|
||||||
<data android:scheme="file" android:mimeType="*/*" android:pathPattern=".*\\.fb2"/>
|
<data android:scheme="file" android:mimeType="*/*" android:pathPattern=".*\\.fb2"/>
|
||||||
<data android:scheme="file" android:mimeType="*/*" android:pathPattern=".*\\.fb2\\.zip"/>
|
<data android:scheme="file" android:mimeType="*/*" android:pathPattern=".*\\.fb2\\.zip"/>
|
||||||
|
<data android:scheme="file" android:mimeType="application/octet-stream" android:pathPattern=".*\\.fb2"/>
|
||||||
|
<data android:scheme="file" android:mimeType="application/octet-stream" android:pathPattern=".*\\.fb2\\.zip"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
<provider
|
<provider
|
||||||
|
|||||||
@ -102,6 +102,14 @@ actual fun defaultLibraryScanPath(): String? =
|
|||||||
?: appContext.filesDir.absolutePath
|
?: appContext.filesDir.absolutePath
|
||||||
|
|
||||||
fun rememberPlatformOpenBookIntent(intent: Intent?) {
|
fun rememberPlatformOpenBookIntent(intent: Intent?) {
|
||||||
|
val uri = intent
|
||||||
|
?.takeIf { it.action == Intent.ACTION_VIEW }
|
||||||
|
?.data
|
||||||
|
if (uri != null && intent.flags and Intent.FLAG_GRANT_READ_URI_PERMISSION != 0) {
|
||||||
|
runCatching {
|
||||||
|
appContext.contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
}
|
||||||
|
}
|
||||||
pendingOpenBookUri = intent
|
pendingOpenBookUri = intent
|
||||||
?.takeIf { it.action == Intent.ACTION_VIEW }
|
?.takeIf { it.action == Intent.ACTION_VIEW }
|
||||||
?.data
|
?.data
|
||||||
@ -111,9 +119,21 @@ actual suspend fun loadPlatformOpenBookRequest(): PlatformOpenBookRequest? = wit
|
|||||||
val uri = pendingOpenBookUri ?: return@withContext null
|
val uri = pendingOpenBookUri ?: return@withContext null
|
||||||
pendingOpenBookUri = null
|
pendingOpenBookUri = null
|
||||||
val bytes = readStorageUriBytes(uri.toString()) ?: return@withContext null
|
val bytes = readStorageUriBytes(uri.toString()) ?: return@withContext null
|
||||||
|
val displayName = displayNameFor(uri)
|
||||||
|
val sizeBytes = sizeBytesFor(uri) ?: bytes.size.toLong()
|
||||||
|
val importResult = openLibraryDatabase().useLibrary { db ->
|
||||||
|
LibraryScanner(db, ::appendLibraryLog).importExternalFileAndGetResult(
|
||||||
|
bytes = bytes,
|
||||||
|
displayName = displayName,
|
||||||
|
storageUri = uri.toString(),
|
||||||
|
sizeBytes = sizeBytes,
|
||||||
|
lastModifiedMillis = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
appendLibraryLog("platform open imported=${importResult.imported} fileId=${importResult.fileId} uri=$uri")
|
||||||
PlatformOpenBookRequest(
|
PlatformOpenBookRequest(
|
||||||
id = "platform:${uri}",
|
id = importResult.fileId,
|
||||||
displayName = displayNameFor(uri),
|
displayName = displayName,
|
||||||
bytes = bytes,
|
bytes = bytes,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -374,6 +394,15 @@ actual suspend fun shareLibraryBookFile(fileId: String): Boolean = withContext(D
|
|||||||
|
|
||||||
actual suspend fun viewLibraryBookFile(folder: String, fileName: String): Boolean = false
|
actual suspend fun viewLibraryBookFile(folder: String, fileName: String): Boolean = false
|
||||||
|
|
||||||
|
actual fun openPlatformUrl(url: String): Boolean =
|
||||||
|
runCatching {
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply {
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
appContext.startActivity(intent)
|
||||||
|
true
|
||||||
|
}.getOrDefault(false)
|
||||||
|
|
||||||
actual suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras = withContext(Dispatchers.IO) {
|
actual suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras = withContext(Dispatchers.IO) {
|
||||||
openLibraryDatabase().useLibrary { db ->
|
openLibraryDatabase().useLibrary { db ->
|
||||||
val file = db.files.get(fileId) ?: return@useLibrary BookInfoExtras()
|
val file = db.files.get(fileId) ?: return@useLibrary BookInfoExtras()
|
||||||
@ -689,6 +718,11 @@ private fun displayNameFor(uri: Uri): String =
|
|||||||
if (cursor.moveToFirst()) cursor.getString(0) else null
|
if (cursor.moveToFirst()) cursor.getString(0) else null
|
||||||
} ?: uri.lastPathSegment ?: uri.toString()
|
} ?: uri.lastPathSegment ?: uri.toString()
|
||||||
|
|
||||||
|
private fun sizeBytesFor(uri: Uri): Long? =
|
||||||
|
appContext.contentResolver.query(uri, arrayOf(OpenableColumns.SIZE), null, null, null)?.use { cursor ->
|
||||||
|
if (cursor.moveToFirst() && !cursor.isNull(0)) cursor.getLong(0) else null
|
||||||
|
}
|
||||||
|
|
||||||
private fun LibraryScanSummary.toLibraryScanProgress(): LibraryScanProgress =
|
private fun LibraryScanSummary.toLibraryScanProgress(): LibraryScanProgress =
|
||||||
LibraryScanProgress(
|
LibraryScanProgress(
|
||||||
scannedFiles = scannedFiles,
|
scannedFiles = scannedFiles,
|
||||||
|
|||||||
@ -24,6 +24,10 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
|
import net.sergeych.toread.fb2.Fb2Format
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@ -180,6 +184,9 @@ private fun BookReaderApp(
|
|||||||
var nextLibraryItemRefreshId by remember { mutableStateOf(0L) }
|
var nextLibraryItemRefreshId by remember { mutableStateOf(0L) }
|
||||||
var libraryItemRefreshRequest by remember { mutableStateOf<LibraryItemRefreshRequest?>(null) }
|
var libraryItemRefreshRequest by remember { mutableStateOf<LibraryItemRefreshRequest?>(null) }
|
||||||
var imageViewer by remember { mutableStateOf<ViewedBookImage?>(null) }
|
var imageViewer by remember { mutableStateOf<ViewedBookImage?>(null) }
|
||||||
|
var resumeGeneration by remember { mutableStateOf(0L) }
|
||||||
|
var downloadsRescanRequestGeneration by remember { mutableStateOf(0L) }
|
||||||
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
fun refreshLibraryItem(fileId: String) {
|
fun refreshLibraryItem(fileId: String) {
|
||||||
@ -191,6 +198,60 @@ private fun BookReaderApp(
|
|||||||
state = loadStartupState()
|
state = loadStartupState()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DisposableEffect(lifecycleOwner) {
|
||||||
|
val observer = LifecycleEventObserver { _, event ->
|
||||||
|
if (event == Lifecycle.Event.ON_RESUME) {
|
||||||
|
resumeGeneration += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lifecycleOwner.lifecycle.addObserver(observer)
|
||||||
|
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
while (true) {
|
||||||
|
delay(500)
|
||||||
|
val requestResult = runCatching { loadPlatformOpenBookRequest() }
|
||||||
|
if (requestResult.isFailure) {
|
||||||
|
val scanPath = when (val current = state) {
|
||||||
|
is AppState.Library -> current.scanPath
|
||||||
|
is AppState.Scan -> current.scanPath
|
||||||
|
is AppState.Reader -> current.scanPath
|
||||||
|
is AppState.BookInfo -> current.scanPath
|
||||||
|
is AppState.Error, AppState.LoadingStartup -> defaultLibraryScanPath().orEmpty()
|
||||||
|
}
|
||||||
|
state = AppState.Library(emptyList(), scanPath, requestResult.exceptionOrNull()?.message ?: strings.couldNotOpenLibrary)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val request = requestResult.getOrNull() ?: continue
|
||||||
|
val scanPath = when (val current = state) {
|
||||||
|
is AppState.Library -> current.scanPath
|
||||||
|
is AppState.Scan -> current.scanPath
|
||||||
|
is AppState.Reader -> current.scanPath
|
||||||
|
is AppState.BookInfo -> current.scanPath
|
||||||
|
is AppState.Error, AppState.LoadingStartup -> defaultLibraryScanPath().orEmpty()
|
||||||
|
}
|
||||||
|
val nextState = runCatching {
|
||||||
|
AppState.Reader(
|
||||||
|
fileId = request.id,
|
||||||
|
book = Fb2Format.parse(request.bytes, request.displayName),
|
||||||
|
libraryItems = emptyList(),
|
||||||
|
scanPath = scanPath,
|
||||||
|
)
|
||||||
|
}.getOrElse {
|
||||||
|
AppState.Library(emptyList(), scanPath, it.message ?: strings.couldNotOpen(request.displayName))
|
||||||
|
}
|
||||||
|
libraryBackState = when (val current = state) {
|
||||||
|
is AppState.Library -> current
|
||||||
|
is AppState.Scan -> AppState.Library(current.items, current.scanPath, current.message)
|
||||||
|
is AppState.Reader, is AppState.BookInfo -> libraryBackState
|
||||||
|
is AppState.Error, AppState.LoadingStartup -> null
|
||||||
|
}
|
||||||
|
state = nextState
|
||||||
|
refreshLibraryItem(request.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val activeBookState = when (val current = state) {
|
val activeBookState = when (val current = state) {
|
||||||
is AppState.Reader -> current.fileId to current.libraryItems.isEmpty()
|
is AppState.Reader -> current.fileId to current.libraryItems.isEmpty()
|
||||||
is AppState.BookInfo -> current.fileId to current.libraryItems.isEmpty()
|
is AppState.BookInfo -> current.fileId to current.libraryItems.isEmpty()
|
||||||
@ -296,6 +357,7 @@ private fun BookReaderApp(
|
|||||||
val backState = libraryBackState?.copy(message = current.message)
|
val backState = libraryBackState?.copy(message = current.message)
|
||||||
?: AppState.Library(current.libraryItems, current.scanPath, current.message)
|
?: AppState.Library(current.libraryItems, current.scanPath, current.message)
|
||||||
libraryBackState = null
|
libraryBackState = null
|
||||||
|
downloadsRescanRequestGeneration += 1
|
||||||
backState
|
backState
|
||||||
}
|
}
|
||||||
is AppState.Scan -> AppState.Library(current.items, current.scanPath, current.message)
|
is AppState.Scan -> AppState.Library(current.items, current.scanPath, current.message)
|
||||||
@ -357,6 +419,14 @@ private fun BookReaderApp(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(resumeGeneration, downloadsRescanRequestGeneration, state !is AppState.LoadingStartup) {
|
||||||
|
if (state is AppState.LoadingStartup || activeScan != null || scanJob?.isActive == true) return@LaunchedEffect
|
||||||
|
if (!loadScanDownloadsAutomatically() || !loadDownloadsWasScanned()) return@LaunchedEffect
|
||||||
|
val path = downloadsScanPath() ?: return@LaunchedEffect
|
||||||
|
state = state.withMessage(strings.scanningDownloads)
|
||||||
|
startScan(path)
|
||||||
|
}
|
||||||
|
|
||||||
Box(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
val currentLibraryState = when (val current = state) {
|
val currentLibraryState = when (val current = state) {
|
||||||
is AppState.Library -> current
|
is AppState.Library -> current
|
||||||
@ -380,7 +450,6 @@ private fun BookReaderApp(
|
|||||||
libraryBackState = null
|
libraryBackState = null
|
||||||
state = AppState.Scan(libraryState.items, libraryState.scanPath, libraryState.message)
|
state = AppState.Scan(libraryState.items, libraryState.scanPath, libraryState.message)
|
||||||
},
|
},
|
||||||
onStartScan = ::startScan,
|
|
||||||
onDeleteRequested = ::requestDelete,
|
onDeleteRequested = ::requestDelete,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -447,3 +516,12 @@ internal data class ViewedBookImage(
|
|||||||
|
|
||||||
private fun LibraryScanReport.hasLibraryChanges(): Boolean =
|
private fun LibraryScanReport.hasLibraryChanges(): Boolean =
|
||||||
importedFiles > 0
|
importedFiles > 0
|
||||||
|
|
||||||
|
private fun AppState.withMessage(message: String): AppState =
|
||||||
|
when (this) {
|
||||||
|
is AppState.Library -> copy(message = message)
|
||||||
|
is AppState.Scan -> copy(message = message)
|
||||||
|
is AppState.Reader -> copy(message = message)
|
||||||
|
is AppState.BookInfo -> copy(message = message)
|
||||||
|
is AppState.Error, AppState.LoadingStartup -> this
|
||||||
|
}
|
||||||
|
|||||||
@ -42,7 +42,10 @@ internal suspend fun loadStartupState(): AppState {
|
|||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
return AppState.Error(t.message ?: strings.couldNotOpenLibrary)
|
return AppState.Error(t.message ?: strings.couldNotOpenLibrary)
|
||||||
}
|
}
|
||||||
loadPlatformOpenBookRequest()?.let { request ->
|
val platformRequest = runCatching { loadPlatformOpenBookRequest() }.getOrElse {
|
||||||
|
return AppState.Library(emptyList(), scanPath, it.message ?: strings.couldNotOpenLibrary)
|
||||||
|
}
|
||||||
|
platformRequest?.let { request ->
|
||||||
return runCatching {
|
return runCatching {
|
||||||
AppState.Reader(
|
AppState.Reader(
|
||||||
fileId = request.id,
|
fileId = request.id,
|
||||||
|
|||||||
@ -142,6 +142,8 @@ expect suspend fun shareLibraryBookFile(fileId: String): Boolean
|
|||||||
|
|
||||||
expect suspend fun viewLibraryBookFile(folder: String, fileName: String): Boolean
|
expect suspend fun viewLibraryBookFile(folder: String, fileName: String): Boolean
|
||||||
|
|
||||||
|
expect fun openPlatformUrl(url: String): Boolean
|
||||||
|
|
||||||
expect suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras
|
expect suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras
|
||||||
|
|
||||||
expect suspend fun loadActiveReadingFileId(): String?
|
expect suspend fun loadActiveReadingFileId(): String?
|
||||||
|
|||||||
@ -98,7 +98,6 @@ internal fun LibraryScreen(
|
|||||||
hiddenFileIds: Set<String>,
|
hiddenFileIds: Set<String>,
|
||||||
onStateChange: (AppState) -> Unit,
|
onStateChange: (AppState) -> Unit,
|
||||||
onNavigateToScan: () -> Unit,
|
onNavigateToScan: () -> Unit,
|
||||||
onStartScan: (String) -> Unit,
|
|
||||||
onDeleteRequested: (
|
onDeleteRequested: (
|
||||||
request: LibraryDeleteRequest,
|
request: LibraryDeleteRequest,
|
||||||
remove: () -> Unit,
|
remove: () -> Unit,
|
||||||
@ -116,8 +115,6 @@ internal fun LibraryScreen(
|
|||||||
var settingsMenuOpen by remember { mutableStateOf(false) }
|
var settingsMenuOpen by remember { mutableStateOf(false) }
|
||||||
var localeMenuOpen by remember { mutableStateOf(false) }
|
var localeMenuOpen by remember { mutableStateOf(false) }
|
||||||
var autoScanDownloads by remember { mutableStateOf(true) }
|
var autoScanDownloads by remember { mutableStateOf(true) }
|
||||||
var autoScanSettingLoaded by remember { mutableStateOf(false) }
|
|
||||||
var backgroundDownloadsRescanStarted by remember { mutableStateOf(false) }
|
|
||||||
var searchText by remember { mutableStateOf("") }
|
var searchText by remember { mutableStateOf("") }
|
||||||
var searchFocused by remember { mutableStateOf(false) }
|
var searchFocused by remember { mutableStateOf(false) }
|
||||||
var searchResults by remember { mutableStateOf<List<LibraryItem>>(emptyList()) }
|
var searchResults by remember { mutableStateOf<List<LibraryItem>>(emptyList()) }
|
||||||
@ -314,25 +311,6 @@ internal fun LibraryScreen(
|
|||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
autoScanDownloads = loadScanDownloadsAutomatically()
|
autoScanDownloads = loadScanDownloadsAutomatically()
|
||||||
autoScanSettingLoaded = true
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(autoScanSettingLoaded, autoScanDownloads, activeScan == null) {
|
|
||||||
if (
|
|
||||||
!autoScanSettingLoaded ||
|
|
||||||
!autoScanDownloads ||
|
|
||||||
backgroundDownloadsRescanStarted ||
|
|
||||||
activeScan != null
|
|
||||||
) {
|
|
||||||
return@LaunchedEffect
|
|
||||||
}
|
|
||||||
if (loadDownloadsWasScanned()) {
|
|
||||||
downloadsScanPath()?.let { path ->
|
|
||||||
backgroundDownloadsRescanStarted = true
|
|
||||||
message = strings.scanningDownloads
|
|
||||||
onStartScan(path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(activeScan != null) {
|
LaunchedEffect(activeScan != null) {
|
||||||
|
|||||||
@ -1293,14 +1293,100 @@ private fun Fb2Text.toAnnotatedString(
|
|||||||
span.href?.takeIf { it.isNotBlank() }?.let {
|
span.href?.takeIf { it.isNotBlank() }?.let {
|
||||||
pushStringAnnotation(ReaderLinkAnnotationTag, it)
|
pushStringAnnotation(ReaderLinkAnnotationTag, it)
|
||||||
}
|
}
|
||||||
|
if (isLink) {
|
||||||
withStyle(spanStyle) {
|
withStyle(spanStyle) {
|
||||||
appendWithHighlight(span.text, plainOffset, highlightedRange, highlightColor, language, hyphenation)
|
appendWithHighlight(span.text, plainOffset, highlightedRange, highlightColor, language, hyphenation)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
appendWithDetectedWebLinks(
|
||||||
|
text = span.text,
|
||||||
|
spanStyle = spanStyle,
|
||||||
|
plainOffset = plainOffset,
|
||||||
|
highlightedRange = highlightedRange,
|
||||||
|
highlightColor = highlightColor,
|
||||||
|
language = language,
|
||||||
|
hyphenation = hyphenation,
|
||||||
|
)
|
||||||
|
}
|
||||||
if (isLink) pop()
|
if (isLink) pop()
|
||||||
plainOffset += span.text.length
|
plainOffset += span.text.length
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun AnnotatedString.Builder.appendWithDetectedWebLinks(
|
||||||
|
text: String,
|
||||||
|
spanStyle: SpanStyle,
|
||||||
|
plainOffset: Int,
|
||||||
|
highlightedRange: ReaderSentenceRange?,
|
||||||
|
highlightColor: Color,
|
||||||
|
language: String?,
|
||||||
|
hyphenation: HyphenationRegistry,
|
||||||
|
) {
|
||||||
|
var cursor = 0
|
||||||
|
text.webUrlRanges().forEach { range ->
|
||||||
|
if (cursor < range.start) {
|
||||||
|
withStyle(spanStyle) {
|
||||||
|
appendWithHighlight(
|
||||||
|
text = text.substring(cursor, range.start),
|
||||||
|
plainOffset = plainOffset + cursor,
|
||||||
|
highlightedRange = highlightedRange,
|
||||||
|
highlightColor = highlightColor,
|
||||||
|
language = language,
|
||||||
|
hyphenation = hyphenation,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val url = text.substring(range)
|
||||||
|
pushStringAnnotation(ReaderLinkAnnotationTag, url)
|
||||||
|
withStyle(spanStyle.readerLinkStyle()) {
|
||||||
|
appendWithHighlight(
|
||||||
|
text = url,
|
||||||
|
plainOffset = plainOffset + range.start,
|
||||||
|
highlightedRange = highlightedRange,
|
||||||
|
highlightColor = highlightColor,
|
||||||
|
language = null,
|
||||||
|
hyphenation = hyphenation,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
pop()
|
||||||
|
cursor = range.endInclusive + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cursor < text.length) {
|
||||||
|
withStyle(spanStyle) {
|
||||||
|
appendWithHighlight(
|
||||||
|
text = text.substring(cursor),
|
||||||
|
plainOffset = plainOffset + cursor,
|
||||||
|
highlightedRange = highlightedRange,
|
||||||
|
highlightColor = highlightColor,
|
||||||
|
language = language,
|
||||||
|
hyphenation = hyphenation,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun SpanStyle.readerLinkStyle(): SpanStyle =
|
||||||
|
copy(
|
||||||
|
color = LinkTextColor,
|
||||||
|
textDecoration = when (textDecoration) {
|
||||||
|
TextDecoration.LineThrough ->
|
||||||
|
TextDecoration.combine(listOf(TextDecoration.Underline, TextDecoration.LineThrough))
|
||||||
|
else -> TextDecoration.Underline
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun String.webUrlRanges(): List<IntRange> =
|
||||||
|
WebUrlRegex.findAll(this).mapNotNull { match ->
|
||||||
|
val trimmed = match.value.trimEnd(*WebUrlTrailingPunctuation)
|
||||||
|
if (trimmed.substringAfter("://").isEmpty()) return@mapNotNull null
|
||||||
|
match.range.first..<(match.range.first + trimmed.length)
|
||||||
|
}.toList()
|
||||||
|
|
||||||
|
internal fun String.isWebUrl(): Boolean =
|
||||||
|
startsWith("https://", ignoreCase = true) || startsWith("http://", ignoreCase = true)
|
||||||
|
|
||||||
private fun AnnotatedString.Builder.appendWithHighlight(
|
private fun AnnotatedString.Builder.appendWithHighlight(
|
||||||
text: String,
|
text: String,
|
||||||
plainOffset: Int,
|
plainOffset: Int,
|
||||||
@ -1678,6 +1764,8 @@ private const val EllipsisPauseAfterMillis = 350L
|
|||||||
private const val CombiningAcuteAccent = '\u0301'
|
private const val CombiningAcuteAccent = '\u0301'
|
||||||
private const val ReadAloudStressableLetters = "аеёиоуыэюяАЕЁИОУЫЭЮЯaeiouyAEIOUY"
|
private const val ReadAloudStressableLetters = "аеёиоуыэюяАЕЁИОУЫЭЮЯaeiouyAEIOUY"
|
||||||
private const val ReaderLinkAnnotationTag = "fb2-link"
|
private const val ReaderLinkAnnotationTag = "fb2-link"
|
||||||
|
private val WebUrlRegex = Regex("""https?://\S+""", RegexOption.IGNORE_CASE)
|
||||||
|
private val WebUrlTrailingPunctuation = charArrayOf('.', ',', ';', ':', '!', '?', ')', ']', '}', '>', '"', '\'', '»', '”', '’')
|
||||||
private val LinkTextColor = Color(0xFF0B57D0)
|
private val LinkTextColor = Color(0xFF0B57D0)
|
||||||
private val ReaderLinkMinimumTouchWidth = 44.dp
|
private val ReaderLinkMinimumTouchWidth = 44.dp
|
||||||
private val ReaderLinkMinimumTouchHeight = 40.dp
|
private val ReaderLinkMinimumTouchHeight = 40.dp
|
||||||
|
|||||||
@ -130,6 +130,14 @@ internal fun BookView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun openReaderLink(href: String) {
|
||||||
|
if (href.isWebUrl()) {
|
||||||
|
openPlatformUrl(href)
|
||||||
|
} else {
|
||||||
|
selectedNoteId = href.removePrefix("#")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun updateReaderFontSettings(transform: (ReaderFontSettings) -> ReaderFontSettings) {
|
fun updateReaderFontSettings(transform: (ReaderFontSettings) -> ReaderFontSettings) {
|
||||||
val next = transform(readerFontSettings).coerced()
|
val next = transform(readerFontSettings).coerced()
|
||||||
if (next == readerFontSettings) return
|
if (next == readerFontSettings) return
|
||||||
@ -353,7 +361,7 @@ internal fun BookView(
|
|||||||
},
|
},
|
||||||
onUserScroll = { userScrollGeneration += 1 },
|
onUserScroll = { userScrollGeneration += 1 },
|
||||||
onImageOpen = onImageOpen,
|
onImageOpen = onImageOpen,
|
||||||
onNoteOpen = { href -> selectedNoteId = href.removePrefix("#") },
|
onNoteOpen = ::openReaderLink,
|
||||||
)
|
)
|
||||||
if (readerSettingsPanelVisible) {
|
if (readerSettingsPanelVisible) {
|
||||||
ReaderFontSettingsPanel(
|
ReaderFontSettingsPanel(
|
||||||
@ -420,7 +428,7 @@ internal fun BookView(
|
|||||||
noteId = noteId,
|
noteId = noteId,
|
||||||
onDismiss = { selectedNoteId = null },
|
onDismiss = { selectedNoteId = null },
|
||||||
onImageOpen = onImageOpen,
|
onImageOpen = onImageOpen,
|
||||||
onNoteOpen = { href -> selectedNoteId = href.removePrefix("#") },
|
onNoteOpen = ::openReaderLink,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,24 @@
|
|||||||
|
package net.sergeych.toread
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
class ReaderLinkDetectionTest {
|
||||||
|
@Test
|
||||||
|
fun detectsWebUrlsAndTrimsSentencePunctuation() {
|
||||||
|
val text = "See http://example.org/path?q=1, then https://site.test/end."
|
||||||
|
|
||||||
|
val urls = text.webUrlRanges().map { text.substring(it) }
|
||||||
|
|
||||||
|
assertEquals(listOf("http://example.org/path?q=1", "https://site.test/end"), urls)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun ignoresNonWebUrls() {
|
||||||
|
val text = "ftp://files.test https://secure.test"
|
||||||
|
|
||||||
|
val urls = text.webUrlRanges().map { text.substring(it) }
|
||||||
|
|
||||||
|
assertEquals(listOf("https://secure.test"), urls)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -20,6 +20,7 @@ import java.awt.datatransfer.Transferable
|
|||||||
import java.awt.datatransfer.UnsupportedFlavorException
|
import java.awt.datatransfer.UnsupportedFlavorException
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.ByteArrayInputStream
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.net.URI
|
||||||
import javax.imageio.ImageIO
|
import javax.imageio.ImageIO
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
@ -36,6 +37,14 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
private var pendingOpenBookFile: File? = null
|
||||||
|
|
||||||
|
fun rememberPlatformOpenBookArguments(args: Array<String>) {
|
||||||
|
pendingOpenBookFile = args.asSequence()
|
||||||
|
.map(::File)
|
||||||
|
.firstOrNull { it.isFile && it.name.isSupportedBookFile() }
|
||||||
|
}
|
||||||
|
|
||||||
actual fun loadDefaultBookBytes(): ByteArray? {
|
actual fun loadDefaultBookBytes(): ByteArray? {
|
||||||
var current = File(System.getProperty("user.dir")).absoluteFile
|
var current = File(System.getProperty("user.dir")).absoluteFile
|
||||||
while (true) {
|
while (true) {
|
||||||
@ -70,7 +79,26 @@ actual suspend fun copyImageToClipboard(bytes: ByteArray, mimeType: String, labe
|
|||||||
|
|
||||||
actual fun defaultLibraryScanPath(): String? = findProjectRoot()?.let { File(it, "test_books").absolutePath }
|
actual fun defaultLibraryScanPath(): String? = findProjectRoot()?.let { File(it, "test_books").absolutePath }
|
||||||
|
|
||||||
actual suspend fun loadPlatformOpenBookRequest(): PlatformOpenBookRequest? = null
|
actual suspend fun loadPlatformOpenBookRequest(): PlatformOpenBookRequest? = withContext(Dispatchers.IO) {
|
||||||
|
val file = pendingOpenBookFile ?: return@withContext null
|
||||||
|
pendingOpenBookFile = null
|
||||||
|
val bytes = file.readBytes()
|
||||||
|
val importResult = openLibraryDatabase().useLibrary { db ->
|
||||||
|
LibraryScanner(db, ::appendLibraryLog).importExternalFileAndGetResult(
|
||||||
|
bytes = bytes,
|
||||||
|
displayName = file.name,
|
||||||
|
storageUri = file.absolutePath,
|
||||||
|
sizeBytes = file.length(),
|
||||||
|
lastModifiedMillis = file.lastModified(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
appendLibraryLog("platform open imported=${importResult.imported} fileId=${importResult.fileId} path=${file.absolutePath}")
|
||||||
|
PlatformOpenBookRequest(
|
||||||
|
id = importResult.fileId,
|
||||||
|
displayName = file.name,
|
||||||
|
bytes = bytes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
actual suspend fun chooseLibraryScanDirectory(): String? = withContext(Dispatchers.IO) {
|
actual suspend fun chooseLibraryScanDirectory(): String? = withContext(Dispatchers.IO) {
|
||||||
val chooser = JFileChooser(defaultLibraryScanPath())
|
val chooser = JFileChooser(defaultLibraryScanPath())
|
||||||
@ -301,6 +329,15 @@ actual suspend fun viewLibraryBookFile(folder: String, fileName: String): Boolea
|
|||||||
}.getOrDefault(false)
|
}.getOrDefault(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
actual fun openPlatformUrl(url: String): Boolean =
|
||||||
|
runCatching {
|
||||||
|
if (!Desktop.isDesktopSupported() || !Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
|
||||||
|
return@runCatching false
|
||||||
|
}
|
||||||
|
Desktop.getDesktop().browse(URI(url))
|
||||||
|
true
|
||||||
|
}.getOrDefault(false)
|
||||||
|
|
||||||
private fun selectOrOpenBookFile(directory: File, file: File): Boolean {
|
private fun selectOrOpenBookFile(directory: File, file: File): Boolean {
|
||||||
if (isLinuxDesktop() && trySelectLinuxFile(file)) return true
|
if (isLinuxDesktop() && trySelectLinuxFile(file)) return true
|
||||||
if (!Desktop.isDesktopSupported()) return false
|
if (!Desktop.isDesktopSupported()) return false
|
||||||
@ -654,6 +691,9 @@ private fun runCommand(vararg command: String): String? =
|
|||||||
}
|
}
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
|
|
||||||
|
private fun String.isSupportedBookFile(): Boolean =
|
||||||
|
endsWith(".fb2", ignoreCase = true) || endsWith(".fb2.zip", ignoreCase = true)
|
||||||
|
|
||||||
private const val ActiveReadingFileIdFlag = "active_reading_file_id"
|
private const val ActiveReadingFileIdFlag = "active_reading_file_id"
|
||||||
private const val ThemeModeFlag = "theme_mode"
|
private const val ThemeModeFlag = "theme_mode"
|
||||||
private const val ReaderFontSettingsFlag = "reader_font_settings"
|
private const val ReaderFontSettingsFlag = "reader_font_settings"
|
||||||
|
|||||||
@ -3,7 +3,9 @@ package net.sergeych.toread
|
|||||||
import androidx.compose.ui.window.Window
|
import androidx.compose.ui.window.Window
|
||||||
import androidx.compose.ui.window.application
|
import androidx.compose.ui.window.application
|
||||||
|
|
||||||
fun main() = application {
|
fun main(args: Array<String>) {
|
||||||
|
rememberPlatformOpenBookArguments(args)
|
||||||
|
application {
|
||||||
Window(
|
Window(
|
||||||
onCloseRequest = ::exitApplication,
|
onCloseRequest = ::exitApplication,
|
||||||
title = strings.appName,
|
title = strings.appName,
|
||||||
@ -11,3 +13,4 @@ fun main() = application {
|
|||||||
App()
|
App()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -63,6 +63,9 @@ actual suspend fun shareLibraryBookFile(fileId: String): Boolean = false
|
|||||||
|
|
||||||
actual suspend fun viewLibraryBookFile(folder: String, fileName: String): Boolean = false
|
actual suspend fun viewLibraryBookFile(folder: String, fileName: String): Boolean = false
|
||||||
|
|
||||||
|
actual fun openPlatformUrl(url: String): Boolean =
|
||||||
|
window.open(url, target = "_blank") != null
|
||||||
|
|
||||||
actual suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras = BookInfoExtras()
|
actual suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras = BookInfoExtras()
|
||||||
|
|
||||||
actual suspend fun loadActiveReadingFileId(): String? = null
|
actual suspend fun loadActiveReadingFileId(): String? = null
|
||||||
|
|||||||
@ -26,6 +26,11 @@ data class LibraryScanSummary(
|
|||||||
val currentFile: String? = null,
|
val currentFile: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class LibraryImportResult(
|
||||||
|
val fileId: String,
|
||||||
|
val imported: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
class LibraryScanner(
|
class LibraryScanner(
|
||||||
private val database: H2LibraryDatabase,
|
private val database: H2LibraryDatabase,
|
||||||
private val log: (String) -> Unit = {},
|
private val log: (String) -> Unit = {},
|
||||||
@ -85,17 +90,29 @@ class LibraryScanner(
|
|||||||
storageUri: String,
|
storageUri: String,
|
||||||
sizeBytes: Long?,
|
sizeBytes: Long?,
|
||||||
lastModifiedMillis: Long?,
|
lastModifiedMillis: Long?,
|
||||||
): Boolean {
|
): Boolean =
|
||||||
|
importExternalFileAndGetResult(bytes, displayName, storageUri, sizeBytes, lastModifiedMillis).imported
|
||||||
|
|
||||||
|
fun importExternalFileAndGetResult(
|
||||||
|
bytes: ByteArray,
|
||||||
|
displayName: String,
|
||||||
|
storageUri: String,
|
||||||
|
sizeBytes: Long?,
|
||||||
|
lastModifiedMillis: Long?,
|
||||||
|
): LibraryImportResult {
|
||||||
val rawSha256 = bytes.sha256Hex()
|
val rawSha256 = bytes.sha256Hex()
|
||||||
if (sizeBytes != null && database.files.findByOriginalFilenameSizeAndRawSha256(displayName, sizeBytes, rawSha256).isNotEmpty()) {
|
if (sizeBytes != null) {
|
||||||
return false
|
database.files.findByOriginalFilenameSizeAndRawSha256(displayName, sizeBytes, rawSha256).firstOrNull()?.let {
|
||||||
|
return LibraryImportResult(fileId = it.id, imported = false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
database.files.findPrimaryDuplicateTarget(bodyClusterId = null, bodyId = null, rawSha256 = rawSha256)?.let { duplicateTarget ->
|
database.files.findPrimaryDuplicateTarget(bodyClusterId = null, bodyId = null, rawSha256 = rawSha256)?.let { duplicateTarget ->
|
||||||
|
val fileId = "file-${UUID.randomUUID()}"
|
||||||
database.files.upsert(
|
database.files.upsert(
|
||||||
BookFileRecord(
|
BookFileRecord(
|
||||||
id = "file-${UUID.randomUUID()}",
|
id = fileId,
|
||||||
bookId = duplicateTarget.bookId,
|
bookId = duplicateTarget.bookId,
|
||||||
bodyId = duplicateTarget.bodyId,
|
bodyId = duplicateTarget.bodyId,
|
||||||
bodyClusterId = duplicateTarget.bodyClusterId,
|
bodyClusterId = duplicateTarget.bodyClusterId,
|
||||||
@ -114,7 +131,7 @@ class LibraryScanner(
|
|||||||
updatedAt = now,
|
updatedAt = now,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return true
|
return LibraryImportResult(fileId = fileId, imported = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
val book = Fb2Format.parse(bytes, displayName)
|
val book = Fb2Format.parse(bytes, displayName)
|
||||||
@ -129,6 +146,7 @@ class LibraryScanner(
|
|||||||
database.files.findPrimaryDuplicateTarget(bodyClusterId = clusterId, bodyId = bodyId, rawSha256 = rawSha256)
|
database.files.findPrimaryDuplicateTarget(bodyClusterId = clusterId, bodyId = bodyId, rawSha256 = rawSha256)
|
||||||
}
|
}
|
||||||
val bookId = duplicateTarget?.bookId ?: "book-${UUID.randomUUID()}"
|
val bookId = duplicateTarget?.bookId ?: "book-${UUID.randomUUID()}"
|
||||||
|
val fileId = "file-${UUID.randomUUID()}"
|
||||||
|
|
||||||
database.transaction {
|
database.transaction {
|
||||||
if (duplicateTarget == null || duplicateTarget.bookId == null) {
|
if (duplicateTarget == null || duplicateTarget.bookId == null) {
|
||||||
@ -177,7 +195,7 @@ class LibraryScanner(
|
|||||||
}
|
}
|
||||||
files.upsert(
|
files.upsert(
|
||||||
BookFileRecord(
|
BookFileRecord(
|
||||||
id = "file-${UUID.randomUUID()}",
|
id = fileId,
|
||||||
bookId = bookId,
|
bookId = bookId,
|
||||||
bodyId = bodyId,
|
bodyId = bodyId,
|
||||||
bodyClusterId = clusterId,
|
bodyClusterId = clusterId,
|
||||||
@ -197,7 +215,7 @@ class LibraryScanner(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return true
|
return LibraryImportResult(fileId = fileId, imported = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun importLinkedFile(file: File): Boolean {
|
private fun importLinkedFile(file: File): Boolean {
|
||||||
|
|||||||
@ -15,13 +15,14 @@ class LibraryScannerTest {
|
|||||||
val db = H2LibraryDatabase.openMemory("scansFb2ZipSubtreeIntoLinkedLibraryRecords")
|
val db = H2LibraryDatabase.openMemory("scansFb2ZipSubtreeIntoLinkedLibraryRecords")
|
||||||
try {
|
try {
|
||||||
val summary = LibraryScanner(db).scanSubtree(File(root, "test_books"))
|
val summary = LibraryScanner(db).scanSubtree(File(root, "test_books"))
|
||||||
|
val expectedFiles = LibraryScanner.countSupportedBookFiles(File(root, "test_books"))
|
||||||
|
|
||||||
assertEquals(1, summary.scannedFiles)
|
assertEquals(expectedFiles, summary.scannedFiles)
|
||||||
assertEquals(1, summary.importedFiles)
|
assertEquals(expectedFiles, summary.importedFiles)
|
||||||
assertEquals(0, summary.skippedFiles)
|
assertEquals(0, summary.skippedFiles)
|
||||||
assertEquals(0, summary.failedFiles)
|
assertEquals(0, summary.failedFiles)
|
||||||
|
|
||||||
val file = db.files.list().single()
|
val file = db.files.list().first { it.storageUri?.endsWith(".fb2.zip") == true }
|
||||||
assertEquals("fb2.zip", file.format)
|
assertEquals("fb2.zip", file.format)
|
||||||
assertTrue(file.storageUri?.endsWith(".fb2.zip") == true)
|
assertTrue(file.storageUri?.endsWith(".fb2.zip") == true)
|
||||||
val book = assertNotNull(file.bookId?.let(db.books::get))
|
val book = assertNotNull(file.bookId?.let(db.books::get))
|
||||||
@ -31,7 +32,7 @@ class LibraryScannerTest {
|
|||||||
|
|
||||||
val second = LibraryScanner(db).scanSubtree(File(root, "test_books"))
|
val second = LibraryScanner(db).scanSubtree(File(root, "test_books"))
|
||||||
assertEquals(0, second.importedFiles)
|
assertEquals(0, second.importedFiles)
|
||||||
assertEquals(1, second.skippedFiles)
|
assertEquals(expectedFiles, second.skippedFiles)
|
||||||
} finally {
|
} finally {
|
||||||
db.close()
|
db.close()
|
||||||
}
|
}
|
||||||
@ -92,6 +93,36 @@ class LibraryScannerTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun importExternalFileResultReturnsExistingFileForExactRepeat() {
|
||||||
|
val root = findProjectRoot()
|
||||||
|
val bytes = File(root, "test_books/Maraini_Zapiski-Terezy-Numy.G7vc8A.872381.fb2.zip").readBytes()
|
||||||
|
val db = H2LibraryDatabase.openMemory("importExternalFileResultReturnsExistingFileForExactRepeat")
|
||||||
|
try {
|
||||||
|
val scanner = LibraryScanner(db)
|
||||||
|
val first = scanner.importExternalFileAndGetResult(
|
||||||
|
bytes = bytes,
|
||||||
|
displayName = "opened.fb2.zip",
|
||||||
|
storageUri = "/tmp/opened.fb2.zip",
|
||||||
|
sizeBytes = bytes.size.toLong(),
|
||||||
|
lastModifiedMillis = 1L,
|
||||||
|
)
|
||||||
|
val second = scanner.importExternalFileAndGetResult(
|
||||||
|
bytes = bytes,
|
||||||
|
displayName = "opened.fb2.zip",
|
||||||
|
storageUri = "/tmp/opened.fb2.zip",
|
||||||
|
sizeBytes = bytes.size.toLong(),
|
||||||
|
lastModifiedMillis = 1L,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(first.imported)
|
||||||
|
assertEquals(false, second.imported)
|
||||||
|
assertEquals(first.fileId, second.fileId)
|
||||||
|
} finally {
|
||||||
|
db.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun findProjectRoot(): File {
|
private fun findProjectRoot(): File {
|
||||||
var current = File(System.getProperty("user.dir")).absoluteFile
|
var current = File(System.getProperty("user.dir")).absoluteFile
|
||||||
while (true) {
|
while (true) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user