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 SCAN_DIR_NAME = "scanned_pages"
const val THUMBNAIL_DIR_NAME = "thumbnails" 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( class ImageRepository(
scanRootDir: File, scanRootDir: File,
val transformations: ImageTransformations, val transformations: ImageTransformations,
@@ -52,16 +58,13 @@ class ImageRepository(
private val metadataFile = File(scanDir, "document.json") 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 { private val json = Json {
prettyPrint = false prettyPrint = false
encodeDefaults = true encodeDefaults = true
} }
private var pages: PageStore = PageStore(loadPages())
private fun loadPages(): MutableList<PageV2> { private fun loadPages(): MutableList<PageV2> {
normalizeLegacyFiles() normalizeLegacyFiles()
val filesOnDisk = scanDir.listFiles() 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> { private fun loadMetadata(): List<PageV2> {
val json = metadataFile.readText() val json = metadataFile.readText()
@@ -121,16 +121,16 @@ class ImageRepository(
} }
private fun saveMetadata() { private fun saveMetadata() {
val metadata = DocumentMetadataV2(pages = pages) val metadata = DocumentMetadataV2(pages = pages.pages())
metadataFile.writeText(json.encodeToString(metadata)) metadataFile.writeText(json.encodeToString(metadata))
} }
fun pages(): List<ScanPage> = fun pages(): List<ScanPage> =
pages.map { pages.pages().map {
ScanPage(it.id, it.toMetadata()) 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) { fun add(pageBytes: ByteArray, sourceBytes: ByteArray, metadata: PageMetadata) {
val id = "${System.currentTimeMillis()}" val id = "${System.currentTimeMillis()}"
@@ -139,15 +139,15 @@ class ImageRepository(
file.writeBytes(pageBytes) file.writeBytes(pageBytes)
writeThumbnail(file) writeThumbnail(file)
File(sourceDir, fileName).writeBytes(sourceBytes) File(sourceDir, fileName).writeBytes(sourceBytes)
val page = PageV2( pages.addOrReplace(
id = id, PageV2(
quad = metadata.normalizedQuad.toSerializable(), id = id,
baseRotationDegrees = metadata.baseRotation.degrees, quad = metadata.normalizedQuad.toSerializable(),
manualRotationDegrees = metadata.manualRotation.degrees, baseRotationDegrees = metadata.baseRotation.degrees,
isColored = metadata.isColored manualRotationDegrees = metadata.manualRotation.degrees,
isColored = metadata.isColored
)
) )
pagesById[page.id] = page
pages.add(page)
saveMetadata() saveMetadata()
} }
@@ -166,31 +166,27 @@ class ImageRepository(
fun PageV2.workFileName() = workFileName(id, manualRotationDegrees) fun PageV2.workFileName() = workFileName(id, manualRotationDegrees)
fun rotate(id: String, clockwise: Boolean) { fun rotate(id: String, clockwise: Boolean) {
val index = indexOfPage(id) val page = pages.get(id) ?: return
if (index < 0)
return
val page = pages[index]
val delta = if (clockwise) Rotation.R90 else Rotation.R270 val delta = if (clockwise) Rotation.R90 else Rotation.R270
val currentManualRotation = Rotation.fromDegrees(page.manualRotationDegrees) val newRotation = Rotation.fromDegrees(page.manualRotationDegrees).add(delta)
val newManualRotation = currentManualRotation.add(delta)
if (newManualRotation == currentManualRotation) {
return // no-op
}
val inputFile = File(scanDir, "$id.jpg") val inputFile = File(scanDir, "$id.jpg")
if (!inputFile.exists()) { val outputFile = File(scanDir, workFileName(id, newRotation.degrees))
return
} if (inputFile.exists() && !outputFile.exists()) {
val outputFile = File(scanDir, workFileName(id, newManualRotation.degrees)) transformations.rotate(
if (!outputFile.exists()) { inputFile,
val jpegQuality = ExportQuality.BALANCED.jpegQuality outputFile,
transformations.rotate(inputFile, outputFile, newManualRotation.degrees, jpegQuality) 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() saveMetadata()
} }
@@ -238,22 +234,13 @@ class ImageRepository(
} }
fun movePage(id: String, newIndex: Int) { fun movePage(id: String, newIndex: Int) {
val index = indexOfPage(id) pages.move(id, newIndex)
if (index < 0) return
val page = pages.removeAt(index)
val safeIndex = newIndex.coerceIn(0, pages.size)
pages.add(safeIndex, page)
saveMetadata() saveMetadata()
} }
fun delete(id: String) { fun delete(id: String) {
val index = indexOfPage(id) pages.delete(id)
if (index < 0)
return
pages.removeAt(index)
saveMetadata() saveMetadata()
pagesById.remove(id)
getSourceFile(id).delete() getSourceFile(id).delete()
scanDir.listFiles() scanDir.listFiles()
@@ -267,7 +254,6 @@ class ImageRepository(
fun clear() { fun clear() {
pages.clear() pages.clear()
saveMetadata() // "empty" json file saveMetadata() // "empty" json file
pagesById.clear()
thumbnailDir.listFiles()?.forEach { thumbnailDir.listFiles()?.forEach {
file -> file.delete() 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") 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 @Test
fun `should find existing files at initialization with no json and with rotation`() { fun `should find existing files at initialization with no json and with rotation`() {
scanDir().mkdirs() scanDir().mkdirs()