Introduce PageStore (used by ImageRepository)
This commit is contained in:
@@ -32,6 +32,12 @@ const val SOURCE_DIR_NAME = "sources"
|
||||
const val SCAN_DIR_NAME = "scanned_pages"
|
||||
const val THUMBNAIL_DIR_NAME = "thumbnails"
|
||||
|
||||
/**
|
||||
* Repository responsible for:
|
||||
* - page persistence (document.json)
|
||||
* - image files (work, source, thumbnails)
|
||||
* - page-level operations (add, rotate, move, delete)
|
||||
*/
|
||||
class ImageRepository(
|
||||
scanRootDir: File,
|
||||
val transformations: ImageTransformations,
|
||||
@@ -52,16 +58,13 @@ class ImageRepository(
|
||||
|
||||
private val metadataFile = File(scanDir, "document.json")
|
||||
|
||||
private val pagesById = mutableMapOf<String, PageV2>()
|
||||
private var pages: MutableList<PageV2> = loadPages().also {
|
||||
pagesById.putAll(it.associateBy { p -> p.id })
|
||||
}
|
||||
|
||||
private val json = Json {
|
||||
prettyPrint = false
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
private var pages: PageStore = PageStore(loadPages())
|
||||
|
||||
private fun loadPages(): MutableList<PageV2> {
|
||||
normalizeLegacyFiles()
|
||||
val filesOnDisk = scanDir.listFiles()
|
||||
@@ -87,9 +90,6 @@ class ImageRepository(
|
||||
}
|
||||
}
|
||||
|
||||
private fun indexOfPage(id: String): Int =
|
||||
pages.indexOfFirst { it.id == id }
|
||||
|
||||
private fun loadMetadata(): List<PageV2> {
|
||||
val json = metadataFile.readText()
|
||||
|
||||
@@ -121,16 +121,16 @@ class ImageRepository(
|
||||
}
|
||||
|
||||
private fun saveMetadata() {
|
||||
val metadata = DocumentMetadataV2(pages = pages)
|
||||
val metadata = DocumentMetadataV2(pages = pages.pages())
|
||||
metadataFile.writeText(json.encodeToString(metadata))
|
||||
}
|
||||
|
||||
fun pages(): List<ScanPage> =
|
||||
pages.map {
|
||||
pages.pages().map {
|
||||
ScanPage(it.id, it.toMetadata())
|
||||
}
|
||||
|
||||
private fun page(id: String): PageV2? = pagesById[id]
|
||||
private fun page(id: String): PageV2? = pages.get(id)
|
||||
|
||||
fun add(pageBytes: ByteArray, sourceBytes: ByteArray, metadata: PageMetadata) {
|
||||
val id = "${System.currentTimeMillis()}"
|
||||
@@ -139,15 +139,15 @@ class ImageRepository(
|
||||
file.writeBytes(pageBytes)
|
||||
writeThumbnail(file)
|
||||
File(sourceDir, fileName).writeBytes(sourceBytes)
|
||||
val page = PageV2(
|
||||
id = id,
|
||||
quad = metadata.normalizedQuad.toSerializable(),
|
||||
baseRotationDegrees = metadata.baseRotation.degrees,
|
||||
manualRotationDegrees = metadata.manualRotation.degrees,
|
||||
isColored = metadata.isColored
|
||||
pages.addOrReplace(
|
||||
PageV2(
|
||||
id = id,
|
||||
quad = metadata.normalizedQuad.toSerializable(),
|
||||
baseRotationDegrees = metadata.baseRotation.degrees,
|
||||
manualRotationDegrees = metadata.manualRotation.degrees,
|
||||
isColored = metadata.isColored
|
||||
)
|
||||
)
|
||||
pagesById[page.id] = page
|
||||
pages.add(page)
|
||||
saveMetadata()
|
||||
}
|
||||
|
||||
@@ -166,31 +166,27 @@ class ImageRepository(
|
||||
fun PageV2.workFileName() = workFileName(id, manualRotationDegrees)
|
||||
|
||||
fun rotate(id: String, clockwise: Boolean) {
|
||||
val index = indexOfPage(id)
|
||||
if (index < 0)
|
||||
return
|
||||
val page = pages[index]
|
||||
val page = pages.get(id) ?: return
|
||||
|
||||
val delta = if (clockwise) Rotation.R90 else Rotation.R270
|
||||
val currentManualRotation = Rotation.fromDegrees(page.manualRotationDegrees)
|
||||
val newManualRotation = currentManualRotation.add(delta)
|
||||
if (newManualRotation == currentManualRotation) {
|
||||
return // no-op
|
||||
}
|
||||
val newRotation = Rotation.fromDegrees(page.manualRotationDegrees).add(delta)
|
||||
|
||||
val inputFile = File(scanDir, "$id.jpg")
|
||||
if (!inputFile.exists()) {
|
||||
return
|
||||
}
|
||||
val outputFile = File(scanDir, workFileName(id, newManualRotation.degrees))
|
||||
if (!outputFile.exists()) {
|
||||
val jpegQuality = ExportQuality.BALANCED.jpegQuality
|
||||
transformations.rotate(inputFile, outputFile, newManualRotation.degrees, jpegQuality)
|
||||
val outputFile = File(scanDir, workFileName(id, newRotation.degrees))
|
||||
|
||||
if (inputFile.exists() && !outputFile.exists()) {
|
||||
transformations.rotate(
|
||||
inputFile,
|
||||
outputFile,
|
||||
newRotation.degrees,
|
||||
ExportQuality.BALANCED.jpegQuality
|
||||
)
|
||||
}
|
||||
|
||||
pages.update(id) {
|
||||
it.copy(manualRotationDegrees = newRotation.degrees)
|
||||
}
|
||||
|
||||
val updated = page.copy(manualRotationDegrees = newManualRotation.degrees)
|
||||
pagesById[id] = updated
|
||||
pages[index] = updated
|
||||
saveMetadata()
|
||||
}
|
||||
|
||||
@@ -238,22 +234,13 @@ class ImageRepository(
|
||||
}
|
||||
|
||||
fun movePage(id: String, newIndex: Int) {
|
||||
val index = indexOfPage(id)
|
||||
if (index < 0) return
|
||||
|
||||
val page = pages.removeAt(index)
|
||||
val safeIndex = newIndex.coerceIn(0, pages.size)
|
||||
pages.add(safeIndex, page)
|
||||
pages.move(id, newIndex)
|
||||
saveMetadata()
|
||||
}
|
||||
|
||||
fun delete(id: String) {
|
||||
val index = indexOfPage(id)
|
||||
if (index < 0)
|
||||
return
|
||||
pages.removeAt(index)
|
||||
pages.delete(id)
|
||||
saveMetadata()
|
||||
pagesById.remove(id)
|
||||
|
||||
getSourceFile(id).delete()
|
||||
scanDir.listFiles()
|
||||
@@ -267,7 +254,6 @@ class ImageRepository(
|
||||
fun clear() {
|
||||
pages.clear()
|
||||
saveMetadata() // "empty" json file
|
||||
pagesById.clear()
|
||||
|
||||
thumbnailDir.listFiles()?.forEach {
|
||||
file -> file.delete()
|
||||
|
||||
53
app/src/main/java/org/fairscan/app/data/PageStore.kt
Normal file
53
app/src/main/java/org/fairscan/app/data/PageStore.kt
Normal file
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright 2025 Pierre-Yves Nicolas
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the Free
|
||||
* Software Foundation, either version 3 of the License, or (at your option)
|
||||
* any later version.
|
||||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
||||
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
||||
* more details.
|
||||
* You should have received a copy of the GNU General Public License along with
|
||||
* this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.fairscan.app.data
|
||||
|
||||
class PageStore(pages: List<PageV2>) {
|
||||
|
||||
private val pages = LinkedHashMap<String, PageV2>()
|
||||
.also { map -> pages.forEach { map.put(it.id, it) } }
|
||||
|
||||
fun pages(): List<PageV2> =
|
||||
pages.values.toList()
|
||||
|
||||
fun get(id: String): PageV2? =
|
||||
pages[id]
|
||||
|
||||
fun addOrReplace(page: PageV2) =
|
||||
pages.put(page.id, page)
|
||||
|
||||
fun update(id: String, transform: (PageV2) -> PageV2) {
|
||||
val page = pages[id] ?: return
|
||||
pages[id] = transform(page)
|
||||
}
|
||||
|
||||
fun delete(id: String) = pages.remove(id)
|
||||
|
||||
fun clear() = pages.clear()
|
||||
|
||||
fun move(id: String, newIndex: Int) {
|
||||
val page = pages[id] ?: return
|
||||
|
||||
val entries = pages.entries.toList()
|
||||
.filterNot { it.key == id }
|
||||
|
||||
pages.clear()
|
||||
|
||||
val safeIndex = newIndex.coerceIn(0, entries.size)
|
||||
entries.take(safeIndex).forEach { pages[it.key] = it.value }
|
||||
pages.put(id, page)
|
||||
entries.drop(safeIndex).forEach { pages[it.key] = it.value }
|
||||
}
|
||||
}
|
||||
@@ -111,6 +111,15 @@ class ImageRepositoryTest {
|
||||
assertThat(repo().imageIds()).containsExactly("1")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no json and two files with same id`() {
|
||||
scanDir().mkdirs()
|
||||
File(scanDir(), "1768153479486.jpg").writeBytes(byteArrayOf(101, 102, 103))
|
||||
File(scanDir(), "1768153479486-270.jpg").writeBytes(byteArrayOf(105, 106, 107))
|
||||
val repo = repo()
|
||||
assertThat(repo.imageIds()).containsExactly("1768153479486")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should find existing files at initialization with no json and with rotation`() {
|
||||
scanDir().mkdirs()
|
||||
|
||||
Reference in New Issue
Block a user