+image viewer, better dupes protection and background processing
This commit is contained in:
1 parent
14c9863a83
commit
ade4fb1896
18 files changed
+817
-127
No files matched your search
@@ -94,6 +94,7 @@ data class BookFileRecord(
|
||||
val bookId: String? = null,
|
||||
val bodyId: String? = null,
|
||||
val bodyClusterId: String? = null,
|
||||
val duplicateOfFileId: String? = null,
|
||||
val rawSha256: String,
|
||||
val format: String? = null,
|
||||
val mimeType: String? = null,
|
||||
@@ -194,6 +195,10 @@ interface BookFileRepository {
|
||||
fun get(id: String): BookFileRecord?
|
||||
fun getLibraryFile(id: String): LibraryFileRecord?
|
||||
fun findByRawSha256(rawSha256: String): List<BookFileRecord>
|
||||
fun findByOriginalFilenameSizeAndModified(originalFilename: String, sizeBytes: Long, lastModifiedMillis: Long): List<BookFileRecord>
|
||||
fun findByOriginalFilenameSizeAndRawSha256(originalFilename: String, sizeBytes: Long, rawSha256: String): List<BookFileRecord>
|
||||
fun findPrimaryDuplicateTarget(bodyClusterId: String?, bodyId: String?, rawSha256: String): BookFileRecord?
|
||||
fun markDuplicateFiles(): Int
|
||||
fun listLibraryFiles(limit: Int = 100, offset: Int = 0): List<LibraryFileRecord>
|
||||
fun list(limit: Int = 500, offset: Int = 0): List<BookFileRecord>
|
||||
fun listForBook(bookId: String): List<BookFileRecord>
|
||||
|
||||
@@ -199,6 +199,7 @@ private fun migrate(connection: Connection) {
|
||||
book_id VARCHAR,
|
||||
body_id VARCHAR,
|
||||
body_cluster_id VARCHAR,
|
||||
duplicate_of_file_id VARCHAR,
|
||||
raw_sha256 VARCHAR NOT NULL,
|
||||
format VARCHAR,
|
||||
mime_type VARCHAR,
|
||||
@@ -219,10 +220,14 @@ private fun migrate(connection: Connection) {
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
statement.execute("ALTER TABLE book_files ADD COLUMN IF NOT EXISTS duplicate_of_file_id VARCHAR")
|
||||
statement.execute("ALTER TABLE book_files ADD COLUMN IF NOT EXISTS reading_status VARCHAR NOT NULL DEFAULT 'NEW'")
|
||||
statement.execute("ALTER TABLE book_files ADD COLUMN IF NOT EXISTS last_read_at BIGINT")
|
||||
connection.createIndexIfMissing("idx_book_files_raw_sha256", "CREATE INDEX IF NOT EXISTS idx_book_files_raw_sha256 ON book_files(raw_sha256)")
|
||||
connection.createIndexIfMissing("idx_book_files_book_id", "CREATE INDEX IF NOT EXISTS idx_book_files_book_id ON book_files(book_id)")
|
||||
connection.createIndexIfMissing("idx_book_files_duplicate", "CREATE INDEX IF NOT EXISTS idx_book_files_duplicate ON book_files(duplicate_of_file_id)")
|
||||
connection.createIndexIfMissing("idx_book_files_name_size_mtime", "CREATE INDEX IF NOT EXISTS idx_book_files_name_size_mtime ON book_files(original_filename, size_bytes, last_modified_millis)")
|
||||
connection.createIndexIfMissing("idx_book_files_name_size_hash", "CREATE INDEX IF NOT EXISTS idx_book_files_name_size_hash ON book_files(original_filename, size_bytes, raw_sha256)")
|
||||
connection.createIndexIfMissing("idx_book_files_updated_at", "CREATE INDEX IF NOT EXISTS idx_book_files_updated_at ON book_files(updated_at)")
|
||||
connection.createIndexIfMissing("idx_book_files_reading_order", "CREATE INDEX IF NOT EXISTS idx_book_files_reading_order ON book_files(reading_status, last_read_at)")
|
||||
statement.execute(
|
||||
@@ -470,31 +475,32 @@ private class JdbcBookFileRepository(private val connection: Connection) : BookF
|
||||
connection.prepareStatement(
|
||||
"""
|
||||
MERGE INTO book_files(
|
||||
id, book_id, body_id, body_cluster_id, raw_sha256, format, mime_type, size_bytes,
|
||||
original_filename, storage_kind, storage_uri, content_object_id, last_modified_millis,
|
||||
last_seen_at, reading_status, last_read_at, created_at, updated_at
|
||||
id, book_id, body_id, body_cluster_id, duplicate_of_file_id, raw_sha256, format,
|
||||
mime_type, size_bytes, original_filename, storage_kind, storage_uri, content_object_id,
|
||||
last_modified_millis, last_seen_at, reading_status, last_read_at, created_at, updated_at
|
||||
)
|
||||
KEY(id) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
KEY(id) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""".trimIndent()
|
||||
).use { statement ->
|
||||
statement.setString(1, file.id)
|
||||
statement.setStringOrNull(2, file.bookId)
|
||||
statement.setStringOrNull(3, file.bodyId)
|
||||
statement.setStringOrNull(4, file.bodyClusterId)
|
||||
statement.setString(5, file.rawSha256)
|
||||
statement.setStringOrNull(6, file.format)
|
||||
statement.setStringOrNull(7, file.mimeType)
|
||||
statement.setLongOrNull(8, file.sizeBytes)
|
||||
statement.setStringOrNull(9, file.originalFilename)
|
||||
statement.setString(10, file.storageKind.name)
|
||||
statement.setStringOrNull(11, file.storageUri)
|
||||
statement.setStringOrNull(12, file.contentObjectId)
|
||||
statement.setLongOrNull(13, file.lastModifiedMillis)
|
||||
statement.setLongOrNull(14, file.lastSeenAt)
|
||||
statement.setString(15, file.readingStatus.name)
|
||||
statement.setLongOrNull(16, file.lastReadAt)
|
||||
statement.setLong(17, file.createdAt)
|
||||
statement.setLong(18, file.updatedAt)
|
||||
statement.setStringOrNull(5, file.duplicateOfFileId)
|
||||
statement.setString(6, file.rawSha256)
|
||||
statement.setStringOrNull(7, file.format)
|
||||
statement.setStringOrNull(8, file.mimeType)
|
||||
statement.setLongOrNull(9, file.sizeBytes)
|
||||
statement.setStringOrNull(10, file.originalFilename)
|
||||
statement.setString(11, file.storageKind.name)
|
||||
statement.setStringOrNull(12, file.storageUri)
|
||||
statement.setStringOrNull(13, file.contentObjectId)
|
||||
statement.setLongOrNull(14, file.lastModifiedMillis)
|
||||
statement.setLongOrNull(15, file.lastSeenAt)
|
||||
statement.setString(16, file.readingStatus.name)
|
||||
statement.setLongOrNull(17, file.lastReadAt)
|
||||
statement.setLong(18, file.createdAt)
|
||||
statement.setLong(19, file.updatedAt)
|
||||
statement.executeUpdate()
|
||||
}
|
||||
}
|
||||
@@ -534,27 +540,133 @@ private class JdbcBookFileRepository(private val connection: Connection) : BookF
|
||||
}
|
||||
}
|
||||
|
||||
override fun listLibraryFiles(limit: Int, offset: Int): List<LibraryFileRecord> {
|
||||
override fun findByOriginalFilenameSizeAndModified(
|
||||
originalFilename: String,
|
||||
sizeBytes: Long,
|
||||
lastModifiedMillis: Long,
|
||||
): List<BookFileRecord> {
|
||||
return connection.prepareStatement(
|
||||
"""
|
||||
WITH visible_files AS (
|
||||
SELECT * FROM book_files
|
||||
WHERE original_filename = ?
|
||||
AND size_bytes = ?
|
||||
AND last_modified_millis = ?
|
||||
ORDER BY created_at
|
||||
""".trimIndent()
|
||||
).use { statement ->
|
||||
statement.setString(1, originalFilename)
|
||||
statement.setLong(2, sizeBytes)
|
||||
statement.setLong(3, lastModifiedMillis)
|
||||
statement.executeQuery().use { resultSet -> resultSet.mapRows { it.toBookFileRecord() } }
|
||||
}
|
||||
}
|
||||
|
||||
override fun findByOriginalFilenameSizeAndRawSha256(
|
||||
originalFilename: String,
|
||||
sizeBytes: Long,
|
||||
rawSha256: String,
|
||||
): List<BookFileRecord> {
|
||||
return connection.prepareStatement(
|
||||
"""
|
||||
SELECT * FROM book_files
|
||||
WHERE original_filename = ?
|
||||
AND size_bytes = ?
|
||||
AND raw_sha256 = ?
|
||||
ORDER BY created_at
|
||||
""".trimIndent()
|
||||
).use { statement ->
|
||||
statement.setString(1, originalFilename)
|
||||
statement.setLong(2, sizeBytes)
|
||||
statement.setString(3, rawSha256)
|
||||
statement.executeQuery().use { resultSet -> resultSet.mapRows { it.toBookFileRecord() } }
|
||||
}
|
||||
}
|
||||
|
||||
override fun findPrimaryDuplicateTarget(bodyClusterId: String?, bodyId: String?, rawSha256: String): BookFileRecord? {
|
||||
return connection.prepareStatement(
|
||||
"""
|
||||
SELECT * FROM book_files
|
||||
WHERE duplicate_of_file_id IS NULL
|
||||
AND (
|
||||
(? IS NOT NULL AND body_cluster_id = ?)
|
||||
OR (? IS NOT NULL AND body_id = ?)
|
||||
OR raw_sha256 = ?
|
||||
)
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN LOWER(COALESCE(format, '')) = 'fb2.zip'
|
||||
OR LOWER(COALESCE(original_filename, '')) LIKE '%.fb2.zip'
|
||||
THEN 0 ELSE 1
|
||||
END,
|
||||
CASE WHEN reading_status = 'READING' THEN 0 ELSE 1 END,
|
||||
last_read_at DESC NULLS LAST,
|
||||
updated_at DESC,
|
||||
id
|
||||
LIMIT 1
|
||||
""".trimIndent()
|
||||
).use { statement ->
|
||||
statement.setStringOrNull(1, bodyClusterId)
|
||||
statement.setStringOrNull(2, bodyClusterId)
|
||||
statement.setStringOrNull(3, bodyId)
|
||||
statement.setStringOrNull(4, bodyId)
|
||||
statement.setString(5, rawSha256)
|
||||
statement.executeQuery().use { resultSet ->
|
||||
if (resultSet.next()) resultSet.toBookFileRecord() else null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun markDuplicateFiles(): Int {
|
||||
val now = System.currentTimeMillis()
|
||||
return connection.prepareStatement(
|
||||
"""
|
||||
MERGE INTO book_files(id, duplicate_of_file_id, updated_at)
|
||||
KEY(id)
|
||||
SELECT id, primary_file_id, ?
|
||||
FROM (
|
||||
SELECT
|
||||
f.*,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY COALESCE(f.body_cluster_id, f.body_id, f.book_id, f.raw_sha256, f.id)
|
||||
id,
|
||||
FIRST_VALUE(id) OVER (
|
||||
PARTITION BY COALESCE(body_cluster_id, body_id, book_id, raw_sha256, id)
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN LOWER(COALESCE(f.format, '')) = 'fb2.zip'
|
||||
OR LOWER(COALESCE(f.original_filename, '')) LIKE '%.fb2.zip'
|
||||
WHEN LOWER(COALESCE(format, '')) = 'fb2.zip'
|
||||
OR LOWER(COALESCE(original_filename, '')) LIKE '%.fb2.zip'
|
||||
THEN 0 ELSE 1
|
||||
END,
|
||||
CASE WHEN f.reading_status = 'READING' THEN 0 ELSE 1 END,
|
||||
f.last_read_at DESC NULLS LAST,
|
||||
f.updated_at DESC,
|
||||
f.id
|
||||
CASE WHEN reading_status = 'READING' THEN 0 ELSE 1 END,
|
||||
last_read_at DESC NULLS LAST,
|
||||
updated_at DESC,
|
||||
id
|
||||
) AS primary_file_id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY COALESCE(body_cluster_id, body_id, book_id, raw_sha256, id)
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN LOWER(COALESCE(format, '')) = 'fb2.zip'
|
||||
OR LOWER(COALESCE(original_filename, '')) LIKE '%.fb2.zip'
|
||||
THEN 0 ELSE 1
|
||||
END,
|
||||
CASE WHEN reading_status = 'READING' THEN 0 ELSE 1 END,
|
||||
last_read_at DESC NULLS LAST,
|
||||
updated_at DESC,
|
||||
id
|
||||
) AS duplicate_rank
|
||||
FROM book_files f
|
||||
)
|
||||
FROM book_files
|
||||
WHERE duplicate_of_file_id IS NULL
|
||||
) ranked
|
||||
WHERE duplicate_rank > 1
|
||||
""".trimIndent()
|
||||
).use { statement ->
|
||||
statement.setLong(1, now)
|
||||
statement.executeUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
override fun listLibraryFiles(limit: Int, offset: Int): List<LibraryFileRecord> {
|
||||
markDuplicateFiles()
|
||||
return connection.prepareStatement(
|
||||
"""
|
||||
SELECT
|
||||
f.id AS file_id,
|
||||
f.book_id AS book_id,
|
||||
@@ -569,9 +681,9 @@ private class JdbcBookFileRepository(private val connection: Connection) : BookF
|
||||
f.last_seen_at AS last_seen_at,
|
||||
f.reading_status AS reading_status,
|
||||
f.last_read_at AS last_read_at
|
||||
FROM visible_files f
|
||||
FROM book_files f
|
||||
LEFT JOIN books b ON b.id = f.book_id
|
||||
WHERE f.duplicate_rank = 1
|
||||
WHERE f.duplicate_of_file_id IS NULL
|
||||
ORDER BY
|
||||
CASE WHEN f.reading_status = 'READING' THEN 0 ELSE 1 END,
|
||||
CASE WHEN f.reading_status = 'READING' THEN f.last_read_at END DESC NULLS LAST,
|
||||
@@ -836,6 +948,7 @@ private fun ResultSet.toBookFileRecord() = BookFileRecord(
|
||||
bookId = getString("book_id"),
|
||||
bodyId = getString("body_id"),
|
||||
bodyClusterId = getString("body_cluster_id"),
|
||||
duplicateOfFileId = getString("duplicate_of_file_id"),
|
||||
rawSha256 = getString("raw_sha256"),
|
||||
format = getString("format"),
|
||||
mimeType = getString("mime_type"),
|
||||
|
||||
@@ -82,34 +82,65 @@ class LibraryScanner(
|
||||
lastModifiedMillis: Long?,
|
||||
): Boolean {
|
||||
val rawSha256 = bytes.sha256Hex()
|
||||
if (database.files.findByRawSha256(rawSha256).isNotEmpty()) return false
|
||||
if (sizeBytes != null && database.files.findByOriginalFilenameSizeAndRawSha256(displayName, sizeBytes, rawSha256).isNotEmpty()) {
|
||||
return false
|
||||
}
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
database.files.findPrimaryDuplicateTarget(bodyClusterId = null, bodyId = null, rawSha256 = rawSha256)?.let { duplicateTarget ->
|
||||
database.files.upsert(
|
||||
BookFileRecord(
|
||||
id = "file-${UUID.randomUUID()}",
|
||||
bookId = duplicateTarget.bookId,
|
||||
bodyId = duplicateTarget.bodyId,
|
||||
bodyClusterId = duplicateTarget.bodyClusterId,
|
||||
duplicateOfFileId = duplicateTarget.id,
|
||||
rawSha256 = rawSha256,
|
||||
format = displayName.bookFormat(),
|
||||
mimeType = displayName.bookMimeType(),
|
||||
sizeBytes = sizeBytes,
|
||||
originalFilename = displayName,
|
||||
storageKind = BookFileStorageKind.EXTERNAL_URI,
|
||||
storageUri = storageUri,
|
||||
lastModifiedMillis = lastModifiedMillis,
|
||||
lastSeenAt = now,
|
||||
createdAt = now,
|
||||
updatedAt = now,
|
||||
)
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
val book = Fb2Format.parse(bytes, displayName)
|
||||
val cover = book.coverImage()
|
||||
val canonicalText = book.canonicalText()
|
||||
val bodyHash = canonicalText.encodeToByteArray().sha256Hex()
|
||||
val now = System.currentTimeMillis()
|
||||
val knownBody = database.bodies.findByExactTextHash(bodyHash, CanonicalizationVersion)
|
||||
val bodyId = knownBody?.id ?: "body-${UUID.randomUUID()}"
|
||||
val knownCluster = knownBody?.let { database.clusters.findByRepresentativeBodyId(it.id) }
|
||||
val clusterId = knownCluster?.id ?: "cluster-${UUID.randomUUID()}"
|
||||
val bookId = "book-${UUID.randomUUID()}"
|
||||
val duplicateTarget = knownBody?.let {
|
||||
database.files.findPrimaryDuplicateTarget(bodyClusterId = clusterId, bodyId = bodyId, rawSha256 = rawSha256)
|
||||
}
|
||||
val bookId = duplicateTarget?.bookId ?: "book-${UUID.randomUUID()}"
|
||||
|
||||
database.transaction {
|
||||
books.upsert(
|
||||
BookRecord(
|
||||
id = bookId,
|
||||
title = book.title.ifBlank { displayName.substringBeforeLast('.') },
|
||||
authors = book.authors.mapNotNull { it.displayName.takeIf(String::isNotBlank) },
|
||||
language = book.language,
|
||||
date = book.date,
|
||||
description = book.annotation,
|
||||
coverImage = cover?.bytes,
|
||||
coverImageMimeType = cover?.mimeType,
|
||||
createdAt = now,
|
||||
updatedAt = now,
|
||||
if (duplicateTarget == null || duplicateTarget.bookId == null) {
|
||||
books.upsert(
|
||||
BookRecord(
|
||||
id = bookId,
|
||||
title = book.title.ifBlank { displayName.substringBeforeLast('.') },
|
||||
authors = book.authors.mapNotNull { it.displayName.takeIf(String::isNotBlank) },
|
||||
language = book.language,
|
||||
date = book.date,
|
||||
description = book.annotation,
|
||||
coverImage = cover?.bytes,
|
||||
coverImageMimeType = cover?.mimeType,
|
||||
createdAt = now,
|
||||
updatedAt = now,
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
if (knownBody == null) {
|
||||
bodies.upsert(
|
||||
BookBodyRecord(
|
||||
@@ -143,6 +174,7 @@ class LibraryScanner(
|
||||
bookId = bookId,
|
||||
bodyId = bodyId,
|
||||
bodyClusterId = clusterId,
|
||||
duplicateOfFileId = duplicateTarget?.id,
|
||||
rawSha256 = rawSha256,
|
||||
format = displayName.bookFormat(),
|
||||
mimeType = displayName.bookMimeType(),
|
||||
@@ -160,14 +192,27 @@ class LibraryScanner(
|
||||
return true
|
||||
}
|
||||
|
||||
private fun importLinkedFile(file: File): Boolean =
|
||||
importExternalFile(
|
||||
private fun importLinkedFile(file: File): Boolean {
|
||||
val sizeBytes = file.length()
|
||||
val lastModifiedMillis = file.lastModified()
|
||||
if (database.files.findByOriginalFilenameSizeAndModified(file.name, sizeBytes, lastModifiedMillis).isNotEmpty()) {
|
||||
return false
|
||||
}
|
||||
return importExternalFile(
|
||||
bytes = file.readBytes(),
|
||||
displayName = file.name,
|
||||
storageUri = file.absolutePath,
|
||||
sizeBytes = file.length(),
|
||||
lastModifiedMillis = file.lastModified(),
|
||||
sizeBytes = sizeBytes,
|
||||
lastModifiedMillis = lastModifiedMillis,
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun countSupportedBookFiles(root: File): Int {
|
||||
require(root.isDirectory) { "Scan root is not a directory: ${root.path}" }
|
||||
return root.walkTopDown().count { it.isFile && it.isSupportedBookFile() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val CanonicalizationVersion = 1
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package net.sergeych.toread.storage.jdbc
|
||||
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class LibraryScannerTest {
|
||||
@@ -35,6 +37,61 @@ class LibraryScannerTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun scanMarksImportedDuplicateFilesImmediately() {
|
||||
val root = findProjectRoot()
|
||||
val source = File(root, "test_books/Maraini_Zapiski-Terezy-Numy.G7vc8A.872381.fb2.zip")
|
||||
val tempDir = Files.createTempDirectory("yereed-dupe-scan").toFile()
|
||||
try {
|
||||
source.copyTo(File(tempDir, source.name))
|
||||
source.copyTo(File(tempDir, "same-book-different-name.fb2.zip"))
|
||||
|
||||
val db = H2LibraryDatabase.openMemory("scanMarksImportedDuplicateFilesImmediately")
|
||||
try {
|
||||
val summary = LibraryScanner(db).scanSubtree(tempDir)
|
||||
|
||||
assertEquals(2, summary.scannedFiles)
|
||||
assertEquals(2, summary.importedFiles)
|
||||
assertEquals(0, summary.skippedFiles)
|
||||
|
||||
val files = db.files.list().sortedBy { it.duplicateOfFileId != null }
|
||||
assertEquals(2, files.size)
|
||||
assertNull(files.first().duplicateOfFileId)
|
||||
assertEquals(files.first().id, files.last().duplicateOfFileId)
|
||||
assertEquals(1, db.files.listLibraryFiles().size)
|
||||
|
||||
val second = LibraryScanner(db).scanSubtree(tempDir)
|
||||
assertEquals(0, second.importedFiles)
|
||||
assertEquals(2, second.skippedFiles)
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
} finally {
|
||||
tempDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun listingLibraryFilesPersistsLegacyDuplicateMarkers() {
|
||||
val root = findProjectRoot()
|
||||
val bytes = File(root, "test_books/Maraini_Zapiski-Terezy-Numy.G7vc8A.872381.fb2.zip").readBytes()
|
||||
val db = H2LibraryDatabase.openMemory("listingLibraryFilesPersistsLegacyDuplicateMarkers")
|
||||
try {
|
||||
val scanner = LibraryScanner(db)
|
||||
assertTrue(scanner.importExternalFile(bytes, "primary.fb2.zip", "/tmp/primary.fb2.zip", bytes.size.toLong(), 1L))
|
||||
assertTrue(scanner.importExternalFile(bytes, "secondary.fb2.zip", "/tmp/secondary.fb2.zip", bytes.size.toLong(), 2L))
|
||||
|
||||
val duplicate = db.files.list().single { it.duplicateOfFileId != null }
|
||||
db.files.upsert(duplicate.copy(duplicateOfFileId = null))
|
||||
|
||||
assertEquals(2, db.files.list().count { it.duplicateOfFileId == null })
|
||||
assertEquals(1, db.files.listLibraryFiles().size)
|
||||
assertEquals(1, db.files.list().count { it.duplicateOfFileId != null })
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun findProjectRoot(): File {
|
||||
var current = File(System.getProperty("user.dir")).absoluteFile
|
||||
while (true) {
|
||||
|
||||
Reference in new issue
Block a user