feat(export): add exportToMarkdown() to ImportExportRepository
Implement Markdown export for the entire inventory (Issue #36). The method renders categories as headings with items in a table (Name, Menge, Einheit, MHD, Lagerort). Empty categories are skipped. Dates are formatted as dd.MM.yyyy (German), quantities use German decimal format (comma). Settings section shows household_size and kcal_per_day if present. Includes 6 unit tests covering: full export, empty categories, missing expiry date, settings section, fractional quantities, and irrelevant settings omission. Closes #36
This commit is contained in:
parent
e85b151cd5
commit
12f787a406
3 changed files with 180 additions and 0 deletions
|
|
@ -14,6 +14,8 @@ import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.Locale
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
internal class ImportExportRepositoryImpl @Inject constructor(
|
internal class ImportExportRepositoryImpl @Inject constructor(
|
||||||
|
|
@ -86,4 +88,56 @@ internal class ImportExportRepositoryImpl @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun exportToMarkdown(): String = withContext(Dispatchers.IO) {
|
||||||
|
val categories = categoryDao.getAll().first()
|
||||||
|
val locations = locationDao.getAll().first()
|
||||||
|
val items = itemDao.getAll().first()
|
||||||
|
val settings = settingsDao.getAll().first()
|
||||||
|
|
||||||
|
val locationMap = locations.associate { it.id to it.name }
|
||||||
|
val dateFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy", Locale.GERMAN)
|
||||||
|
|
||||||
|
val sb = StringBuilder()
|
||||||
|
sb.appendLine("# Krisenvorrat Inventar")
|
||||||
|
sb.appendLine()
|
||||||
|
|
||||||
|
for (category in categories) {
|
||||||
|
val categoryItems = items.filter { it.categoryId == category.id }
|
||||||
|
if (categoryItems.isEmpty()) continue
|
||||||
|
|
||||||
|
sb.appendLine("## ${category.name}")
|
||||||
|
sb.appendLine()
|
||||||
|
sb.appendLine("| Name | Menge | Einheit | MHD | Lagerort |")
|
||||||
|
sb.appendLine("|------|-------|---------|-----|----------|")
|
||||||
|
for (item in categoryItems) {
|
||||||
|
val quantity = formatQuantity(item.quantity)
|
||||||
|
val expiryDate = item.expiryDate?.format(dateFormatter) ?: "–"
|
||||||
|
val location = locationMap[item.locationId] ?: "–"
|
||||||
|
sb.appendLine("| ${item.name} | $quantity | ${item.unit} | $expiryDate | $location |")
|
||||||
|
}
|
||||||
|
sb.appendLine()
|
||||||
|
}
|
||||||
|
|
||||||
|
val settingsMap = settings.associate { it.key to it.value }
|
||||||
|
val householdSize = settingsMap["household_size"]
|
||||||
|
val kcalPerDay = settingsMap["kcal_per_day"]
|
||||||
|
if (householdSize != null || kcalPerDay != null) {
|
||||||
|
sb.appendLine("## Einstellungen")
|
||||||
|
sb.appendLine()
|
||||||
|
if (householdSize != null) sb.appendLine("- **Haushaltsgröße:** $householdSize Personen")
|
||||||
|
if (kcalPerDay != null) sb.appendLine("- **kcal/Tag:** $kcalPerDay")
|
||||||
|
sb.appendLine()
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.toString().trimEnd() + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatQuantity(value: Double): String {
|
||||||
|
return if (value == value.toLong().toDouble()) {
|
||||||
|
value.toLong().toString()
|
||||||
|
} else {
|
||||||
|
String.format(Locale.GERMAN, "%.1f", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,4 +3,5 @@ package de.krisenvorrat.app.domain.repository
|
||||||
internal interface ImportExportRepository {
|
internal interface ImportExportRepository {
|
||||||
suspend fun exportToJson(): String
|
suspend fun exportToJson(): String
|
||||||
suspend fun importFromJson(json: String): Result<Unit>
|
suspend fun importFromJson(json: String): Result<Unit>
|
||||||
|
suspend fun exportToMarkdown(): String
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -113,4 +113,129 @@ class ImportExportRepositoryImplTest {
|
||||||
assertTrue(result.isFailure)
|
assertTrue(result.isFailure)
|
||||||
assertTrue(result.exceptionOrNull()?.message?.contains("99") == true)
|
assertTrue(result.exceptionOrNull()?.message?.contains("99") == true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun test_exportToMarkdown_withCategoriesAndItems_producesValidMarkdown() = runBlocking {
|
||||||
|
// Given
|
||||||
|
val categoryDao = FakeCategoryDao()
|
||||||
|
val locationDao = FakeLocationDao()
|
||||||
|
val itemDao = FakeItemDao()
|
||||||
|
categoryDao.upsertAll(listOf(CategoryEntity(id = 1, name = "Lebensmittel")))
|
||||||
|
locationDao.upsertAll(listOf(LocationEntity(id = 1, name = "Keller")))
|
||||||
|
itemDao.upsertAll(listOf(
|
||||||
|
buildItemEntity("item1").copy(
|
||||||
|
name = "Konserve",
|
||||||
|
quantity = 5.0,
|
||||||
|
unit = "Stk",
|
||||||
|
expiryDate = LocalDate.of(2027, 3, 15),
|
||||||
|
locationId = 1
|
||||||
|
)
|
||||||
|
))
|
||||||
|
val repository = buildRepository(categoryDao, locationDao, itemDao)
|
||||||
|
|
||||||
|
// When
|
||||||
|
val markdown = repository.exportToMarkdown()
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertTrue(markdown.contains("# Krisenvorrat Inventar"))
|
||||||
|
assertTrue(markdown.contains("## Lebensmittel"))
|
||||||
|
assertTrue(markdown.contains("| Konserve | 5 | Stk | 15.03.2027 | Keller |"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun test_exportToMarkdown_withEmptyCategory_skipsCategory() = runBlocking {
|
||||||
|
// Given
|
||||||
|
val categoryDao = FakeCategoryDao()
|
||||||
|
val itemDao = FakeItemDao()
|
||||||
|
categoryDao.upsertAll(listOf(
|
||||||
|
CategoryEntity(id = 1, name = "Lebensmittel"),
|
||||||
|
CategoryEntity(id = 2, name = "Hygiene")
|
||||||
|
))
|
||||||
|
itemDao.upsertAll(listOf(buildItemEntity("item1").copy(categoryId = 1)))
|
||||||
|
val repository = buildRepository(categoryDao, itemDao = itemDao)
|
||||||
|
|
||||||
|
// When
|
||||||
|
val markdown = repository.exportToMarkdown()
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertTrue(markdown.contains("## Lebensmittel"))
|
||||||
|
assertTrue(!markdown.contains("## Hygiene"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun test_exportToMarkdown_withNoExpiryDate_showsDash() = runBlocking {
|
||||||
|
// Given
|
||||||
|
val categoryDao = FakeCategoryDao()
|
||||||
|
val locationDao = FakeLocationDao()
|
||||||
|
val itemDao = FakeItemDao()
|
||||||
|
categoryDao.upsertAll(listOf(CategoryEntity(id = 1, name = "Wasser")))
|
||||||
|
locationDao.upsertAll(listOf(LocationEntity(id = 1, name = "Keller")))
|
||||||
|
itemDao.upsertAll(listOf(
|
||||||
|
buildItemEntity("item1").copy(name = "Flasche", expiryDate = null)
|
||||||
|
))
|
||||||
|
val repository = buildRepository(categoryDao, locationDao, itemDao)
|
||||||
|
|
||||||
|
// When
|
||||||
|
val markdown = repository.exportToMarkdown()
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertTrue(markdown.contains("| Flasche | 2 | Stk | – | Keller |"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun test_exportToMarkdown_withSettings_includesSettingsSection() = runBlocking {
|
||||||
|
// Given
|
||||||
|
val categoryDao = FakeCategoryDao()
|
||||||
|
val settingsDao = FakeSettingsDao()
|
||||||
|
categoryDao.upsertAll(listOf(CategoryEntity(id = 1, name = "Test")))
|
||||||
|
settingsDao.upsertAll(listOf(
|
||||||
|
SettingsEntity(key = "household_size", value = "4"),
|
||||||
|
SettingsEntity(key = "kcal_per_day", value = "2000")
|
||||||
|
))
|
||||||
|
val repository = buildRepository(categoryDao, settingsDao = settingsDao)
|
||||||
|
|
||||||
|
// When
|
||||||
|
val markdown = repository.exportToMarkdown()
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertTrue(markdown.contains("## Einstellungen"))
|
||||||
|
assertTrue(markdown.contains("**Haushaltsgröße:** 4 Personen"))
|
||||||
|
assertTrue(markdown.contains("**kcal/Tag:** 2000"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun test_exportToMarkdown_withFractionalQuantity_formatsWithComma() = runBlocking {
|
||||||
|
// Given
|
||||||
|
val categoryDao = FakeCategoryDao()
|
||||||
|
val locationDao = FakeLocationDao()
|
||||||
|
val itemDao = FakeItemDao()
|
||||||
|
categoryDao.upsertAll(listOf(CategoryEntity(id = 1, name = "Lebensmittel")))
|
||||||
|
locationDao.upsertAll(listOf(LocationEntity(id = 1, name = "Keller")))
|
||||||
|
itemDao.upsertAll(listOf(
|
||||||
|
buildItemEntity("item1").copy(name = "Reis", quantity = 2.5, unit = "kg")
|
||||||
|
))
|
||||||
|
val repository = buildRepository(categoryDao, locationDao, itemDao)
|
||||||
|
|
||||||
|
// When
|
||||||
|
val markdown = repository.exportToMarkdown()
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertTrue(markdown.contains("| Reis | 2,5 | kg |"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun test_exportToMarkdown_withNoSettingsRelevant_omitsSettingsSection() = runBlocking {
|
||||||
|
// Given
|
||||||
|
val categoryDao = FakeCategoryDao()
|
||||||
|
val settingsDao = FakeSettingsDao()
|
||||||
|
categoryDao.upsertAll(listOf(CategoryEntity(id = 1, name = "Test")))
|
||||||
|
settingsDao.upsertAll(listOf(SettingsEntity(key = "theme", value = "dark")))
|
||||||
|
val repository = buildRepository(categoryDao, settingsDao = settingsDao)
|
||||||
|
|
||||||
|
// When
|
||||||
|
val markdown = repository.exportToMarkdown()
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertTrue(!markdown.contains("## Einstellungen"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue