Introduce PageStore (used by ImageRepository)

This commit is contained in:
Pierre-Yves Nicolas
2026-01-13 14:00:00 +01:00
parent 8c65eb58f3
commit 817dd062c5
3 changed files with 98 additions and 50 deletions

View File

@@ -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(
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()

View 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 }
}
}

View File

@@ -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()