Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ae2c9be564 | |||
| 8980f9599b |
@@ -60,7 +60,8 @@ implausibly - is `12_adaptive_preprocess_ocr.sh` below.
|
|||||||
steps are short-lived and stateless (one `docker run` per receipt).
|
steps are short-lived and stateless (one `docker run` per receipt).
|
||||||
`3_extract_data.sh` reaches it via `--network host` at
|
`3_extract_data.sh` reaches it via `--network host` at
|
||||||
`http://127.0.0.1:11434` by default (override with `OLLAMA_URL`/
|
`http://127.0.0.1:11434` by default (override with `OLLAMA_URL`/
|
||||||
`OLLAMA_MODEL`).
|
`OLLAMA_MODEL`; `OLLAMA_NUM_CTX` (default 3072) and `OLLAMA_NUM_THREAD`
|
||||||
|
(default: Ollama's) set the request options).
|
||||||
- `build-image.sh` / `upload-image.sh` - build/push the
|
- `build-image.sh` / `upload-image.sh` - build/push the
|
||||||
`tooling-container/` image, tagged from `tooling-container/VERSION`.
|
`tooling-container/` image, tagged from `tooling-container/VERSION`.
|
||||||
Image name defaults to `fuelish/tools`, overridable via
|
Image name defaults to `fuelish/tools`, overridable via
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package de.ladkau.fuelish.export
|
||||||
|
|
||||||
|
import android.content.ContentResolver
|
||||||
|
import android.net.Uri
|
||||||
|
import de.ladkau.fuelish.data.Receipt
|
||||||
|
import java.io.OutputStream
|
||||||
|
import java.text.ParseException
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.zip.ZipEntry
|
||||||
|
import java.util.zip.ZipOutputStream
|
||||||
|
|
||||||
|
/** IMG_YYYYMMDD_HHMMSS.jpg, built from the receipt's own extracted
|
||||||
|
* date/time (Receipt.date/time - DD.MM.YYYY / HH:MM:SS), not the photo
|
||||||
|
* file's own capture timestamp or when it was saved in the app - so the
|
||||||
|
* exported name reflects what's on the receipt. Null if date or time is
|
||||||
|
* missing or doesn't parse, since there's then no valid name to build. */
|
||||||
|
fun Receipt.photoExportName(): String? {
|
||||||
|
val d = date ?: return null
|
||||||
|
val t = time ?: return null
|
||||||
|
val parsed = try {
|
||||||
|
SOURCE_FORMAT.parse("$d $t")
|
||||||
|
} catch (e: ParseException) {
|
||||||
|
null
|
||||||
|
} ?: return null
|
||||||
|
return "IMG_${TARGET_FORMAT.format(parsed)}.jpg"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val SOURCE_FORMAT = SimpleDateFormat("dd.MM.yyyy HH:mm:ss", Locale.GERMANY)
|
||||||
|
private val TARGET_FORMAT = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US)
|
||||||
|
|
||||||
|
/** Zips every receipt's photo (skipping receipts with no photo, or no
|
||||||
|
* parseable date/time to name it with) under photoExportName(), appending
|
||||||
|
* "_2", "_3", ... on a name collision (e.g. two receipts logged with the
|
||||||
|
* same date and time to the second) so nothing silently overwrites
|
||||||
|
* another entry. Returns the count actually written. */
|
||||||
|
fun exportPhotos(receipts: List<Receipt>, resolver: ContentResolver, out: OutputStream): Int {
|
||||||
|
val usedNames = mutableSetOf<String>()
|
||||||
|
var written = 0
|
||||||
|
ZipOutputStream(out).use { zip ->
|
||||||
|
for (receipt in receipts) {
|
||||||
|
val imageUri = receipt.imageUri ?: continue
|
||||||
|
val baseName = receipt.photoExportName() ?: continue
|
||||||
|
val name = uniqueName(baseName, usedNames)
|
||||||
|
usedNames += name
|
||||||
|
|
||||||
|
val bytes = try {
|
||||||
|
resolver.openInputStream(Uri.parse(imageUri))?.use { it.readBytes() }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
} ?: continue
|
||||||
|
|
||||||
|
zip.putNextEntry(ZipEntry(name))
|
||||||
|
zip.write(bytes)
|
||||||
|
zip.closeEntry()
|
||||||
|
written++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return written
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun uniqueName(baseName: String, used: Set<String>): String {
|
||||||
|
if (baseName !in used) return baseName
|
||||||
|
val stem = baseName.removeSuffix(".jpg")
|
||||||
|
var n = 2
|
||||||
|
while ("${stem}_$n.jpg" in used) n++
|
||||||
|
return "${stem}_$n.jpg"
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import de.ladkau.fuelish.data.Receipt
|
|||||||
import de.ladkau.fuelish.data.ReceiptRepository
|
import de.ladkau.fuelish.data.ReceiptRepository
|
||||||
import de.ladkau.fuelish.export.exportCsv as writeCsvRows
|
import de.ladkau.fuelish.export.exportCsv as writeCsvRows
|
||||||
import de.ladkau.fuelish.export.exportOds as writeOdsRows
|
import de.ladkau.fuelish.export.exportOds as writeOdsRows
|
||||||
|
import de.ladkau.fuelish.export.exportPhotos as writePhotosZip
|
||||||
import de.ladkau.fuelish.log.AppLog
|
import de.ladkau.fuelish.log.AppLog
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
@@ -27,6 +28,25 @@ class ExportViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
|
|
||||||
fun exportOds(onReady: (Uri) -> Unit) = export("fuelish-receipts.ods", ::writeOdsRows, onReady)
|
fun exportOds(onReady: (Uri) -> Unit) = export("fuelish-receipts.ods", ::writeOdsRows, onReady)
|
||||||
|
|
||||||
|
/** Zips every receipt's photo as IMG_YYYYMMDD_HHMMSS.jpg (from the
|
||||||
|
* receipt's own date/time, not the photo's capture time) - see
|
||||||
|
* export/PhotoExporter.kt's photoExportName(). Receipts with no photo
|
||||||
|
* or no parseable date/time are skipped, not failed. */
|
||||||
|
fun exportPhotos(onReady: (Uri) -> Unit) {
|
||||||
|
val app = getApplication<Application>()
|
||||||
|
viewModelScope.launch {
|
||||||
|
val receipts = repository.receipts.first()
|
||||||
|
val (uri, written) = withContext(Dispatchers.IO) {
|
||||||
|
val dir = File(app.cacheDir, "exports").apply { mkdirs() }
|
||||||
|
val file = File(dir, "fuelish-photos.zip")
|
||||||
|
val count = file.outputStream().use { writePhotosZip(receipts, app.contentResolver, it) }
|
||||||
|
FileProvider.getUriForFile(app, "${app.packageName}.fileprovider", file) to count
|
||||||
|
}
|
||||||
|
AppLog.log("Exported $written of ${receipts.size} photo(s) to fuelish-photos.zip")
|
||||||
|
onReady(uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun export(fileName: String, write: (List<Receipt>, OutputStream) -> Unit, onReady: (Uri) -> Unit) {
|
private fun export(fileName: String, write: (List<Receipt>, OutputStream) -> Unit, onReady: (Uri) -> Unit) {
|
||||||
val app = getApplication<Application>()
|
val app = getApplication<Application>()
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
|||||||
@@ -117,6 +117,15 @@ fun FuelishApp() {
|
|||||||
},
|
},
|
||||||
modifier = Modifier.padding(horizontal = 12.dp),
|
modifier = Modifier.padding(horizontal = 12.dp),
|
||||||
)
|
)
|
||||||
|
NavigationDrawerItem(
|
||||||
|
label = { Text(stringResource(R.string.overview_export_photos)) },
|
||||||
|
selected = false,
|
||||||
|
onClick = {
|
||||||
|
scope.launch { drawerState.close() }
|
||||||
|
exportViewModel.exportPhotos { uri -> shareFile(context, uri, "application/zip") }
|
||||||
|
},
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -10,11 +10,16 @@ import androidx.compose.foundation.layout.width
|
|||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Clear
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||||
|
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
@@ -32,14 +37,25 @@ private val DELETE_COLUMN_WIDTH = 48.dp
|
|||||||
@Composable
|
@Composable
|
||||||
fun OverviewScreen(onReceiptClick: (Long) -> Unit, viewModel: OverviewViewModel = viewModel()) {
|
fun OverviewScreen(onReceiptClick: (Long) -> Unit, viewModel: OverviewViewModel = viewModel()) {
|
||||||
val receipts by viewModel.receipts.collectAsState()
|
val receipts by viewModel.receipts.collectAsState()
|
||||||
|
val sort by viewModel.sort.collectAsState()
|
||||||
|
val searchQuery by viewModel.searchQuery.collectAsState()
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
ReceiptTableHeader()
|
SearchField(
|
||||||
|
query = searchQuery,
|
||||||
|
onQueryChange = viewModel::setSearchQuery,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
ReceiptTableHeader(sort = sort, onColumnClick = viewModel::setSortColumn)
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
|
|
||||||
if (receipts.isEmpty()) {
|
if (receipts.isEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.overview_empty),
|
text = if (searchQuery.isBlank()) {
|
||||||
|
stringResource(R.string.overview_empty)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.overview_empty_search, searchQuery)
|
||||||
|
},
|
||||||
modifier = Modifier.padding(16.dp),
|
modifier = Modifier.padding(16.dp),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -58,32 +74,80 @@ fun OverviewScreen(onReceiptClick: (Long) -> Unit, viewModel: OverviewViewModel
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ReceiptTableHeader() {
|
private fun SearchField(query: String, onQueryChange: (String) -> Unit, modifier: Modifier = Modifier) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = query,
|
||||||
|
onValueChange = onQueryChange,
|
||||||
|
placeholder = { Text(stringResource(R.string.overview_search_placeholder)) },
|
||||||
|
leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
|
||||||
|
trailingIcon = {
|
||||||
|
if (query.isNotEmpty()) {
|
||||||
|
IconButton(onClick = { onQueryChange("") }) {
|
||||||
|
Icon(Icons.Filled.Clear, contentDescription = stringResource(R.string.overview_search_clear))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
singleLine = true,
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ReceiptTableHeader(sort: SortState, onColumnClick: (SortColumn) -> Unit) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(
|
SortableHeaderCell(
|
||||||
text = stringResource(R.string.overview_column_date),
|
label = stringResource(R.string.overview_column_date),
|
||||||
style = MaterialTheme.typography.labelLarge,
|
column = SortColumn.DATE,
|
||||||
|
sort = sort,
|
||||||
|
onClick = onColumnClick,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
Text(
|
SortableHeaderCell(
|
||||||
text = stringResource(R.string.overview_column_litres),
|
label = stringResource(R.string.overview_column_litres),
|
||||||
style = MaterialTheme.typography.labelLarge,
|
column = SortColumn.LITRES,
|
||||||
|
sort = sort,
|
||||||
|
onClick = onColumnClick,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
Text(
|
SortableHeaderCell(
|
||||||
text = stringResource(R.string.overview_column_total),
|
label = stringResource(R.string.overview_column_total),
|
||||||
style = MaterialTheme.typography.labelLarge,
|
column = SortColumn.TOTAL_PRICE,
|
||||||
|
sort = sort,
|
||||||
|
onClick = onColumnClick,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
Row(modifier = Modifier.width(DELETE_COLUMN_WIDTH)) {}
|
Row(modifier = Modifier.width(DELETE_COLUMN_WIDTH)) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SortableHeaderCell(
|
||||||
|
label: String,
|
||||||
|
column: SortColumn,
|
||||||
|
sort: SortState,
|
||||||
|
onClick: (SortColumn) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier.clickable { onClick(column) },
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(text = label, style = MaterialTheme.typography.labelLarge)
|
||||||
|
if (sort.column == column) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (sort.direction == SortDirection.ASCENDING) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.padding(start = 2.dp).width(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ReceiptRow(receipt: Receipt, onClick: () -> Unit, onDelete: () -> Unit) {
|
private fun ReceiptRow(receipt: Receipt, onClick: () -> Unit, onDelete: () -> Unit) {
|
||||||
Row(
|
Row(
|
||||||
|
|||||||
@@ -5,17 +5,56 @@ import androidx.lifecycle.AndroidViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import de.ladkau.fuelish.data.Receipt
|
import de.ladkau.fuelish.data.Receipt
|
||||||
import de.ladkau.fuelish.data.ReceiptRepository
|
import de.ladkau.fuelish.data.ReceiptRepository
|
||||||
|
import de.ladkau.fuelish.export.toExportRow
|
||||||
import de.ladkau.fuelish.log.AppLog
|
import de.ladkau.fuelish.log.AppLog
|
||||||
|
import java.text.ParseException
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Locale
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/** The overview table's 3 visible/sortable columns (price/litre and
|
||||||
|
* discount are detail-only, not shown in the list - see
|
||||||
|
* ReceiptDetailScreen). DATE sorts by the combined date+time instant,
|
||||||
|
* not the date string alone, since within-day ordering still matters for
|
||||||
|
* a fuel-cost log. */
|
||||||
|
enum class SortColumn { DATE, LITRES, TOTAL_PRICE }
|
||||||
|
|
||||||
|
enum class SortDirection { ASCENDING, DESCENDING }
|
||||||
|
|
||||||
|
data class SortState(val column: SortColumn, val direction: SortDirection)
|
||||||
|
|
||||||
class OverviewViewModel(application: Application) : AndroidViewModel(application) {
|
class OverviewViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
private val repository = ReceiptRepository(application)
|
private val repository = ReceiptRepository(application)
|
||||||
|
|
||||||
val receipts: StateFlow<List<Receipt>> = repository.receipts
|
private val _sort = MutableStateFlow(SortState(SortColumn.DATE, SortDirection.DESCENDING))
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
val sort: StateFlow<SortState> = _sort.asStateFlow()
|
||||||
|
|
||||||
|
private val _searchQuery = MutableStateFlow("")
|
||||||
|
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
|
||||||
|
|
||||||
|
val receipts: StateFlow<List<Receipt>> = combine(repository.receipts, _searchQuery, _sort) { all, query, sort ->
|
||||||
|
all.filter { matchesQuery(it, query) }.sortedWith(comparator(sort))
|
||||||
|
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||||
|
|
||||||
|
fun setSearchQuery(query: String) {
|
||||||
|
_searchQuery.value = query
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tapping the already-active column flips its direction; tapping a
|
||||||
|
* different one switches to it, ascending. */
|
||||||
|
fun setSortColumn(column: SortColumn) {
|
||||||
|
_sort.value = if (_sort.value.column == column) {
|
||||||
|
_sort.value.copy(direction = _sort.value.direction.flipped())
|
||||||
|
} else {
|
||||||
|
SortState(column, SortDirection.ASCENDING)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun delete(receipt: Receipt) {
|
fun delete(receipt: Receipt) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -24,3 +63,47 @@ class OverviewViewModel(application: Application) : AndroidViewModel(application
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun SortDirection.flipped() =
|
||||||
|
if (this == SortDirection.ASCENDING) SortDirection.DESCENDING else SortDirection.ASCENDING
|
||||||
|
|
||||||
|
/** Substring match (case-insensitive) against the same formatted field
|
||||||
|
* values CSV/ODS export uses (export/ReceiptRow.kt's toExportRow()) -
|
||||||
|
* "fulltext" across the table's actual fields, not just the three shown
|
||||||
|
* in the overview list. */
|
||||||
|
private fun matchesQuery(receipt: Receipt, query: String): Boolean {
|
||||||
|
if (query.isBlank()) return true
|
||||||
|
val needle = query.trim()
|
||||||
|
return receipt.toExportRow().any { it.contains(needle, ignoreCase = true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val DATE_TIME_FORMAT = SimpleDateFormat("dd.MM.yyyy HH:mm:ss", Locale.GERMANY)
|
||||||
|
|
||||||
|
/** Receipts with no parseable date+time sort to the very end regardless
|
||||||
|
* of direction, rather than jumping between "first" and "last" depending
|
||||||
|
* on which way the column is currently sorted. */
|
||||||
|
private fun dateTimeSortKey(receipt: Receipt): Long {
|
||||||
|
val date = receipt.date ?: return Long.MAX_VALUE
|
||||||
|
val time = receipt.time ?: "00:00:00"
|
||||||
|
return try {
|
||||||
|
DATE_TIME_FORMAT.parse("$date $time")?.time ?: Long.MAX_VALUE
|
||||||
|
} catch (e: ParseException) {
|
||||||
|
Long.MAX_VALUE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun comparator(sort: SortState): Comparator<Receipt> {
|
||||||
|
val base: Comparator<Receipt> = when (sort.column) {
|
||||||
|
SortColumn.DATE -> compareBy { dateTimeSortKey(it) }
|
||||||
|
SortColumn.LITRES -> nullsLastDouble { it.litres }
|
||||||
|
SortColumn.TOTAL_PRICE -> nullsLastDouble { it.totalPrice }
|
||||||
|
}
|
||||||
|
return if (sort.direction == SortDirection.DESCENDING) base.reversed() else base
|
||||||
|
}
|
||||||
|
|
||||||
|
/** compareBy(nullsLast()) alone would put nulls first when .reversed() is
|
||||||
|
* applied for descending order - build the null-handling into the base
|
||||||
|
* comparator instead so nulls stay last in both directions, same as
|
||||||
|
* dateTimeSortKey()'s Long.MAX_VALUE approach. */
|
||||||
|
private fun nullsLastDouble(selector: (Receipt) -> Double?): Comparator<Receipt> =
|
||||||
|
compareBy { selector(it) ?: Double.MAX_VALUE }
|
||||||
|
|||||||
@@ -34,12 +34,16 @@
|
|||||||
<string name="receipt_delete">Delete</string>
|
<string name="receipt_delete">Delete</string>
|
||||||
|
|
||||||
<string name="overview_empty">No receipts have been scanned yet</string>
|
<string name="overview_empty">No receipts have been scanned yet</string>
|
||||||
|
<string name="overview_empty_search">No receipts match “%1$s”</string>
|
||||||
|
<string name="overview_search_placeholder">Search</string>
|
||||||
|
<string name="overview_search_clear">Clear search</string>
|
||||||
<string name="overview_column_date">Date</string>
|
<string name="overview_column_date">Date</string>
|
||||||
<string name="overview_column_litres">Litres</string>
|
<string name="overview_column_litres">Litres</string>
|
||||||
<string name="overview_column_total">Total</string>
|
<string name="overview_column_total">Total</string>
|
||||||
<string name="overview_delete">Delete</string>
|
<string name="overview_delete">Delete</string>
|
||||||
<string name="overview_export_csv">Export CSV</string>
|
<string name="overview_export_csv">Export CSV</string>
|
||||||
<string name="overview_export_ods">Export ODS</string>
|
<string name="overview_export_ods">Export ODS</string>
|
||||||
|
<string name="overview_export_photos">Export photos</string>
|
||||||
<string name="overview_export_done">Exported to %1$s</string>
|
<string name="overview_export_done">Exported to %1$s</string>
|
||||||
|
|
||||||
<string name="settings_server_url">Server URL</string>
|
<string name="settings_server_url">Server URL</string>
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
0.1.0
|
0.1.1
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ import requests
|
|||||||
|
|
||||||
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://127.0.0.1:11434/api/chat")
|
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://127.0.0.1:11434/api/chat")
|
||||||
MODEL = os.environ.get("OLLAMA_MODEL", "qwen2.5:3b")
|
MODEL = os.environ.get("OLLAMA_MODEL", "qwen2.5:3b")
|
||||||
|
# Ollama restarts its runner when a request's context size or thread count
|
||||||
|
# differs from the loaded one; set these to match other clients of a shared
|
||||||
|
# Ollama. OLLAMA_NUM_THREAD unset = Ollama's default.
|
||||||
|
NUM_CTX = int(os.environ.get("OLLAMA_NUM_CTX", "3072"))
|
||||||
|
NUM_THREAD = os.environ.get("OLLAMA_NUM_THREAD")
|
||||||
|
|
||||||
SCHEMA = {
|
SCHEMA = {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -57,6 +62,9 @@ Respond with ONLY the JSON object, no other text.
|
|||||||
|
|
||||||
|
|
||||||
def structure(ocr_text: str) -> dict:
|
def structure(ocr_text: str) -> dict:
|
||||||
|
options = {"temperature": 0, "num_ctx": NUM_CTX}
|
||||||
|
if NUM_THREAD:
|
||||||
|
options["num_thread"] = int(NUM_THREAD)
|
||||||
payload = {
|
payload = {
|
||||||
"model": MODEL,
|
"model": MODEL,
|
||||||
"messages": [
|
"messages": [
|
||||||
@@ -64,7 +72,7 @@ def structure(ocr_text: str) -> dict:
|
|||||||
],
|
],
|
||||||
"format": SCHEMA,
|
"format": SCHEMA,
|
||||||
"stream": False,
|
"stream": False,
|
||||||
"options": {"temperature": 0, "num_ctx": 3072},
|
"options": options,
|
||||||
}
|
}
|
||||||
|
|
||||||
start = time.time()
|
start = time.time()
|
||||||
|
|||||||
Reference in New Issue
Block a user